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