diff --git a/.changeset/smooth-taxis-mate.md b/.changeset/smooth-taxis-mate.md new file mode 100644 index 0000000000..10221029c8 --- /dev/null +++ b/.changeset/smooth-taxis-mate.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": patch +--- + +Internal: convert backgroundImage dimensions to numbers during parsing. diff --git a/packages/perseus/src/util/parse-perseus-json/general-purpose-parsers/string-to-number.test.ts b/packages/perseus/src/util/parse-perseus-json/general-purpose-parsers/string-to-number.test.ts new file mode 100644 index 0000000000..bb75d46206 --- /dev/null +++ b/packages/perseus/src/util/parse-perseus-json/general-purpose-parsers/string-to-number.test.ts @@ -0,0 +1,40 @@ +import {success} from "../result"; + +import {stringToNumber} from "./string-to-number"; +import {ctx, parseFailureWith} from "./test-helpers"; + +describe("stringToNumber", () => { + it("accepts a number", () => { + expect(stringToNumber(42, ctx())).toEqual(success(42)); + }); + + it("accepts a numeric string", () => { + expect(stringToNumber("7", ctx())).toEqual(success(7)); + }); + + it("parses a decimal", () => { + expect(stringToNumber("5.5", ctx())).toEqual(success(5.5)); + }); + + it("parses a negative number", () => { + expect(stringToNumber("-2", ctx())).toEqual(success(-2)); + }); + + it("rejects the empty string", () => { + expect(stringToNumber("", ctx())).toEqual( + parseFailureWith({ + badValue: "", + expected: ["a number or numeric string"], + }), + ); + }); + + it("rejects a non-numeric string", () => { + expect(stringToNumber("3a", ctx())).toEqual( + parseFailureWith({ + badValue: "3a", + expected: ["a number or numeric string"], + }), + ); + }); +}); diff --git a/packages/perseus/src/util/parse-perseus-json/general-purpose-parsers/string-to-number.ts b/packages/perseus/src/util/parse-perseus-json/general-purpose-parsers/string-to-number.ts new file mode 100644 index 0000000000..0569035c4f --- /dev/null +++ b/packages/perseus/src/util/parse-perseus-json/general-purpose-parsers/string-to-number.ts @@ -0,0 +1,17 @@ +import type {PartialParser} from "../parser-types"; + +export const stringToNumber: PartialParser = ( + rawValue, + ctx, +) => { + if (typeof rawValue === "number") { + return ctx.success(rawValue); + } + + const parsedNumber = +rawValue; + if (rawValue === "" || isNaN(parsedNumber)) { + return ctx.failure("a number or numeric string", rawValue); + } + + return ctx.success(parsedNumber); +}; diff --git a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/perseus-image-background.ts b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/perseus-image-background.ts index ae8c21b164..248644ce53 100644 --- a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/perseus-image-background.ts +++ b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/perseus-image-background.ts @@ -3,21 +3,26 @@ import { number, object, optional, + pipeParsers, string, union, } from "../general-purpose-parsers"; +import {stringToNumber} from "../general-purpose-parsers/string-to-number"; import type {Parser} from "../parser-types"; import type {PerseusImageBackground} from "@khanacademy/perseus"; +const numericToNumber = pipeParsers(union(number).or(string).parser).then( + stringToNumber, +).parser; + export const parsePerseusImageBackground: Parser = object({ url: optional(nullable(string)), - width: optional(number), - height: optional(number), - top: optional(number), - left: optional(number), - bottom: optional(number), - // TODO(benchristel): convert scale to a number - scale: optional(union(number).or(string).parser), + width: optional(numericToNumber), + height: optional(numericToNumber), + top: optional(numericToNumber), + left: optional(numericToNumber), + bottom: optional(numericToNumber), + scale: optional(numericToNumber), }); diff --git a/packages/perseus/src/util/parse-perseus-json/regression-tests/__snapshots__/parse-perseus-json-snapshot.test.ts.snap b/packages/perseus/src/util/parse-perseus-json/regression-tests/__snapshots__/parse-perseus-json-snapshot.test.ts.snap index 1aa0ec1b21..a5b884de44 100644 --- a/packages/perseus/src/util/parse-perseus-json/regression-tests/__snapshots__/parse-perseus-json-snapshot.test.ts.snap +++ b/packages/perseus/src/util/parse-perseus-json/regression-tests/__snapshots__/parse-perseus-json-snapshot.test.ts.snap @@ -473,6 +473,166 @@ exports[`parseAndTypecheckPerseusItem correctly parses data/iframe-missing-stati } `; +exports[`parseAndTypecheckPerseusItem correctly parses data/interactive-graph-with-string-backgroundImage-left.json 1`] = ` +{ + "answer": undefined, + "answerArea": { + "calculator": false, + "chi2Table": false, + "periodicTable": false, + "tTable": false, + "zTable": false, + }, + "hints": [], + "itemDataVersion": { + "major": 0, + "minor": 1, + }, + "question": { + "content": "Ally is excited to compete in a $6$-mile race. The race organizers plotted the course on a coordinate map. The starting point is at $(4,3)$, and the ending point is at $(4,9)$. Ally's family decides to stand at $(4,6)$ on the map. + +**Plot the starting point, ending point, and place where Ally's family stands on the map.** + +[[☃ interactive-graph 1]] + +**How far along will Ally be in the race when she reaches her family?** +[[☃ radio 1]]", + "images": {}, + "metadata": undefined, + "widgets": { + "interactive-graph 1": { + "alignment": "default", + "graded": true, + "key": undefined, + "options": { + "backgroundImage": { + "bottom": 4, + "height": 0, + "left": 0, + "scale": 1, + "top": undefined, + "url": "", + "width": 0, + }, + "correct": { + "coord": undefined, + "coords": [ + [ + 4, + 3, + ], + [ + 4, + 6, + ], + [ + 4, + 9, + ], + ], + "numPoints": 3, + "startCoords": undefined, + "type": "point", + }, + "fullGraphAriaDescription": undefined, + "fullGraphLabel": undefined, + "graph": { + "coord": undefined, + "coords": undefined, + "numPoints": 3, + "startCoords": undefined, + "type": "point", + }, + "gridStep": [ + 1, + 1, + ], + "labels": [ + "x", + "y", + ], + "lockedFigures": undefined, + "markings": "graph", + "range": [ + [ + -1, + 10, + ], + [ + -1, + 10, + ], + ], + "rulerLabel": "", + "rulerTicks": 10, + "showProtractor": false, + "showRuler": false, + "showTooltips": false, + "snapStep": [ + 0.5, + 0.5, + ], + "step": [ + 1, + 1, + ], + }, + "static": false, + "type": "interactive-graph", + "version": { + "major": 0, + "minor": 0, + }, + }, + "radio 1": { + "alignment": "default", + "graded": true, + "key": undefined, + "options": { + "choices": [ + { + "clue": undefined, + "content": "Less than halfway through the race", + "correct": false, + "isNoneOfTheAbove": undefined, + "widgets": undefined, + }, + { + "clue": undefined, + "content": "Halfway through the race", + "correct": true, + "isNoneOfTheAbove": undefined, + "widgets": undefined, + }, + { + "clue": undefined, + "content": "More than halfway through the race", + "correct": undefined, + "isNoneOfTheAbove": undefined, + "widgets": undefined, + }, + ], + "countChoices": false, + "deselectEnabled": false, + "displayCount": null, + "hasNoneOfTheAbove": false, + "multipleSelect": false, + "noneOfTheAbove": undefined, + "onePerLine": undefined, + "randomize": false, + }, + "static": false, + "type": "radio", + "version": { + "major": 1, + "minor": 0, + }, + }, + }, + }, +} +`; + exports[`parseAndTypecheckPerseusItem correctly parses data/item-missing-answerArea.json 1`] = ` { "_multi": { diff --git a/packages/perseus/src/util/parse-perseus-json/regression-tests/data/interactive-graph-with-string-backgroundImage-left.json b/packages/perseus/src/util/parse-perseus-json/regression-tests/data/interactive-graph-with-string-backgroundImage-left.json new file mode 100644 index 0000000000..5e348cd967 --- /dev/null +++ b/packages/perseus/src/util/parse-perseus-json/regression-tests/data/interactive-graph-with-string-backgroundImage-left.json @@ -0,0 +1,125 @@ +{ + "question": { + "content": "Ally is excited to compete in a $6$-mile race. The race organizers plotted the course on a coordinate map. The starting point is at $(4,3)$, and the ending point is at $(4,9)$. Ally's family decides to stand at $(4,6)$ on the map.\n\n**Plot the starting point, ending point, and place where Ally's family stands on the map.**\n\n[[☃ interactive-graph 1]]\n\n**How far along will Ally be in the race when she reaches her family?** \n[[☃ radio 1]]", + "images": {}, + "widgets": { + "interactive-graph 1": { + "type": "interactive-graph", + "alignment": "default", + "static": false, + "graded": true, + "options": { + "step": [ + 1, + 1 + ], + "backgroundImage": { + "url": "", + "scale": "1", + "bottom": "4", + "left": "0", + "width": 0, + "height": 0 + }, + "markings": "graph", + "labels": [ + "x", + "y" + ], + "showProtractor": false, + "showRuler": false, + "showTooltips": false, + "rulerLabel": "", + "rulerTicks": 10, + "range": [ + [ + -1, + 10 + ], + [ + -1, + 10 + ] + ], + "gridStep": [ + 1, + 1 + ], + "snapStep": [ + 0.5, + 0.5 + ], + "graph": { + "type": "point", + "numPoints": 3 + }, + "correct": { + "type": "point", + "coords": [ + [ + 4, + 3 + ], + [ + 4, + 6 + ], + [ + 4, + 9 + ] + ], + "numPoints": 3 + } + }, + "version": { + "major": 0, + "minor": 0 + } + }, + "radio 1": { + "type": "radio", + "alignment": "default", + "static": false, + "graded": true, + "options": { + "choices": [ + { + "correct": false, + "content": "Less than halfway through the race" + }, + { + "correct": true, + "content": "Halfway through the race" + }, + { + "content": "More than halfway through the race" + } + ], + "randomize": false, + "multipleSelect": false, + "countChoices": false, + "displayCount": null, + "hasNoneOfTheAbove": false, + "deselectEnabled": false + }, + "version": { + "major": 1, + "minor": 0 + } + } + } + }, + "answerArea": { + "calculator": false, + "chi2Table": false, + "periodicTable": false, + "tTable": false, + "zTable": false + }, + "itemDataVersion": { + "major": 0, + "minor": 1 + }, + "hints": [] +}