diff --git a/.changeset/sweet-ligers-arrive.md b/.changeset/sweet-ligers-arrive.md new file mode 100644 index 0000000000..a1f98675e0 --- /dev/null +++ b/.changeset/sweet-ligers-arrive.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": minor +--- + +Introduces a validation function for the number line widget (extracted from the scoring function). diff --git a/packages/perseus/src/validation.types.ts b/packages/perseus/src/validation.types.ts index 3f92fe36a8..f0fde91d66 100644 --- a/packages/perseus/src/validation.types.ts +++ b/packages/perseus/src/validation.types.ts @@ -38,7 +38,6 @@ import type { PerseusGroupWidgetOptions, PerseusMatcherWidgetOptions, PerseusMatrixWidgetAnswers, - PerseusNumberLineWidgetOptions, PerseusNumericInputAnswer, PerseusOrdererWidgetOptions, PerseusRadioChoice, @@ -164,7 +163,11 @@ export type PerseusMatrixUserInput = { answers: PerseusMatrixRubric["answers"]; }; -export type PerseusNumberLineRubric = PerseusNumberLineWidgetOptions & { +export type PerseusNumberLineScoringData = { + correctRel: string | null | undefined; + correctX: number; + range: ReadonlyArray; + initialX: number | null | undefined; isInequality: boolean; }; @@ -246,7 +249,7 @@ export type Rubric = | PerseusLabelImageRubric | PerseusMatcherRubric | PerseusMatrixRubric - | PerseusNumberLineRubric + | PerseusNumberLineScoringData | PerseusNumericInputRubric | PerseusOrdererRubric | PerseusPlotterScoringData diff --git a/packages/perseus/src/widgets/number-line/score-number-line.test.ts b/packages/perseus/src/widgets/number-line/score-number-line.test.ts index 8976dcf94f..82d9e7545a 100644 --- a/packages/perseus/src/widgets/number-line/score-number-line.test.ts +++ b/packages/perseus/src/widgets/number-line/score-number-line.test.ts @@ -1,109 +1,83 @@ import scoreNumberLine from "./score-number-line"; import type { - PerseusNumberLineRubric, + PerseusNumberLineScoringData, PerseusNumberLineUserInput, } from "../../validation.types"; -const baseInput: PerseusNumberLineUserInput = { - isTickCrtl: true, - numLinePosition: 1, - rel: "eq", - numDivisions: 10, - divisionRange: [-10, 10], -}; - -const baseRubric: PerseusNumberLineRubric = { - correctRel: "eq", - correctX: -1.5, - divisionRange: [1, 12], - initialX: -1, - labelRange: [null, null], - labelStyle: "decimal", - labelTicks: true, - numDivisions: null, - range: [-1.5, 1.5], - snapDivisions: 2, - static: false, - tickStep: 0.5, - isInequality: false, -}; - -function generateInput( - extend?: Partial, -): PerseusNumberLineUserInput { - return {...baseInput, ...extend}; -} - -function generateRubric( - extend?: Partial, -): PerseusNumberLineRubric { - return {...baseRubric, ...extend}; -} - describe("scoreNumberLine", () => { - it("is invalid when outside allowed range", () => { - // Arrange - const userInput = generateInput({ - divisionRange: [-1, 1], - numLinePosition: 10, - }); - - const rubric = generateRubric(); - - // Act - const result = scoreNumberLine(userInput, rubric); - - // Assert - expect(result).toHaveInvalidInput( - "Number of divisions is outside the allowed range.", - ); - }); - it("is invalid when end state is the same as beginning state", () => { // Arrange - const userInput = generateInput({ + const userInput: PerseusNumberLineUserInput = { + isTickCrtl: true, + rel: "eq", + numDivisions: 10, + divisionRange: [-10, 10], numLinePosition: 0, - }); + }; - const rubric = generateRubric({ + const scoringData: PerseusNumberLineScoringData = { + correctRel: "eq", + correctX: -1.5, initialX: 0, - }); + range: [-1.5, 1.5], + isInequality: false, + }; // Act - const result = scoreNumberLine(userInput, rubric); + const score = scoreNumberLine(userInput, scoringData); // Assert - expect(result).toHaveInvalidInput(); + expect(score).toHaveInvalidInput(); }); it("can be answered correctly", () => { // Arrange - const userInput = generateInput({ + const userInput: PerseusNumberLineUserInput = { + isTickCrtl: true, + rel: "eq", + numDivisions: 10, + divisionRange: [-10, 10], numLinePosition: -1.5, - }); + }; - const rubric = generateRubric(); + const scoringData: PerseusNumberLineScoringData = { + correctRel: "eq", + correctX: -1.5, + initialX: -1, + range: [-1.5, 1.5], + isInequality: false, + }; // Act - const result = scoreNumberLine(userInput, rubric); + const score = scoreNumberLine(userInput, scoringData); // Assert - expect(result).toHaveBeenAnsweredCorrectly(); + expect(score).toHaveBeenAnsweredCorrectly(); }); it("can be answered incorrectly", () => { // Arrange - const userInput = generateInput({ + const userInput: PerseusNumberLineUserInput = { + isTickCrtl: true, + rel: "eq", + numDivisions: 10, + divisionRange: [-10, 10], numLinePosition: 1.5, - }); + }; - const rubric = generateRubric(); + const scoringData: PerseusNumberLineScoringData = { + correctRel: "eq", + correctX: -1.5, + initialX: -1, + range: [-1.5, 1.5], + isInequality: false, + }; // Act - const result = scoreNumberLine(userInput, rubric); + const score = scoreNumberLine(userInput, scoringData); // Assert - expect(result).toHaveBeenAnsweredIncorrectly(); + expect(score).toHaveBeenAnsweredIncorrectly(); }); }); diff --git a/packages/perseus/src/widgets/number-line/score-number-line.ts b/packages/perseus/src/widgets/number-line/score-number-line.ts index fabacfe520..429c39d680 100644 --- a/packages/perseus/src/widgets/number-line/score-number-line.ts +++ b/packages/perseus/src/widgets/number-line/score-number-line.ts @@ -1,36 +1,33 @@ import {number as knumber} from "@khanacademy/kmath"; +import validateNumberLine from "./validate-number-line"; + import type {PerseusScore} from "../../types"; import type { - PerseusNumberLineRubric, + PerseusNumberLineScoringData, PerseusNumberLineUserInput, } from "../../validation.types"; function scoreNumberLine( - state: PerseusNumberLineUserInput, - rubric: PerseusNumberLineRubric, + userInput: PerseusNumberLineUserInput, + scoringData: PerseusNumberLineScoringData, ): PerseusScore { - const range = rubric.range; - const divisionRange = state.divisionRange; - const start = rubric.initialX != null ? rubric.initialX : range[0]; - const startRel = rubric.isInequality ? "ge" : "eq"; - const correctRel = rubric.correctRel || "eq"; + const validationError = validateNumberLine(userInput); + if (validationError) { + return validationError; + } + + const range = scoringData.range; + const start = + scoringData.initialX != null ? scoringData.initialX : range[0]; + const startRel = scoringData.isInequality ? "ge" : "eq"; + const correctRel = scoringData.correctRel || "eq"; const correctPos = knumber.equal( - state.numLinePosition, - rubric.correctX || 0, + userInput.numLinePosition, + scoringData.correctX || 0, ); - const outsideAllowedRange = - state.numDivisions > divisionRange[1] || - state.numDivisions < divisionRange[0]; - // TODO: I don't think isTickCrtl is a thing anymore - if (state.isTickCrtl && outsideAllowedRange) { - return { - type: "invalid", - message: "Number of divisions is outside the allowed range.", - }; - } - if (correctPos && correctRel === state.rel) { + if (correctPos && correctRel === userInput.rel) { return { type: "points", earned: 1, @@ -38,7 +35,7 @@ function scoreNumberLine( message: null, }; } - if (state.numLinePosition === start && state.rel === startRel) { + if (userInput.numLinePosition === start && userInput.rel === startRel) { // We're where we started. return { type: "invalid", diff --git a/packages/perseus/src/widgets/number-line/validate-number-line.test.ts b/packages/perseus/src/widgets/number-line/validate-number-line.test.ts new file mode 100644 index 0000000000..de7d1a157b --- /dev/null +++ b/packages/perseus/src/widgets/number-line/validate-number-line.test.ts @@ -0,0 +1,41 @@ +import validateNumberLine from "./validate-number-line"; + +import type {PerseusNumberLineUserInput} from "../../validation.types"; + +describe("validateNumberLine", () => { + it("is invalid when outside allowed range", () => { + // Arrange + const userInput: PerseusNumberLineUserInput = { + isTickCrtl: true, + rel: "eq", + numDivisions: 10, + divisionRange: [-1, 1], + numLinePosition: 10, + }; + + // Act + const validationError = validateNumberLine(userInput); + + // Assert + expect(validationError).toHaveInvalidInput( + "Number of divisions is outside the allowed range.", + ); + }); + + it("returns null when validation passes", () => { + // Arrange + const userInput: PerseusNumberLineUserInput = { + isTickCrtl: true, + rel: "eq", + numDivisions: 10, + divisionRange: [-10, 10], + numLinePosition: -1.5, + }; + + // Act + const validationError = validateNumberLine(userInput); + + // Assert + expect(validationError).toBeNull(); + }); +}); diff --git a/packages/perseus/src/widgets/number-line/validate-number-line.ts b/packages/perseus/src/widgets/number-line/validate-number-line.ts new file mode 100644 index 0000000000..bc57947c8c --- /dev/null +++ b/packages/perseus/src/widgets/number-line/validate-number-line.ts @@ -0,0 +1,28 @@ +import type {PerseusScore} from "../../types"; +import type {PerseusNumberLineUserInput} from "../../validation.types"; + +/** + * Checks user input is within the allowed range and not the same as the initial + * state. + * @param userInput + * @see 'scoreNumberLine' for the scoring logic. + */ +function validateNumberLine( + userInput: PerseusNumberLineUserInput, +): Extract | null { + const divisionRange = userInput.divisionRange; + const outsideAllowedRange = + userInput.numDivisions > divisionRange[1] || + userInput.numDivisions < divisionRange[0]; + + // TODO: I don't think isTickCrtl is a thing anymore + if (userInput.isTickCrtl && outsideAllowedRange) { + return { + type: "invalid", + message: "Number of divisions is outside the allowed range.", + }; + } + return null; +} + +export default validateNumberLine;