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