@khanacademy/perseus-score 2.3.6 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +105 -852
- package/dist/index.js.map +1 -1
- package/dist/widgets/categorizer/validate-categorizer.d.ts +1 -1
- package/dist/widgets/interactive-graph/score-interactive-graph.d.ts +1 -1
- package/package.json +17 -8
- package/dist/es/index.js +0 -2275
- package/dist/es/index.js.map +0 -1
- package/dist/shared-utils/add-library-version-to-perseus-debug.d.ts +0 -9
package/dist/index.js
CHANGED
|
@@ -1,35 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
var kmath = require('@khanacademy/kmath');
|
|
7
|
-
var perseusCore = require('@khanacademy/perseus-core');
|
|
8
|
-
var _ = require('underscore');
|
|
9
|
-
var perseusScore = require('@khanacademy/perseus-score');
|
|
10
|
-
|
|
11
|
-
function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }
|
|
12
|
-
|
|
13
|
-
function _interopNamespace(e) {
|
|
14
|
-
if (e && e.__esModule) return e;
|
|
15
|
-
var n = Object.create(null);
|
|
16
|
-
if (e) {
|
|
17
|
-
Object.keys(e).forEach(function (k) {
|
|
18
|
-
if (k !== 'default') {
|
|
19
|
-
var d = Object.getOwnPropertyDescriptor(e, k);
|
|
20
|
-
Object.defineProperty(n, k, d.get ? d : {
|
|
21
|
-
enumerable: true,
|
|
22
|
-
get: function () { return e[k]; }
|
|
23
|
-
});
|
|
24
|
-
}
|
|
25
|
-
});
|
|
26
|
-
}
|
|
27
|
-
n["default"] = e;
|
|
28
|
-
return Object.freeze(n);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
var KAS__namespace = /*#__PURE__*/_interopNamespace(KAS);
|
|
32
|
-
var ___default = /*#__PURE__*/_interopDefaultLegacy(_);
|
|
1
|
+
import _extends from '@babel/runtime/helpers/extends';
|
|
2
|
+
import * as KAS from '@khanacademy/kas';
|
|
3
|
+
import { KhanMath, geometry, angles, coefficients, number } from '@khanacademy/kmath';
|
|
4
|
+
import { PerseusError, Errors, getDecimalSeparator, GrapherUtil, approximateDeepEqual, approximateEqual, deepClone, getMatrixSize, getWidgetIdsFromContent, getUpgradedWidgetOptions } from '@khanacademy/perseus-core';
|
|
5
|
+
import _ from 'underscore';
|
|
33
6
|
|
|
34
7
|
const MISSING_PERCENT_ERROR = "MISSING_PERCENT_ERROR";
|
|
35
8
|
const NEEDS_TO_BE_SIMPLIFIED_ERROR = "NEEDS_TO_BE_SIMPLIFIED_ERROR";
|
|
@@ -56,135 +29,33 @@ const ErrorCodes = {
|
|
|
56
29
|
FILL_ALL_CELLS_ERROR
|
|
57
30
|
};
|
|
58
31
|
|
|
59
|
-
/* eslint-disable no-useless-escape */
|
|
60
32
|
const MAXERROR_EPSILON = Math.pow(2, -42);
|
|
61
|
-
|
|
62
|
-
// TOOD(kevinb): Figure out how this relates to KEScore in
|
|
63
|
-
// perseus-all-package/types.js and see if there's a way to
|
|
64
|
-
// unify these types.
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Answer types
|
|
68
|
-
*
|
|
69
|
-
* Utility for creating answerable questions displayed in exercises
|
|
70
|
-
*
|
|
71
|
-
* Different answer types produce different kinds of input displays, and do
|
|
72
|
-
* different kinds of checking on the solutions.
|
|
73
|
-
*
|
|
74
|
-
* Each of the objects contain two functions, setup and createValidator.
|
|
75
|
-
*
|
|
76
|
-
* The setup function takes a solutionarea and solution, and performs setup
|
|
77
|
-
* within the solutionarea, and then returns an object which contains:
|
|
78
|
-
*
|
|
79
|
-
* answer: a function which, when called, will retrieve the current answer from
|
|
80
|
-
* the solutionarea, which can then be validated using the validator
|
|
81
|
-
* function
|
|
82
|
-
* validator: a function returned from the createValidator function (defined
|
|
83
|
-
* below)
|
|
84
|
-
* solution: the correct answer to the problem
|
|
85
|
-
* showGuess: a function which, when given a guess, shows the guess within the
|
|
86
|
-
* provided solutionarea
|
|
87
|
-
* showGuessCustom: a function which displays parts of a guess that are not
|
|
88
|
-
* within the solutionarea; currently only used for custom
|
|
89
|
-
* answers
|
|
90
|
-
*
|
|
91
|
-
* The createValidator function only takes a solution, and it returns a
|
|
92
|
-
* function which can be used to validate an answer.
|
|
93
|
-
*
|
|
94
|
-
* The resulting validator function returns:
|
|
95
|
-
* - true: if the answer is fully correct
|
|
96
|
-
* - false: if the answer is incorrect
|
|
97
|
-
* - "" (the empty string): if no answer has been provided (e.g. the answer box
|
|
98
|
-
* is left unfilled)
|
|
99
|
-
* - a string: if there is some slight error
|
|
100
|
-
*
|
|
101
|
-
* In most cases, setup and createValidator don't really need the solution DOM
|
|
102
|
-
* element so we have setupFunctional and createValidatorFunctional for them
|
|
103
|
-
* which take only $solution.text() and $solution.data(). This makes it easier
|
|
104
|
-
* to reuse specific answer types.
|
|
105
|
-
*
|
|
106
|
-
* TODO(alpert): Think of a less-absurd name for createValidatorFunctional.
|
|
107
|
-
*
|
|
108
|
-
*/
|
|
109
33
|
const KhanAnswerTypes = {
|
|
110
|
-
/*
|
|
111
|
-
* predicate answer type
|
|
112
|
-
*
|
|
113
|
-
* performs simple predicate-based checking of a numeric solution, with
|
|
114
|
-
* different kinds of number formats
|
|
115
|
-
*
|
|
116
|
-
* Uses the data-forms option on the solution to choose which number formats
|
|
117
|
-
* are acceptable. Available data-forms:
|
|
118
|
-
*
|
|
119
|
-
* - integer: 3
|
|
120
|
-
* - proper: 3/5
|
|
121
|
-
* - improper: 5/3
|
|
122
|
-
* - pi: 3 pi
|
|
123
|
-
* - log: log(5)
|
|
124
|
-
* - percent: 15%
|
|
125
|
-
* - mixed: 1 1/3
|
|
126
|
-
* - decimal: 1.7
|
|
127
|
-
*
|
|
128
|
-
* The solution should be a predicate of the form:
|
|
129
|
-
*
|
|
130
|
-
* function(guess, maxError) {
|
|
131
|
-
* return abs(guess - 3) < maxError;
|
|
132
|
-
* }
|
|
133
|
-
*
|
|
134
|
-
*/
|
|
135
34
|
predicate: {
|
|
136
35
|
defaultForms: "integer, proper, improper, mixed, decimal",
|
|
137
36
|
createValidatorFunctional: function (predicate, options) {
|
|
138
|
-
|
|
139
|
-
options = ___default["default"].extend({
|
|
37
|
+
options = _.extend({
|
|
140
38
|
simplify: "required",
|
|
141
39
|
ratio: false,
|
|
142
40
|
forms: KhanAnswerTypes.predicate.defaultForms
|
|
143
41
|
}, options);
|
|
144
42
|
let acceptableForms;
|
|
145
|
-
|
|
146
|
-
// TODO(merlob) fix all places that depend on this, then delete
|
|
147
|
-
if (!___default["default"].isArray(options.forms)) {
|
|
43
|
+
if (!_.isArray(options.forms)) {
|
|
148
44
|
acceptableForms = options.forms.split(/\s*,\s*/);
|
|
149
45
|
} else {
|
|
150
46
|
acceptableForms = options.forms;
|
|
151
47
|
}
|
|
152
|
-
|
|
153
|
-
// TODO(jack): remove options.inexact in favor of options.maxError
|
|
154
48
|
if (options.inexact === undefined) {
|
|
155
|
-
// If we aren't allowing inexact, ensure that we don't have a
|
|
156
|
-
// large maxError as well.
|
|
157
49
|
options.maxError = 0;
|
|
158
50
|
}
|
|
159
|
-
// Allow a small tolerance on maxError, to avoid numerical
|
|
160
|
-
// representation issues (2.3 should be correct for a solution of
|
|
161
|
-
// 2.45 with maxError=0.15).
|
|
162
51
|
options.maxError = +options.maxError + MAXERROR_EPSILON;
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
// in the list so we don't prematurely complain about not having
|
|
166
|
-
// a percent sign when the user entered the correct answer in a
|
|
167
|
-
// different form (such as a decimal or fraction)
|
|
168
|
-
if (___default["default"].contains(acceptableForms, "percent")) {
|
|
169
|
-
acceptableForms = ___default["default"].without(acceptableForms, "percent");
|
|
52
|
+
if (_.contains(acceptableForms, "percent")) {
|
|
53
|
+
acceptableForms = _.without(acceptableForms, "percent");
|
|
170
54
|
acceptableForms.push("percent");
|
|
171
55
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const fractionTransformer = function (text) {
|
|
175
|
-
text = text
|
|
176
|
-
// Replace unicode minus sign with hyphen
|
|
177
|
-
.replace(/\u2212/, "-")
|
|
178
|
-
// Remove space after +, -
|
|
179
|
-
.replace(/([+-])\s+/g, "$1")
|
|
180
|
-
// Remove leading/trailing whitespace
|
|
181
|
-
.replace(/(^\s*)|(\s*$)/gi, "");
|
|
182
|
-
|
|
183
|
-
// Extract numerator and denominator
|
|
56
|
+
const fractionTransformer = function fractionTransformer(text) {
|
|
57
|
+
text = text.replace(/\u2212/, "-").replace(/([+-])\s+/g, "$1").replace(/(^\s*)|(\s*$)/gi, "");
|
|
184
58
|
const match = text.match(/^([+-]?\d+)\s*\/\s*([+-]?\d+)$/);
|
|
185
|
-
// Fractions are represented as "-\frac{numerator}{denominator}"
|
|
186
|
-
// in Mobile device input instead of "numerator/denominator" as
|
|
187
|
-
// in web-browser.
|
|
188
59
|
const mobileDeviceMatch = text.match(/^([+-]?)\\frac\{([+-]?\d+)\}\{([+-]?\d+)\}$/);
|
|
189
60
|
const parsedInt = parseInt(text, 10);
|
|
190
61
|
if (match || mobileDeviceMatch) {
|
|
@@ -204,7 +75,7 @@ const KhanAnswerTypes = {
|
|
|
204
75
|
}
|
|
205
76
|
denom = parseFloat(mobileDeviceMatch[3]);
|
|
206
77
|
}
|
|
207
|
-
simplified = simplified && denom > 0 && (options.ratio || denom !== 1) &&
|
|
78
|
+
simplified = simplified && denom > 0 && (options.ratio || denom !== 1) && KhanMath.getGCD(num, denom) === 1;
|
|
208
79
|
return [{
|
|
209
80
|
value: num / denom,
|
|
210
81
|
exact: simplified
|
|
@@ -218,23 +89,8 @@ const KhanAnswerTypes = {
|
|
|
218
89
|
}
|
|
219
90
|
return [];
|
|
220
91
|
};
|
|
221
|
-
|
|
222
|
-
/*
|
|
223
|
-
* Different forms of numbers
|
|
224
|
-
*
|
|
225
|
-
* Each function returns a list of objects of the form:
|
|
226
|
-
*
|
|
227
|
-
* {
|
|
228
|
-
* value: numerical value,
|
|
229
|
-
* exact: true/false
|
|
230
|
-
* }
|
|
231
|
-
*/
|
|
232
92
|
const forms = {
|
|
233
|
-
// integer, which is encompassed by decimal
|
|
234
93
|
integer: function (text) {
|
|
235
|
-
// Compare the decimal form to the decimal form rounded to
|
|
236
|
-
// an integer. Only accept if the user actually entered an
|
|
237
|
-
// integer.
|
|
238
94
|
const decimal = forms.decimal(text);
|
|
239
95
|
const rounded = forms.decimal(text, 1);
|
|
240
96
|
if (decimal[0].value != null && decimal[0].value === rounded[0].value || decimal[1].value != null && decimal[1].value === rounded[1].value) {
|
|
@@ -242,101 +98,64 @@ const KhanAnswerTypes = {
|
|
|
242
98
|
}
|
|
243
99
|
return [];
|
|
244
100
|
},
|
|
245
|
-
// A proper fraction
|
|
246
101
|
proper: function (text) {
|
|
247
102
|
const transformed = fractionTransformer(text);
|
|
248
103
|
return transformed.flatMap(o => {
|
|
249
|
-
// All fractions that are less than 1
|
|
250
104
|
if (Math.abs(o.value) < 1) {
|
|
251
105
|
return [o];
|
|
252
106
|
}
|
|
253
107
|
return [];
|
|
254
108
|
});
|
|
255
109
|
},
|
|
256
|
-
// an improper fraction
|
|
257
110
|
improper: function (text) {
|
|
258
|
-
// As our answer keys are always in simplest form, we need
|
|
259
|
-
// to check for the existence of a fraction in the input before
|
|
260
|
-
// validating the answer. If no fraction is found, we consider
|
|
261
|
-
// the answer to be incorrect.
|
|
262
111
|
const fractionExists = text.includes("/") || text.match(/\\(d?frac)/);
|
|
263
112
|
if (!fractionExists) {
|
|
264
113
|
return [];
|
|
265
114
|
}
|
|
266
115
|
const transformed = fractionTransformer(text);
|
|
267
116
|
return transformed.flatMap(o => {
|
|
268
|
-
// All fractions that are greater than 1
|
|
269
117
|
if (Math.abs(o.value) >= 1) {
|
|
270
118
|
return [o];
|
|
271
119
|
}
|
|
272
120
|
return [];
|
|
273
121
|
});
|
|
274
122
|
},
|
|
275
|
-
// pi-like numbers
|
|
276
123
|
pi: function (text) {
|
|
277
124
|
let match;
|
|
278
125
|
let possibilities = [];
|
|
279
|
-
|
|
280
|
-
// Replace unicode minus sign with hyphen
|
|
281
126
|
text = text.replace(/\u2212/, "-");
|
|
282
|
-
|
|
283
|
-
// - pi
|
|
284
|
-
// (Note: we also support \pi (for TeX), p, tau (and \tau,
|
|
285
|
-
// and t), pau.)
|
|
286
127
|
if (match = text.match(/^([+-]?)\s*(\\?pi|p|\u03c0|\\?tau|t|\u03c4|pau)$/i)) {
|
|
287
128
|
possibilities = [{
|
|
288
129
|
value: parseFloat(match[1] + "1"),
|
|
289
130
|
exact: true
|
|
290
131
|
}];
|
|
291
|
-
|
|
292
|
-
// 5 / 6 pi
|
|
293
132
|
} else if (match = text.match(/^([+-]?\s*\d+\s*(?:\/\s*[+-]?\s*\d+)?)\s*\*?\s*(\\?pi|p|\u03c0|\\?tau|t|\u03c4|pau)$/i)) {
|
|
294
133
|
possibilities = fractionTransformer(match[1]);
|
|
295
|
-
|
|
296
|
-
// 4 5 / 6 pi
|
|
297
134
|
} else if (match = text.match(/^([+-]?)\s*(\d+)\s*([+-]?\d+)\s*\/\s*([+-]?\d+)\s*\*?\s*(\\?pi|p|\u03c0|\\?tau|t|\u03c4|pau)$/i)) {
|
|
298
135
|
const sign = parseFloat(match[1] + "1");
|
|
299
136
|
const integ = parseFloat(match[2]);
|
|
300
137
|
const num = parseFloat(match[3]);
|
|
301
138
|
const denom = parseFloat(match[4]);
|
|
302
|
-
const simplified = num < denom &&
|
|
139
|
+
const simplified = num < denom && KhanMath.getGCD(num, denom) === 1;
|
|
303
140
|
possibilities = [{
|
|
304
141
|
value: sign * (integ + num / denom),
|
|
305
142
|
exact: simplified
|
|
306
143
|
}];
|
|
307
|
-
|
|
308
|
-
// 5 pi / 6
|
|
309
144
|
} else if (match = text.match(/^([+-]?\s*\d+)\s*\*?\s*(\\?pi|p|\u03c0|\\?tau|t|\u03c4|pau)\s*(?:\/\s*([+-]?\s*\d+))?$/i)) {
|
|
310
145
|
possibilities = fractionTransformer(match[1] + "/" + match[3]);
|
|
311
|
-
|
|
312
|
-
// - pi / 4
|
|
313
146
|
} else if (match = text.match(/^([+-]?)\s*\*?\s*(\\?pi|p|\u03c0|\\?tau|t|\u03c4|pau)\s*(?:\/\s*([+-]?\d+))?$/i)) {
|
|
314
147
|
possibilities = fractionTransformer(match[1] + "1/" + match[3]);
|
|
315
|
-
|
|
316
|
-
// 0
|
|
317
148
|
} else if (text === "0") {
|
|
318
149
|
possibilities = [{
|
|
319
150
|
value: 0,
|
|
320
151
|
exact: true
|
|
321
152
|
}];
|
|
322
|
-
|
|
323
|
-
// 0.5 pi (fallback)
|
|
324
153
|
} else if (match = text.match(/^(.+)\s*\*?\s*(\\?pi|p|\u03c0|\\?tau|t|\u03c4|pau)$/i)) {
|
|
325
154
|
possibilities = forms.decimal(match[1]);
|
|
326
155
|
} else {
|
|
327
|
-
possibilities =
|
|
156
|
+
possibilities = _.reduce(KhanAnswerTypes.predicate.defaultForms.split(/\s*,\s*/), function (memo, form) {
|
|
328
157
|
return memo.concat(forms[form](text));
|
|
329
158
|
}, []);
|
|
330
|
-
|
|
331
|
-
// If the answer is a floating point number that's
|
|
332
|
-
// near a multiple of pi, mark is as being possibly
|
|
333
|
-
// an approximation of pi. We actually check if
|
|
334
|
-
// it's a plausible approximation of pi/12, since
|
|
335
|
-
// sometimes the correct answer is like pi/3 or pi/4.
|
|
336
|
-
// We also say it's a pi-approximation if it involves
|
|
337
|
-
// x/7 (since 22/7 is an approximation of pi.)
|
|
338
|
-
// Never mark an integer as being an approximation
|
|
339
|
-
// of pi.
|
|
340
159
|
let approximatesPi = false;
|
|
341
160
|
const number = parseFloat(text);
|
|
342
161
|
if (!isNaN(number) && number !== parseInt(text)) {
|
|
@@ -349,7 +168,7 @@ const KhanAnswerTypes = {
|
|
|
349
168
|
approximatesPi = true;
|
|
350
169
|
}
|
|
351
170
|
if (approximatesPi) {
|
|
352
|
-
|
|
171
|
+
_.each(possibilities, function (possibility) {
|
|
353
172
|
possibility.piApprox = true;
|
|
354
173
|
});
|
|
355
174
|
}
|
|
@@ -359,9 +178,6 @@ const KhanAnswerTypes = {
|
|
|
359
178
|
if (text.match(/\\?tau|t|\u03c4/)) {
|
|
360
179
|
multiplier = Math.PI * 2;
|
|
361
180
|
}
|
|
362
|
-
|
|
363
|
-
// We're taking an early stand along side xkcd in the
|
|
364
|
-
// inevitable ti vs. pau debate... http://xkcd.com/1292
|
|
365
181
|
if (text.match(/pau/)) {
|
|
366
182
|
multiplier = Math.PI * 1.5;
|
|
367
183
|
}
|
|
@@ -370,12 +186,8 @@ const KhanAnswerTypes = {
|
|
|
370
186
|
});
|
|
371
187
|
return possibilities;
|
|
372
188
|
},
|
|
373
|
-
// Converts '' to 1 and '-' to -1 so you can write "[___] x"
|
|
374
|
-
// and accept sane things
|
|
375
189
|
coefficient: function (text) {
|
|
376
190
|
let possibilities = [];
|
|
377
|
-
|
|
378
|
-
// Replace unicode minus sign with hyphen
|
|
379
191
|
text = text.replace(/\u2212/, "-");
|
|
380
192
|
if (text === "") {
|
|
381
193
|
possibilities = [{
|
|
@@ -390,19 +202,14 @@ const KhanAnswerTypes = {
|
|
|
390
202
|
}
|
|
391
203
|
return possibilities;
|
|
392
204
|
},
|
|
393
|
-
// simple log(c) form
|
|
394
205
|
log: function (text) {
|
|
395
206
|
let match;
|
|
396
207
|
let possibilities = [];
|
|
397
|
-
|
|
398
|
-
// Replace unicode minus sign with hyphen
|
|
399
208
|
text = text.replace(/\u2212/, "-");
|
|
400
209
|
text = text.replace(/[ \(\)]/g, "");
|
|
401
210
|
if (match = text.match(/^log\s*(\S+)\s*$/i)) {
|
|
402
|
-
// @ts-expect-error - TS2322 - Type '{ value: number | undefined; exact: boolean; }[]' is not assignable to type 'never[]'.
|
|
403
211
|
possibilities = forms.decimal(match[1]);
|
|
404
212
|
} else if (text === "0") {
|
|
405
|
-
// @ts-expect-error - TS2322 - Type 'number' is not assignable to type 'never'. | TS2322 - Type 'boolean' is not assignable to type 'never'.
|
|
406
213
|
possibilities = [{
|
|
407
214
|
value: 0,
|
|
408
215
|
exact: true
|
|
@@ -410,10 +217,8 @@ const KhanAnswerTypes = {
|
|
|
410
217
|
}
|
|
411
218
|
return possibilities;
|
|
412
219
|
},
|
|
413
|
-
// Numbers with percent signs
|
|
414
220
|
percent: function (text) {
|
|
415
221
|
text = String(text).trim();
|
|
416
|
-
// store whether or not there is a percent sign
|
|
417
222
|
let hasPercentSign = false;
|
|
418
223
|
if (text.indexOf("%") === text.length - 1) {
|
|
419
224
|
text = text.substring(0, text.length - 1).trim();
|
|
@@ -422,26 +227,18 @@ const KhanAnswerTypes = {
|
|
|
422
227
|
const transformed = forms.decimal(text);
|
|
423
228
|
transformed.forEach(t => {
|
|
424
229
|
t.exact = hasPercentSign;
|
|
425
|
-
// @ts-expect-error - TS2532 - Object is possibly 'undefined'.
|
|
426
230
|
t.value = t.value / 100;
|
|
427
231
|
});
|
|
428
232
|
return transformed;
|
|
429
233
|
},
|
|
430
|
-
// Mixed numbers, like 1 3/4
|
|
431
234
|
mixed: function (text) {
|
|
432
|
-
const match = text
|
|
433
|
-
// Replace unicode minus sign with hyphen
|
|
434
|
-
.replace(/\u2212/, "-")
|
|
435
|
-
// Remove space after +, -
|
|
436
|
-
.replace(/([+-])\s+/g, "$1")
|
|
437
|
-
// Extract integer, numerator and denominator
|
|
438
|
-
.match(/^([+-]?)(\d+)\s+(\d+)\s*\/\s*(\d+)$/);
|
|
235
|
+
const match = text.replace(/\u2212/, "-").replace(/([+-])\s+/g, "$1").match(/^([+-]?)(\d+)\s+(\d+)\s*\/\s*(\d+)$/);
|
|
439
236
|
if (match) {
|
|
440
237
|
const sign = parseFloat(match[1] + "1");
|
|
441
238
|
const integ = parseFloat(match[2]);
|
|
442
239
|
const num = parseFloat(match[3]);
|
|
443
240
|
const denom = parseFloat(match[4]);
|
|
444
|
-
const simplified = num < denom &&
|
|
241
|
+
const simplified = num < denom && KhanMath.getGCD(num, denom) === 1;
|
|
445
242
|
return [{
|
|
446
243
|
value: sign * (integ + num / denom),
|
|
447
244
|
exact: simplified
|
|
@@ -449,28 +246,10 @@ const KhanAnswerTypes = {
|
|
|
449
246
|
}
|
|
450
247
|
return [];
|
|
451
248
|
},
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
// answer. We round to 1/1e10 by default, which is healthily
|
|
455
|
-
// less than machine epsilon but should be more than any real
|
|
456
|
-
// decimal answer would use. (The 'integer' answer type uses
|
|
457
|
-
// precision == 1.)
|
|
458
|
-
decimal: function (text) {
|
|
459
|
-
let precision = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 1e10;
|
|
460
|
-
const normal = function (text) {
|
|
249
|
+
decimal: function (text, precision = 1e10) {
|
|
250
|
+
const normal = function normal(text) {
|
|
461
251
|
text = String(text).trim();
|
|
462
|
-
const match = text
|
|
463
|
-
// Replace unicode minus sign with hyphen
|
|
464
|
-
.replace(/\u2212/, "-")
|
|
465
|
-
// Remove space after +, -
|
|
466
|
-
.replace(/([+-])\s+/g, "$1")
|
|
467
|
-
// Extract integer, numerator and denominator. If
|
|
468
|
-
// commas or spaces are used, they must be in the
|
|
469
|
-
// "correct" places
|
|
470
|
-
.match(/^([+-]?(?:\d{1,3}(?:[, ]?\d{3})*\.?|\d{0,3}(?:[, ]?\d{3})*\.(?:\d{3}[, ]?)*\d{1,3}))$/);
|
|
471
|
-
|
|
472
|
-
// You can't start a number with `0,`, to prevent us
|
|
473
|
-
// interpeting '0.342' as correct for '342'
|
|
252
|
+
const match = text.replace(/\u2212/, "-").replace(/([+-])\s+/g, "$1").match(/^([+-]?(?:\d{1,3}(?:[, ]?\d{3})*\.?|\d{0,3}(?:[, ]?\d{3})*\.(?:\d{3}[, ]?)*\d{1,3}))$/);
|
|
474
253
|
const badLeadingZero = text.match(/^0[0,]*,/);
|
|
475
254
|
if (match && !badLeadingZero) {
|
|
476
255
|
let x = parseFloat(match[1].replace(/[, ]/g, ""));
|
|
@@ -480,7 +259,7 @@ const KhanAnswerTypes = {
|
|
|
480
259
|
return x;
|
|
481
260
|
}
|
|
482
261
|
};
|
|
483
|
-
const commas = function (text) {
|
|
262
|
+
const commas = function commas(text) {
|
|
484
263
|
text = text.replace(/([\.,])/g, function (_, c) {
|
|
485
264
|
return c === "." ? "," : ".";
|
|
486
265
|
});
|
|
@@ -495,11 +274,7 @@ const KhanAnswerTypes = {
|
|
|
495
274
|
}];
|
|
496
275
|
}
|
|
497
276
|
};
|
|
498
|
-
|
|
499
|
-
// validator function
|
|
500
277
|
return function (guess) {
|
|
501
|
-
// The fallback variable is used in place of the answer, if no
|
|
502
|
-
// answer is provided (i.e. the field is left blank)
|
|
503
278
|
const fallback = options.fallback != null ? "" + options.fallback : "";
|
|
504
279
|
guess = String(guess).trim() || fallback;
|
|
505
280
|
const score = {
|
|
@@ -508,30 +283,18 @@ const KhanAnswerTypes = {
|
|
|
508
283
|
message: null,
|
|
509
284
|
guess: guess
|
|
510
285
|
};
|
|
511
|
-
|
|
512
|
-
// iterate over all the acceptable forms, and if one of the
|
|
513
|
-
// answers is correct, return true
|
|
514
286
|
acceptableForms.forEach(form => {
|
|
515
287
|
const transformed = forms[form](guess);
|
|
516
288
|
for (let j = 0, l = transformed.length; j < l; j++) {
|
|
517
289
|
const val = transformed[j].value;
|
|
518
290
|
const exact = transformed[j].exact;
|
|
519
291
|
const piApprox = transformed[j].piApprox;
|
|
520
|
-
// If a string was returned, and it exactly matches,
|
|
521
|
-
// return true
|
|
522
292
|
if (predicate(val, options.maxError)) {
|
|
523
|
-
// If the exact correct number was returned,
|
|
524
|
-
// return true
|
|
525
293
|
if (exact || options.simplify === "optional") {
|
|
526
294
|
score.correct = true;
|
|
527
295
|
score.message = options.message || null;
|
|
528
|
-
// If the answer is correct, don't say it's
|
|
529
|
-
// empty. This happens, for example, with the
|
|
530
|
-
// coefficient type where guess === "" but is
|
|
531
|
-
// interpreted as "1" which is correct.
|
|
532
296
|
score.empty = false;
|
|
533
297
|
} else if (form === "percent") {
|
|
534
|
-
// Otherwise, an error was returned
|
|
535
298
|
score.empty = true;
|
|
536
299
|
score.message = ErrorCodes.MISSING_PERCENT_ERROR;
|
|
537
300
|
} else {
|
|
@@ -540,9 +303,6 @@ const KhanAnswerTypes = {
|
|
|
540
303
|
}
|
|
541
304
|
score.message = ErrorCodes.NEEDS_TO_BE_SIMPLIFIED_ERROR;
|
|
542
305
|
}
|
|
543
|
-
// The return false below stops the looping of the
|
|
544
|
-
// callback since predicate check succeeded.
|
|
545
|
-
// No more forms to look to verify the user guess.
|
|
546
306
|
return false;
|
|
547
307
|
}
|
|
548
308
|
if (piApprox && predicate(val, Math.abs(val * 0.001))) {
|
|
@@ -553,9 +313,9 @@ const KhanAnswerTypes = {
|
|
|
553
313
|
});
|
|
554
314
|
if (score.correct === false) {
|
|
555
315
|
let interpretedGuess = false;
|
|
556
|
-
|
|
557
|
-
const anyAreNaN =
|
|
558
|
-
return t.value != null && !
|
|
316
|
+
_.each(forms, function (form) {
|
|
317
|
+
const anyAreNaN = _.any(form(guess), function (t) {
|
|
318
|
+
return t.value != null && !_.isNaN(t.value);
|
|
559
319
|
});
|
|
560
320
|
if (anyAreNaN) {
|
|
561
321
|
interpretedGuess = true;
|
|
@@ -571,83 +331,26 @@ const KhanAnswerTypes = {
|
|
|
571
331
|
};
|
|
572
332
|
}
|
|
573
333
|
},
|
|
574
|
-
/*
|
|
575
|
-
* number answer type
|
|
576
|
-
*
|
|
577
|
-
* wraps the predicate answer type to performs simple number-based checking
|
|
578
|
-
* of a solution
|
|
579
|
-
*/
|
|
580
334
|
number: {
|
|
581
335
|
convertToPredicate: function (correctAnswer, options) {
|
|
582
336
|
const correctFloat = parseFloat(correctAnswer);
|
|
583
337
|
return [function (guess, maxError) {
|
|
584
338
|
return Math.abs(guess - correctFloat) < maxError;
|
|
585
|
-
}, {
|
|
586
|
-
...options,
|
|
339
|
+
}, _extends({}, options, {
|
|
587
340
|
type: "predicate"
|
|
588
|
-
}];
|
|
341
|
+
})];
|
|
589
342
|
},
|
|
590
343
|
createValidatorFunctional: function (correctAnswer, options) {
|
|
591
344
|
return KhanAnswerTypes.predicate.createValidatorFunctional(...KhanAnswerTypes.number.convertToPredicate(correctAnswer, options));
|
|
592
345
|
}
|
|
593
346
|
},
|
|
594
|
-
/*
|
|
595
|
-
* The expression answer type parses a given expression or equation
|
|
596
|
-
* and semantically compares it to the solution. In addition, instant
|
|
597
|
-
* feedback is provided by rendering the last answer that fully parsed.
|
|
598
|
-
*
|
|
599
|
-
* Parsing options:
|
|
600
|
-
* functions (e.g. data-functions="f g h")
|
|
601
|
-
* A space or comma separated list of single-letter variables that
|
|
602
|
-
* should be interpreted as functions. Case sensitive. "e" and "i"
|
|
603
|
-
* are reserved.
|
|
604
|
-
*
|
|
605
|
-
* no functions specified: f(x+y) == fx + fy
|
|
606
|
-
* with "f" as a function: f(x+y) != fx + fy
|
|
607
|
-
*
|
|
608
|
-
* Comparison options:
|
|
609
|
-
* same-form (e.g. data-same-form)
|
|
610
|
-
* If present, the answer must match the solution's structure in
|
|
611
|
-
* addition to evaluating the same. Commutativity and excess negation
|
|
612
|
-
* are ignored, but all other changes will trigger a rejection. Useful
|
|
613
|
-
* for requiring a particular form of an equation, or if the answer
|
|
614
|
-
* must be factored.
|
|
615
|
-
*
|
|
616
|
-
* example question: Factor x^2 + x - 2
|
|
617
|
-
* example solution: (x-1)(x+2)
|
|
618
|
-
* accepted answers: (x-1)(x+2), (x+2)(x-1), ---(-x-2)(-1+x), etc.
|
|
619
|
-
* rejected answers: x^2+x-2, x*x+x-2, x(x+1)-2, (x-1)(x+2)^1, etc.
|
|
620
|
-
* rejection message: Your answer is not in the correct form
|
|
621
|
-
*
|
|
622
|
-
* simplify (e.g. data-simplify)
|
|
623
|
-
* If present, the answer must be fully expanded and simplified. Use
|
|
624
|
-
* carefully - simplification is hard and there may be bugs, or you
|
|
625
|
-
* might not agree on the definition of "simplified" used. You will
|
|
626
|
-
* get an error if the provided solution is not itself fully expanded
|
|
627
|
-
* and simplified.
|
|
628
|
-
*
|
|
629
|
-
* example question: Simplify ((n*x^5)^5) / (n^(-2)*x^2)^-3
|
|
630
|
-
* example solution: x^31 / n
|
|
631
|
-
* accepted answers: x^31 / n, x^31 / n^1, x^31 * n^(-1), etc.
|
|
632
|
-
* rejected answers: (x^25 * n^5) / (x^(-6) * n^6), etc.
|
|
633
|
-
* rejection message: Your answer is not fully expanded and simplified
|
|
634
|
-
*
|
|
635
|
-
* Rendering options:
|
|
636
|
-
* times (e.g. data-times)
|
|
637
|
-
* If present, explicit multiplication (such as between numbers) will
|
|
638
|
-
* be rendered with a cross/x symbol (TeX: \times) instead of the usual
|
|
639
|
-
* center dot (TeX: \cdot).
|
|
640
|
-
*
|
|
641
|
-
* normal rendering: 2 * 3^x -> 2 \cdot 3^{x}
|
|
642
|
-
* but with "times": 2 * 3^x -> 2 \times 3^{x}
|
|
643
|
-
*/
|
|
644
347
|
expression: {
|
|
645
348
|
parseSolution: function (solutionString, options) {
|
|
646
|
-
let solution =
|
|
349
|
+
let solution = KAS.parse(solutionString, options);
|
|
647
350
|
if (!solution.parsed) {
|
|
648
|
-
throw new
|
|
351
|
+
throw new PerseusError("The provided solution (" + solutionString + ") didn't parse.", Errors.InvalidInput);
|
|
649
352
|
} else if (options.simplified && !solution.expr.isSimplified()) {
|
|
650
|
-
throw new
|
|
353
|
+
throw new PerseusError("The provided solution (" + solutionString + ") isn't fully expanded and simplified.", Errors.InvalidInput);
|
|
651
354
|
} else {
|
|
652
355
|
solution = solution.expr;
|
|
653
356
|
}
|
|
@@ -660,80 +363,37 @@ const KhanAnswerTypes = {
|
|
|
660
363
|
correct: false,
|
|
661
364
|
message: null,
|
|
662
365
|
guess: guess,
|
|
663
|
-
// Setting `ungraded` to true indicates that if the
|
|
664
|
-
// guess doesn't match any of the solutions, the guess
|
|
665
|
-
// shouldn't be marked as incorrect; instead, `message`
|
|
666
|
-
// should be shown to the user. This is different from
|
|
667
|
-
// setting `empty` to true, since the behavior of `empty`
|
|
668
|
-
// is that `message` only will be shown if the guess is
|
|
669
|
-
// graded as empty for every solution.
|
|
670
366
|
ungraded: false
|
|
671
367
|
};
|
|
672
|
-
|
|
673
|
-
// Don't bother parsing an empty input
|
|
674
368
|
if (!guess) {
|
|
675
|
-
// @ts-expect-error - TS2540 - Cannot assign to 'empty' because it is a read-only property.
|
|
676
369
|
score.empty = true;
|
|
677
370
|
return score;
|
|
678
371
|
}
|
|
679
|
-
const answer =
|
|
680
|
-
|
|
681
|
-
// An unsuccessful parse doesn't count as wrong
|
|
372
|
+
const answer = KAS.parse(guess, options);
|
|
682
373
|
if (!answer.parsed) {
|
|
683
|
-
// @ts-expect-error - TS2540 - Cannot assign to 'empty' because it is a read-only property.
|
|
684
374
|
score.empty = true;
|
|
685
375
|
return score;
|
|
686
376
|
}
|
|
687
|
-
|
|
688
|
-
// Solution will need to be parsed again if we're creating
|
|
689
|
-
// this from a multiple question type
|
|
690
377
|
if (typeof solution === "string") {
|
|
691
378
|
solution = KhanAnswerTypes.expression.parseSolution(solution, options);
|
|
692
379
|
}
|
|
693
|
-
const result =
|
|
380
|
+
const result = KAS.compare(answer.expr, solution, options);
|
|
694
381
|
if (result.equal) {
|
|
695
|
-
// Correct answer
|
|
696
|
-
// @ts-expect-error - TS2540 - Cannot assign to 'correct' because it is a read-only property.
|
|
697
382
|
score.correct = true;
|
|
698
383
|
} else if (result.wrongVariableNames || result.wrongVariableCase) {
|
|
699
|
-
// We don't want to give people an error for getting the
|
|
700
|
-
// variable names or the variable case wrong.
|
|
701
|
-
// TODO(aasmund): This should ideally have been handled
|
|
702
|
-
// under the `result.message` condition, but the
|
|
703
|
-
// KAS messages currently aren't translatable.
|
|
704
|
-
// @ts-expect-error - TS2540 - Cannot assign to 'ungraded' because it is a read-only property.
|
|
705
384
|
score.ungraded = true;
|
|
706
|
-
// @ts-expect-error - TS2540 - Cannot assign to 'message' because it is a read-only property.
|
|
707
385
|
score.message = result.wrongVariableCase ? ErrorCodes.WRONG_CASE_ERROR : ErrorCodes.WRONG_LETTER_ERROR;
|
|
708
|
-
// Don't tell the use they're "almost there" in this case, that may not be true and isn't helpful.
|
|
709
|
-
// @ts-expect-error - TS2339 - Property 'suppressAlmostThere' does not exist on type '{ readonly empty: false; readonly correct: false; readonly message: string | null | undefined; readonly guess: any; readonly ungraded: false; }'.
|
|
710
386
|
score.suppressAlmostThere = true;
|
|
711
387
|
} else if (result.message) {
|
|
712
|
-
// Nearly correct answer
|
|
713
|
-
// TODO(aasmund): This message also isn't translatable;
|
|
714
|
-
// need to fix that in KAS
|
|
715
|
-
// @ts-expect-error - TS2540 - Cannot assign to 'message' because it is a read-only property.
|
|
716
388
|
score.message = result.message;
|
|
717
389
|
} else {
|
|
718
|
-
|
|
719
|
-
// TODO(aasmund): I think this branch is effectively dead,
|
|
720
|
-
// because the replacement will only work in situations
|
|
721
|
-
// where the variables are wrong (except if the variable
|
|
722
|
-
// is x, in which case the replacement won't work either),
|
|
723
|
-
// which is handled by another branch. When we implement a
|
|
724
|
-
// more sophisticated variable check, revive this or
|
|
725
|
-
// remove it completely if it will never come into play.
|
|
726
|
-
const answerX = KAS__namespace.parse(guess.replace(/[xX]/g, "*"), options);
|
|
390
|
+
const answerX = KAS.parse(guess.replace(/[xX]/g, "*"), options);
|
|
727
391
|
if (answerX.parsed) {
|
|
728
|
-
const resultX =
|
|
392
|
+
const resultX = KAS.compare(answerX.expr, solution, options);
|
|
729
393
|
if (resultX.equal) {
|
|
730
|
-
// @ts-expect-error - TS2540 - Cannot assign to 'ungraded' because it is a read-only property.
|
|
731
394
|
score.ungraded = true;
|
|
732
|
-
// @ts-expect-error - TS2540 - Cannot assign to 'message' because it is a read-only property.
|
|
733
395
|
score.message = ErrorCodes.MULTIPLICATION_SIGN_ERROR;
|
|
734
396
|
} else if (resultX.message) {
|
|
735
|
-
// TODO(aasmund): I18nize `score.message`
|
|
736
|
-
// @ts-expect-error - TS2540 - Cannot assign to 'message' because it is a read-only property.
|
|
737
397
|
score.message = resultX.message + " Also, I'm a computer. I only understand " + "multiplication if you use an " + "asterisk (*) as the multiplication " + "sign.";
|
|
738
398
|
}
|
|
739
399
|
}
|
|
@@ -759,28 +419,18 @@ function scoreCategorizer(userInput, rubric) {
|
|
|
759
419
|
};
|
|
760
420
|
}
|
|
761
421
|
|
|
762
|
-
/**
|
|
763
|
-
* Checks userInput from the categorizer widget to see if the user has selected
|
|
764
|
-
* a category for each item.
|
|
765
|
-
* @param userInput - The user's input corresponding to an array of indices that
|
|
766
|
-
* represent the selected category for each row/item.
|
|
767
|
-
* @param validationData - An array of strings corresponding to each row/item
|
|
768
|
-
* @param strings - Used to provide a validation message
|
|
769
|
-
*/
|
|
770
422
|
function validateCategorizer(userInput, validationData) {
|
|
771
423
|
const incomplete = validationData.items.some((_, i) => userInput.values[i] == null);
|
|
772
424
|
if (incomplete) {
|
|
773
425
|
return {
|
|
774
426
|
type: "invalid",
|
|
775
|
-
message:
|
|
427
|
+
message: ErrorCodes.INVALID_SELECTION_ERROR
|
|
776
428
|
};
|
|
777
429
|
}
|
|
778
430
|
return null;
|
|
779
431
|
}
|
|
780
432
|
|
|
781
433
|
function scoreCSProgram(userInput) {
|
|
782
|
-
// The CS program can tell us whether it's correct or incorrect,
|
|
783
|
-
// and pass an optional message
|
|
784
434
|
if (userInput.status === "correct") {
|
|
785
435
|
return {
|
|
786
436
|
type: "points",
|
|
@@ -813,10 +463,6 @@ function scoreDropdown(userInput, rubric) {
|
|
|
813
463
|
};
|
|
814
464
|
}
|
|
815
465
|
|
|
816
|
-
/**
|
|
817
|
-
* Checks if the user has selected an item from the dropdown before scoring.
|
|
818
|
-
* This is shown with a userInput value / index other than 0.
|
|
819
|
-
*/
|
|
820
466
|
function validateDropdown(userInput) {
|
|
821
467
|
if (userInput.value === 0) {
|
|
822
468
|
return {
|
|
@@ -827,63 +473,25 @@ function validateDropdown(userInput) {
|
|
|
827
473
|
return null;
|
|
828
474
|
}
|
|
829
475
|
|
|
830
|
-
/* Content creators input a list of answers which are matched from top to
|
|
831
|
-
* bottom. The intent is that they can include spcific solutions which should
|
|
832
|
-
* be graded as correct or incorrect (or ungraded!) first, then get more
|
|
833
|
-
* general.
|
|
834
|
-
*
|
|
835
|
-
* We iterate through each answer, trying to match it with the user's input
|
|
836
|
-
* using the following angorithm:
|
|
837
|
-
* - Try to parse the user's input. If it doesn't parse then return "not
|
|
838
|
-
* graded".
|
|
839
|
-
* - For each answer:
|
|
840
|
-
* ~ Try to validate the user's input against the answer. The answer is
|
|
841
|
-
* expected to parse.
|
|
842
|
-
* ~ If the user's input validates (the validator judges it "correct"), we've
|
|
843
|
-
* matched and can stop considering answers.
|
|
844
|
-
* - If there were no matches or the matching answer is considered "ungraded",
|
|
845
|
-
* show the user an error. TODO(joel) - what error?
|
|
846
|
-
* - Otherwise, pass through the resulting points and message.
|
|
847
|
-
*/
|
|
848
476
|
function scoreExpression(userInput, rubric, locale) {
|
|
849
|
-
const options =
|
|
850
|
-
|
|
851
|
-
decimal_separator:
|
|
477
|
+
const options = _.clone(rubric);
|
|
478
|
+
_.extend(options, {
|
|
479
|
+
decimal_separator: getDecimalSeparator(locale)
|
|
852
480
|
});
|
|
853
481
|
const createValidator = answer => {
|
|
854
|
-
|
|
855
|
-
// solution answer, not the student answer, and we don't want a
|
|
856
|
-
// solution to work if the student is using a different language
|
|
857
|
-
// (different from the content creation language, ie. English).
|
|
858
|
-
const expression = KAS__namespace.parse(answer.value, rubric);
|
|
859
|
-
// An answer may not be parsed if the expression was defined
|
|
860
|
-
// incorrectly. For example if the answer is using a symbol defined
|
|
861
|
-
// in the function variables list for the expression.
|
|
482
|
+
const expression = KAS.parse(answer.value, rubric);
|
|
862
483
|
if (!expression.parsed) {
|
|
863
|
-
|
|
864
|
-
throw new perseusCore.PerseusError("Unable to parse solution answer for expression", perseusCore.Errors.InvalidInput, {
|
|
484
|
+
throw new PerseusError("Unable to parse solution answer for expression", Errors.InvalidInput, {
|
|
865
485
|
metadata: {
|
|
866
486
|
rubric: JSON.stringify(rubric)
|
|
867
487
|
}
|
|
868
488
|
});
|
|
869
489
|
}
|
|
870
|
-
return KhanAnswerTypes.expression.createValidatorFunctional(expression.expr,
|
|
490
|
+
return KhanAnswerTypes.expression.createValidatorFunctional(expression.expr, _({}).extend(options, {
|
|
871
491
|
simplify: answer.simplify,
|
|
872
492
|
form: answer.form
|
|
873
493
|
}));
|
|
874
494
|
};
|
|
875
|
-
|
|
876
|
-
// Find the first answer form that matches the user's input and that
|
|
877
|
-
// is considered correct. Also, track whether the input is
|
|
878
|
-
// considered "empty" for all answer forms, and keep the validation
|
|
879
|
-
// result for the first answer form for which the user's input was
|
|
880
|
-
// considered "ungraded".
|
|
881
|
-
// (Terminology reminder: the answer forms are provided by the
|
|
882
|
-
// assessment items; they are not the user's input. Each one might
|
|
883
|
-
// represent a correct answer, an incorrect one (if the exercise
|
|
884
|
-
// creator has predicted certain common wrong answers and wants to
|
|
885
|
-
// provide guidance via a message), or an ungraded one (same idea,
|
|
886
|
-
// but without giving the user an incorrect mark for the question).
|
|
887
495
|
let matchingAnswerForm;
|
|
888
496
|
let matchMessage;
|
|
889
497
|
let allEmpty = true;
|
|
@@ -894,34 +502,18 @@ function scoreExpression(userInput, rubric, locale) {
|
|
|
894
502
|
continue;
|
|
895
503
|
}
|
|
896
504
|
const result = validator(userInput);
|
|
897
|
-
|
|
898
|
-
// Short-circuit as soon as the user's input matches some answer
|
|
899
|
-
// (independently of whether the answer is correct)
|
|
900
505
|
if (result.correct) {
|
|
901
506
|
matchingAnswerForm = answerForm;
|
|
902
507
|
matchMessage = result.message || "";
|
|
903
508
|
break;
|
|
904
509
|
}
|
|
905
510
|
allEmpty = allEmpty && result.empty;
|
|
906
|
-
// If this answer form is correct and the user's input is considered
|
|
907
|
-
// "ungraded" for it, we'll want to keep the evaluation result for
|
|
908
|
-
// later. If the user's input doesn't match any answer forms, we'll
|
|
909
|
-
// show the message from this validation.
|
|
910
511
|
if (answerForm.considered === "correct" && result.ungraded && !firstUngradedResult) {
|
|
911
512
|
firstUngradedResult = result;
|
|
912
513
|
}
|
|
913
514
|
}
|
|
914
|
-
|
|
915
|
-
// Now check to see if we matched any answer form at all, and if
|
|
916
|
-
// we did, whether it's considered correct, incorrect, or ungraded
|
|
917
515
|
if (!matchingAnswerForm) {
|
|
918
516
|
if (firstUngradedResult) {
|
|
919
|
-
// While we didn't directly match with any answer form, we
|
|
920
|
-
// did at some point get an "ungraded" validation result,
|
|
921
|
-
// which might indicate e.g. a mismatch in variable casing.
|
|
922
|
-
// We'll return "invalid", which will let the user try again
|
|
923
|
-
// with no penalty, and the hopefully helpful validation
|
|
924
|
-
// message.
|
|
925
517
|
return {
|
|
926
518
|
type: "invalid",
|
|
927
519
|
message: firstUngradedResult.message,
|
|
@@ -929,14 +521,11 @@ function scoreExpression(userInput, rubric, locale) {
|
|
|
929
521
|
};
|
|
930
522
|
}
|
|
931
523
|
if (allEmpty) {
|
|
932
|
-
// If everything graded as empty, it's invalid.
|
|
933
524
|
return {
|
|
934
525
|
type: "invalid",
|
|
935
526
|
message: null
|
|
936
527
|
};
|
|
937
528
|
}
|
|
938
|
-
// We fell through all the possibilities and we're not empty,
|
|
939
|
-
// so the answer is considered incorrect.
|
|
940
529
|
return {
|
|
941
530
|
type: "points",
|
|
942
531
|
earned: 0,
|
|
@@ -949,9 +538,6 @@ function scoreExpression(userInput, rubric, locale) {
|
|
|
949
538
|
message: matchMessage
|
|
950
539
|
};
|
|
951
540
|
}
|
|
952
|
-
// We matched a graded answer form, so we can now tell the user
|
|
953
|
-
// whether their input was correct or incorrect, and hand out
|
|
954
|
-
// points accordingly
|
|
955
541
|
return {
|
|
956
542
|
type: "points",
|
|
957
543
|
earned: matchingAnswerForm.considered === "correct" ? 1 : 0,
|
|
@@ -960,14 +546,6 @@ function scoreExpression(userInput, rubric, locale) {
|
|
|
960
546
|
};
|
|
961
547
|
}
|
|
962
548
|
|
|
963
|
-
/**
|
|
964
|
-
* Checks user input from the expression widget to see if it is scorable.
|
|
965
|
-
*
|
|
966
|
-
* Note: Most of the expression widget's validation requires the Rubric because
|
|
967
|
-
* of its use of KhanAnswerTypes as a core part of scoring.
|
|
968
|
-
*
|
|
969
|
-
* @see `scoreExpression()` for more details.
|
|
970
|
-
*/
|
|
971
549
|
function validateExpression(userInput) {
|
|
972
550
|
if (userInput === "") {
|
|
973
551
|
return {
|
|
@@ -983,13 +561,13 @@ function getCoefficientsByType(data) {
|
|
|
983
561
|
return undefined;
|
|
984
562
|
}
|
|
985
563
|
if (data.type === "exponential" || data.type === "logarithm") {
|
|
986
|
-
const grader =
|
|
564
|
+
const grader = GrapherUtil.functionForType(data.type);
|
|
987
565
|
return grader.getCoefficients(data.coords, data.asymptote);
|
|
988
566
|
} else if (data.type === "linear" || data.type === "quadratic" || data.type === "absolute_value" || data.type === "sinusoid" || data.type === "tangent") {
|
|
989
|
-
const grader =
|
|
567
|
+
const grader = GrapherUtil.functionForType(data.type);
|
|
990
568
|
return grader.getCoefficients(data.coords);
|
|
991
569
|
} else {
|
|
992
|
-
throw new
|
|
570
|
+
throw new PerseusError("Invalid grapher type", Errors.InvalidInput);
|
|
993
571
|
}
|
|
994
572
|
}
|
|
995
573
|
function scoreGrapher(userInput, rubric) {
|
|
@@ -1001,17 +579,13 @@ function scoreGrapher(userInput, rubric) {
|
|
|
1001
579
|
message: null
|
|
1002
580
|
};
|
|
1003
581
|
}
|
|
1004
|
-
|
|
1005
|
-
// We haven't moved the coords
|
|
1006
582
|
if (userInput.coords == null) {
|
|
1007
583
|
return {
|
|
1008
584
|
type: "invalid",
|
|
1009
585
|
message: null
|
|
1010
586
|
};
|
|
1011
587
|
}
|
|
1012
|
-
|
|
1013
|
-
// Get new function handler for grading
|
|
1014
|
-
const grader = perseusCore.GrapherUtil.functionForType(userInput.type);
|
|
588
|
+
const grader = GrapherUtil.functionForType(userInput.type);
|
|
1015
589
|
const guessCoeffs = getCoefficientsByType(userInput);
|
|
1016
590
|
const correctCoeffs = getCoefficientsByType(rubric.correct);
|
|
1017
591
|
if (guessCoeffs == null || correctCoeffs == null) {
|
|
@@ -1036,10 +610,7 @@ function scoreGrapher(userInput, rubric) {
|
|
|
1036
610
|
};
|
|
1037
611
|
}
|
|
1038
612
|
|
|
1039
|
-
// TODO: merge this with scoreCSProgram, it's the same code
|
|
1040
613
|
function scoreIframe(userInput) {
|
|
1041
|
-
// The iframe can tell us whether it's correct or incorrect,
|
|
1042
|
-
// and pass an optional message
|
|
1043
614
|
if (userInput.status === "correct") {
|
|
1044
615
|
return {
|
|
1045
616
|
type: "points",
|
|
@@ -1067,16 +638,15 @@ const {
|
|
|
1067
638
|
canonicalSineCoefficients,
|
|
1068
639
|
similar,
|
|
1069
640
|
clockwise
|
|
1070
|
-
} =
|
|
641
|
+
} = geometry;
|
|
1071
642
|
const {
|
|
1072
643
|
getClockwiseAngle
|
|
1073
|
-
} =
|
|
644
|
+
} = angles;
|
|
1074
645
|
const {
|
|
1075
646
|
getSinusoidCoefficients,
|
|
1076
647
|
getQuadraticCoefficients
|
|
1077
|
-
} =
|
|
648
|
+
} = coefficients;
|
|
1078
649
|
function scoreInteractiveGraph(userInput, rubric) {
|
|
1079
|
-
// None-type graphs are not graded
|
|
1080
650
|
if (userInput.type === "none" && rubric.correct.type === "none") {
|
|
1081
651
|
return {
|
|
1082
652
|
type: "points",
|
|
@@ -1085,22 +655,11 @@ function scoreInteractiveGraph(userInput, rubric) {
|
|
|
1085
655
|
message: null
|
|
1086
656
|
};
|
|
1087
657
|
}
|
|
1088
|
-
|
|
1089
|
-
// When nothing has moved, there will neither be coords nor the
|
|
1090
|
-
// circle's center/radius fields. When those fields are absent, skip
|
|
1091
|
-
// all these checks; just go mark the answer as empty.
|
|
1092
|
-
const hasValue = Boolean(
|
|
1093
|
-
// @ts-expect-error - TS2339 - Property 'coords' does not exist on type 'PerseusGraphType'.
|
|
1094
|
-
userInput.coords ||
|
|
1095
|
-
// @ts-expect-error - TS2339 - Property 'center' does not exist on type 'PerseusGraphType'. | TS2339 - Property 'radius' does not exist on type 'PerseusGraphType'.
|
|
1096
|
-
userInput.center && userInput.radius);
|
|
658
|
+
const hasValue = Boolean(userInput.coords || userInput.center && userInput.radius);
|
|
1097
659
|
if (userInput.type === rubric.correct.type && hasValue) {
|
|
1098
660
|
if (userInput.type === "linear" && rubric.correct.type === "linear" && userInput.coords != null) {
|
|
1099
661
|
const guess = userInput.coords;
|
|
1100
662
|
const correct = rubric.correct.coords;
|
|
1101
|
-
|
|
1102
|
-
// If both of the guess points are on the correct line, it's
|
|
1103
|
-
// correct.
|
|
1104
663
|
if (collinear(correct[0], correct[1], guess[0]) && collinear(correct[0], correct[1], guess[1])) {
|
|
1105
664
|
return {
|
|
1106
665
|
type: "points",
|
|
@@ -1121,10 +680,9 @@ function scoreInteractiveGraph(userInput, rubric) {
|
|
|
1121
680
|
};
|
|
1122
681
|
}
|
|
1123
682
|
} else if (userInput.type === "quadratic" && rubric.correct.type === "quadratic" && userInput.coords != null) {
|
|
1124
|
-
// If the parabola coefficients match, it's correct.
|
|
1125
683
|
const guessCoeffs = getQuadraticCoefficients(userInput.coords);
|
|
1126
684
|
const correctCoeffs = getQuadraticCoefficients(rubric.correct.coords);
|
|
1127
|
-
if (
|
|
685
|
+
if (approximateDeepEqual(guessCoeffs, correctCoeffs)) {
|
|
1128
686
|
return {
|
|
1129
687
|
type: "points",
|
|
1130
688
|
earned: 1,
|
|
@@ -1137,8 +695,7 @@ function scoreInteractiveGraph(userInput, rubric) {
|
|
|
1137
695
|
const correctCoeffs = getSinusoidCoefficients(rubric.correct.coords);
|
|
1138
696
|
const canonicalGuessCoeffs = canonicalSineCoefficients(guessCoeffs);
|
|
1139
697
|
const canonicalCorrectCoeffs = canonicalSineCoefficients(correctCoeffs);
|
|
1140
|
-
|
|
1141
|
-
if (perseusCore.approximateDeepEqual(canonicalGuessCoeffs, canonicalCorrectCoeffs)) {
|
|
698
|
+
if (approximateDeepEqual(canonicalGuessCoeffs, canonicalCorrectCoeffs)) {
|
|
1142
699
|
return {
|
|
1143
700
|
type: "points",
|
|
1144
701
|
earned: 1,
|
|
@@ -1147,7 +704,7 @@ function scoreInteractiveGraph(userInput, rubric) {
|
|
|
1147
704
|
};
|
|
1148
705
|
}
|
|
1149
706
|
} else if (userInput.type === "circle" && rubric.correct.type === "circle") {
|
|
1150
|
-
if (
|
|
707
|
+
if (approximateDeepEqual(userInput.center, rubric.correct.center) && approximateEqual(userInput.radius, rubric.correct.radius)) {
|
|
1151
708
|
return {
|
|
1152
709
|
type: "points",
|
|
1153
710
|
earned: 1,
|
|
@@ -1162,14 +719,9 @@ function scoreInteractiveGraph(userInput, rubric) {
|
|
|
1162
719
|
}
|
|
1163
720
|
const guess = userInput.coords.slice();
|
|
1164
721
|
correct = correct.slice();
|
|
1165
|
-
|
|
1166
|
-
// eq() comparison but _.isEqual(0, -0) is false, so we'll use
|
|
1167
|
-
// eq() anyway. The sort should be fine because it'll stringify
|
|
1168
|
-
// it and -0 converted to a string is "0"
|
|
1169
|
-
guess?.sort();
|
|
1170
|
-
// @ts-expect-error - TS2339 - Property 'sort' does not exist on type 'readonly Coord[]'.
|
|
722
|
+
guess == null || guess.sort();
|
|
1171
723
|
correct.sort();
|
|
1172
|
-
if (
|
|
724
|
+
if (approximateDeepEqual(guess, correct)) {
|
|
1173
725
|
return {
|
|
1174
726
|
type: "points",
|
|
1175
727
|
earned: 1,
|
|
@@ -1184,14 +736,13 @@ function scoreInteractiveGraph(userInput, rubric) {
|
|
|
1184
736
|
if (rubric.correct.match === "similar") {
|
|
1185
737
|
match = similar(guess, correct, Number.POSITIVE_INFINITY);
|
|
1186
738
|
} else if (rubric.correct.match === "congruent") {
|
|
1187
|
-
match = similar(guess, correct,
|
|
739
|
+
match = similar(guess, correct, number.DEFAULT_TOLERANCE);
|
|
1188
740
|
} else if (rubric.correct.match === "approx") {
|
|
1189
741
|
match = similar(guess, correct, 0.1);
|
|
1190
742
|
} else {
|
|
1191
|
-
/* exact */
|
|
1192
743
|
guess.sort();
|
|
1193
744
|
correct.sort();
|
|
1194
|
-
match =
|
|
745
|
+
match = approximateDeepEqual(guess, correct);
|
|
1195
746
|
}
|
|
1196
747
|
if (match) {
|
|
1197
748
|
return {
|
|
@@ -1202,11 +753,11 @@ function scoreInteractiveGraph(userInput, rubric) {
|
|
|
1202
753
|
};
|
|
1203
754
|
}
|
|
1204
755
|
} else if (userInput.type === "segment" && rubric.correct.type === "segment" && userInput.coords != null) {
|
|
1205
|
-
let guess =
|
|
1206
|
-
let correct =
|
|
1207
|
-
guess =
|
|
1208
|
-
correct =
|
|
1209
|
-
if (
|
|
756
|
+
let guess = deepClone(userInput.coords);
|
|
757
|
+
let correct = deepClone(rubric.correct.coords);
|
|
758
|
+
guess = _.invoke(guess, "sort").sort();
|
|
759
|
+
correct = _.invoke(correct, "sort").sort();
|
|
760
|
+
if (approximateDeepEqual(guess, correct)) {
|
|
1210
761
|
return {
|
|
1211
762
|
type: "points",
|
|
1212
763
|
earned: 1,
|
|
@@ -1217,7 +768,7 @@ function scoreInteractiveGraph(userInput, rubric) {
|
|
|
1217
768
|
} else if (userInput.type === "ray" && rubric.correct.type === "ray" && userInput.coords != null) {
|
|
1218
769
|
const guess = userInput.coords;
|
|
1219
770
|
const correct = rubric.correct.coords;
|
|
1220
|
-
if (
|
|
771
|
+
if (approximateDeepEqual(guess[0], correct[0]) && collinear(correct[0], correct[1], guess[1])) {
|
|
1221
772
|
return {
|
|
1222
773
|
type: "points",
|
|
1223
774
|
earned: 1,
|
|
@@ -1229,38 +780,27 @@ function scoreInteractiveGraph(userInput, rubric) {
|
|
|
1229
780
|
const coords = userInput.coords;
|
|
1230
781
|
const correct = rubric.correct.coords;
|
|
1231
782
|
const allowReflexAngles = rubric.correct.allowReflexAngles;
|
|
1232
|
-
|
|
1233
|
-
// While the angle graph should always have 3 points, our types
|
|
1234
|
-
// technically allow for null values. We'll check for that here.
|
|
1235
|
-
// TODO: (LEMS-2857) We would like to update the type of coords
|
|
1236
|
-
// to be non-nullable, as the graph should always have 3 points.
|
|
1237
783
|
if (!coords) {
|
|
1238
784
|
return {
|
|
1239
785
|
type: "invalid",
|
|
1240
786
|
message: null
|
|
1241
787
|
};
|
|
1242
788
|
}
|
|
1243
|
-
|
|
1244
|
-
// We need to check both the direction of the angle and the
|
|
1245
|
-
// whether the graph allows for reflexive angles in order to
|
|
1246
|
-
// to determine if we need to reverse the coords for scoring.
|
|
1247
789
|
const areClockwise = clockwise([coords[0], coords[2], coords[1]]);
|
|
1248
790
|
const shouldReverseCoords = areClockwise && !allowReflexAngles;
|
|
1249
791
|
const guess = shouldReverseCoords ? coords.slice().reverse() : coords;
|
|
1250
792
|
let match;
|
|
1251
793
|
if (rubric.correct.match === "congruent") {
|
|
1252
|
-
const angles =
|
|
794
|
+
const angles = _.map([guess, correct], function (coords) {
|
|
1253
795
|
if (!coords) {
|
|
1254
796
|
return false;
|
|
1255
797
|
}
|
|
1256
798
|
const angle = getClockwiseAngle(coords, allowReflexAngles);
|
|
1257
799
|
return angle;
|
|
1258
800
|
});
|
|
1259
|
-
|
|
1260
|
-
match = perseusCore.approximateEqual(...angles);
|
|
801
|
+
match = approximateEqual(...angles);
|
|
1261
802
|
} else {
|
|
1262
|
-
|
|
1263
|
-
match = perseusCore.approximateDeepEqual(guess[1], correct[1]) && collinear(correct[1], correct[0], guess[0]) && collinear(correct[1], correct[2], guess[2]);
|
|
803
|
+
match = approximateDeepEqual(guess[1], correct[1]) && collinear(correct[1], correct[0], guess[0]) && collinear(correct[1], correct[2], guess[2]);
|
|
1264
804
|
}
|
|
1265
805
|
if (match) {
|
|
1266
806
|
return {
|
|
@@ -1272,11 +812,7 @@ function scoreInteractiveGraph(userInput, rubric) {
|
|
|
1272
812
|
}
|
|
1273
813
|
}
|
|
1274
814
|
}
|
|
1275
|
-
|
|
1276
|
-
// The input wasn't correct, so check if it's a blank input or if it's
|
|
1277
|
-
// actually just wrong
|
|
1278
|
-
if (!hasValue || ___default["default"].isEqual(userInput, rubric.graph)) {
|
|
1279
|
-
// We're where we started.
|
|
815
|
+
if (!hasValue || _.isEqual(userInput, rubric.graph)) {
|
|
1280
816
|
return {
|
|
1281
817
|
type: "invalid",
|
|
1282
818
|
message: null
|
|
@@ -1290,8 +826,6 @@ function scoreInteractiveGraph(userInput, rubric) {
|
|
|
1290
826
|
};
|
|
1291
827
|
}
|
|
1292
828
|
|
|
1293
|
-
// Question state for marker as result of user selected answers.
|
|
1294
|
-
|
|
1295
829
|
function scoreLabelImageMarker(userInput, rubric) {
|
|
1296
830
|
const score = {
|
|
1297
831
|
hasAnswers: false,
|
|
@@ -1302,11 +836,9 @@ function scoreLabelImageMarker(userInput, rubric) {
|
|
|
1302
836
|
}
|
|
1303
837
|
if (rubric.length > 0) {
|
|
1304
838
|
if (userInput && userInput.length === rubric.length) {
|
|
1305
|
-
// All correct answers are selected by the user.
|
|
1306
839
|
score.isCorrect = userInput.every(choice => rubric.includes(choice));
|
|
1307
840
|
}
|
|
1308
841
|
} else if (!userInput || userInput.length === 0) {
|
|
1309
|
-
// Correct as no answers should be selected by the user.
|
|
1310
842
|
score.isCorrect = true;
|
|
1311
843
|
}
|
|
1312
844
|
return score;
|
|
@@ -1321,8 +853,6 @@ function scoreLabelImage(userInput, rubric) {
|
|
|
1321
853
|
}
|
|
1322
854
|
return {
|
|
1323
855
|
type: "points",
|
|
1324
|
-
// Markers with no expected answers are graded as correct if user
|
|
1325
|
-
// makes no answer selection.
|
|
1326
856
|
earned: numCorrect === userInput.markers.length ? 1 : 0,
|
|
1327
857
|
total: 1,
|
|
1328
858
|
message: null
|
|
@@ -1330,7 +860,7 @@ function scoreLabelImage(userInput, rubric) {
|
|
|
1330
860
|
}
|
|
1331
861
|
|
|
1332
862
|
function scoreMatcher(userInput, rubric) {
|
|
1333
|
-
const correct =
|
|
863
|
+
const correct = _.isEqual(userInput.left, rubric.left) && _.isEqual(userInput.right, rubric.right);
|
|
1334
864
|
return {
|
|
1335
865
|
type: "points",
|
|
1336
866
|
earned: correct ? 1 : 0,
|
|
@@ -1342,23 +872,20 @@ function scoreMatcher(userInput, rubric) {
|
|
|
1342
872
|
function scoreMatrix(userInput, rubric) {
|
|
1343
873
|
const solution = rubric.answers;
|
|
1344
874
|
const supplied = userInput.answers;
|
|
1345
|
-
const solutionSize =
|
|
1346
|
-
const suppliedSize =
|
|
875
|
+
const solutionSize = getMatrixSize(solution);
|
|
876
|
+
const suppliedSize = getMatrixSize(supplied);
|
|
1347
877
|
const incorrectSize = solutionSize[0] !== suppliedSize[0] || solutionSize[1] !== suppliedSize[1];
|
|
1348
878
|
const createValidator = KhanAnswerTypes.number.createValidatorFunctional;
|
|
1349
879
|
let message = null;
|
|
1350
880
|
let incorrect = false;
|
|
1351
|
-
|
|
1352
|
-
|
|
881
|
+
_(suppliedSize[0]).times(row => {
|
|
882
|
+
_(suppliedSize[1]).times(col => {
|
|
1353
883
|
if (!incorrectSize) {
|
|
1354
|
-
const validator = createValidator(
|
|
1355
|
-
// @ts-expect-error - TS2345 - Argument of type 'number' is not assignable to parameter of type 'string'.
|
|
1356
|
-
solution[row][col], {
|
|
884
|
+
const validator = createValidator(solution[row][col], {
|
|
1357
885
|
simplify: true
|
|
1358
886
|
});
|
|
1359
887
|
const result = validator(supplied[row][col]);
|
|
1360
888
|
if (result.message) {
|
|
1361
|
-
// @ts-expect-error - TS2322 - Type 'string' is not assignable to type 'null'.
|
|
1362
889
|
message = result.message;
|
|
1363
890
|
}
|
|
1364
891
|
if (!result.correct) {
|
|
@@ -1383,17 +910,9 @@ function scoreMatrix(userInput, rubric) {
|
|
|
1383
910
|
};
|
|
1384
911
|
}
|
|
1385
912
|
|
|
1386
|
-
/**
|
|
1387
|
-
* Checks user input from the matrix widget to see if it is scorable.
|
|
1388
|
-
*
|
|
1389
|
-
* Note: The matrix widget cannot do much validation without the Scoring
|
|
1390
|
-
* Data because of its use of KhanAnswerTypes as a core part of scoring.
|
|
1391
|
-
*
|
|
1392
|
-
* @see `scoreMatrix()` for more details.
|
|
1393
|
-
*/
|
|
1394
913
|
function validateMatrix(userInput) {
|
|
1395
914
|
const supplied = userInput.answers;
|
|
1396
|
-
const suppliedSize =
|
|
915
|
+
const suppliedSize = getMatrixSize(supplied);
|
|
1397
916
|
for (let row = 0; row < suppliedSize[0]; row++) {
|
|
1398
917
|
for (let col = 0; col < suppliedSize[1]; col++) {
|
|
1399
918
|
if (supplied[row][col] == null || supplied[row][col].toString().length === 0) {
|
|
@@ -1412,7 +931,7 @@ function scoreNumberLine(userInput, rubric) {
|
|
|
1412
931
|
const start = rubric.initialX != null ? rubric.initialX : range[0];
|
|
1413
932
|
const startRel = rubric.isInequality ? "ge" : "eq";
|
|
1414
933
|
const correctRel = rubric.correctRel || "eq";
|
|
1415
|
-
const correctPos =
|
|
934
|
+
const correctPos = number.equal(userInput.numLinePosition, rubric.correctX || 0);
|
|
1416
935
|
if (correctPos && correctRel === userInput.rel) {
|
|
1417
936
|
return {
|
|
1418
937
|
type: "points",
|
|
@@ -1422,7 +941,6 @@ function scoreNumberLine(userInput, rubric) {
|
|
|
1422
941
|
};
|
|
1423
942
|
}
|
|
1424
943
|
if (userInput.numLinePosition === start && userInput.rel === startRel) {
|
|
1425
|
-
// We're where we started.
|
|
1426
944
|
return {
|
|
1427
945
|
type: "invalid",
|
|
1428
946
|
message: null
|
|
@@ -1436,17 +954,9 @@ function scoreNumberLine(userInput, rubric) {
|
|
|
1436
954
|
};
|
|
1437
955
|
}
|
|
1438
956
|
|
|
1439
|
-
/**
|
|
1440
|
-
* Checks user input is within the allowed range and not the same as the initial
|
|
1441
|
-
* state.
|
|
1442
|
-
* @param userInput
|
|
1443
|
-
* @see 'scoreNumberLine' for the scoring logic.
|
|
1444
|
-
*/
|
|
1445
957
|
function validateNumberLine(userInput) {
|
|
1446
958
|
const divisionRange = userInput.divisionRange;
|
|
1447
959
|
const outsideAllowedRange = userInput.numDivisions > divisionRange[1] || userInput.numDivisions < divisionRange[0];
|
|
1448
|
-
|
|
1449
|
-
// TODO: I don't think isTickCrtl is a thing anymore
|
|
1450
960
|
if (userInput.isTickCrtl && outsideAllowedRange) {
|
|
1451
961
|
return {
|
|
1452
962
|
type: "invalid",
|
|
@@ -1456,18 +966,6 @@ function validateNumberLine(userInput) {
|
|
|
1456
966
|
return null;
|
|
1457
967
|
}
|
|
1458
968
|
|
|
1459
|
-
/*
|
|
1460
|
-
* In this file, an `expression` is some portion of valid TeX enclosed in
|
|
1461
|
-
* curly brackets.
|
|
1462
|
-
*/
|
|
1463
|
-
|
|
1464
|
-
/*
|
|
1465
|
-
* Find the index at which an expression ends, i.e., has an unmatched
|
|
1466
|
-
* closing curly bracket. This method assumes that we start with a non-open
|
|
1467
|
-
* bracket character and end when we've seen more left than right brackets
|
|
1468
|
-
* (rather than assuming that we start with a bracket character and wait for
|
|
1469
|
-
* bracket equality).
|
|
1470
|
-
*/
|
|
1471
969
|
function findEndpoint(tex, currentIndex) {
|
|
1472
970
|
let bracketDepth = 0;
|
|
1473
971
|
for (let i = currentIndex, len = tex.length; i < len; i++) {
|
|
@@ -1481,22 +979,10 @@ function findEndpoint(tex, currentIndex) {
|
|
|
1481
979
|
return i;
|
|
1482
980
|
}
|
|
1483
981
|
}
|
|
1484
|
-
// If we never see unbalanced curly brackets, default to the
|
|
1485
|
-
// entire string
|
|
1486
982
|
return tex.length;
|
|
1487
983
|
}
|
|
1488
|
-
|
|
1489
|
-
/*
|
|
1490
|
-
* Parses an individual set of curly brackets into TeX.
|
|
1491
|
-
*/
|
|
1492
984
|
function parseNextExpression(tex, currentIndex, handler) {
|
|
1493
|
-
// Find the first '{' and grab subsequent TeX
|
|
1494
|
-
// Ex) tex: '{3}{7}', and we want the '3'
|
|
1495
985
|
const openBracketIndex = tex.indexOf("{", currentIndex);
|
|
1496
|
-
|
|
1497
|
-
// If there is no open bracket, set the endpoint to the end of the string
|
|
1498
|
-
// and the expression to an empty string. This helps ensure we don't
|
|
1499
|
-
// get stuck in an infinite loop when users handtype TeX.
|
|
1500
986
|
if (openBracketIndex === -1) {
|
|
1501
987
|
return {
|
|
1502
988
|
endpoint: tex.length,
|
|
@@ -1504,8 +990,6 @@ function parseNextExpression(tex, currentIndex, handler) {
|
|
|
1504
990
|
};
|
|
1505
991
|
}
|
|
1506
992
|
const nextExpIndex = openBracketIndex + 1;
|
|
1507
|
-
|
|
1508
|
-
// Truncate to only contain remaining TeX
|
|
1509
993
|
const endpoint = findEndpoint(tex, nextExpIndex);
|
|
1510
994
|
const expressionTeX = tex.substring(nextExpIndex, endpoint);
|
|
1511
995
|
const parsedExp = walkTex(expressionTeX, handler);
|
|
@@ -1534,62 +1018,27 @@ function walkTex(tex, handler) {
|
|
|
1534
1018
|
if (!tex) {
|
|
1535
1019
|
return "";
|
|
1536
1020
|
}
|
|
1537
|
-
|
|
1538
|
-
// Ex) tex: '2 \dfrac {3}{7}'
|
|
1539
1021
|
let parsedString = "";
|
|
1540
1022
|
let currentIndex = 0;
|
|
1541
1023
|
let nextFrac = getNextFracIndex(tex, currentIndex);
|
|
1542
|
-
|
|
1543
|
-
// For each \dfrac, find the two expressions (wrapped in {}) and recur
|
|
1544
1024
|
while (nextFrac > -1) {
|
|
1545
|
-
// Gather first fragment, preceding \dfrac
|
|
1546
|
-
// Ex) parsedString: '2 '
|
|
1547
1025
|
parsedString += tex.substring(currentIndex, nextFrac);
|
|
1548
|
-
|
|
1549
|
-
// Remove everything preceding \dfrac, which has been parsed
|
|
1550
1026
|
currentIndex = nextFrac;
|
|
1551
|
-
|
|
1552
|
-
// Parse first expression and move index past it
|
|
1553
|
-
// Ex) firstParsedExpression.expression: '3'
|
|
1554
1027
|
const firstParsedExpression = parseNextExpression(tex, currentIndex, handler);
|
|
1555
1028
|
currentIndex = firstParsedExpression.endpoint + 1;
|
|
1556
|
-
|
|
1557
|
-
// Parse second expression
|
|
1558
|
-
// Ex) secondParsedExpression.expression: '7'
|
|
1559
1029
|
const secondParsedExpression = parseNextExpression(tex, currentIndex, handler);
|
|
1560
1030
|
currentIndex = secondParsedExpression.endpoint + 1;
|
|
1561
|
-
|
|
1562
|
-
// Add expressions to running total of parsed expressions
|
|
1563
1031
|
if (parsedString.length) {
|
|
1564
1032
|
parsedString += " ";
|
|
1565
1033
|
}
|
|
1566
|
-
|
|
1567
|
-
// Apply a custom handler based on the parsed subexpressions
|
|
1568
1034
|
parsedString += handler(firstParsedExpression.expression, secondParsedExpression.expression);
|
|
1569
|
-
|
|
1570
|
-
// Find next DFrac, relative to currentIndex
|
|
1571
1035
|
nextFrac = getNextFracIndex(tex, currentIndex);
|
|
1572
1036
|
}
|
|
1573
|
-
|
|
1574
|
-
// Add remaining TeX, which is \dfrac-free
|
|
1575
1037
|
parsedString += tex.slice(currentIndex);
|
|
1576
1038
|
return parsedString;
|
|
1577
1039
|
}
|
|
1578
|
-
|
|
1579
|
-
/*
|
|
1580
|
-
* Parse a TeX expression into something interpretable by input-number.
|
|
1581
|
-
* The process is concerned with: (1) parsing fractions, i.e., \dfracs; and
|
|
1582
|
-
* (2) removing backslash-escaping from certain characters (right now, only
|
|
1583
|
-
* percent signs).
|
|
1584
|
-
*
|
|
1585
|
-
* The basic algorithm for handling \dfracs splits on \dfracs and then recurs
|
|
1586
|
-
* on the subsequent "expressions", i.e., the {} pairs that follow \dfrac. The
|
|
1587
|
-
* recursion is to allow for nested \dfrac elements.
|
|
1588
|
-
*
|
|
1589
|
-
* Backslash-escapes are removed with a simple search-and-replace.
|
|
1590
|
-
*/
|
|
1591
1040
|
function parseTex(tex) {
|
|
1592
|
-
const handler = function (exp1, exp2) {
|
|
1041
|
+
const handler = function handler(exp1, exp2) {
|
|
1593
1042
|
return exp1 + "/" + exp2;
|
|
1594
1043
|
};
|
|
1595
1044
|
const texWithoutFracs = walkTex(tex, handler);
|
|
@@ -1621,54 +1070,26 @@ const answerFormButtons = [{
|
|
|
1621
1070
|
value: "pi",
|
|
1622
1071
|
content: "\u03C0"
|
|
1623
1072
|
}];
|
|
1624
|
-
|
|
1625
|
-
// This function checks if the user inputted a percent value, parsing
|
|
1626
|
-
// it as a number (and maybe scaling) so that it can be graded.
|
|
1627
|
-
// NOTE(michaelpolyak): Unlike `KhanAnswerTypes.number.percent()` which
|
|
1628
|
-
// can accept several input forms with or without "%", the decision
|
|
1629
|
-
// to parse based on the presence of "%" in the input, is so that we
|
|
1630
|
-
// don't accidently scale the user typed value before grading, CP-930.
|
|
1631
1073
|
function maybeParsePercentInput(inputValue, normalizedAnswerExpected) {
|
|
1632
|
-
// If the input value is not a string ending with "%", then there's
|
|
1633
|
-
// nothing more to do. The value will be graded as inputted by user.
|
|
1634
1074
|
if (!(typeof inputValue === "string" && inputValue.endsWith("%"))) {
|
|
1635
1075
|
return inputValue;
|
|
1636
1076
|
}
|
|
1637
1077
|
const value = parseFloat(inputValue.slice(0, -1));
|
|
1638
|
-
// If the input value stripped of the "%" cannot be parsed as a
|
|
1639
|
-
// number (the slice is not really necessary for parseFloat to work
|
|
1640
|
-
// if the string starts with a number) then return the original
|
|
1641
|
-
// input for grading.
|
|
1642
1078
|
if (isNaN(value)) {
|
|
1643
1079
|
return inputValue;
|
|
1644
1080
|
}
|
|
1645
|
-
|
|
1646
|
-
// Next, if all correct answers are in the range of |0,1| then we
|
|
1647
|
-
// scale the user typed value. We assume this is the correct thing
|
|
1648
|
-
// to do since the input value ends with "%".
|
|
1649
1081
|
if (normalizedAnswerExpected) {
|
|
1650
1082
|
return value / 100;
|
|
1651
1083
|
}
|
|
1652
|
-
|
|
1653
|
-
// Otherwise, we return input value (number) stripped of the "%".
|
|
1654
1084
|
return value;
|
|
1655
1085
|
}
|
|
1656
1086
|
function scoreNumericInput(userInput, rubric) {
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
// if answerForm isn't set on the answer
|
|
1660
|
-
// https://khanacademy.atlassian.net/browse/LC-691
|
|
1661
|
-
.filter(e => e !== "pi");
|
|
1087
|
+
var _matchedAnswer$messag;
|
|
1088
|
+
const defaultAnswerForms = answerFormButtons.map(e => e["value"]).filter(e => e !== "pi");
|
|
1662
1089
|
const createValidator = answer => {
|
|
1090
|
+
var _answer$answerForms;
|
|
1663
1091
|
const stringAnswer = `${answer.value}`;
|
|
1664
|
-
|
|
1665
|
-
// Always validate against the provided answer forms (pi, decimal, etc.)
|
|
1666
|
-
const validatorForms = [...(answer.answerForms ?? [])];
|
|
1667
|
-
|
|
1668
|
-
// When an answer is set to strict, we validate using ONLY
|
|
1669
|
-
// the provided answerForms. If strict is false, or if there
|
|
1670
|
-
// were no provided answer forms, we will include all
|
|
1671
|
-
// of the default answer forms in our validator.
|
|
1092
|
+
const validatorForms = [...((_answer$answerForms = answer.answerForms) != null ? _answer$answerForms : [])];
|
|
1672
1093
|
if (!answer.strict || validatorForms.length === 0) {
|
|
1673
1094
|
validatorForms.push(...defaultAnswerForms);
|
|
1674
1095
|
}
|
|
@@ -1676,18 +1097,12 @@ function scoreNumericInput(userInput, rubric) {
|
|
|
1676
1097
|
message: answer.message,
|
|
1677
1098
|
simplify: answer.status === "correct" ? answer.simplify : "optional",
|
|
1678
1099
|
inexact: true,
|
|
1679
|
-
// TODO(merlob) backfill / delete
|
|
1680
1100
|
maxError: answer.maxError,
|
|
1681
1101
|
forms: validatorForms
|
|
1682
1102
|
});
|
|
1683
1103
|
};
|
|
1684
|
-
|
|
1685
|
-
// We may have received TeX; try to parse it before grading.
|
|
1686
|
-
// If `currentValue` is not TeX, this should be a no-op.
|
|
1687
1104
|
const currentValue = parseTex(userInput.currentValue);
|
|
1688
1105
|
const normalizedAnswerExpected = rubric.answers.filter(answer => answer.status === "correct").every(answer => answer.value != null && Math.abs(answer.value) <= 1);
|
|
1689
|
-
|
|
1690
|
-
// The coefficient is an attribute of the widget
|
|
1691
1106
|
let localValue = currentValue;
|
|
1692
1107
|
if (rubric.coefficient) {
|
|
1693
1108
|
if (!localValue) {
|
|
@@ -1699,21 +1114,16 @@ function scoreNumericInput(userInput, rubric) {
|
|
|
1699
1114
|
const matchedAnswer = rubric.answers.map(answer => {
|
|
1700
1115
|
const validateFn = createValidator(answer);
|
|
1701
1116
|
const score = validateFn(maybeParsePercentInput(localValue, normalizedAnswerExpected));
|
|
1702
|
-
return {
|
|
1703
|
-
...answer,
|
|
1117
|
+
return _extends({}, answer, {
|
|
1704
1118
|
score
|
|
1705
|
-
};
|
|
1119
|
+
});
|
|
1706
1120
|
}).find(answer => {
|
|
1707
|
-
// NOTE: "answer.score.correct" indicates a match via the validate function.
|
|
1708
|
-
// It does NOT indicate that the answer itself is correct.
|
|
1709
1121
|
return answer.score.correct || answer.status === "correct" && answer.score.empty;
|
|
1710
1122
|
});
|
|
1711
|
-
const result = matchedAnswer
|
|
1712
|
-
empty: matchedAnswer
|
|
1713
|
-
correct: matchedAnswer
|
|
1714
|
-
message: matchedAnswer
|
|
1715
|
-
guess: localValue
|
|
1716
|
-
};
|
|
1123
|
+
const result = (matchedAnswer == null ? void 0 : matchedAnswer.status) === "correct" ? matchedAnswer.score : {
|
|
1124
|
+
empty: (matchedAnswer == null ? void 0 : matchedAnswer.status) === "ungraded",
|
|
1125
|
+
correct: (matchedAnswer == null ? void 0 : matchedAnswer.status) === "correct",
|
|
1126
|
+
message: (_matchedAnswer$messag = matchedAnswer == null ? void 0 : matchedAnswer.message) != null ? _matchedAnswer$messag : null};
|
|
1717
1127
|
if (result.empty) {
|
|
1718
1128
|
return {
|
|
1719
1129
|
type: "invalid",
|
|
@@ -1729,7 +1139,7 @@ function scoreNumericInput(userInput, rubric) {
|
|
|
1729
1139
|
}
|
|
1730
1140
|
|
|
1731
1141
|
function scoreOrderer(userInput, rubric) {
|
|
1732
|
-
const correct =
|
|
1142
|
+
const correct = _.isEqual(userInput.current, rubric.correctOptions.map(option => option.content));
|
|
1733
1143
|
return {
|
|
1734
1144
|
type: "points",
|
|
1735
1145
|
earned: correct ? 1 : 0,
|
|
@@ -1738,12 +1148,6 @@ function scoreOrderer(userInput, rubric) {
|
|
|
1738
1148
|
};
|
|
1739
1149
|
}
|
|
1740
1150
|
|
|
1741
|
-
/**
|
|
1742
|
-
* Checks user input from the orderer widget to see if the user has started
|
|
1743
|
-
* ordering the options, making the widget scorable.
|
|
1744
|
-
* @param userInput
|
|
1745
|
-
* @see `scoreOrderer` for more details.
|
|
1746
|
-
*/
|
|
1747
1151
|
function validateOrderer(userInput) {
|
|
1748
1152
|
if (userInput.current.length === 0) {
|
|
1749
1153
|
return {
|
|
@@ -1757,20 +1161,14 @@ function validateOrderer(userInput) {
|
|
|
1757
1161
|
function scorePlotter(userInput, rubric) {
|
|
1758
1162
|
return {
|
|
1759
1163
|
type: "points",
|
|
1760
|
-
earned:
|
|
1164
|
+
earned: approximateDeepEqual(userInput, rubric.correct) ? 1 : 0,
|
|
1761
1165
|
total: 1,
|
|
1762
1166
|
message: null
|
|
1763
1167
|
};
|
|
1764
1168
|
}
|
|
1765
1169
|
|
|
1766
|
-
/**
|
|
1767
|
-
* Checks user input to confirm it is not the same as the starting values for the graph.
|
|
1768
|
-
* This means the user has modified the graph, and the question can be scored.
|
|
1769
|
-
*
|
|
1770
|
-
* @see 'scorePlotter' for more details on scoring.
|
|
1771
|
-
*/
|
|
1772
1170
|
function validatePlotter(userInput, validationData) {
|
|
1773
|
-
if (
|
|
1171
|
+
if (approximateDeepEqual(userInput, validationData.starting)) {
|
|
1774
1172
|
return {
|
|
1775
1173
|
type: "invalid",
|
|
1776
1174
|
message: null
|
|
@@ -1791,7 +1189,6 @@ function scoreRadio(userInput, rubric) {
|
|
|
1791
1189
|
type: "invalid",
|
|
1792
1190
|
message: ErrorCodes.CHOOSE_CORRECT_NUM_ERROR
|
|
1793
1191
|
};
|
|
1794
|
-
// If NOTA and some other answer are checked, ...
|
|
1795
1192
|
}
|
|
1796
1193
|
const noneOfTheAboveSelected = rubric.choices.some((choice, index) => choice.isNoneOfTheAbove && userInput.choicesSelected[index]);
|
|
1797
1194
|
if (noneOfTheAboveSelected && numSelected > 1) {
|
|
@@ -1819,14 +1216,6 @@ function scoreRadio(userInput, rubric) {
|
|
|
1819
1216
|
};
|
|
1820
1217
|
}
|
|
1821
1218
|
|
|
1822
|
-
/**
|
|
1823
|
-
* Checks if the user has selected at least one option. Additional validation
|
|
1824
|
-
* is done in scoreRadio to check if the number of selected options is correct
|
|
1825
|
-
* and if the user has selected both a correct option and the "none of the above"
|
|
1826
|
-
* option.
|
|
1827
|
-
* @param userInput
|
|
1828
|
-
* @see `scoreRadio` for the additional validation logic and the scoring logic.
|
|
1829
|
-
*/
|
|
1830
1219
|
function validateRadio(userInput) {
|
|
1831
1220
|
const numSelected = userInput.choicesSelected.reduce((sum, selected) => {
|
|
1832
1221
|
return sum + (selected ? 1 : 0);
|
|
@@ -1841,7 +1230,7 @@ function validateRadio(userInput) {
|
|
|
1841
1230
|
}
|
|
1842
1231
|
|
|
1843
1232
|
function scoreSorter(userInput, rubric) {
|
|
1844
|
-
const correct =
|
|
1233
|
+
const correct = approximateDeepEqual(userInput.options, rubric.correct);
|
|
1845
1234
|
return {
|
|
1846
1235
|
type: "points",
|
|
1847
1236
|
earned: correct ? 1 : 0,
|
|
@@ -1850,20 +1239,7 @@ function scoreSorter(userInput, rubric) {
|
|
|
1850
1239
|
};
|
|
1851
1240
|
}
|
|
1852
1241
|
|
|
1853
|
-
/**
|
|
1854
|
-
* Checks user input for the sorter widget to ensure that the user has made
|
|
1855
|
-
* changes before attempting to score the widget.
|
|
1856
|
-
* @param userInput
|
|
1857
|
-
* @see 'scoreSorter' in 'packages/perseus/src/widgets/sorter/score-sorter.ts'
|
|
1858
|
-
* for more details on how the sorter widget is scored.
|
|
1859
|
-
*/
|
|
1860
1242
|
function validateSorter(userInput) {
|
|
1861
|
-
// If the sorter widget hasn't been changed yet, we treat it as "empty" which
|
|
1862
|
-
// prevents the "Check" button from becoming active. We want the user
|
|
1863
|
-
// to make a change before trying to move forward. This makes an
|
|
1864
|
-
// assumption that the initial order isn't the correct order! However,
|
|
1865
|
-
// this should be rare if it happens, and interacting with the list
|
|
1866
|
-
// will enable the button, so they won't be locked out of progressing.
|
|
1867
1243
|
if (!userInput.changed) {
|
|
1868
1244
|
return {
|
|
1869
1245
|
type: "invalid",
|
|
@@ -1873,15 +1249,8 @@ function validateSorter(userInput) {
|
|
|
1873
1249
|
return null;
|
|
1874
1250
|
}
|
|
1875
1251
|
|
|
1876
|
-
|
|
1877
|
-
* Filters the given table (modelled as a 2D array) to remove any rows that are
|
|
1878
|
-
* completely empty.
|
|
1879
|
-
*
|
|
1880
|
-
* @returns A new table with only non-empty rows.
|
|
1881
|
-
*/
|
|
1882
|
-
const filterNonEmpty = function (table) {
|
|
1252
|
+
const filterNonEmpty = function filterNonEmpty(table) {
|
|
1883
1253
|
return table.filter(function (row) {
|
|
1884
|
-
// Return only rows that are non-empty.
|
|
1885
1254
|
return row.some(cell => cell);
|
|
1886
1255
|
});
|
|
1887
1256
|
};
|
|
@@ -1986,10 +1355,6 @@ function scoreInputNumber(userInput, rubric) {
|
|
|
1986
1355
|
if (rubric.answerType == null) {
|
|
1987
1356
|
rubric.answerType = "number";
|
|
1988
1357
|
}
|
|
1989
|
-
|
|
1990
|
-
// note(matthewc): this will get immediately parsed again by
|
|
1991
|
-
// `KhanAnswerTypes.number.convertToPredicate`, but a string is
|
|
1992
|
-
// expected here
|
|
1993
1358
|
const stringValue = `${rubric.value}`;
|
|
1994
1359
|
const val = KhanAnswerTypes.number.createValidatorFunctional(stringValue, {
|
|
1995
1360
|
simplify: rubric.simplify,
|
|
@@ -1997,9 +1362,6 @@ function scoreInputNumber(userInput, rubric) {
|
|
|
1997
1362
|
maxError: rubric.maxError,
|
|
1998
1363
|
forms: inputNumberAnswerTypes[rubric.answerType].forms
|
|
1999
1364
|
});
|
|
2000
|
-
|
|
2001
|
-
// We may have received TeX; try to parse it before grading.
|
|
2002
|
-
// If `currentValue` is not TeX, this should be a no-op.
|
|
2003
1365
|
const currentValue = parseTex(userInput.currentValue);
|
|
2004
1366
|
const result = val(currentValue);
|
|
2005
1367
|
if (result.empty) {
|
|
@@ -2016,16 +1378,7 @@ function scoreInputNumber(userInput, rubric) {
|
|
|
2016
1378
|
};
|
|
2017
1379
|
}
|
|
2018
1380
|
|
|
2019
|
-
|
|
2020
|
-
* Several widgets don't have "right"/"wrong" scoring logic,
|
|
2021
|
-
* so this just says to move on past those widgets
|
|
2022
|
-
*
|
|
2023
|
-
* TODO(LEMS-2543) widgets that use this probably shouldn't have any
|
|
2024
|
-
* scoring logic and the thing scoring an exercise
|
|
2025
|
-
* should just know to skip these
|
|
2026
|
-
*/
|
|
2027
|
-
function scoreNoop() {
|
|
2028
|
-
let points = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 0;
|
|
1381
|
+
function scoreNoop(points = 0) {
|
|
2029
1382
|
return {
|
|
2030
1383
|
type: "points",
|
|
2031
1384
|
earned: points,
|
|
@@ -2034,32 +1387,21 @@ function scoreNoop() {
|
|
|
2034
1387
|
};
|
|
2035
1388
|
}
|
|
2036
1389
|
|
|
2037
|
-
// The `group` widget is basically a widget hosting a full Perseus system in
|
|
2038
|
-
// it. As such, scoring a group means scoring all widgets it contains.
|
|
2039
1390
|
function scoreGroup(userInput, rubric, locale) {
|
|
2040
1391
|
const scores = scoreWidgetsFunctional(rubric.widgets, Object.keys(rubric.widgets), userInput, locale);
|
|
2041
1392
|
return flattenScores(scores);
|
|
2042
1393
|
}
|
|
2043
1394
|
|
|
2044
|
-
|
|
2045
|
-
* Checks the given user input to see if any answerable widgets have not been
|
|
2046
|
-
* "filled in" (ie. if they're empty). Another way to think about this
|
|
2047
|
-
* function is that its a check to see if we can score the provided input.
|
|
2048
|
-
*/
|
|
2049
|
-
function emptyWidgetsFunctional(widgets,
|
|
2050
|
-
// This is a port of old code, I'm not sure why
|
|
2051
|
-
// we need widgetIds vs the keys of the widgets object
|
|
2052
|
-
widgetIds, userInputMap, locale) {
|
|
1395
|
+
function emptyWidgetsFunctional(widgets, widgetIds, userInputMap, locale) {
|
|
2053
1396
|
return widgetIds.filter(id => {
|
|
2054
1397
|
const widget = widgets[id];
|
|
2055
1398
|
if (!widget || widget.static === true) {
|
|
2056
|
-
// Static widgets shouldn't count as empty
|
|
2057
1399
|
return false;
|
|
2058
1400
|
}
|
|
2059
1401
|
const validator = getWidgetValidator(widget.type);
|
|
2060
1402
|
const userInput = userInputMap[id];
|
|
2061
1403
|
const validationData = widget.options;
|
|
2062
|
-
const score = validator
|
|
1404
|
+
const score = validator == null ? void 0 : validator(userInput, validationData, locale);
|
|
2063
1405
|
if (score) {
|
|
2064
1406
|
return scoreIsEmpty(score);
|
|
2065
1407
|
}
|
|
@@ -2085,7 +1427,6 @@ function validateLabelImage(userInput) {
|
|
|
2085
1427
|
numAnswered++;
|
|
2086
1428
|
}
|
|
2087
1429
|
}
|
|
2088
|
-
// We expect all question markers to be answered before grading.
|
|
2089
1430
|
if (numAnswered !== userInput.markers.length) {
|
|
2090
1431
|
return {
|
|
2091
1432
|
type: "invalid",
|
|
@@ -2126,10 +1467,12 @@ function registerWidget(type, scorer, validator) {
|
|
|
2126
1467
|
};
|
|
2127
1468
|
}
|
|
2128
1469
|
const getWidgetValidator = name => {
|
|
2129
|
-
|
|
1470
|
+
var _widgets$name$validat, _widgets$name;
|
|
1471
|
+
return (_widgets$name$validat = (_widgets$name = widgets[name]) == null ? void 0 : _widgets$name.validator) != null ? _widgets$name$validat : null;
|
|
2130
1472
|
};
|
|
2131
1473
|
const getWidgetScorer = name => {
|
|
2132
|
-
|
|
1474
|
+
var _widgets$name$scorer, _widgets$name2;
|
|
1475
|
+
return (_widgets$name$scorer = (_widgets$name2 = widgets[name]) == null ? void 0 : _widgets$name2.scorer) != null ? _widgets$name$scorer : null;
|
|
2133
1476
|
};
|
|
2134
1477
|
registerWidget("categorizer", scoreCategorizer, validateCategorizer);
|
|
2135
1478
|
registerWidget("cs-program", scoreCSProgram);
|
|
@@ -2169,40 +1512,13 @@ const noScore = {
|
|
|
2169
1512
|
total: 0,
|
|
2170
1513
|
message: null
|
|
2171
1514
|
};
|
|
2172
|
-
|
|
2173
|
-
/**
|
|
2174
|
-
* If a widget says that it is empty once it is graded.
|
|
2175
|
-
* Trying to encapsulate references to the score format.
|
|
2176
|
-
*/
|
|
2177
1515
|
function scoreIsEmpty(score) {
|
|
2178
|
-
// HACK(benkomalo): ugh. this isn't great; the Perseus score objects
|
|
2179
|
-
// overload the type "invalid" for what should probably be three
|
|
2180
|
-
// distinct cases:
|
|
2181
|
-
// - truly empty or not fully filled out
|
|
2182
|
-
// - invalid or malformed inputs
|
|
2183
|
-
// - "almost correct" like inputs where the widget wants to give
|
|
2184
|
-
// feedback (e.g. a fraction needs to be reduced, or `pi` should
|
|
2185
|
-
// be used instead of 3.14)
|
|
2186
|
-
//
|
|
2187
|
-
// Unfortunately the coercion happens all over the place, as these
|
|
2188
|
-
// Perseus style score objects are created *everywhere* (basically
|
|
2189
|
-
// in every widget), so it's hard to change now. We assume that
|
|
2190
|
-
// anything with a "message" is not truly empty, and one of the
|
|
2191
|
-
// latter two cases for now.
|
|
2192
1516
|
return score.type === "invalid" && (!score.message || score.message.length === 0);
|
|
2193
1517
|
}
|
|
2194
|
-
|
|
2195
|
-
/**
|
|
2196
|
-
* Combine two score objects.
|
|
2197
|
-
*
|
|
2198
|
-
* Given two score objects for two different widgets, combine them so that
|
|
2199
|
-
* if one is wrong, the total score is wrong, etc.
|
|
2200
|
-
*/
|
|
2201
1518
|
function combineScores(scoreA, scoreB) {
|
|
2202
1519
|
let message;
|
|
2203
1520
|
if (scoreA.type === "points" && scoreB.type === "points") {
|
|
2204
1521
|
if (scoreA.message && scoreB.message && scoreA.message !== scoreB.message) {
|
|
2205
|
-
// TODO(alpert): Figure out how to combine messages usefully
|
|
2206
1522
|
message = null;
|
|
2207
1523
|
} else {
|
|
2208
1524
|
message = scoreA.message || scoreB.message;
|
|
@@ -2222,7 +1538,6 @@ function combineScores(scoreA, scoreB) {
|
|
|
2222
1538
|
}
|
|
2223
1539
|
if (scoreA.type === "invalid" && scoreB.type === "invalid") {
|
|
2224
1540
|
if (scoreA.message && scoreB.message && scoreA.message !== scoreB.message) {
|
|
2225
|
-
// TODO(alpert): Figure out how to combine messages usefully
|
|
2226
1541
|
message = null;
|
|
2227
1542
|
} else {
|
|
2228
1543
|
message = scoreA.message || scoreB.message;
|
|
@@ -2232,12 +1547,7 @@ function combineScores(scoreA, scoreB) {
|
|
|
2232
1547
|
message: message
|
|
2233
1548
|
};
|
|
2234
1549
|
}
|
|
2235
|
-
|
|
2236
|
-
/**
|
|
2237
|
-
* The above checks cover all combinations of score type, so if we get here
|
|
2238
|
-
* then something is amiss with our inputs.
|
|
2239
|
-
*/
|
|
2240
|
-
throw new perseusCore.PerseusError("PerseusScore with unknown type encountered", perseusCore.Errors.InvalidInput, {
|
|
1550
|
+
throw new PerseusError("PerseusScore with unknown type encountered", Errors.InvalidInput, {
|
|
2241
1551
|
metadata: {
|
|
2242
1552
|
scoreA: JSON.stringify(scoreA),
|
|
2243
1553
|
scoreB: JSON.stringify(scoreB)
|
|
@@ -2247,38 +1557,22 @@ function combineScores(scoreA, scoreB) {
|
|
|
2247
1557
|
function flattenScores(widgetScoreMap) {
|
|
2248
1558
|
return Object.values(widgetScoreMap).reduce(combineScores, noScore);
|
|
2249
1559
|
}
|
|
2250
|
-
|
|
2251
|
-
/**
|
|
2252
|
-
* score a Perseus item
|
|
2253
|
-
*
|
|
2254
|
-
* @param perseusRenderData - the full answer data, includes the correct answer
|
|
2255
|
-
* @param userInputMap - the user's input for each widget, mapped by ID
|
|
2256
|
-
* @param locale - string locale for math parsing ("de" 1.000,00 vs "en" 1,000.00)
|
|
2257
|
-
*/
|
|
2258
1560
|
function scorePerseusItem(perseusRenderData, userInputMap, locale) {
|
|
2259
|
-
|
|
2260
|
-
// widget data for widgets that are not in PerseusRenderer.content,
|
|
2261
|
-
// so this checks that the widgets are being used before scoring them
|
|
2262
|
-
const usedWidgetIds = perseusCore.getWidgetIdsFromContent(perseusRenderData.content);
|
|
1561
|
+
const usedWidgetIds = getWidgetIdsFromContent(perseusRenderData.content);
|
|
2263
1562
|
const scores = scoreWidgetsFunctional(perseusRenderData.widgets, usedWidgetIds, userInputMap, locale);
|
|
2264
1563
|
return flattenScores(scores);
|
|
2265
1564
|
}
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
function scoreWidgetsFunctional(widgets,
|
|
2269
|
-
// This is a port of old code, I'm not sure why
|
|
2270
|
-
// we need widgetIds vs the keys of the widgets object
|
|
2271
|
-
widgetIds, userInputMap, locale) {
|
|
2272
|
-
const upgradedWidgets = perseusCore.getUpgradedWidgetOptions(widgets);
|
|
1565
|
+
function scoreWidgetsFunctional(widgets, widgetIds, userInputMap, locale) {
|
|
1566
|
+
const upgradedWidgets = getUpgradedWidgetOptions(widgets);
|
|
2273
1567
|
const gradedWidgetIds = widgetIds.filter(id => {
|
|
2274
1568
|
const props = upgradedWidgets[id];
|
|
2275
|
-
const widgetIsGraded = props
|
|
2276
|
-
const widgetIsStatic = !!props
|
|
2277
|
-
// Ungraded widgets or widgets set to static shouldn't be graded.
|
|
1569
|
+
const widgetIsGraded = (props == null ? void 0 : props.graded) == null || props.graded;
|
|
1570
|
+
const widgetIsStatic = !!(props != null && props.static);
|
|
2278
1571
|
return widgetIsGraded && !widgetIsStatic;
|
|
2279
1572
|
});
|
|
2280
1573
|
const widgetScores = {};
|
|
2281
1574
|
gradedWidgetIds.forEach(id => {
|
|
1575
|
+
var _validator;
|
|
2282
1576
|
const widget = upgradedWidgets[id];
|
|
2283
1577
|
if (!widget) {
|
|
2284
1578
|
return;
|
|
@@ -2286,10 +1580,7 @@ widgetIds, userInputMap, locale) {
|
|
|
2286
1580
|
const userInput = userInputMap[id];
|
|
2287
1581
|
const validator = getWidgetValidator(widget.type);
|
|
2288
1582
|
const scorer = getWidgetScorer(widget.type);
|
|
2289
|
-
|
|
2290
|
-
// We do validation (empty checks) first and then scoring. If
|
|
2291
|
-
// validation fails, it's result is itself a PerseusScore.
|
|
2292
|
-
const score = validator?.(userInput, widget.options, locale) ?? scorer?.(userInput, widget.options, locale);
|
|
1583
|
+
const score = (_validator = validator == null ? void 0 : validator(userInput, widget.options, locale)) != null ? _validator : scorer == null ? void 0 : scorer(userInput, widget.options, locale);
|
|
2293
1584
|
if (score != null) {
|
|
2294
1585
|
widgetScores[id] = score;
|
|
2295
1586
|
}
|
|
@@ -2297,43 +1588,5 @@ widgetIds, userInputMap, locale) {
|
|
|
2297
1588
|
return widgetScores;
|
|
2298
1589
|
}
|
|
2299
1590
|
|
|
2300
|
-
|
|
2301
|
-
exports.KhanAnswerTypes = KhanAnswerTypes;
|
|
2302
|
-
exports.emptyWidgetsFunctional = emptyWidgetsFunctional;
|
|
2303
|
-
exports.flattenScores = flattenScores;
|
|
2304
|
-
exports.getWidgetScorer = getWidgetScorer;
|
|
2305
|
-
exports.getWidgetValidator = getWidgetValidator;
|
|
2306
|
-
exports.inputNumberAnswerTypes = inputNumberAnswerTypes;
|
|
2307
|
-
exports.registerWidget = registerWidget;
|
|
2308
|
-
exports.scoreCSProgram = scoreCSProgram;
|
|
2309
|
-
exports.scoreCategorizer = scoreCategorizer;
|
|
2310
|
-
exports.scoreDropdown = scoreDropdown;
|
|
2311
|
-
exports.scoreExpression = scoreExpression;
|
|
2312
|
-
exports.scoreGrapher = scoreGrapher;
|
|
2313
|
-
exports.scoreIframe = scoreIframe;
|
|
2314
|
-
exports.scoreInputNumber = scoreInputNumber;
|
|
2315
|
-
exports.scoreInteractiveGraph = scoreInteractiveGraph;
|
|
2316
|
-
exports.scoreLabelImage = scoreLabelImage;
|
|
2317
|
-
exports.scoreLabelImageMarker = scoreLabelImageMarker;
|
|
2318
|
-
exports.scoreMatcher = scoreMatcher;
|
|
2319
|
-
exports.scoreMatrix = scoreMatrix;
|
|
2320
|
-
exports.scoreNumberLine = scoreNumberLine;
|
|
2321
|
-
exports.scoreNumericInput = scoreNumericInput;
|
|
2322
|
-
exports.scoreOrderer = scoreOrderer;
|
|
2323
|
-
exports.scorePerseusItem = scorePerseusItem;
|
|
2324
|
-
exports.scorePlotter = scorePlotter;
|
|
2325
|
-
exports.scoreRadio = scoreRadio;
|
|
2326
|
-
exports.scoreSorter = scoreSorter;
|
|
2327
|
-
exports.scoreTable = scoreTable;
|
|
2328
|
-
exports.scoreWidgetsFunctional = scoreWidgetsFunctional;
|
|
2329
|
-
exports.validateCategorizer = validateCategorizer;
|
|
2330
|
-
exports.validateDropdown = validateDropdown;
|
|
2331
|
-
exports.validateExpression = validateExpression;
|
|
2332
|
-
exports.validateMatrix = validateMatrix;
|
|
2333
|
-
exports.validateNumberLine = validateNumberLine;
|
|
2334
|
-
exports.validateOrderer = validateOrderer;
|
|
2335
|
-
exports.validatePlotter = validatePlotter;
|
|
2336
|
-
exports.validateRadio = validateRadio;
|
|
2337
|
-
exports.validateSorter = validateSorter;
|
|
2338
|
-
exports.validateTable = validateTable;
|
|
1591
|
+
export { ErrorCodes, KhanAnswerTypes, emptyWidgetsFunctional, flattenScores, getWidgetScorer, getWidgetValidator, inputNumberAnswerTypes, registerWidget, scoreCSProgram, scoreCategorizer, scoreDropdown, scoreExpression, scoreGrapher, scoreIframe, scoreInputNumber, scoreInteractiveGraph, scoreLabelImage, scoreLabelImageMarker, scoreMatcher, scoreMatrix, scoreNumberLine, scoreNumericInput, scoreOrderer, scorePerseusItem, scorePlotter, scoreRadio, scoreSorter, scoreTable, scoreWidgetsFunctional, validateCategorizer, validateDropdown, validateExpression, validateMatrix, validateNumberLine, validateOrderer, validatePlotter, validateRadio, validateSorter, validateTable };
|
|
2339
1592
|
//# sourceMappingURL=index.js.map
|