From 493715e3d9a8892ae6f7e052b830e4d88367cd19 Mon Sep 17 00:00:00 2001 From: Matthew Date: Mon, 30 Sep 2024 13:37:09 -0500 Subject: [PATCH] Split out InteractiveGraph validator (#1700) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary: Split out the validation logic from the InteractiveGraph widget Issue: LEMS-2356 ## Test plan: Nothing should change, mostly just moving code around Author: handeyeco Reviewers: benchristel, handeyeco Required Reviewers: Approved By: benchristel Checks: ✅ Publish npm snapshot (ubuntu-latest, 20.x), ✅ Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ✅ Cypress (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ Check builds for changes in size (ubuntu-latest, 20.x), ✅ Publish Storybook to Chromatic (ubuntu-latest, 20.x), ✅ gerald Pull Request URL: https://github.com/Khan/perseus/pull/1700 --- .changeset/nervous-otters-fly.md | 5 + .../src/widgets/interactive-graph.test.tsx | 301 +------------ .../perseus/src/widgets/interactive-graph.tsx | 397 +++--------------- .../interactive-graph-validator.test.ts | 299 +++++++++++++ .../interactive-graph-validator.ts | 313 ++++++++++++++ 5 files changed, 675 insertions(+), 640 deletions(-) create mode 100644 .changeset/nervous-otters-fly.md create mode 100644 packages/perseus/src/widgets/interactive-graphs/interactive-graph-validator.test.ts create mode 100644 packages/perseus/src/widgets/interactive-graphs/interactive-graph-validator.ts diff --git a/.changeset/nervous-otters-fly.md b/.changeset/nervous-otters-fly.md new file mode 100644 index 0000000000..818680d6ad --- /dev/null +++ b/.changeset/nervous-otters-fly.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": patch +--- + +Split out InteractiveGraph validator diff --git a/packages/perseus/src/widgets/interactive-graph.test.tsx b/packages/perseus/src/widgets/interactive-graph.test.tsx index aba4dae7b1..8185210ae0 100644 --- a/packages/perseus/src/widgets/interactive-graph.test.tsx +++ b/packages/perseus/src/widgets/interactive-graph.test.tsx @@ -1,310 +1,11 @@ -import invariant from "tiny-invariant"; - -import {clone} from "../../../../testing/object-utils"; - -import InteractiveGraph, {shouldUseMafs} from "./interactive-graph"; +import {shouldUseMafs} from "./interactive-graph"; import type { PerseusGraphTypeLinear, PerseusGraphTypePoint, PerseusGraphTypePolygon, - PerseusGraphType, PerseusGraphTypeNone, } from "../perseus-types"; -import type {PerseusInteractiveGraphRubric} from "../validation.types"; - -function createRubric(graph: PerseusGraphType): PerseusInteractiveGraphRubric { - return {graph, correct: graph}; -} - -describe("InteractiveGraph.validate on a segment question", () => { - it("marks the answer invalid if guess.coords is missing", () => { - const guess: PerseusGraphType = {type: "segment"}; - const rubric: PerseusInteractiveGraphRubric = createRubric({ - type: "segment", - coords: [ - [ - [0, 0], - [1, 1], - ], - ], - }); - - const result = InteractiveGraph.widget.validate(guess, rubric); - - expect(result).toHaveInvalidInput(); - }); - - it("does not award points if guess.coords is wrong", () => { - const guess: PerseusGraphType = { - type: "segment", - coords: [ - [ - [99, 0], - [1, 1], - ], - ], - }; - const rubric: PerseusInteractiveGraphRubric = createRubric({ - type: "segment", - coords: [ - [ - [0, 0], - [1, 1], - ], - ], - }); - - const result = InteractiveGraph.widget.validate(guess, rubric); - - expect(result).toHaveBeenAnsweredIncorrectly(); - }); - - it("awards points if guess.coords is right", () => { - const guess: PerseusGraphType = { - type: "segment", - coords: [ - [ - [0, 0], - [1, 1], - ], - ], - }; - const rubric: PerseusInteractiveGraphRubric = createRubric({ - type: "segment", - coords: [ - [ - [0, 0], - [1, 1], - ], - ], - }); - - const result = InteractiveGraph.widget.validate(guess, rubric); - - expect(result).toHaveBeenAnsweredCorrectly(); - }); - - it("allows points of a segment to be specified in reverse order", () => { - const guess: PerseusGraphType = { - type: "segment", - coords: [ - [ - [1, 1], - [0, 0], - ], - ], - }; - const rubric: PerseusInteractiveGraphRubric = createRubric({ - type: "segment", - coords: [ - [ - [0, 0], - [1, 1], - ], - ], - }); - - const result = InteractiveGraph.widget.validate(guess, rubric); - - expect(result).toHaveBeenAnsweredCorrectly(); - }); - - it("does not modify the `guess` data", () => { - const guess: PerseusGraphType = { - type: "segment", - coords: [ - [ - [1, 1], - [0, 0], - ], - ], - }; - const rubric: PerseusInteractiveGraphRubric = createRubric({ - type: "segment", - coords: [ - [ - [0, 0], - [1, 1], - ], - ], - }); - - InteractiveGraph.widget.validate(guess, rubric); - - expect(guess.coords).toEqual([ - [ - [1, 1], - [0, 0], - ], - ]); - }); - - it("does not modify the `rubric` data", () => { - const guess: PerseusGraphType = { - type: "segment", - coords: [ - [ - [1, 1], - [0, 0], - ], - ], - }; - const rubric: PerseusInteractiveGraphRubric = createRubric({ - type: "segment", - coords: [ - [ - [1, 1], - [0, 0], - ], - ], - }); - - InteractiveGraph.widget.validate(guess, rubric); - - // Narrow the type of `rubric.correct` to segment graph; otherwise TS - // thinks it might not have a `coords` property. - invariant(rubric.correct.type === "segment"); - expect(rubric.correct.coords).toEqual([ - [ - [1, 1], - [0, 0], - ], - ]); - }); -}); - -describe("InteractiveGraph.validate on a point question", () => { - it("marks the answer invalid if guess.coords is missing", () => { - const guess: PerseusGraphType = {type: "point"}; - const rubric: PerseusInteractiveGraphRubric = createRubric({ - type: "point", - coords: [[0, 0]], - }); - - const result = InteractiveGraph.widget.validate(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. - const guess: PerseusGraphType = { - type: "point", - coords: [[0, 0]], - }; - const rubric: PerseusInteractiveGraphRubric = createRubric({ - type: "point", - }); - - expect(() => - InteractiveGraph.widget.validate(guess, rubric), - ).toThrowError(); - }); - - it("does not award points if guess.coords is wrong", () => { - const guess: PerseusGraphType = { - type: "point", - coords: [[9, 9]], - }; - const rubric: PerseusInteractiveGraphRubric = createRubric({ - type: "point", - coords: [[0, 0]], - }); - - const result = InteractiveGraph.widget.validate(guess, rubric); - - expect(result).toHaveBeenAnsweredIncorrectly(); - }); - - it("awards points if guess.coords is right", () => { - const guess: PerseusGraphType = { - type: "point", - coords: [[7, 8]], - }; - const rubric: PerseusInteractiveGraphRubric = createRubric({ - type: "point", - coords: [[7, 8]], - }); - - const result = InteractiveGraph.widget.validate(guess, rubric); - - expect(result).toEqual({ - type: "points", - earned: 1, - total: 1, - message: null, - }); - }); - - it("allows points to be specified in any order", () => { - const guess: PerseusGraphType = { - type: "point", - coords: [ - [7, 8], - [5, 6], - ], - }; - const rubric: PerseusInteractiveGraphRubric = createRubric({ - type: "point", - coords: [ - [5, 6], - [7, 8], - ], - }); - - const result = InteractiveGraph.widget.validate(guess, rubric); - - expect(result).toHaveBeenAnsweredCorrectly(); - }); - - it("does not modify the `guess` data", () => { - const guess: PerseusGraphType = { - type: "point", - coords: [ - [7, 8], - [5, 6], - ], - }; - const rubric: PerseusInteractiveGraphRubric = createRubric({ - type: "point", - coords: [ - [5, 6], - [7, 8], - ], - }); - - const guessClone = clone(guess); - - InteractiveGraph.widget.validate(guess, rubric); - - expect(guess).toEqual(guessClone); - }); - - it("does not modify the `rubric` data", () => { - const guess: PerseusGraphType = { - type: "point", - coords: [ - [7, 8], - [5, 6], - ], - }; - const rubric: PerseusInteractiveGraphRubric = createRubric({ - type: "point", - coords: [ - [5, 6], - [7, 8], - ], - }); - - const rubricClone = clone(rubric); - - InteractiveGraph.widget.validate(guess, rubric); - - expect(rubric).toEqual(rubricClone); - }); -}); describe("shouldUseMafs", () => { it("is false given no mafs flags", () => { diff --git a/packages/perseus/src/widgets/interactive-graph.tsx b/packages/perseus/src/widgets/interactive-graph.tsx index c7b3634553..8886012c28 100644 --- a/packages/perseus/src/widgets/interactive-graph.tsx +++ b/packages/perseus/src/widgets/interactive-graph.tsx @@ -14,7 +14,6 @@ import Util from "../util"; import KhanColors from "../util/colors"; import { angleMeasures, - canonicalSineCoefficients, ccw, collinear, getLineEquation, @@ -24,7 +23,6 @@ import { magnitude, rotate, sign, - similar, vector, } from "../util/geometry"; import GraphUtils from "../util/graph-utils"; @@ -32,6 +30,7 @@ import {polar} from "../util/graphie"; import {getInteractiveBoxFromSizeClass} from "../util/sizing-utils"; import {StatefulMafsGraph} from "./interactive-graphs"; +import interactiveGraphValidator from "./interactive-graphs/interactive-graph-validator"; import type {StatefulMafsGraphType} from "./interactive-graphs/stateful-mafs-graph"; import type {QuadraticGraphState} from "./interactive-graphs/types"; @@ -68,7 +67,6 @@ const defaultBackgroundImage = { }; const eq = Util.eq; -const deepEq = Util.deepEq; const UNLIMITED = "unlimited" as const; @@ -135,6 +133,57 @@ type DefaultProps = { graph: Props["graph"]; }; +// TODO: there's another, very similar getSinusoidCoefficients function +// they should probably be merged +export function getSinusoidCoefficients( + coords: ReadonlyArray, +): SineCoefficient { + // It's assumed that p1 is the root and p2 is the first peak + const p1 = coords[0]; + const p2 = coords[1]; + + // Resulting coefficients are canonical for this sine curve + const amplitude = p2[1] - p1[1]; + const angularFrequency = Math.PI / (2 * (p2[0] - p1[0])); + const phase = p1[0] * angularFrequency; + const verticalOffset = p1[1]; + + return [amplitude, angularFrequency, phase, verticalOffset]; +} + +// TODO: there's another, very similar getQuadraticCoefficients function +// they should probably be merged +export function getQuadraticCoefficients( + coords: ReadonlyArray, +): QuadraticCoefficient { + const p1 = coords[0]; + const p2 = coords[1]; + const p3 = coords[2]; + + const denom = (p1[0] - p2[0]) * (p1[0] - p3[0]) * (p2[0] - p3[0]); + if (denom === 0) { + // Many of the callers assume that the return value is always defined. + // @ts-expect-error - TS2322 - Type 'undefined' is not assignable to type 'QuadraticCoefficient'. + return; + } + const a = + (p3[0] * (p2[1] - p1[1]) + + p2[0] * (p1[1] - p3[1]) + + p1[0] * (p3[1] - p2[1])) / + denom; + const b = + (p3[0] * p3[0] * (p1[1] - p2[1]) + + p2[0] * p2[0] * (p3[1] - p1[1]) + + p1[0] * p1[0] * (p2[1] - p3[1])) / + denom; + const c = + (p2[0] * p3[0] * (p2[0] - p3[0]) * p1[1] + + p3[0] * p1[0] * (p3[0] - p1[0]) * p2[1] + + p1[0] * p2[0] * (p1[0] - p2[0]) * p3[1]) / + denom; + return [a, b, c]; +} + // (LEMS-2190): Move the Mafs Angle Graph coordinate reversal logic in interactive-graph-state.ts // to this file when we remove the legacy graph. This logic allows us to support bi-directional angles // for the new (non-reflexive) Mafs graphs, while maintaining the same scoring behaviour as the legacy graph. @@ -1680,7 +1729,7 @@ class LegacyInteractiveGraph extends React.Component { } simpleValidate(rubric: PerseusInteractiveGraphRubric) { - return InteractiveGraph.validate(this.getUserInput(), rubric); + return interactiveGraphValidator(this.getUserInput(), rubric); } focus: () => void = $.noop; @@ -1790,7 +1839,7 @@ class InteractiveGraph extends React.Component { } simpleValidate(rubric: PerseusInteractiveGraphRubric) { - return InteractiveGraph.validate(this.getUserInput(), rubric); + return interactiveGraphValidator(this.getUserInput(), rubric); } render() { @@ -1824,53 +1873,6 @@ class InteractiveGraph extends React.Component { ); } - static getQuadraticCoefficients( - coords: ReadonlyArray, - ): QuadraticCoefficient { - const p1 = coords[0]; - const p2 = coords[1]; - const p3 = coords[2]; - - const denom = (p1[0] - p2[0]) * (p1[0] - p3[0]) * (p2[0] - p3[0]); - if (denom === 0) { - // Many of the callers assume that the return value is always defined. - // @ts-expect-error - TS2322 - Type 'undefined' is not assignable to type 'QuadraticCoefficient'. - return; - } - const a = - (p3[0] * (p2[1] - p1[1]) + - p2[0] * (p1[1] - p3[1]) + - p1[0] * (p3[1] - p2[1])) / - denom; - const b = - (p3[0] * p3[0] * (p1[1] - p2[1]) + - p2[0] * p2[0] * (p3[1] - p1[1]) + - p1[0] * p1[0] * (p2[1] - p3[1])) / - denom; - const c = - (p2[0] * p3[0] * (p2[0] - p3[0]) * p1[1] + - p3[0] * p1[0] * (p3[0] - p1[0]) * p2[1] + - p1[0] * p2[0] * (p1[0] - p2[0]) * p3[1]) / - denom; - return [a, b, c]; - } - - static getSinusoidCoefficients( - coords: ReadonlyArray, - ): SineCoefficient { - // It's assumed that p1 is the root and p2 is the first peak - const p1 = coords[0]; - const p2 = coords[1]; - - // Resulting coefficients are canonical for this sine curve - const amplitude = p2[1] - p1[1]; - const angularFrequency = Math.PI / (2 * (p2[0] - p1[0])); - const phase = p1[0] * angularFrequency; - const verticalOffset = p1[1]; - - return [amplitude, angularFrequency, phase, verticalOffset]; - } - /** * @param {object} graph Like props.graph or props.correct * @param {object} props of an InteractiveGraph instance @@ -2198,7 +2200,7 @@ class InteractiveGraph extends React.Component { // @ts-expect-error - TS2339 - Property 'coords' does not exist on type 'PerseusGraphType'. props.graph.coords || InteractiveGraph.defaultQuadraticCoords(props); - return InteractiveGraph.getQuadraticCoefficients(coords); + return getQuadraticCoefficients(coords); } static defaultQuadraticCoords(props: Props): QuadraticGraphState["coords"] { @@ -2227,7 +2229,7 @@ class InteractiveGraph extends React.Component { const coords = // @ts-expect-error - TS2339 - Property 'coords' does not exist on type 'PerseusGraphType'. props.graph.coords || InteractiveGraph.defaultSinusoidCoords(props); - return InteractiveGraph.getSinusoidCoefficients(coords); + return getSinusoidCoefficients(coords); } static defaultSinusoidCoords(props: Props): ReadonlyArray { @@ -2363,292 +2365,7 @@ class InteractiveGraph extends React.Component { userInput: PerseusGraphType, rubric: PerseusInteractiveGraphRubric, ): PerseusScore { - // None-type graphs are not graded - if (userInput.type === "none" && rubric.correct.type === "none") { - return { - type: "points", - earned: 0, - total: 0, - message: 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 (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 both of the guess points are on the correct line, it's - // correct. - if ( - // @ts-expect-error - TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'. - collinear(correct[0], correct[1], guess[0]) && - // @ts-expect-error - TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'. - 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 as ReadonlyArray< - ReadonlyArray - >; - - 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 - ) { - // If the parabola coefficients match, it's correct. - const guessCoeffs = this.getQuadraticCoefficients( - userInput.coords, - ); - const correctCoeffs = this.getQuadraticCoefficients( - // @ts-expect-error - TS2345 - Argument of type 'readonly Coord[] | undefined' is not assignable to parameter of type 'readonly Coord[]'. - rubric.correct.coords, - ); - if (deepEq(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 = this.getSinusoidCoefficients( - userInput.coords, - ); - const correctCoeffs = this.getSinusoidCoefficients( - // @ts-expect-error - TS2345 - Argument of type 'readonly Coord[] | undefined' is not assignable to parameter of type 'readonly Coord[]'. - rubric.correct.coords, - ); - - const canonicalGuessCoeffs = - canonicalSineCoefficients(guessCoeffs); - const canonicalCorrectCoeffs = - canonicalSineCoefficients(correctCoeffs); - // If the canonical coefficients match, it's correct. - if (deepEq(canonicalGuessCoeffs, canonicalCorrectCoeffs)) { - return { - type: "points", - earned: 1, - total: 1, - message: null, - }; - } - } else if ( - userInput.type === "circle" && - rubric.correct.type === "circle" - ) { - if ( - deepEq(userInput.center, rubric.correct.center) && - eq(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(); - // Everything's already rounded so we shouldn't need to do an - // eq() comparison but _.isEqual(0, -0) is false, so we'll use - // eq() anyway. The sort should be fine because it'll stringify - // it and -0 converted to a string is "0" - guess?.sort(); - // @ts-expect-error - TS2339 - Property 'sort' does not exist on type 'readonly Coord[]'. - correct.sort(); - if (deepEq(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: Array = userInput.coords?.slice(); - // @ts-expect-error - TS2322 - Type 'Coord[] | undefined' is not assignable to type 'Coord[]'. - const correct: Array = 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, knumber.DEFAULT_TOLERANCE); - } else if (rubric.correct.match === "approx") { - match = similar(guess, correct, 0.1); - } else { - /* exact */ - guess.sort(); - correct.sort(); - match = deepEq(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 = Util.deepClone(userInput.coords); - let correct = Util.deepClone(rubric.correct?.coords); - guess = _.invoke(guess, "sort").sort(); - // @ts-expect-error - TS2345 - Argument of type '(readonly Coord[])[] | undefined' is not assignable to parameter of type 'Collection'. - correct = _.invoke(correct, "sort").sort(); - if (deepEq(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 ( - // @ts-expect-error - TS2532 - Object is possibly 'undefined'. - deepEq(guess[0], correct[0]) && - // @ts-expect-error - TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'. - 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 guess = userInput.coords; - const correct = rubric.correct.coords; - - let match; - if (rubric.correct.match === "congruent") { - const angles = _.map([guess, correct], function (coords) { - const angle = GraphUtils.findAngle( - // @ts-expect-error - TS2532 - Object is possibly 'undefined'. - coords[2], - // @ts-expect-error - TS2532 - Object is possibly 'undefined'. - coords[0], - // @ts-expect-error - TS2532 - Object is possibly 'undefined'. - coords[1], - ); - return (angle + 360) % 360; - }); - // @ts-expect-error - TS2556 - A spread argument must either have a tuple type or be passed to a rest parameter. - match = eq(...angles); - } else { - /* exact */ - match = // @ts-expect-error - TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'. - deepEq(guess[1], correct[1]) && - // @ts-expect-error - TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'. - collinear(correct[1], correct[0], guess[0]) && - // @ts-expect-error - TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'. - collinear(correct[1], correct[2], guess[2]); - } - - if (match) { - return { - type: "points", - earned: 1, - total: 1, - message: null, - }; - } - } - } - - // 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, - }; - } - return { - type: "points", - earned: 0, - total: 1, - message: null, - }; + return interactiveGraphValidator(userInput, rubric); } static getUserInputFromProps(props: Props): PerseusGraphType { diff --git a/packages/perseus/src/widgets/interactive-graphs/interactive-graph-validator.test.ts b/packages/perseus/src/widgets/interactive-graphs/interactive-graph-validator.test.ts new file mode 100644 index 0000000000..22fb8046cc --- /dev/null +++ b/packages/perseus/src/widgets/interactive-graphs/interactive-graph-validator.test.ts @@ -0,0 +1,299 @@ +import invariant from "tiny-invariant"; + +import {clone} from "../../../../../testing/object-utils"; + +import interactiveGraphValidator from "./interactive-graph-validator"; + +import type {PerseusGraphType} from "../../perseus-types"; +import type {PerseusInteractiveGraphRubric} from "../../validation.types"; + +function createRubric(graph: PerseusGraphType): PerseusInteractiveGraphRubric { + return {graph, correct: graph}; +} + +describe("InteractiveGraph.validate on a segment question", () => { + it("marks the answer invalid if guess.coords is missing", () => { + const guess: PerseusGraphType = {type: "segment"}; + const rubric: PerseusInteractiveGraphRubric = createRubric({ + type: "segment", + coords: [ + [ + [0, 0], + [1, 1], + ], + ], + }); + + const result = interactiveGraphValidator(guess, rubric); + + expect(result).toHaveInvalidInput(); + }); + + it("does not award points if guess.coords is wrong", () => { + const guess: PerseusGraphType = { + type: "segment", + coords: [ + [ + [99, 0], + [1, 1], + ], + ], + }; + const rubric: PerseusInteractiveGraphRubric = createRubric({ + type: "segment", + coords: [ + [ + [0, 0], + [1, 1], + ], + ], + }); + + const result = interactiveGraphValidator(guess, rubric); + + expect(result).toHaveBeenAnsweredIncorrectly(); + }); + + it("awards points if guess.coords is right", () => { + const guess: PerseusGraphType = { + type: "segment", + coords: [ + [ + [0, 0], + [1, 1], + ], + ], + }; + const rubric: PerseusInteractiveGraphRubric = createRubric({ + type: "segment", + coords: [ + [ + [0, 0], + [1, 1], + ], + ], + }); + + const result = interactiveGraphValidator(guess, rubric); + + expect(result).toHaveBeenAnsweredCorrectly(); + }); + + it("allows points of a segment to be specified in reverse order", () => { + const guess: PerseusGraphType = { + type: "segment", + coords: [ + [ + [1, 1], + [0, 0], + ], + ], + }; + const rubric: PerseusInteractiveGraphRubric = createRubric({ + type: "segment", + coords: [ + [ + [0, 0], + [1, 1], + ], + ], + }); + + const result = interactiveGraphValidator(guess, rubric); + + expect(result).toHaveBeenAnsweredCorrectly(); + }); + + it("does not modify the `guess` data", () => { + const guess: PerseusGraphType = { + type: "segment", + coords: [ + [ + [1, 1], + [0, 0], + ], + ], + }; + const rubric: PerseusInteractiveGraphRubric = createRubric({ + type: "segment", + coords: [ + [ + [0, 0], + [1, 1], + ], + ], + }); + + interactiveGraphValidator(guess, rubric); + + expect(guess.coords).toEqual([ + [ + [1, 1], + [0, 0], + ], + ]); + }); + + it("does not modify the `rubric` data", () => { + const guess: PerseusGraphType = { + type: "segment", + coords: [ + [ + [1, 1], + [0, 0], + ], + ], + }; + const rubric: PerseusInteractiveGraphRubric = createRubric({ + type: "segment", + coords: [ + [ + [1, 1], + [0, 0], + ], + ], + }); + + interactiveGraphValidator(guess, rubric); + + // Narrow the type of `rubric.correct` to segment graph; otherwise TS + // thinks it might not have a `coords` property. + invariant(rubric.correct.type === "segment"); + expect(rubric.correct.coords).toEqual([ + [ + [1, 1], + [0, 0], + ], + ]); + }); +}); + +describe("InteractiveGraph.validate on a point question", () => { + it("marks the answer invalid if guess.coords is missing", () => { + const guess: PerseusGraphType = {type: "point"}; + const rubric: PerseusInteractiveGraphRubric = createRubric({ + type: "point", + coords: [[0, 0]], + }); + + const result = interactiveGraphValidator(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. + const guess: PerseusGraphType = { + type: "point", + coords: [[0, 0]], + }; + const rubric: PerseusInteractiveGraphRubric = createRubric({ + type: "point", + }); + + expect(() => interactiveGraphValidator(guess, rubric)).toThrowError(); + }); + + it("does not award points if guess.coords is wrong", () => { + const guess: PerseusGraphType = { + type: "point", + coords: [[9, 9]], + }; + const rubric: PerseusInteractiveGraphRubric = createRubric({ + type: "point", + coords: [[0, 0]], + }); + + const result = interactiveGraphValidator(guess, rubric); + + expect(result).toHaveBeenAnsweredIncorrectly(); + }); + + it("awards points if guess.coords is right", () => { + const guess: PerseusGraphType = { + type: "point", + coords: [[7, 8]], + }; + const rubric: PerseusInteractiveGraphRubric = createRubric({ + type: "point", + coords: [[7, 8]], + }); + + const result = interactiveGraphValidator(guess, rubric); + + expect(result).toEqual({ + type: "points", + earned: 1, + total: 1, + message: null, + }); + }); + + it("allows points to be specified in any order", () => { + const guess: PerseusGraphType = { + type: "point", + coords: [ + [7, 8], + [5, 6], + ], + }; + const rubric: PerseusInteractiveGraphRubric = createRubric({ + type: "point", + coords: [ + [5, 6], + [7, 8], + ], + }); + + const result = interactiveGraphValidator(guess, rubric); + + expect(result).toHaveBeenAnsweredCorrectly(); + }); + + it("does not modify the `guess` data", () => { + const guess: PerseusGraphType = { + type: "point", + coords: [ + [7, 8], + [5, 6], + ], + }; + const rubric: PerseusInteractiveGraphRubric = createRubric({ + type: "point", + coords: [ + [5, 6], + [7, 8], + ], + }); + + const guessClone = clone(guess); + + interactiveGraphValidator(guess, rubric); + + expect(guess).toEqual(guessClone); + }); + + it("does not modify the `rubric` data", () => { + const guess: PerseusGraphType = { + type: "point", + coords: [ + [7, 8], + [5, 6], + ], + }; + const rubric: PerseusInteractiveGraphRubric = createRubric({ + type: "point", + coords: [ + [5, 6], + [7, 8], + ], + }); + + const rubricClone = clone(rubric); + + interactiveGraphValidator(guess, rubric); + + expect(rubric).toEqual(rubricClone); + }); +}); diff --git a/packages/perseus/src/widgets/interactive-graphs/interactive-graph-validator.ts b/packages/perseus/src/widgets/interactive-graphs/interactive-graph-validator.ts new file mode 100644 index 0000000000..24ac5f90c9 --- /dev/null +++ b/packages/perseus/src/widgets/interactive-graphs/interactive-graph-validator.ts @@ -0,0 +1,313 @@ +import {number as knumber} from "@khanacademy/kmath"; +import _ from "underscore"; + +import Util from "../../util"; +import { + canonicalSineCoefficients, + collinear, + similar, +} from "../../util/geometry"; +import GraphUtils from "../../util/graph-utils"; +import { + getQuadraticCoefficients, + getSinusoidCoefficients, +} from "../interactive-graph"; + +import type {Coord} from "../../interactive2/types"; +import type {PerseusScore} from "../../types"; +import type { + PerseusInteractiveGraphRubric, + PerseusInteractiveGraphUserInput, +} from "../../validation.types"; + +const eq = Util.eq; +const deepEq = Util.deepEq; + +function interactiveGraphValidator( + userInput: PerseusInteractiveGraphUserInput, + rubric: PerseusInteractiveGraphRubric, +): PerseusScore { + // None-type graphs are not graded + if (userInput.type === "none" && rubric.correct.type === "none") { + return { + type: "points", + earned: 0, + total: 0, + message: 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 (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 both of the guess points are on the correct line, it's + // correct. + if ( + // @ts-expect-error - TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'. + collinear(correct[0], correct[1], guess[0]) && + // @ts-expect-error - TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'. + 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 as ReadonlyArray< + ReadonlyArray + >; + + 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 + ) { + // If the parabola coefficients match, it's correct. + const guessCoeffs = getQuadraticCoefficients(userInput.coords); + const correctCoeffs = getQuadraticCoefficients( + // @ts-expect-error - TS2345 - Argument of type 'readonly Coord[] | undefined' is not assignable to parameter of type 'readonly Coord[]'. + rubric.correct.coords, + ); + if (deepEq(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( + // @ts-expect-error - TS2345 - Argument of type 'readonly Coord[] | undefined' is not assignable to parameter of type 'readonly Coord[]'. + rubric.correct.coords, + ); + + const canonicalGuessCoeffs = canonicalSineCoefficients(guessCoeffs); + const canonicalCorrectCoeffs = + canonicalSineCoefficients(correctCoeffs); + // If the canonical coefficients match, it's correct. + if (deepEq(canonicalGuessCoeffs, canonicalCorrectCoeffs)) { + return { + type: "points", + earned: 1, + total: 1, + message: null, + }; + } + } else if ( + userInput.type === "circle" && + rubric.correct.type === "circle" + ) { + if ( + deepEq(userInput.center, rubric.correct.center) && + eq(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(); + // Everything's already rounded so we shouldn't need to do an + // eq() comparison but _.isEqual(0, -0) is false, so we'll use + // eq() anyway. The sort should be fine because it'll stringify + // it and -0 converted to a string is "0" + guess?.sort(); + // @ts-expect-error - TS2339 - Property 'sort' does not exist on type 'readonly Coord[]'. + correct.sort(); + if (deepEq(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: Array = userInput.coords?.slice(); + // @ts-expect-error - TS2322 - Type 'Coord[] | undefined' is not assignable to type 'Coord[]'. + const correct: Array = 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, knumber.DEFAULT_TOLERANCE); + } else if (rubric.correct.match === "approx") { + match = similar(guess, correct, 0.1); + } else { + /* exact */ + guess.sort(); + correct.sort(); + match = deepEq(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 = Util.deepClone(userInput.coords); + let correct = Util.deepClone(rubric.correct?.coords); + guess = _.invoke(guess, "sort").sort(); + // @ts-expect-error - TS2345 - Argument of type '(readonly Coord[])[] | undefined' is not assignable to parameter of type 'Collection'. + correct = _.invoke(correct, "sort").sort(); + if (deepEq(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 ( + // @ts-expect-error - TS2532 - Object is possibly 'undefined'. + deepEq(guess[0], correct[0]) && + // @ts-expect-error - TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'. + 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 guess = userInput.coords; + const correct = rubric.correct.coords; + + let match; + if (rubric.correct.match === "congruent") { + const angles = _.map([guess, correct], function (coords) { + const angle = GraphUtils.findAngle( + // @ts-expect-error - TS2532 - Object is possibly 'undefined'. + coords[2], + // @ts-expect-error - TS2532 - Object is possibly 'undefined'. + coords[0], + // @ts-expect-error - TS2532 - Object is possibly 'undefined'. + coords[1], + ); + return (angle + 360) % 360; + }); + // @ts-expect-error - TS2556 - A spread argument must either have a tuple type or be passed to a rest parameter. + match = eq(...angles); + } else { + /* exact */ + match = // @ts-expect-error - TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'. + deepEq(guess[1], correct[1]) && + // @ts-expect-error - TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'. + collinear(correct[1], correct[0], guess[0]) && + // @ts-expect-error - TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'. + collinear(correct[1], correct[2], guess[2]); + } + + if (match) { + return { + type: "points", + earned: 1, + total: 1, + message: null, + }; + } + } + } + + // 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, + }; + } + return { + type: "points", + earned: 0, + total: 1, + message: null, + }; +} + +export default interactiveGraphValidator;