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