@khanacademy/perseus-score 4.0.1 → 4.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
@@ -30,2293 +30,89 @@ function _interopNamespaceCompat(e) {
30
30
  var KAS__namespace = /*#__PURE__*/_interopNamespaceCompat(KAS);
31
31
  var ___default = /*#__PURE__*/_interopDefaultCompat(_);
32
32
 
33
- const MISSING_PERCENT_ERROR = "MISSING_PERCENT_ERROR";
34
- const NEEDS_TO_BE_SIMPLIFIED_ERROR = "NEEDS_TO_BE_SIMPLIFIED_ERROR";
35
- const APPROXIMATED_PI_ERROR = "APPROXIMATED_PI_ERROR";
36
- const EXTRA_SYMBOLS_ERROR = "EXTRA_SYMBOLS_ERROR";
37
- const WRONG_CASE_ERROR = "WRONG_CASE_ERROR";
38
- const WRONG_LETTER_ERROR = "WRONG_LETTER_ERROR";
39
- const MULTIPLICATION_SIGN_ERROR = "MULTIPLICATION_SIGN_ERROR";
40
- const INVALID_SELECTION_ERROR = "INVALID_SELECTION_ERROR";
41
- const CHOOSE_CORRECT_NUM_ERROR = "CHOOSE_CORRECT_NUM_ERROR";
42
- const NOT_NONE_ABOVE_ERROR = "NOT_NONE_ABOVE_ERROR";
43
- const FILL_ALL_CELLS_ERROR = "FILL_ALL_CELLS_ERROR";
44
- const ErrorCodes = {
45
- MISSING_PERCENT_ERROR,
46
- NEEDS_TO_BE_SIMPLIFIED_ERROR,
47
- APPROXIMATED_PI_ERROR,
48
- EXTRA_SYMBOLS_ERROR,
49
- WRONG_CASE_ERROR,
50
- WRONG_LETTER_ERROR,
51
- MULTIPLICATION_SIGN_ERROR,
52
- INVALID_SELECTION_ERROR,
53
- CHOOSE_CORRECT_NUM_ERROR,
54
- NOT_NONE_ABOVE_ERROR,
55
- FILL_ALL_CELLS_ERROR
56
- };
33
+ const MISSING_PERCENT_ERROR="MISSING_PERCENT_ERROR";const NEEDS_TO_BE_SIMPLIFIED_ERROR="NEEDS_TO_BE_SIMPLIFIED_ERROR";const APPROXIMATED_PI_ERROR="APPROXIMATED_PI_ERROR";const EXTRA_SYMBOLS_ERROR="EXTRA_SYMBOLS_ERROR";const WRONG_CASE_ERROR="WRONG_CASE_ERROR";const WRONG_LETTER_ERROR="WRONG_LETTER_ERROR";const MULTIPLICATION_SIGN_ERROR="MULTIPLICATION_SIGN_ERROR";const INVALID_SELECTION_ERROR="INVALID_SELECTION_ERROR";const CHOOSE_CORRECT_NUM_ERROR="CHOOSE_CORRECT_NUM_ERROR";const NOT_NONE_ABOVE_ERROR="NOT_NONE_ABOVE_ERROR";const FILL_ALL_CELLS_ERROR="FILL_ALL_CELLS_ERROR";const ErrorCodes={MISSING_PERCENT_ERROR,NEEDS_TO_BE_SIMPLIFIED_ERROR,APPROXIMATED_PI_ERROR,EXTRA_SYMBOLS_ERROR,WRONG_CASE_ERROR,WRONG_LETTER_ERROR,MULTIPLICATION_SIGN_ERROR,INVALID_SELECTION_ERROR,CHOOSE_CORRECT_NUM_ERROR,NOT_NONE_ABOVE_ERROR,FILL_ALL_CELLS_ERROR};
57
34
 
58
- /* eslint-disable no-useless-escape */
59
- const MAXERROR_EPSILON = Math.pow(2, -42);
35
+ const MAXERROR_EPSILON=Math.pow(2,-42);const KhanAnswerTypes={predicate:{defaultForms:"integer, proper, improper, mixed, decimal",createValidatorFunctional:function(predicate,options){options=___default.default.extend({simplify:"required",ratio:false,forms:KhanAnswerTypes.predicate.defaultForms},options);let acceptableForms;if(!___default.default.isArray(options.forms)){acceptableForms=options.forms.split(/\s*,\s*/);}else {acceptableForms=options.forms;}if(options.inexact===undefined){options.maxError=0;}options.maxError=+options.maxError+MAXERROR_EPSILON;if(___default.default.contains(acceptableForms,"percent")){acceptableForms=___default.default.without(acceptableForms,"percent");acceptableForms.push("percent");}const fractionTransformer=function(text){text=text.replace(/\u2212/,"-").replace(/([+-])\s+/g,"$1").replace(/(^\s*)|(\s*$)/gi,"");const match=text.match(/^([+-]?\d+)\s*\/\s*([+-]?\d+)$/);const mobileDeviceMatch=text.match(/^([+-]?)\\frac\{([+-]?\d+)\}\{([+-]?\d+)\}$/);const parsedInt=parseInt(text,10);if(match||mobileDeviceMatch){let num;let denom;let simplified=true;if(match){num=parseFloat(match[1]);denom=parseFloat(match[2]);}else {num=parseFloat(mobileDeviceMatch[2]);if(mobileDeviceMatch[1]==="-"){if(num<0){simplified=false;}num=-num;}denom=parseFloat(mobileDeviceMatch[3]);}simplified=simplified&&denom>0&&(options.ratio||denom!==1)&&kmath.KhanMath.getGCD(num,denom)===1;return [{value:num/denom,exact:simplified}]}if(!isNaN(parsedInt)&&""+parsedInt===text){return [{value:parsedInt,exact:true}]}return []};const forms={integer:function(text){const decimal=forms.decimal(text);const rounded=forms.decimal(text,1);if(decimal[0].value!=null&&decimal[0].value===rounded[0].value||decimal[1].value!=null&&decimal[1].value===rounded[1].value){return decimal}return []},proper:function(text){const transformed=fractionTransformer(text);return transformed.flatMap(o=>{if(Math.abs(o.value)<1){return [o]}return []})},improper:function(text){const fractionExists=text.includes("/")||text.match(/\\(d?frac)/);if(!fractionExists){return []}const transformed=fractionTransformer(text);return transformed.flatMap(o=>{if(Math.abs(o.value)>=1){return [o]}return []})},pi:function(text){let match;let possibilities=[];text=text.replace(/\u2212/,"-");if(match=text.match(/^([+-]?)\s*(\\?pi|p|\u03c0|\\?tau|t|\u03c4|pau)$/i)){possibilities=[{value:parseFloat(match[1]+"1"),exact:true}];}else if(match=text.match(/^([+-]?\s*\d+\s*(?:\/\s*[+-]?\s*\d+)?)\s*\*?\s*(\\?pi|p|\u03c0|\\?tau|t|\u03c4|pau)$/i)){possibilities=fractionTransformer(match[1]);}else if(match=text.match(/^([+-]?)\s*(\d+)\s*([+-]?\d+)\s*\/\s*([+-]?\d+)\s*\*?\s*(\\?pi|p|\u03c0|\\?tau|t|\u03c4|pau)$/i)){const sign=parseFloat(match[1]+"1");const integ=parseFloat(match[2]);const num=parseFloat(match[3]);const denom=parseFloat(match[4]);const simplified=num<denom&&kmath.KhanMath.getGCD(num,denom)===1;possibilities=[{value:sign*(integ+num/denom),exact:simplified}];}else if(match=text.match(/^([+-]?\s*\d+)\s*\*?\s*(\\?pi|p|\u03c0|\\?tau|t|\u03c4|pau)\s*(?:\/\s*([+-]?\s*\d+))?$/i)){possibilities=fractionTransformer(match[1]+"/"+match[3]);}else if(match=text.match(/^([+-]?)\s*\*?\s*(\\?pi|p|\u03c0|\\?tau|t|\u03c4|pau)\s*(?:\/\s*([+-]?\d+))?$/i)){possibilities=fractionTransformer(match[1]+"1/"+match[3]);}else if(text==="0"){possibilities=[{value:0,exact:true}];}else if(match=text.match(/^(.+)\s*\*?\s*(\\?pi|p|\u03c0|\\?tau|t|\u03c4|pau)$/i)){possibilities=forms.decimal(match[1]);}else {possibilities=___default.default.reduce(KhanAnswerTypes.predicate.defaultForms.split(/\s*,\s*/),function(memo,form){return memo.concat(forms[form](text))},[]);let approximatesPi=false;const number=parseFloat(text);if(!isNaN(number)&&number!==parseInt(text)){const piMult=Math.PI/12;const roundedNumber=piMult*Math.round(number/piMult);if(Math.abs(number-roundedNumber)<.01){approximatesPi=true;}}else if(text.match(/\/\s*7/)){approximatesPi=true;}if(approximatesPi){___default.default.each(possibilities,function(possibility){possibility.piApprox=true;});}return possibilities}let multiplier=Math.PI;if(text.match(/\\?tau|t|\u03c4/)){multiplier=Math.PI*2;}if(text.match(/pau/)){multiplier=Math.PI*1.5;}possibilities.forEach(possibility=>{possibility.value*=multiplier;});return possibilities},coefficient:function(text){let possibilities=[];text=text.replace(/\u2212/,"-");if(text===""){possibilities=[{value:1,exact:true}];}else if(text==="-"){possibilities=[{value:-1,exact:true}];}return possibilities},log:function(text){let match;let possibilities=[];text=text.replace(/\u2212/,"-");text=text.replace(/[ \(\)]/g,"");if(match=text.match(/^log\s*(\S+)\s*$/i)){possibilities=forms.decimal(match[1]);}else if(text==="0"){possibilities=[{value:0,exact:true}];}return possibilities},percent:function(text){text=String(text).trim();let hasPercentSign=false;if(text.indexOf("%")===text.length-1){text=text.substring(0,text.length-1).trim();hasPercentSign=true;}const transformed=forms.decimal(text);transformed.forEach(t=>{t.exact=hasPercentSign;t.value=t.value/100;});return transformed},mixed:function(text){const match=text.replace(/\u2212/,"-").replace(/([+-])\s+/g,"$1").match(/^([+-]?)(\d+)\s+(\d+)\s*\/\s*(\d+)$/);if(match){const sign=parseFloat(match[1]+"1");const integ=parseFloat(match[2]);const num=parseFloat(match[3]);const denom=parseFloat(match[4]);const simplified=num<denom&&kmath.KhanMath.getGCD(num,denom)===1;return [{value:sign*(integ+num/denom),exact:simplified}]}return []},decimal:function(text,precision=1e10){const normal=function(text){text=String(text).trim();const match=text.replace(/\u2212/,"-").replace(/([+-])\s+/g,"$1").match(/^([+-]?(?:\d{1,3}(?:[, ]?\d{3})*\.?|\d{0,3}(?:[, ]?\d{3})*\.(?:\d{3}[, ]?)*\d{1,3}))$/);const badLeadingZero=text.match(/^0[0,]*,/);if(match&&!badLeadingZero){let x=parseFloat(match[1].replace(/[, ]/g,""));if(options.inexact===undefined){x=Math.round(x*precision)/precision;}return x}};const commas=function(text){text=text.replace(/([\.,])/g,function(_,c){return c==="."?",":"."});return normal(text)};return [{value:normal(text),exact:true},{value:commas(text),exact:true}]}};return function(guess){const fallback=options.fallback!=null?""+options.fallback:"";guess=String(guess).trim()||fallback;const score={empty:guess==="",correct:false,message:null,guess:guess};const findCorrectAnswer=()=>{for(const form of acceptableForms){const transformed=forms[form](guess);for(let j=0,l=transformed.length;j<l;j++){const val=transformed[j].value;const exact=transformed[j].exact;const piApprox=transformed[j].piApprox;if(predicate(val,options.maxError)){if(exact||options.simplify==="optional"){score.correct=true;score.message=options.message||null;score.empty=false;}else if(form==="percent"){score.empty=true;score.message=ErrorCodes.MISSING_PERCENT_ERROR;}else {if(options.simplify!=="enforced"){score.empty=true;}score.message=ErrorCodes.NEEDS_TO_BE_SIMPLIFIED_ERROR;}return false}if(piApprox&&predicate(val,Math.abs(val*.001))){score.empty=true;score.message=ErrorCodes.APPROXIMATED_PI_ERROR;}}}};findCorrectAnswer();if(score.correct===false){let interpretedGuess=false;___default.default.each(forms,function(form){const anyAreNaN=___default.default.any(form(guess),function(t){return t.value!=null&&!___default.default.isNaN(t.value)});if(anyAreNaN){interpretedGuess=true;}});if(!interpretedGuess){score.empty=true;score.message=ErrorCodes.EXTRA_SYMBOLS_ERROR;return score}}return score}}},number:{convertToPredicate:function(correctAnswer,options){const correctFloat=parseFloat(correctAnswer);return [function(guess,maxError){return Math.abs(guess-correctFloat)<maxError},{...options,type:"predicate"}]},createValidatorFunctional:function(correctAnswer,options){return KhanAnswerTypes.predicate.createValidatorFunctional(...KhanAnswerTypes.number.convertToPredicate(correctAnswer,options))}},expression:{parseSolution:function(solutionString,options){let solution=KAS__namespace.parse(solutionString,options);if(!solution.parsed){throw new perseusCore.PerseusError("The provided solution ("+solutionString+") didn't parse.",perseusCore.Errors.InvalidInput)}else if(options.simplified&&!solution.expr.isSimplified()){throw new perseusCore.PerseusError("The provided solution ("+solutionString+") isn't fully expanded and simplified.",perseusCore.Errors.InvalidInput)}else {solution=solution.expr;}return solution},createValidatorFunctional:function(solution,options){return function(guess){const score={empty:false,correct:false,message:null,guess:guess,ungraded:false};if(!guess){score.empty=true;return score}const answer=KAS__namespace.parse(guess,options);if(!answer.parsed){score.empty=true;return score}if(typeof solution==="string"){solution=KhanAnswerTypes.expression.parseSolution(solution,options);}const result=KAS__namespace.compare(answer.expr,solution,options);if(result.equal){score.correct=true;}else if(result.wrongVariableNames||result.wrongVariableCase){score.ungraded=true;score.message=result.wrongVariableCase?ErrorCodes.WRONG_CASE_ERROR:ErrorCodes.WRONG_LETTER_ERROR;score.suppressAlmostThere=true;}else if(result.message){score.message=result.message;}else {const answerX=KAS__namespace.parse(guess.replace(/[xX]/g,"*"),options);if(answerX.parsed){const resultX=KAS__namespace.compare(answerX.expr,solution,options);if(resultX.equal){score.ungraded=true;score.message=ErrorCodes.MULTIPLICATION_SIGN_ERROR;}else if(resultX.message){score.message=resultX.message+" Also, I'm a computer. I only understand "+"multiplication if you use an "+"asterisk (*) as the multiplication "+"sign.";}}}return score}}}};
60
36
 
61
- // TOOD(kevinb): Figure out how this relates to KEScore in
62
- // perseus-all-package/types.js and see if there's a way to
63
- // unify these types.
37
+ function scoreCategorizer(userInput,rubric){let allCorrect=true;rubric.values.forEach((value,i)=>{if(userInput.values[i]!==value){allCorrect=false;}});return {type:"points",earned:allCorrect?1:0,total:1,message:null}}
64
38
 
65
- /**
66
- * Answer types
67
- *
68
- * Utility for creating answerable questions displayed in exercises
69
- *
70
- * Different answer types produce different kinds of input displays, and do
71
- * different kinds of checking on the solutions.
72
- *
73
- * Each of the objects contain two functions, setup and createValidator.
74
- *
75
- * The setup function takes a solutionarea and solution, and performs setup
76
- * within the solutionarea, and then returns an object which contains:
77
- *
78
- * answer: a function which, when called, will retrieve the current answer from
79
- * the solutionarea, which can then be validated using the validator
80
- * function
81
- * validator: a function returned from the createValidator function (defined
82
- * below)
83
- * solution: the correct answer to the problem
84
- * showGuess: a function which, when given a guess, shows the guess within the
85
- * provided solutionarea
86
- * showGuessCustom: a function which displays parts of a guess that are not
87
- * within the solutionarea; currently only used for custom
88
- * answers
89
- *
90
- * The createValidator function only takes a solution, and it returns a
91
- * function which can be used to validate an answer.
92
- *
93
- * The resulting validator function returns:
94
- * - true: if the answer is fully correct
95
- * - false: if the answer is incorrect
96
- * - "" (the empty string): if no answer has been provided (e.g. the answer box
97
- * is left unfilled)
98
- * - a string: if there is some slight error
99
- *
100
- * In most cases, setup and createValidator don't really need the solution DOM
101
- * element so we have setupFunctional and createValidatorFunctional for them
102
- * which take only $solution.text() and $solution.data(). This makes it easier
103
- * to reuse specific answer types.
104
- *
105
- * TODO(alpert): Think of a less-absurd name for createValidatorFunctional.
106
- *
107
- */
108
- const KhanAnswerTypes = {
109
- /*
110
- * predicate answer type
111
- *
112
- * performs simple predicate-based checking of a numeric solution, with
113
- * different kinds of number formats
114
- *
115
- * Uses the data-forms option on the solution to choose which number formats
116
- * are acceptable. Available data-forms:
117
- *
118
- * - integer: 3
119
- * - proper: 3/5
120
- * - improper: 5/3
121
- * - pi: 3 pi
122
- * - log: log(5)
123
- * - percent: 15%
124
- * - mixed: 1 1/3
125
- * - decimal: 1.7
126
- *
127
- * The solution should be a predicate of the form:
128
- *
129
- * function(guess, maxError) {
130
- * return abs(guess - 3) < maxError;
131
- * }
132
- *
133
- */
134
- predicate: {
135
- defaultForms: "integer, proper, improper, mixed, decimal",
136
- createValidatorFunctional: function (predicate, options) {
137
- // Extract the options from the given solution object
138
- options = ___default.default.extend({
139
- simplify: "required",
140
- ratio: false,
141
- forms: KhanAnswerTypes.predicate.defaultForms
142
- }, options);
143
- let acceptableForms;
144
- // this is maintaining backwards compatibility
145
- // TODO(merlob) fix all places that depend on this, then delete
146
- if (!___default.default.isArray(options.forms)) {
147
- acceptableForms = options.forms.split(/\s*,\s*/);
148
- } else {
149
- acceptableForms = options.forms;
150
- }
39
+ function validateCategorizer(userInput,validationData){const incomplete=validationData.items.some((_,i)=>userInput.values[i]==null);if(incomplete){return {type:"invalid",message:ErrorCodes.INVALID_SELECTION_ERROR}}return null}
151
40
 
152
- // TODO(jack): remove options.inexact in favor of options.maxError
153
- if (options.inexact === undefined) {
154
- // If we aren't allowing inexact, ensure that we don't have a
155
- // large maxError as well.
156
- options.maxError = 0;
157
- }
158
- // Allow a small tolerance on maxError, to avoid numerical
159
- // representation issues (2.3 should be correct for a solution of
160
- // 2.45 with maxError=0.15).
161
- options.maxError = +options.maxError + MAXERROR_EPSILON;
41
+ function scoreCSProgram(userInput){if(userInput.status==="correct"){return {type:"points",earned:1,total:1,message:userInput.message||null}}if(userInput.status==="incorrect"){return {type:"points",earned:0,total:1,message:userInput.message||null}}return {type:"invalid",message:"Keep going, you're not there yet!"}}
162
42
 
163
- // If percent is an acceptable form, make sure it's the last one
164
- // in the list so we don't prematurely complain about not having
165
- // a percent sign when the user entered the correct answer in a
166
- // different form (such as a decimal or fraction)
167
- if (___default.default.contains(acceptableForms, "percent")) {
168
- acceptableForms = ___default.default.without(acceptableForms, "percent");
169
- acceptableForms.push("percent");
170
- }
43
+ function scoreDropdown(userInput,rubric){const correct=rubric.choices[userInput.value-1].correct;return {type:"points",earned:correct?1:0,total:1,message:null}}
171
44
 
172
- // Take text looking like a fraction, and turn it into a number
173
- const fractionTransformer = function (text) {
174
- text = text
175
- // Replace unicode minus sign with hyphen
176
- .replace(/\u2212/, "-")
177
- // Remove space after +, -
178
- .replace(/([+-])\s+/g, "$1")
179
- // Remove leading/trailing whitespace
180
- .replace(/(^\s*)|(\s*$)/gi, "");
45
+ function validateDropdown(userInput){if(userInput.value===0){return {type:"invalid",message:null}}return null}
181
46
 
182
- // Extract numerator and denominator
183
- const match = text.match(/^([+-]?\d+)\s*\/\s*([+-]?\d+)$/);
184
- // Fractions are represented as "-\frac{numerator}{denominator}"
185
- // in Mobile device input instead of "numerator/denominator" as
186
- // in web-browser.
187
- const mobileDeviceMatch = text.match(/^([+-]?)\\frac\{([+-]?\d+)\}\{([+-]?\d+)\}$/);
188
- const parsedInt = parseInt(text, 10);
189
- if (match || mobileDeviceMatch) {
190
- let num;
191
- let denom;
192
- let simplified = true;
193
- if (match) {
194
- num = parseFloat(match[1]);
195
- denom = parseFloat(match[2]);
196
- } else {
197
- num = parseFloat(mobileDeviceMatch[2]);
198
- if (mobileDeviceMatch[1] === "-") {
199
- if (num < 0) {
200
- simplified = false;
201
- }
202
- num = -num;
203
- }
204
- denom = parseFloat(mobileDeviceMatch[3]);
205
- }
206
- simplified = simplified && denom > 0 && (options.ratio || denom !== 1) && kmath.KhanMath.getGCD(num, denom) === 1;
207
- return [{
208
- value: num / denom,
209
- exact: simplified
210
- }];
211
- }
212
- if (!isNaN(parsedInt) && "" + parsedInt === text) {
213
- return [{
214
- value: parsedInt,
215
- exact: true
216
- }];
217
- }
218
- return [];
219
- };
220
-
221
- /*
222
- * Different forms of numbers
223
- *
224
- * Each function returns a list of objects of the form:
225
- *
226
- * {
227
- * value: numerical value,
228
- * exact: true/false
229
- * }
230
- */
231
- const forms = {
232
- // integer, which is encompassed by decimal
233
- integer: function (text) {
234
- // Compare the decimal form to the decimal form rounded to
235
- // an integer. Only accept if the user actually entered an
236
- // integer.
237
- const decimal = forms.decimal(text);
238
- const rounded = forms.decimal(text, 1);
239
- if (decimal[0].value != null && decimal[0].value === rounded[0].value || decimal[1].value != null && decimal[1].value === rounded[1].value) {
240
- return decimal;
241
- }
242
- return [];
243
- },
244
- // A proper fraction
245
- proper: function (text) {
246
- const transformed = fractionTransformer(text);
247
- return transformed.flatMap(o => {
248
- // All fractions that are less than 1
249
- if (Math.abs(o.value) < 1) {
250
- return [o];
251
- }
252
- return [];
253
- });
254
- },
255
- // an improper fraction
256
- improper: function (text) {
257
- // As our answer keys are always in simplest form, we need
258
- // to check for the existence of a fraction in the input before
259
- // validating the answer. If no fraction is found, we consider
260
- // the answer to be incorrect.
261
- const fractionExists = text.includes("/") || text.match(/\\(d?frac)/);
262
- if (!fractionExists) {
263
- return [];
264
- }
265
- const transformed = fractionTransformer(text);
266
- return transformed.flatMap(o => {
267
- // All fractions that are greater than 1
268
- if (Math.abs(o.value) >= 1) {
269
- return [o];
270
- }
271
- return [];
272
- });
273
- },
274
- // pi-like numbers
275
- pi: function (text) {
276
- let match;
277
- let possibilities = [];
278
-
279
- // Replace unicode minus sign with hyphen
280
- text = text.replace(/\u2212/, "-");
281
-
282
- // - pi
283
- // (Note: we also support \pi (for TeX), p, tau (and \tau,
284
- // and t), pau.)
285
- if (match = text.match(/^([+-]?)\s*(\\?pi|p|\u03c0|\\?tau|t|\u03c4|pau)$/i)) {
286
- possibilities = [{
287
- value: parseFloat(match[1] + "1"),
288
- exact: true
289
- }];
290
-
291
- // 5 / 6 pi
292
- } else if (match = text.match(/^([+-]?\s*\d+\s*(?:\/\s*[+-]?\s*\d+)?)\s*\*?\s*(\\?pi|p|\u03c0|\\?tau|t|\u03c4|pau)$/i)) {
293
- possibilities = fractionTransformer(match[1]);
294
-
295
- // 4 5 / 6 pi
296
- } else if (match = text.match(/^([+-]?)\s*(\d+)\s*([+-]?\d+)\s*\/\s*([+-]?\d+)\s*\*?\s*(\\?pi|p|\u03c0|\\?tau|t|\u03c4|pau)$/i)) {
297
- const sign = parseFloat(match[1] + "1");
298
- const integ = parseFloat(match[2]);
299
- const num = parseFloat(match[3]);
300
- const denom = parseFloat(match[4]);
301
- const simplified = num < denom && kmath.KhanMath.getGCD(num, denom) === 1;
302
- possibilities = [{
303
- value: sign * (integ + num / denom),
304
- exact: simplified
305
- }];
306
-
307
- // 5 pi / 6
308
- } else if (match = text.match(/^([+-]?\s*\d+)\s*\*?\s*(\\?pi|p|\u03c0|\\?tau|t|\u03c4|pau)\s*(?:\/\s*([+-]?\s*\d+))?$/i)) {
309
- possibilities = fractionTransformer(match[1] + "/" + match[3]);
310
-
311
- // - pi / 4
312
- } else if (match = text.match(/^([+-]?)\s*\*?\s*(\\?pi|p|\u03c0|\\?tau|t|\u03c4|pau)\s*(?:\/\s*([+-]?\d+))?$/i)) {
313
- possibilities = fractionTransformer(match[1] + "1/" + match[3]);
314
-
315
- // 0
316
- } else if (text === "0") {
317
- possibilities = [{
318
- value: 0,
319
- exact: true
320
- }];
321
-
322
- // 0.5 pi (fallback)
323
- } else if (match = text.match(/^(.+)\s*\*?\s*(\\?pi|p|\u03c0|\\?tau|t|\u03c4|pau)$/i)) {
324
- possibilities = forms.decimal(match[1]);
325
- } else {
326
- possibilities = ___default.default.reduce(KhanAnswerTypes.predicate.defaultForms.split(/\s*,\s*/), function (memo, form) {
327
- return memo.concat(forms[form](text));
328
- }, []);
329
-
330
- // If the answer is a floating point number that's
331
- // near a multiple of pi, mark is as being possibly
332
- // an approximation of pi. We actually check if
333
- // it's a plausible approximation of pi/12, since
334
- // sometimes the correct answer is like pi/3 or pi/4.
335
- // We also say it's a pi-approximation if it involves
336
- // x/7 (since 22/7 is an approximation of pi.)
337
- // Never mark an integer as being an approximation
338
- // of pi.
339
- let approximatesPi = false;
340
- const number = parseFloat(text);
341
- if (!isNaN(number) && number !== parseInt(text)) {
342
- const piMult = Math.PI / 12;
343
- const roundedNumber = piMult * Math.round(number / piMult);
344
- if (Math.abs(number - roundedNumber) < 0.01) {
345
- approximatesPi = true;
346
- }
347
- } else if (text.match(/\/\s*7/)) {
348
- approximatesPi = true;
349
- }
350
- if (approximatesPi) {
351
- ___default.default.each(possibilities, function (possibility) {
352
- possibility.piApprox = true;
353
- });
354
- }
355
- return possibilities;
356
- }
357
- let multiplier = Math.PI;
358
- if (text.match(/\\?tau|t|\u03c4/)) {
359
- multiplier = Math.PI * 2;
360
- }
361
-
362
- // We're taking an early stand along side xkcd in the
363
- // inevitable ti vs. pau debate... http://xkcd.com/1292
364
- if (text.match(/pau/)) {
365
- multiplier = Math.PI * 1.5;
366
- }
367
- possibilities.forEach(possibility => {
368
- possibility.value *= multiplier;
369
- });
370
- return possibilities;
371
- },
372
- // Converts '' to 1 and '-' to -1 so you can write "[___] x"
373
- // and accept sane things
374
- coefficient: function (text) {
375
- let possibilities = [];
376
-
377
- // Replace unicode minus sign with hyphen
378
- text = text.replace(/\u2212/, "-");
379
- if (text === "") {
380
- possibilities = [{
381
- value: 1,
382
- exact: true
383
- }];
384
- } else if (text === "-") {
385
- possibilities = [{
386
- value: -1,
387
- exact: true
388
- }];
389
- }
390
- return possibilities;
391
- },
392
- // simple log(c) form
393
- log: function (text) {
394
- let match;
395
- let possibilities = [];
396
-
397
- // Replace unicode minus sign with hyphen
398
- text = text.replace(/\u2212/, "-");
399
- text = text.replace(/[ \(\)]/g, "");
400
- if (match = text.match(/^log\s*(\S+)\s*$/i)) {
401
- // @ts-expect-error - TS2322 - Type '{ value: number | undefined; exact: boolean; }[]' is not assignable to type 'never[]'.
402
- possibilities = forms.decimal(match[1]);
403
- } else if (text === "0") {
404
- // @ts-expect-error - TS2322 - Type 'number' is not assignable to type 'never'. | TS2322 - Type 'boolean' is not assignable to type 'never'.
405
- possibilities = [{
406
- value: 0,
407
- exact: true
408
- }];
409
- }
410
- return possibilities;
411
- },
412
- // Numbers with percent signs
413
- percent: function (text) {
414
- text = String(text).trim();
415
- // store whether or not there is a percent sign
416
- let hasPercentSign = false;
417
- if (text.indexOf("%") === text.length - 1) {
418
- text = text.substring(0, text.length - 1).trim();
419
- hasPercentSign = true;
420
- }
421
- const transformed = forms.decimal(text);
422
- transformed.forEach(t => {
423
- t.exact = hasPercentSign;
424
- // @ts-expect-error - TS2532 - Object is possibly 'undefined'.
425
- t.value = t.value / 100;
426
- });
427
- return transformed;
428
- },
429
- // Mixed numbers, like 1 3/4
430
- mixed: function (text) {
431
- const match = text
432
- // Replace unicode minus sign with hyphen
433
- .replace(/\u2212/, "-")
434
- // Remove space after +, -
435
- .replace(/([+-])\s+/g, "$1")
436
- // Extract integer, numerator and denominator
437
- .match(/^([+-]?)(\d+)\s+(\d+)\s*\/\s*(\d+)$/);
438
- if (match) {
439
- const sign = parseFloat(match[1] + "1");
440
- const integ = parseFloat(match[2]);
441
- const num = parseFloat(match[3]);
442
- const denom = parseFloat(match[4]);
443
- const simplified = num < denom && kmath.KhanMath.getGCD(num, denom) === 1;
444
- return [{
445
- value: sign * (integ + num / denom),
446
- exact: simplified
447
- }];
448
- }
449
- return [];
450
- },
451
- // Decimal numbers -- compare entered text rounded to
452
- // 'precision' Reciprical of the precision against the correct
453
- // answer. We round to 1/1e10 by default, which is healthily
454
- // less than machine epsilon but should be more than any real
455
- // decimal answer would use. (The 'integer' answer type uses
456
- // precision == 1.)
457
- decimal: function (text) {
458
- let precision = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 1e10;
459
- const normal = function (text) {
460
- text = String(text).trim();
461
- const match = text
462
- // Replace unicode minus sign with hyphen
463
- .replace(/\u2212/, "-")
464
- // Remove space after +, -
465
- .replace(/([+-])\s+/g, "$1")
466
- // Extract integer, numerator and denominator. If
467
- // commas or spaces are used, they must be in the
468
- // "correct" places
469
- .match(/^([+-]?(?:\d{1,3}(?:[, ]?\d{3})*\.?|\d{0,3}(?:[, ]?\d{3})*\.(?:\d{3}[, ]?)*\d{1,3}))$/);
470
-
471
- // You can't start a number with `0,`, to prevent us
472
- // interpeting '0.342' as correct for '342'
473
- const badLeadingZero = text.match(/^0[0,]*,/);
474
- if (match && !badLeadingZero) {
475
- let x = parseFloat(match[1].replace(/[, ]/g, ""));
476
- if (options.inexact === undefined) {
477
- x = Math.round(x * precision) / precision;
478
- }
479
- return x;
480
- }
481
- };
482
- const commas = function (text) {
483
- text = text.replace(/([\.,])/g, function (_, c) {
484
- return c === "." ? "," : ".";
485
- });
486
- return normal(text);
487
- };
488
- return [{
489
- value: normal(text),
490
- exact: true
491
- }, {
492
- value: commas(text),
493
- exact: true
494
- }];
495
- }
496
- };
497
-
498
- // validator function
499
- return function (guess) {
500
- // The fallback variable is used in place of the answer, if no
501
- // answer is provided (i.e. the field is left blank)
502
- const fallback = options.fallback != null ? "" + options.fallback : "";
503
- guess = String(guess).trim() || fallback;
504
- const score = {
505
- empty: guess === "",
506
- correct: false,
507
- message: null,
508
- guess: guess
509
- };
510
-
511
- // Iterate over all the acceptable forms
512
- // and exit if one of the answers is correct.
513
- //
514
- // HACK: This function is a bug fix from LEMS-2962;
515
- // after a transition from jQuery's `each` to JS's `forEach`
516
- // we realized this code was banking on the ability to:
517
- // 1. exit early from nested loops (can be tricky outside of functions)
518
- // 2. mutate external variables (score)
519
- // Could probably be refactored to be a pure function that
520
- // returns a score, but this code is poorly tested and prone to break.
521
- const findCorrectAnswer = () => {
522
- // WARNING: Don't use `forEach` without additional refactoring
523
- // because code needs to be able to exit early
524
- for (const form of acceptableForms) {
525
- const transformed = forms[form](guess);
526
- for (let j = 0, l = transformed.length; j < l; j++) {
527
- const val = transformed[j].value;
528
- const exact = transformed[j].exact;
529
- const piApprox = transformed[j].piApprox;
530
- // If a string was returned, and it exactly matches,
531
- // return true
532
- if (predicate(val, options.maxError)) {
533
- // If the exact correct number was returned,
534
- // return true
535
- if (exact || options.simplify === "optional") {
536
- score.correct = true;
537
- score.message = options.message || null;
538
- // If the answer is correct, don't say it's
539
- // empty. This happens, for example, with the
540
- // coefficient type where guess === "" but is
541
- // interpreted as "1" which is correct.
542
- score.empty = false;
543
- } else if (form === "percent") {
544
- // Otherwise, an error was returned
545
- score.empty = true;
546
- score.message = ErrorCodes.MISSING_PERCENT_ERROR;
547
- } else {
548
- if (options.simplify !== "enforced") {
549
- score.empty = true;
550
- }
551
- score.message = ErrorCodes.NEEDS_TO_BE_SIMPLIFIED_ERROR;
552
- }
553
- // HACK: The return false below stops the looping of the
554
- // callback since predicate check succeeded.
555
- // No more forms to look to verify the user guess.
556
- return false;
557
- }
558
- if (piApprox && predicate(val, Math.abs(val * 0.001))) {
559
- score.empty = true;
560
- score.message = ErrorCodes.APPROXIMATED_PI_ERROR;
561
- }
562
- }
563
- }
564
- };
565
-
566
- // mutates `score`
567
- findCorrectAnswer();
568
- if (score.correct === false) {
569
- let interpretedGuess = false;
570
- ___default.default.each(forms, function (form) {
571
- const anyAreNaN = ___default.default.any(form(guess), function (t) {
572
- return t.value != null && !___default.default.isNaN(t.value);
573
- });
574
- if (anyAreNaN) {
575
- interpretedGuess = true;
576
- }
577
- });
578
- if (!interpretedGuess) {
579
- score.empty = true;
580
- score.message = ErrorCodes.EXTRA_SYMBOLS_ERROR;
581
- return score;
582
- }
583
- }
584
- return score;
585
- };
586
- }
587
- },
588
- /*
589
- * number answer type
590
- *
591
- * wraps the predicate answer type to performs simple number-based checking
592
- * of a solution
593
- */
594
- number: {
595
- convertToPredicate: function (correctAnswer, options) {
596
- const correctFloat = parseFloat(correctAnswer);
597
- return [function (guess, maxError) {
598
- return Math.abs(guess - correctFloat) < maxError;
599
- }, {
600
- ...options,
601
- type: "predicate"
602
- }];
603
- },
604
- createValidatorFunctional: function (correctAnswer, options) {
605
- return KhanAnswerTypes.predicate.createValidatorFunctional(...KhanAnswerTypes.number.convertToPredicate(correctAnswer, options));
606
- }
607
- },
608
- /*
609
- * The expression answer type parses a given expression or equation
610
- * and semantically compares it to the solution. In addition, instant
611
- * feedback is provided by rendering the last answer that fully parsed.
612
- *
613
- * Parsing options:
614
- * functions (e.g. data-functions="f g h")
615
- * A space or comma separated list of single-letter variables that
616
- * should be interpreted as functions. Case sensitive. "e" and "i"
617
- * are reserved.
618
- *
619
- * no functions specified: f(x+y) == fx + fy
620
- * with "f" as a function: f(x+y) != fx + fy
621
- *
622
- * Comparison options:
623
- * same-form (e.g. data-same-form)
624
- * If present, the answer must match the solution's structure in
625
- * addition to evaluating the same. Commutativity and excess negation
626
- * are ignored, but all other changes will trigger a rejection. Useful
627
- * for requiring a particular form of an equation, or if the answer
628
- * must be factored.
629
- *
630
- * example question: Factor x^2 + x - 2
631
- * example solution: (x-1)(x+2)
632
- * accepted answers: (x-1)(x+2), (x+2)(x-1), ---(-x-2)(-1+x), etc.
633
- * rejected answers: x^2+x-2, x*x+x-2, x(x+1)-2, (x-1)(x+2)^1, etc.
634
- * rejection message: Your answer is not in the correct form
635
- *
636
- * simplify (e.g. data-simplify)
637
- * If present, the answer must be fully expanded and simplified. Use
638
- * carefully - simplification is hard and there may be bugs, or you
639
- * might not agree on the definition of "simplified" used. You will
640
- * get an error if the provided solution is not itself fully expanded
641
- * and simplified.
642
- *
643
- * example question: Simplify ((n*x^5)^5) / (n^(-2)*x^2)^-3
644
- * example solution: x^31 / n
645
- * accepted answers: x^31 / n, x^31 / n^1, x^31 * n^(-1), etc.
646
- * rejected answers: (x^25 * n^5) / (x^(-6) * n^6), etc.
647
- * rejection message: Your answer is not fully expanded and simplified
648
- *
649
- * Rendering options:
650
- * times (e.g. data-times)
651
- * If present, explicit multiplication (such as between numbers) will
652
- * be rendered with a cross/x symbol (TeX: \times) instead of the usual
653
- * center dot (TeX: \cdot).
654
- *
655
- * normal rendering: 2 * 3^x -> 2 \cdot 3^{x}
656
- * but with "times": 2 * 3^x -> 2 \times 3^{x}
657
- */
658
- expression: {
659
- parseSolution: function (solutionString, options) {
660
- let solution = KAS__namespace.parse(solutionString, options);
661
- if (!solution.parsed) {
662
- throw new perseusCore.PerseusError("The provided solution (" + solutionString + ") didn't parse.", perseusCore.Errors.InvalidInput);
663
- } else if (options.simplified && !solution.expr.isSimplified()) {
664
- throw new perseusCore.PerseusError("The provided solution (" + solutionString + ") isn't fully expanded and simplified.", perseusCore.Errors.InvalidInput);
665
- } else {
666
- solution = solution.expr;
667
- }
668
- return solution;
669
- },
670
- createValidatorFunctional: function (solution, options) {
671
- return function (guess) {
672
- const score = {
673
- empty: false,
674
- correct: false,
675
- message: null,
676
- guess: guess,
677
- // Setting `ungraded` to true indicates that if the
678
- // guess doesn't match any of the solutions, the guess
679
- // shouldn't be marked as incorrect; instead, `message`
680
- // should be shown to the user. This is different from
681
- // setting `empty` to true, since the behavior of `empty`
682
- // is that `message` only will be shown if the guess is
683
- // graded as empty for every solution.
684
- ungraded: false
685
- };
686
-
687
- // Don't bother parsing an empty input
688
- if (!guess) {
689
- // @ts-expect-error - TS2540 - Cannot assign to 'empty' because it is a read-only property.
690
- score.empty = true;
691
- return score;
692
- }
693
- const answer = KAS__namespace.parse(guess, options);
694
-
695
- // An unsuccessful parse doesn't count as wrong
696
- if (!answer.parsed) {
697
- // @ts-expect-error - TS2540 - Cannot assign to 'empty' because it is a read-only property.
698
- score.empty = true;
699
- return score;
700
- }
701
-
702
- // Solution will need to be parsed again if we're creating
703
- // this from a multiple question type
704
- if (typeof solution === "string") {
705
- solution = KhanAnswerTypes.expression.parseSolution(solution, options);
706
- }
707
- const result = KAS__namespace.compare(answer.expr, solution, options);
708
- if (result.equal) {
709
- // Correct answer
710
- // @ts-expect-error - TS2540 - Cannot assign to 'correct' because it is a read-only property.
711
- score.correct = true;
712
- } else if (result.wrongVariableNames || result.wrongVariableCase) {
713
- // We don't want to give people an error for getting the
714
- // variable names or the variable case wrong.
715
- // TODO(aasmund): This should ideally have been handled
716
- // under the `result.message` condition, but the
717
- // KAS messages currently aren't translatable.
718
- // @ts-expect-error - TS2540 - Cannot assign to 'ungraded' because it is a read-only property.
719
- score.ungraded = true;
720
- // @ts-expect-error - TS2540 - Cannot assign to 'message' because it is a read-only property.
721
- score.message = result.wrongVariableCase ? ErrorCodes.WRONG_CASE_ERROR : ErrorCodes.WRONG_LETTER_ERROR;
722
- // Don't tell the use they're "almost there" in this case, that may not be true and isn't helpful.
723
- // @ts-expect-error - TS2339 - Property 'suppressAlmostThere' does not exist on type '{ readonly empty: false; readonly correct: false; readonly message: string | null | undefined; readonly guess: any; readonly ungraded: false; }'.
724
- score.suppressAlmostThere = true;
725
- } else if (result.message) {
726
- // Nearly correct answer
727
- // TODO(aasmund): This message also isn't translatable;
728
- // need to fix that in KAS
729
- // @ts-expect-error - TS2540 - Cannot assign to 'message' because it is a read-only property.
730
- score.message = result.message;
731
- } else {
732
- // Replace x with * and see if it would have been correct
733
- // TODO(aasmund): I think this branch is effectively dead,
734
- // because the replacement will only work in situations
735
- // where the variables are wrong (except if the variable
736
- // is x, in which case the replacement won't work either),
737
- // which is handled by another branch. When we implement a
738
- // more sophisticated variable check, revive this or
739
- // remove it completely if it will never come into play.
740
- const answerX = KAS__namespace.parse(guess.replace(/[xX]/g, "*"), options);
741
- if (answerX.parsed) {
742
- const resultX = KAS__namespace.compare(answerX.expr, solution, options);
743
- if (resultX.equal) {
744
- // @ts-expect-error - TS2540 - Cannot assign to 'ungraded' because it is a read-only property.
745
- score.ungraded = true;
746
- // @ts-expect-error - TS2540 - Cannot assign to 'message' because it is a read-only property.
747
- score.message = ErrorCodes.MULTIPLICATION_SIGN_ERROR;
748
- } else if (resultX.message) {
749
- // TODO(aasmund): I18nize `score.message`
750
- // @ts-expect-error - TS2540 - Cannot assign to 'message' because it is a read-only property.
751
- score.message = resultX.message + " Also, I'm a computer. I only understand " + "multiplication if you use an " + "asterisk (*) as the multiplication " + "sign.";
752
- }
753
- }
754
- }
755
- return score;
756
- };
757
- }
758
- }
759
- };
760
-
761
- function scoreCategorizer(userInput, rubric) {
762
- let allCorrect = true;
763
- rubric.values.forEach((value, i) => {
764
- if (userInput.values[i] !== value) {
765
- allCorrect = false;
766
- }
767
- });
768
- return {
769
- type: "points",
770
- earned: allCorrect ? 1 : 0,
771
- total: 1,
772
- message: null
773
- };
774
- }
775
-
776
- /**
777
- * Checks userInput from the categorizer widget to see if the user has selected
778
- * a category for each item.
779
- * @param userInput - The user's input corresponding to an array of indices that
780
- * represent the selected category for each row/item.
781
- * @param validationData - An array of strings corresponding to each row/item
782
- * @param strings - Used to provide a validation message
783
- */
784
- function validateCategorizer(userInput, validationData) {
785
- const incomplete = validationData.items.some((_, i) => userInput.values[i] == null);
786
- if (incomplete) {
787
- return {
788
- type: "invalid",
789
- message: ErrorCodes.INVALID_SELECTION_ERROR
790
- };
791
- }
792
- return null;
793
- }
794
-
795
- function scoreCSProgram(userInput) {
796
- // The CS program can tell us whether it's correct or incorrect,
797
- // and pass an optional message
798
- if (userInput.status === "correct") {
799
- return {
800
- type: "points",
801
- earned: 1,
802
- total: 1,
803
- message: userInput.message || null
804
- };
805
- }
806
- if (userInput.status === "incorrect") {
807
- return {
808
- type: "points",
809
- earned: 0,
810
- total: 1,
811
- message: userInput.message || null
812
- };
813
- }
814
- return {
815
- type: "invalid",
816
- message: "Keep going, you're not there yet!"
817
- };
818
- }
819
-
820
- function scoreDropdown(userInput, rubric) {
821
- const correct = rubric.choices[userInput.value - 1].correct;
822
- return {
823
- type: "points",
824
- earned: correct ? 1 : 0,
825
- total: 1,
826
- message: null
827
- };
828
- }
829
-
830
- /**
831
- * Checks if the user has selected an item from the dropdown before scoring.
832
- * This is shown with a userInput value / index other than 0.
833
- */
834
- function validateDropdown(userInput) {
835
- if (userInput.value === 0) {
836
- return {
837
- type: "invalid",
838
- message: null
839
- };
840
- }
841
- return null;
842
- }
843
-
844
- /* Content creators input a list of answers which are matched from top to
845
- * bottom. The intent is that they can include spcific solutions which should
846
- * be graded as correct or incorrect (or ungraded!) first, then get more
847
- * general.
848
- *
849
- * We iterate through each answer, trying to match it with the user's input
850
- * using the following angorithm:
851
- * - Try to parse the user's input. If it doesn't parse then return "not
852
- * graded".
853
- * - For each answer:
854
- * ~ Try to validate the user's input against the answer. The answer is
855
- * expected to parse.
856
- * ~ If the user's input validates (the validator judges it "correct"), we've
857
- * matched and can stop considering answers.
858
- * - If there were no matches or the matching answer is considered "ungraded",
859
- * show the user an error. TODO(joel) - what error?
860
- * - Otherwise, pass through the resulting points and message.
861
- */
862
- function scoreExpression(userInput, rubric, locale) {
863
- const options = ___default.default.clone(rubric);
864
- ___default.default.extend(options, {
865
- decimal_separator: perseusCore.getDecimalSeparator(locale)
866
- });
867
- const createValidator = answer => {
868
- // We give options to KAS.parse here because it is parsing the
869
- // solution answer, not the student answer, and we don't want a
870
- // solution to work if the student is using a different language
871
- // (different from the content creation language, ie. English).
872
- const expression = KAS__namespace.parse(answer.value, rubric);
873
- // An answer may not be parsed if the expression was defined
874
- // incorrectly. For example if the answer is using a symbol defined
875
- // in the function variables list for the expression.
876
- if (!expression.parsed) {
877
- /* c8 ignore next */
878
- throw new perseusCore.PerseusError("Unable to parse solution answer for expression", perseusCore.Errors.InvalidInput, {
879
- metadata: {
880
- rubric: JSON.stringify(rubric)
881
- }
882
- });
883
- }
884
- return KhanAnswerTypes.expression.createValidatorFunctional(expression.expr, ___default.default({}).extend(options, {
885
- simplify: answer.simplify,
886
- form: answer.form
887
- }));
888
- };
889
-
890
- // Find the first answer form that matches the user's input and that
891
- // is considered correct. Also, track whether the input is
892
- // considered "empty" for all answer forms, and keep the validation
893
- // result for the first answer form for which the user's input was
894
- // considered "ungraded".
895
- // (Terminology reminder: the answer forms are provided by the
896
- // assessment items; they are not the user's input. Each one might
897
- // represent a correct answer, an incorrect one (if the exercise
898
- // creator has predicted certain common wrong answers and wants to
899
- // provide guidance via a message), or an ungraded one (same idea,
900
- // but without giving the user an incorrect mark for the question).
901
- let matchingAnswerForm;
902
- let matchMessage;
903
- let allEmpty = true;
904
- let firstUngradedResult;
905
- // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
906
- for (const answerForm of rubric.answerForms || []) {
907
- const validator = createValidator(answerForm);
908
- // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
909
- if (!validator) {
910
- continue;
911
- }
912
- const result = validator(userInput);
913
-
914
- // Short-circuit as soon as the user's input matches some answer
915
- // (independently of whether the answer is correct)
916
- if (result.correct) {
917
- matchingAnswerForm = answerForm;
918
- matchMessage = result.message || "";
919
- break;
920
- }
921
- allEmpty = allEmpty && result.empty;
922
- // If this answer form is correct and the user's input is considered
923
- // "ungraded" for it, we'll want to keep the evaluation result for
924
- // later. If the user's input doesn't match any answer forms, we'll
925
- // show the message from this validation.
926
- if (answerForm.considered === "correct" && result.ungraded && !firstUngradedResult) {
927
- firstUngradedResult = result;
928
- }
929
- }
930
-
931
- // Now check to see if we matched any answer form at all, and if
932
- // we did, whether it's considered correct, incorrect, or ungraded
933
- if (!matchingAnswerForm) {
934
- if (firstUngradedResult) {
935
- // While we didn't directly match with any answer form, we
936
- // did at some point get an "ungraded" validation result,
937
- // which might indicate e.g. a mismatch in variable casing.
938
- // We'll return "invalid", which will let the user try again
939
- // with no penalty, and the hopefully helpful validation
940
- // message.
941
- return {
942
- type: "invalid",
943
- message: firstUngradedResult.message,
944
- suppressAlmostThere: firstUngradedResult.suppressAlmostThere
945
- };
946
- }
947
- if (allEmpty) {
948
- // If everything graded as empty, it's invalid.
949
- return {
950
- type: "invalid",
951
- message: null
952
- };
953
- }
954
- // We fell through all the possibilities and we're not empty,
955
- // so the answer is considered incorrect.
956
- return {
957
- type: "points",
958
- earned: 0,
959
- total: 1
960
- };
961
- }
962
- if (matchingAnswerForm.considered === "ungraded") {
963
- return {
964
- type: "invalid",
965
- message: matchMessage
966
- };
967
- }
968
- // We matched a graded answer form, so we can now tell the user
969
- // whether their input was correct or incorrect, and hand out
970
- // points accordingly
971
- return {
972
- type: "points",
973
- earned: matchingAnswerForm.considered === "correct" ? 1 : 0,
974
- total: 1,
975
- message: matchMessage
976
- };
977
- }
978
-
979
- /**
980
- * Checks user input from the expression widget to see if it is scorable.
981
- *
982
- * Note: Most of the expression widget's validation requires the Rubric because
983
- * of its use of KhanAnswerTypes as a core part of scoring.
984
- *
985
- * @see `scoreExpression()` for more details.
986
- */
987
- function validateExpression(userInput) {
988
- if (userInput === "") {
989
- return {
990
- type: "invalid",
991
- message: null
992
- };
993
- }
994
- return null;
995
- }
996
-
997
- function getCoefficientsByType(data) {
998
- if (data.coords == null) {
999
- return undefined;
1000
- }
1001
- if (data.type === "exponential" || data.type === "logarithm") {
1002
- const grader = perseusCore.GrapherUtil.functionForType(data.type);
1003
- return grader.getCoefficients(data.coords, data.asymptote);
1004
- } else if (data.type === "linear" || data.type === "quadratic" || data.type === "absolute_value" || data.type === "sinusoid" || data.type === "tangent") {
1005
- const grader = perseusCore.GrapherUtil.functionForType(data.type);
1006
- return grader.getCoefficients(data.coords);
1007
- } else {
1008
- throw new perseusCore.PerseusError("Invalid grapher type", perseusCore.Errors.InvalidInput);
1009
- }
1010
- }
1011
- function scoreGrapher(userInput, rubric) {
1012
- if (userInput.type !== rubric.correct.type) {
1013
- return {
1014
- type: "points",
1015
- earned: 0,
1016
- total: 1,
1017
- message: null
1018
- };
1019
- }
1020
-
1021
- // We haven't moved the coords
1022
- if (userInput.coords == null) {
1023
- return {
1024
- type: "invalid",
1025
- message: null
1026
- };
1027
- }
1028
-
1029
- // Get new function handler for grading
1030
- const grader = perseusCore.GrapherUtil.functionForType(userInput.type);
1031
- const guessCoeffs = getCoefficientsByType(userInput);
1032
- const correctCoeffs = getCoefficientsByType(rubric.correct);
1033
- if (guessCoeffs == null || correctCoeffs == null) {
1034
- return {
1035
- type: "invalid",
1036
- message: null
1037
- };
1038
- }
1039
- if (grader.areEqual(guessCoeffs, correctCoeffs)) {
1040
- return {
1041
- type: "points",
1042
- earned: 1,
1043
- total: 1,
1044
- message: null
1045
- };
1046
- }
1047
- return {
1048
- type: "points",
1049
- earned: 0,
1050
- total: 1,
1051
- message: null
1052
- };
1053
- }
1054
-
1055
- // TODO: merge this with scoreCSProgram, it's the same code
1056
- function scoreIframe(userInput) {
1057
- // The iframe can tell us whether it's correct or incorrect,
1058
- // and pass an optional message
1059
- if (userInput.status === "correct") {
1060
- return {
1061
- type: "points",
1062
- earned: 1,
1063
- total: 1,
1064
- message: userInput.message || null
1065
- };
1066
- }
1067
- if (userInput.status === "incorrect") {
1068
- return {
1069
- type: "points",
1070
- earned: 0,
1071
- total: 1,
1072
- message: userInput.message || null
1073
- };
1074
- }
1075
- return {
1076
- type: "invalid",
1077
- message: "Keep going, you're not there yet!"
1078
- };
1079
- }
1080
-
1081
- const {
1082
- collinear,
1083
- canonicalSineCoefficients,
1084
- similar,
1085
- clockwise
1086
- } = kmath.geometry;
1087
- const {
1088
- getClockwiseAngle
1089
- } = kmath.angles;
1090
- const {
1091
- getSinusoidCoefficients,
1092
- getQuadraticCoefficients
1093
- } = kmath.coefficients;
1094
- function scoreInteractiveGraph(userInput, rubric) {
1095
- // None-type graphs are not graded
1096
- if (userInput.type === "none" && rubric.correct.type === "none") {
1097
- return {
1098
- type: "points",
1099
- earned: 0,
1100
- total: 0,
1101
- message: null
1102
- };
1103
- }
1104
-
1105
- // When nothing has moved, there will neither be coords nor the
1106
- // circle's center/radius fields. When those fields are absent, skip
1107
- // all these checks; just go mark the answer as empty.
1108
- const hasValue = Boolean(
1109
- // @ts-expect-error - TS2339 - Property 'coords' does not exist on type 'PerseusGraphType'.
1110
- userInput.coords ||
1111
- // @ts-expect-error - TS2339 - Property 'center' does not exist on type 'PerseusGraphType'. | TS2339 - Property 'radius' does not exist on type 'PerseusGraphType'.
1112
- userInput.center && userInput.radius);
1113
- if (userInput.type === rubric.correct.type && hasValue) {
1114
- if (userInput.type === "linear" && rubric.correct.type === "linear" && userInput.coords != null) {
1115
- const guess = userInput.coords;
1116
- const correct = rubric.correct.coords;
1117
-
1118
- // If both of the guess points are on the correct line, it's
1119
- // correct.
1120
- if (collinear(correct[0], correct[1], guess[0]) && collinear(correct[0], correct[1], guess[1])) {
1121
- return {
1122
- type: "points",
1123
- earned: 1,
1124
- total: 1,
1125
- message: null
1126
- };
1127
- }
1128
- } else if (userInput.type === "linear-system" && rubric.correct.type === "linear-system" && userInput.coords != null) {
1129
- const guess = userInput.coords;
1130
- const correct = rubric.correct.coords;
1131
- if (collinear(correct[0][0], correct[0][1], guess[0][0]) && collinear(correct[0][0], correct[0][1], guess[0][1]) && collinear(correct[1][0], correct[1][1], guess[1][0]) && collinear(correct[1][0], correct[1][1], guess[1][1]) || collinear(correct[0][0], correct[0][1], guess[1][0]) && collinear(correct[0][0], correct[0][1], guess[1][1]) && collinear(correct[1][0], correct[1][1], guess[0][0]) && collinear(correct[1][0], correct[1][1], guess[0][1])) {
1132
- return {
1133
- type: "points",
1134
- earned: 1,
1135
- total: 1,
1136
- message: null
1137
- };
1138
- }
1139
- } else if (userInput.type === "quadratic" && rubric.correct.type === "quadratic" && userInput.coords != null) {
1140
- // If the parabola coefficients match, it's correct.
1141
- const guessCoeffs = getQuadraticCoefficients(userInput.coords);
1142
- const correctCoeffs = getQuadraticCoefficients(rubric.correct.coords);
1143
- if (perseusCore.approximateDeepEqual(guessCoeffs, correctCoeffs)) {
1144
- return {
1145
- type: "points",
1146
- earned: 1,
1147
- total: 1,
1148
- message: null
1149
- };
1150
- }
1151
- } else if (userInput.type === "sinusoid" && rubric.correct.type === "sinusoid" && userInput.coords != null) {
1152
- const guessCoeffs = getSinusoidCoefficients(userInput.coords);
1153
- const correctCoeffs = getSinusoidCoefficients(rubric.correct.coords);
1154
- const canonicalGuessCoeffs = canonicalSineCoefficients(guessCoeffs);
1155
- const canonicalCorrectCoeffs = canonicalSineCoefficients(correctCoeffs);
1156
- // If the canonical coefficients match, it's correct.
1157
- if (perseusCore.approximateDeepEqual(canonicalGuessCoeffs, canonicalCorrectCoeffs)) {
1158
- return {
1159
- type: "points",
1160
- earned: 1,
1161
- total: 1,
1162
- message: null
1163
- };
1164
- }
1165
- } else if (userInput.type === "circle" && rubric.correct.type === "circle") {
1166
- if (perseusCore.approximateDeepEqual(userInput.center, rubric.correct.center) && perseusCore.approximateEqual(userInput.radius, rubric.correct.radius)) {
1167
- return {
1168
- type: "points",
1169
- earned: 1,
1170
- total: 1,
1171
- message: null
1172
- };
1173
- }
1174
- } else if (userInput.type === "point" && rubric.correct.type === "point" && userInput.coords != null) {
1175
- let correct = rubric.correct.coords;
1176
- if (correct == null) {
1177
- throw new Error("Point graph rubric has null coords");
1178
- }
1179
- const guess = userInput.coords.slice();
1180
- correct = correct.slice();
1181
- // Everything's already rounded so we shouldn't need to do an
1182
- // eq() comparison but _.isEqual(0, -0) is false, so we'll use
1183
- // eq() anyway. The sort should be fine because it'll stringify
1184
- // it and -0 converted to a string is "0"
1185
- guess?.sort();
1186
- // @ts-expect-error - TS2339 - Property 'sort' does not exist on type 'readonly Coord[]'.
1187
- correct.sort();
1188
- if (perseusCore.approximateDeepEqual(guess, correct)) {
1189
- return {
1190
- type: "points",
1191
- earned: 1,
1192
- total: 1,
1193
- message: null
1194
- };
1195
- }
1196
- } else if (userInput.type === "polygon" && rubric.correct.type === "polygon" && userInput.coords != null) {
1197
- const guess = userInput.coords.slice();
1198
- const correct = rubric.correct.coords.slice();
1199
- let match;
1200
- if (rubric.correct.match === "similar") {
1201
- match = similar(guess, correct, Number.POSITIVE_INFINITY);
1202
- } else if (rubric.correct.match === "congruent") {
1203
- match = similar(guess, correct, kmath.number.DEFAULT_TOLERANCE);
1204
- } else if (rubric.correct.match === "approx") {
1205
- match = similar(guess, correct, 0.1);
1206
- } else {
1207
- /* exact */
1208
- guess.sort();
1209
- correct.sort();
1210
- match = perseusCore.approximateDeepEqual(guess, correct);
1211
- }
1212
- if (match) {
1213
- return {
1214
- type: "points",
1215
- earned: 1,
1216
- total: 1,
1217
- message: null
1218
- };
1219
- }
1220
- } else if (userInput.type === "segment" && rubric.correct.type === "segment" && userInput.coords != null) {
1221
- let guess = perseusCore.deepClone(userInput.coords);
1222
- let correct = perseusCore.deepClone(rubric.correct.coords);
1223
- guess = ___default.default.invoke(guess, "sort").sort();
1224
- correct = ___default.default.invoke(correct, "sort").sort();
1225
- if (perseusCore.approximateDeepEqual(guess, correct)) {
1226
- return {
1227
- type: "points",
1228
- earned: 1,
1229
- total: 1,
1230
- message: null
1231
- };
1232
- }
1233
- } else if (userInput.type === "ray" && rubric.correct.type === "ray" && userInput.coords != null) {
1234
- const guess = userInput.coords;
1235
- const correct = rubric.correct.coords;
1236
- if (perseusCore.approximateDeepEqual(guess[0], correct[0]) && collinear(correct[0], correct[1], guess[1])) {
1237
- return {
1238
- type: "points",
1239
- earned: 1,
1240
- total: 1,
1241
- message: null
1242
- };
1243
- }
1244
- } else if (userInput.type === "angle" && rubric.correct.type === "angle") {
1245
- const coords = userInput.coords;
1246
- const correct = rubric.correct.coords;
1247
- const allowReflexAngles = rubric.correct.allowReflexAngles;
1248
-
1249
- // While the angle graph should always have 3 points, our types
1250
- // technically allow for null values. We'll check for that here.
1251
- // TODO: (LEMS-2857) We would like to update the type of coords
1252
- // to be non-nullable, as the graph should always have 3 points.
1253
- if (!coords) {
1254
- return {
1255
- type: "invalid",
1256
- message: null
1257
- };
1258
- }
1259
-
1260
- // We need to check both the direction of the angle and the
1261
- // whether the graph allows for reflexive angles in order to
1262
- // to determine if we need to reverse the coords for scoring.
1263
- const areClockwise = clockwise([coords[0], coords[2], coords[1]]);
1264
- const shouldReverseCoords = areClockwise && !allowReflexAngles;
1265
- const guess = shouldReverseCoords ? coords.slice().reverse() : coords;
1266
- let match;
1267
- if (rubric.correct.match === "congruent") {
1268
- const angles = ___default.default.map([guess, correct], function (coords) {
1269
- // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
1270
- if (!coords) {
1271
- return false;
1272
- }
1273
- const angle = getClockwiseAngle(coords, allowReflexAngles);
1274
- return angle;
1275
- });
1276
- // @ts-expect-error - TS2556 - A spread argument must either have a tuple type or be passed to a rest parameter.
1277
- match = perseusCore.approximateEqual(...angles);
1278
- } else {
1279
- /* exact */
1280
- match = perseusCore.approximateDeepEqual(guess[1], correct[1]) && collinear(correct[1], correct[0], guess[0]) && collinear(correct[1], correct[2], guess[2]);
1281
- }
1282
- if (match) {
1283
- return {
1284
- type: "points",
1285
- earned: 1,
1286
- total: 1,
1287
- message: null
1288
- };
1289
- }
1290
- }
1291
- }
1292
-
1293
- // The input wasn't correct, so check if it's a blank input or if it's
1294
- // actually just wrong
1295
- if (!hasValue || ___default.default.isEqual(userInput, rubric.graph)) {
1296
- // We're where we started.
1297
- return {
1298
- type: "invalid",
1299
- message: null
1300
- };
1301
- }
1302
- return {
1303
- type: "points",
1304
- earned: 0,
1305
- total: 1,
1306
- message: null
1307
- };
1308
- }
1309
-
1310
- // Question state for marker as result of user selected answers.
1311
-
1312
- function scoreLabelImageMarker(userInput, rubric) {
1313
- const score = {
1314
- hasAnswers: false,
1315
- isCorrect: false
1316
- };
1317
- if (userInput && userInput.length > 0) {
1318
- score.hasAnswers = true;
1319
- }
1320
- if (rubric.length > 0) {
1321
- if (userInput && userInput.length === rubric.length) {
1322
- // All correct answers are selected by the user.
1323
- score.isCorrect = userInput.every(choice => rubric.includes(choice));
1324
- }
1325
- } else if (!userInput || userInput.length === 0) {
1326
- // Correct as no answers should be selected by the user.
1327
- score.isCorrect = true;
1328
- }
1329
- return score;
1330
- }
1331
- function scoreLabelImage(userInput, rubric) {
1332
- let numCorrect = 0;
1333
- for (let i = 0; i < userInput.markers.length; i++) {
1334
- const score = scoreLabelImageMarker(userInput.markers[i].selected, rubric.markers[i].answers);
1335
- if (score.isCorrect) {
1336
- numCorrect++;
1337
- }
1338
- }
1339
- return {
1340
- type: "points",
1341
- // Markers with no expected answers are graded as correct if user
1342
- // makes no answer selection.
1343
- earned: numCorrect === userInput.markers.length ? 1 : 0,
1344
- total: 1,
1345
- message: null
1346
- };
1347
- }
1348
-
1349
- function scoreMatcher(userInput, rubric) {
1350
- const correct = ___default.default.isEqual(userInput.left, rubric.left) && ___default.default.isEqual(userInput.right, rubric.right);
1351
- return {
1352
- type: "points",
1353
- earned: correct ? 1 : 0,
1354
- total: 1,
1355
- message: null
1356
- };
1357
- }
1358
-
1359
- function scoreMatrix(userInput, rubric) {
1360
- const solution = rubric.answers;
1361
- const supplied = userInput.answers;
1362
- const solutionSize = perseusCore.getMatrixSize(solution);
1363
- const suppliedSize = perseusCore.getMatrixSize(supplied);
1364
- const incorrectSize = solutionSize[0] !== suppliedSize[0] || solutionSize[1] !== suppliedSize[1];
1365
- const createValidator = KhanAnswerTypes.number.createValidatorFunctional;
1366
- let message = null;
1367
- let incorrect = false;
1368
- ___default.default(suppliedSize[0]).times(row => {
1369
- ___default.default(suppliedSize[1]).times(col => {
1370
- if (!incorrectSize) {
1371
- const validator = createValidator(
1372
- // @ts-expect-error - TS2345 - Argument of type 'number' is not assignable to parameter of type 'string'.
1373
- solution[row][col], {
1374
- simplify: true
1375
- });
1376
- const result = validator(supplied[row][col]);
1377
- if (result.message) {
1378
- // @ts-expect-error - TS2322 - Type 'string' is not assignable to type 'null'.
1379
- message = result.message;
1380
- }
1381
- if (!result.correct) {
1382
- incorrect = true;
1383
- }
1384
- }
1385
- });
1386
- });
1387
- if (incorrectSize) {
1388
- return {
1389
- type: "points",
1390
- earned: 0,
1391
- total: 1,
1392
- message: null
1393
- };
1394
- }
1395
- return {
1396
- type: "points",
1397
- earned: incorrect ? 0 : 1,
1398
- total: 1,
1399
- message: message
1400
- };
1401
- }
1402
-
1403
- /**
1404
- * Checks user input from the matrix widget to see if it is scorable.
1405
- *
1406
- * Note: The matrix widget cannot do much validation without the Scoring
1407
- * Data because of its use of KhanAnswerTypes as a core part of scoring.
1408
- *
1409
- * @see `scoreMatrix()` for more details.
1410
- */
1411
- function validateMatrix(userInput) {
1412
- const supplied = userInput.answers;
1413
- const suppliedSize = perseusCore.getMatrixSize(supplied);
1414
- for (let row = 0; row < suppliedSize[0]; row++) {
1415
- for (let col = 0; col < suppliedSize[1]; col++) {
1416
- if (supplied[row][col] == null || supplied[row][col].toString().length === 0) {
1417
- return {
1418
- type: "invalid",
1419
- message: ErrorCodes.FILL_ALL_CELLS_ERROR
1420
- };
1421
- }
1422
- }
1423
- }
1424
- return null;
1425
- }
1426
-
1427
- function scoreNumberLine(userInput, rubric) {
1428
- const range = rubric.range;
1429
- const start = rubric.initialX != null ? rubric.initialX : range[0];
1430
- const startRel = rubric.isInequality ? "ge" : "eq";
1431
- const correctRel = rubric.correctRel || "eq";
1432
- const correctPos = kmath.number.equal(userInput.numLinePosition,
1433
- // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
1434
- rubric.correctX || 0);
1435
- if (correctPos && correctRel === userInput.rel) {
1436
- return {
1437
- type: "points",
1438
- earned: 1,
1439
- total: 1,
1440
- message: null
1441
- };
1442
- }
1443
- if (userInput.numLinePosition === start && userInput.rel === startRel) {
1444
- // We're where we started.
1445
- return {
1446
- type: "invalid",
1447
- message: null
1448
- };
1449
- }
1450
- return {
1451
- type: "points",
1452
- earned: 0,
1453
- total: 1,
1454
- message: null
1455
- };
1456
- }
1457
-
1458
- /**
1459
- * Checks user input is within the allowed range and not the same as the initial
1460
- * state.
1461
- * @param userInput
1462
- * @see 'scoreNumberLine' for the scoring logic.
1463
- */
1464
- function validateNumberLine(userInput) {
1465
- const divisionRange = userInput.divisionRange;
1466
- const outsideAllowedRange = userInput.numDivisions > divisionRange[1] || userInput.numDivisions < divisionRange[0];
47
+ function scoreExpression(userInput,rubric,locale){const options=___default.default.clone(rubric);___default.default.extend(options,{decimal_separator:perseusCore.getDecimalSeparator(locale)});const createValidator=answer=>{const expression=KAS__namespace.parse(answer.value,rubric);if(!expression.parsed){throw new perseusCore.PerseusError("Unable to parse solution answer for expression",perseusCore.Errors.InvalidInput,{metadata:{rubric:JSON.stringify(rubric)}})}return KhanAnswerTypes.expression.createValidatorFunctional(expression.expr,___default.default({}).extend(options,{simplify:answer.simplify,form:answer.form}))};let matchingAnswerForm;let matchMessage;let allEmpty=true;let firstUngradedResult;for(const answerForm of rubric.answerForms||[]){const validator=createValidator(answerForm);if(!validator){continue}const result=validator(userInput);if(result.correct){matchingAnswerForm=answerForm;matchMessage=result.message||"";break}allEmpty=allEmpty&&result.empty;if(answerForm.considered==="correct"&&result.ungraded&&!firstUngradedResult){firstUngradedResult=result;}}if(!matchingAnswerForm){if(firstUngradedResult){return {type:"invalid",message:firstUngradedResult.message,suppressAlmostThere:firstUngradedResult.suppressAlmostThere}}if(allEmpty){return {type:"invalid",message:null}}return {type:"points",earned:0,total:1}}if(matchingAnswerForm.considered==="ungraded"){return {type:"invalid",message:matchMessage}}return {type:"points",earned:matchingAnswerForm.considered==="correct"?1:0,total:1,message:matchMessage}}
1467
48
 
1468
- // TODO: I don't think isTickCrtl is a thing anymore
1469
- if (userInput.isTickCrtl && outsideAllowedRange) {
1470
- return {
1471
- type: "invalid",
1472
- message: "Number of divisions is outside the allowed range."
1473
- };
1474
- }
1475
- return null;
1476
- }
1477
-
1478
- /*
1479
- * In this file, an `expression` is some portion of valid TeX enclosed in
1480
- * curly brackets.
1481
- */
1482
-
1483
- /*
1484
- * Find the index at which an expression ends, i.e., has an unmatched
1485
- * closing curly bracket. This method assumes that we start with a non-open
1486
- * bracket character and end when we've seen more left than right brackets
1487
- * (rather than assuming that we start with a bracket character and wait for
1488
- * bracket equality).
1489
- */
1490
- function findEndpoint(tex, currentIndex) {
1491
- let bracketDepth = 0;
1492
- for (let i = currentIndex, len = tex.length; i < len; i++) {
1493
- const c = tex[i];
1494
- if (c === "{") {
1495
- bracketDepth++;
1496
- } else if (c === "}") {
1497
- bracketDepth--;
1498
- }
1499
- if (bracketDepth < 0) {
1500
- return i;
1501
- }
1502
- }
1503
- // If we never see unbalanced curly brackets, default to the
1504
- // entire string
1505
- return tex.length;
1506
- }
1507
-
1508
- /*
1509
- * Parses an individual set of curly brackets into TeX.
1510
- */
1511
- function parseNextExpression(tex, currentIndex, handler) {
1512
- // Find the first '{' and grab subsequent TeX
1513
- // Ex) tex: '{3}{7}', and we want the '3'
1514
- const openBracketIndex = tex.indexOf("{", currentIndex);
1515
-
1516
- // If there is no open bracket, set the endpoint to the end of the string
1517
- // and the expression to an empty string. This helps ensure we don't
1518
- // get stuck in an infinite loop when users handtype TeX.
1519
- if (openBracketIndex === -1) {
1520
- return {
1521
- endpoint: tex.length,
1522
- expression: ""
1523
- };
1524
- }
1525
- const nextExpIndex = openBracketIndex + 1;
49
+ function validateExpression(userInput){if(userInput===""){return {type:"invalid",message:null}}return null}
1526
50
 
1527
- // Truncate to only contain remaining TeX
1528
- const endpoint = findEndpoint(tex, nextExpIndex);
1529
- const expressionTeX = tex.substring(nextExpIndex, endpoint);
1530
- const parsedExp = walkTex(expressionTeX, handler);
1531
- return {
1532
- endpoint: endpoint,
1533
- expression: parsedExp
1534
- };
1535
- }
1536
- function getNextFracIndex(tex, currentIndex) {
1537
- const dfrac = "\\dfrac";
1538
- const frac = "\\frac";
1539
- const nextFrac = tex.indexOf(frac, currentIndex);
1540
- const nextDFrac = tex.indexOf(dfrac, currentIndex);
1541
- if (nextFrac > -1 && nextDFrac > -1) {
1542
- return Math.min(nextFrac, nextDFrac);
1543
- }
1544
- if (nextFrac > -1) {
1545
- return nextFrac;
1546
- }
1547
- if (nextDFrac > -1) {
1548
- return nextDFrac;
1549
- }
1550
- return -1;
1551
- }
1552
- function walkTex(tex, handler) {
1553
- if (!tex) {
1554
- return "";
1555
- }
51
+ function getCoefficientsByType(data){if(data.coords==null){return undefined}if(data.type==="exponential"||data.type==="logarithm"){const grader=perseusCore.GrapherUtil.functionForType(data.type);return grader.getCoefficients(data.coords,data.asymptote)}else if(data.type==="linear"||data.type==="quadratic"||data.type==="absolute_value"||data.type==="sinusoid"||data.type==="tangent"){const grader=perseusCore.GrapherUtil.functionForType(data.type);return grader.getCoefficients(data.coords)}else {throw new perseusCore.PerseusError("Invalid grapher type",perseusCore.Errors.InvalidInput)}}function scoreGrapher(userInput,rubric){if(userInput.type!==rubric.correct.type){return {type:"points",earned:0,total:1,message:null}}if(userInput.coords==null){return {type:"invalid",message:null}}const grader=perseusCore.GrapherUtil.functionForType(userInput.type);const guessCoeffs=getCoefficientsByType(userInput);const correctCoeffs=getCoefficientsByType(rubric.correct);if(guessCoeffs==null||correctCoeffs==null){return {type:"invalid",message:null}}if(grader.areEqual(guessCoeffs,correctCoeffs)){return {type:"points",earned:1,total:1,message:null}}return {type:"points",earned:0,total:1,message:null}}
1556
52
 
1557
- // Ex) tex: '2 \dfrac {3}{7}'
1558
- let parsedString = "";
1559
- let currentIndex = 0;
1560
- let nextFrac = getNextFracIndex(tex, currentIndex);
53
+ function scoreIframe(userInput){if(userInput.status==="correct"){return {type:"points",earned:1,total:1,message:userInput.message||null}}if(userInput.status==="incorrect"){return {type:"points",earned:0,total:1,message:userInput.message||null}}return {type:"invalid",message:"Keep going, you're not there yet!"}}
1561
54
 
1562
- // For each \dfrac, find the two expressions (wrapped in {}) and recur
1563
- while (nextFrac > -1) {
1564
- // Gather first fragment, preceding \dfrac
1565
- // Ex) parsedString: '2 '
1566
- parsedString += tex.substring(currentIndex, nextFrac);
55
+ const{collinear,canonicalSineCoefficients,similar,clockwise}=kmath.geometry;const{getClockwiseAngle}=kmath.angles;const{getSinusoidCoefficients,getQuadraticCoefficients}=kmath.coefficients;function scoreInteractiveGraph(userInput,rubric){if(userInput.type==="none"&&rubric.correct.type==="none"){return {type:"points",earned:0,total:0,message:null}}const hasValue=Boolean(userInput.coords||userInput.center&&userInput.radius);if(userInput.type===rubric.correct.type&&hasValue){if(userInput.type==="linear"&&rubric.correct.type==="linear"&&userInput.coords!=null){const guess=userInput.coords;const correct=rubric.correct.coords;if(collinear(correct[0],correct[1],guess[0])&&collinear(correct[0],correct[1],guess[1])){return {type:"points",earned:1,total:1,message:null}}}else if(userInput.type==="linear-system"&&rubric.correct.type==="linear-system"&&userInput.coords!=null){const guess=userInput.coords;const correct=rubric.correct.coords;if(collinear(correct[0][0],correct[0][1],guess[0][0])&&collinear(correct[0][0],correct[0][1],guess[0][1])&&collinear(correct[1][0],correct[1][1],guess[1][0])&&collinear(correct[1][0],correct[1][1],guess[1][1])||collinear(correct[0][0],correct[0][1],guess[1][0])&&collinear(correct[0][0],correct[0][1],guess[1][1])&&collinear(correct[1][0],correct[1][1],guess[0][0])&&collinear(correct[1][0],correct[1][1],guess[0][1])){return {type:"points",earned:1,total:1,message:null}}}else if(userInput.type==="quadratic"&&rubric.correct.type==="quadratic"&&userInput.coords!=null){const guessCoeffs=getQuadraticCoefficients(userInput.coords);const correctCoeffs=getQuadraticCoefficients(rubric.correct.coords);if(perseusCore.approximateDeepEqual(guessCoeffs,correctCoeffs)){return {type:"points",earned:1,total:1,message:null}}}else if(userInput.type==="sinusoid"&&rubric.correct.type==="sinusoid"&&userInput.coords!=null){const guessCoeffs=getSinusoidCoefficients(userInput.coords);const correctCoeffs=getSinusoidCoefficients(rubric.correct.coords);const canonicalGuessCoeffs=canonicalSineCoefficients(guessCoeffs);const canonicalCorrectCoeffs=canonicalSineCoefficients(correctCoeffs);if(perseusCore.approximateDeepEqual(canonicalGuessCoeffs,canonicalCorrectCoeffs)){return {type:"points",earned:1,total:1,message:null}}}else if(userInput.type==="circle"&&rubric.correct.type==="circle"){if(perseusCore.approximateDeepEqual(userInput.center,rubric.correct.center)&&perseusCore.approximateEqual(userInput.radius,rubric.correct.radius)){return {type:"points",earned:1,total:1,message:null}}}else if(userInput.type==="point"&&rubric.correct.type==="point"&&userInput.coords!=null){let correct=rubric.correct.coords;if(correct==null){throw new Error("Point graph rubric has null coords")}const guess=userInput.coords.slice();correct=correct.slice();guess?.sort();correct.sort();if(perseusCore.approximateDeepEqual(guess,correct)){return {type:"points",earned:1,total:1,message:null}}}else if(userInput.type==="polygon"&&rubric.correct.type==="polygon"&&userInput.coords!=null){const guess=userInput.coords.slice();const correct=rubric.correct.coords.slice();let match;if(rubric.correct.match==="similar"){match=similar(guess,correct,Number.POSITIVE_INFINITY);}else if(rubric.correct.match==="congruent"){match=similar(guess,correct,kmath.number.DEFAULT_TOLERANCE);}else if(rubric.correct.match==="approx"){match=similar(guess,correct,.1);}else {guess.sort();correct.sort();match=perseusCore.approximateDeepEqual(guess,correct);}if(match){return {type:"points",earned:1,total:1,message:null}}}else if(userInput.type==="segment"&&rubric.correct.type==="segment"&&userInput.coords!=null){let guess=perseusCore.deepClone(userInput.coords);let correct=perseusCore.deepClone(rubric.correct.coords);guess=___default.default.invoke(guess,"sort").sort();correct=___default.default.invoke(correct,"sort").sort();if(perseusCore.approximateDeepEqual(guess,correct)){return {type:"points",earned:1,total:1,message:null}}}else if(userInput.type==="ray"&&rubric.correct.type==="ray"&&userInput.coords!=null){const guess=userInput.coords;const correct=rubric.correct.coords;if(perseusCore.approximateDeepEqual(guess[0],correct[0])&&collinear(correct[0],correct[1],guess[1])){return {type:"points",earned:1,total:1,message:null}}}else if(userInput.type==="angle"&&rubric.correct.type==="angle"){const coords=userInput.coords;const correct=rubric.correct.coords;const allowReflexAngles=rubric.correct.allowReflexAngles;if(!coords){return {type:"invalid",message:null}}const areClockwise=clockwise([coords[0],coords[2],coords[1]]);const shouldReverseCoords=areClockwise&&!allowReflexAngles;const guess=shouldReverseCoords?coords.slice().reverse():coords;let match;if(rubric.correct.match==="congruent"){const angles=___default.default.map([guess,correct],function(coords){if(!coords){return false}const angle=getClockwiseAngle(coords,allowReflexAngles);return angle});match=perseusCore.approximateEqual(...angles);}else {match=perseusCore.approximateDeepEqual(guess[1],correct[1])&&collinear(correct[1],correct[0],guess[0])&&collinear(correct[1],correct[2],guess[2]);}if(match){return {type:"points",earned:1,total:1,message:null}}}}if(!hasValue||___default.default.isEqual(userInput,rubric.graph)){return {type:"invalid",message:null}}return {type:"points",earned:0,total:1,message:null}}
1567
56
 
1568
- // Remove everything preceding \dfrac, which has been parsed
1569
- currentIndex = nextFrac;
57
+ function scoreLabelImageMarker(userInput,rubric){const score={hasAnswers:false,isCorrect:false};if(userInput&&userInput.length>0){score.hasAnswers=true;}if(rubric.length>0){if(userInput&&userInput.length===rubric.length){score.isCorrect=userInput.every(choice=>rubric.includes(choice));}}else if(!userInput||userInput.length===0){score.isCorrect=true;}return score}function scoreLabelImage(userInput,rubric){let numCorrect=0;for(let i=0;i<userInput.markers.length;i++){const score=scoreLabelImageMarker(userInput.markers[i].selected,rubric.markers[i].answers);if(score.isCorrect){numCorrect++;}}return {type:"points",earned:numCorrect===userInput.markers.length?1:0,total:1,message:null}}
1570
58
 
1571
- // Parse first expression and move index past it
1572
- // Ex) firstParsedExpression.expression: '3'
1573
- const firstParsedExpression = parseNextExpression(tex, currentIndex, handler);
1574
- currentIndex = firstParsedExpression.endpoint + 1;
59
+ function scoreMatcher(userInput,rubric){const correct=___default.default.isEqual(userInput.left,rubric.left)&&___default.default.isEqual(userInput.right,rubric.right);return {type:"points",earned:correct?1:0,total:1,message:null}}
1575
60
 
1576
- // Parse second expression
1577
- // Ex) secondParsedExpression.expression: '7'
1578
- const secondParsedExpression = parseNextExpression(tex, currentIndex, handler);
1579
- currentIndex = secondParsedExpression.endpoint + 1;
61
+ function scoreMatrix(userInput,rubric){const solution=rubric.answers;const supplied=userInput.answers;const solutionSize=perseusCore.getMatrixSize(solution);const suppliedSize=perseusCore.getMatrixSize(supplied);const incorrectSize=solutionSize[0]!==suppliedSize[0]||solutionSize[1]!==suppliedSize[1];const createValidator=KhanAnswerTypes.number.createValidatorFunctional;let message=null;let incorrect=false;___default.default(suppliedSize[0]).times(row=>{___default.default(suppliedSize[1]).times(col=>{if(!incorrectSize){const validator=createValidator(solution[row][col],{simplify:true});const result=validator(supplied[row][col]);if(result.message){message=result.message;}if(!result.correct){incorrect=true;}}});});if(incorrectSize){return {type:"points",earned:0,total:1,message:null}}return {type:"points",earned:incorrect?0:1,total:1,message:message}}
1580
62
 
1581
- // Add expressions to running total of parsed expressions
1582
- // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
1583
- if (parsedString.length) {
1584
- parsedString += " ";
1585
- }
63
+ function validateMatrix(userInput){const supplied=userInput.answers;const suppliedSize=perseusCore.getMatrixSize(supplied);for(let row=0;row<suppliedSize[0];row++){for(let col=0;col<suppliedSize[1];col++){if(supplied[row][col]==null||supplied[row][col].toString().length===0){return {type:"invalid",message:ErrorCodes.FILL_ALL_CELLS_ERROR}}}}return null}
1586
64
 
1587
- // Apply a custom handler based on the parsed subexpressions
1588
- parsedString += handler(firstParsedExpression.expression, secondParsedExpression.expression);
65
+ function scoreNumberLine(userInput,rubric){const range=rubric.range;const start=rubric.initialX!=null?rubric.initialX:range[0];const startRel=rubric.isInequality?"ge":"eq";const correctRel=rubric.correctRel||"eq";const correctPos=kmath.number.equal(userInput.numLinePosition,rubric.correctX||0);if(correctPos&&correctRel===userInput.rel){return {type:"points",earned:1,total:1,message:null}}if(userInput.numLinePosition===start&&userInput.rel===startRel){return {type:"invalid",message:null}}return {type:"points",earned:0,total:1,message:null}}
1589
66
 
1590
- // Find next DFrac, relative to currentIndex
1591
- nextFrac = getNextFracIndex(tex, currentIndex);
1592
- }
67
+ function validateNumberLine(userInput){const divisionRange=userInput.divisionRange;const outsideAllowedRange=userInput.numDivisions>divisionRange[1]||userInput.numDivisions<divisionRange[0];if(userInput.isTickCrtl&&outsideAllowedRange){return {type:"invalid",message:"Number of divisions is outside the allowed range."}}return null}
1593
68
 
1594
- // Add remaining TeX, which is \dfrac-free
1595
- parsedString += tex.slice(currentIndex);
1596
- return parsedString;
1597
- }
69
+ function findEndpoint(tex,currentIndex){let bracketDepth=0;for(let i=currentIndex,len=tex.length;i<len;i++){const c=tex[i];if(c==="{"){bracketDepth++;}else if(c==="}"){bracketDepth--;}if(bracketDepth<0){return i}}return tex.length}function parseNextExpression(tex,currentIndex,handler){const openBracketIndex=tex.indexOf("{",currentIndex);if(openBracketIndex===-1){return {endpoint:tex.length,expression:""}}const nextExpIndex=openBracketIndex+1;const endpoint=findEndpoint(tex,nextExpIndex);const expressionTeX=tex.substring(nextExpIndex,endpoint);const parsedExp=walkTex(expressionTeX,handler);return {endpoint:endpoint,expression:parsedExp}}function getNextFracIndex(tex,currentIndex){const dfrac="\\dfrac";const frac="\\frac";const nextFrac=tex.indexOf(frac,currentIndex);const nextDFrac=tex.indexOf(dfrac,currentIndex);if(nextFrac>-1&&nextDFrac>-1){return Math.min(nextFrac,nextDFrac)}if(nextFrac>-1){return nextFrac}if(nextDFrac>-1){return nextDFrac}return -1}function walkTex(tex,handler){if(!tex){return ""}let parsedString="";let currentIndex=0;let nextFrac=getNextFracIndex(tex,currentIndex);while(nextFrac>-1){parsedString+=tex.substring(currentIndex,nextFrac);currentIndex=nextFrac;const firstParsedExpression=parseNextExpression(tex,currentIndex,handler);currentIndex=firstParsedExpression.endpoint+1;const secondParsedExpression=parseNextExpression(tex,currentIndex,handler);currentIndex=secondParsedExpression.endpoint+1;if(parsedString.length){parsedString+=" ";}parsedString+=handler(firstParsedExpression.expression,secondParsedExpression.expression);nextFrac=getNextFracIndex(tex,currentIndex);}parsedString+=tex.slice(currentIndex);return parsedString}function parseTex(tex){const handler=function(exp1,exp2){return exp1+"/"+exp2};const texWithoutFracs=walkTex(tex,handler);return texWithoutFracs.replace("\\%","%")}
1598
70
 
1599
- /*
1600
- * Parse a TeX expression into something interpretable by input-number.
1601
- * The process is concerned with: (1) parsing fractions, i.e., \dfracs; and
1602
- * (2) removing backslash-escaping from certain characters (right now, only
1603
- * percent signs).
1604
- *
1605
- * The basic algorithm for handling \dfracs splits on \dfracs and then recurs
1606
- * on the subsequent "expressions", i.e., the {} pairs that follow \dfrac. The
1607
- * recursion is to allow for nested \dfrac elements.
1608
- *
1609
- * Backslash-escapes are removed with a simple search-and-replace.
1610
- */
1611
- function parseTex(tex) {
1612
- const handler = function (exp1, exp2) {
1613
- return exp1 + "/" + exp2;
1614
- };
1615
- const texWithoutFracs = walkTex(tex, handler);
1616
- return texWithoutFracs.replace("\\%", "%");
1617
- }
71
+ const answerFormButtons=[{title:"Integers",value:"integer",content:"6"},{title:"Decimals",value:"decimal",content:"0.75"},{title:"Proper fractions",value:"proper",content:"⅗"},{title:"Improper fractions",value:"improper",content:"⁷⁄₄"},{title:"Mixed numbers",value:"mixed",content:"1¾"},{title:"Numbers with π",value:"pi",content:"π"}];function maybeParsePercentInput(inputValue,normalizedAnswerExpected){if(!(typeof inputValue==="string"&&inputValue.endsWith("%"))){return inputValue}const value=parseFloat(inputValue.slice(0,-1));if(isNaN(value)){return inputValue}if(normalizedAnswerExpected){return value/100}return value}function scoreNumericInput(userInput,rubric){const defaultAnswerForms=answerFormButtons.map(e=>e["value"]).filter(e=>e!=="pi");const createValidator=answer=>{const stringAnswer=`${answer.value}`;const validatorForms=[...answer.answerForms??[]];if(!answer.strict||validatorForms.length===0){validatorForms.push(...defaultAnswerForms);}return KhanAnswerTypes.number.createValidatorFunctional(stringAnswer,{message:answer.message,simplify:answer.status==="correct"?answer.simplify:"optional",inexact:true,maxError:answer.maxError,forms:validatorForms})};const currentValue=parseTex(userInput.currentValue);const normalizedAnswerExpected=rubric.answers.filter(answer=>answer.status==="correct").every(answer=>answer.value!=null&&Math.abs(answer.value)<=1);let localValue=currentValue;if(rubric.coefficient){if(!localValue){localValue=1;}else if(localValue==="-"){localValue=-1;}}const matchedAnswer=rubric.answers.map(answer=>{const validateFn=createValidator(answer);const score=validateFn(maybeParsePercentInput(localValue,normalizedAnswerExpected));return {...answer,score}}).find(answer=>{return answer.score.correct||answer.status==="correct"&&answer.score.empty});const result=matchedAnswer?.status==="correct"?matchedAnswer.score:{empty:matchedAnswer?.status==="ungraded",correct:matchedAnswer?.status==="correct",message:matchedAnswer?.message??null};if(result.empty){return {type:"invalid",message:result.message}}return {type:"points",earned:result.correct?1:0,total:1,message:result.message}}
1618
72
 
1619
- const answerFormButtons = [{
1620
- title: "Integers",
1621
- value: "integer",
1622
- content: "6"
1623
- }, {
1624
- title: "Decimals",
1625
- value: "decimal",
1626
- content: "0.75"
1627
- }, {
1628
- title: "Proper fractions",
1629
- value: "proper",
1630
- content: "\u2157"
1631
- }, {
1632
- title: "Improper fractions",
1633
- value: "improper",
1634
- content: "\u2077\u2044\u2084"
1635
- }, {
1636
- title: "Mixed numbers",
1637
- value: "mixed",
1638
- content: "1\u00BE"
1639
- }, {
1640
- title: "Numbers with \u03C0",
1641
- value: "pi",
1642
- content: "\u03C0"
1643
- }];
73
+ function scoreOrderer(userInput,rubric){const correct=___default.default.isEqual(userInput.current,rubric.correctOptions.map(option=>option.content));return {type:"points",earned:correct?1:0,total:1,message:null}}
1644
74
 
1645
- // This function checks if the user inputted a percent value, parsing
1646
- // it as a number (and maybe scaling) so that it can be graded.
1647
- // NOTE(michaelpolyak): Unlike `KhanAnswerTypes.number.percent()` which
1648
- // can accept several input forms with or without "%", the decision
1649
- // to parse based on the presence of "%" in the input, is so that we
1650
- // don't accidently scale the user typed value before grading, CP-930.
1651
- function maybeParsePercentInput(inputValue, normalizedAnswerExpected) {
1652
- // If the input value is not a string ending with "%", then there's
1653
- // nothing more to do. The value will be graded as inputted by user.
1654
- if (!(typeof inputValue === "string" && inputValue.endsWith("%"))) {
1655
- return inputValue;
1656
- }
1657
- const value = parseFloat(inputValue.slice(0, -1));
1658
- // If the input value stripped of the "%" cannot be parsed as a
1659
- // number (the slice is not really necessary for parseFloat to work
1660
- // if the string starts with a number) then return the original
1661
- // input for grading.
1662
- if (isNaN(value)) {
1663
- return inputValue;
1664
- }
75
+ function validateOrderer(userInput){if(userInput.current.length===0){return {type:"invalid",message:null}}return null}
1665
76
 
1666
- // Next, if all correct answers are in the range of |0,1| then we
1667
- // scale the user typed value. We assume this is the correct thing
1668
- // to do since the input value ends with "%".
1669
- if (normalizedAnswerExpected) {
1670
- return value / 100;
1671
- }
77
+ function scorePlotter(userInput,rubric){return {type:"points",earned:perseusCore.approximateDeepEqual(userInput,rubric.correct)?1:0,total:1,message:null}}
1672
78
 
1673
- // Otherwise, we return input value (number) stripped of the "%".
1674
- return value;
1675
- }
1676
- function scoreNumericInput(userInput, rubric) {
1677
- const defaultAnswerForms = answerFormButtons.map(e => e["value"])
1678
- // Don't default to validating the answer as a pi answer
1679
- // if answerForm isn't set on the answer
1680
- // https://khanacademy.atlassian.net/browse/LC-691
1681
- .filter(e => e !== "pi");
1682
- const createValidator = answer => {
1683
- const stringAnswer = `${answer.value}`;
79
+ function validatePlotter(userInput,validationData){if(perseusCore.approximateDeepEqual(userInput,validationData.starting)){return {type:"invalid",message:null}}return null}
1684
80
 
1685
- // Always validate against the provided answer forms (pi, decimal, etc.)
1686
- const validatorForms = [...(answer.answerForms ?? [])];
81
+ function scoreRadio(userInput,rubric){const numSelected=userInput.choicesSelected.reduce((sum,selected)=>{return sum+(selected?1:0)},0);const numCorrect=rubric.choices.reduce((sum,currentChoice)=>{return currentChoice.correct?sum+1:sum},0);if(numCorrect>1&&numSelected!==numCorrect){return {type:"invalid",message:ErrorCodes.CHOOSE_CORRECT_NUM_ERROR}}const noneOfTheAboveSelected=rubric.choices.some((choice,index)=>choice.isNoneOfTheAbove&&userInput.choicesSelected[index]);if(noneOfTheAboveSelected&&numSelected>1){return {type:"invalid",message:ErrorCodes.NOT_NONE_ABOVE_ERROR}}const correct=userInput.choicesSelected.every((selected,i)=>{let isCorrect;if(rubric.choices[i].isNoneOfTheAbove){isCorrect=rubric.choices.every((choice,j)=>{return i===j||!choice.correct});}else {isCorrect=!!rubric.choices[i].correct;}return isCorrect===selected});return {type:"points",earned:correct?1:0,total:1,message:null}}
1687
82
 
1688
- // When an answer is set to strict, we validate using ONLY
1689
- // the provided answerForms. If strict is false, or if there
1690
- // were no provided answer forms, we will include all
1691
- // of the default answer forms in our validator.
1692
- if (!answer.strict || validatorForms.length === 0) {
1693
- validatorForms.push(...defaultAnswerForms);
1694
- }
1695
- return KhanAnswerTypes.number.createValidatorFunctional(stringAnswer, {
1696
- message: answer.message,
1697
- simplify: answer.status === "correct" ? answer.simplify : "optional",
1698
- inexact: true,
1699
- // TODO(merlob) backfill / delete
1700
- maxError: answer.maxError,
1701
- forms: validatorForms
1702
- });
1703
- };
83
+ function validateRadio(userInput){if(!userInput.choicesSelected.includes(true)){return {type:"invalid",message:null}}return null}
1704
84
 
1705
- // We may have received TeX; try to parse it before grading.
1706
- // If `currentValue` is not TeX, this should be a no-op.
1707
- const currentValue = parseTex(userInput.currentValue);
1708
- const normalizedAnswerExpected = rubric.answers.filter(answer => answer.status === "correct").every(answer => answer.value != null && Math.abs(answer.value) <= 1);
85
+ function scoreSorter(userInput,rubric){const correct=perseusCore.approximateDeepEqual(userInput.options,rubric.correct);return {type:"points",earned:correct?1:0,total:1,message:null}}
1709
86
 
1710
- // The coefficient is an attribute of the widget
1711
- let localValue = currentValue;
1712
- if (rubric.coefficient) {
1713
- if (!localValue) {
1714
- localValue = 1;
1715
- } else if (localValue === "-") {
1716
- localValue = -1;
1717
- }
1718
- }
1719
- const matchedAnswer = rubric.answers.map(answer => {
1720
- const validateFn = createValidator(answer);
1721
- const score = validateFn(maybeParsePercentInput(localValue, normalizedAnswerExpected));
1722
- return {
1723
- ...answer,
1724
- score
1725
- };
1726
- }).find(answer => {
1727
- // NOTE: "answer.score.correct" indicates a match via the validate function.
1728
- // It does NOT indicate that the answer itself is correct.
1729
- return answer.score.correct || answer.status === "correct" && answer.score.empty;
1730
- });
1731
- const result = matchedAnswer?.status === "correct" ? matchedAnswer.score : {
1732
- empty: matchedAnswer?.status === "ungraded",
1733
- correct: matchedAnswer?.status === "correct",
1734
- message: matchedAnswer?.message ?? null};
1735
- if (result.empty) {
1736
- return {
1737
- type: "invalid",
1738
- message: result.message
1739
- };
1740
- }
1741
- return {
1742
- type: "points",
1743
- earned: result.correct ? 1 : 0,
1744
- total: 1,
1745
- message: result.message
1746
- };
1747
- }
87
+ function validateSorter(userInput){if(!userInput.changed){return {type:"invalid",message:null}}return null}
1748
88
 
1749
- function scoreOrderer(userInput, rubric) {
1750
- const correct = ___default.default.isEqual(userInput.current, rubric.correctOptions.map(option => option.content));
1751
- return {
1752
- type: "points",
1753
- earned: correct ? 1 : 0,
1754
- total: 1,
1755
- message: null
1756
- };
1757
- }
89
+ const filterNonEmpty=function(table){return table.filter(function(row){return row.some(cell=>cell)})};
1758
90
 
1759
- /**
1760
- * Checks user input from the orderer widget to see if the user has started
1761
- * ordering the options, making the widget scorable.
1762
- * @param userInput
1763
- * @see `scoreOrderer` for more details.
1764
- */
1765
- function validateOrderer(userInput) {
1766
- if (userInput.current.length === 0) {
1767
- return {
1768
- type: "invalid",
1769
- message: null
1770
- };
1771
- }
1772
- return null;
1773
- }
91
+ function validateTable(userInput){const supplied=filterNonEmpty(userInput);const hasEmptyCell=supplied.some(function(row){return row.some(function(cell){return cell===""})});if(hasEmptyCell||!supplied.length){return {type:"invalid",message:null}}return null}
1774
92
 
1775
- function scorePlotter(userInput, rubric) {
1776
- return {
1777
- type: "points",
1778
- earned: perseusCore.approximateDeepEqual(userInput, rubric.correct) ? 1 : 0,
1779
- total: 1,
1780
- message: null
1781
- };
1782
- }
93
+ function scoreTable(userInput,rubric){const validationResult=validateTable(userInput);if(validationResult!=null){return validationResult}const supplied=filterNonEmpty(userInput);const solution=filterNonEmpty(rubric.answers);if(supplied.length!==solution.length){return {type:"points",earned:0,total:1,message:null}}const createValidator=KhanAnswerTypes.number.createValidatorFunctional;let message=null;const allCorrect=solution.every(function(rowSolution){for(let i=0;i<supplied.length;i++){const rowSupplied=supplied[i];const correct=rowSupplied.every(function(cellSupplied,i){const cellSolution=rowSolution[i];const validator=createValidator(cellSolution,{simplify:true});const result=validator(cellSupplied);if(result.message){message=result.message;}return result.correct});if(correct){supplied.splice(i,1);return true}}return false});return {type:"points",earned:allCorrect?1:0,total:1,message}}
1783
94
 
1784
- /**
1785
- * Checks user input to confirm it is not the same as the starting values for the graph.
1786
- * This means the user has modified the graph, and the question can be scored.
1787
- *
1788
- * @see 'scorePlotter' for more details on scoring.
1789
- */
1790
- function validatePlotter(userInput, validationData) {
1791
- if (perseusCore.approximateDeepEqual(userInput, validationData.starting)) {
1792
- return {
1793
- type: "invalid",
1794
- message: null
1795
- };
1796
- }
1797
- return null;
1798
- }
95
+ const inputNumberAnswerTypes={number:{name:"Numbers",forms:"integer, decimal, proper, improper, mixed"},decimal:{name:"Decimals",forms:"decimal"},integer:{name:"Integers",forms:"integer"},rational:{name:"Fractions and mixed numbers",forms:"integer, proper, improper, mixed"},improper:{name:"Improper numbers (no mixed)",forms:"integer, proper, improper"},mixed:{name:"Mixed numbers (no improper)",forms:"integer, proper, mixed"},percent:{name:"Numbers or percents",forms:"integer, decimal, proper, improper, mixed, percent"},pi:{name:"Numbers with pi",forms:"pi"}};function scoreInputNumber(userInput,rubric){if(rubric.answerType==null){rubric.answerType="number";}const stringValue=`${rubric.value}`;const val=KhanAnswerTypes.number.createValidatorFunctional(stringValue,{simplify:rubric.simplify,inexact:rubric.inexact||undefined,maxError:rubric.maxError,forms:inputNumberAnswerTypes[rubric.answerType].forms});const currentValue=parseTex(userInput.currentValue);const result=val(currentValue);if(result.empty){return {type:"invalid",message:result.message}}return {type:"points",earned:result.correct?1:0,total:1,message:result.message}}
1799
96
 
1800
- function scoreRadio(userInput, rubric) {
1801
- const numSelected = userInput.choicesSelected.reduce((sum, selected) => {
1802
- return sum + (selected ? 1 : 0);
1803
- }, 0);
1804
- const numCorrect = rubric.choices.reduce((sum, currentChoice) => {
1805
- return currentChoice.correct ? sum + 1 : sum;
1806
- }, 0);
1807
- if (numCorrect > 1 && numSelected !== numCorrect) {
1808
- return {
1809
- type: "invalid",
1810
- message: ErrorCodes.CHOOSE_CORRECT_NUM_ERROR
1811
- };
1812
- // If NOTA and some other answer are checked, ...
1813
- }
1814
- const noneOfTheAboveSelected = rubric.choices.some((choice, index) => choice.isNoneOfTheAbove && userInput.choicesSelected[index]);
1815
- if (noneOfTheAboveSelected && numSelected > 1) {
1816
- return {
1817
- type: "invalid",
1818
- message: ErrorCodes.NOT_NONE_ABOVE_ERROR
1819
- };
1820
- }
1821
- const correct = userInput.choicesSelected.every((selected, i) => {
1822
- let isCorrect;
1823
- if (rubric.choices[i].isNoneOfTheAbove) {
1824
- isCorrect = rubric.choices.every((choice, j) => {
1825
- return i === j || !choice.correct;
1826
- });
1827
- } else {
1828
- isCorrect = !!rubric.choices[i].correct;
1829
- }
1830
- return isCorrect === selected;
1831
- });
1832
- return {
1833
- type: "points",
1834
- earned: correct ? 1 : 0,
1835
- total: 1,
1836
- message: null
1837
- };
1838
- }
97
+ function scoreNoop(points=0){return {type:"points",earned:points,total:points,message:null}}
1839
98
 
1840
- /**
1841
- * Checks if the user has selected at least one option. Additional validation
1842
- * is done in scoreRadio to check if the number of selected options is correct
1843
- * and if the user has selected both a correct option and the "none of the above"
1844
- * option.
1845
- * @param userInput
1846
- * @see `scoreRadio` for the additional validation logic and the scoring logic.
1847
- */
1848
- function validateRadio(userInput) {
1849
- const numSelected = userInput.choicesSelected.reduce((sum, selected) => {
1850
- return sum + (selected ? 1 : 0);
1851
- }, 0);
1852
- if (numSelected === 0) {
1853
- return {
1854
- type: "invalid",
1855
- message: null
1856
- };
1857
- }
1858
- return null;
1859
- }
99
+ function scoreGroup(userInput,rubric,locale){const scores=scoreWidgetsFunctional(rubric.widgets,Object.keys(rubric.widgets),userInput,locale);return flattenScores(scores)}
1860
100
 
1861
- function scoreSorter(userInput, rubric) {
1862
- const correct = perseusCore.approximateDeepEqual(userInput.options, rubric.correct);
1863
- return {
1864
- type: "points",
1865
- earned: correct ? 1 : 0,
1866
- total: 1,
1867
- message: null
1868
- };
1869
- }
101
+ function emptyWidgetsFunctional(widgets,widgetIds,userInputMap,locale){return widgetIds.filter(id=>{const widget=widgets[id];if(!widget||widget.static===true){return false}const validator=getWidgetValidator(widget.type);const userInput=userInputMap[id];const validationData=widget.options;const score=validator?.(userInput,validationData,locale);if(score){return scoreIsEmpty(score)}})}
1870
102
 
1871
- /**
1872
- * Checks user input for the sorter widget to ensure that the user has made
1873
- * changes before attempting to score the widget.
1874
- * @param userInput
1875
- * @see 'scoreSorter' in 'packages/perseus/src/widgets/sorter/score-sorter.ts'
1876
- * for more details on how the sorter widget is scored.
1877
- */
1878
- function validateSorter(userInput) {
1879
- // If the sorter widget hasn't been changed yet, we treat it as "empty" which
1880
- // prevents the "Check" button from becoming active. We want the user
1881
- // to make a change before trying to move forward. This makes an
1882
- // assumption that the initial order isn't the correct order! However,
1883
- // this should be rare if it happens, and interacting with the list
1884
- // will enable the button, so they won't be locked out of progressing.
1885
- if (!userInput.changed) {
1886
- return {
1887
- type: "invalid",
1888
- message: null
1889
- };
1890
- }
1891
- return null;
1892
- }
103
+ function validateGroup(userInput,validationData,locale){const emptyWidgets=emptyWidgetsFunctional(validationData.widgets,Object.keys(validationData.widgets),userInput,locale);if(emptyWidgets.length===0){return null}return {type:"invalid",message:null}}
1893
104
 
1894
- /**
1895
- * Filters the given table (modelled as a 2D array) to remove any rows that are
1896
- * completely empty.
1897
- *
1898
- * @returns A new table with only non-empty rows.
1899
- */
1900
- const filterNonEmpty = function (table) {
1901
- return table.filter(function (row) {
1902
- // Return only rows that are non-empty.
1903
- return row.some(cell => cell);
1904
- });
1905
- };
105
+ function validateLabelImage(userInput){let numAnswered=0;for(let i=0;i<userInput.markers.length;i++){const userSelection=userInput.markers[i].selected;if(userSelection&&userSelection.length>0){numAnswered++;}}if(numAnswered!==userInput.markers.length){return {type:"invalid",message:null}}return null}
1906
106
 
1907
- function validateTable(userInput) {
1908
- const supplied = filterNonEmpty(userInput);
1909
- const hasEmptyCell = supplied.some(function (row) {
1910
- return row.some(function (cell) {
1911
- return cell === "";
1912
- });
1913
- });
107
+ function validateMockWidget(userInput){if(userInput.currentValue==null||userInput.currentValue===""){return {type:"invalid",message:""}}return null}
1914
108
 
1915
- // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
1916
- if (hasEmptyCell || !supplied.length) {
1917
- return {
1918
- type: "invalid",
1919
- message: null
1920
- };
1921
- }
1922
- return null;
1923
- }
109
+ function scoreMockWidget(userInput,rubric){const validationResult=validateMockWidget(userInput);if(validationResult!=null){return validationResult}return {type:"points",earned:userInput.currentValue===rubric.value?1:0,total:1,message:""}}
1924
110
 
1925
- function scoreTable(userInput, rubric) {
1926
- const validationResult = validateTable(userInput);
1927
- if (validationResult != null) {
1928
- return validationResult;
1929
- }
1930
- const supplied = filterNonEmpty(userInput);
1931
- const solution = filterNonEmpty(rubric.answers);
1932
- if (supplied.length !== solution.length) {
1933
- return {
1934
- type: "points",
1935
- earned: 0,
1936
- total: 1,
1937
- message: null
1938
- };
1939
- }
1940
- const createValidator = KhanAnswerTypes.number.createValidatorFunctional;
1941
- let message = null;
1942
- const allCorrect = solution.every(function (rowSolution) {
1943
- for (let i = 0; i < supplied.length; i++) {
1944
- const rowSupplied = supplied[i];
1945
- const correct = rowSupplied.every(function (cellSupplied, i) {
1946
- const cellSolution = rowSolution[i];
1947
- const validator = createValidator(cellSolution, {
1948
- simplify: true
1949
- });
1950
- const result = validator(cellSupplied);
1951
- if (result.message) {
1952
- message = result.message;
1953
- }
1954
- return result.correct;
1955
- });
1956
- if (correct) {
1957
- supplied.splice(i, 1);
1958
- return true;
1959
- }
1960
- }
1961
- return false;
1962
- });
1963
- return {
1964
- type: "points",
1965
- earned: allCorrect ? 1 : 0,
1966
- total: 1,
1967
- message
1968
- };
1969
- }
111
+ const widgets={};function registerWidget(type,scorer,validator){widgets[type]={scorer,validator};}const getWidgetValidator=name=>{return widgets[name]?.validator??null};const getWidgetScorer=name=>{return widgets[name]?.scorer??null};registerWidget("categorizer",scoreCategorizer,validateCategorizer);registerWidget("cs-program",scoreCSProgram);registerWidget("dropdown",scoreDropdown,validateDropdown);registerWidget("expression",scoreExpression,validateExpression);registerWidget("grapher",scoreGrapher);registerWidget("group",scoreGroup,validateGroup);registerWidget("iframe",scoreIframe);registerWidget("input-number",scoreInputNumber);registerWidget("interactive-graph",scoreInteractiveGraph);registerWidget("label-image",scoreLabelImage,validateLabelImage);registerWidget("matcher",scoreMatcher);registerWidget("matrix",scoreMatrix,validateMatrix);registerWidget("mock-widget",scoreMockWidget,scoreMockWidget);registerWidget("number-line",scoreNumberLine,validateNumberLine);registerWidget("numeric-input",scoreNumericInput);registerWidget("orderer",scoreOrderer,validateOrderer);registerWidget("plotter",scorePlotter,validatePlotter);registerWidget("radio",scoreRadio,validateRadio);registerWidget("sorter",scoreSorter,validateSorter);registerWidget("table",scoreTable,validateTable);registerWidget("deprecated-standin",()=>scoreNoop(1));registerWidget("measurer",()=>scoreNoop(1));registerWidget("definition",scoreNoop);registerWidget("explanation",scoreNoop);registerWidget("image",scoreNoop);registerWidget("interaction",scoreNoop);registerWidget("molecule",scoreNoop);registerWidget("passage",scoreNoop);registerWidget("passage-ref",scoreNoop);registerWidget("passage-ref-target",scoreNoop);registerWidget("video",scoreNoop);
1970
112
 
1971
- const inputNumberAnswerTypes = {
1972
- number: {
1973
- name: "Numbers",
1974
- forms: "integer, decimal, proper, improper, mixed"
1975
- },
1976
- decimal: {
1977
- name: "Decimals",
1978
- forms: "decimal"
1979
- },
1980
- integer: {
1981
- name: "Integers",
1982
- forms: "integer"
1983
- },
1984
- rational: {
1985
- name: "Fractions and mixed numbers",
1986
- forms: "integer, proper, improper, mixed"
1987
- },
1988
- improper: {
1989
- name: "Improper numbers (no mixed)",
1990
- forms: "integer, proper, improper"
1991
- },
1992
- mixed: {
1993
- name: "Mixed numbers (no improper)",
1994
- forms: "integer, proper, mixed"
1995
- },
1996
- percent: {
1997
- name: "Numbers or percents",
1998
- forms: "integer, decimal, proper, improper, mixed, percent"
1999
- },
2000
- pi: {
2001
- name: "Numbers with pi",
2002
- forms: "pi"
2003
- }
2004
- };
2005
- function scoreInputNumber(userInput, rubric) {
2006
- if (rubric.answerType == null) {
2007
- rubric.answerType = "number";
2008
- }
113
+ const noScore={type:"points",earned:0,total:0,message:null};function scoreIsEmpty(score){return score.type==="invalid"&&(!score.message||score.message.length===0)}function combineScores(scoreA,scoreB){let message;if(scoreA.type==="points"&&scoreB.type==="points"){if(scoreA.message&&scoreB.message&&scoreA.message!==scoreB.message){message=null;}else {message=scoreA.message||scoreB.message;}return {type:"points",earned:scoreA.earned+scoreB.earned,total:scoreA.total+scoreB.total,message:message}}if(scoreA.type==="points"&&scoreB.type==="invalid"){return scoreB}if(scoreA.type==="invalid"&&scoreB.type==="points"){return scoreA}if(scoreA.type==="invalid"&&scoreB.type==="invalid"){if(scoreA.message&&scoreB.message&&scoreA.message!==scoreB.message){message=null;}else {message=scoreA.message||scoreB.message;}return {type:"invalid",message:message}}throw new perseusCore.PerseusError("PerseusScore with unknown type encountered",perseusCore.Errors.InvalidInput,{metadata:{scoreA:JSON.stringify(scoreA),scoreB:JSON.stringify(scoreB)}})}function flattenScores(widgetScoreMap){return Object.values(widgetScoreMap).reduce(combineScores,noScore)}function scorePerseusItem(perseusRenderData,userInputMap,locale){const usedWidgetIds=perseusCore.getWidgetIdsFromContent(perseusRenderData.content);const scores=scoreWidgetsFunctional(perseusRenderData.widgets,usedWidgetIds,userInputMap,locale);return flattenScores(scores)}function scoreWidgetsFunctional(widgets,widgetIds,userInputMap,locale){const upgradedWidgets=perseusCore.getUpgradedWidgetOptions(widgets);const gradedWidgetIds=widgetIds.filter(id=>{const props=upgradedWidgets[id];const widgetIsGraded=props?.graded==null||props.graded;const widgetIsStatic=!!props?.static;return widgetIsGraded&&!widgetIsStatic});const widgetScores={};gradedWidgetIds.forEach(id=>{const widget=upgradedWidgets[id];if(!widget){return}const userInput=userInputMap[id];const validator=getWidgetValidator(widget.type);const scorer=getWidgetScorer(widget.type);const score=validator?.(userInput,widget.options,locale)??scorer?.(userInput,widget.options,locale);if(score!=null){widgetScores[id]=score;}});return widgetScores}
2009
114
 
2010
- // note(matthewc): this will get immediately parsed again by
2011
- // `KhanAnswerTypes.number.convertToPredicate`, but a string is
2012
- // expected here
2013
- const stringValue = `${rubric.value}`;
2014
- const val = KhanAnswerTypes.number.createValidatorFunctional(stringValue, {
2015
- simplify: rubric.simplify,
2016
- inexact: rubric.inexact || undefined,
2017
- maxError: rubric.maxError,
2018
- forms: inputNumberAnswerTypes[rubric.answerType].forms
2019
- });
2020
-
2021
- // We may have received TeX; try to parse it before grading.
2022
- // If `currentValue` is not TeX, this should be a no-op.
2023
- const currentValue = parseTex(userInput.currentValue);
2024
- const result = val(currentValue);
2025
- if (result.empty) {
2026
- return {
2027
- type: "invalid",
2028
- message: result.message
2029
- };
2030
- }
2031
- return {
2032
- type: "points",
2033
- earned: result.correct ? 1 : 0,
2034
- total: 1,
2035
- message: result.message
2036
- };
2037
- }
2038
-
2039
- /**
2040
- * Several widgets don't have "right"/"wrong" scoring logic,
2041
- * so this just says to move on past those widgets
2042
- *
2043
- * TODO(LEMS-2543) widgets that use this probably shouldn't have any
2044
- * scoring logic and the thing scoring an exercise
2045
- * should just know to skip these
2046
- */
2047
- function scoreNoop() {
2048
- let points = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 0;
2049
- return {
2050
- type: "points",
2051
- earned: points,
2052
- total: points,
2053
- message: null
2054
- };
2055
- }
2056
-
2057
- // The `group` widget is basically a widget hosting a full Perseus system in
2058
-
2059
- // it. As such, scoring a group means scoring all widgets it contains.
2060
- function scoreGroup(userInput, rubric, locale) {
2061
- const scores = scoreWidgetsFunctional(rubric.widgets, Object.keys(rubric.widgets), userInput, locale);
2062
- return flattenScores(scores);
2063
- }
2064
-
2065
- /**
2066
- * Checks the given user input to see if any answerable widgets have not been
2067
- * "filled in" (ie. if they're empty). Another way to think about this
2068
- * function is that its a check to see if we can score the provided input.
2069
- */
2070
- function emptyWidgetsFunctional(widgets,
2071
- // This is a port of old code, I'm not sure why
2072
- // we need widgetIds vs the keys of the widgets object
2073
- widgetIds, userInputMap, locale) {
2074
- return widgetIds.filter(id => {
2075
- const widget = widgets[id];
2076
- if (!widget || widget.static === true) {
2077
- // Static widgets shouldn't count as empty
2078
- return false;
2079
- }
2080
- const validator = getWidgetValidator(widget.type);
2081
- const userInput = userInputMap[id];
2082
- const validationData = widget.options;
2083
- const score = validator?.(userInput, validationData, locale);
2084
- if (score) {
2085
- return scoreIsEmpty(score);
2086
- }
2087
- });
2088
- }
2089
-
2090
- function validateGroup(userInput, validationData, locale) {
2091
- const emptyWidgets = emptyWidgetsFunctional(validationData.widgets, Object.keys(validationData.widgets), userInput, locale);
2092
- if (emptyWidgets.length === 0) {
2093
- return null;
2094
- }
2095
- return {
2096
- type: "invalid",
2097
- message: null
2098
- };
2099
- }
2100
-
2101
- function validateLabelImage(userInput) {
2102
- let numAnswered = 0;
2103
- for (let i = 0; i < userInput.markers.length; i++) {
2104
- const userSelection = userInput.markers[i].selected;
2105
- if (userSelection && userSelection.length > 0) {
2106
- numAnswered++;
2107
- }
2108
- }
2109
- // We expect all question markers to be answered before grading.
2110
- if (numAnswered !== userInput.markers.length) {
2111
- return {
2112
- type: "invalid",
2113
- message: null
2114
- };
2115
- }
2116
- return null;
2117
- }
2118
-
2119
- function validateMockWidget(userInput) {
2120
- if (userInput.currentValue == null || userInput.currentValue === "") {
2121
- return {
2122
- type: "invalid",
2123
- message: ""
2124
- };
2125
- }
2126
- return null;
2127
- }
2128
-
2129
- function scoreMockWidget(userInput, rubric) {
2130
- const validationResult = validateMockWidget(userInput);
2131
- if (validationResult != null) {
2132
- return validationResult;
2133
- }
2134
- return {
2135
- type: "points",
2136
- earned: userInput.currentValue === rubric.value ? 1 : 0,
2137
- total: 1,
2138
- message: ""
2139
- };
2140
- }
2141
-
2142
- const widgets = {};
2143
- function registerWidget(type, scorer, validator) {
2144
- widgets[type] = {
2145
- scorer,
2146
- validator
2147
- };
2148
- }
2149
- const getWidgetValidator = name => {
2150
- return widgets[name]?.validator ?? null;
2151
- };
2152
- const getWidgetScorer = name => {
2153
- return widgets[name]?.scorer ?? null;
2154
- };
2155
- registerWidget("categorizer", scoreCategorizer, validateCategorizer);
2156
- registerWidget("cs-program", scoreCSProgram);
2157
- registerWidget("dropdown", scoreDropdown, validateDropdown);
2158
- registerWidget("expression", scoreExpression, validateExpression);
2159
- registerWidget("grapher", scoreGrapher);
2160
- registerWidget("group", scoreGroup, validateGroup);
2161
- registerWidget("iframe", scoreIframe);
2162
- registerWidget("input-number", scoreInputNumber);
2163
- registerWidget("interactive-graph", scoreInteractiveGraph);
2164
- registerWidget("label-image", scoreLabelImage, validateLabelImage);
2165
- registerWidget("matcher", scoreMatcher);
2166
- registerWidget("matrix", scoreMatrix, validateMatrix);
2167
- registerWidget("mock-widget", scoreMockWidget, scoreMockWidget);
2168
- registerWidget("number-line", scoreNumberLine, validateNumberLine);
2169
- registerWidget("numeric-input", scoreNumericInput);
2170
- registerWidget("orderer", scoreOrderer, validateOrderer);
2171
- registerWidget("plotter", scorePlotter, validatePlotter);
2172
- registerWidget("radio", scoreRadio, validateRadio);
2173
- registerWidget("sorter", scoreSorter, validateSorter);
2174
- registerWidget("table", scoreTable, validateTable);
2175
- registerWidget("deprecated-standin", () => scoreNoop(1));
2176
- registerWidget("measurer", () => scoreNoop(1));
2177
- registerWidget("definition", scoreNoop);
2178
- registerWidget("explanation", scoreNoop);
2179
- registerWidget("image", scoreNoop);
2180
- registerWidget("interaction", scoreNoop);
2181
- registerWidget("molecule", scoreNoop);
2182
- registerWidget("passage", scoreNoop);
2183
- registerWidget("passage-ref", scoreNoop);
2184
- registerWidget("passage-ref-target", scoreNoop);
2185
- registerWidget("video", scoreNoop);
2186
-
2187
- const noScore = {
2188
- type: "points",
2189
- earned: 0,
2190
- total: 0,
2191
- message: null
2192
- };
2193
-
2194
- /**
2195
- * If a widget says that it is empty once it is graded.
2196
- * Trying to encapsulate references to the score format.
2197
- */
2198
- function scoreIsEmpty(score) {
2199
- // HACK(benkomalo): ugh. this isn't great; the Perseus score objects
2200
- // overload the type "invalid" for what should probably be three
2201
- // distinct cases:
2202
- // - truly empty or not fully filled out
2203
- // - invalid or malformed inputs
2204
- // - "almost correct" like inputs where the widget wants to give
2205
- // feedback (e.g. a fraction needs to be reduced, or `pi` should
2206
- // be used instead of 3.14)
2207
- //
2208
- // Unfortunately the coercion happens all over the place, as these
2209
- // Perseus style score objects are created *everywhere* (basically
2210
- // in every widget), so it's hard to change now. We assume that
2211
- // anything with a "message" is not truly empty, and one of the
2212
- // latter two cases for now.
2213
- return score.type === "invalid" && (!score.message || score.message.length === 0);
2214
- }
2215
-
2216
- /**
2217
- * Combine two score objects.
2218
- *
2219
- * Given two score objects for two different widgets, combine them so that
2220
- * if one is wrong, the total score is wrong, etc.
2221
- */
2222
- function combineScores(scoreA, scoreB) {
2223
- let message;
2224
- if (scoreA.type === "points" && scoreB.type === "points") {
2225
- if (scoreA.message && scoreB.message && scoreA.message !== scoreB.message) {
2226
- // TODO(alpert): Figure out how to combine messages usefully
2227
- message = null;
2228
- } else {
2229
- message = scoreA.message || scoreB.message;
2230
- }
2231
- return {
2232
- type: "points",
2233
- earned: scoreA.earned + scoreB.earned,
2234
- total: scoreA.total + scoreB.total,
2235
- message: message
2236
- };
2237
- }
2238
- if (scoreA.type === "points" && scoreB.type === "invalid") {
2239
- return scoreB;
2240
- }
2241
- if (scoreA.type === "invalid" && scoreB.type === "points") {
2242
- return scoreA;
2243
- }
2244
- if (scoreA.type === "invalid" && scoreB.type === "invalid") {
2245
- if (scoreA.message && scoreB.message && scoreA.message !== scoreB.message) {
2246
- // TODO(alpert): Figure out how to combine messages usefully
2247
- message = null;
2248
- } else {
2249
- message = scoreA.message || scoreB.message;
2250
- }
2251
- return {
2252
- type: "invalid",
2253
- message: message
2254
- };
2255
- }
2256
-
2257
- /**
2258
- * The above checks cover all combinations of score type, so if we get here
2259
- * then something is amiss with our inputs.
2260
- */
2261
- throw new perseusCore.PerseusError("PerseusScore with unknown type encountered", perseusCore.Errors.InvalidInput, {
2262
- metadata: {
2263
- scoreA: JSON.stringify(scoreA),
2264
- scoreB: JSON.stringify(scoreB)
2265
- }
2266
- });
2267
- }
2268
- function flattenScores(widgetScoreMap) {
2269
- return Object.values(widgetScoreMap).reduce(combineScores, noScore);
2270
- }
2271
-
2272
- /**
2273
- * score a Perseus item
2274
- *
2275
- * @param perseusRenderData - the full answer data, includes the correct answer
2276
- * @param userInputMap - the user's input for each widget, mapped by ID
2277
- * @param locale - string locale for math parsing ("de" 1.000,00 vs "en" 1,000.00)
2278
- */
2279
- function scorePerseusItem(perseusRenderData, userInputMap, locale) {
2280
- // There seems to be a chance that PerseusRenderer.widgets might include
2281
- // widget data for widgets that are not in PerseusRenderer.content,
2282
- // so this checks that the widgets are being used before scoring them
2283
- const usedWidgetIds = perseusCore.getWidgetIdsFromContent(perseusRenderData.content);
2284
- const scores = scoreWidgetsFunctional(perseusRenderData.widgets, usedWidgetIds, userInputMap, locale);
2285
- return flattenScores(scores);
2286
- }
2287
-
2288
- // TODO: combine scorePerseusItem with scoreWidgetsFunctional
2289
- function scoreWidgetsFunctional(widgets,
2290
- // This is a port of old code, I'm not sure why
2291
- // we need widgetIds vs the keys of the widgets object
2292
- widgetIds, userInputMap, locale) {
2293
- const upgradedWidgets = perseusCore.getUpgradedWidgetOptions(widgets);
2294
- const gradedWidgetIds = widgetIds.filter(id => {
2295
- const props = upgradedWidgets[id];
2296
- const widgetIsGraded = props?.graded == null || props.graded;
2297
- const widgetIsStatic = !!props?.static;
2298
- // Ungraded widgets or widgets set to static shouldn't be graded.
2299
- return widgetIsGraded && !widgetIsStatic;
2300
- });
2301
- const widgetScores = {};
2302
- gradedWidgetIds.forEach(id => {
2303
- const widget = upgradedWidgets[id];
2304
- if (!widget) {
2305
- return;
2306
- }
2307
- const userInput = userInputMap[id];
2308
- const validator = getWidgetValidator(widget.type);
2309
- const scorer = getWidgetScorer(widget.type);
2310
-
2311
- // We do validation (empty checks) first and then scoring. If
2312
- // validation fails, it's result is itself a PerseusScore.
2313
- const score = validator?.(userInput, widget.options, locale) ?? scorer?.(userInput, widget.options, locale);
2314
- if (score != null) {
2315
- widgetScores[id] = score;
2316
- }
2317
- });
2318
- return widgetScores;
2319
- }
115
+ function hasEmptyDINERWidgets(itemData,userInputMap){const usedWidgetIds=perseusCore.getWidgetIdsFromContent(itemData.question.content);const widgets=itemData.question.widgets;for(const widgetId of usedWidgetIds){const widget=widgets[widgetId];const input=userInputMap[widgetId];switch(widget.type){case "dropdown":{if(input.value===0){return true}break}case "interactive-graph":{break}case "numeric-input":{if(!input.currentValue&&!widget.options.coefficient){return true}break}case "expression":{if(!input){return true}break}case "radio":{if(!input.choicesSelected.includes(true)){return true}break}}}return false}
2320
116
 
2321
117
  exports.ErrorCodes = ErrorCodes;
2322
118
  exports.KhanAnswerTypes = KhanAnswerTypes;
@@ -2324,6 +120,7 @@ exports.emptyWidgetsFunctional = emptyWidgetsFunctional;
2324
120
  exports.flattenScores = flattenScores;
2325
121
  exports.getWidgetScorer = getWidgetScorer;
2326
122
  exports.getWidgetValidator = getWidgetValidator;
123
+ exports.hasEmptyDINERWidgets = hasEmptyDINERWidgets;
2327
124
  exports.inputNumberAnswerTypes = inputNumberAnswerTypes;
2328
125
  exports.registerWidget = registerWidget;
2329
126
  exports.scoreCSProgram = scoreCSProgram;