@khanacademy/perseus-score 2.0.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.
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,25 +12585,6 @@ function validateCategorizer(userInput, validationData) {
12570
12585
  return null;
12571
12586
  }
12572
12587
 
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
12588
  function scoreCSProgram(userInput) {
12593
12589
  // The CS program can tell us whether it's correct or incorrect,
12594
12590
  // and pass an optional message
@@ -12614,25 +12610,7 @@ function scoreCSProgram(userInput) {
12614
12610
  };
12615
12611
  }
12616
12612
 
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
12613
  function scoreDropdown(userInput, rubric) {
12632
- const validationError = validateDropdown(userInput);
12633
- if (validationError) {
12634
- return validationError;
12635
- }
12636
12614
  const correct = rubric.choices[userInput.value - 1].correct;
12637
12615
  return {
12638
12616
  type: "points",
@@ -12643,15 +12621,11 @@ function scoreDropdown(userInput, rubric) {
12643
12621
  }
12644
12622
 
12645
12623
  /**
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.
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.
12652
12626
  */
12653
- function validateExpression(userInput) {
12654
- if (userInput === "") {
12627
+ function validateDropdown(userInput) {
12628
+ if (userInput.value === 0) {
12655
12629
  return {
12656
12630
  type: "invalid",
12657
12631
  message: null
@@ -12679,10 +12653,6 @@ function validateExpression(userInput) {
12679
12653
  * - Otherwise, pass through the resulting points and message.
12680
12654
  */
12681
12655
  function scoreExpression(userInput, rubric, locale) {
12682
- const validationError = validateExpression(userInput);
12683
- if (validationError) {
12684
- return validationError;
12685
- }
12686
12656
  const options = _.clone(rubric);
12687
12657
  _.extend(options, {
12688
12658
  decimal_separator: perseusCore.getDecimalSeparator(locale)
@@ -12797,6 +12767,24 @@ function scoreExpression(userInput, rubric, locale) {
12797
12767
  };
12798
12768
  }
12799
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
+
12800
12788
  function getCoefficientsByType(data) {
12801
12789
  if (data.coords == null) {
12802
12790
  return undefined;
@@ -13096,24 +13084,6 @@ function scoreInteractiveGraph(userInput, rubric) {
13096
13084
  };
13097
13085
  }
13098
13086
 
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
13087
  // Question state for marker as result of user selected answers.
13118
13088
 
13119
13089
  function scoreLabelImageMarker(userInput, rubric) {
@@ -13136,10 +13106,6 @@ function scoreLabelImageMarker(userInput, rubric) {
13136
13106
  return score;
13137
13107
  }
13138
13108
  function scoreLabelImage(userInput, rubric) {
13139
- const validationError = validateLabelImage(userInput);
13140
- if (validationError) {
13141
- return validationError;
13142
- }
13143
13109
  let numCorrect = 0;
13144
13110
  for (let i = 0; i < userInput.markers.length; i++) {
13145
13111
  const score = scoreLabelImageMarker(userInput.markers[i].selected, rubric.markers[i].answers);
@@ -13167,35 +13133,7 @@ function scoreMatcher(userInput, rubric) {
13167
13133
  };
13168
13134
  }
13169
13135
 
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
13136
  function scoreMatrix(userInput, rubric) {
13195
- const validationError = validateMatrix(userInput);
13196
- if (validationError != null) {
13197
- return validationError;
13198
- }
13199
13137
  const solution = rubric.answers;
13200
13138
  const supplied = userInput.answers;
13201
13139
  const solutionSize = perseusCore.getMatrixSize(solution);
@@ -13240,30 +13178,30 @@ function scoreMatrix(userInput, rubric) {
13240
13178
  }
13241
13179
 
13242
13180
  /**
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.
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.
13247
13187
  */
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
- };
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
+ }
13258
13200
  }
13259
13201
  return null;
13260
13202
  }
13261
13203
 
13262
13204
  function scoreNumberLine(userInput, rubric) {
13263
- const validationError = validateNumberLine(userInput);
13264
- if (validationError) {
13265
- return validationError;
13266
- }
13267
13205
  const range = rubric.range;
13268
13206
  const start = rubric.initialX != null ? rubric.initialX : range[0];
13269
13207
  const startRel = rubric.isInequality ? "ge" : "eq";
@@ -13292,6 +13230,26 @@ function scoreNumberLine(userInput, rubric) {
13292
13230
  };
13293
13231
  }
13294
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
+
13295
13253
  /*
13296
13254
  * In this file, an `expression` is some portion of valid TeX enclosed in
13297
13255
  * curly brackets.
@@ -13554,27 +13512,7 @@ function scoreNumericInput(userInput, rubric) {
13554
13512
  };
13555
13513
  }
13556
13514
 
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
13515
  function scoreOrderer(userInput, rubric) {
13574
- const validationError = validateOrderer(userInput);
13575
- if (validationError) {
13576
- return validationError;
13577
- }
13578
13516
  const correct = _.isEqual(userInput.current, rubric.correctOptions.map(option => option.content));
13579
13517
  return {
13580
13518
  type: "points",
@@ -13585,13 +13523,13 @@ function scoreOrderer(userInput, rubric) {
13585
13523
  }
13586
13524
 
13587
13525
  /**
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.
13526
+ * Checks user input from the orderer widget to see if the user has started
13527
+ * ordering the options, making the widget scorable.
13528
+ * @param userInput
13529
+ * @see `scoreOrderer` for more details.
13592
13530
  */
13593
- function validatePlotter(userInput, validationData) {
13594
- if (perseusCore.approximateDeepEqual(userInput, validationData.starting)) {
13531
+ function validateOrderer(userInput) {
13532
+ if (userInput.current.length === 0) {
13595
13533
  return {
13596
13534
  type: "invalid",
13597
13535
  message: null
@@ -13601,10 +13539,6 @@ function validatePlotter(userInput, validationData) {
13601
13539
  }
13602
13540
 
13603
13541
  function scorePlotter(userInput, rubric) {
13604
- const validationError = validatePlotter(userInput, rubric);
13605
- if (validationError) {
13606
- return validationError;
13607
- }
13608
13542
  return {
13609
13543
  type: "points",
13610
13544
  earned: perseusCore.approximateDeepEqual(userInput, rubric.correct) ? 1 : 0,
@@ -13614,18 +13548,13 @@ function scorePlotter(userInput, rubric) {
13614
13548
  }
13615
13549
 
13616
13550
  /**
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.
13551
+ * Checks user input to confirm it is not the same as the starting values for the graph.
13552
+ * This means the user has modified the graph, and the question can be scored.
13553
+ *
13554
+ * @see 'scorePlotter' for more details on scoring.
13623
13555
  */
13624
- function validateRadio(userInput) {
13625
- const numSelected = userInput.choicesSelected.reduce((sum, selected) => {
13626
- return sum + (selected ? 1 : 0);
13627
- }, 0);
13628
- if (numSelected === 0) {
13556
+ function validatePlotter(userInput, validationData) {
13557
+ if (perseusCore.approximateDeepEqual(userInput, validationData.starting)) {
13629
13558
  return {
13630
13559
  type: "invalid",
13631
13560
  message: null
@@ -13635,10 +13564,6 @@ function validateRadio(userInput) {
13635
13564
  }
13636
13565
 
13637
13566
  function scoreRadio(userInput, rubric) {
13638
- const validationError = validateRadio(userInput);
13639
- if (validationError) {
13640
- return validationError;
13641
- }
13642
13567
  const numSelected = userInput.choicesSelected.reduce((sum, selected) => {
13643
13568
  return sum + (selected ? 1 : 0);
13644
13569
  }, 0);
@@ -13679,6 +13604,37 @@ function scoreRadio(userInput, rubric) {
13679
13604
  };
13680
13605
  }
13681
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
+
13682
13638
  /**
13683
13639
  * Checks user input for the sorter widget to ensure that the user has made
13684
13640
  * changes before attempting to score the widget.
@@ -13702,20 +13658,6 @@ function validateSorter(userInput) {
13702
13658
  return null;
13703
13659
  }
13704
13660
 
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
13661
  /**
13720
13662
  * Filters the given table (modelled as a 2D array) to remove any rows that are
13721
13663
  * completely empty.
@@ -13859,9 +13801,289 @@ function scoreInputNumber(userInput, rubric) {
13859
13801
  };
13860
13802
  }
13861
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
+
13862
14079
  exports.ErrorCodes = ErrorCodes;
13863
14080
  exports.KhanAnswerTypes = KhanAnswerTypes;
14081
+ exports.emptyWidgetsFunctional = emptyWidgetsFunctional;
14082
+ exports.flattenScores = flattenScores;
14083
+ exports.getWidgetScorer = getWidgetScorer;
14084
+ exports.getWidgetValidator = getWidgetValidator;
13864
14085
  exports.inputNumberAnswerTypes = inputNumberAnswerTypes;
14086
+ exports.registerWidget = registerWidget;
13865
14087
  exports.scoreCSProgram = scoreCSProgram;
13866
14088
  exports.scoreCategorizer = scoreCategorizer;
13867
14089
  exports.scoreDropdown = scoreDropdown;
@@ -13877,10 +14099,12 @@ exports.scoreMatrix = scoreMatrix;
13877
14099
  exports.scoreNumberLine = scoreNumberLine;
13878
14100
  exports.scoreNumericInput = scoreNumericInput;
13879
14101
  exports.scoreOrderer = scoreOrderer;
14102
+ exports.scorePerseusItem = scorePerseusItem;
13880
14103
  exports.scorePlotter = scorePlotter;
13881
14104
  exports.scoreRadio = scoreRadio;
13882
14105
  exports.scoreSorter = scoreSorter;
13883
14106
  exports.scoreTable = scoreTable;
14107
+ exports.scoreWidgetsFunctional = scoreWidgetsFunctional;
13884
14108
  exports.validateCategorizer = validateCategorizer;
13885
14109
  exports.validateDropdown = validateDropdown;
13886
14110
  exports.validateExpression = validateExpression;