@khanacademy/perseus-score 1.1.0 → 2.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 (32) hide show
  1. package/dist/es/index.js +424 -196
  2. package/dist/es/index.js.map +1 -1
  3. package/dist/index.d.ts +14 -0
  4. package/dist/index.js +437 -194
  5. package/dist/index.js.map +1 -1
  6. package/dist/score.d.ts +21 -0
  7. package/dist/util/score-noop.d.ts +11 -0
  8. package/dist/util/test-helpers.d.ts +14 -0
  9. package/dist/validate.d.ts +7 -0
  10. package/dist/validation.types.d.ts +136 -32
  11. package/dist/validation.typetest.d.ts +1 -0
  12. package/dist/widgets/categorizer/score-categorizer.d.ts +2 -2
  13. package/dist/widgets/cs-program/score-cs-program.d.ts +1 -1
  14. package/dist/widgets/expression/score-expression.d.ts +1 -1
  15. package/dist/widgets/grapher/score-grapher.d.ts +1 -1
  16. package/dist/widgets/group/score-group.d.ts +3 -0
  17. package/dist/widgets/group/validate-group.d.ts +3 -0
  18. package/dist/widgets/interactive-graph/score-interactive-graph.d.ts +1 -1
  19. package/dist/widgets/label-image/score-label-image.d.ts +2 -3
  20. package/dist/widgets/label-image/validate-label-image.d.ts +3 -0
  21. package/dist/widgets/matcher/score-matcher.d.ts +1 -1
  22. package/dist/widgets/matrix/score-matrix.d.ts +1 -1
  23. package/dist/widgets/matrix/validate-matrix.d.ts +2 -2
  24. package/dist/widgets/mock-widget/mock-widget-validation.types.d.ts +6 -0
  25. package/dist/widgets/mock-widget/score-mock-widget.d.ts +4 -0
  26. package/dist/widgets/mock-widget/validate-mock-widget.d.ts +4 -0
  27. package/dist/widgets/number-line/score-number-line.d.ts +2 -2
  28. package/dist/widgets/plotter/score-plotter.d.ts +2 -2
  29. package/dist/widgets/sorter/score-sorter.d.ts +1 -1
  30. package/dist/widgets/table/score-table.d.ts +1 -1
  31. package/dist/widgets/widget-registry.d.ts +4 -0
  32. package/package.json +4 -4
package/dist/index.js CHANGED
@@ -12551,6 +12551,21 @@ const KhanAnswerTypes = {
12551
12551
  }
12552
12552
  };
12553
12553
 
12554
+ function scoreCategorizer(userInput, rubric) {
12555
+ let allCorrect = true;
12556
+ rubric.values.forEach((value, i) => {
12557
+ if (userInput.values[i] !== value) {
12558
+ allCorrect = false;
12559
+ }
12560
+ });
12561
+ return {
12562
+ type: "points",
12563
+ earned: allCorrect ? 1 : 0,
12564
+ total: 1,
12565
+ message: null
12566
+ };
12567
+ }
12568
+
12554
12569
  /**
12555
12570
  * Checks userInput from the categorizer widget to see if the user has selected
12556
12571
  * a category for each item.
@@ -12570,43 +12585,23 @@ function validateCategorizer(userInput, validationData) {
12570
12585
  return null;
12571
12586
  }
12572
12587
 
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) {
12588
+ function scoreCSProgram(userInput) {
12594
12589
  // The CS program can tell us whether it's correct or incorrect,
12595
12590
  // and pass an optional message
12596
- if (state.status === "correct") {
12591
+ if (userInput.status === "correct") {
12597
12592
  return {
12598
12593
  type: "points",
12599
12594
  earned: 1,
12600
12595
  total: 1,
12601
- message: state.message || null
12596
+ message: userInput.message || null
12602
12597
  };
12603
12598
  }
12604
- if (state.status === "incorrect") {
12599
+ if (userInput.status === "incorrect") {
12605
12600
  return {
12606
12601
  type: "points",
12607
12602
  earned: 0,
12608
12603
  total: 1,
12609
- message: state.message || null
12604
+ message: userInput.message || null
12610
12605
  };
12611
12606
  }
12612
12607
  return {
@@ -12615,25 +12610,7 @@ function scoreCSProgram(state) {
12615
12610
  };
12616
12611
  }
12617
12612
 
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
12613
  function scoreDropdown(userInput, rubric) {
12633
- const validationError = validateDropdown(userInput);
12634
- if (validationError) {
12635
- return validationError;
12636
- }
12637
12614
  const correct = rubric.choices[userInput.value - 1].correct;
12638
12615
  return {
12639
12616
  type: "points",
@@ -12644,15 +12621,11 @@ function scoreDropdown(userInput, rubric) {
12644
12621
  }
12645
12622
 
12646
12623
  /**
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.
12624
+ * Checks if the user has selected an item from the dropdown before scoring.
12625
+ * This is shown with a userInput value / index other than 0.
12653
12626
  */
12654
- function validateExpression(userInput) {
12655
- if (userInput === "") {
12627
+ function validateDropdown(userInput) {
12628
+ if (userInput.value === 0) {
12656
12629
  return {
12657
12630
  type: "invalid",
12658
12631
  message: null
@@ -12679,13 +12652,7 @@ function validateExpression(userInput) {
12679
12652
  * show the user an error. TODO(joel) - what error?
12680
12653
  * - Otherwise, pass through the resulting points and message.
12681
12654
  */
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
- }
12655
+ function scoreExpression(userInput, rubric, locale) {
12689
12656
  const options = _.clone(rubric);
12690
12657
  _.extend(options, {
12691
12658
  decimal_separator: perseusCore.getDecimalSeparator(locale)
@@ -12701,7 +12668,11 @@ strings, locale) {
12701
12668
  // in the function variables list for the expression.
12702
12669
  if (!expression.parsed) {
12703
12670
  /* c8 ignore next */
12704
- throw new perseusCore.PerseusError("Unable to parse solution answer for expression", perseusCore.Errors.InvalidInput);
12671
+ throw new perseusCore.PerseusError("Unable to parse solution answer for expression", perseusCore.Errors.InvalidInput, {
12672
+ metadata: {
12673
+ rubric: JSON.stringify(rubric)
12674
+ }
12675
+ });
12705
12676
  }
12706
12677
  return KhanAnswerTypes.expression.createValidatorFunctional(expression.expr, _({}).extend(options, {
12707
12678
  simplify: answer.simplify,
@@ -12796,6 +12767,24 @@ strings, locale) {
12796
12767
  };
12797
12768
  }
12798
12769
 
12770
+ /**
12771
+ * Checks user input from the expression widget to see if it is scorable.
12772
+ *
12773
+ * Note: Most of the expression widget's validation requires the Rubric because
12774
+ * of its use of KhanAnswerTypes as a core part of scoring.
12775
+ *
12776
+ * @see `scoreExpression()` for more details.
12777
+ */
12778
+ function validateExpression(userInput) {
12779
+ if (userInput === "") {
12780
+ return {
12781
+ type: "invalid",
12782
+ message: null
12783
+ };
12784
+ }
12785
+ return null;
12786
+ }
12787
+
12799
12788
  function getCoefficientsByType(data) {
12800
12789
  if (data.coords == null) {
12801
12790
  return undefined;
@@ -13097,47 +13086,33 @@ function scoreInteractiveGraph(userInput, rubric) {
13097
13086
 
13098
13087
  // Question state for marker as result of user selected answers.
13099
13088
 
13100
- function scoreLabelImageMarker(marker) {
13089
+ function scoreLabelImageMarker(userInput, rubric) {
13101
13090
  const score = {
13102
13091
  hasAnswers: false,
13103
13092
  isCorrect: false
13104
13093
  };
13105
- if (marker.selected && marker.selected.length > 0) {
13094
+ if (userInput && userInput.length > 0) {
13106
13095
  score.hasAnswers = true;
13107
13096
  }
13108
- if (marker.answers.length > 0) {
13109
- if (marker.selected && marker.selected.length === marker.answers.length) {
13097
+ if (rubric.length > 0) {
13098
+ if (userInput && userInput.length === rubric.length) {
13110
13099
  // All correct answers are selected by the user.
13111
- score.isCorrect = marker.selected.every(choice => marker.answers.includes(choice));
13100
+ score.isCorrect = userInput.every(choice => rubric.includes(choice));
13112
13101
  }
13113
- } else if (!marker.selected || marker.selected.length === 0) {
13102
+ } else if (!userInput || userInput.length === 0) {
13114
13103
  // Correct as no answers should be selected by the user.
13115
13104
  score.isCorrect = true;
13116
13105
  }
13117
13106
  return score;
13118
13107
  }
13119
-
13120
- // TODO(LEMS-2440): May need to pull answers out of PerseusLabelImageWidgetOptions[markers] for the rubric
13121
13108
  function scoreLabelImage(userInput, rubric) {
13122
- let numAnswered = 0;
13123
13109
  let numCorrect = 0;
13124
- for (const marker of userInput.markers) {
13125
- const score = scoreLabelImageMarker(marker);
13126
- if (score.hasAnswers) {
13127
- numAnswered++;
13128
- }
13110
+ for (let i = 0; i < userInput.markers.length; i++) {
13111
+ const score = scoreLabelImageMarker(userInput.markers[i].selected, rubric.markers[i].answers);
13129
13112
  if (score.isCorrect) {
13130
13113
  numCorrect++;
13131
13114
  }
13132
13115
  }
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
13116
  return {
13142
13117
  type: "points",
13143
13118
  // Markers with no expected answers are graded as correct if user
@@ -13148,8 +13123,8 @@ function scoreLabelImage(userInput, rubric) {
13148
13123
  };
13149
13124
  }
13150
13125
 
13151
- function scoreMatcher(state, rubric) {
13152
- const correct = _.isEqual(state.left, rubric.left) && _.isEqual(state.right, rubric.right);
13126
+ function scoreMatcher(userInput, rubric) {
13127
+ const correct = _.isEqual(userInput.left, rubric.left) && _.isEqual(userInput.right, rubric.right);
13153
13128
  return {
13154
13129
  type: "points",
13155
13130
  earned: correct ? 1 : 0,
@@ -13158,35 +13133,7 @@ function scoreMatcher(state, rubric) {
13158
13133
  };
13159
13134
  }
13160
13135
 
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
13136
  function scoreMatrix(userInput, rubric) {
13186
- const validationResult = validateMatrix(userInput);
13187
- if (validationResult != null) {
13188
- return validationResult;
13189
- }
13190
13137
  const solution = rubric.answers;
13191
13138
  const supplied = userInput.answers;
13192
13139
  const solutionSize = perseusCore.getMatrixSize(solution);
@@ -13231,35 +13178,35 @@ function scoreMatrix(userInput, rubric) {
13231
13178
  }
13232
13179
 
13233
13180
  /**
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.
13181
+ * Checks user input from the matrix widget to see if it is scorable.
13182
+ *
13183
+ * Note: The matrix widget cannot do much validation without the Scoring
13184
+ * Data because of its use of KhanAnswerTypes as a core part of scoring.
13185
+ *
13186
+ * @see `scoreMatrix()` for more details.
13238
13187
  */
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
- };
13188
+ function validateMatrix(userInput) {
13189
+ const supplied = userInput.answers;
13190
+ const suppliedSize = perseusCore.getMatrixSize(supplied);
13191
+ for (let row = 0; row < suppliedSize[0]; row++) {
13192
+ for (let col = 0; col < suppliedSize[1]; col++) {
13193
+ if (supplied[row][col] == null || supplied[row][col].toString().length === 0) {
13194
+ return {
13195
+ type: "invalid",
13196
+ message: ErrorCodes.FILL_ALL_CELLS_ERROR
13197
+ };
13198
+ }
13199
+ }
13249
13200
  }
13250
13201
  return null;
13251
13202
  }
13252
13203
 
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);
13204
+ function scoreNumberLine(userInput, rubric) {
13205
+ const range = rubric.range;
13206
+ const start = rubric.initialX != null ? rubric.initialX : range[0];
13207
+ const startRel = rubric.isInequality ? "ge" : "eq";
13208
+ const correctRel = rubric.correctRel || "eq";
13209
+ const correctPos = kmath.number.equal(userInput.numLinePosition, rubric.correctX || 0);
13263
13210
  if (correctPos && correctRel === userInput.rel) {
13264
13211
  return {
13265
13212
  type: "points",
@@ -13283,6 +13230,26 @@ function scoreNumberLine(userInput, scoringData) {
13283
13230
  };
13284
13231
  }
13285
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
+
13286
13253
  /*
13287
13254
  * In this file, an `expression` is some portion of valid TeX enclosed in
13288
13255
  * curly brackets.
@@ -13545,6 +13512,16 @@ function scoreNumericInput(userInput, rubric) {
13545
13512
  };
13546
13513
  }
13547
13514
 
13515
+ function scoreOrderer(userInput, rubric) {
13516
+ const correct = _.isEqual(userInput.current, rubric.correctOptions.map(option => option.content));
13517
+ return {
13518
+ type: "points",
13519
+ earned: correct ? 1 : 0,
13520
+ total: 1,
13521
+ message: null
13522
+ };
13523
+ }
13524
+
13548
13525
  /**
13549
13526
  * Checks user input from the orderer widget to see if the user has started
13550
13527
  * ordering the options, making the widget scorable.
@@ -13561,15 +13538,10 @@ function validateOrderer(userInput) {
13561
13538
  return null;
13562
13539
  }
13563
13540
 
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));
13541
+ function scorePlotter(userInput, rubric) {
13570
13542
  return {
13571
13543
  type: "points",
13572
- earned: correct ? 1 : 0,
13544
+ earned: perseusCore.approximateDeepEqual(userInput, rubric.correct) ? 1 : 0,
13573
13545
  total: 1,
13574
13546
  message: null
13575
13547
  };
@@ -13591,45 +13563,7 @@ function validatePlotter(userInput, validationData) {
13591
13563
  return null;
13592
13564
  }
13593
13565
 
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
13566
  function scoreRadio(userInput, rubric) {
13629
- const validationError = validateRadio(userInput);
13630
- if (validationError) {
13631
- return validationError;
13632
- }
13633
13567
  const numSelected = userInput.choicesSelected.reduce((sum, selected) => {
13634
13568
  return sum + (selected ? 1 : 0);
13635
13569
  }, 0);
@@ -13670,6 +13604,37 @@ function scoreRadio(userInput, rubric) {
13670
13604
  };
13671
13605
  }
13672
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 scoreSorter(userInput, rubric) {
13629
+ const correct = perseusCore.approximateDeepEqual(userInput.options, rubric.correct);
13630
+ return {
13631
+ type: "points",
13632
+ earned: correct ? 1 : 0,
13633
+ total: 1,
13634
+ message: null
13635
+ };
13636
+ }
13637
+
13673
13638
  /**
13674
13639
  * Checks user input for the sorter widget to ensure that the user has made
13675
13640
  * changes before attempting to score the widget.
@@ -13693,20 +13658,6 @@ function validateSorter(userInput) {
13693
13658
  return null;
13694
13659
  }
13695
13660
 
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
13661
  /**
13711
13662
  * Filters the given table (modelled as a 2D array) to remove any rows that are
13712
13663
  * completely empty.
@@ -13850,9 +13801,289 @@ function scoreInputNumber(userInput, rubric) {
13850
13801
  };
13851
13802
  }
13852
13803
 
13804
+ /**
13805
+ * Several widgets don't have "right"/"wrong" scoring logic,
13806
+ * so this just says to move on past those widgets
13807
+ *
13808
+ * TODO(LEMS-2543) widgets that use this probably shouldn't have any
13809
+ * scoring logic and the thing scoring an exercise
13810
+ * should just know to skip these
13811
+ */
13812
+ function scoreNoop() {
13813
+ let points = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 0;
13814
+ return {
13815
+ type: "points",
13816
+ earned: points,
13817
+ total: points,
13818
+ message: null
13819
+ };
13820
+ }
13821
+
13822
+ // The `group` widget is basically a widget hosting a full Perseus system in
13823
+ // it. As such, scoring a group means scoring all widgets it contains.
13824
+ function scoreGroup(userInput, rubric, locale) {
13825
+ const scores = scoreWidgetsFunctional(rubric.widgets, Object.keys(rubric.widgets), userInput, locale);
13826
+ return flattenScores(scores);
13827
+ }
13828
+
13829
+ /**
13830
+ * Checks the given user input to see if any answerable widgets have not been
13831
+ * "filled in" (ie. if they're empty). Another way to think about this
13832
+ * function is that its a check to see if we can score the provided input.
13833
+ */
13834
+ function emptyWidgetsFunctional(widgets,
13835
+ // This is a port of old code, I'm not sure why
13836
+ // we need widgetIds vs the keys of the widgets object
13837
+ widgetIds, userInputMap, locale) {
13838
+ return widgetIds.filter(id => {
13839
+ const widget = widgets[id];
13840
+ if (!widget || widget.static === true) {
13841
+ // Static widgets shouldn't count as empty
13842
+ return false;
13843
+ }
13844
+ const validator = getWidgetValidator(widget.type);
13845
+ const userInput = userInputMap[id];
13846
+ const validationData = widget.options;
13847
+ const score = validator?.(userInput, validationData, locale);
13848
+ if (score) {
13849
+ return scoreIsEmpty(score);
13850
+ }
13851
+ });
13852
+ }
13853
+
13854
+ function validateGroup(userInput, validationData, locale) {
13855
+ const emptyWidgets = emptyWidgetsFunctional(validationData.widgets, Object.keys(validationData.widgets), userInput, locale);
13856
+ if (emptyWidgets.length === 0) {
13857
+ return null;
13858
+ }
13859
+ return {
13860
+ type: "invalid",
13861
+ message: null
13862
+ };
13863
+ }
13864
+
13865
+ function validateLabelImage(userInput) {
13866
+ let numAnswered = 0;
13867
+ for (let i = 0; i < userInput.markers.length; i++) {
13868
+ const userSelection = userInput.markers[i].selected;
13869
+ if (userSelection && userSelection.length > 0) {
13870
+ numAnswered++;
13871
+ }
13872
+ }
13873
+ // We expect all question markers to be answered before grading.
13874
+ if (numAnswered !== userInput.markers.length) {
13875
+ return {
13876
+ type: "invalid",
13877
+ message: null
13878
+ };
13879
+ }
13880
+ return null;
13881
+ }
13882
+
13883
+ function validateMockWidget(userInput) {
13884
+ if (userInput.currentValue == null || userInput.currentValue === "") {
13885
+ return {
13886
+ type: "invalid",
13887
+ message: ""
13888
+ };
13889
+ }
13890
+ return null;
13891
+ }
13892
+
13893
+ function scoreMockWidget(userInput, rubric) {
13894
+ const validationResult = validateMockWidget(userInput);
13895
+ if (validationResult != null) {
13896
+ return validationResult;
13897
+ }
13898
+ return {
13899
+ type: "points",
13900
+ earned: userInput.currentValue === rubric.value ? 1 : 0,
13901
+ total: 1,
13902
+ message: ""
13903
+ };
13904
+ }
13905
+
13906
+ const widgets = {};
13907
+ function registerWidget(type, scorer, validator) {
13908
+ widgets[type] = {
13909
+ scorer,
13910
+ validator
13911
+ };
13912
+ }
13913
+ const getWidgetValidator = name => {
13914
+ return widgets[name]?.validator ?? null;
13915
+ };
13916
+ const getWidgetScorer = name => {
13917
+ return widgets[name]?.scorer ?? null;
13918
+ };
13919
+ registerWidget("categorizer", scoreCategorizer, validateCategorizer);
13920
+ registerWidget("cs-program", scoreCSProgram);
13921
+ registerWidget("dropdown", scoreDropdown, validateDropdown);
13922
+ registerWidget("expression", scoreExpression, validateExpression);
13923
+ registerWidget("grapher", scoreGrapher);
13924
+ registerWidget("group", scoreGroup, validateGroup);
13925
+ registerWidget("iframe", scoreIframe);
13926
+ registerWidget("input-number", scoreInputNumber);
13927
+ registerWidget("interactive-graph", scoreInteractiveGraph);
13928
+ registerWidget("label-image", scoreLabelImage, validateLabelImage);
13929
+ registerWidget("matcher", scoreMatcher);
13930
+ registerWidget("matrix", scoreMatrix, validateMatrix);
13931
+ registerWidget("mock-widget", scoreMockWidget, scoreMockWidget);
13932
+ registerWidget("number-line", scoreNumberLine, validateNumberLine);
13933
+ registerWidget("numeric-input", scoreNumericInput);
13934
+ registerWidget("orderer", scoreOrderer, validateOrderer);
13935
+ registerWidget("plotter", scorePlotter, validatePlotter);
13936
+ registerWidget("radio", scoreRadio, validateRadio);
13937
+ registerWidget("sorter", scoreSorter, validateSorter);
13938
+ registerWidget("table", scoreTable, validateTable);
13939
+ registerWidget("deprecated-standin", () => scoreNoop(1));
13940
+ registerWidget("measurer", () => scoreNoop(1));
13941
+ registerWidget("definition", scoreNoop);
13942
+ registerWidget("explanation", scoreNoop);
13943
+ registerWidget("image", scoreNoop);
13944
+ registerWidget("interaction", scoreNoop);
13945
+ registerWidget("molecule", scoreNoop);
13946
+ registerWidget("passage", scoreNoop);
13947
+ registerWidget("passage-ref", scoreNoop);
13948
+ registerWidget("passage-ref-target", scoreNoop);
13949
+ registerWidget("video", scoreNoop);
13950
+
13951
+ const noScore = {
13952
+ type: "points",
13953
+ earned: 0,
13954
+ total: 0,
13955
+ message: null
13956
+ };
13957
+
13958
+ /**
13959
+ * If a widget says that it is empty once it is graded.
13960
+ * Trying to encapsulate references to the score format.
13961
+ */
13962
+ function scoreIsEmpty(score) {
13963
+ // HACK(benkomalo): ugh. this isn't great; the Perseus score objects
13964
+ // overload the type "invalid" for what should probably be three
13965
+ // distinct cases:
13966
+ // - truly empty or not fully filled out
13967
+ // - invalid or malformed inputs
13968
+ // - "almost correct" like inputs where the widget wants to give
13969
+ // feedback (e.g. a fraction needs to be reduced, or `pi` should
13970
+ // be used instead of 3.14)
13971
+ //
13972
+ // Unfortunately the coercion happens all over the place, as these
13973
+ // Perseus style score objects are created *everywhere* (basically
13974
+ // in every widget), so it's hard to change now. We assume that
13975
+ // anything with a "message" is not truly empty, and one of the
13976
+ // latter two cases for now.
13977
+ return score.type === "invalid" && (!score.message || score.message.length === 0);
13978
+ }
13979
+
13980
+ /**
13981
+ * Combine two score objects.
13982
+ *
13983
+ * Given two score objects for two different widgets, combine them so that
13984
+ * if one is wrong, the total score is wrong, etc.
13985
+ */
13986
+ function combineScores(scoreA, scoreB) {
13987
+ let message;
13988
+ if (scoreA.type === "points" && scoreB.type === "points") {
13989
+ if (scoreA.message && scoreB.message && scoreA.message !== scoreB.message) {
13990
+ // TODO(alpert): Figure out how to combine messages usefully
13991
+ message = null;
13992
+ } else {
13993
+ message = scoreA.message || scoreB.message;
13994
+ }
13995
+ return {
13996
+ type: "points",
13997
+ earned: scoreA.earned + scoreB.earned,
13998
+ total: scoreA.total + scoreB.total,
13999
+ message: message
14000
+ };
14001
+ }
14002
+ if (scoreA.type === "points" && scoreB.type === "invalid") {
14003
+ return scoreB;
14004
+ }
14005
+ if (scoreA.type === "invalid" && scoreB.type === "points") {
14006
+ return scoreA;
14007
+ }
14008
+ if (scoreA.type === "invalid" && scoreB.type === "invalid") {
14009
+ if (scoreA.message && scoreB.message && scoreA.message !== scoreB.message) {
14010
+ // TODO(alpert): Figure out how to combine messages usefully
14011
+ message = null;
14012
+ } else {
14013
+ message = scoreA.message || scoreB.message;
14014
+ }
14015
+ return {
14016
+ type: "invalid",
14017
+ message: message
14018
+ };
14019
+ }
14020
+
14021
+ /**
14022
+ * The above checks cover all combinations of score type, so if we get here
14023
+ * then something is amiss with our inputs.
14024
+ */
14025
+ throw new perseusCore.PerseusError("PerseusScore with unknown type encountered", perseusCore.Errors.InvalidInput, {
14026
+ metadata: {
14027
+ scoreA: JSON.stringify(scoreA),
14028
+ scoreB: JSON.stringify(scoreB)
14029
+ }
14030
+ });
14031
+ }
14032
+ function flattenScores(widgetScoreMap) {
14033
+ return Object.values(widgetScoreMap).reduce(combineScores, noScore);
14034
+ }
14035
+
14036
+ // once scorePerseusItem is the only one calling scoreWidgetsFunctional
14037
+ function scorePerseusItem(perseusRenderData, userInputMap, locale) {
14038
+ // There seems to be a chance that PerseusRenderer.widgets might include
14039
+ // widget data for widgets that are not in PerseusRenderer.content,
14040
+ // so this checks that the widgets are being used before scoring them
14041
+ const usedWidgetIds = perseusCore.getWidgetIdsFromContent(perseusRenderData.content);
14042
+ const scores = scoreWidgetsFunctional(perseusRenderData.widgets, usedWidgetIds, userInputMap, locale);
14043
+ return flattenScores(scores);
14044
+ }
14045
+
14046
+ // TODO: combine scorePerseusItem with scoreWidgetsFunctional
14047
+ function scoreWidgetsFunctional(widgets,
14048
+ // This is a port of old code, I'm not sure why
14049
+ // we need widgetIds vs the keys of the widgets object
14050
+ widgetIds, userInputMap, locale) {
14051
+ const upgradedWidgets = perseusCore.getUpgradedWidgetOptions(widgets);
14052
+ const gradedWidgetIds = widgetIds.filter(id => {
14053
+ const props = upgradedWidgets[id];
14054
+ const widgetIsGraded = props?.graded == null || props.graded;
14055
+ const widgetIsStatic = !!props?.static;
14056
+ // Ungraded widgets or widgets set to static shouldn't be graded.
14057
+ return widgetIsGraded && !widgetIsStatic;
14058
+ });
14059
+ const widgetScores = {};
14060
+ gradedWidgetIds.forEach(id => {
14061
+ const widget = upgradedWidgets[id];
14062
+ if (!widget) {
14063
+ return;
14064
+ }
14065
+ const userInput = userInputMap[id];
14066
+ const validator = getWidgetValidator(widget.type);
14067
+ const scorer = getWidgetScorer(widget.type);
14068
+
14069
+ // We do validation (empty checks) first and then scoring. If
14070
+ // validation fails, it's result is itself a PerseusScore.
14071
+ const score = validator?.(userInput, widget.options, locale) ?? scorer?.(userInput, widget.options, locale);
14072
+ if (score != null) {
14073
+ widgetScores[id] = score;
14074
+ }
14075
+ });
14076
+ return widgetScores;
14077
+ }
14078
+
13853
14079
  exports.ErrorCodes = ErrorCodes;
13854
14080
  exports.KhanAnswerTypes = KhanAnswerTypes;
14081
+ exports.emptyWidgetsFunctional = emptyWidgetsFunctional;
14082
+ exports.flattenScores = flattenScores;
14083
+ exports.getWidgetScorer = getWidgetScorer;
14084
+ exports.getWidgetValidator = getWidgetValidator;
13855
14085
  exports.inputNumberAnswerTypes = inputNumberAnswerTypes;
14086
+ exports.registerWidget = registerWidget;
13856
14087
  exports.scoreCSProgram = scoreCSProgram;
13857
14088
  exports.scoreCategorizer = scoreCategorizer;
13858
14089
  exports.scoreDropdown = scoreDropdown;
@@ -13868,8 +14099,20 @@ exports.scoreMatrix = scoreMatrix;
13868
14099
  exports.scoreNumberLine = scoreNumberLine;
13869
14100
  exports.scoreNumericInput = scoreNumericInput;
13870
14101
  exports.scoreOrderer = scoreOrderer;
14102
+ exports.scorePerseusItem = scorePerseusItem;
13871
14103
  exports.scorePlotter = scorePlotter;
13872
14104
  exports.scoreRadio = scoreRadio;
13873
14105
  exports.scoreSorter = scoreSorter;
13874
14106
  exports.scoreTable = scoreTable;
14107
+ exports.scoreWidgetsFunctional = scoreWidgetsFunctional;
14108
+ exports.validateCategorizer = validateCategorizer;
14109
+ exports.validateDropdown = validateDropdown;
14110
+ exports.validateExpression = validateExpression;
14111
+ exports.validateMatrix = validateMatrix;
14112
+ exports.validateNumberLine = validateNumberLine;
14113
+ exports.validateOrderer = validateOrderer;
14114
+ exports.validatePlotter = validatePlotter;
14115
+ exports.validateRadio = validateRadio;
14116
+ exports.validateSorter = validateSorter;
14117
+ exports.validateTable = validateTable;
13875
14118
  //# sourceMappingURL=index.js.map