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