@khanacademy/perseus-score 3.0.0 → 4.0.1

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