@khanacademy/perseus-score 2.0.0 → 2.2.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/es/index.js +419 -187
- package/dist/es/index.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +422 -185
- package/dist/index.js.map +1 -1
- package/dist/score.d.ts +21 -0
- package/dist/util/score-noop.d.ts +11 -0
- package/dist/util/test-helpers.d.ts +14 -0
- package/dist/validate.d.ts +7 -0
- package/dist/validation.types.d.ts +2 -0
- package/dist/widgets/group/score-group.d.ts +3 -0
- package/dist/widgets/group/validate-group.d.ts +3 -0
- package/dist/widgets/mock-widget/mock-widget-validation.types.d.ts +6 -0
- package/dist/widgets/mock-widget/score-mock-widget.d.ts +4 -0
- package/dist/widgets/mock-widget/validate-mock-widget.d.ts +4 -0
- package/dist/widgets/widget-registry.d.ts +4 -0
- 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,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
|
|
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
|
|
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;
|
|
@@ -12884,7 +12872,8 @@ function scoreIframe(userInput) {
|
|
|
12884
12872
|
const {
|
|
12885
12873
|
collinear,
|
|
12886
12874
|
canonicalSineCoefficients,
|
|
12887
|
-
similar
|
|
12875
|
+
similar,
|
|
12876
|
+
clockwise
|
|
12888
12877
|
} = kmath.geometry;
|
|
12889
12878
|
const {
|
|
12890
12879
|
getClockwiseAngle
|
|
@@ -13044,9 +13033,27 @@ function scoreInteractiveGraph(userInput, rubric) {
|
|
|
13044
13033
|
};
|
|
13045
13034
|
}
|
|
13046
13035
|
} else if (userInput.type === "angle" && rubric.correct.type === "angle") {
|
|
13047
|
-
const
|
|
13036
|
+
const coords = userInput.coords;
|
|
13048
13037
|
const correct = rubric.correct.coords;
|
|
13049
13038
|
const allowReflexAngles = rubric.correct.allowReflexAngles;
|
|
13039
|
+
|
|
13040
|
+
// While the angle graph should always have 3 points, our types
|
|
13041
|
+
// technically allow for null values. We'll check for that here.
|
|
13042
|
+
// TODO: (LEMS-2857) We would like to update the type of coords
|
|
13043
|
+
// to be non-nullable, as the graph should always have 3 points.
|
|
13044
|
+
if (!coords) {
|
|
13045
|
+
return {
|
|
13046
|
+
type: "invalid",
|
|
13047
|
+
message: null
|
|
13048
|
+
};
|
|
13049
|
+
}
|
|
13050
|
+
|
|
13051
|
+
// We need to check both the direction of the angle and the
|
|
13052
|
+
// whether the graph allows for reflexive angles in order to
|
|
13053
|
+
// to determine if we need to reverse the coords for scoring.
|
|
13054
|
+
const areClockwise = clockwise([coords[0], coords[2], coords[1]]);
|
|
13055
|
+
const shouldReverseCoords = areClockwise && !allowReflexAngles;
|
|
13056
|
+
const guess = shouldReverseCoords ? coords.slice().reverse() : coords;
|
|
13050
13057
|
let match;
|
|
13051
13058
|
if (rubric.correct.match === "congruent") {
|
|
13052
13059
|
const angles = _.map([guess, correct], function (coords) {
|
|
@@ -13060,13 +13067,7 @@ function scoreInteractiveGraph(userInput, rubric) {
|
|
|
13060
13067
|
match = perseusCore.approximateEqual(...angles);
|
|
13061
13068
|
} else {
|
|
13062
13069
|
/* exact */
|
|
13063
|
-
match =
|
|
13064
|
-
// @ts-expect-error - TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'.
|
|
13065
|
-
perseusCore.approximateDeepEqual(guess[1], correct[1]) &&
|
|
13066
|
-
// @ts-expect-error - TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'.
|
|
13067
|
-
collinear(correct[1], correct[0], guess[0]) &&
|
|
13068
|
-
// @ts-expect-error - TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'.
|
|
13069
|
-
collinear(correct[1], correct[2], guess[2]);
|
|
13070
|
+
match = perseusCore.approximateDeepEqual(guess[1], correct[1]) && collinear(correct[1], correct[0], guess[0]) && collinear(correct[1], correct[2], guess[2]);
|
|
13070
13071
|
}
|
|
13071
13072
|
if (match) {
|
|
13072
13073
|
return {
|
|
@@ -13096,24 +13097,6 @@ function scoreInteractiveGraph(userInput, rubric) {
|
|
|
13096
13097
|
};
|
|
13097
13098
|
}
|
|
13098
13099
|
|
|
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
13100
|
// Question state for marker as result of user selected answers.
|
|
13118
13101
|
|
|
13119
13102
|
function scoreLabelImageMarker(userInput, rubric) {
|
|
@@ -13136,10 +13119,6 @@ function scoreLabelImageMarker(userInput, rubric) {
|
|
|
13136
13119
|
return score;
|
|
13137
13120
|
}
|
|
13138
13121
|
function scoreLabelImage(userInput, rubric) {
|
|
13139
|
-
const validationError = validateLabelImage(userInput);
|
|
13140
|
-
if (validationError) {
|
|
13141
|
-
return validationError;
|
|
13142
|
-
}
|
|
13143
13122
|
let numCorrect = 0;
|
|
13144
13123
|
for (let i = 0; i < userInput.markers.length; i++) {
|
|
13145
13124
|
const score = scoreLabelImageMarker(userInput.markers[i].selected, rubric.markers[i].answers);
|
|
@@ -13167,35 +13146,7 @@ function scoreMatcher(userInput, rubric) {
|
|
|
13167
13146
|
};
|
|
13168
13147
|
}
|
|
13169
13148
|
|
|
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
13149
|
function scoreMatrix(userInput, rubric) {
|
|
13195
|
-
const validationError = validateMatrix(userInput);
|
|
13196
|
-
if (validationError != null) {
|
|
13197
|
-
return validationError;
|
|
13198
|
-
}
|
|
13199
13150
|
const solution = rubric.answers;
|
|
13200
13151
|
const supplied = userInput.answers;
|
|
13201
13152
|
const solutionSize = perseusCore.getMatrixSize(solution);
|
|
@@ -13240,30 +13191,30 @@ function scoreMatrix(userInput, rubric) {
|
|
|
13240
13191
|
}
|
|
13241
13192
|
|
|
13242
13193
|
/**
|
|
13243
|
-
* Checks user input
|
|
13244
|
-
*
|
|
13245
|
-
*
|
|
13246
|
-
*
|
|
13194
|
+
* Checks user input from the matrix widget to see if it is scorable.
|
|
13195
|
+
*
|
|
13196
|
+
* Note: The matrix widget cannot do much validation without the Scoring
|
|
13197
|
+
* Data because of its use of KhanAnswerTypes as a core part of scoring.
|
|
13198
|
+
*
|
|
13199
|
+
* @see `scoreMatrix()` for more details.
|
|
13247
13200
|
*/
|
|
13248
|
-
function
|
|
13249
|
-
const
|
|
13250
|
-
const
|
|
13251
|
-
|
|
13252
|
-
|
|
13253
|
-
|
|
13254
|
-
|
|
13255
|
-
|
|
13256
|
-
|
|
13257
|
-
|
|
13201
|
+
function validateMatrix(userInput) {
|
|
13202
|
+
const supplied = userInput.answers;
|
|
13203
|
+
const suppliedSize = perseusCore.getMatrixSize(supplied);
|
|
13204
|
+
for (let row = 0; row < suppliedSize[0]; row++) {
|
|
13205
|
+
for (let col = 0; col < suppliedSize[1]; col++) {
|
|
13206
|
+
if (supplied[row][col] == null || supplied[row][col].toString().length === 0) {
|
|
13207
|
+
return {
|
|
13208
|
+
type: "invalid",
|
|
13209
|
+
message: ErrorCodes.FILL_ALL_CELLS_ERROR
|
|
13210
|
+
};
|
|
13211
|
+
}
|
|
13212
|
+
}
|
|
13258
13213
|
}
|
|
13259
13214
|
return null;
|
|
13260
13215
|
}
|
|
13261
13216
|
|
|
13262
13217
|
function scoreNumberLine(userInput, rubric) {
|
|
13263
|
-
const validationError = validateNumberLine(userInput);
|
|
13264
|
-
if (validationError) {
|
|
13265
|
-
return validationError;
|
|
13266
|
-
}
|
|
13267
13218
|
const range = rubric.range;
|
|
13268
13219
|
const start = rubric.initialX != null ? rubric.initialX : range[0];
|
|
13269
13220
|
const startRel = rubric.isInequality ? "ge" : "eq";
|
|
@@ -13292,6 +13243,26 @@ function scoreNumberLine(userInput, rubric) {
|
|
|
13292
13243
|
};
|
|
13293
13244
|
}
|
|
13294
13245
|
|
|
13246
|
+
/**
|
|
13247
|
+
* Checks user input is within the allowed range and not the same as the initial
|
|
13248
|
+
* state.
|
|
13249
|
+
* @param userInput
|
|
13250
|
+
* @see 'scoreNumberLine' for the scoring logic.
|
|
13251
|
+
*/
|
|
13252
|
+
function validateNumberLine(userInput) {
|
|
13253
|
+
const divisionRange = userInput.divisionRange;
|
|
13254
|
+
const outsideAllowedRange = userInput.numDivisions > divisionRange[1] || userInput.numDivisions < divisionRange[0];
|
|
13255
|
+
|
|
13256
|
+
// TODO: I don't think isTickCrtl is a thing anymore
|
|
13257
|
+
if (userInput.isTickCrtl && outsideAllowedRange) {
|
|
13258
|
+
return {
|
|
13259
|
+
type: "invalid",
|
|
13260
|
+
message: "Number of divisions is outside the allowed range."
|
|
13261
|
+
};
|
|
13262
|
+
}
|
|
13263
|
+
return null;
|
|
13264
|
+
}
|
|
13265
|
+
|
|
13295
13266
|
/*
|
|
13296
13267
|
* In this file, an `expression` is some portion of valid TeX enclosed in
|
|
13297
13268
|
* curly brackets.
|
|
@@ -13554,27 +13525,7 @@ function scoreNumericInput(userInput, rubric) {
|
|
|
13554
13525
|
};
|
|
13555
13526
|
}
|
|
13556
13527
|
|
|
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
13528
|
function scoreOrderer(userInput, rubric) {
|
|
13574
|
-
const validationError = validateOrderer(userInput);
|
|
13575
|
-
if (validationError) {
|
|
13576
|
-
return validationError;
|
|
13577
|
-
}
|
|
13578
13529
|
const correct = _.isEqual(userInput.current, rubric.correctOptions.map(option => option.content));
|
|
13579
13530
|
return {
|
|
13580
13531
|
type: "points",
|
|
@@ -13585,13 +13536,13 @@ function scoreOrderer(userInput, rubric) {
|
|
|
13585
13536
|
}
|
|
13586
13537
|
|
|
13587
13538
|
/**
|
|
13588
|
-
* Checks user input
|
|
13589
|
-
*
|
|
13590
|
-
*
|
|
13591
|
-
* @see
|
|
13539
|
+
* Checks user input from the orderer widget to see if the user has started
|
|
13540
|
+
* ordering the options, making the widget scorable.
|
|
13541
|
+
* @param userInput
|
|
13542
|
+
* @see `scoreOrderer` for more details.
|
|
13592
13543
|
*/
|
|
13593
|
-
function
|
|
13594
|
-
if (
|
|
13544
|
+
function validateOrderer(userInput) {
|
|
13545
|
+
if (userInput.current.length === 0) {
|
|
13595
13546
|
return {
|
|
13596
13547
|
type: "invalid",
|
|
13597
13548
|
message: null
|
|
@@ -13601,10 +13552,6 @@ function validatePlotter(userInput, validationData) {
|
|
|
13601
13552
|
}
|
|
13602
13553
|
|
|
13603
13554
|
function scorePlotter(userInput, rubric) {
|
|
13604
|
-
const validationError = validatePlotter(userInput, rubric);
|
|
13605
|
-
if (validationError) {
|
|
13606
|
-
return validationError;
|
|
13607
|
-
}
|
|
13608
13555
|
return {
|
|
13609
13556
|
type: "points",
|
|
13610
13557
|
earned: perseusCore.approximateDeepEqual(userInput, rubric.correct) ? 1 : 0,
|
|
@@ -13614,18 +13561,13 @@ function scorePlotter(userInput, rubric) {
|
|
|
13614
13561
|
}
|
|
13615
13562
|
|
|
13616
13563
|
/**
|
|
13617
|
-
* Checks
|
|
13618
|
-
*
|
|
13619
|
-
*
|
|
13620
|
-
*
|
|
13621
|
-
* @param userInput
|
|
13622
|
-
* @see `scoreRadio` for the additional validation logic and the scoring logic.
|
|
13564
|
+
* Checks user input to confirm it is not the same as the starting values for the graph.
|
|
13565
|
+
* This means the user has modified the graph, and the question can be scored.
|
|
13566
|
+
*
|
|
13567
|
+
* @see 'scorePlotter' for more details on scoring.
|
|
13623
13568
|
*/
|
|
13624
|
-
function
|
|
13625
|
-
|
|
13626
|
-
return sum + (selected ? 1 : 0);
|
|
13627
|
-
}, 0);
|
|
13628
|
-
if (numSelected === 0) {
|
|
13569
|
+
function validatePlotter(userInput, validationData) {
|
|
13570
|
+
if (perseusCore.approximateDeepEqual(userInput, validationData.starting)) {
|
|
13629
13571
|
return {
|
|
13630
13572
|
type: "invalid",
|
|
13631
13573
|
message: null
|
|
@@ -13635,10 +13577,6 @@ function validateRadio(userInput) {
|
|
|
13635
13577
|
}
|
|
13636
13578
|
|
|
13637
13579
|
function scoreRadio(userInput, rubric) {
|
|
13638
|
-
const validationError = validateRadio(userInput);
|
|
13639
|
-
if (validationError) {
|
|
13640
|
-
return validationError;
|
|
13641
|
-
}
|
|
13642
13580
|
const numSelected = userInput.choicesSelected.reduce((sum, selected) => {
|
|
13643
13581
|
return sum + (selected ? 1 : 0);
|
|
13644
13582
|
}, 0);
|
|
@@ -13679,6 +13617,37 @@ function scoreRadio(userInput, rubric) {
|
|
|
13679
13617
|
};
|
|
13680
13618
|
}
|
|
13681
13619
|
|
|
13620
|
+
/**
|
|
13621
|
+
* Checks if the user has selected at least one option. Additional validation
|
|
13622
|
+
* is done in scoreRadio to check if the number of selected options is correct
|
|
13623
|
+
* and if the user has selected both a correct option and the "none of the above"
|
|
13624
|
+
* option.
|
|
13625
|
+
* @param userInput
|
|
13626
|
+
* @see `scoreRadio` for the additional validation logic and the scoring logic.
|
|
13627
|
+
*/
|
|
13628
|
+
function validateRadio(userInput) {
|
|
13629
|
+
const numSelected = userInput.choicesSelected.reduce((sum, selected) => {
|
|
13630
|
+
return sum + (selected ? 1 : 0);
|
|
13631
|
+
}, 0);
|
|
13632
|
+
if (numSelected === 0) {
|
|
13633
|
+
return {
|
|
13634
|
+
type: "invalid",
|
|
13635
|
+
message: null
|
|
13636
|
+
};
|
|
13637
|
+
}
|
|
13638
|
+
return null;
|
|
13639
|
+
}
|
|
13640
|
+
|
|
13641
|
+
function scoreSorter(userInput, rubric) {
|
|
13642
|
+
const correct = perseusCore.approximateDeepEqual(userInput.options, rubric.correct);
|
|
13643
|
+
return {
|
|
13644
|
+
type: "points",
|
|
13645
|
+
earned: correct ? 1 : 0,
|
|
13646
|
+
total: 1,
|
|
13647
|
+
message: null
|
|
13648
|
+
};
|
|
13649
|
+
}
|
|
13650
|
+
|
|
13682
13651
|
/**
|
|
13683
13652
|
* Checks user input for the sorter widget to ensure that the user has made
|
|
13684
13653
|
* changes before attempting to score the widget.
|
|
@@ -13702,20 +13671,6 @@ function validateSorter(userInput) {
|
|
|
13702
13671
|
return null;
|
|
13703
13672
|
}
|
|
13704
13673
|
|
|
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
13674
|
/**
|
|
13720
13675
|
* Filters the given table (modelled as a 2D array) to remove any rows that are
|
|
13721
13676
|
* completely empty.
|
|
@@ -13859,9 +13814,289 @@ function scoreInputNumber(userInput, rubric) {
|
|
|
13859
13814
|
};
|
|
13860
13815
|
}
|
|
13861
13816
|
|
|
13817
|
+
/**
|
|
13818
|
+
* Several widgets don't have "right"/"wrong" scoring logic,
|
|
13819
|
+
* so this just says to move on past those widgets
|
|
13820
|
+
*
|
|
13821
|
+
* TODO(LEMS-2543) widgets that use this probably shouldn't have any
|
|
13822
|
+
* scoring logic and the thing scoring an exercise
|
|
13823
|
+
* should just know to skip these
|
|
13824
|
+
*/
|
|
13825
|
+
function scoreNoop() {
|
|
13826
|
+
let points = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 0;
|
|
13827
|
+
return {
|
|
13828
|
+
type: "points",
|
|
13829
|
+
earned: points,
|
|
13830
|
+
total: points,
|
|
13831
|
+
message: null
|
|
13832
|
+
};
|
|
13833
|
+
}
|
|
13834
|
+
|
|
13835
|
+
// The `group` widget is basically a widget hosting a full Perseus system in
|
|
13836
|
+
// it. As such, scoring a group means scoring all widgets it contains.
|
|
13837
|
+
function scoreGroup(userInput, rubric, locale) {
|
|
13838
|
+
const scores = scoreWidgetsFunctional(rubric.widgets, Object.keys(rubric.widgets), userInput, locale);
|
|
13839
|
+
return flattenScores(scores);
|
|
13840
|
+
}
|
|
13841
|
+
|
|
13842
|
+
/**
|
|
13843
|
+
* Checks the given user input to see if any answerable widgets have not been
|
|
13844
|
+
* "filled in" (ie. if they're empty). Another way to think about this
|
|
13845
|
+
* function is that its a check to see if we can score the provided input.
|
|
13846
|
+
*/
|
|
13847
|
+
function emptyWidgetsFunctional(widgets,
|
|
13848
|
+
// This is a port of old code, I'm not sure why
|
|
13849
|
+
// we need widgetIds vs the keys of the widgets object
|
|
13850
|
+
widgetIds, userInputMap, locale) {
|
|
13851
|
+
return widgetIds.filter(id => {
|
|
13852
|
+
const widget = widgets[id];
|
|
13853
|
+
if (!widget || widget.static === true) {
|
|
13854
|
+
// Static widgets shouldn't count as empty
|
|
13855
|
+
return false;
|
|
13856
|
+
}
|
|
13857
|
+
const validator = getWidgetValidator(widget.type);
|
|
13858
|
+
const userInput = userInputMap[id];
|
|
13859
|
+
const validationData = widget.options;
|
|
13860
|
+
const score = validator?.(userInput, validationData, locale);
|
|
13861
|
+
if (score) {
|
|
13862
|
+
return scoreIsEmpty(score);
|
|
13863
|
+
}
|
|
13864
|
+
});
|
|
13865
|
+
}
|
|
13866
|
+
|
|
13867
|
+
function validateGroup(userInput, validationData, locale) {
|
|
13868
|
+
const emptyWidgets = emptyWidgetsFunctional(validationData.widgets, Object.keys(validationData.widgets), userInput, locale);
|
|
13869
|
+
if (emptyWidgets.length === 0) {
|
|
13870
|
+
return null;
|
|
13871
|
+
}
|
|
13872
|
+
return {
|
|
13873
|
+
type: "invalid",
|
|
13874
|
+
message: null
|
|
13875
|
+
};
|
|
13876
|
+
}
|
|
13877
|
+
|
|
13878
|
+
function validateLabelImage(userInput) {
|
|
13879
|
+
let numAnswered = 0;
|
|
13880
|
+
for (let i = 0; i < userInput.markers.length; i++) {
|
|
13881
|
+
const userSelection = userInput.markers[i].selected;
|
|
13882
|
+
if (userSelection && userSelection.length > 0) {
|
|
13883
|
+
numAnswered++;
|
|
13884
|
+
}
|
|
13885
|
+
}
|
|
13886
|
+
// We expect all question markers to be answered before grading.
|
|
13887
|
+
if (numAnswered !== userInput.markers.length) {
|
|
13888
|
+
return {
|
|
13889
|
+
type: "invalid",
|
|
13890
|
+
message: null
|
|
13891
|
+
};
|
|
13892
|
+
}
|
|
13893
|
+
return null;
|
|
13894
|
+
}
|
|
13895
|
+
|
|
13896
|
+
function validateMockWidget(userInput) {
|
|
13897
|
+
if (userInput.currentValue == null || userInput.currentValue === "") {
|
|
13898
|
+
return {
|
|
13899
|
+
type: "invalid",
|
|
13900
|
+
message: ""
|
|
13901
|
+
};
|
|
13902
|
+
}
|
|
13903
|
+
return null;
|
|
13904
|
+
}
|
|
13905
|
+
|
|
13906
|
+
function scoreMockWidget(userInput, rubric) {
|
|
13907
|
+
const validationResult = validateMockWidget(userInput);
|
|
13908
|
+
if (validationResult != null) {
|
|
13909
|
+
return validationResult;
|
|
13910
|
+
}
|
|
13911
|
+
return {
|
|
13912
|
+
type: "points",
|
|
13913
|
+
earned: userInput.currentValue === rubric.value ? 1 : 0,
|
|
13914
|
+
total: 1,
|
|
13915
|
+
message: ""
|
|
13916
|
+
};
|
|
13917
|
+
}
|
|
13918
|
+
|
|
13919
|
+
const widgets = {};
|
|
13920
|
+
function registerWidget(type, scorer, validator) {
|
|
13921
|
+
widgets[type] = {
|
|
13922
|
+
scorer,
|
|
13923
|
+
validator
|
|
13924
|
+
};
|
|
13925
|
+
}
|
|
13926
|
+
const getWidgetValidator = name => {
|
|
13927
|
+
return widgets[name]?.validator ?? null;
|
|
13928
|
+
};
|
|
13929
|
+
const getWidgetScorer = name => {
|
|
13930
|
+
return widgets[name]?.scorer ?? null;
|
|
13931
|
+
};
|
|
13932
|
+
registerWidget("categorizer", scoreCategorizer, validateCategorizer);
|
|
13933
|
+
registerWidget("cs-program", scoreCSProgram);
|
|
13934
|
+
registerWidget("dropdown", scoreDropdown, validateDropdown);
|
|
13935
|
+
registerWidget("expression", scoreExpression, validateExpression);
|
|
13936
|
+
registerWidget("grapher", scoreGrapher);
|
|
13937
|
+
registerWidget("group", scoreGroup, validateGroup);
|
|
13938
|
+
registerWidget("iframe", scoreIframe);
|
|
13939
|
+
registerWidget("input-number", scoreInputNumber);
|
|
13940
|
+
registerWidget("interactive-graph", scoreInteractiveGraph);
|
|
13941
|
+
registerWidget("label-image", scoreLabelImage, validateLabelImage);
|
|
13942
|
+
registerWidget("matcher", scoreMatcher);
|
|
13943
|
+
registerWidget("matrix", scoreMatrix, validateMatrix);
|
|
13944
|
+
registerWidget("mock-widget", scoreMockWidget, scoreMockWidget);
|
|
13945
|
+
registerWidget("number-line", scoreNumberLine, validateNumberLine);
|
|
13946
|
+
registerWidget("numeric-input", scoreNumericInput);
|
|
13947
|
+
registerWidget("orderer", scoreOrderer, validateOrderer);
|
|
13948
|
+
registerWidget("plotter", scorePlotter, validatePlotter);
|
|
13949
|
+
registerWidget("radio", scoreRadio, validateRadio);
|
|
13950
|
+
registerWidget("sorter", scoreSorter, validateSorter);
|
|
13951
|
+
registerWidget("table", scoreTable, validateTable);
|
|
13952
|
+
registerWidget("deprecated-standin", () => scoreNoop(1));
|
|
13953
|
+
registerWidget("measurer", () => scoreNoop(1));
|
|
13954
|
+
registerWidget("definition", scoreNoop);
|
|
13955
|
+
registerWidget("explanation", scoreNoop);
|
|
13956
|
+
registerWidget("image", scoreNoop);
|
|
13957
|
+
registerWidget("interaction", scoreNoop);
|
|
13958
|
+
registerWidget("molecule", scoreNoop);
|
|
13959
|
+
registerWidget("passage", scoreNoop);
|
|
13960
|
+
registerWidget("passage-ref", scoreNoop);
|
|
13961
|
+
registerWidget("passage-ref-target", scoreNoop);
|
|
13962
|
+
registerWidget("video", scoreNoop);
|
|
13963
|
+
|
|
13964
|
+
const noScore = {
|
|
13965
|
+
type: "points",
|
|
13966
|
+
earned: 0,
|
|
13967
|
+
total: 0,
|
|
13968
|
+
message: null
|
|
13969
|
+
};
|
|
13970
|
+
|
|
13971
|
+
/**
|
|
13972
|
+
* If a widget says that it is empty once it is graded.
|
|
13973
|
+
* Trying to encapsulate references to the score format.
|
|
13974
|
+
*/
|
|
13975
|
+
function scoreIsEmpty(score) {
|
|
13976
|
+
// HACK(benkomalo): ugh. this isn't great; the Perseus score objects
|
|
13977
|
+
// overload the type "invalid" for what should probably be three
|
|
13978
|
+
// distinct cases:
|
|
13979
|
+
// - truly empty or not fully filled out
|
|
13980
|
+
// - invalid or malformed inputs
|
|
13981
|
+
// - "almost correct" like inputs where the widget wants to give
|
|
13982
|
+
// feedback (e.g. a fraction needs to be reduced, or `pi` should
|
|
13983
|
+
// be used instead of 3.14)
|
|
13984
|
+
//
|
|
13985
|
+
// Unfortunately the coercion happens all over the place, as these
|
|
13986
|
+
// Perseus style score objects are created *everywhere* (basically
|
|
13987
|
+
// in every widget), so it's hard to change now. We assume that
|
|
13988
|
+
// anything with a "message" is not truly empty, and one of the
|
|
13989
|
+
// latter two cases for now.
|
|
13990
|
+
return score.type === "invalid" && (!score.message || score.message.length === 0);
|
|
13991
|
+
}
|
|
13992
|
+
|
|
13993
|
+
/**
|
|
13994
|
+
* Combine two score objects.
|
|
13995
|
+
*
|
|
13996
|
+
* Given two score objects for two different widgets, combine them so that
|
|
13997
|
+
* if one is wrong, the total score is wrong, etc.
|
|
13998
|
+
*/
|
|
13999
|
+
function combineScores(scoreA, scoreB) {
|
|
14000
|
+
let message;
|
|
14001
|
+
if (scoreA.type === "points" && scoreB.type === "points") {
|
|
14002
|
+
if (scoreA.message && scoreB.message && scoreA.message !== scoreB.message) {
|
|
14003
|
+
// TODO(alpert): Figure out how to combine messages usefully
|
|
14004
|
+
message = null;
|
|
14005
|
+
} else {
|
|
14006
|
+
message = scoreA.message || scoreB.message;
|
|
14007
|
+
}
|
|
14008
|
+
return {
|
|
14009
|
+
type: "points",
|
|
14010
|
+
earned: scoreA.earned + scoreB.earned,
|
|
14011
|
+
total: scoreA.total + scoreB.total,
|
|
14012
|
+
message: message
|
|
14013
|
+
};
|
|
14014
|
+
}
|
|
14015
|
+
if (scoreA.type === "points" && scoreB.type === "invalid") {
|
|
14016
|
+
return scoreB;
|
|
14017
|
+
}
|
|
14018
|
+
if (scoreA.type === "invalid" && scoreB.type === "points") {
|
|
14019
|
+
return scoreA;
|
|
14020
|
+
}
|
|
14021
|
+
if (scoreA.type === "invalid" && scoreB.type === "invalid") {
|
|
14022
|
+
if (scoreA.message && scoreB.message && scoreA.message !== scoreB.message) {
|
|
14023
|
+
// TODO(alpert): Figure out how to combine messages usefully
|
|
14024
|
+
message = null;
|
|
14025
|
+
} else {
|
|
14026
|
+
message = scoreA.message || scoreB.message;
|
|
14027
|
+
}
|
|
14028
|
+
return {
|
|
14029
|
+
type: "invalid",
|
|
14030
|
+
message: message
|
|
14031
|
+
};
|
|
14032
|
+
}
|
|
14033
|
+
|
|
14034
|
+
/**
|
|
14035
|
+
* The above checks cover all combinations of score type, so if we get here
|
|
14036
|
+
* then something is amiss with our inputs.
|
|
14037
|
+
*/
|
|
14038
|
+
throw new perseusCore.PerseusError("PerseusScore with unknown type encountered", perseusCore.Errors.InvalidInput, {
|
|
14039
|
+
metadata: {
|
|
14040
|
+
scoreA: JSON.stringify(scoreA),
|
|
14041
|
+
scoreB: JSON.stringify(scoreB)
|
|
14042
|
+
}
|
|
14043
|
+
});
|
|
14044
|
+
}
|
|
14045
|
+
function flattenScores(widgetScoreMap) {
|
|
14046
|
+
return Object.values(widgetScoreMap).reduce(combineScores, noScore);
|
|
14047
|
+
}
|
|
14048
|
+
|
|
14049
|
+
// once scorePerseusItem is the only one calling scoreWidgetsFunctional
|
|
14050
|
+
function scorePerseusItem(perseusRenderData, userInputMap, locale) {
|
|
14051
|
+
// There seems to be a chance that PerseusRenderer.widgets might include
|
|
14052
|
+
// widget data for widgets that are not in PerseusRenderer.content,
|
|
14053
|
+
// so this checks that the widgets are being used before scoring them
|
|
14054
|
+
const usedWidgetIds = perseusCore.getWidgetIdsFromContent(perseusRenderData.content);
|
|
14055
|
+
const scores = scoreWidgetsFunctional(perseusRenderData.widgets, usedWidgetIds, userInputMap, locale);
|
|
14056
|
+
return flattenScores(scores);
|
|
14057
|
+
}
|
|
14058
|
+
|
|
14059
|
+
// TODO: combine scorePerseusItem with scoreWidgetsFunctional
|
|
14060
|
+
function scoreWidgetsFunctional(widgets,
|
|
14061
|
+
// This is a port of old code, I'm not sure why
|
|
14062
|
+
// we need widgetIds vs the keys of the widgets object
|
|
14063
|
+
widgetIds, userInputMap, locale) {
|
|
14064
|
+
const upgradedWidgets = perseusCore.getUpgradedWidgetOptions(widgets);
|
|
14065
|
+
const gradedWidgetIds = widgetIds.filter(id => {
|
|
14066
|
+
const props = upgradedWidgets[id];
|
|
14067
|
+
const widgetIsGraded = props?.graded == null || props.graded;
|
|
14068
|
+
const widgetIsStatic = !!props?.static;
|
|
14069
|
+
// Ungraded widgets or widgets set to static shouldn't be graded.
|
|
14070
|
+
return widgetIsGraded && !widgetIsStatic;
|
|
14071
|
+
});
|
|
14072
|
+
const widgetScores = {};
|
|
14073
|
+
gradedWidgetIds.forEach(id => {
|
|
14074
|
+
const widget = upgradedWidgets[id];
|
|
14075
|
+
if (!widget) {
|
|
14076
|
+
return;
|
|
14077
|
+
}
|
|
14078
|
+
const userInput = userInputMap[id];
|
|
14079
|
+
const validator = getWidgetValidator(widget.type);
|
|
14080
|
+
const scorer = getWidgetScorer(widget.type);
|
|
14081
|
+
|
|
14082
|
+
// We do validation (empty checks) first and then scoring. If
|
|
14083
|
+
// validation fails, it's result is itself a PerseusScore.
|
|
14084
|
+
const score = validator?.(userInput, widget.options, locale) ?? scorer?.(userInput, widget.options, locale);
|
|
14085
|
+
if (score != null) {
|
|
14086
|
+
widgetScores[id] = score;
|
|
14087
|
+
}
|
|
14088
|
+
});
|
|
14089
|
+
return widgetScores;
|
|
14090
|
+
}
|
|
14091
|
+
|
|
13862
14092
|
exports.ErrorCodes = ErrorCodes;
|
|
13863
14093
|
exports.KhanAnswerTypes = KhanAnswerTypes;
|
|
14094
|
+
exports.emptyWidgetsFunctional = emptyWidgetsFunctional;
|
|
14095
|
+
exports.flattenScores = flattenScores;
|
|
14096
|
+
exports.getWidgetScorer = getWidgetScorer;
|
|
14097
|
+
exports.getWidgetValidator = getWidgetValidator;
|
|
13864
14098
|
exports.inputNumberAnswerTypes = inputNumberAnswerTypes;
|
|
14099
|
+
exports.registerWidget = registerWidget;
|
|
13865
14100
|
exports.scoreCSProgram = scoreCSProgram;
|
|
13866
14101
|
exports.scoreCategorizer = scoreCategorizer;
|
|
13867
14102
|
exports.scoreDropdown = scoreDropdown;
|
|
@@ -13877,10 +14112,12 @@ exports.scoreMatrix = scoreMatrix;
|
|
|
13877
14112
|
exports.scoreNumberLine = scoreNumberLine;
|
|
13878
14113
|
exports.scoreNumericInput = scoreNumericInput;
|
|
13879
14114
|
exports.scoreOrderer = scoreOrderer;
|
|
14115
|
+
exports.scorePerseusItem = scorePerseusItem;
|
|
13880
14116
|
exports.scorePlotter = scorePlotter;
|
|
13881
14117
|
exports.scoreRadio = scoreRadio;
|
|
13882
14118
|
exports.scoreSorter = scoreSorter;
|
|
13883
14119
|
exports.scoreTable = scoreTable;
|
|
14120
|
+
exports.scoreWidgetsFunctional = scoreWidgetsFunctional;
|
|
13884
14121
|
exports.validateCategorizer = validateCategorizer;
|
|
13885
14122
|
exports.validateDropdown = validateDropdown;
|
|
13886
14123
|
exports.validateExpression = validateExpression;
|