@khanacademy/perseus-score 3.0.0 → 4.0.0

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