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