@khanacademy/perseus-score 3.0.0 → 4.0.1
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 +2296 -0
- package/dist/es/index.js.map +1 -0
- package/dist/index.js +895 -127
- 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,39 +507,69 @@ const KhanAnswerTypes = {
|
|
|
283
507
|
message: null,
|
|
284
508
|
guess: guess
|
|
285
509
|
};
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
510
|
+
|
|
511
|
+
// Iterate over all the acceptable forms
|
|
512
|
+
// and exit if one of the answers is correct.
|
|
513
|
+
//
|
|
514
|
+
// HACK: This function is a bug fix from LEMS-2962;
|
|
515
|
+
// after a transition from jQuery's `each` to JS's `forEach`
|
|
516
|
+
// we realized this code was banking on the ability to:
|
|
517
|
+
// 1. exit early from nested loops (can be tricky outside of functions)
|
|
518
|
+
// 2. mutate external variables (score)
|
|
519
|
+
// Could probably be refactored to be a pure function that
|
|
520
|
+
// returns a score, but this code is poorly tested and prone to break.
|
|
521
|
+
const findCorrectAnswer = () => {
|
|
522
|
+
// WARNING: Don't use `forEach` without additional refactoring
|
|
523
|
+
// because code needs to be able to exit early
|
|
524
|
+
for (const form of acceptableForms) {
|
|
525
|
+
const transformed = forms[form](guess);
|
|
526
|
+
for (let j = 0, l = transformed.length; j < l; j++) {
|
|
527
|
+
const val = transformed[j].value;
|
|
528
|
+
const exact = transformed[j].exact;
|
|
529
|
+
const piApprox = transformed[j].piApprox;
|
|
530
|
+
// If a string was returned, and it exactly matches,
|
|
531
|
+
// return true
|
|
532
|
+
if (predicate(val, options.maxError)) {
|
|
533
|
+
// If the exact correct number was returned,
|
|
534
|
+
// return true
|
|
535
|
+
if (exact || options.simplify === "optional") {
|
|
536
|
+
score.correct = true;
|
|
537
|
+
score.message = options.message || null;
|
|
538
|
+
// If the answer is correct, don't say it's
|
|
539
|
+
// empty. This happens, for example, with the
|
|
540
|
+
// coefficient type where guess === "" but is
|
|
541
|
+
// interpreted as "1" which is correct.
|
|
542
|
+
score.empty = false;
|
|
543
|
+
} else if (form === "percent") {
|
|
544
|
+
// Otherwise, an error was returned
|
|
302
545
|
score.empty = true;
|
|
546
|
+
score.message = ErrorCodes.MISSING_PERCENT_ERROR;
|
|
547
|
+
} else {
|
|
548
|
+
if (options.simplify !== "enforced") {
|
|
549
|
+
score.empty = true;
|
|
550
|
+
}
|
|
551
|
+
score.message = ErrorCodes.NEEDS_TO_BE_SIMPLIFIED_ERROR;
|
|
303
552
|
}
|
|
304
|
-
|
|
553
|
+
// HACK: The return false below stops the looping of the
|
|
554
|
+
// callback since predicate check succeeded.
|
|
555
|
+
// No more forms to look to verify the user guess.
|
|
556
|
+
return false;
|
|
557
|
+
}
|
|
558
|
+
if (piApprox && predicate(val, Math.abs(val * 0.001))) {
|
|
559
|
+
score.empty = true;
|
|
560
|
+
score.message = ErrorCodes.APPROXIMATED_PI_ERROR;
|
|
305
561
|
}
|
|
306
|
-
return false;
|
|
307
|
-
}
|
|
308
|
-
if (piApprox && predicate(val, Math.abs(val * 0.001))) {
|
|
309
|
-
score.empty = true;
|
|
310
|
-
score.message = ErrorCodes.APPROXIMATED_PI_ERROR;
|
|
311
562
|
}
|
|
312
563
|
}
|
|
313
|
-
}
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
// mutates `score`
|
|
567
|
+
findCorrectAnswer();
|
|
314
568
|
if (score.correct === false) {
|
|
315
569
|
let interpretedGuess = false;
|
|
316
|
-
|
|
317
|
-
const anyAreNaN =
|
|
318
|
-
return t.value != null && !
|
|
570
|
+
___default.default.each(forms, function (form) {
|
|
571
|
+
const anyAreNaN = ___default.default.any(form(guess), function (t) {
|
|
572
|
+
return t.value != null && !___default.default.isNaN(t.value);
|
|
319
573
|
});
|
|
320
574
|
if (anyAreNaN) {
|
|
321
575
|
interpretedGuess = true;
|
|
@@ -331,26 +585,83 @@ const KhanAnswerTypes = {
|
|
|
331
585
|
};
|
|
332
586
|
}
|
|
333
587
|
},
|
|
588
|
+
/*
|
|
589
|
+
* number answer type
|
|
590
|
+
*
|
|
591
|
+
* wraps the predicate answer type to performs simple number-based checking
|
|
592
|
+
* of a solution
|
|
593
|
+
*/
|
|
334
594
|
number: {
|
|
335
595
|
convertToPredicate: function (correctAnswer, options) {
|
|
336
596
|
const correctFloat = parseFloat(correctAnswer);
|
|
337
597
|
return [function (guess, maxError) {
|
|
338
598
|
return Math.abs(guess - correctFloat) < maxError;
|
|
339
|
-
},
|
|
599
|
+
}, {
|
|
600
|
+
...options,
|
|
340
601
|
type: "predicate"
|
|
341
|
-
}
|
|
602
|
+
}];
|
|
342
603
|
},
|
|
343
604
|
createValidatorFunctional: function (correctAnswer, options) {
|
|
344
605
|
return KhanAnswerTypes.predicate.createValidatorFunctional(...KhanAnswerTypes.number.convertToPredicate(correctAnswer, options));
|
|
345
606
|
}
|
|
346
607
|
},
|
|
608
|
+
/*
|
|
609
|
+
* The expression answer type parses a given expression or equation
|
|
610
|
+
* and semantically compares it to the solution. In addition, instant
|
|
611
|
+
* feedback is provided by rendering the last answer that fully parsed.
|
|
612
|
+
*
|
|
613
|
+
* Parsing options:
|
|
614
|
+
* functions (e.g. data-functions="f g h")
|
|
615
|
+
* A space or comma separated list of single-letter variables that
|
|
616
|
+
* should be interpreted as functions. Case sensitive. "e" and "i"
|
|
617
|
+
* are reserved.
|
|
618
|
+
*
|
|
619
|
+
* no functions specified: f(x+y) == fx + fy
|
|
620
|
+
* with "f" as a function: f(x+y) != fx + fy
|
|
621
|
+
*
|
|
622
|
+
* Comparison options:
|
|
623
|
+
* same-form (e.g. data-same-form)
|
|
624
|
+
* If present, the answer must match the solution's structure in
|
|
625
|
+
* addition to evaluating the same. Commutativity and excess negation
|
|
626
|
+
* are ignored, but all other changes will trigger a rejection. Useful
|
|
627
|
+
* for requiring a particular form of an equation, or if the answer
|
|
628
|
+
* must be factored.
|
|
629
|
+
*
|
|
630
|
+
* example question: Factor x^2 + x - 2
|
|
631
|
+
* example solution: (x-1)(x+2)
|
|
632
|
+
* accepted answers: (x-1)(x+2), (x+2)(x-1), ---(-x-2)(-1+x), etc.
|
|
633
|
+
* rejected answers: x^2+x-2, x*x+x-2, x(x+1)-2, (x-1)(x+2)^1, etc.
|
|
634
|
+
* rejection message: Your answer is not in the correct form
|
|
635
|
+
*
|
|
636
|
+
* simplify (e.g. data-simplify)
|
|
637
|
+
* If present, the answer must be fully expanded and simplified. Use
|
|
638
|
+
* carefully - simplification is hard and there may be bugs, or you
|
|
639
|
+
* might not agree on the definition of "simplified" used. You will
|
|
640
|
+
* get an error if the provided solution is not itself fully expanded
|
|
641
|
+
* and simplified.
|
|
642
|
+
*
|
|
643
|
+
* example question: Simplify ((n*x^5)^5) / (n^(-2)*x^2)^-3
|
|
644
|
+
* example solution: x^31 / n
|
|
645
|
+
* accepted answers: x^31 / n, x^31 / n^1, x^31 * n^(-1), etc.
|
|
646
|
+
* rejected answers: (x^25 * n^5) / (x^(-6) * n^6), etc.
|
|
647
|
+
* rejection message: Your answer is not fully expanded and simplified
|
|
648
|
+
*
|
|
649
|
+
* Rendering options:
|
|
650
|
+
* times (e.g. data-times)
|
|
651
|
+
* If present, explicit multiplication (such as between numbers) will
|
|
652
|
+
* be rendered with a cross/x symbol (TeX: \times) instead of the usual
|
|
653
|
+
* center dot (TeX: \cdot).
|
|
654
|
+
*
|
|
655
|
+
* normal rendering: 2 * 3^x -> 2 \cdot 3^{x}
|
|
656
|
+
* but with "times": 2 * 3^x -> 2 \times 3^{x}
|
|
657
|
+
*/
|
|
347
658
|
expression: {
|
|
348
659
|
parseSolution: function (solutionString, options) {
|
|
349
|
-
let solution =
|
|
660
|
+
let solution = KAS__namespace.parse(solutionString, options);
|
|
350
661
|
if (!solution.parsed) {
|
|
351
|
-
throw new PerseusError("The provided solution (" + solutionString + ") didn't parse.", Errors.InvalidInput);
|
|
662
|
+
throw new perseusCore.PerseusError("The provided solution (" + solutionString + ") didn't parse.", perseusCore.Errors.InvalidInput);
|
|
352
663
|
} else if (options.simplified && !solution.expr.isSimplified()) {
|
|
353
|
-
throw new PerseusError("The provided solution (" + solutionString + ") isn't fully expanded and simplified.", Errors.InvalidInput);
|
|
664
|
+
throw new perseusCore.PerseusError("The provided solution (" + solutionString + ") isn't fully expanded and simplified.", perseusCore.Errors.InvalidInput);
|
|
354
665
|
} else {
|
|
355
666
|
solution = solution.expr;
|
|
356
667
|
}
|
|
@@ -363,37 +674,80 @@ const KhanAnswerTypes = {
|
|
|
363
674
|
correct: false,
|
|
364
675
|
message: null,
|
|
365
676
|
guess: guess,
|
|
677
|
+
// Setting `ungraded` to true indicates that if the
|
|
678
|
+
// guess doesn't match any of the solutions, the guess
|
|
679
|
+
// shouldn't be marked as incorrect; instead, `message`
|
|
680
|
+
// should be shown to the user. This is different from
|
|
681
|
+
// setting `empty` to true, since the behavior of `empty`
|
|
682
|
+
// is that `message` only will be shown if the guess is
|
|
683
|
+
// graded as empty for every solution.
|
|
366
684
|
ungraded: false
|
|
367
685
|
};
|
|
686
|
+
|
|
687
|
+
// Don't bother parsing an empty input
|
|
368
688
|
if (!guess) {
|
|
689
|
+
// @ts-expect-error - TS2540 - Cannot assign to 'empty' because it is a read-only property.
|
|
369
690
|
score.empty = true;
|
|
370
691
|
return score;
|
|
371
692
|
}
|
|
372
|
-
const answer =
|
|
693
|
+
const answer = KAS__namespace.parse(guess, options);
|
|
694
|
+
|
|
695
|
+
// An unsuccessful parse doesn't count as wrong
|
|
373
696
|
if (!answer.parsed) {
|
|
697
|
+
// @ts-expect-error - TS2540 - Cannot assign to 'empty' because it is a read-only property.
|
|
374
698
|
score.empty = true;
|
|
375
699
|
return score;
|
|
376
700
|
}
|
|
701
|
+
|
|
702
|
+
// Solution will need to be parsed again if we're creating
|
|
703
|
+
// this from a multiple question type
|
|
377
704
|
if (typeof solution === "string") {
|
|
378
705
|
solution = KhanAnswerTypes.expression.parseSolution(solution, options);
|
|
379
706
|
}
|
|
380
|
-
const result =
|
|
707
|
+
const result = KAS__namespace.compare(answer.expr, solution, options);
|
|
381
708
|
if (result.equal) {
|
|
709
|
+
// Correct answer
|
|
710
|
+
// @ts-expect-error - TS2540 - Cannot assign to 'correct' because it is a read-only property.
|
|
382
711
|
score.correct = true;
|
|
383
712
|
} else if (result.wrongVariableNames || result.wrongVariableCase) {
|
|
713
|
+
// We don't want to give people an error for getting the
|
|
714
|
+
// variable names or the variable case wrong.
|
|
715
|
+
// TODO(aasmund): This should ideally have been handled
|
|
716
|
+
// under the `result.message` condition, but the
|
|
717
|
+
// KAS messages currently aren't translatable.
|
|
718
|
+
// @ts-expect-error - TS2540 - Cannot assign to 'ungraded' because it is a read-only property.
|
|
384
719
|
score.ungraded = true;
|
|
720
|
+
// @ts-expect-error - TS2540 - Cannot assign to 'message' because it is a read-only property.
|
|
385
721
|
score.message = result.wrongVariableCase ? ErrorCodes.WRONG_CASE_ERROR : ErrorCodes.WRONG_LETTER_ERROR;
|
|
722
|
+
// Don't tell the use they're "almost there" in this case, that may not be true and isn't helpful.
|
|
723
|
+
// @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
724
|
score.suppressAlmostThere = true;
|
|
387
725
|
} else if (result.message) {
|
|
726
|
+
// Nearly correct answer
|
|
727
|
+
// TODO(aasmund): This message also isn't translatable;
|
|
728
|
+
// need to fix that in KAS
|
|
729
|
+
// @ts-expect-error - TS2540 - Cannot assign to 'message' because it is a read-only property.
|
|
388
730
|
score.message = result.message;
|
|
389
731
|
} else {
|
|
390
|
-
|
|
732
|
+
// Replace x with * and see if it would have been correct
|
|
733
|
+
// TODO(aasmund): I think this branch is effectively dead,
|
|
734
|
+
// because the replacement will only work in situations
|
|
735
|
+
// where the variables are wrong (except if the variable
|
|
736
|
+
// is x, in which case the replacement won't work either),
|
|
737
|
+
// which is handled by another branch. When we implement a
|
|
738
|
+
// more sophisticated variable check, revive this or
|
|
739
|
+
// remove it completely if it will never come into play.
|
|
740
|
+
const answerX = KAS__namespace.parse(guess.replace(/[xX]/g, "*"), options);
|
|
391
741
|
if (answerX.parsed) {
|
|
392
|
-
const resultX =
|
|
742
|
+
const resultX = KAS__namespace.compare(answerX.expr, solution, options);
|
|
393
743
|
if (resultX.equal) {
|
|
744
|
+
// @ts-expect-error - TS2540 - Cannot assign to 'ungraded' because it is a read-only property.
|
|
394
745
|
score.ungraded = true;
|
|
746
|
+
// @ts-expect-error - TS2540 - Cannot assign to 'message' because it is a read-only property.
|
|
395
747
|
score.message = ErrorCodes.MULTIPLICATION_SIGN_ERROR;
|
|
396
748
|
} else if (resultX.message) {
|
|
749
|
+
// TODO(aasmund): I18nize `score.message`
|
|
750
|
+
// @ts-expect-error - TS2540 - Cannot assign to 'message' because it is a read-only property.
|
|
397
751
|
score.message = resultX.message + " Also, I'm a computer. I only understand " + "multiplication if you use an " + "asterisk (*) as the multiplication " + "sign.";
|
|
398
752
|
}
|
|
399
753
|
}
|
|
@@ -419,6 +773,14 @@ function scoreCategorizer(userInput, rubric) {
|
|
|
419
773
|
};
|
|
420
774
|
}
|
|
421
775
|
|
|
776
|
+
/**
|
|
777
|
+
* Checks userInput from the categorizer widget to see if the user has selected
|
|
778
|
+
* a category for each item.
|
|
779
|
+
* @param userInput - The user's input corresponding to an array of indices that
|
|
780
|
+
* represent the selected category for each row/item.
|
|
781
|
+
* @param validationData - An array of strings corresponding to each row/item
|
|
782
|
+
* @param strings - Used to provide a validation message
|
|
783
|
+
*/
|
|
422
784
|
function validateCategorizer(userInput, validationData) {
|
|
423
785
|
const incomplete = validationData.items.some((_, i) => userInput.values[i] == null);
|
|
424
786
|
if (incomplete) {
|
|
@@ -431,6 +793,8 @@ function validateCategorizer(userInput, validationData) {
|
|
|
431
793
|
}
|
|
432
794
|
|
|
433
795
|
function scoreCSProgram(userInput) {
|
|
796
|
+
// The CS program can tell us whether it's correct or incorrect,
|
|
797
|
+
// and pass an optional message
|
|
434
798
|
if (userInput.status === "correct") {
|
|
435
799
|
return {
|
|
436
800
|
type: "points",
|
|
@@ -463,6 +827,10 @@ function scoreDropdown(userInput, rubric) {
|
|
|
463
827
|
};
|
|
464
828
|
}
|
|
465
829
|
|
|
830
|
+
/**
|
|
831
|
+
* Checks if the user has selected an item from the dropdown before scoring.
|
|
832
|
+
* This is shown with a userInput value / index other than 0.
|
|
833
|
+
*/
|
|
466
834
|
function validateDropdown(userInput) {
|
|
467
835
|
if (userInput.value === 0) {
|
|
468
836
|
return {
|
|
@@ -473,47 +841,103 @@ function validateDropdown(userInput) {
|
|
|
473
841
|
return null;
|
|
474
842
|
}
|
|
475
843
|
|
|
844
|
+
/* Content creators input a list of answers which are matched from top to
|
|
845
|
+
* bottom. The intent is that they can include spcific solutions which should
|
|
846
|
+
* be graded as correct or incorrect (or ungraded!) first, then get more
|
|
847
|
+
* general.
|
|
848
|
+
*
|
|
849
|
+
* We iterate through each answer, trying to match it with the user's input
|
|
850
|
+
* using the following angorithm:
|
|
851
|
+
* - Try to parse the user's input. If it doesn't parse then return "not
|
|
852
|
+
* graded".
|
|
853
|
+
* - For each answer:
|
|
854
|
+
* ~ Try to validate the user's input against the answer. The answer is
|
|
855
|
+
* expected to parse.
|
|
856
|
+
* ~ If the user's input validates (the validator judges it "correct"), we've
|
|
857
|
+
* matched and can stop considering answers.
|
|
858
|
+
* - If there were no matches or the matching answer is considered "ungraded",
|
|
859
|
+
* show the user an error. TODO(joel) - what error?
|
|
860
|
+
* - Otherwise, pass through the resulting points and message.
|
|
861
|
+
*/
|
|
476
862
|
function scoreExpression(userInput, rubric, locale) {
|
|
477
|
-
const options =
|
|
478
|
-
|
|
479
|
-
decimal_separator: getDecimalSeparator(locale)
|
|
863
|
+
const options = ___default.default.clone(rubric);
|
|
864
|
+
___default.default.extend(options, {
|
|
865
|
+
decimal_separator: perseusCore.getDecimalSeparator(locale)
|
|
480
866
|
});
|
|
481
867
|
const createValidator = answer => {
|
|
482
|
-
|
|
868
|
+
// We give options to KAS.parse here because it is parsing the
|
|
869
|
+
// solution answer, not the student answer, and we don't want a
|
|
870
|
+
// solution to work if the student is using a different language
|
|
871
|
+
// (different from the content creation language, ie. English).
|
|
872
|
+
const expression = KAS__namespace.parse(answer.value, rubric);
|
|
873
|
+
// An answer may not be parsed if the expression was defined
|
|
874
|
+
// incorrectly. For example if the answer is using a symbol defined
|
|
875
|
+
// in the function variables list for the expression.
|
|
483
876
|
if (!expression.parsed) {
|
|
484
|
-
|
|
877
|
+
/* c8 ignore next */
|
|
878
|
+
throw new perseusCore.PerseusError("Unable to parse solution answer for expression", perseusCore.Errors.InvalidInput, {
|
|
485
879
|
metadata: {
|
|
486
880
|
rubric: JSON.stringify(rubric)
|
|
487
881
|
}
|
|
488
882
|
});
|
|
489
883
|
}
|
|
490
|
-
return KhanAnswerTypes.expression.createValidatorFunctional(expression.expr,
|
|
884
|
+
return KhanAnswerTypes.expression.createValidatorFunctional(expression.expr, ___default.default({}).extend(options, {
|
|
491
885
|
simplify: answer.simplify,
|
|
492
886
|
form: answer.form
|
|
493
887
|
}));
|
|
494
888
|
};
|
|
889
|
+
|
|
890
|
+
// Find the first answer form that matches the user's input and that
|
|
891
|
+
// is considered correct. Also, track whether the input is
|
|
892
|
+
// considered "empty" for all answer forms, and keep the validation
|
|
893
|
+
// result for the first answer form for which the user's input was
|
|
894
|
+
// considered "ungraded".
|
|
895
|
+
// (Terminology reminder: the answer forms are provided by the
|
|
896
|
+
// assessment items; they are not the user's input. Each one might
|
|
897
|
+
// represent a correct answer, an incorrect one (if the exercise
|
|
898
|
+
// creator has predicted certain common wrong answers and wants to
|
|
899
|
+
// provide guidance via a message), or an ungraded one (same idea,
|
|
900
|
+
// but without giving the user an incorrect mark for the question).
|
|
495
901
|
let matchingAnswerForm;
|
|
496
902
|
let matchMessage;
|
|
497
903
|
let allEmpty = true;
|
|
498
904
|
let firstUngradedResult;
|
|
905
|
+
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
|
|
499
906
|
for (const answerForm of rubric.answerForms || []) {
|
|
500
907
|
const validator = createValidator(answerForm);
|
|
908
|
+
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
|
|
501
909
|
if (!validator) {
|
|
502
910
|
continue;
|
|
503
911
|
}
|
|
504
912
|
const result = validator(userInput);
|
|
913
|
+
|
|
914
|
+
// Short-circuit as soon as the user's input matches some answer
|
|
915
|
+
// (independently of whether the answer is correct)
|
|
505
916
|
if (result.correct) {
|
|
506
917
|
matchingAnswerForm = answerForm;
|
|
507
918
|
matchMessage = result.message || "";
|
|
508
919
|
break;
|
|
509
920
|
}
|
|
510
921
|
allEmpty = allEmpty && result.empty;
|
|
922
|
+
// If this answer form is correct and the user's input is considered
|
|
923
|
+
// "ungraded" for it, we'll want to keep the evaluation result for
|
|
924
|
+
// later. If the user's input doesn't match any answer forms, we'll
|
|
925
|
+
// show the message from this validation.
|
|
511
926
|
if (answerForm.considered === "correct" && result.ungraded && !firstUngradedResult) {
|
|
512
927
|
firstUngradedResult = result;
|
|
513
928
|
}
|
|
514
929
|
}
|
|
930
|
+
|
|
931
|
+
// Now check to see if we matched any answer form at all, and if
|
|
932
|
+
// we did, whether it's considered correct, incorrect, or ungraded
|
|
515
933
|
if (!matchingAnswerForm) {
|
|
516
934
|
if (firstUngradedResult) {
|
|
935
|
+
// While we didn't directly match with any answer form, we
|
|
936
|
+
// did at some point get an "ungraded" validation result,
|
|
937
|
+
// which might indicate e.g. a mismatch in variable casing.
|
|
938
|
+
// We'll return "invalid", which will let the user try again
|
|
939
|
+
// with no penalty, and the hopefully helpful validation
|
|
940
|
+
// message.
|
|
517
941
|
return {
|
|
518
942
|
type: "invalid",
|
|
519
943
|
message: firstUngradedResult.message,
|
|
@@ -521,11 +945,14 @@ function scoreExpression(userInput, rubric, locale) {
|
|
|
521
945
|
};
|
|
522
946
|
}
|
|
523
947
|
if (allEmpty) {
|
|
948
|
+
// If everything graded as empty, it's invalid.
|
|
524
949
|
return {
|
|
525
950
|
type: "invalid",
|
|
526
951
|
message: null
|
|
527
952
|
};
|
|
528
953
|
}
|
|
954
|
+
// We fell through all the possibilities and we're not empty,
|
|
955
|
+
// so the answer is considered incorrect.
|
|
529
956
|
return {
|
|
530
957
|
type: "points",
|
|
531
958
|
earned: 0,
|
|
@@ -538,6 +965,9 @@ function scoreExpression(userInput, rubric, locale) {
|
|
|
538
965
|
message: matchMessage
|
|
539
966
|
};
|
|
540
967
|
}
|
|
968
|
+
// We matched a graded answer form, so we can now tell the user
|
|
969
|
+
// whether their input was correct or incorrect, and hand out
|
|
970
|
+
// points accordingly
|
|
541
971
|
return {
|
|
542
972
|
type: "points",
|
|
543
973
|
earned: matchingAnswerForm.considered === "correct" ? 1 : 0,
|
|
@@ -546,6 +976,14 @@ function scoreExpression(userInput, rubric, locale) {
|
|
|
546
976
|
};
|
|
547
977
|
}
|
|
548
978
|
|
|
979
|
+
/**
|
|
980
|
+
* Checks user input from the expression widget to see if it is scorable.
|
|
981
|
+
*
|
|
982
|
+
* Note: Most of the expression widget's validation requires the Rubric because
|
|
983
|
+
* of its use of KhanAnswerTypes as a core part of scoring.
|
|
984
|
+
*
|
|
985
|
+
* @see `scoreExpression()` for more details.
|
|
986
|
+
*/
|
|
549
987
|
function validateExpression(userInput) {
|
|
550
988
|
if (userInput === "") {
|
|
551
989
|
return {
|
|
@@ -561,13 +999,13 @@ function getCoefficientsByType(data) {
|
|
|
561
999
|
return undefined;
|
|
562
1000
|
}
|
|
563
1001
|
if (data.type === "exponential" || data.type === "logarithm") {
|
|
564
|
-
const grader = GrapherUtil.functionForType(data.type);
|
|
1002
|
+
const grader = perseusCore.GrapherUtil.functionForType(data.type);
|
|
565
1003
|
return grader.getCoefficients(data.coords, data.asymptote);
|
|
566
1004
|
} 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);
|
|
1005
|
+
const grader = perseusCore.GrapherUtil.functionForType(data.type);
|
|
568
1006
|
return grader.getCoefficients(data.coords);
|
|
569
1007
|
} else {
|
|
570
|
-
throw new PerseusError("Invalid grapher type", Errors.InvalidInput);
|
|
1008
|
+
throw new perseusCore.PerseusError("Invalid grapher type", perseusCore.Errors.InvalidInput);
|
|
571
1009
|
}
|
|
572
1010
|
}
|
|
573
1011
|
function scoreGrapher(userInput, rubric) {
|
|
@@ -579,13 +1017,17 @@ function scoreGrapher(userInput, rubric) {
|
|
|
579
1017
|
message: null
|
|
580
1018
|
};
|
|
581
1019
|
}
|
|
1020
|
+
|
|
1021
|
+
// We haven't moved the coords
|
|
582
1022
|
if (userInput.coords == null) {
|
|
583
1023
|
return {
|
|
584
1024
|
type: "invalid",
|
|
585
1025
|
message: null
|
|
586
1026
|
};
|
|
587
1027
|
}
|
|
588
|
-
|
|
1028
|
+
|
|
1029
|
+
// Get new function handler for grading
|
|
1030
|
+
const grader = perseusCore.GrapherUtil.functionForType(userInput.type);
|
|
589
1031
|
const guessCoeffs = getCoefficientsByType(userInput);
|
|
590
1032
|
const correctCoeffs = getCoefficientsByType(rubric.correct);
|
|
591
1033
|
if (guessCoeffs == null || correctCoeffs == null) {
|
|
@@ -610,7 +1052,10 @@ function scoreGrapher(userInput, rubric) {
|
|
|
610
1052
|
};
|
|
611
1053
|
}
|
|
612
1054
|
|
|
1055
|
+
// TODO: merge this with scoreCSProgram, it's the same code
|
|
613
1056
|
function scoreIframe(userInput) {
|
|
1057
|
+
// The iframe can tell us whether it's correct or incorrect,
|
|
1058
|
+
// and pass an optional message
|
|
614
1059
|
if (userInput.status === "correct") {
|
|
615
1060
|
return {
|
|
616
1061
|
type: "points",
|
|
@@ -638,15 +1083,16 @@ const {
|
|
|
638
1083
|
canonicalSineCoefficients,
|
|
639
1084
|
similar,
|
|
640
1085
|
clockwise
|
|
641
|
-
} = geometry;
|
|
1086
|
+
} = kmath.geometry;
|
|
642
1087
|
const {
|
|
643
1088
|
getClockwiseAngle
|
|
644
|
-
} = angles;
|
|
1089
|
+
} = kmath.angles;
|
|
645
1090
|
const {
|
|
646
1091
|
getSinusoidCoefficients,
|
|
647
1092
|
getQuadraticCoefficients
|
|
648
|
-
} = coefficients;
|
|
1093
|
+
} = kmath.coefficients;
|
|
649
1094
|
function scoreInteractiveGraph(userInput, rubric) {
|
|
1095
|
+
// None-type graphs are not graded
|
|
650
1096
|
if (userInput.type === "none" && rubric.correct.type === "none") {
|
|
651
1097
|
return {
|
|
652
1098
|
type: "points",
|
|
@@ -655,11 +1101,22 @@ function scoreInteractiveGraph(userInput, rubric) {
|
|
|
655
1101
|
message: null
|
|
656
1102
|
};
|
|
657
1103
|
}
|
|
658
|
-
|
|
1104
|
+
|
|
1105
|
+
// When nothing has moved, there will neither be coords nor the
|
|
1106
|
+
// circle's center/radius fields. When those fields are absent, skip
|
|
1107
|
+
// all these checks; just go mark the answer as empty.
|
|
1108
|
+
const hasValue = Boolean(
|
|
1109
|
+
// @ts-expect-error - TS2339 - Property 'coords' does not exist on type 'PerseusGraphType'.
|
|
1110
|
+
userInput.coords ||
|
|
1111
|
+
// @ts-expect-error - TS2339 - Property 'center' does not exist on type 'PerseusGraphType'. | TS2339 - Property 'radius' does not exist on type 'PerseusGraphType'.
|
|
1112
|
+
userInput.center && userInput.radius);
|
|
659
1113
|
if (userInput.type === rubric.correct.type && hasValue) {
|
|
660
1114
|
if (userInput.type === "linear" && rubric.correct.type === "linear" && userInput.coords != null) {
|
|
661
1115
|
const guess = userInput.coords;
|
|
662
1116
|
const correct = rubric.correct.coords;
|
|
1117
|
+
|
|
1118
|
+
// If both of the guess points are on the correct line, it's
|
|
1119
|
+
// correct.
|
|
663
1120
|
if (collinear(correct[0], correct[1], guess[0]) && collinear(correct[0], correct[1], guess[1])) {
|
|
664
1121
|
return {
|
|
665
1122
|
type: "points",
|
|
@@ -680,9 +1137,10 @@ function scoreInteractiveGraph(userInput, rubric) {
|
|
|
680
1137
|
};
|
|
681
1138
|
}
|
|
682
1139
|
} else if (userInput.type === "quadratic" && rubric.correct.type === "quadratic" && userInput.coords != null) {
|
|
1140
|
+
// If the parabola coefficients match, it's correct.
|
|
683
1141
|
const guessCoeffs = getQuadraticCoefficients(userInput.coords);
|
|
684
1142
|
const correctCoeffs = getQuadraticCoefficients(rubric.correct.coords);
|
|
685
|
-
if (approximateDeepEqual(guessCoeffs, correctCoeffs)) {
|
|
1143
|
+
if (perseusCore.approximateDeepEqual(guessCoeffs, correctCoeffs)) {
|
|
686
1144
|
return {
|
|
687
1145
|
type: "points",
|
|
688
1146
|
earned: 1,
|
|
@@ -695,7 +1153,8 @@ function scoreInteractiveGraph(userInput, rubric) {
|
|
|
695
1153
|
const correctCoeffs = getSinusoidCoefficients(rubric.correct.coords);
|
|
696
1154
|
const canonicalGuessCoeffs = canonicalSineCoefficients(guessCoeffs);
|
|
697
1155
|
const canonicalCorrectCoeffs = canonicalSineCoefficients(correctCoeffs);
|
|
698
|
-
|
|
1156
|
+
// If the canonical coefficients match, it's correct.
|
|
1157
|
+
if (perseusCore.approximateDeepEqual(canonicalGuessCoeffs, canonicalCorrectCoeffs)) {
|
|
699
1158
|
return {
|
|
700
1159
|
type: "points",
|
|
701
1160
|
earned: 1,
|
|
@@ -704,7 +1163,7 @@ function scoreInteractiveGraph(userInput, rubric) {
|
|
|
704
1163
|
};
|
|
705
1164
|
}
|
|
706
1165
|
} else if (userInput.type === "circle" && rubric.correct.type === "circle") {
|
|
707
|
-
if (approximateDeepEqual(userInput.center, rubric.correct.center) && approximateEqual(userInput.radius, rubric.correct.radius)) {
|
|
1166
|
+
if (perseusCore.approximateDeepEqual(userInput.center, rubric.correct.center) && perseusCore.approximateEqual(userInput.radius, rubric.correct.radius)) {
|
|
708
1167
|
return {
|
|
709
1168
|
type: "points",
|
|
710
1169
|
earned: 1,
|
|
@@ -719,9 +1178,14 @@ function scoreInteractiveGraph(userInput, rubric) {
|
|
|
719
1178
|
}
|
|
720
1179
|
const guess = userInput.coords.slice();
|
|
721
1180
|
correct = correct.slice();
|
|
722
|
-
|
|
1181
|
+
// Everything's already rounded so we shouldn't need to do an
|
|
1182
|
+
// eq() comparison but _.isEqual(0, -0) is false, so we'll use
|
|
1183
|
+
// eq() anyway. The sort should be fine because it'll stringify
|
|
1184
|
+
// it and -0 converted to a string is "0"
|
|
1185
|
+
guess?.sort();
|
|
1186
|
+
// @ts-expect-error - TS2339 - Property 'sort' does not exist on type 'readonly Coord[]'.
|
|
723
1187
|
correct.sort();
|
|
724
|
-
if (approximateDeepEqual(guess, correct)) {
|
|
1188
|
+
if (perseusCore.approximateDeepEqual(guess, correct)) {
|
|
725
1189
|
return {
|
|
726
1190
|
type: "points",
|
|
727
1191
|
earned: 1,
|
|
@@ -736,13 +1200,14 @@ function scoreInteractiveGraph(userInput, rubric) {
|
|
|
736
1200
|
if (rubric.correct.match === "similar") {
|
|
737
1201
|
match = similar(guess, correct, Number.POSITIVE_INFINITY);
|
|
738
1202
|
} else if (rubric.correct.match === "congruent") {
|
|
739
|
-
match = similar(guess, correct, number.DEFAULT_TOLERANCE);
|
|
1203
|
+
match = similar(guess, correct, kmath.number.DEFAULT_TOLERANCE);
|
|
740
1204
|
} else if (rubric.correct.match === "approx") {
|
|
741
1205
|
match = similar(guess, correct, 0.1);
|
|
742
1206
|
} else {
|
|
1207
|
+
/* exact */
|
|
743
1208
|
guess.sort();
|
|
744
1209
|
correct.sort();
|
|
745
|
-
match = approximateDeepEqual(guess, correct);
|
|
1210
|
+
match = perseusCore.approximateDeepEqual(guess, correct);
|
|
746
1211
|
}
|
|
747
1212
|
if (match) {
|
|
748
1213
|
return {
|
|
@@ -753,11 +1218,11 @@ function scoreInteractiveGraph(userInput, rubric) {
|
|
|
753
1218
|
};
|
|
754
1219
|
}
|
|
755
1220
|
} 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)) {
|
|
1221
|
+
let guess = perseusCore.deepClone(userInput.coords);
|
|
1222
|
+
let correct = perseusCore.deepClone(rubric.correct.coords);
|
|
1223
|
+
guess = ___default.default.invoke(guess, "sort").sort();
|
|
1224
|
+
correct = ___default.default.invoke(correct, "sort").sort();
|
|
1225
|
+
if (perseusCore.approximateDeepEqual(guess, correct)) {
|
|
761
1226
|
return {
|
|
762
1227
|
type: "points",
|
|
763
1228
|
earned: 1,
|
|
@@ -768,7 +1233,7 @@ function scoreInteractiveGraph(userInput, rubric) {
|
|
|
768
1233
|
} else if (userInput.type === "ray" && rubric.correct.type === "ray" && userInput.coords != null) {
|
|
769
1234
|
const guess = userInput.coords;
|
|
770
1235
|
const correct = rubric.correct.coords;
|
|
771
|
-
if (approximateDeepEqual(guess[0], correct[0]) && collinear(correct[0], correct[1], guess[1])) {
|
|
1236
|
+
if (perseusCore.approximateDeepEqual(guess[0], correct[0]) && collinear(correct[0], correct[1], guess[1])) {
|
|
772
1237
|
return {
|
|
773
1238
|
type: "points",
|
|
774
1239
|
earned: 1,
|
|
@@ -780,27 +1245,39 @@ function scoreInteractiveGraph(userInput, rubric) {
|
|
|
780
1245
|
const coords = userInput.coords;
|
|
781
1246
|
const correct = rubric.correct.coords;
|
|
782
1247
|
const allowReflexAngles = rubric.correct.allowReflexAngles;
|
|
1248
|
+
|
|
1249
|
+
// While the angle graph should always have 3 points, our types
|
|
1250
|
+
// technically allow for null values. We'll check for that here.
|
|
1251
|
+
// TODO: (LEMS-2857) We would like to update the type of coords
|
|
1252
|
+
// to be non-nullable, as the graph should always have 3 points.
|
|
783
1253
|
if (!coords) {
|
|
784
1254
|
return {
|
|
785
1255
|
type: "invalid",
|
|
786
1256
|
message: null
|
|
787
1257
|
};
|
|
788
1258
|
}
|
|
1259
|
+
|
|
1260
|
+
// We need to check both the direction of the angle and the
|
|
1261
|
+
// whether the graph allows for reflexive angles in order to
|
|
1262
|
+
// to determine if we need to reverse the coords for scoring.
|
|
789
1263
|
const areClockwise = clockwise([coords[0], coords[2], coords[1]]);
|
|
790
1264
|
const shouldReverseCoords = areClockwise && !allowReflexAngles;
|
|
791
1265
|
const guess = shouldReverseCoords ? coords.slice().reverse() : coords;
|
|
792
1266
|
let match;
|
|
793
1267
|
if (rubric.correct.match === "congruent") {
|
|
794
|
-
const angles =
|
|
1268
|
+
const angles = ___default.default.map([guess, correct], function (coords) {
|
|
1269
|
+
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
|
|
795
1270
|
if (!coords) {
|
|
796
1271
|
return false;
|
|
797
1272
|
}
|
|
798
1273
|
const angle = getClockwiseAngle(coords, allowReflexAngles);
|
|
799
1274
|
return angle;
|
|
800
1275
|
});
|
|
801
|
-
|
|
1276
|
+
// @ts-expect-error - TS2556 - A spread argument must either have a tuple type or be passed to a rest parameter.
|
|
1277
|
+
match = perseusCore.approximateEqual(...angles);
|
|
802
1278
|
} else {
|
|
803
|
-
|
|
1279
|
+
/* exact */
|
|
1280
|
+
match = perseusCore.approximateDeepEqual(guess[1], correct[1]) && collinear(correct[1], correct[0], guess[0]) && collinear(correct[1], correct[2], guess[2]);
|
|
804
1281
|
}
|
|
805
1282
|
if (match) {
|
|
806
1283
|
return {
|
|
@@ -812,7 +1289,11 @@ function scoreInteractiveGraph(userInput, rubric) {
|
|
|
812
1289
|
}
|
|
813
1290
|
}
|
|
814
1291
|
}
|
|
815
|
-
|
|
1292
|
+
|
|
1293
|
+
// The input wasn't correct, so check if it's a blank input or if it's
|
|
1294
|
+
// actually just wrong
|
|
1295
|
+
if (!hasValue || ___default.default.isEqual(userInput, rubric.graph)) {
|
|
1296
|
+
// We're where we started.
|
|
816
1297
|
return {
|
|
817
1298
|
type: "invalid",
|
|
818
1299
|
message: null
|
|
@@ -826,6 +1307,8 @@ function scoreInteractiveGraph(userInput, rubric) {
|
|
|
826
1307
|
};
|
|
827
1308
|
}
|
|
828
1309
|
|
|
1310
|
+
// Question state for marker as result of user selected answers.
|
|
1311
|
+
|
|
829
1312
|
function scoreLabelImageMarker(userInput, rubric) {
|
|
830
1313
|
const score = {
|
|
831
1314
|
hasAnswers: false,
|
|
@@ -836,9 +1319,11 @@ function scoreLabelImageMarker(userInput, rubric) {
|
|
|
836
1319
|
}
|
|
837
1320
|
if (rubric.length > 0) {
|
|
838
1321
|
if (userInput && userInput.length === rubric.length) {
|
|
1322
|
+
// All correct answers are selected by the user.
|
|
839
1323
|
score.isCorrect = userInput.every(choice => rubric.includes(choice));
|
|
840
1324
|
}
|
|
841
1325
|
} else if (!userInput || userInput.length === 0) {
|
|
1326
|
+
// Correct as no answers should be selected by the user.
|
|
842
1327
|
score.isCorrect = true;
|
|
843
1328
|
}
|
|
844
1329
|
return score;
|
|
@@ -853,6 +1338,8 @@ function scoreLabelImage(userInput, rubric) {
|
|
|
853
1338
|
}
|
|
854
1339
|
return {
|
|
855
1340
|
type: "points",
|
|
1341
|
+
// Markers with no expected answers are graded as correct if user
|
|
1342
|
+
// makes no answer selection.
|
|
856
1343
|
earned: numCorrect === userInput.markers.length ? 1 : 0,
|
|
857
1344
|
total: 1,
|
|
858
1345
|
message: null
|
|
@@ -860,7 +1347,7 @@ function scoreLabelImage(userInput, rubric) {
|
|
|
860
1347
|
}
|
|
861
1348
|
|
|
862
1349
|
function scoreMatcher(userInput, rubric) {
|
|
863
|
-
const correct =
|
|
1350
|
+
const correct = ___default.default.isEqual(userInput.left, rubric.left) && ___default.default.isEqual(userInput.right, rubric.right);
|
|
864
1351
|
return {
|
|
865
1352
|
type: "points",
|
|
866
1353
|
earned: correct ? 1 : 0,
|
|
@@ -872,20 +1359,23 @@ function scoreMatcher(userInput, rubric) {
|
|
|
872
1359
|
function scoreMatrix(userInput, rubric) {
|
|
873
1360
|
const solution = rubric.answers;
|
|
874
1361
|
const supplied = userInput.answers;
|
|
875
|
-
const solutionSize = getMatrixSize(solution);
|
|
876
|
-
const suppliedSize = getMatrixSize(supplied);
|
|
1362
|
+
const solutionSize = perseusCore.getMatrixSize(solution);
|
|
1363
|
+
const suppliedSize = perseusCore.getMatrixSize(supplied);
|
|
877
1364
|
const incorrectSize = solutionSize[0] !== suppliedSize[0] || solutionSize[1] !== suppliedSize[1];
|
|
878
1365
|
const createValidator = KhanAnswerTypes.number.createValidatorFunctional;
|
|
879
1366
|
let message = null;
|
|
880
1367
|
let incorrect = false;
|
|
881
|
-
|
|
882
|
-
|
|
1368
|
+
___default.default(suppliedSize[0]).times(row => {
|
|
1369
|
+
___default.default(suppliedSize[1]).times(col => {
|
|
883
1370
|
if (!incorrectSize) {
|
|
884
|
-
const validator = createValidator(
|
|
1371
|
+
const validator = createValidator(
|
|
1372
|
+
// @ts-expect-error - TS2345 - Argument of type 'number' is not assignable to parameter of type 'string'.
|
|
1373
|
+
solution[row][col], {
|
|
885
1374
|
simplify: true
|
|
886
1375
|
});
|
|
887
1376
|
const result = validator(supplied[row][col]);
|
|
888
1377
|
if (result.message) {
|
|
1378
|
+
// @ts-expect-error - TS2322 - Type 'string' is not assignable to type 'null'.
|
|
889
1379
|
message = result.message;
|
|
890
1380
|
}
|
|
891
1381
|
if (!result.correct) {
|
|
@@ -910,9 +1400,17 @@ function scoreMatrix(userInput, rubric) {
|
|
|
910
1400
|
};
|
|
911
1401
|
}
|
|
912
1402
|
|
|
1403
|
+
/**
|
|
1404
|
+
* Checks user input from the matrix widget to see if it is scorable.
|
|
1405
|
+
*
|
|
1406
|
+
* Note: The matrix widget cannot do much validation without the Scoring
|
|
1407
|
+
* Data because of its use of KhanAnswerTypes as a core part of scoring.
|
|
1408
|
+
*
|
|
1409
|
+
* @see `scoreMatrix()` for more details.
|
|
1410
|
+
*/
|
|
913
1411
|
function validateMatrix(userInput) {
|
|
914
1412
|
const supplied = userInput.answers;
|
|
915
|
-
const suppliedSize = getMatrixSize(supplied);
|
|
1413
|
+
const suppliedSize = perseusCore.getMatrixSize(supplied);
|
|
916
1414
|
for (let row = 0; row < suppliedSize[0]; row++) {
|
|
917
1415
|
for (let col = 0; col < suppliedSize[1]; col++) {
|
|
918
1416
|
if (supplied[row][col] == null || supplied[row][col].toString().length === 0) {
|
|
@@ -931,7 +1429,9 @@ function scoreNumberLine(userInput, rubric) {
|
|
|
931
1429
|
const start = rubric.initialX != null ? rubric.initialX : range[0];
|
|
932
1430
|
const startRel = rubric.isInequality ? "ge" : "eq";
|
|
933
1431
|
const correctRel = rubric.correctRel || "eq";
|
|
934
|
-
const correctPos = number.equal(userInput.numLinePosition,
|
|
1432
|
+
const correctPos = kmath.number.equal(userInput.numLinePosition,
|
|
1433
|
+
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
|
|
1434
|
+
rubric.correctX || 0);
|
|
935
1435
|
if (correctPos && correctRel === userInput.rel) {
|
|
936
1436
|
return {
|
|
937
1437
|
type: "points",
|
|
@@ -941,6 +1441,7 @@ function scoreNumberLine(userInput, rubric) {
|
|
|
941
1441
|
};
|
|
942
1442
|
}
|
|
943
1443
|
if (userInput.numLinePosition === start && userInput.rel === startRel) {
|
|
1444
|
+
// We're where we started.
|
|
944
1445
|
return {
|
|
945
1446
|
type: "invalid",
|
|
946
1447
|
message: null
|
|
@@ -954,9 +1455,17 @@ function scoreNumberLine(userInput, rubric) {
|
|
|
954
1455
|
};
|
|
955
1456
|
}
|
|
956
1457
|
|
|
1458
|
+
/**
|
|
1459
|
+
* Checks user input is within the allowed range and not the same as the initial
|
|
1460
|
+
* state.
|
|
1461
|
+
* @param userInput
|
|
1462
|
+
* @see 'scoreNumberLine' for the scoring logic.
|
|
1463
|
+
*/
|
|
957
1464
|
function validateNumberLine(userInput) {
|
|
958
1465
|
const divisionRange = userInput.divisionRange;
|
|
959
1466
|
const outsideAllowedRange = userInput.numDivisions > divisionRange[1] || userInput.numDivisions < divisionRange[0];
|
|
1467
|
+
|
|
1468
|
+
// TODO: I don't think isTickCrtl is a thing anymore
|
|
960
1469
|
if (userInput.isTickCrtl && outsideAllowedRange) {
|
|
961
1470
|
return {
|
|
962
1471
|
type: "invalid",
|
|
@@ -966,6 +1475,18 @@ function validateNumberLine(userInput) {
|
|
|
966
1475
|
return null;
|
|
967
1476
|
}
|
|
968
1477
|
|
|
1478
|
+
/*
|
|
1479
|
+
* In this file, an `expression` is some portion of valid TeX enclosed in
|
|
1480
|
+
* curly brackets.
|
|
1481
|
+
*/
|
|
1482
|
+
|
|
1483
|
+
/*
|
|
1484
|
+
* Find the index at which an expression ends, i.e., has an unmatched
|
|
1485
|
+
* closing curly bracket. This method assumes that we start with a non-open
|
|
1486
|
+
* bracket character and end when we've seen more left than right brackets
|
|
1487
|
+
* (rather than assuming that we start with a bracket character and wait for
|
|
1488
|
+
* bracket equality).
|
|
1489
|
+
*/
|
|
969
1490
|
function findEndpoint(tex, currentIndex) {
|
|
970
1491
|
let bracketDepth = 0;
|
|
971
1492
|
for (let i = currentIndex, len = tex.length; i < len; i++) {
|
|
@@ -979,10 +1500,22 @@ function findEndpoint(tex, currentIndex) {
|
|
|
979
1500
|
return i;
|
|
980
1501
|
}
|
|
981
1502
|
}
|
|
1503
|
+
// If we never see unbalanced curly brackets, default to the
|
|
1504
|
+
// entire string
|
|
982
1505
|
return tex.length;
|
|
983
1506
|
}
|
|
1507
|
+
|
|
1508
|
+
/*
|
|
1509
|
+
* Parses an individual set of curly brackets into TeX.
|
|
1510
|
+
*/
|
|
984
1511
|
function parseNextExpression(tex, currentIndex, handler) {
|
|
1512
|
+
// Find the first '{' and grab subsequent TeX
|
|
1513
|
+
// Ex) tex: '{3}{7}', and we want the '3'
|
|
985
1514
|
const openBracketIndex = tex.indexOf("{", currentIndex);
|
|
1515
|
+
|
|
1516
|
+
// If there is no open bracket, set the endpoint to the end of the string
|
|
1517
|
+
// and the expression to an empty string. This helps ensure we don't
|
|
1518
|
+
// get stuck in an infinite loop when users handtype TeX.
|
|
986
1519
|
if (openBracketIndex === -1) {
|
|
987
1520
|
return {
|
|
988
1521
|
endpoint: tex.length,
|
|
@@ -990,6 +1523,8 @@ function parseNextExpression(tex, currentIndex, handler) {
|
|
|
990
1523
|
};
|
|
991
1524
|
}
|
|
992
1525
|
const nextExpIndex = openBracketIndex + 1;
|
|
1526
|
+
|
|
1527
|
+
// Truncate to only contain remaining TeX
|
|
993
1528
|
const endpoint = findEndpoint(tex, nextExpIndex);
|
|
994
1529
|
const expressionTeX = tex.substring(nextExpIndex, endpoint);
|
|
995
1530
|
const parsedExp = walkTex(expressionTeX, handler);
|
|
@@ -1018,27 +1553,63 @@ function walkTex(tex, handler) {
|
|
|
1018
1553
|
if (!tex) {
|
|
1019
1554
|
return "";
|
|
1020
1555
|
}
|
|
1556
|
+
|
|
1557
|
+
// Ex) tex: '2 \dfrac {3}{7}'
|
|
1021
1558
|
let parsedString = "";
|
|
1022
1559
|
let currentIndex = 0;
|
|
1023
1560
|
let nextFrac = getNextFracIndex(tex, currentIndex);
|
|
1561
|
+
|
|
1562
|
+
// For each \dfrac, find the two expressions (wrapped in {}) and recur
|
|
1024
1563
|
while (nextFrac > -1) {
|
|
1564
|
+
// Gather first fragment, preceding \dfrac
|
|
1565
|
+
// Ex) parsedString: '2 '
|
|
1025
1566
|
parsedString += tex.substring(currentIndex, nextFrac);
|
|
1567
|
+
|
|
1568
|
+
// Remove everything preceding \dfrac, which has been parsed
|
|
1026
1569
|
currentIndex = nextFrac;
|
|
1570
|
+
|
|
1571
|
+
// Parse first expression and move index past it
|
|
1572
|
+
// Ex) firstParsedExpression.expression: '3'
|
|
1027
1573
|
const firstParsedExpression = parseNextExpression(tex, currentIndex, handler);
|
|
1028
1574
|
currentIndex = firstParsedExpression.endpoint + 1;
|
|
1575
|
+
|
|
1576
|
+
// Parse second expression
|
|
1577
|
+
// Ex) secondParsedExpression.expression: '7'
|
|
1029
1578
|
const secondParsedExpression = parseNextExpression(tex, currentIndex, handler);
|
|
1030
1579
|
currentIndex = secondParsedExpression.endpoint + 1;
|
|
1580
|
+
|
|
1581
|
+
// Add expressions to running total of parsed expressions
|
|
1582
|
+
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
|
|
1031
1583
|
if (parsedString.length) {
|
|
1032
1584
|
parsedString += " ";
|
|
1033
1585
|
}
|
|
1586
|
+
|
|
1587
|
+
// Apply a custom handler based on the parsed subexpressions
|
|
1034
1588
|
parsedString += handler(firstParsedExpression.expression, secondParsedExpression.expression);
|
|
1589
|
+
|
|
1590
|
+
// Find next DFrac, relative to currentIndex
|
|
1035
1591
|
nextFrac = getNextFracIndex(tex, currentIndex);
|
|
1036
1592
|
}
|
|
1593
|
+
|
|
1594
|
+
// Add remaining TeX, which is \dfrac-free
|
|
1037
1595
|
parsedString += tex.slice(currentIndex);
|
|
1038
1596
|
return parsedString;
|
|
1039
1597
|
}
|
|
1598
|
+
|
|
1599
|
+
/*
|
|
1600
|
+
* Parse a TeX expression into something interpretable by input-number.
|
|
1601
|
+
* The process is concerned with: (1) parsing fractions, i.e., \dfracs; and
|
|
1602
|
+
* (2) removing backslash-escaping from certain characters (right now, only
|
|
1603
|
+
* percent signs).
|
|
1604
|
+
*
|
|
1605
|
+
* The basic algorithm for handling \dfracs splits on \dfracs and then recurs
|
|
1606
|
+
* on the subsequent "expressions", i.e., the {} pairs that follow \dfrac. The
|
|
1607
|
+
* recursion is to allow for nested \dfrac elements.
|
|
1608
|
+
*
|
|
1609
|
+
* Backslash-escapes are removed with a simple search-and-replace.
|
|
1610
|
+
*/
|
|
1040
1611
|
function parseTex(tex) {
|
|
1041
|
-
const handler = function
|
|
1612
|
+
const handler = function (exp1, exp2) {
|
|
1042
1613
|
return exp1 + "/" + exp2;
|
|
1043
1614
|
};
|
|
1044
1615
|
const texWithoutFracs = walkTex(tex, handler);
|
|
@@ -1070,26 +1641,54 @@ const answerFormButtons = [{
|
|
|
1070
1641
|
value: "pi",
|
|
1071
1642
|
content: "\u03C0"
|
|
1072
1643
|
}];
|
|
1644
|
+
|
|
1645
|
+
// This function checks if the user inputted a percent value, parsing
|
|
1646
|
+
// it as a number (and maybe scaling) so that it can be graded.
|
|
1647
|
+
// NOTE(michaelpolyak): Unlike `KhanAnswerTypes.number.percent()` which
|
|
1648
|
+
// can accept several input forms with or without "%", the decision
|
|
1649
|
+
// to parse based on the presence of "%" in the input, is so that we
|
|
1650
|
+
// don't accidently scale the user typed value before grading, CP-930.
|
|
1073
1651
|
function maybeParsePercentInput(inputValue, normalizedAnswerExpected) {
|
|
1652
|
+
// If the input value is not a string ending with "%", then there's
|
|
1653
|
+
// nothing more to do. The value will be graded as inputted by user.
|
|
1074
1654
|
if (!(typeof inputValue === "string" && inputValue.endsWith("%"))) {
|
|
1075
1655
|
return inputValue;
|
|
1076
1656
|
}
|
|
1077
1657
|
const value = parseFloat(inputValue.slice(0, -1));
|
|
1658
|
+
// If the input value stripped of the "%" cannot be parsed as a
|
|
1659
|
+
// number (the slice is not really necessary for parseFloat to work
|
|
1660
|
+
// if the string starts with a number) then return the original
|
|
1661
|
+
// input for grading.
|
|
1078
1662
|
if (isNaN(value)) {
|
|
1079
1663
|
return inputValue;
|
|
1080
1664
|
}
|
|
1665
|
+
|
|
1666
|
+
// Next, if all correct answers are in the range of |0,1| then we
|
|
1667
|
+
// scale the user typed value. We assume this is the correct thing
|
|
1668
|
+
// to do since the input value ends with "%".
|
|
1081
1669
|
if (normalizedAnswerExpected) {
|
|
1082
1670
|
return value / 100;
|
|
1083
1671
|
}
|
|
1672
|
+
|
|
1673
|
+
// Otherwise, we return input value (number) stripped of the "%".
|
|
1084
1674
|
return value;
|
|
1085
1675
|
}
|
|
1086
1676
|
function scoreNumericInput(userInput, rubric) {
|
|
1087
|
-
|
|
1088
|
-
|
|
1677
|
+
const defaultAnswerForms = answerFormButtons.map(e => e["value"])
|
|
1678
|
+
// Don't default to validating the answer as a pi answer
|
|
1679
|
+
// if answerForm isn't set on the answer
|
|
1680
|
+
// https://khanacademy.atlassian.net/browse/LC-691
|
|
1681
|
+
.filter(e => e !== "pi");
|
|
1089
1682
|
const createValidator = answer => {
|
|
1090
|
-
var _answer$answerForms;
|
|
1091
1683
|
const stringAnswer = `${answer.value}`;
|
|
1092
|
-
|
|
1684
|
+
|
|
1685
|
+
// Always validate against the provided answer forms (pi, decimal, etc.)
|
|
1686
|
+
const validatorForms = [...(answer.answerForms ?? [])];
|
|
1687
|
+
|
|
1688
|
+
// When an answer is set to strict, we validate using ONLY
|
|
1689
|
+
// the provided answerForms. If strict is false, or if there
|
|
1690
|
+
// were no provided answer forms, we will include all
|
|
1691
|
+
// of the default answer forms in our validator.
|
|
1093
1692
|
if (!answer.strict || validatorForms.length === 0) {
|
|
1094
1693
|
validatorForms.push(...defaultAnswerForms);
|
|
1095
1694
|
}
|
|
@@ -1097,12 +1696,18 @@ function scoreNumericInput(userInput, rubric) {
|
|
|
1097
1696
|
message: answer.message,
|
|
1098
1697
|
simplify: answer.status === "correct" ? answer.simplify : "optional",
|
|
1099
1698
|
inexact: true,
|
|
1699
|
+
// TODO(merlob) backfill / delete
|
|
1100
1700
|
maxError: answer.maxError,
|
|
1101
1701
|
forms: validatorForms
|
|
1102
1702
|
});
|
|
1103
1703
|
};
|
|
1704
|
+
|
|
1705
|
+
// We may have received TeX; try to parse it before grading.
|
|
1706
|
+
// If `currentValue` is not TeX, this should be a no-op.
|
|
1104
1707
|
const currentValue = parseTex(userInput.currentValue);
|
|
1105
1708
|
const normalizedAnswerExpected = rubric.answers.filter(answer => answer.status === "correct").every(answer => answer.value != null && Math.abs(answer.value) <= 1);
|
|
1709
|
+
|
|
1710
|
+
// The coefficient is an attribute of the widget
|
|
1106
1711
|
let localValue = currentValue;
|
|
1107
1712
|
if (rubric.coefficient) {
|
|
1108
1713
|
if (!localValue) {
|
|
@@ -1114,16 +1719,19 @@ function scoreNumericInput(userInput, rubric) {
|
|
|
1114
1719
|
const matchedAnswer = rubric.answers.map(answer => {
|
|
1115
1720
|
const validateFn = createValidator(answer);
|
|
1116
1721
|
const score = validateFn(maybeParsePercentInput(localValue, normalizedAnswerExpected));
|
|
1117
|
-
return
|
|
1722
|
+
return {
|
|
1723
|
+
...answer,
|
|
1118
1724
|
score
|
|
1119
|
-
}
|
|
1725
|
+
};
|
|
1120
1726
|
}).find(answer => {
|
|
1727
|
+
// NOTE: "answer.score.correct" indicates a match via the validate function.
|
|
1728
|
+
// It does NOT indicate that the answer itself is correct.
|
|
1121
1729
|
return answer.score.correct || answer.status === "correct" && answer.score.empty;
|
|
1122
1730
|
});
|
|
1123
|
-
const result =
|
|
1124
|
-
empty:
|
|
1125
|
-
correct:
|
|
1126
|
-
message:
|
|
1731
|
+
const result = matchedAnswer?.status === "correct" ? matchedAnswer.score : {
|
|
1732
|
+
empty: matchedAnswer?.status === "ungraded",
|
|
1733
|
+
correct: matchedAnswer?.status === "correct",
|
|
1734
|
+
message: matchedAnswer?.message ?? null};
|
|
1127
1735
|
if (result.empty) {
|
|
1128
1736
|
return {
|
|
1129
1737
|
type: "invalid",
|
|
@@ -1139,7 +1747,7 @@ function scoreNumericInput(userInput, rubric) {
|
|
|
1139
1747
|
}
|
|
1140
1748
|
|
|
1141
1749
|
function scoreOrderer(userInput, rubric) {
|
|
1142
|
-
const correct =
|
|
1750
|
+
const correct = ___default.default.isEqual(userInput.current, rubric.correctOptions.map(option => option.content));
|
|
1143
1751
|
return {
|
|
1144
1752
|
type: "points",
|
|
1145
1753
|
earned: correct ? 1 : 0,
|
|
@@ -1148,6 +1756,12 @@ function scoreOrderer(userInput, rubric) {
|
|
|
1148
1756
|
};
|
|
1149
1757
|
}
|
|
1150
1758
|
|
|
1759
|
+
/**
|
|
1760
|
+
* Checks user input from the orderer widget to see if the user has started
|
|
1761
|
+
* ordering the options, making the widget scorable.
|
|
1762
|
+
* @param userInput
|
|
1763
|
+
* @see `scoreOrderer` for more details.
|
|
1764
|
+
*/
|
|
1151
1765
|
function validateOrderer(userInput) {
|
|
1152
1766
|
if (userInput.current.length === 0) {
|
|
1153
1767
|
return {
|
|
@@ -1161,14 +1775,20 @@ function validateOrderer(userInput) {
|
|
|
1161
1775
|
function scorePlotter(userInput, rubric) {
|
|
1162
1776
|
return {
|
|
1163
1777
|
type: "points",
|
|
1164
|
-
earned: approximateDeepEqual(userInput, rubric.correct) ? 1 : 0,
|
|
1778
|
+
earned: perseusCore.approximateDeepEqual(userInput, rubric.correct) ? 1 : 0,
|
|
1165
1779
|
total: 1,
|
|
1166
1780
|
message: null
|
|
1167
1781
|
};
|
|
1168
1782
|
}
|
|
1169
1783
|
|
|
1784
|
+
/**
|
|
1785
|
+
* Checks user input to confirm it is not the same as the starting values for the graph.
|
|
1786
|
+
* This means the user has modified the graph, and the question can be scored.
|
|
1787
|
+
*
|
|
1788
|
+
* @see 'scorePlotter' for more details on scoring.
|
|
1789
|
+
*/
|
|
1170
1790
|
function validatePlotter(userInput, validationData) {
|
|
1171
|
-
if (approximateDeepEqual(userInput, validationData.starting)) {
|
|
1791
|
+
if (perseusCore.approximateDeepEqual(userInput, validationData.starting)) {
|
|
1172
1792
|
return {
|
|
1173
1793
|
type: "invalid",
|
|
1174
1794
|
message: null
|
|
@@ -1189,6 +1809,7 @@ function scoreRadio(userInput, rubric) {
|
|
|
1189
1809
|
type: "invalid",
|
|
1190
1810
|
message: ErrorCodes.CHOOSE_CORRECT_NUM_ERROR
|
|
1191
1811
|
};
|
|
1812
|
+
// If NOTA and some other answer are checked, ...
|
|
1192
1813
|
}
|
|
1193
1814
|
const noneOfTheAboveSelected = rubric.choices.some((choice, index) => choice.isNoneOfTheAbove && userInput.choicesSelected[index]);
|
|
1194
1815
|
if (noneOfTheAboveSelected && numSelected > 1) {
|
|
@@ -1216,6 +1837,14 @@ function scoreRadio(userInput, rubric) {
|
|
|
1216
1837
|
};
|
|
1217
1838
|
}
|
|
1218
1839
|
|
|
1840
|
+
/**
|
|
1841
|
+
* Checks if the user has selected at least one option. Additional validation
|
|
1842
|
+
* is done in scoreRadio to check if the number of selected options is correct
|
|
1843
|
+
* and if the user has selected both a correct option and the "none of the above"
|
|
1844
|
+
* option.
|
|
1845
|
+
* @param userInput
|
|
1846
|
+
* @see `scoreRadio` for the additional validation logic and the scoring logic.
|
|
1847
|
+
*/
|
|
1219
1848
|
function validateRadio(userInput) {
|
|
1220
1849
|
const numSelected = userInput.choicesSelected.reduce((sum, selected) => {
|
|
1221
1850
|
return sum + (selected ? 1 : 0);
|
|
@@ -1230,7 +1859,7 @@ function validateRadio(userInput) {
|
|
|
1230
1859
|
}
|
|
1231
1860
|
|
|
1232
1861
|
function scoreSorter(userInput, rubric) {
|
|
1233
|
-
const correct = approximateDeepEqual(userInput.options, rubric.correct);
|
|
1862
|
+
const correct = perseusCore.approximateDeepEqual(userInput.options, rubric.correct);
|
|
1234
1863
|
return {
|
|
1235
1864
|
type: "points",
|
|
1236
1865
|
earned: correct ? 1 : 0,
|
|
@@ -1239,7 +1868,20 @@ function scoreSorter(userInput, rubric) {
|
|
|
1239
1868
|
};
|
|
1240
1869
|
}
|
|
1241
1870
|
|
|
1871
|
+
/**
|
|
1872
|
+
* Checks user input for the sorter widget to ensure that the user has made
|
|
1873
|
+
* changes before attempting to score the widget.
|
|
1874
|
+
* @param userInput
|
|
1875
|
+
* @see 'scoreSorter' in 'packages/perseus/src/widgets/sorter/score-sorter.ts'
|
|
1876
|
+
* for more details on how the sorter widget is scored.
|
|
1877
|
+
*/
|
|
1242
1878
|
function validateSorter(userInput) {
|
|
1879
|
+
// If the sorter widget hasn't been changed yet, we treat it as "empty" which
|
|
1880
|
+
// prevents the "Check" button from becoming active. We want the user
|
|
1881
|
+
// to make a change before trying to move forward. This makes an
|
|
1882
|
+
// assumption that the initial order isn't the correct order! However,
|
|
1883
|
+
// this should be rare if it happens, and interacting with the list
|
|
1884
|
+
// will enable the button, so they won't be locked out of progressing.
|
|
1243
1885
|
if (!userInput.changed) {
|
|
1244
1886
|
return {
|
|
1245
1887
|
type: "invalid",
|
|
@@ -1249,8 +1891,15 @@ function validateSorter(userInput) {
|
|
|
1249
1891
|
return null;
|
|
1250
1892
|
}
|
|
1251
1893
|
|
|
1252
|
-
|
|
1894
|
+
/**
|
|
1895
|
+
* Filters the given table (modelled as a 2D array) to remove any rows that are
|
|
1896
|
+
* completely empty.
|
|
1897
|
+
*
|
|
1898
|
+
* @returns A new table with only non-empty rows.
|
|
1899
|
+
*/
|
|
1900
|
+
const filterNonEmpty = function (table) {
|
|
1253
1901
|
return table.filter(function (row) {
|
|
1902
|
+
// Return only rows that are non-empty.
|
|
1254
1903
|
return row.some(cell => cell);
|
|
1255
1904
|
});
|
|
1256
1905
|
};
|
|
@@ -1262,6 +1911,8 @@ function validateTable(userInput) {
|
|
|
1262
1911
|
return cell === "";
|
|
1263
1912
|
});
|
|
1264
1913
|
});
|
|
1914
|
+
|
|
1915
|
+
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
|
|
1265
1916
|
if (hasEmptyCell || !supplied.length) {
|
|
1266
1917
|
return {
|
|
1267
1918
|
type: "invalid",
|
|
@@ -1355,6 +2006,10 @@ function scoreInputNumber(userInput, rubric) {
|
|
|
1355
2006
|
if (rubric.answerType == null) {
|
|
1356
2007
|
rubric.answerType = "number";
|
|
1357
2008
|
}
|
|
2009
|
+
|
|
2010
|
+
// note(matthewc): this will get immediately parsed again by
|
|
2011
|
+
// `KhanAnswerTypes.number.convertToPredicate`, but a string is
|
|
2012
|
+
// expected here
|
|
1358
2013
|
const stringValue = `${rubric.value}`;
|
|
1359
2014
|
const val = KhanAnswerTypes.number.createValidatorFunctional(stringValue, {
|
|
1360
2015
|
simplify: rubric.simplify,
|
|
@@ -1362,6 +2017,9 @@ function scoreInputNumber(userInput, rubric) {
|
|
|
1362
2017
|
maxError: rubric.maxError,
|
|
1363
2018
|
forms: inputNumberAnswerTypes[rubric.answerType].forms
|
|
1364
2019
|
});
|
|
2020
|
+
|
|
2021
|
+
// We may have received TeX; try to parse it before grading.
|
|
2022
|
+
// If `currentValue` is not TeX, this should be a no-op.
|
|
1365
2023
|
const currentValue = parseTex(userInput.currentValue);
|
|
1366
2024
|
const result = val(currentValue);
|
|
1367
2025
|
if (result.empty) {
|
|
@@ -1378,7 +2036,16 @@ function scoreInputNumber(userInput, rubric) {
|
|
|
1378
2036
|
};
|
|
1379
2037
|
}
|
|
1380
2038
|
|
|
1381
|
-
|
|
2039
|
+
/**
|
|
2040
|
+
* Several widgets don't have "right"/"wrong" scoring logic,
|
|
2041
|
+
* so this just says to move on past those widgets
|
|
2042
|
+
*
|
|
2043
|
+
* TODO(LEMS-2543) widgets that use this probably shouldn't have any
|
|
2044
|
+
* scoring logic and the thing scoring an exercise
|
|
2045
|
+
* should just know to skip these
|
|
2046
|
+
*/
|
|
2047
|
+
function scoreNoop() {
|
|
2048
|
+
let points = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 0;
|
|
1382
2049
|
return {
|
|
1383
2050
|
type: "points",
|
|
1384
2051
|
earned: points,
|
|
@@ -1387,21 +2054,33 @@ function scoreNoop(points = 0) {
|
|
|
1387
2054
|
};
|
|
1388
2055
|
}
|
|
1389
2056
|
|
|
2057
|
+
// The `group` widget is basically a widget hosting a full Perseus system in
|
|
2058
|
+
|
|
2059
|
+
// it. As such, scoring a group means scoring all widgets it contains.
|
|
1390
2060
|
function scoreGroup(userInput, rubric, locale) {
|
|
1391
2061
|
const scores = scoreWidgetsFunctional(rubric.widgets, Object.keys(rubric.widgets), userInput, locale);
|
|
1392
2062
|
return flattenScores(scores);
|
|
1393
2063
|
}
|
|
1394
2064
|
|
|
1395
|
-
|
|
2065
|
+
/**
|
|
2066
|
+
* Checks the given user input to see if any answerable widgets have not been
|
|
2067
|
+
* "filled in" (ie. if they're empty). Another way to think about this
|
|
2068
|
+
* function is that its a check to see if we can score the provided input.
|
|
2069
|
+
*/
|
|
2070
|
+
function emptyWidgetsFunctional(widgets,
|
|
2071
|
+
// This is a port of old code, I'm not sure why
|
|
2072
|
+
// we need widgetIds vs the keys of the widgets object
|
|
2073
|
+
widgetIds, userInputMap, locale) {
|
|
1396
2074
|
return widgetIds.filter(id => {
|
|
1397
2075
|
const widget = widgets[id];
|
|
1398
2076
|
if (!widget || widget.static === true) {
|
|
2077
|
+
// Static widgets shouldn't count as empty
|
|
1399
2078
|
return false;
|
|
1400
2079
|
}
|
|
1401
2080
|
const validator = getWidgetValidator(widget.type);
|
|
1402
2081
|
const userInput = userInputMap[id];
|
|
1403
2082
|
const validationData = widget.options;
|
|
1404
|
-
const score = validator
|
|
2083
|
+
const score = validator?.(userInput, validationData, locale);
|
|
1405
2084
|
if (score) {
|
|
1406
2085
|
return scoreIsEmpty(score);
|
|
1407
2086
|
}
|
|
@@ -1427,6 +2106,7 @@ function validateLabelImage(userInput) {
|
|
|
1427
2106
|
numAnswered++;
|
|
1428
2107
|
}
|
|
1429
2108
|
}
|
|
2109
|
+
// We expect all question markers to be answered before grading.
|
|
1430
2110
|
if (numAnswered !== userInput.markers.length) {
|
|
1431
2111
|
return {
|
|
1432
2112
|
type: "invalid",
|
|
@@ -1467,12 +2147,10 @@ function registerWidget(type, scorer, validator) {
|
|
|
1467
2147
|
};
|
|
1468
2148
|
}
|
|
1469
2149
|
const getWidgetValidator = name => {
|
|
1470
|
-
|
|
1471
|
-
return (_widgets$name$validat = (_widgets$name = widgets[name]) == null ? void 0 : _widgets$name.validator) != null ? _widgets$name$validat : null;
|
|
2150
|
+
return widgets[name]?.validator ?? null;
|
|
1472
2151
|
};
|
|
1473
2152
|
const getWidgetScorer = name => {
|
|
1474
|
-
|
|
1475
|
-
return (_widgets$name$scorer = (_widgets$name2 = widgets[name]) == null ? void 0 : _widgets$name2.scorer) != null ? _widgets$name$scorer : null;
|
|
2153
|
+
return widgets[name]?.scorer ?? null;
|
|
1476
2154
|
};
|
|
1477
2155
|
registerWidget("categorizer", scoreCategorizer, validateCategorizer);
|
|
1478
2156
|
registerWidget("cs-program", scoreCSProgram);
|
|
@@ -1512,13 +2190,40 @@ const noScore = {
|
|
|
1512
2190
|
total: 0,
|
|
1513
2191
|
message: null
|
|
1514
2192
|
};
|
|
2193
|
+
|
|
2194
|
+
/**
|
|
2195
|
+
* If a widget says that it is empty once it is graded.
|
|
2196
|
+
* Trying to encapsulate references to the score format.
|
|
2197
|
+
*/
|
|
1515
2198
|
function scoreIsEmpty(score) {
|
|
2199
|
+
// HACK(benkomalo): ugh. this isn't great; the Perseus score objects
|
|
2200
|
+
// overload the type "invalid" for what should probably be three
|
|
2201
|
+
// distinct cases:
|
|
2202
|
+
// - truly empty or not fully filled out
|
|
2203
|
+
// - invalid or malformed inputs
|
|
2204
|
+
// - "almost correct" like inputs where the widget wants to give
|
|
2205
|
+
// feedback (e.g. a fraction needs to be reduced, or `pi` should
|
|
2206
|
+
// be used instead of 3.14)
|
|
2207
|
+
//
|
|
2208
|
+
// Unfortunately the coercion happens all over the place, as these
|
|
2209
|
+
// Perseus style score objects are created *everywhere* (basically
|
|
2210
|
+
// in every widget), so it's hard to change now. We assume that
|
|
2211
|
+
// anything with a "message" is not truly empty, and one of the
|
|
2212
|
+
// latter two cases for now.
|
|
1516
2213
|
return score.type === "invalid" && (!score.message || score.message.length === 0);
|
|
1517
2214
|
}
|
|
2215
|
+
|
|
2216
|
+
/**
|
|
2217
|
+
* Combine two score objects.
|
|
2218
|
+
*
|
|
2219
|
+
* Given two score objects for two different widgets, combine them so that
|
|
2220
|
+
* if one is wrong, the total score is wrong, etc.
|
|
2221
|
+
*/
|
|
1518
2222
|
function combineScores(scoreA, scoreB) {
|
|
1519
2223
|
let message;
|
|
1520
2224
|
if (scoreA.type === "points" && scoreB.type === "points") {
|
|
1521
2225
|
if (scoreA.message && scoreB.message && scoreA.message !== scoreB.message) {
|
|
2226
|
+
// TODO(alpert): Figure out how to combine messages usefully
|
|
1522
2227
|
message = null;
|
|
1523
2228
|
} else {
|
|
1524
2229
|
message = scoreA.message || scoreB.message;
|
|
@@ -1538,6 +2243,7 @@ function combineScores(scoreA, scoreB) {
|
|
|
1538
2243
|
}
|
|
1539
2244
|
if (scoreA.type === "invalid" && scoreB.type === "invalid") {
|
|
1540
2245
|
if (scoreA.message && scoreB.message && scoreA.message !== scoreB.message) {
|
|
2246
|
+
// TODO(alpert): Figure out how to combine messages usefully
|
|
1541
2247
|
message = null;
|
|
1542
2248
|
} else {
|
|
1543
2249
|
message = scoreA.message || scoreB.message;
|
|
@@ -1547,7 +2253,12 @@ function combineScores(scoreA, scoreB) {
|
|
|
1547
2253
|
message: message
|
|
1548
2254
|
};
|
|
1549
2255
|
}
|
|
1550
|
-
|
|
2256
|
+
|
|
2257
|
+
/**
|
|
2258
|
+
* The above checks cover all combinations of score type, so if we get here
|
|
2259
|
+
* then something is amiss with our inputs.
|
|
2260
|
+
*/
|
|
2261
|
+
throw new perseusCore.PerseusError("PerseusScore with unknown type encountered", perseusCore.Errors.InvalidInput, {
|
|
1551
2262
|
metadata: {
|
|
1552
2263
|
scoreA: JSON.stringify(scoreA),
|
|
1553
2264
|
scoreB: JSON.stringify(scoreB)
|
|
@@ -1557,22 +2268,38 @@ function combineScores(scoreA, scoreB) {
|
|
|
1557
2268
|
function flattenScores(widgetScoreMap) {
|
|
1558
2269
|
return Object.values(widgetScoreMap).reduce(combineScores, noScore);
|
|
1559
2270
|
}
|
|
2271
|
+
|
|
2272
|
+
/**
|
|
2273
|
+
* score a Perseus item
|
|
2274
|
+
*
|
|
2275
|
+
* @param perseusRenderData - the full answer data, includes the correct answer
|
|
2276
|
+
* @param userInputMap - the user's input for each widget, mapped by ID
|
|
2277
|
+
* @param locale - string locale for math parsing ("de" 1.000,00 vs "en" 1,000.00)
|
|
2278
|
+
*/
|
|
1560
2279
|
function scorePerseusItem(perseusRenderData, userInputMap, locale) {
|
|
1561
|
-
|
|
2280
|
+
// There seems to be a chance that PerseusRenderer.widgets might include
|
|
2281
|
+
// widget data for widgets that are not in PerseusRenderer.content,
|
|
2282
|
+
// so this checks that the widgets are being used before scoring them
|
|
2283
|
+
const usedWidgetIds = perseusCore.getWidgetIdsFromContent(perseusRenderData.content);
|
|
1562
2284
|
const scores = scoreWidgetsFunctional(perseusRenderData.widgets, usedWidgetIds, userInputMap, locale);
|
|
1563
2285
|
return flattenScores(scores);
|
|
1564
2286
|
}
|
|
1565
|
-
|
|
1566
|
-
|
|
2287
|
+
|
|
2288
|
+
// TODO: combine scorePerseusItem with scoreWidgetsFunctional
|
|
2289
|
+
function scoreWidgetsFunctional(widgets,
|
|
2290
|
+
// This is a port of old code, I'm not sure why
|
|
2291
|
+
// we need widgetIds vs the keys of the widgets object
|
|
2292
|
+
widgetIds, userInputMap, locale) {
|
|
2293
|
+
const upgradedWidgets = perseusCore.getUpgradedWidgetOptions(widgets);
|
|
1567
2294
|
const gradedWidgetIds = widgetIds.filter(id => {
|
|
1568
2295
|
const props = upgradedWidgets[id];
|
|
1569
|
-
const widgetIsGraded =
|
|
1570
|
-
const widgetIsStatic = !!
|
|
2296
|
+
const widgetIsGraded = props?.graded == null || props.graded;
|
|
2297
|
+
const widgetIsStatic = !!props?.static;
|
|
2298
|
+
// Ungraded widgets or widgets set to static shouldn't be graded.
|
|
1571
2299
|
return widgetIsGraded && !widgetIsStatic;
|
|
1572
2300
|
});
|
|
1573
2301
|
const widgetScores = {};
|
|
1574
2302
|
gradedWidgetIds.forEach(id => {
|
|
1575
|
-
var _validator;
|
|
1576
2303
|
const widget = upgradedWidgets[id];
|
|
1577
2304
|
if (!widget) {
|
|
1578
2305
|
return;
|
|
@@ -1580,7 +2307,10 @@ function scoreWidgetsFunctional(widgets, widgetIds, userInputMap, locale) {
|
|
|
1580
2307
|
const userInput = userInputMap[id];
|
|
1581
2308
|
const validator = getWidgetValidator(widget.type);
|
|
1582
2309
|
const scorer = getWidgetScorer(widget.type);
|
|
1583
|
-
|
|
2310
|
+
|
|
2311
|
+
// We do validation (empty checks) first and then scoring. If
|
|
2312
|
+
// validation fails, it's result is itself a PerseusScore.
|
|
2313
|
+
const score = validator?.(userInput, widget.options, locale) ?? scorer?.(userInput, widget.options, locale);
|
|
1584
2314
|
if (score != null) {
|
|
1585
2315
|
widgetScores[id] = score;
|
|
1586
2316
|
}
|
|
@@ -1588,5 +2318,43 @@ function scoreWidgetsFunctional(widgets, widgetIds, userInputMap, locale) {
|
|
|
1588
2318
|
return widgetScores;
|
|
1589
2319
|
}
|
|
1590
2320
|
|
|
1591
|
-
|
|
2321
|
+
exports.ErrorCodes = ErrorCodes;
|
|
2322
|
+
exports.KhanAnswerTypes = KhanAnswerTypes;
|
|
2323
|
+
exports.emptyWidgetsFunctional = emptyWidgetsFunctional;
|
|
2324
|
+
exports.flattenScores = flattenScores;
|
|
2325
|
+
exports.getWidgetScorer = getWidgetScorer;
|
|
2326
|
+
exports.getWidgetValidator = getWidgetValidator;
|
|
2327
|
+
exports.inputNumberAnswerTypes = inputNumberAnswerTypes;
|
|
2328
|
+
exports.registerWidget = registerWidget;
|
|
2329
|
+
exports.scoreCSProgram = scoreCSProgram;
|
|
2330
|
+
exports.scoreCategorizer = scoreCategorizer;
|
|
2331
|
+
exports.scoreDropdown = scoreDropdown;
|
|
2332
|
+
exports.scoreExpression = scoreExpression;
|
|
2333
|
+
exports.scoreGrapher = scoreGrapher;
|
|
2334
|
+
exports.scoreIframe = scoreIframe;
|
|
2335
|
+
exports.scoreInputNumber = scoreInputNumber;
|
|
2336
|
+
exports.scoreInteractiveGraph = scoreInteractiveGraph;
|
|
2337
|
+
exports.scoreLabelImage = scoreLabelImage;
|
|
2338
|
+
exports.scoreLabelImageMarker = scoreLabelImageMarker;
|
|
2339
|
+
exports.scoreMatcher = scoreMatcher;
|
|
2340
|
+
exports.scoreMatrix = scoreMatrix;
|
|
2341
|
+
exports.scoreNumberLine = scoreNumberLine;
|
|
2342
|
+
exports.scoreNumericInput = scoreNumericInput;
|
|
2343
|
+
exports.scoreOrderer = scoreOrderer;
|
|
2344
|
+
exports.scorePerseusItem = scorePerseusItem;
|
|
2345
|
+
exports.scorePlotter = scorePlotter;
|
|
2346
|
+
exports.scoreRadio = scoreRadio;
|
|
2347
|
+
exports.scoreSorter = scoreSorter;
|
|
2348
|
+
exports.scoreTable = scoreTable;
|
|
2349
|
+
exports.scoreWidgetsFunctional = scoreWidgetsFunctional;
|
|
2350
|
+
exports.validateCategorizer = validateCategorizer;
|
|
2351
|
+
exports.validateDropdown = validateDropdown;
|
|
2352
|
+
exports.validateExpression = validateExpression;
|
|
2353
|
+
exports.validateMatrix = validateMatrix;
|
|
2354
|
+
exports.validateNumberLine = validateNumberLine;
|
|
2355
|
+
exports.validateOrderer = validateOrderer;
|
|
2356
|
+
exports.validatePlotter = validatePlotter;
|
|
2357
|
+
exports.validateRadio = validateRadio;
|
|
2358
|
+
exports.validateSorter = validateSorter;
|
|
2359
|
+
exports.validateTable = validateTable;
|
|
1592
2360
|
//# sourceMappingURL=index.js.map
|