@khanacademy/perseus-score 1.0.0 → 2.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.
Files changed (40) hide show
  1. package/dist/error-codes.d.ts +4 -0
  2. package/dist/es/index.js +1332 -4
  3. package/dist/es/index.js.map +1 -1
  4. package/dist/index.d.ts +29 -0
  5. package/dist/index.js +1348 -1
  6. package/dist/index.js.map +1 -1
  7. package/dist/util/tex-wrangler.d.ts +2 -0
  8. package/dist/validation.types.d.ts +279 -0
  9. package/dist/validation.typetest.d.ts +1 -0
  10. package/dist/widgets/categorizer/score-categorizer.d.ts +3 -0
  11. package/dist/widgets/categorizer/validate-categorizer.d.ts +11 -0
  12. package/dist/widgets/cs-program/score-cs-program.d.ts +3 -0
  13. package/dist/widgets/dropdown/score-dropdown.d.ts +3 -0
  14. package/dist/widgets/dropdown/validate-dropdown.d.ts +7 -0
  15. package/dist/widgets/expression/score-expression.d.ts +3 -0
  16. package/dist/widgets/expression/validate-expression.d.ts +11 -0
  17. package/dist/widgets/grapher/score-grapher.d.ts +3 -0
  18. package/dist/widgets/iframe/score-iframe.d.ts +3 -0
  19. package/dist/widgets/input-number/score-input-number.d.ts +37 -0
  20. package/dist/widgets/interactive-graph/score-interactive-graph.d.ts +3 -0
  21. package/dist/widgets/label-image/score-label-image.d.ts +8 -0
  22. package/dist/widgets/label-image/validate-label-image.d.ts +3 -0
  23. package/dist/widgets/matcher/score-matcher.d.ts +3 -0
  24. package/dist/widgets/matrix/score-matrix.d.ts +3 -0
  25. package/dist/widgets/matrix/validate-matrix.d.ts +11 -0
  26. package/dist/widgets/number-line/score-number-line.d.ts +3 -0
  27. package/dist/widgets/number-line/validate-number-line.d.ts +11 -0
  28. package/dist/widgets/numeric-input/score-numeric-input.d.ts +4 -0
  29. package/dist/widgets/orderer/score-orderer.d.ts +3 -0
  30. package/dist/widgets/orderer/validate-orderer.d.ts +9 -0
  31. package/dist/widgets/plotter/score-plotter.d.ts +3 -0
  32. package/dist/widgets/plotter/validate-plotter.d.ts +9 -0
  33. package/dist/widgets/radio/score-radio.d.ts +3 -0
  34. package/dist/widgets/radio/validate-radio.d.ts +11 -0
  35. package/dist/widgets/sorter/score-sorter.d.ts +3 -0
  36. package/dist/widgets/sorter/validate-sorter.d.ts +10 -0
  37. package/dist/widgets/table/score-table.d.ts +3 -0
  38. package/dist/widgets/table/utils.d.ts +7 -0
  39. package/dist/widgets/table/validate-table.d.ts +3 -0
  40. package/package.json +4 -4
package/dist/es/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as KAS from '@khanacademy/kas';
2
- import { KhanMath } from '@khanacademy/kmath';
3
- import { PerseusError, Errors } from '@khanacademy/perseus-core';
2
+ import { KhanMath, number, geometry, angles, coefficients } from '@khanacademy/kmath';
3
+ import { PerseusError, Errors, getDecimalSeparator, GrapherUtil, approximateDeepEqual, approximateEqual, deepClone, getMatrixSize } from '@khanacademy/perseus-core';
4
+ import { ErrorCodes as ErrorCodes$1 } from '@khanacademy/perseus-score';
4
5
 
5
6
  var commonjsGlobal = typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {};
6
7
 
@@ -11832,6 +11833,10 @@ const EXTRA_SYMBOLS_ERROR = "EXTRA_SYMBOLS_ERROR";
11832
11833
  const WRONG_CASE_ERROR = "WRONG_CASE_ERROR";
11833
11834
  const WRONG_LETTER_ERROR = "WRONG_LETTER_ERROR";
11834
11835
  const MULTIPLICATION_SIGN_ERROR = "MULTIPLICATION_SIGN_ERROR";
11836
+ const INVALID_SELECTION_ERROR = "INVALID_SELECTION_ERROR";
11837
+ const CHOOSE_CORRECT_NUM_ERROR = "CHOOSE_CORRECT_NUM_ERROR";
11838
+ const NOT_NONE_ABOVE_ERROR = "NOT_NONE_ABOVE_ERROR";
11839
+ const FILL_ALL_CELLS_ERROR = "FILL_ALL_CELLS_ERROR";
11835
11840
  const ErrorCodes = {
11836
11841
  MISSING_PERCENT_ERROR,
11837
11842
  NEEDS_TO_BE_SIMPLIFIED_ERROR,
@@ -11839,7 +11844,11 @@ const ErrorCodes = {
11839
11844
  EXTRA_SYMBOLS_ERROR,
11840
11845
  WRONG_CASE_ERROR,
11841
11846
  WRONG_LETTER_ERROR,
11842
- MULTIPLICATION_SIGN_ERROR
11847
+ MULTIPLICATION_SIGN_ERROR,
11848
+ INVALID_SELECTION_ERROR,
11849
+ CHOOSE_CORRECT_NUM_ERROR,
11850
+ NOT_NONE_ABOVE_ERROR,
11851
+ FILL_ALL_CELLS_ERROR
11843
11852
  };
11844
11853
 
11845
11854
  /* eslint-disable no-useless-escape */
@@ -12517,5 +12526,1324 @@ const KhanAnswerTypes = {
12517
12526
  }
12518
12527
  };
12519
12528
 
12520
- export { ErrorCodes, KhanAnswerTypes };
12529
+ /**
12530
+ * Checks userInput from the categorizer widget to see if the user has selected
12531
+ * a category for each item.
12532
+ * @param userInput - The user's input corresponding to an array of indices that
12533
+ * represent the selected category for each row/item.
12534
+ * @param validationData - An array of strings corresponding to each row/item
12535
+ * @param strings - Used to provide a validation message
12536
+ */
12537
+ function validateCategorizer(userInput, validationData) {
12538
+ const incomplete = validationData.items.some((_, i) => userInput.values[i] == null);
12539
+ if (incomplete) {
12540
+ return {
12541
+ type: "invalid",
12542
+ message: ErrorCodes$1.INVALID_SELECTION_ERROR
12543
+ };
12544
+ }
12545
+ return null;
12546
+ }
12547
+
12548
+ function scoreCategorizer(userInput, rubric) {
12549
+ const validationError = validateCategorizer(userInput, rubric);
12550
+ if (validationError) {
12551
+ return validationError;
12552
+ }
12553
+ let allCorrect = true;
12554
+ rubric.values.forEach((value, i) => {
12555
+ if (userInput.values[i] !== value) {
12556
+ allCorrect = false;
12557
+ }
12558
+ });
12559
+ return {
12560
+ type: "points",
12561
+ earned: allCorrect ? 1 : 0,
12562
+ total: 1,
12563
+ message: null
12564
+ };
12565
+ }
12566
+
12567
+ function scoreCSProgram(userInput) {
12568
+ // The CS program can tell us whether it's correct or incorrect,
12569
+ // and pass an optional message
12570
+ if (userInput.status === "correct") {
12571
+ return {
12572
+ type: "points",
12573
+ earned: 1,
12574
+ total: 1,
12575
+ message: userInput.message || null
12576
+ };
12577
+ }
12578
+ if (userInput.status === "incorrect") {
12579
+ return {
12580
+ type: "points",
12581
+ earned: 0,
12582
+ total: 1,
12583
+ message: userInput.message || null
12584
+ };
12585
+ }
12586
+ return {
12587
+ type: "invalid",
12588
+ message: "Keep going, you're not there yet!"
12589
+ };
12590
+ }
12591
+
12592
+ /**
12593
+ * Checks if the user has selected an item from the dropdown before scoring.
12594
+ * This is shown with a userInput value / index other than 0.
12595
+ */
12596
+ function validateDropdown(userInput) {
12597
+ if (userInput.value === 0) {
12598
+ return {
12599
+ type: "invalid",
12600
+ message: null
12601
+ };
12602
+ }
12603
+ return null;
12604
+ }
12605
+
12606
+ function scoreDropdown(userInput, rubric) {
12607
+ const validationError = validateDropdown(userInput);
12608
+ if (validationError) {
12609
+ return validationError;
12610
+ }
12611
+ const correct = rubric.choices[userInput.value - 1].correct;
12612
+ return {
12613
+ type: "points",
12614
+ earned: correct ? 1 : 0,
12615
+ total: 1,
12616
+ message: null
12617
+ };
12618
+ }
12619
+
12620
+ /**
12621
+ * Checks user input from the expression widget to see if it is scorable.
12622
+ *
12623
+ * Note: Most of the expression widget's validation requires the Rubric because
12624
+ * of its use of KhanAnswerTypes as a core part of scoring.
12625
+ *
12626
+ * @see `scoreExpression()` for more details.
12627
+ */
12628
+ function validateExpression(userInput) {
12629
+ if (userInput === "") {
12630
+ return {
12631
+ type: "invalid",
12632
+ message: null
12633
+ };
12634
+ }
12635
+ return null;
12636
+ }
12637
+
12638
+ /* Content creators input a list of answers which are matched from top to
12639
+ * bottom. The intent is that they can include spcific solutions which should
12640
+ * be graded as correct or incorrect (or ungraded!) first, then get more
12641
+ * general.
12642
+ *
12643
+ * We iterate through each answer, trying to match it with the user's input
12644
+ * using the following angorithm:
12645
+ * - Try to parse the user's input. If it doesn't parse then return "not
12646
+ * graded".
12647
+ * - For each answer:
12648
+ * ~ Try to validate the user's input against the answer. The answer is
12649
+ * expected to parse.
12650
+ * ~ If the user's input validates (the validator judges it "correct"), we've
12651
+ * matched and can stop considering answers.
12652
+ * - If there were no matches or the matching answer is considered "ungraded",
12653
+ * show the user an error. TODO(joel) - what error?
12654
+ * - Otherwise, pass through the resulting points and message.
12655
+ */
12656
+ function scoreExpression(userInput, rubric, locale) {
12657
+ const validationError = validateExpression(userInput);
12658
+ if (validationError) {
12659
+ return validationError;
12660
+ }
12661
+ const options = _.clone(rubric);
12662
+ _.extend(options, {
12663
+ decimal_separator: getDecimalSeparator(locale)
12664
+ });
12665
+ const createValidator = answer => {
12666
+ // We give options to KAS.parse here because it is parsing the
12667
+ // solution answer, not the student answer, and we don't want a
12668
+ // solution to work if the student is using a different language
12669
+ // (different from the content creation language, ie. English).
12670
+ const expression = KAS.parse(answer.value, rubric);
12671
+ // An answer may not be parsed if the expression was defined
12672
+ // incorrectly. For example if the answer is using a symbol defined
12673
+ // in the function variables list for the expression.
12674
+ if (!expression.parsed) {
12675
+ /* c8 ignore next */
12676
+ throw new PerseusError("Unable to parse solution answer for expression", Errors.InvalidInput, {
12677
+ metadata: {
12678
+ rubric: JSON.stringify(rubric)
12679
+ }
12680
+ });
12681
+ }
12682
+ return KhanAnswerTypes.expression.createValidatorFunctional(expression.expr, _({}).extend(options, {
12683
+ simplify: answer.simplify,
12684
+ form: answer.form
12685
+ }));
12686
+ };
12687
+
12688
+ // Find the first answer form that matches the user's input and that
12689
+ // is considered correct. Also, track whether the input is
12690
+ // considered "empty" for all answer forms, and keep the validation
12691
+ // result for the first answer form for which the user's input was
12692
+ // considered "ungraded".
12693
+ // (Terminology reminder: the answer forms are provided by the
12694
+ // assessment items; they are not the user's input. Each one might
12695
+ // represent a correct answer, an incorrect one (if the exercise
12696
+ // creator has predicted certain common wrong answers and wants to
12697
+ // provide guidance via a message), or an ungraded one (same idea,
12698
+ // but without giving the user an incorrect mark for the question).
12699
+ let matchingAnswerForm;
12700
+ let matchMessage;
12701
+ let allEmpty = true;
12702
+ let firstUngradedResult;
12703
+ for (const answerForm of rubric.answerForms || []) {
12704
+ const validator = createValidator(answerForm);
12705
+ if (!validator) {
12706
+ continue;
12707
+ }
12708
+ const result = validator(userInput);
12709
+
12710
+ // Short-circuit as soon as the user's input matches some answer
12711
+ // (independently of whether the answer is correct)
12712
+ if (result.correct) {
12713
+ matchingAnswerForm = answerForm;
12714
+ matchMessage = result.message || "";
12715
+ break;
12716
+ }
12717
+ allEmpty = allEmpty && result.empty;
12718
+ // If this answer form is correct and the user's input is considered
12719
+ // "ungraded" for it, we'll want to keep the evaluation result for
12720
+ // later. If the user's input doesn't match any answer forms, we'll
12721
+ // show the message from this validation.
12722
+ if (answerForm.considered === "correct" && result.ungraded && !firstUngradedResult) {
12723
+ firstUngradedResult = result;
12724
+ }
12725
+ }
12726
+
12727
+ // Now check to see if we matched any answer form at all, and if
12728
+ // we did, whether it's considered correct, incorrect, or ungraded
12729
+ if (!matchingAnswerForm) {
12730
+ if (firstUngradedResult) {
12731
+ // While we didn't directly match with any answer form, we
12732
+ // did at some point get an "ungraded" validation result,
12733
+ // which might indicate e.g. a mismatch in variable casing.
12734
+ // We'll return "invalid", which will let the user try again
12735
+ // with no penalty, and the hopefully helpful validation
12736
+ // message.
12737
+ return {
12738
+ type: "invalid",
12739
+ message: firstUngradedResult.message,
12740
+ suppressAlmostThere: firstUngradedResult.suppressAlmostThere
12741
+ };
12742
+ }
12743
+ if (allEmpty) {
12744
+ // If everything graded as empty, it's invalid.
12745
+ return {
12746
+ type: "invalid",
12747
+ message: null
12748
+ };
12749
+ }
12750
+ // We fell through all the possibilities and we're not empty,
12751
+ // so the answer is considered incorrect.
12752
+ return {
12753
+ type: "points",
12754
+ earned: 0,
12755
+ total: 1
12756
+ };
12757
+ }
12758
+ if (matchingAnswerForm.considered === "ungraded") {
12759
+ return {
12760
+ type: "invalid",
12761
+ message: matchMessage
12762
+ };
12763
+ }
12764
+ // We matched a graded answer form, so we can now tell the user
12765
+ // whether their input was correct or incorrect, and hand out
12766
+ // points accordingly
12767
+ return {
12768
+ type: "points",
12769
+ earned: matchingAnswerForm.considered === "correct" ? 1 : 0,
12770
+ total: 1,
12771
+ message: matchMessage
12772
+ };
12773
+ }
12774
+
12775
+ function getCoefficientsByType(data) {
12776
+ if (data.coords == null) {
12777
+ return undefined;
12778
+ }
12779
+ if (data.type === "exponential" || data.type === "logarithm") {
12780
+ const grader = GrapherUtil.functionForType(data.type);
12781
+ return grader.getCoefficients(data.coords, data.asymptote);
12782
+ } else if (data.type === "linear" || data.type === "quadratic" || data.type === "absolute_value" || data.type === "sinusoid" || data.type === "tangent") {
12783
+ const grader = GrapherUtil.functionForType(data.type);
12784
+ return grader.getCoefficients(data.coords);
12785
+ } else {
12786
+ throw new PerseusError("Invalid grapher type", Errors.InvalidInput);
12787
+ }
12788
+ }
12789
+ function scoreGrapher(userInput, rubric) {
12790
+ if (userInput.type !== rubric.correct.type) {
12791
+ return {
12792
+ type: "points",
12793
+ earned: 0,
12794
+ total: 1,
12795
+ message: null
12796
+ };
12797
+ }
12798
+
12799
+ // We haven't moved the coords
12800
+ if (userInput.coords == null) {
12801
+ return {
12802
+ type: "invalid",
12803
+ message: null
12804
+ };
12805
+ }
12806
+
12807
+ // Get new function handler for grading
12808
+ const grader = GrapherUtil.functionForType(userInput.type);
12809
+ const guessCoeffs = getCoefficientsByType(userInput);
12810
+ const correctCoeffs = getCoefficientsByType(rubric.correct);
12811
+ if (guessCoeffs == null || correctCoeffs == null) {
12812
+ return {
12813
+ type: "invalid",
12814
+ message: null
12815
+ };
12816
+ }
12817
+ if (grader.areEqual(guessCoeffs, correctCoeffs)) {
12818
+ return {
12819
+ type: "points",
12820
+ earned: 1,
12821
+ total: 1,
12822
+ message: null
12823
+ };
12824
+ }
12825
+ return {
12826
+ type: "points",
12827
+ earned: 0,
12828
+ total: 1,
12829
+ message: null
12830
+ };
12831
+ }
12832
+
12833
+ // TODO: merge this with scoreCSProgram, it's the same code
12834
+ function scoreIframe(userInput) {
12835
+ // The iframe can tell us whether it's correct or incorrect,
12836
+ // and pass an optional message
12837
+ if (userInput.status === "correct") {
12838
+ return {
12839
+ type: "points",
12840
+ earned: 1,
12841
+ total: 1,
12842
+ message: userInput.message || null
12843
+ };
12844
+ }
12845
+ if (userInput.status === "incorrect") {
12846
+ return {
12847
+ type: "points",
12848
+ earned: 0,
12849
+ total: 1,
12850
+ message: userInput.message || null
12851
+ };
12852
+ }
12853
+ return {
12854
+ type: "invalid",
12855
+ message: "Keep going, you're not there yet!"
12856
+ };
12857
+ }
12858
+
12859
+ const {
12860
+ collinear,
12861
+ canonicalSineCoefficients,
12862
+ similar
12863
+ } = geometry;
12864
+ const {
12865
+ getClockwiseAngle
12866
+ } = angles;
12867
+ const {
12868
+ getSinusoidCoefficients,
12869
+ getQuadraticCoefficients
12870
+ } = coefficients;
12871
+ function scoreInteractiveGraph(userInput, rubric) {
12872
+ // None-type graphs are not graded
12873
+ if (userInput.type === "none" && rubric.correct.type === "none") {
12874
+ return {
12875
+ type: "points",
12876
+ earned: 0,
12877
+ total: 0,
12878
+ message: null
12879
+ };
12880
+ }
12881
+
12882
+ // When nothing has moved, there will neither be coords nor the
12883
+ // circle's center/radius fields. When those fields are absent, skip
12884
+ // all these checks; just go mark the answer as empty.
12885
+ const hasValue = Boolean(
12886
+ // @ts-expect-error - TS2339 - Property 'coords' does not exist on type 'PerseusGraphType'.
12887
+ userInput.coords ||
12888
+ // @ts-expect-error - TS2339 - Property 'center' does not exist on type 'PerseusGraphType'. | TS2339 - Property 'radius' does not exist on type 'PerseusGraphType'.
12889
+ userInput.center && userInput.radius);
12890
+ if (userInput.type === rubric.correct.type && hasValue) {
12891
+ if (userInput.type === "linear" && rubric.correct.type === "linear" && userInput.coords != null) {
12892
+ const guess = userInput.coords;
12893
+ const correct = rubric.correct.coords;
12894
+
12895
+ // If both of the guess points are on the correct line, it's
12896
+ // correct.
12897
+ if (collinear(correct[0], correct[1], guess[0]) && collinear(correct[0], correct[1], guess[1])) {
12898
+ return {
12899
+ type: "points",
12900
+ earned: 1,
12901
+ total: 1,
12902
+ message: null
12903
+ };
12904
+ }
12905
+ } else if (userInput.type === "linear-system" && rubric.correct.type === "linear-system" && userInput.coords != null) {
12906
+ const guess = userInput.coords;
12907
+ const correct = rubric.correct.coords;
12908
+ if (collinear(correct[0][0], correct[0][1], guess[0][0]) && collinear(correct[0][0], correct[0][1], guess[0][1]) && collinear(correct[1][0], correct[1][1], guess[1][0]) && collinear(correct[1][0], correct[1][1], guess[1][1]) || collinear(correct[0][0], correct[0][1], guess[1][0]) && collinear(correct[0][0], correct[0][1], guess[1][1]) && collinear(correct[1][0], correct[1][1], guess[0][0]) && collinear(correct[1][0], correct[1][1], guess[0][1])) {
12909
+ return {
12910
+ type: "points",
12911
+ earned: 1,
12912
+ total: 1,
12913
+ message: null
12914
+ };
12915
+ }
12916
+ } else if (userInput.type === "quadratic" && rubric.correct.type === "quadratic" && userInput.coords != null) {
12917
+ // If the parabola coefficients match, it's correct.
12918
+ const guessCoeffs = getQuadraticCoefficients(userInput.coords);
12919
+ const correctCoeffs = getQuadraticCoefficients(rubric.correct.coords);
12920
+ if (approximateDeepEqual(guessCoeffs, correctCoeffs)) {
12921
+ return {
12922
+ type: "points",
12923
+ earned: 1,
12924
+ total: 1,
12925
+ message: null
12926
+ };
12927
+ }
12928
+ } else if (userInput.type === "sinusoid" && rubric.correct.type === "sinusoid" && userInput.coords != null) {
12929
+ const guessCoeffs = getSinusoidCoefficients(userInput.coords);
12930
+ const correctCoeffs = getSinusoidCoefficients(rubric.correct.coords);
12931
+ const canonicalGuessCoeffs = canonicalSineCoefficients(guessCoeffs);
12932
+ const canonicalCorrectCoeffs = canonicalSineCoefficients(correctCoeffs);
12933
+ // If the canonical coefficients match, it's correct.
12934
+ if (approximateDeepEqual(canonicalGuessCoeffs, canonicalCorrectCoeffs)) {
12935
+ return {
12936
+ type: "points",
12937
+ earned: 1,
12938
+ total: 1,
12939
+ message: null
12940
+ };
12941
+ }
12942
+ } else if (userInput.type === "circle" && rubric.correct.type === "circle") {
12943
+ if (approximateDeepEqual(userInput.center, rubric.correct.center) && approximateEqual(userInput.radius, rubric.correct.radius)) {
12944
+ return {
12945
+ type: "points",
12946
+ earned: 1,
12947
+ total: 1,
12948
+ message: null
12949
+ };
12950
+ }
12951
+ } else if (userInput.type === "point" && rubric.correct.type === "point" && userInput.coords != null) {
12952
+ let correct = rubric.correct.coords;
12953
+ if (correct == null) {
12954
+ throw new Error("Point graph rubric has null coords");
12955
+ }
12956
+ const guess = userInput.coords.slice();
12957
+ correct = correct.slice();
12958
+ // Everything's already rounded so we shouldn't need to do an
12959
+ // eq() comparison but _.isEqual(0, -0) is false, so we'll use
12960
+ // eq() anyway. The sort should be fine because it'll stringify
12961
+ // it and -0 converted to a string is "0"
12962
+ guess == null || guess.sort();
12963
+ // @ts-expect-error - TS2339 - Property 'sort' does not exist on type 'readonly Coord[]'.
12964
+ correct.sort();
12965
+ if (approximateDeepEqual(guess, correct)) {
12966
+ return {
12967
+ type: "points",
12968
+ earned: 1,
12969
+ total: 1,
12970
+ message: null
12971
+ };
12972
+ }
12973
+ } else if (userInput.type === "polygon" && rubric.correct.type === "polygon" && userInput.coords != null) {
12974
+ const guess = userInput.coords.slice();
12975
+ const correct = rubric.correct.coords.slice();
12976
+ let match;
12977
+ if (rubric.correct.match === "similar") {
12978
+ match = similar(guess, correct, Number.POSITIVE_INFINITY);
12979
+ } else if (rubric.correct.match === "congruent") {
12980
+ match = similar(guess, correct, number.DEFAULT_TOLERANCE);
12981
+ } else if (rubric.correct.match === "approx") {
12982
+ match = similar(guess, correct, 0.1);
12983
+ } else {
12984
+ /* exact */
12985
+ guess.sort();
12986
+ correct.sort();
12987
+ match = approximateDeepEqual(guess, correct);
12988
+ }
12989
+ if (match) {
12990
+ return {
12991
+ type: "points",
12992
+ earned: 1,
12993
+ total: 1,
12994
+ message: null
12995
+ };
12996
+ }
12997
+ } else if (userInput.type === "segment" && rubric.correct.type === "segment" && userInput.coords != null) {
12998
+ let guess = deepClone(userInput.coords);
12999
+ let correct = deepClone(rubric.correct.coords);
13000
+ guess = _.invoke(guess, "sort").sort();
13001
+ correct = _.invoke(correct, "sort").sort();
13002
+ if (approximateDeepEqual(guess, correct)) {
13003
+ return {
13004
+ type: "points",
13005
+ earned: 1,
13006
+ total: 1,
13007
+ message: null
13008
+ };
13009
+ }
13010
+ } else if (userInput.type === "ray" && rubric.correct.type === "ray" && userInput.coords != null) {
13011
+ const guess = userInput.coords;
13012
+ const correct = rubric.correct.coords;
13013
+ if (approximateDeepEqual(guess[0], correct[0]) && collinear(correct[0], correct[1], guess[1])) {
13014
+ return {
13015
+ type: "points",
13016
+ earned: 1,
13017
+ total: 1,
13018
+ message: null
13019
+ };
13020
+ }
13021
+ } else if (userInput.type === "angle" && rubric.correct.type === "angle") {
13022
+ const guess = userInput.coords;
13023
+ const correct = rubric.correct.coords;
13024
+ const allowReflexAngles = rubric.correct.allowReflexAngles;
13025
+ let match;
13026
+ if (rubric.correct.match === "congruent") {
13027
+ const angles = _.map([guess, correct], function (coords) {
13028
+ if (!coords) {
13029
+ return false;
13030
+ }
13031
+ const angle = getClockwiseAngle(coords, allowReflexAngles);
13032
+ return angle;
13033
+ });
13034
+ // @ts-expect-error - TS2556 - A spread argument must either have a tuple type or be passed to a rest parameter.
13035
+ match = approximateEqual(...angles);
13036
+ } else {
13037
+ /* exact */
13038
+ match =
13039
+ // @ts-expect-error - TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'.
13040
+ approximateDeepEqual(guess[1], correct[1]) &&
13041
+ // @ts-expect-error - TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'.
13042
+ collinear(correct[1], correct[0], guess[0]) &&
13043
+ // @ts-expect-error - TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'.
13044
+ collinear(correct[1], correct[2], guess[2]);
13045
+ }
13046
+ if (match) {
13047
+ return {
13048
+ type: "points",
13049
+ earned: 1,
13050
+ total: 1,
13051
+ message: null
13052
+ };
13053
+ }
13054
+ }
13055
+ }
13056
+
13057
+ // The input wasn't correct, so check if it's a blank input or if it's
13058
+ // actually just wrong
13059
+ if (!hasValue || _.isEqual(userInput, rubric.graph)) {
13060
+ // We're where we started.
13061
+ return {
13062
+ type: "invalid",
13063
+ message: null
13064
+ };
13065
+ }
13066
+ return {
13067
+ type: "points",
13068
+ earned: 0,
13069
+ total: 1,
13070
+ message: null
13071
+ };
13072
+ }
13073
+
13074
+ function validateLabelImage(userInput) {
13075
+ let numAnswered = 0;
13076
+ for (let i = 0; i < userInput.markers.length; i++) {
13077
+ const userSelection = userInput.markers[i].selected;
13078
+ if (userSelection && userSelection.length > 0) {
13079
+ numAnswered++;
13080
+ }
13081
+ }
13082
+ // We expect all question markers to be answered before grading.
13083
+ if (numAnswered !== userInput.markers.length) {
13084
+ return {
13085
+ type: "invalid",
13086
+ message: null
13087
+ };
13088
+ }
13089
+ return null;
13090
+ }
13091
+
13092
+ // Question state for marker as result of user selected answers.
13093
+
13094
+ function scoreLabelImageMarker(userInput, rubric) {
13095
+ const score = {
13096
+ hasAnswers: false,
13097
+ isCorrect: false
13098
+ };
13099
+ if (userInput && userInput.length > 0) {
13100
+ score.hasAnswers = true;
13101
+ }
13102
+ if (rubric.length > 0) {
13103
+ if (userInput && userInput.length === rubric.length) {
13104
+ // All correct answers are selected by the user.
13105
+ score.isCorrect = userInput.every(choice => rubric.includes(choice));
13106
+ }
13107
+ } else if (!userInput || userInput.length === 0) {
13108
+ // Correct as no answers should be selected by the user.
13109
+ score.isCorrect = true;
13110
+ }
13111
+ return score;
13112
+ }
13113
+ function scoreLabelImage(userInput, rubric) {
13114
+ const validationError = validateLabelImage(userInput);
13115
+ if (validationError) {
13116
+ return validationError;
13117
+ }
13118
+ let numCorrect = 0;
13119
+ for (let i = 0; i < userInput.markers.length; i++) {
13120
+ const score = scoreLabelImageMarker(userInput.markers[i].selected, rubric.markers[i].answers);
13121
+ if (score.isCorrect) {
13122
+ numCorrect++;
13123
+ }
13124
+ }
13125
+ return {
13126
+ type: "points",
13127
+ // Markers with no expected answers are graded as correct if user
13128
+ // makes no answer selection.
13129
+ earned: numCorrect === userInput.markers.length ? 1 : 0,
13130
+ total: 1,
13131
+ message: null
13132
+ };
13133
+ }
13134
+
13135
+ function scoreMatcher(userInput, rubric) {
13136
+ const correct = _.isEqual(userInput.left, rubric.left) && _.isEqual(userInput.right, rubric.right);
13137
+ return {
13138
+ type: "points",
13139
+ earned: correct ? 1 : 0,
13140
+ total: 1,
13141
+ message: null
13142
+ };
13143
+ }
13144
+
13145
+ /**
13146
+ * Checks user input from the matrix widget to see if it is scorable.
13147
+ *
13148
+ * Note: The matrix widget cannot do much validation without the Scoring
13149
+ * Data because of its use of KhanAnswerTypes as a core part of scoring.
13150
+ *
13151
+ * @see `scoreMatrix()` for more details.
13152
+ */
13153
+ function validateMatrix(userInput) {
13154
+ const supplied = userInput.answers;
13155
+ const suppliedSize = getMatrixSize(supplied);
13156
+ for (let row = 0; row < suppliedSize[0]; row++) {
13157
+ for (let col = 0; col < suppliedSize[1]; col++) {
13158
+ if (supplied[row][col] == null || supplied[row][col].toString().length === 0) {
13159
+ return {
13160
+ type: "invalid",
13161
+ message: ErrorCodes.FILL_ALL_CELLS_ERROR
13162
+ };
13163
+ }
13164
+ }
13165
+ }
13166
+ return null;
13167
+ }
13168
+
13169
+ function scoreMatrix(userInput, rubric) {
13170
+ const validationError = validateMatrix(userInput);
13171
+ if (validationError != null) {
13172
+ return validationError;
13173
+ }
13174
+ const solution = rubric.answers;
13175
+ const supplied = userInput.answers;
13176
+ const solutionSize = getMatrixSize(solution);
13177
+ const suppliedSize = getMatrixSize(supplied);
13178
+ const incorrectSize = solutionSize[0] !== suppliedSize[0] || solutionSize[1] !== suppliedSize[1];
13179
+ const createValidator = KhanAnswerTypes.number.createValidatorFunctional;
13180
+ let message = null;
13181
+ let incorrect = false;
13182
+ _(suppliedSize[0]).times(row => {
13183
+ _(suppliedSize[1]).times(col => {
13184
+ if (!incorrectSize) {
13185
+ const validator = createValidator(
13186
+ // @ts-expect-error - TS2345 - Argument of type 'number' is not assignable to parameter of type 'string'.
13187
+ solution[row][col], {
13188
+ simplify: true
13189
+ });
13190
+ const result = validator(supplied[row][col]);
13191
+ if (result.message) {
13192
+ // @ts-expect-error - TS2322 - Type 'string' is not assignable to type 'null'.
13193
+ message = result.message;
13194
+ }
13195
+ if (!result.correct) {
13196
+ incorrect = true;
13197
+ }
13198
+ }
13199
+ });
13200
+ });
13201
+ if (incorrectSize) {
13202
+ return {
13203
+ type: "points",
13204
+ earned: 0,
13205
+ total: 1,
13206
+ message: null
13207
+ };
13208
+ }
13209
+ return {
13210
+ type: "points",
13211
+ earned: incorrect ? 0 : 1,
13212
+ total: 1,
13213
+ message: message
13214
+ };
13215
+ }
13216
+
13217
+ /**
13218
+ * Checks user input is within the allowed range and not the same as the initial
13219
+ * state.
13220
+ * @param userInput
13221
+ * @see 'scoreNumberLine' for the scoring logic.
13222
+ */
13223
+ function validateNumberLine(userInput) {
13224
+ const divisionRange = userInput.divisionRange;
13225
+ const outsideAllowedRange = userInput.numDivisions > divisionRange[1] || userInput.numDivisions < divisionRange[0];
13226
+
13227
+ // TODO: I don't think isTickCrtl is a thing anymore
13228
+ if (userInput.isTickCrtl && outsideAllowedRange) {
13229
+ return {
13230
+ type: "invalid",
13231
+ message: "Number of divisions is outside the allowed range."
13232
+ };
13233
+ }
13234
+ return null;
13235
+ }
13236
+
13237
+ function scoreNumberLine(userInput, rubric) {
13238
+ const validationError = validateNumberLine(userInput);
13239
+ if (validationError) {
13240
+ return validationError;
13241
+ }
13242
+ const range = rubric.range;
13243
+ const start = rubric.initialX != null ? rubric.initialX : range[0];
13244
+ const startRel = rubric.isInequality ? "ge" : "eq";
13245
+ const correctRel = rubric.correctRel || "eq";
13246
+ const correctPos = number.equal(userInput.numLinePosition, rubric.correctX || 0);
13247
+ if (correctPos && correctRel === userInput.rel) {
13248
+ return {
13249
+ type: "points",
13250
+ earned: 1,
13251
+ total: 1,
13252
+ message: null
13253
+ };
13254
+ }
13255
+ if (userInput.numLinePosition === start && userInput.rel === startRel) {
13256
+ // We're where we started.
13257
+ return {
13258
+ type: "invalid",
13259
+ message: null
13260
+ };
13261
+ }
13262
+ return {
13263
+ type: "points",
13264
+ earned: 0,
13265
+ total: 1,
13266
+ message: null
13267
+ };
13268
+ }
13269
+
13270
+ function _extends() {
13271
+ return _extends = Object.assign ? Object.assign.bind() : function (n) {
13272
+ for (var e = 1; e < arguments.length; e++) {
13273
+ var t = arguments[e];
13274
+ for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]);
13275
+ }
13276
+ return n;
13277
+ }, _extends.apply(null, arguments);
13278
+ }
13279
+
13280
+ /*
13281
+ * In this file, an `expression` is some portion of valid TeX enclosed in
13282
+ * curly brackets.
13283
+ */
13284
+
13285
+ /*
13286
+ * Find the index at which an expression ends, i.e., has an unmatched
13287
+ * closing curly bracket. This method assumes that we start with a non-open
13288
+ * bracket character and end when we've seen more left than right brackets
13289
+ * (rather than assuming that we start with a bracket character and wait for
13290
+ * bracket equality).
13291
+ */
13292
+ function findEndpoint(tex, currentIndex) {
13293
+ let bracketDepth = 0;
13294
+ for (let i = currentIndex, len = tex.length; i < len; i++) {
13295
+ const c = tex[i];
13296
+ if (c === "{") {
13297
+ bracketDepth++;
13298
+ } else if (c === "}") {
13299
+ bracketDepth--;
13300
+ }
13301
+ if (bracketDepth < 0) {
13302
+ return i;
13303
+ }
13304
+ }
13305
+ // If we never see unbalanced curly brackets, default to the
13306
+ // entire string
13307
+ return tex.length;
13308
+ }
13309
+
13310
+ /*
13311
+ * Parses an individual set of curly brackets into TeX.
13312
+ */
13313
+ function parseNextExpression(tex, currentIndex, handler) {
13314
+ // Find the first '{' and grab subsequent TeX
13315
+ // Ex) tex: '{3}{7}', and we want the '3'
13316
+ const openBracketIndex = tex.indexOf("{", currentIndex);
13317
+ const nextExpIndex = openBracketIndex + 1;
13318
+
13319
+ // Truncate to only contain remaining TeX
13320
+ const endpoint = findEndpoint(tex, nextExpIndex);
13321
+ const expressionTeX = tex.substring(nextExpIndex, endpoint);
13322
+ const parsedExp = walkTex(expressionTeX, handler);
13323
+ return {
13324
+ endpoint: endpoint,
13325
+ expression: parsedExp
13326
+ };
13327
+ }
13328
+ function getNextFracIndex(tex, currentIndex) {
13329
+ const dfrac = "\\dfrac";
13330
+ const frac = "\\frac";
13331
+ const nextFrac = tex.indexOf(frac, currentIndex);
13332
+ const nextDFrac = tex.indexOf(dfrac, currentIndex);
13333
+ if (nextFrac > -1 && nextDFrac > -1) {
13334
+ return Math.min(nextFrac, nextDFrac);
13335
+ }
13336
+ if (nextFrac > -1) {
13337
+ return nextFrac;
13338
+ }
13339
+ if (nextDFrac > -1) {
13340
+ return nextDFrac;
13341
+ }
13342
+ return -1;
13343
+ }
13344
+ function walkTex(tex, handler) {
13345
+ if (!tex) {
13346
+ return "";
13347
+ }
13348
+
13349
+ // Ex) tex: '2 \dfrac {3}{7}'
13350
+ let parsedString = "";
13351
+ let currentIndex = 0;
13352
+ let nextFrac = getNextFracIndex(tex, currentIndex);
13353
+
13354
+ // For each \dfrac, find the two expressions (wrapped in {}) and recur
13355
+ while (nextFrac > -1) {
13356
+ // Gather first fragment, preceding \dfrac
13357
+ // Ex) parsedString: '2 '
13358
+ parsedString += tex.substring(currentIndex, nextFrac);
13359
+
13360
+ // Remove everything preceding \dfrac, which has been parsed
13361
+ currentIndex = nextFrac;
13362
+
13363
+ // Parse first expression and move index past it
13364
+ // Ex) firstParsedExpression.expression: '3'
13365
+ const firstParsedExpression = parseNextExpression(tex, currentIndex, handler);
13366
+ currentIndex = firstParsedExpression.endpoint + 1;
13367
+
13368
+ // Parse second expression
13369
+ // Ex) secondParsedExpression.expression: '7'
13370
+ const secondParsedExpression = parseNextExpression(tex, currentIndex, handler);
13371
+ currentIndex = secondParsedExpression.endpoint + 1;
13372
+
13373
+ // Add expressions to running total of parsed expressions
13374
+ if (parsedString.length) {
13375
+ parsedString += " ";
13376
+ }
13377
+
13378
+ // Apply a custom handler based on the parsed subexpressions
13379
+ parsedString += handler(firstParsedExpression.expression, secondParsedExpression.expression);
13380
+
13381
+ // Find next DFrac, relative to currentIndex
13382
+ nextFrac = getNextFracIndex(tex, currentIndex);
13383
+ }
13384
+
13385
+ // Add remaining TeX, which is \dfrac-free
13386
+ parsedString += tex.slice(currentIndex);
13387
+ return parsedString;
13388
+ }
13389
+
13390
+ /*
13391
+ * Parse a TeX expression into something interpretable by input-number.
13392
+ * The process is concerned with: (1) parsing fractions, i.e., \dfracs; and
13393
+ * (2) removing backslash-escaping from certain characters (right now, only
13394
+ * percent signs).
13395
+ *
13396
+ * The basic algorithm for handling \dfracs splits on \dfracs and then recurs
13397
+ * on the subsequent "expressions", i.e., the {} pairs that follow \dfrac. The
13398
+ * recursion is to allow for nested \dfrac elements.
13399
+ *
13400
+ * Backslash-escapes are removed with a simple search-and-replace.
13401
+ */
13402
+ function parseTex(tex) {
13403
+ const handler = function handler(exp1, exp2) {
13404
+ return exp1 + "/" + exp2;
13405
+ };
13406
+ const texWithoutFracs = walkTex(tex, handler);
13407
+ return texWithoutFracs.replace("\\%", "%");
13408
+ }
13409
+
13410
+ const answerFormButtons = [{
13411
+ title: "Integers",
13412
+ value: "integer",
13413
+ content: "6"
13414
+ }, {
13415
+ title: "Decimals",
13416
+ value: "decimal",
13417
+ content: "0.75"
13418
+ }, {
13419
+ title: "Proper fractions",
13420
+ value: "proper",
13421
+ content: "\u2157"
13422
+ }, {
13423
+ title: "Improper fractions",
13424
+ value: "improper",
13425
+ content: "\u2077\u2044\u2084"
13426
+ }, {
13427
+ title: "Mixed numbers",
13428
+ value: "mixed",
13429
+ content: "1\u00BE"
13430
+ }, {
13431
+ title: "Numbers with \u03C0",
13432
+ value: "pi",
13433
+ content: "\u03C0"
13434
+ }];
13435
+
13436
+ // This function checks if the user inputted a percent value, parsing
13437
+ // it as a number (and maybe scaling) so that it can be graded.
13438
+ // NOTE(michaelpolyak): Unlike `KhanAnswerTypes.number.percent()` which
13439
+ // can accept several input forms with or without "%", the decision
13440
+ // to parse based on the presence of "%" in the input, is so that we
13441
+ // don't accidently scale the user typed value before grading, CP-930.
13442
+ function maybeParsePercentInput(inputValue, normalizedAnswerExpected) {
13443
+ // If the input value is not a string ending with "%", then there's
13444
+ // nothing more to do. The value will be graded as inputted by user.
13445
+ if (!(typeof inputValue === "string" && inputValue.endsWith("%"))) {
13446
+ return inputValue;
13447
+ }
13448
+ const value = parseFloat(inputValue.slice(0, -1));
13449
+ // If the input value stripped of the "%" cannot be parsed as a
13450
+ // number (the slice is not really necessary for parseFloat to work
13451
+ // if the string starts with a number) then return the original
13452
+ // input for grading.
13453
+ if (isNaN(value)) {
13454
+ return inputValue;
13455
+ }
13456
+
13457
+ // Next, if all correct answers are in the range of |0,1| then we
13458
+ // scale the user typed value. We assume this is the correct thing
13459
+ // to do since the input value ends with "%".
13460
+ if (normalizedAnswerExpected) {
13461
+ return value / 100;
13462
+ }
13463
+
13464
+ // Otherwise, we return input value (number) stripped of the "%".
13465
+ return value;
13466
+ }
13467
+ function scoreNumericInput(userInput, rubric) {
13468
+ var _matchedAnswer$messag;
13469
+ const defaultAnswerForms = answerFormButtons.map(e => e["value"])
13470
+ // Don't default to validating the answer as a pi answer
13471
+ // if answerForm isn't set on the answer
13472
+ // https://khanacademy.atlassian.net/browse/LC-691
13473
+ .filter(e => e !== "pi");
13474
+ const createValidator = answer => {
13475
+ var _answer$answerForms;
13476
+ const stringAnswer = `${answer.value}`;
13477
+
13478
+ // Always validate against the provided answer forms (pi, decimal, etc.)
13479
+ const validatorForms = [...((_answer$answerForms = answer.answerForms) != null ? _answer$answerForms : [])];
13480
+
13481
+ // When an answer is set to strict, we validate using ONLY
13482
+ // the provided answerForms. If strict is false, or if there
13483
+ // were no provided answer forms, we will include all
13484
+ // of the default answer forms in our validator.
13485
+ if (!answer.strict || validatorForms.length === 0) {
13486
+ validatorForms.push(...defaultAnswerForms);
13487
+ }
13488
+ return KhanAnswerTypes.number.createValidatorFunctional(stringAnswer, {
13489
+ message: answer.message,
13490
+ simplify: answer.status === "correct" ? answer.simplify : "optional",
13491
+ inexact: true,
13492
+ // TODO(merlob) backfill / delete
13493
+ maxError: answer.maxError,
13494
+ forms: validatorForms
13495
+ });
13496
+ };
13497
+
13498
+ // We may have received TeX; try to parse it before grading.
13499
+ // If `currentValue` is not TeX, this should be a no-op.
13500
+ const currentValue = parseTex(userInput.currentValue);
13501
+ const normalizedAnswerExpected = rubric.answers.filter(answer => answer.status === "correct").every(answer => answer.value != null && Math.abs(answer.value) <= 1);
13502
+
13503
+ // The coefficient is an attribute of the widget
13504
+ let localValue = currentValue;
13505
+ if (rubric.coefficient) {
13506
+ if (!localValue) {
13507
+ localValue = 1;
13508
+ } else if (localValue === "-") {
13509
+ localValue = -1;
13510
+ }
13511
+ }
13512
+ const matchedAnswer = rubric.answers.map(answer => {
13513
+ const validateFn = createValidator(answer);
13514
+ const score = validateFn(maybeParsePercentInput(localValue, normalizedAnswerExpected));
13515
+ return _extends({}, answer, {
13516
+ score
13517
+ });
13518
+ }).find(answer => {
13519
+ // NOTE: "answer.score.correct" indicates a match via the validate function.
13520
+ // It does NOT indicate that the answer itself is correct.
13521
+ return answer.score.correct || answer.status === "correct" && answer.score.empty;
13522
+ });
13523
+ const result = (matchedAnswer == null ? void 0 : matchedAnswer.status) === "correct" ? matchedAnswer.score : {
13524
+ empty: (matchedAnswer == null ? void 0 : matchedAnswer.status) === "ungraded",
13525
+ correct: (matchedAnswer == null ? void 0 : matchedAnswer.status) === "correct",
13526
+ message: (_matchedAnswer$messag = matchedAnswer == null ? void 0 : matchedAnswer.message) != null ? _matchedAnswer$messag : null,
13527
+ guess: localValue
13528
+ };
13529
+ if (result.empty) {
13530
+ return {
13531
+ type: "invalid",
13532
+ message: result.message
13533
+ };
13534
+ }
13535
+ return {
13536
+ type: "points",
13537
+ earned: result.correct ? 1 : 0,
13538
+ total: 1,
13539
+ message: result.message
13540
+ };
13541
+ }
13542
+
13543
+ /**
13544
+ * Checks user input from the orderer widget to see if the user has started
13545
+ * ordering the options, making the widget scorable.
13546
+ * @param userInput
13547
+ * @see `scoreOrderer` for more details.
13548
+ */
13549
+ function validateOrderer(userInput) {
13550
+ if (userInput.current.length === 0) {
13551
+ return {
13552
+ type: "invalid",
13553
+ message: null
13554
+ };
13555
+ }
13556
+ return null;
13557
+ }
13558
+
13559
+ function scoreOrderer(userInput, rubric) {
13560
+ const validationError = validateOrderer(userInput);
13561
+ if (validationError) {
13562
+ return validationError;
13563
+ }
13564
+ const correct = _.isEqual(userInput.current, rubric.correctOptions.map(option => option.content));
13565
+ return {
13566
+ type: "points",
13567
+ earned: correct ? 1 : 0,
13568
+ total: 1,
13569
+ message: null
13570
+ };
13571
+ }
13572
+
13573
+ /**
13574
+ * Checks user input to confirm it is not the same as the starting values for the graph.
13575
+ * This means the user has modified the graph, and the question can be scored.
13576
+ *
13577
+ * @see 'scorePlotter' for more details on scoring.
13578
+ */
13579
+ function validatePlotter(userInput, validationData) {
13580
+ if (approximateDeepEqual(userInput, validationData.starting)) {
13581
+ return {
13582
+ type: "invalid",
13583
+ message: null
13584
+ };
13585
+ }
13586
+ return null;
13587
+ }
13588
+
13589
+ function scorePlotter(userInput, rubric) {
13590
+ const validationError = validatePlotter(userInput, rubric);
13591
+ if (validationError) {
13592
+ return validationError;
13593
+ }
13594
+ return {
13595
+ type: "points",
13596
+ earned: approximateDeepEqual(userInput, rubric.correct) ? 1 : 0,
13597
+ total: 1,
13598
+ message: null
13599
+ };
13600
+ }
13601
+
13602
+ /**
13603
+ * Checks if the user has selected at least one option. Additional validation
13604
+ * is done in scoreRadio to check if the number of selected options is correct
13605
+ * and if the user has selected both a correct option and the "none of the above"
13606
+ * option.
13607
+ * @param userInput
13608
+ * @see `scoreRadio` for the additional validation logic and the scoring logic.
13609
+ */
13610
+ function validateRadio(userInput) {
13611
+ const numSelected = userInput.choicesSelected.reduce((sum, selected) => {
13612
+ return sum + (selected ? 1 : 0);
13613
+ }, 0);
13614
+ if (numSelected === 0) {
13615
+ return {
13616
+ type: "invalid",
13617
+ message: null
13618
+ };
13619
+ }
13620
+ return null;
13621
+ }
13622
+
13623
+ function scoreRadio(userInput, rubric) {
13624
+ const validationError = validateRadio(userInput);
13625
+ if (validationError) {
13626
+ return validationError;
13627
+ }
13628
+ const numSelected = userInput.choicesSelected.reduce((sum, selected) => {
13629
+ return sum + (selected ? 1 : 0);
13630
+ }, 0);
13631
+ const numCorrect = rubric.choices.reduce((sum, currentChoice) => {
13632
+ return currentChoice.correct ? sum + 1 : sum;
13633
+ }, 0);
13634
+ if (numCorrect > 1 && numSelected !== numCorrect) {
13635
+ return {
13636
+ type: "invalid",
13637
+ message: ErrorCodes.CHOOSE_CORRECT_NUM_ERROR
13638
+ };
13639
+ // If NOTA and some other answer are checked, ...
13640
+ }
13641
+
13642
+ const noneOfTheAboveSelected = rubric.choices.some((choice, index) => choice.isNoneOfTheAbove && userInput.choicesSelected[index]);
13643
+ if (noneOfTheAboveSelected && numSelected > 1) {
13644
+ return {
13645
+ type: "invalid",
13646
+ message: ErrorCodes.NOT_NONE_ABOVE_ERROR
13647
+ };
13648
+ }
13649
+ const correct = userInput.choicesSelected.every((selected, i) => {
13650
+ let isCorrect;
13651
+ if (rubric.choices[i].isNoneOfTheAbove) {
13652
+ isCorrect = rubric.choices.every((choice, j) => {
13653
+ return i === j || !choice.correct;
13654
+ });
13655
+ } else {
13656
+ isCorrect = !!rubric.choices[i].correct;
13657
+ }
13658
+ return isCorrect === selected;
13659
+ });
13660
+ return {
13661
+ type: "points",
13662
+ earned: correct ? 1 : 0,
13663
+ total: 1,
13664
+ message: null
13665
+ };
13666
+ }
13667
+
13668
+ /**
13669
+ * Checks user input for the sorter widget to ensure that the user has made
13670
+ * changes before attempting to score the widget.
13671
+ * @param userInput
13672
+ * @see 'scoreSorter' in 'packages/perseus/src/widgets/sorter/score-sorter.ts'
13673
+ * for more details on how the sorter widget is scored.
13674
+ */
13675
+ function validateSorter(userInput) {
13676
+ // If the sorter widget hasn't been changed yet, we treat it as "empty" which
13677
+ // prevents the "Check" button from becoming active. We want the user
13678
+ // to make a change before trying to move forward. This makes an
13679
+ // assumption that the initial order isn't the correct order! However,
13680
+ // this should be rare if it happens, and interacting with the list
13681
+ // will enable the button, so they won't be locked out of progressing.
13682
+ if (!userInput.changed) {
13683
+ return {
13684
+ type: "invalid",
13685
+ message: null
13686
+ };
13687
+ }
13688
+ return null;
13689
+ }
13690
+
13691
+ function scoreSorter(userInput, rubric) {
13692
+ const validationError = validateSorter(userInput);
13693
+ if (validationError) {
13694
+ return validationError;
13695
+ }
13696
+ const correct = approximateDeepEqual(userInput.options, rubric.correct);
13697
+ return {
13698
+ type: "points",
13699
+ earned: correct ? 1 : 0,
13700
+ total: 1,
13701
+ message: null
13702
+ };
13703
+ }
13704
+
13705
+ /**
13706
+ * Filters the given table (modelled as a 2D array) to remove any rows that are
13707
+ * completely empty.
13708
+ *
13709
+ * @returns A new table with only non-empty rows.
13710
+ */
13711
+ const filterNonEmpty = function filterNonEmpty(table) {
13712
+ return table.filter(function (row) {
13713
+ // Return only rows that are non-empty.
13714
+ return row.some(cell => cell);
13715
+ });
13716
+ };
13717
+
13718
+ function validateTable(userInput) {
13719
+ const supplied = filterNonEmpty(userInput);
13720
+ const hasEmptyCell = supplied.some(function (row) {
13721
+ return row.some(function (cell) {
13722
+ return cell === "";
13723
+ });
13724
+ });
13725
+ if (hasEmptyCell || !supplied.length) {
13726
+ return {
13727
+ type: "invalid",
13728
+ message: null
13729
+ };
13730
+ }
13731
+ return null;
13732
+ }
13733
+
13734
+ function scoreTable(userInput, rubric) {
13735
+ const validationResult = validateTable(userInput);
13736
+ if (validationResult != null) {
13737
+ return validationResult;
13738
+ }
13739
+ const supplied = filterNonEmpty(userInput);
13740
+ const solution = filterNonEmpty(rubric.answers);
13741
+ if (supplied.length !== solution.length) {
13742
+ return {
13743
+ type: "points",
13744
+ earned: 0,
13745
+ total: 1,
13746
+ message: null
13747
+ };
13748
+ }
13749
+ const createValidator = KhanAnswerTypes.number.createValidatorFunctional;
13750
+ let message = null;
13751
+ const allCorrect = solution.every(function (rowSolution) {
13752
+ for (let i = 0; i < supplied.length; i++) {
13753
+ const rowSupplied = supplied[i];
13754
+ const correct = rowSupplied.every(function (cellSupplied, i) {
13755
+ const cellSolution = rowSolution[i];
13756
+ const validator = createValidator(cellSolution, {
13757
+ simplify: true
13758
+ });
13759
+ const result = validator(cellSupplied);
13760
+ if (result.message) {
13761
+ message = result.message;
13762
+ }
13763
+ return result.correct;
13764
+ });
13765
+ if (correct) {
13766
+ supplied.splice(i, 1);
13767
+ return true;
13768
+ }
13769
+ }
13770
+ return false;
13771
+ });
13772
+ return {
13773
+ type: "points",
13774
+ earned: allCorrect ? 1 : 0,
13775
+ total: 1,
13776
+ message
13777
+ };
13778
+ }
13779
+
13780
+ const inputNumberAnswerTypes = {
13781
+ number: {
13782
+ name: "Numbers",
13783
+ forms: "integer, decimal, proper, improper, mixed"
13784
+ },
13785
+ decimal: {
13786
+ name: "Decimals",
13787
+ forms: "decimal"
13788
+ },
13789
+ integer: {
13790
+ name: "Integers",
13791
+ forms: "integer"
13792
+ },
13793
+ rational: {
13794
+ name: "Fractions and mixed numbers",
13795
+ forms: "integer, proper, improper, mixed"
13796
+ },
13797
+ improper: {
13798
+ name: "Improper numbers (no mixed)",
13799
+ forms: "integer, proper, improper"
13800
+ },
13801
+ mixed: {
13802
+ name: "Mixed numbers (no improper)",
13803
+ forms: "integer, proper, mixed"
13804
+ },
13805
+ percent: {
13806
+ name: "Numbers or percents",
13807
+ forms: "integer, decimal, proper, improper, mixed, percent"
13808
+ },
13809
+ pi: {
13810
+ name: "Numbers with pi",
13811
+ forms: "pi"
13812
+ }
13813
+ };
13814
+ function scoreInputNumber(userInput, rubric) {
13815
+ if (rubric.answerType == null) {
13816
+ rubric.answerType = "number";
13817
+ }
13818
+
13819
+ // note(matthewc): this will get immediately parsed again by
13820
+ // `KhanAnswerTypes.number.convertToPredicate`, but a string is
13821
+ // expected here
13822
+ const stringValue = `${rubric.value}`;
13823
+ const val = KhanAnswerTypes.number.createValidatorFunctional(stringValue, {
13824
+ simplify: rubric.simplify,
13825
+ inexact: rubric.inexact || undefined,
13826
+ maxError: rubric.maxError,
13827
+ forms: inputNumberAnswerTypes[rubric.answerType].forms
13828
+ });
13829
+
13830
+ // We may have received TeX; try to parse it before grading.
13831
+ // If `currentValue` is not TeX, this should be a no-op.
13832
+ const currentValue = parseTex(userInput.currentValue);
13833
+ const result = val(currentValue);
13834
+ if (result.empty) {
13835
+ return {
13836
+ type: "invalid",
13837
+ message: result.message
13838
+ };
13839
+ }
13840
+ return {
13841
+ type: "points",
13842
+ earned: result.correct ? 1 : 0,
13843
+ total: 1,
13844
+ message: result.message
13845
+ };
13846
+ }
13847
+
13848
+ export { ErrorCodes, KhanAnswerTypes, inputNumberAnswerTypes, scoreCSProgram, scoreCategorizer, scoreDropdown, scoreExpression, scoreGrapher, scoreIframe, scoreInputNumber, scoreInteractiveGraph, scoreLabelImage, scoreLabelImageMarker, scoreMatcher, scoreMatrix, scoreNumberLine, scoreNumericInput, scoreOrderer, scorePlotter, scoreRadio, scoreSorter, scoreTable, validateCategorizer, validateDropdown, validateExpression, validateMatrix, validateNumberLine, validateOrderer, validatePlotter, validateRadio, validateSorter, validateTable };
12521
13849
  //# sourceMappingURL=index.js.map