diff --git a/.changeset/cold-badgers-happen.md b/.changeset/cold-badgers-happen.md new file mode 100644 index 0000000000..1765749a56 --- /dev/null +++ b/.changeset/cold-badgers-happen.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": minor +--- + +Introduces a validation function for the interactive graph widget (extracted from the scoring function). diff --git a/packages/perseus/src/validation.types.ts b/packages/perseus/src/validation.types.ts index 68674223c7..00fa402af9 100644 --- a/packages/perseus/src/validation.types.ts +++ b/packages/perseus/src/validation.types.ts @@ -124,9 +124,12 @@ export type PerseusInputNumberUserInput = { currentValue: string; }; -export type PerseusInteractiveGraphRubric = { +export type PerseusInteractiveGraphScoringData = { // TODO(LEMS-2344): make the type of `correct` more specific correct: PerseusGraphCorrectType; +} & PerseusInteractiveGraphValidationData; + +export type PerseusInteractiveGraphValidationData = { graph: PerseusGraphType; }; @@ -226,7 +229,7 @@ export type Rubric = | PerseusGrapherRubric | PerseusIFrameRubric | PerseusInputNumberRubric - | PerseusInteractiveGraphRubric + | PerseusInteractiveGraphScoringData | PerseusLabelImageRubric | PerseusMatcherRubric | PerseusMatrixRubric diff --git a/packages/perseus/src/widgets/interactive-graph.tsx b/packages/perseus/src/widgets/interactive-graph.tsx index a8bfbdbd50..0b101d5788 100644 --- a/packages/perseus/src/widgets/interactive-graph.tsx +++ b/packages/perseus/src/widgets/interactive-graph.tsx @@ -52,7 +52,7 @@ import type { SineCoefficient, } from "../util/geometry"; import type { - PerseusInteractiveGraphRubric, + PerseusInteractiveGraphScoringData, PerseusInteractiveGraphUserInput, } from "../validation.types"; import type {InteractiveGraphPromptJSON} from "../widget-ai-utils/interactive-graph/interactive-graph-ai-utils"; @@ -119,7 +119,7 @@ const makeInvalidTypeError = ( }; type RenderProps = PerseusInteractiveGraphWidgetOptions; // There's no transform function in exports -type Props = WidgetProps; +type Props = WidgetProps; type State = any; type DefaultProps = { labels: Props["labels"]; diff --git a/packages/perseus/src/widgets/interactive-graphs/score-interactive-graph.test.ts b/packages/perseus/src/widgets/interactive-graphs/score-interactive-graph.test.ts index 6695f5c1eb..d5a03236d6 100644 --- a/packages/perseus/src/widgets/interactive-graphs/score-interactive-graph.test.ts +++ b/packages/perseus/src/widgets/interactive-graphs/score-interactive-graph.test.ts @@ -5,31 +5,9 @@ import {clone} from "../../../../../testing/object-utils"; import scoreInteractiveGraph from "./score-interactive-graph"; import type {PerseusGraphType} from "../../perseus-types"; -import type {PerseusInteractiveGraphRubric} from "../../validation.types"; +import type {PerseusInteractiveGraphScoringData} from "../../validation.types"; describe("InteractiveGraph scoring on a segment question", () => { - it("marks the answer invalid if guess.coords is missing", () => { - const guess: PerseusGraphType = {type: "segment"}; - const rubric: PerseusInteractiveGraphRubric = { - graph: { - type: "segment", - }, - correct: { - type: "segment", - coords: [ - [ - [0, 0], - [1, 1], - ], - ], - }, - }; - - const result = scoreInteractiveGraph(guess, rubric); - - expect(result).toHaveInvalidInput(); - }); - it("does not award points if guess.coords is wrong", () => { const guess: PerseusGraphType = { type: "segment", @@ -41,7 +19,7 @@ describe("InteractiveGraph scoring on a segment question", () => { ], }; - const rubric: PerseusInteractiveGraphRubric = { + const rubric: PerseusInteractiveGraphScoringData = { graph: {type: "segment"}, correct: { type: "segment", @@ -70,7 +48,7 @@ describe("InteractiveGraph scoring on a segment question", () => { ], }; - const rubric: PerseusInteractiveGraphRubric = { + const rubric: PerseusInteractiveGraphScoringData = { graph: {type: "segment"}, correct: { type: "segment", @@ -98,7 +76,7 @@ describe("InteractiveGraph scoring on a segment question", () => { ], ], }; - const rubric: PerseusInteractiveGraphRubric = { + const rubric: PerseusInteractiveGraphScoringData = { graph: {type: "segment"}, correct: { type: "segment", @@ -127,7 +105,7 @@ describe("InteractiveGraph scoring on a segment question", () => { ], }; - const rubric: PerseusInteractiveGraphRubric = { + const rubric: PerseusInteractiveGraphScoringData = { graph: {type: "segment"}, correct: { type: "segment", @@ -160,7 +138,7 @@ describe("InteractiveGraph scoring on a segment question", () => { ], ], }; - const rubric: PerseusInteractiveGraphRubric = { + const rubric: PerseusInteractiveGraphScoringData = { graph: {type: "segment"}, correct: { type: "segment", @@ -187,45 +165,7 @@ describe("InteractiveGraph scoring on a segment question", () => { }); }); -describe("InteractiveGraph scoring on an angle question", () => { - it("marks the answer invalid if guess.coords is missing", () => { - const guess: PerseusGraphType = {type: "angle"}; - const rubric: PerseusInteractiveGraphRubric = { - graph: {type: "angle"}, - correct: { - type: "angle", - coords: [ - [1, 1], - [0, 0], - [-1, -1], - ], - allowReflexAngles: false, - match: "congruent", - }, - }; - - const result = scoreInteractiveGraph(guess, rubric); - - expect(result).toHaveInvalidInput(); - }); -}); - describe("InteractiveGraph scoring on a point question", () => { - it("marks the answer invalid if guess.coords is missing", () => { - const guess: PerseusGraphType = {type: "point"}; - const rubric: PerseusInteractiveGraphRubric = { - graph: {type: "point"}, - correct: { - type: "point", - coords: [[0, 0]], - }, - }; - - const result = scoreInteractiveGraph(guess, rubric); - - expect(result).toHaveInvalidInput(); - }); - it("throws an exception if correct.coords is missing", () => { // Characterization test: this might not be desirable behavior, but // it's the current behavior as of 2024-09-25. @@ -234,7 +174,7 @@ describe("InteractiveGraph scoring on a point question", () => { coords: [[0, 0]], }; - const rubric: PerseusInteractiveGraphRubric = { + const rubric: PerseusInteractiveGraphScoringData = { graph: { type: "point", }, @@ -252,7 +192,7 @@ describe("InteractiveGraph scoring on a point question", () => { type: "point", coords: [[9, 9]], }; - const rubric: PerseusInteractiveGraphRubric = { + const rubric: PerseusInteractiveGraphScoringData = { graph: {type: "point"}, correct: { type: "point", @@ -270,7 +210,7 @@ describe("InteractiveGraph scoring on a point question", () => { type: "point", coords: [[7, 8]], }; - const rubric: PerseusInteractiveGraphRubric = { + const rubric: PerseusInteractiveGraphScoringData = { graph: {type: "point"}, correct: { type: "point", @@ -291,7 +231,7 @@ describe("InteractiveGraph scoring on a point question", () => { [5, 6], ], }; - const rubric: PerseusInteractiveGraphRubric = { + const rubric: PerseusInteractiveGraphScoringData = { graph: {type: "point"}, correct: { type: "point", @@ -315,7 +255,7 @@ describe("InteractiveGraph scoring on a point question", () => { [5, 6], ], }; - const rubric: PerseusInteractiveGraphRubric = { + const rubric: PerseusInteractiveGraphScoringData = { graph: {type: "point"}, correct: { type: "point", @@ -341,7 +281,7 @@ describe("InteractiveGraph scoring on a point question", () => { [5, 6], ], }; - const rubric: PerseusInteractiveGraphRubric = { + const rubric: PerseusInteractiveGraphScoringData = { graph: {type: "point"}, correct: { type: "point", diff --git a/packages/perseus/src/widgets/interactive-graphs/score-interactive-graph.ts b/packages/perseus/src/widgets/interactive-graphs/score-interactive-graph.ts index 0525f2add5..43bc7680ba 100644 --- a/packages/perseus/src/widgets/interactive-graphs/score-interactive-graph.ts +++ b/packages/perseus/src/widgets/interactive-graphs/score-interactive-graph.ts @@ -13,10 +13,11 @@ import { } from "../interactive-graph"; import {getClockwiseAngle} from "./math/angles"; +import validateInteractiveGraph from "./validate-interactive-graph"; import type {PerseusScore} from "../../types"; import type { - PerseusInteractiveGraphRubric, + PerseusInteractiveGraphScoringData, PerseusInteractiveGraphUserInput, } from "../../validation.types"; @@ -25,10 +26,10 @@ const deepEq = Util.deepEq; function scoreInteractiveGraph( userInput: PerseusInteractiveGraphUserInput, - rubric: PerseusInteractiveGraphRubric, + scoringData: PerseusInteractiveGraphScoringData, ): PerseusScore { // None-type graphs are not graded - if (userInput.type === "none" && rubric.correct.type === "none") { + if (userInput.type === "none" && scoringData.correct.type === "none") { return { type: "points", earned: 0, @@ -47,14 +48,14 @@ function scoreInteractiveGraph( (userInput.center && userInput.radius), ); - if (userInput.type === rubric.correct.type && hasValue) { + if (userInput.type === scoringData.correct.type && hasValue) { if ( userInput.type === "linear" && - rubric.correct.type === "linear" && + scoringData.correct.type === "linear" && userInput.coords != null ) { const guess = userInput.coords; - const correct = rubric.correct.coords; + const correct = scoringData.correct.coords; // If both of the guess points are on the correct line, it's // correct. @@ -71,11 +72,11 @@ function scoreInteractiveGraph( } } else if ( userInput.type === "linear-system" && - rubric.correct.type === "linear-system" && + scoringData.correct.type === "linear-system" && userInput.coords != null ) { const guess = userInput.coords; - const correct = rubric.correct.coords; + const correct = scoringData.correct.coords; if ( (collinear(correct[0][0], correct[0][1], guess[0][0]) && @@ -96,13 +97,13 @@ function scoreInteractiveGraph( } } else if ( userInput.type === "quadratic" && - rubric.correct.type === "quadratic" && + scoringData.correct.type === "quadratic" && userInput.coords != null ) { // If the parabola coefficients match, it's correct. const guessCoeffs = getQuadraticCoefficients(userInput.coords); const correctCoeffs = getQuadraticCoefficients( - rubric.correct.coords, + scoringData.correct.coords, ); if (deepEq(guessCoeffs, correctCoeffs)) { return { @@ -114,12 +115,12 @@ function scoreInteractiveGraph( } } else if ( userInput.type === "sinusoid" && - rubric.correct.type === "sinusoid" && + scoringData.correct.type === "sinusoid" && userInput.coords != null ) { const guessCoeffs = getSinusoidCoefficients(userInput.coords); const correctCoeffs = getSinusoidCoefficients( - rubric.correct.coords, + scoringData.correct.coords, ); const canonicalGuessCoeffs = canonicalSineCoefficients(guessCoeffs); @@ -136,11 +137,11 @@ function scoreInteractiveGraph( } } else if ( userInput.type === "circle" && - rubric.correct.type === "circle" + scoringData.correct.type === "circle" ) { if ( - deepEq(userInput.center, rubric.correct.center) && - eq(userInput.radius, rubric.correct.radius) + deepEq(userInput.center, scoringData.correct.center) && + eq(userInput.radius, scoringData.correct.radius) ) { return { type: "points", @@ -151,12 +152,12 @@ function scoreInteractiveGraph( } } else if ( userInput.type === "point" && - rubric.correct.type === "point" && + scoringData.correct.type === "point" && userInput.coords != null ) { - let correct = rubric.correct.coords; + let correct = scoringData.correct.coords; if (correct == null) { - throw new Error("Point graph rubric has null coords"); + throw new Error("Point graph scoringData has null coords"); } const guess = userInput.coords.slice(); correct = correct.slice(); @@ -177,18 +178,18 @@ function scoreInteractiveGraph( } } else if ( userInput.type === "polygon" && - rubric.correct.type === "polygon" && + scoringData.correct.type === "polygon" && userInput.coords != null ) { const guess = userInput.coords.slice(); - const correct = rubric.correct.coords.slice(); + const correct = scoringData.correct.coords.slice(); let match; - if (rubric.correct.match === "similar") { + if (scoringData.correct.match === "similar") { match = similar(guess, correct, Number.POSITIVE_INFINITY); - } else if (rubric.correct.match === "congruent") { + } else if (scoringData.correct.match === "congruent") { match = similar(guess, correct, knumber.DEFAULT_TOLERANCE); - } else if (rubric.correct.match === "approx") { + } else if (scoringData.correct.match === "approx") { match = similar(guess, correct, 0.1); } else { /* exact */ @@ -207,11 +208,11 @@ function scoreInteractiveGraph( } } else if ( userInput.type === "segment" && - rubric.correct.type === "segment" && + scoringData.correct.type === "segment" && userInput.coords != null ) { let guess = Util.deepClone(userInput.coords); - let correct = Util.deepClone(rubric.correct.coords); + let correct = Util.deepClone(scoringData.correct.coords); guess = _.invoke(guess, "sort").sort(); correct = _.invoke(correct, "sort").sort(); if (deepEq(guess, correct)) { @@ -224,11 +225,11 @@ function scoreInteractiveGraph( } } else if ( userInput.type === "ray" && - rubric.correct.type === "ray" && + scoringData.correct.type === "ray" && userInput.coords != null ) { const guess = userInput.coords; - const correct = rubric.correct.coords; + const correct = scoringData.correct.coords; if ( deepEq(guess[0], correct[0]) && collinear(correct[0], correct[1], guess[1]) @@ -242,14 +243,14 @@ function scoreInteractiveGraph( } } else if ( userInput.type === "angle" && - rubric.correct.type === "angle" + scoringData.correct.type === "angle" ) { const guess = userInput.coords; - const correct = rubric.correct.coords; - const allowReflexAngles = rubric.correct.allowReflexAngles; + const correct = scoringData.correct.coords; + const allowReflexAngles = scoringData.correct.allowReflexAngles; let match; - if (rubric.correct.match === "congruent") { + if (scoringData.correct.match === "congruent") { const angles = _.map([guess, correct], function (coords) { if (!coords) { return false; @@ -280,14 +281,11 @@ function scoreInteractiveGraph( } } - // The input wasn't correct, so check if it's a blank input or if it's - // actually just wrong - if (!hasValue || _.isEqual(userInput, rubric.graph)) { - // We're where we started. - return { - type: "invalid", - message: null, - }; + // The input wasn't correct, so check if it's a blank input (validate) or if + // it's actually just wrong + const validationError = validateInteractiveGraph(userInput, scoringData); + if (validationError) { + return validationError; } return { type: "points", diff --git a/packages/perseus/src/widgets/interactive-graphs/validate-interactive-graph.test.ts b/packages/perseus/src/widgets/interactive-graphs/validate-interactive-graph.test.ts new file mode 100644 index 0000000000..3a3b9a06fb --- /dev/null +++ b/packages/perseus/src/widgets/interactive-graphs/validate-interactive-graph.test.ts @@ -0,0 +1,146 @@ +import validateInteractiveGraph from "./validate-interactive-graph"; + +import type {PerseusGraphType} from "../../perseus-types"; +import type {PerseusInteractiveGraphScoringData} from "../../validation.types"; + +describe("InteractiveGraph validating on a segment question", () => { + it("marks the answer invalid if guess.coords is missing", () => { + const guess: PerseusGraphType = {type: "segment"}; + const scoringData: PerseusInteractiveGraphScoringData = { + graph: { + type: "segment", + }, + correct: { + type: "segment", + coords: [ + [ + [0, 0], + [1, 1], + ], + ], + }, + }; + + const validationError = validateInteractiveGraph(guess, scoringData); + + expect(validationError).toHaveInvalidInput(); + }); + + it("returns null if guess.coords is present", () => { + const guess: PerseusGraphType = { + type: "segment", + coords: [ + [ + [0, 0], + [1, 1], + ], + ], + }; + const scoringData: PerseusInteractiveGraphScoringData = { + graph: { + type: "segment", + }, + correct: { + type: "segment", + coords: [ + [ + [0, 0], + [1, 1], + ], + ], + }, + }; + + const validationError = validateInteractiveGraph(guess, scoringData); + + expect(validationError).toBeNull(); + }); +}); + +describe("InteractiveGraph validating on an angle question", () => { + it("marks the answer invalid if guess.coords is missing", () => { + const guess: PerseusGraphType = {type: "angle"}; + const scoringData: PerseusInteractiveGraphScoringData = { + graph: {type: "angle"}, + correct: { + type: "angle", + coords: [ + [1, 1], + [0, 0], + [-1, -1], + ], + allowReflexAngles: false, + match: "congruent", + }, + }; + + const validationError = validateInteractiveGraph(guess, scoringData); + + expect(validationError).toHaveInvalidInput(); + }); + + it("returns null if guess.coords is present", () => { + const guess: PerseusGraphType = { + type: "angle", + coords: [ + [1, 1], + [0, 0], + [-1, -1], + ], + allowReflexAngles: false, + match: "congruent", + }; + const scoringData: PerseusInteractiveGraphScoringData = { + graph: {type: "angle"}, + correct: { + type: "angle", + coords: [ + [1, 1], + [0, 0], + [-1, -1], + ], + allowReflexAngles: false, + match: "congruent", + }, + }; + + const validationError = validateInteractiveGraph(guess, scoringData); + + expect(validationError).toBeNull(); + }); +}); + +describe("InteractiveGraph validating on a point question", () => { + it("marks the answer invalid if guess.coords is missing", () => { + const guess: PerseusGraphType = {type: "point"}; + const scoringData: PerseusInteractiveGraphScoringData = { + graph: {type: "point"}, + correct: { + type: "point", + coords: [[0, 0]], + }, + }; + + const validationError = validateInteractiveGraph(guess, scoringData); + + expect(validationError).toHaveInvalidInput(); + }); + + it("returns null if guess.coords is present", () => { + const guess: PerseusGraphType = { + type: "point", + coords: [[0, 0]], + }; + const scoringData: PerseusInteractiveGraphScoringData = { + graph: {type: "point"}, + correct: { + type: "point", + coords: [[0, 0]], + }, + }; + + const validationError = validateInteractiveGraph(guess, scoringData); + + expect(validationError).toBeNull(); + }); +}); diff --git a/packages/perseus/src/widgets/interactive-graphs/validate-interactive-graph.ts b/packages/perseus/src/widgets/interactive-graphs/validate-interactive-graph.ts new file mode 100644 index 0000000000..2449bc4921 --- /dev/null +++ b/packages/perseus/src/widgets/interactive-graphs/validate-interactive-graph.ts @@ -0,0 +1,39 @@ +import _ from "underscore"; + +import type {PerseusScore} from "../../types"; +import type { + PerseusInteractiveGraphUserInput, + PerseusInteractiveGraphValidationData, +} from "../../validation.types"; + +/** + * Checks if the user input is the same as the starting state. + * @param userInput + * @param validationData + * @see `scoreInteractiveGraph` for the scoring logic. + */ +function validateInteractiveGraph( + userInput: PerseusInteractiveGraphUserInput, + validationData: PerseusInteractiveGraphValidationData, +): Extract | null { + // When nothing has moved, there will neither be coords nor the + // circle's center/radius fields. When those fields are absent, skip + // all these checks; just go mark the answer as empty. + const hasValue = Boolean( + // @ts-expect-error - TS2339 - Property 'coords' does not exist on type 'PerseusGraphType'. + userInput.coords || + // @ts-expect-error - TS2339 - Property 'center' does not exist on type 'PerseusGraphType'. | TS2339 - Property 'radius' does not exist on type 'PerseusGraphType'. + (userInput.center && userInput.radius), + ); + + if (!hasValue || _.isEqual(userInput, validationData.graph)) { + // We're where we started. + return { + type: "invalid", + message: null, + }; + } + return null; +} + +export default validateInteractiveGraph;