From e4d0aaecc2f7159172948ba3c40e2b83e6abe9e1 Mon Sep 17 00:00:00 2001 From: Ben Christel Date: Tue, 7 Jan 2025 09:55:08 -0800 Subject: [PATCH 01/18] Use discriminated union instead of union for locked figures parser --- .../perseus-parsers/interactive-graph-widget.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/interactive-graph-widget.ts b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/interactive-graph-widget.ts index a71209ed37..3bc3ebcb36 100644 --- a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/interactive-graph-widget.ts +++ b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/interactive-graph-widget.ts @@ -266,13 +266,14 @@ const parseLockedFunctionType: Parser = object({ ariaLabel: optional(string), }); -const parseLockedFigure: Parser = union(parseLockedPointType) - .or(parseLockedLineType) - .or(parseLockedVectorType) - .or(parseLockedEllipseType) - .or(parseLockedPolygonType) - .or(parseLockedFunctionType) - .or(parseLockedLabelType).parser; +const parseLockedFigure: Parser = discriminatedUnionOn("type") + .withBranch("point", parseLockedPointType) + .withBranch("line", parseLockedLineType) + .withBranch("vector", parseLockedVectorType) + .withBranch("ellipse", parseLockedEllipseType) + .withBranch("polygon", parseLockedPolygonType) + .withBranch("function", parseLockedFunctionType) + .withBranch("label", parseLockedLabelType).parser; export const parseInteractiveGraphWidget: Parser = parseWidget( From be7f5501b47cf38cf43d5e151d6aed63f33b62fc Mon Sep 17 00:00:00 2001 From: Ben Christel Date: Tue, 7 Jan 2025 10:00:38 -0800 Subject: [PATCH 02/18] Default LockedLine.showPoint1 and showPoint2 to false --- .../interactive-graph-widget.ts | 4 +- ...-graph-locked-line-missing-showPoint1.json | 712 ++++++++++++++++++ 2 files changed, 714 insertions(+), 2 deletions(-) create mode 100644 packages/perseus/src/util/parse-perseus-json/regression-tests/data/interactive-graph-locked-line-missing-showPoint1.json diff --git a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/interactive-graph-widget.ts b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/interactive-graph-widget.ts index 3bc3ebcb36..0de2d6f30c 100644 --- a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/interactive-graph-widget.ts +++ b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/interactive-graph-widget.ts @@ -213,8 +213,8 @@ const parseLockedLineType: Parser = object({ points: pair(parseLockedPointType, parseLockedPointType), color: parseLockedFigureColor, lineStyle: parseLockedLineStyle, - showPoint1: boolean, - showPoint2: boolean, + showPoint1: defaulted(boolean, () => false), + showPoint2: defaulted(boolean, () => false), // TODO(benchristel): default labels to empty array? labels: optional(array(parseLockedLabelType)), ariaLabel: optional(string), diff --git a/packages/perseus/src/util/parse-perseus-json/regression-tests/data/interactive-graph-locked-line-missing-showPoint1.json b/packages/perseus/src/util/parse-perseus-json/regression-tests/data/interactive-graph-locked-line-missing-showPoint1.json new file mode 100644 index 0000000000..89b8bb015c --- /dev/null +++ b/packages/perseus/src/util/parse-perseus-json/regression-tests/data/interactive-graph-locked-line-missing-showPoint1.json @@ -0,0 +1,712 @@ +{ + "question": { + "content": "Custom Axis Labels:\n[[☃ interactive-graph 1]]\n\nLarge $y$-range, origin near bottom left:\n[[☃ interactive-graph 2]]\n\nLarge $x$-range, origin near left side:\n[[☃ interactive-graph 3]]\n\nFractional axis labels:\n[[☃ interactive-graph 4]]\n\nGridlines every two ticks:\n[[☃ interactive-graph 5]]\n\nGridlines every half tick:\n[[☃ interactive-graph 6]]\n\nNonsquare grid:\n[[☃ interactive-graph 7]]\n\nLocked figures:\n[[☃ interactive-graph 8]]\n", + "images": {}, + "widgets": { + "interactive-graph 1": { + "type": "interactive-graph", + "alignment": "default", + "static": false, + "graded": true, + "options": { + "step": [ + 1, + 1 + ], + "backgroundImage": { + "url": null + }, + "markings": "graph", + "labels": [ + "\\text{Re}", + "\\text{Im}" + ], + "showProtractor": false, + "showTooltips": false, + "range": [ + [ + -10, + 10 + ], + [ + -10, + 10 + ] + ], + "gridStep": [ + 1, + 1 + ], + "snapStep": [ + 0.5, + 0.5 + ], + "graph": { + "type": "segment", + "numSegments": 6 + }, + "correct": { + "type": "segment", + "numSegments": 6, + "coords": [ + [ + [ + -5, + 5 + ], + [ + 5, + 5 + ] + ], + [ + [ + -5, + 3 + ], + [ + 5, + 3 + ] + ], + [ + [ + -5, + 1 + ], + [ + 5, + 1 + ] + ], + [ + [ + -5, + -1 + ], + [ + 5, + -1 + ] + ], + [ + [ + -5, + -3 + ], + [ + 5, + -3 + ] + ], + [ + [ + -5, + -5 + ], + [ + 5, + -5 + ] + ] + ] + } + }, + "version": { + "major": 0, + "minor": 0 + } + }, + "interactive-graph 2": { + "type": "interactive-graph", + "alignment": "default", + "static": false, + "graded": true, + "options": { + "step": [ + 1, + 10 + ], + "backgroundImage": { + "url": null + }, + "markings": "graph", + "labels": [ + "x", + "y" + ], + "showProtractor": false, + "showTooltips": false, + "range": [ + [ + -0.7, + 8 + ], + [ + -10, + 100 + ] + ], + "gridStep": [ + 1, + 10 + ], + "snapStep": [ + 0.5, + 5 + ], + "graph": { + "type": "segment" + }, + "correct": { + "type": "segment", + "coords": [ + [ + [ + 1.5, + 70 + ], + [ + 5.5, + 70 + ] + ] + ] + } + }, + "version": { + "major": 0, + "minor": 0 + } + }, + "interactive-graph 3": { + "type": "interactive-graph", + "alignment": "default", + "static": false, + "graded": true, + "options": { + "step": [ + 20, + 1 + ], + "backgroundImage": { + "url": null, + "width": 0, + "height": 0 + }, + "markings": "graph", + "labels": [ + "x", + "y" + ], + "showProtractor": false, + "showTooltips": false, + "range": [ + [ + -10, + 100 + ], + [ + -10, + 10 + ] + ], + "gridStep": [ + 5, + 1 + ], + "snapStep": [ + 2.5, + 0.5 + ], + "graph": { + "type": "segment" + }, + "correct": { + "type": "segment" + } + }, + "version": { + "major": 0, + "minor": 0 + } + }, + "interactive-graph 4": { + "type": "interactive-graph", + "alignment": "default", + "static": false, + "graded": true, + "options": { + "step": [ + 0.5, + 0.5 + ], + "backgroundImage": { + "url": null + }, + "markings": "graph", + "labels": [ + "x", + "y" + ], + "showProtractor": false, + "showTooltips": false, + "range": [ + [ + -3, + 3 + ], + [ + -3, + 3 + ] + ], + "gridStep": [ + 0.5, + 0.5 + ], + "snapStep": [ + 0.25, + 0.25 + ], + "graph": { + "type": "segment" + }, + "correct": { + "type": "segment" + } + }, + "version": { + "major": 0, + "minor": 0 + } + }, + "interactive-graph 5": { + "type": "interactive-graph", + "alignment": "default", + "static": false, + "graded": true, + "options": { + "step": [ + 1, + 1 + ], + "backgroundImage": { + "url": null + }, + "markings": "graph", + "labels": [ + "x", + "y" + ], + "showProtractor": false, + "showTooltips": false, + "range": [ + [ + -10, + 10 + ], + [ + -10, + 10 + ] + ], + "gridStep": [ + 2, + 2 + ], + "snapStep": [ + 1, + 1 + ], + "graph": { + "type": "segment" + }, + "correct": { + "type": "segment" + } + }, + "version": { + "major": 0, + "minor": 0 + } + }, + "interactive-graph 6": { + "type": "interactive-graph", + "alignment": "default", + "static": false, + "graded": true, + "options": { + "step": [ + 1, + 1 + ], + "backgroundImage": { + "url": null + }, + "markings": "graph", + "labels": [ + "x", + "y" + ], + "showProtractor": false, + "showTooltips": false, + "range": [ + [ + -5, + 5 + ], + [ + -5, + 5 + ] + ], + "gridStep": [ + 0.5, + 0.5 + ], + "snapStep": [ + 0.25, + 0.25 + ], + "graph": { + "type": "segment" + }, + "correct": { + "type": "segment" + } + }, + "version": { + "major": 0, + "minor": 0 + } + }, + "interactive-graph 7": { + "type": "interactive-graph", + "alignment": "default", + "static": false, + "graded": true, + "options": { + "step": [ + 1, + 1 + ], + "backgroundImage": { + "url": null + }, + "markings": "graph", + "labels": [ + "x", + "y" + ], + "showProtractor": false, + "showTooltips": false, + "range": [ + [ + -5, + 5 + ], + [ + -5, + 5 + ] + ], + "gridStep": [ + 2, + 0.5 + ], + "snapStep": [ + 1, + 0.25 + ], + "graph": { + "type": "segment" + }, + "correct": { + "type": "segment" + } + }, + "version": { + "major": 0, + "minor": 0 + } + }, + "interactive-graph 8": { + "type": "interactive-graph", + "alignment": "default", + "static": false, + "graded": true, + "options": { + "step": [ + 2, + 2 + ], + "backgroundImage": { + "url": null + }, + "markings": "graph", + "labels": [ + "x", + "y" + ], + "showProtractor": false, + "showTooltips": false, + "range": [ + [ + -10, + 10 + ], + [ + -10, + 10 + ] + ], + "gridStep": [ + 1, + 1 + ], + "snapStep": [ + 0.5, + 0.5 + ], + "lockedFigures": [ + { + "type": "point", + "coord": [ + -1, + 5 + ], + "color": "green", + "filled": true + }, + { + "type": "point", + "coord": [ + 1, + 5 + ], + "color": "grayH", + "filled": false + }, + { + "type": "line", + "kind": "line", + "points": [ + { + "type": "point", + "coord": [ + 0, + 1 + ], + "color": "grayH", + "filled": true + }, + { + "type": "point", + "coord": [ + 5, + 2 + ], + "color": "grayH", + "filled": true + } + ], + "color": "grayH", + "lineStyle": "solid", + "showStartPoint": false, + "showEndPoint": false + }, + { + "type": "line", + "kind": "line", + "points": [ + { + "type": "point", + "coord": [ + 0, + 0 + ], + "color": "grayH", + "filled": true + }, + { + "type": "point", + "coord": [ + 5, + 1 + ], + "color": "grayH", + "filled": false + } + ], + "color": "grayH", + "lineStyle": "dashed", + "showStartPoint": true, + "showEndPoint": true + }, + { + "type": "line", + "kind": "ray", + "points": [ + { + "type": "point", + "coord": [ + 0, + -1 + ], + "color": "pink", + "filled": true + }, + { + "type": "point", + "coord": [ + 5, + 0 + ], + "color": "pink", + "filled": true + } + ], + "color": "pink", + "lineStyle": "solid", + "showStartPoint": false, + "showEndPoint": false + }, + { + "type": "line", + "kind": "ray", + "points": [ + { + "type": "point", + "coord": [ + 0, + -2 + ], + "color": "purple", + "filled": true + }, + { + "type": "point", + "coord": [ + 5, + -1 + ], + "color": "pink", + "filled": false + } + ], + "color": "pink", + "lineStyle": "dashed", + "showStartPoint": true, + "showEndPoint": true + }, + { + "type": "line", + "kind": "segment", + "points": [ + { + "type": "point", + "coord": [ + 0, + -3 + ], + "color": "red", + "filled": true + }, + { + "type": "point", + "coord": [ + 5, + -2 + ], + "color": "red", + "filled": true + } + ], + "color": "red", + "lineStyle": "solid", + "showStartPoint": false, + "showEndPoint": false + }, + { + "type": "line", + "kind": "segment", + "points": [ + { + "type": "point", + "coord": [ + 0, + -4 + ], + "color": "green", + "filled": true + }, + { + "type": "point", + "coord": [ + 5, + -3 + ], + "color": "red", + "filled": false + } + ], + "color": "red", + "lineStyle": "dashed", + "showStartPoint": true, + "showEndPoint": true + }, + { + "type": "label", + "coord": [ + -6, + 0 + ], + "text": "\\frac{1}{4}?", + "color": "blue", + "size": "medium" + } + ], + "graph": { + "type": "segment" + }, + "correct": { + "type": "segment", + "hasBeenInteractedWith": true, + "range": [ + [ + -10, + 10 + ], + [ + -10, + 10 + ] + ], + "snapStep": [ + 0.5, + 0.5 + ], + "markings": "graph", + "coords": [ + [ + [ + -5, + -5 + ], + [ + 5, + 5 + ] + ] + ] + } + }, + "version": { + "major": 0, + "minor": 0 + } + } + } + } +} From f6ae4f9a081f3b012dc452e5f2e8b57bf97cdb42 Mon Sep 17 00:00:00 2001 From: Ben Christel Date: Tue, 7 Jan 2025 12:25:11 -0800 Subject: [PATCH 03/18] Allow null coords in grapher widget --- packages/perseus-core/src/data-schema.ts | 36 +- .../perseus-parsers/grapher-widget.ts | 21 +- .../parse-perseus-json-snapshot.test.ts.snap | 966 +++++++++++++++++- .../src/widgets/grapher/score-grapher.test.ts | 31 + .../src/widgets/grapher/score-grapher.ts | 9 + 5 files changed, 1024 insertions(+), 39 deletions(-) diff --git a/packages/perseus-core/src/data-schema.ts b/packages/perseus-core/src/data-schema.ts index 62d7162177..ac5481dcdc 100644 --- a/packages/perseus-core/src/data-schema.ts +++ b/packages/perseus-core/src/data-schema.ts @@ -550,11 +550,9 @@ export type GraphRange = [ export type GrapherAnswerTypes = | { type: "absolute_value"; - coords: [ - // The vertex - Coord, // A point along one line of the absolute value "V" lines - Coord, - ]; + // If `coords` is null, the graph will not be gradable. All answers + // will be scored as invalid. + coords: null | [vertex: Coord, secondPoint: Coord]; } | { type: "exponential"; @@ -563,12 +561,16 @@ export type GrapherAnswerTypes = asymptote: [Coord, Coord]; // Two points along the exponential curve. One end of the curve // trends towards the asymptote. - coords: [Coord, Coord]; + // If `coords` is null, the graph will not be gradable. All answers + // will be scored as invalid. + coords: null | [Coord, Coord]; } | { type: "linear"; // Two points along the straight line - coords: [Coord, Coord]; + // If coords is null, the graph will not be gradable. All answers + // will be scored as invalid. + coords: null | [Coord, Coord]; } | { type: "logarithm"; @@ -576,25 +578,29 @@ export type GrapherAnswerTypes = asymptote: [Coord, Coord]; // Two points along the logarithmic curve. One end of the curve // trends towards the asymptote. - coords: [Coord, Coord]; + // If coords is null, the graph will not be gradable. All answers + // will be scored as invalid. + coords: null | [Coord, Coord]; } | { type: "quadratic"; - coords: [ - // The vertex of the parabola - Coord, // A point along the parabola - Coord, - ]; + // If coords is null, the graph will not be gradable. All answers + // will be scored as invalid. + coords: null | [vertex: Coord, secondPoint: Coord]; } | { type: "sinusoid"; // Two points on the same slope in the sinusoid wave line. - coords: [Coord, Coord]; + // If coords is null, the graph will not be gradable. All answers + // will be scored as invalid. + coords: null | [Coord, Coord]; } | { type: "tangent"; // Two points on the same slope in the tangent wave line. - coords: [Coord, Coord]; + // If coords is null, the graph will not be gradable. All answers + // will be scored as invalid. + coords: null | [Coord, Coord]; }; export type PerseusGrapherWidgetOptions = { diff --git a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/grapher-widget.ts b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/grapher-widget.ts index 01b5256b03..4e4c8c3285 100644 --- a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/grapher-widget.ts +++ b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/grapher-widget.ts @@ -42,7 +42,7 @@ export const parseGrapherWidget: Parser = parseWidget( "absolute_value", object({ type: constant("absolute_value"), - coords: pairOfPoints, + coords: nullable(pairOfPoints), }), ) .withBranch( @@ -50,21 +50,14 @@ export const parseGrapherWidget: Parser = parseWidget( object({ type: constant("exponential"), asymptote: pairOfPoints, - coords: pairOfPoints, + coords: nullable(pairOfPoints), }), ) .withBranch( "linear", object({ type: constant("linear"), - coords: defaulted( - pairOfPoints, - () => - [ - [-5, 5], - [5, 5], - ] as [[number, number], [number, number]], - ), + coords: nullable(pairOfPoints), }), ) .withBranch( @@ -72,28 +65,28 @@ export const parseGrapherWidget: Parser = parseWidget( object({ type: constant("logarithm"), asymptote: pairOfPoints, - coords: pairOfPoints, + coords: nullable(pairOfPoints), }), ) .withBranch( "quadratic", object({ type: constant("quadratic"), - coords: pairOfPoints, + coords: nullable(pairOfPoints), }), ) .withBranch( "sinusoid", object({ type: constant("sinusoid"), - coords: pairOfPoints, + coords: nullable(pairOfPoints), }), ) .withBranch( "tangent", object({ type: constant("tangent"), - coords: pairOfPoints, + coords: nullable(pairOfPoints), }), ).parser, graph: object({ 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 4f6092c9c4..2b8ca68e98 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 @@ -1292,16 +1292,7 @@ exports[`parseAndTypecheckPerseusItem correctly parses data/grapher-with-null-co ], "correct": { "asymptote": null, - "coords": [ - [ - -5, - 5, - ], - [ - 5, - 5, - ], - ], + "coords": null, "type": "linear", }, "graph": { @@ -2257,6 +2248,961 @@ $3$ | $8$ } `; +exports[`parseAndTypecheckPerseusItem correctly parses data/interactive-graph-locked-line-missing-showPoint1.json 1`] = ` +{ + "answer": undefined, + "answerArea": {}, + "hints": [], + "itemDataVersion": undefined, + "question": { + "content": "Custom Axis Labels: +[[☃ interactive-graph 1]] + +Large $y$-range, origin near bottom left: +[[☃ interactive-graph 2]] + +Large $x$-range, origin near left side: +[[☃ interactive-graph 3]] + +Fractional axis labels: +[[☃ interactive-graph 4]] + +Gridlines every two ticks: +[[☃ interactive-graph 5]] + +Gridlines every half tick: +[[☃ interactive-graph 6]] + +Nonsquare grid: +[[☃ interactive-graph 7]] + +Locked figures: +[[☃ interactive-graph 8]] +", + "images": {}, + "metadata": undefined, + "widgets": { + "interactive-graph 1": { + "alignment": "default", + "graded": true, + "key": undefined, + "options": { + "backgroundImage": { + "bottom": undefined, + "height": undefined, + "left": undefined, + "scale": undefined, + "top": undefined, + "url": null, + "width": undefined, + }, + "correct": { + "coord": undefined, + "coords": [ + [ + [ + -5, + 5, + ], + [ + 5, + 5, + ], + ], + [ + [ + -5, + 3, + ], + [ + 5, + 3, + ], + ], + [ + [ + -5, + 1, + ], + [ + 5, + 1, + ], + ], + [ + [ + -5, + -1, + ], + [ + 5, + -1, + ], + ], + [ + [ + -5, + -3, + ], + [ + 5, + -3, + ], + ], + [ + [ + -5, + -5, + ], + [ + 5, + -5, + ], + ], + ], + "numSegments": 6, + "startCoords": undefined, + "type": "segment", + }, + "fullGraphAriaDescription": undefined, + "fullGraphLabel": undefined, + "graph": { + "coord": undefined, + "coords": undefined, + "numSegments": 6, + "startCoords": undefined, + "type": "segment", + }, + "gridStep": [ + 1, + 1, + ], + "labels": [ + "\\text{Re}", + "\\text{Im}", + ], + "lockedFigures": undefined, + "markings": "graph", + "range": [ + [ + -10, + 10, + ], + [ + -10, + 10, + ], + ], + "rulerLabel": undefined, + "rulerTicks": undefined, + "showProtractor": false, + "showRuler": undefined, + "showTooltips": false, + "snapStep": [ + 0.5, + 0.5, + ], + "step": [ + 1, + 1, + ], + }, + "static": false, + "type": "interactive-graph", + "version": { + "major": 0, + "minor": 0, + }, + }, + "interactive-graph 2": { + "alignment": "default", + "graded": true, + "key": undefined, + "options": { + "backgroundImage": { + "bottom": undefined, + "height": undefined, + "left": undefined, + "scale": undefined, + "top": undefined, + "url": null, + "width": undefined, + }, + "correct": { + "coord": undefined, + "coords": [ + [ + [ + 1.5, + 70, + ], + [ + 5.5, + 70, + ], + ], + ], + "numSegments": undefined, + "startCoords": undefined, + "type": "segment", + }, + "fullGraphAriaDescription": undefined, + "fullGraphLabel": undefined, + "graph": { + "coord": undefined, + "coords": undefined, + "numSegments": undefined, + "startCoords": undefined, + "type": "segment", + }, + "gridStep": [ + 1, + 10, + ], + "labels": [ + "x", + "y", + ], + "lockedFigures": undefined, + "markings": "graph", + "range": [ + [ + -0.7, + 8, + ], + [ + -10, + 100, + ], + ], + "rulerLabel": undefined, + "rulerTicks": undefined, + "showProtractor": false, + "showRuler": undefined, + "showTooltips": false, + "snapStep": [ + 0.5, + 5, + ], + "step": [ + 1, + 10, + ], + }, + "static": false, + "type": "interactive-graph", + "version": { + "major": 0, + "minor": 0, + }, + }, + "interactive-graph 3": { + "alignment": "default", + "graded": true, + "key": undefined, + "options": { + "backgroundImage": { + "bottom": undefined, + "height": 0, + "left": undefined, + "scale": undefined, + "top": undefined, + "url": null, + "width": 0, + }, + "correct": { + "coord": undefined, + "coords": undefined, + "numSegments": undefined, + "startCoords": undefined, + "type": "segment", + }, + "fullGraphAriaDescription": undefined, + "fullGraphLabel": undefined, + "graph": { + "coord": undefined, + "coords": undefined, + "numSegments": undefined, + "startCoords": undefined, + "type": "segment", + }, + "gridStep": [ + 5, + 1, + ], + "labels": [ + "x", + "y", + ], + "lockedFigures": undefined, + "markings": "graph", + "range": [ + [ + -10, + 100, + ], + [ + -10, + 10, + ], + ], + "rulerLabel": undefined, + "rulerTicks": undefined, + "showProtractor": false, + "showRuler": undefined, + "showTooltips": false, + "snapStep": [ + 2.5, + 0.5, + ], + "step": [ + 20, + 1, + ], + }, + "static": false, + "type": "interactive-graph", + "version": { + "major": 0, + "minor": 0, + }, + }, + "interactive-graph 4": { + "alignment": "default", + "graded": true, + "key": undefined, + "options": { + "backgroundImage": { + "bottom": undefined, + "height": undefined, + "left": undefined, + "scale": undefined, + "top": undefined, + "url": null, + "width": undefined, + }, + "correct": { + "coord": undefined, + "coords": undefined, + "numSegments": undefined, + "startCoords": undefined, + "type": "segment", + }, + "fullGraphAriaDescription": undefined, + "fullGraphLabel": undefined, + "graph": { + "coord": undefined, + "coords": undefined, + "numSegments": undefined, + "startCoords": undefined, + "type": "segment", + }, + "gridStep": [ + 0.5, + 0.5, + ], + "labels": [ + "x", + "y", + ], + "lockedFigures": undefined, + "markings": "graph", + "range": [ + [ + -3, + 3, + ], + [ + -3, + 3, + ], + ], + "rulerLabel": undefined, + "rulerTicks": undefined, + "showProtractor": false, + "showRuler": undefined, + "showTooltips": false, + "snapStep": [ + 0.25, + 0.25, + ], + "step": [ + 0.5, + 0.5, + ], + }, + "static": false, + "type": "interactive-graph", + "version": { + "major": 0, + "minor": 0, + }, + }, + "interactive-graph 5": { + "alignment": "default", + "graded": true, + "key": undefined, + "options": { + "backgroundImage": { + "bottom": undefined, + "height": undefined, + "left": undefined, + "scale": undefined, + "top": undefined, + "url": null, + "width": undefined, + }, + "correct": { + "coord": undefined, + "coords": undefined, + "numSegments": undefined, + "startCoords": undefined, + "type": "segment", + }, + "fullGraphAriaDescription": undefined, + "fullGraphLabel": undefined, + "graph": { + "coord": undefined, + "coords": undefined, + "numSegments": undefined, + "startCoords": undefined, + "type": "segment", + }, + "gridStep": [ + 2, + 2, + ], + "labels": [ + "x", + "y", + ], + "lockedFigures": undefined, + "markings": "graph", + "range": [ + [ + -10, + 10, + ], + [ + -10, + 10, + ], + ], + "rulerLabel": undefined, + "rulerTicks": undefined, + "showProtractor": false, + "showRuler": undefined, + "showTooltips": false, + "snapStep": [ + 1, + 1, + ], + "step": [ + 1, + 1, + ], + }, + "static": false, + "type": "interactive-graph", + "version": { + "major": 0, + "minor": 0, + }, + }, + "interactive-graph 6": { + "alignment": "default", + "graded": true, + "key": undefined, + "options": { + "backgroundImage": { + "bottom": undefined, + "height": undefined, + "left": undefined, + "scale": undefined, + "top": undefined, + "url": null, + "width": undefined, + }, + "correct": { + "coord": undefined, + "coords": undefined, + "numSegments": undefined, + "startCoords": undefined, + "type": "segment", + }, + "fullGraphAriaDescription": undefined, + "fullGraphLabel": undefined, + "graph": { + "coord": undefined, + "coords": undefined, + "numSegments": undefined, + "startCoords": undefined, + "type": "segment", + }, + "gridStep": [ + 0.5, + 0.5, + ], + "labels": [ + "x", + "y", + ], + "lockedFigures": undefined, + "markings": "graph", + "range": [ + [ + -5, + 5, + ], + [ + -5, + 5, + ], + ], + "rulerLabel": undefined, + "rulerTicks": undefined, + "showProtractor": false, + "showRuler": undefined, + "showTooltips": false, + "snapStep": [ + 0.25, + 0.25, + ], + "step": [ + 1, + 1, + ], + }, + "static": false, + "type": "interactive-graph", + "version": { + "major": 0, + "minor": 0, + }, + }, + "interactive-graph 7": { + "alignment": "default", + "graded": true, + "key": undefined, + "options": { + "backgroundImage": { + "bottom": undefined, + "height": undefined, + "left": undefined, + "scale": undefined, + "top": undefined, + "url": null, + "width": undefined, + }, + "correct": { + "coord": undefined, + "coords": undefined, + "numSegments": undefined, + "startCoords": undefined, + "type": "segment", + }, + "fullGraphAriaDescription": undefined, + "fullGraphLabel": undefined, + "graph": { + "coord": undefined, + "coords": undefined, + "numSegments": undefined, + "startCoords": undefined, + "type": "segment", + }, + "gridStep": [ + 2, + 0.5, + ], + "labels": [ + "x", + "y", + ], + "lockedFigures": undefined, + "markings": "graph", + "range": [ + [ + -5, + 5, + ], + [ + -5, + 5, + ], + ], + "rulerLabel": undefined, + "rulerTicks": undefined, + "showProtractor": false, + "showRuler": undefined, + "showTooltips": false, + "snapStep": [ + 1, + 0.25, + ], + "step": [ + 1, + 1, + ], + }, + "static": false, + "type": "interactive-graph", + "version": { + "major": 0, + "minor": 0, + }, + }, + "interactive-graph 8": { + "alignment": "default", + "graded": true, + "key": undefined, + "options": { + "backgroundImage": { + "bottom": undefined, + "height": undefined, + "left": undefined, + "scale": undefined, + "top": undefined, + "url": null, + "width": undefined, + }, + "correct": { + "coord": undefined, + "coords": [ + [ + [ + -5, + -5, + ], + [ + 5, + 5, + ], + ], + ], + "hasBeenInteractedWith": true, + "markings": "graph", + "numSegments": undefined, + "range": [ + [ + -10, + 10, + ], + [ + -10, + 10, + ], + ], + "snapStep": [ + 0.5, + 0.5, + ], + "startCoords": undefined, + "type": "segment", + }, + "fullGraphAriaDescription": undefined, + "fullGraphLabel": undefined, + "graph": { + "coord": undefined, + "coords": undefined, + "numSegments": undefined, + "startCoords": undefined, + "type": "segment", + }, + "gridStep": [ + 1, + 1, + ], + "labels": [ + "x", + "y", + ], + "lockedFigures": [ + { + "ariaLabel": undefined, + "color": "green", + "coord": [ + -1, + 5, + ], + "filled": true, + "labels": undefined, + "type": "point", + }, + { + "ariaLabel": undefined, + "color": "grayH", + "coord": [ + 1, + 5, + ], + "filled": false, + "labels": undefined, + "type": "point", + }, + { + "ariaLabel": undefined, + "color": "grayH", + "kind": "line", + "labels": undefined, + "lineStyle": "solid", + "points": [ + { + "ariaLabel": undefined, + "color": "grayH", + "coord": [ + 0, + 1, + ], + "filled": true, + "labels": undefined, + "type": "point", + }, + { + "ariaLabel": undefined, + "color": "grayH", + "coord": [ + 5, + 2, + ], + "filled": true, + "labels": undefined, + "type": "point", + }, + ], + "showEndPoint": false, + "showPoint1": false, + "showPoint2": false, + "showStartPoint": false, + "type": "line", + }, + { + "ariaLabel": undefined, + "color": "grayH", + "kind": "line", + "labels": undefined, + "lineStyle": "dashed", + "points": [ + { + "ariaLabel": undefined, + "color": "grayH", + "coord": [ + 0, + 0, + ], + "filled": true, + "labels": undefined, + "type": "point", + }, + { + "ariaLabel": undefined, + "color": "grayH", + "coord": [ + 5, + 1, + ], + "filled": false, + "labels": undefined, + "type": "point", + }, + ], + "showEndPoint": true, + "showPoint1": false, + "showPoint2": false, + "showStartPoint": true, + "type": "line", + }, + { + "ariaLabel": undefined, + "color": "pink", + "kind": "ray", + "labels": undefined, + "lineStyle": "solid", + "points": [ + { + "ariaLabel": undefined, + "color": "pink", + "coord": [ + 0, + -1, + ], + "filled": true, + "labels": undefined, + "type": "point", + }, + { + "ariaLabel": undefined, + "color": "pink", + "coord": [ + 5, + 0, + ], + "filled": true, + "labels": undefined, + "type": "point", + }, + ], + "showEndPoint": false, + "showPoint1": false, + "showPoint2": false, + "showStartPoint": false, + "type": "line", + }, + { + "ariaLabel": undefined, + "color": "pink", + "kind": "ray", + "labels": undefined, + "lineStyle": "dashed", + "points": [ + { + "ariaLabel": undefined, + "color": "purple", + "coord": [ + 0, + -2, + ], + "filled": true, + "labels": undefined, + "type": "point", + }, + { + "ariaLabel": undefined, + "color": "pink", + "coord": [ + 5, + -1, + ], + "filled": false, + "labels": undefined, + "type": "point", + }, + ], + "showEndPoint": true, + "showPoint1": false, + "showPoint2": false, + "showStartPoint": true, + "type": "line", + }, + { + "ariaLabel": undefined, + "color": "red", + "kind": "segment", + "labels": undefined, + "lineStyle": "solid", + "points": [ + { + "ariaLabel": undefined, + "color": "red", + "coord": [ + 0, + -3, + ], + "filled": true, + "labels": undefined, + "type": "point", + }, + { + "ariaLabel": undefined, + "color": "red", + "coord": [ + 5, + -2, + ], + "filled": true, + "labels": undefined, + "type": "point", + }, + ], + "showEndPoint": false, + "showPoint1": false, + "showPoint2": false, + "showStartPoint": false, + "type": "line", + }, + { + "ariaLabel": undefined, + "color": "red", + "kind": "segment", + "labels": undefined, + "lineStyle": "dashed", + "points": [ + { + "ariaLabel": undefined, + "color": "green", + "coord": [ + 0, + -4, + ], + "filled": true, + "labels": undefined, + "type": "point", + }, + { + "ariaLabel": undefined, + "color": "red", + "coord": [ + 5, + -3, + ], + "filled": false, + "labels": undefined, + "type": "point", + }, + ], + "showEndPoint": true, + "showPoint1": false, + "showPoint2": false, + "showStartPoint": true, + "type": "line", + }, + { + "color": "blue", + "coord": [ + -6, + 0, + ], + "size": "medium", + "text": "\\frac{1}{4}?", + "type": "label", + }, + ], + "markings": "graph", + "range": [ + [ + -10, + 10, + ], + [ + -10, + 10, + ], + ], + "rulerLabel": undefined, + "rulerTicks": undefined, + "showProtractor": false, + "showRuler": undefined, + "showTooltips": false, + "snapStep": [ + 0.5, + 0.5, + ], + "step": [ + 2, + 2, + ], + }, + "static": false, + "type": "interactive-graph", + "version": { + "major": 0, + "minor": 0, + }, + }, + }, + }, +} +`; + exports[`parseAndTypecheckPerseusItem correctly parses data/interactive-graph-missing-graph.json 1`] = ` { "answer": undefined, diff --git a/packages/perseus/src/widgets/grapher/score-grapher.test.ts b/packages/perseus/src/widgets/grapher/score-grapher.test.ts index b4d94b7ae7..e88e706663 100644 --- a/packages/perseus/src/widgets/grapher/score-grapher.test.ts +++ b/packages/perseus/src/widgets/grapher/score-grapher.test.ts @@ -103,6 +103,37 @@ describe("scoreGrapher", () => { expect(result).toHaveInvalidInput(); }); + it("is invalid when rubric has null coords", () => { + // The rubric.correct.coords are null in some cases in legacy data. + // Before this test was added and made to pass, the scoring code would + // throw an exception if the coords were null. From a learner's + // perspective, they'd click the "check answer" button and nothing + // would visibly happen. Returning "invalid" is slightly nicer, and has + // a similar effect (blocking learner progress). + + // Arrange + const userInput: PerseusGrapherUserInput = { + type: "linear", + coords: [ + [-10, -10], + [10, 10], + ], + }; + + const rubric: PerseusGrapherRubric = { + correct: { + type: "linear", + coords: null, + }, + }; + + // Act + const result = scoreGrapher(userInput, rubric); + + // Assert + expect(result).toHaveInvalidInput(); + }) + it("can be answered correctly", () => { const coords: [Coord, Coord] = [ [-10, -10], diff --git a/packages/perseus/src/widgets/grapher/score-grapher.ts b/packages/perseus/src/widgets/grapher/score-grapher.ts index 87736f0fe0..c3673aa07a 100644 --- a/packages/perseus/src/widgets/grapher/score-grapher.ts +++ b/packages/perseus/src/widgets/grapher/score-grapher.ts @@ -50,6 +50,15 @@ function scoreGrapher( }; } + // If the correct coords are null, treat the input as invalid. + // This handles legacy data that contains null coords. + if (rubric.correct.coords == null) { + return { + type: "invalid", + message: null, + } + } + // Get new function handler for grading const grader = functionForType(userInput.type); const guessCoeffs = getCoefficientsByType(userInput); From ac099afa00f15ffa55ceb9db972a33bf54d02d23 Mon Sep 17 00:00:00 2001 From: Ben Christel Date: Tue, 7 Jan 2025 12:40:21 -0800 Subject: [PATCH 04/18] Refactor and fix type errors --- .../src/widgets/grapher/score-grapher.test.ts | 3 --- .../perseus/src/widgets/grapher/score-grapher.ts | 12 +++--------- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/packages/perseus/src/widgets/grapher/score-grapher.test.ts b/packages/perseus/src/widgets/grapher/score-grapher.test.ts index e88e706663..f358e5a54c 100644 --- a/packages/perseus/src/widgets/grapher/score-grapher.test.ts +++ b/packages/perseus/src/widgets/grapher/score-grapher.test.ts @@ -53,9 +53,6 @@ describe("scoreGrapher", () => { const userInput: PerseusGrapherUserInput = { type: "exponential", asymptote, - // TODO: either the types or logic is wrong, - // but the existing scoring function checks for null coords - // @ts-expect-error - TS(2322) - Type 'null' is not assignable to type 'readonly Coord[]'. coords: null, }; diff --git a/packages/perseus/src/widgets/grapher/score-grapher.ts b/packages/perseus/src/widgets/grapher/score-grapher.ts index c3673aa07a..4344550247 100644 --- a/packages/perseus/src/widgets/grapher/score-grapher.ts +++ b/packages/perseus/src/widgets/grapher/score-grapher.ts @@ -12,6 +12,9 @@ import type {GrapherAnswerTypes} from "@khanacademy/perseus-core"; function getCoefficientsByType( data: GrapherAnswerTypes, ): ReadonlyArray | undefined { + if (data.coords == null) { + return undefined; + } if (data.type === "exponential" || data.type === "logarithm") { const grader = functionForType(data.type); return grader.getCoefficients(data.coords, data.asymptote); @@ -50,15 +53,6 @@ function scoreGrapher( }; } - // If the correct coords are null, treat the input as invalid. - // This handles legacy data that contains null coords. - if (rubric.correct.coords == null) { - return { - type: "invalid", - message: null, - } - } - // Get new function handler for grading const grader = functionForType(userInput.type); const guessCoeffs = getCoefficientsByType(userInput); From 3de38ed34be2df2e7317a944fbfdb3cfabf2f6b0 Mon Sep 17 00:00:00 2001 From: Ben Christel Date: Tue, 7 Jan 2025 13:12:58 -0800 Subject: [PATCH 05/18] Improve parse failure message when widget ID is invalid --- .../perseus-parsers/widgets-map.test.ts | 19 +++++++++++++++++++ .../perseus-parsers/widgets-map.ts | 4 ++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/widgets-map.test.ts b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/widgets-map.test.ts index 2bfa98e088..5d6cf0b0ff 100644 --- a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/widgets-map.test.ts +++ b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/widgets-map.test.ts @@ -34,6 +34,25 @@ describe("parseWidgetsMap", () => { expect(result).toEqual(anyFailure); }); + it("rejects a key with ID 0", () => { + // Widget keys with ID = 0 currently cause a full-page crash when the + // exercise is rendered in webapp! + + const widgetsMap: unknown = { + "radio 0": { + type: "radio", + version: {major: 0, minor: 0}, + options: { + choices: [], + noneOfTheAbove: false, + }, + }, + }; + + const result = parse(widgetsMap, parseWidgetsMap); + expect(result).toEqual(failure(`At (root)["radio 0"]["(widget key)"][1] -- expected a string representing a positive integer, but got "0"`)) + }) + it("accepts a categorizer widget", () => { const widgetsMap: unknown = { "categorizer 1": { diff --git a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/widgets-map.ts b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/widgets-map.ts index def3756dbd..c8ae0ad628 100644 --- a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/widgets-map.ts +++ b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/widgets-map.ts @@ -72,7 +72,7 @@ const parseWidgetsMapEntry: ( ) => ParseResult = ([key, widget], widgetMap, ctx) => { const keyComponentsResult = parseWidgetMapKeyComponents( key.split(" "), - ctx, + ctx.forSubtree("(widget key)"), ); if (isFailure(keyComponentsResult)) { return keyComponentsResult; @@ -208,7 +208,7 @@ const parseDeprecatedWidget: Parser = parseWidget( const parseStringToPositiveInt: Parser = (rawValue, ctx) => { if (typeof rawValue !== "string" || !/^[1-9][0-9]*$/.test(rawValue)) { - return ctx.failure("numeric string", rawValue); + return ctx.failure("a string representing a positive integer", rawValue); } return ctx.success(+rawValue); }; From d9d536ff1f59328d3fa401e15100ad8d50508f20 Mon Sep 17 00:00:00 2001 From: Ben Christel Date: Tue, 7 Jan 2025 13:22:39 -0800 Subject: [PATCH 06/18] Handle Interaction elements with missing keys --- .../perseus-parsers/interaction-widget.ts | 21 +- .../data/interaction-element-missing-key.json | 452 ++++++++++++++++++ 2 files changed, 464 insertions(+), 9 deletions(-) create mode 100644 packages/perseus/src/util/parse-perseus-json/regression-tests/data/interaction-element-missing-key.json diff --git a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/interaction-widget.ts b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/interaction-widget.ts index b96c589727..fab2896522 100644 --- a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/interaction-widget.ts +++ b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/interaction-widget.ts @@ -6,7 +6,7 @@ import { number, object, optional, - pair, + pair, pipeParsers, string, union, } from "../general-purpose-parsers"; @@ -21,15 +21,18 @@ import type { InteractionWidget, PerseusInteractionElement, } from "@khanacademy/perseus-core"; +import {convert} from "../general-purpose-parsers/convert"; const pairOfNumbers = pair(number, number); const stringOrEmpty = defaulted(string, () => ""); +const parseKey = pipeParsers(optional(string)).then(convert(String)).parser + type FunctionElement = Extract; const parseFunctionType = constant("function"); const parseFunctionElement: Parser = object({ type: parseFunctionType, - key: string, + key: parseKey, options: object({ value: string, funcName: string, @@ -45,7 +48,7 @@ type LabelElement = Extract; const parseLabelType = constant("label"); const parseLabelElement: Parser = object({ type: parseLabelType, - key: string, + key: parseKey, options: object({ label: string, color: string, @@ -58,7 +61,7 @@ type LineElement = Extract; const parseLineType = constant("line"); const parseLineElement: Parser = object({ type: parseLineType, - key: string, + key: parseKey, options: object({ color: string, startX: string, @@ -78,7 +81,7 @@ type MovableLineElement = Extract< const parseMovableLineType = constant("movable-line"); const parseMovableLineElement: Parser = object({ type: parseMovableLineType, - key: string, + key: parseKey, options: object({ startX: string, startY: string, @@ -103,7 +106,7 @@ type MovablePointElement = Extract< const parseMovablePointType = constant("movable-point"); const parseMovablePointElement: Parser = object({ type: parseMovablePointType, - key: string, + key: parseKey, options: object({ startX: string, startY: string, @@ -125,7 +128,7 @@ type ParametricElement = Extract< const parseParametricType = constant("parametric"); const parseParametricElement: Parser = object({ type: parseParametricType, - key: string, + key: parseKey, options: object({ x: string, y: string, @@ -141,7 +144,7 @@ type PointElement = Extract; const parsePointType = constant("point"); const parsePointElement: Parser = object({ type: parsePointType, - key: string, + key: parseKey, options: object({ color: string, coordX: string, @@ -153,7 +156,7 @@ type RectangleElement = Extract; const parseRectangleType = constant("rectangle"); const parseRectangleElement: Parser = object({ type: parseRectangleType, - key: string, + key: parseKey, options: object({ color: string, coordX: string, diff --git a/packages/perseus/src/util/parse-perseus-json/regression-tests/data/interaction-element-missing-key.json b/packages/perseus/src/util/parse-perseus-json/regression-tests/data/interaction-element-missing-key.json new file mode 100644 index 0000000000..efd2eb02f5 --- /dev/null +++ b/packages/perseus/src/util/parse-perseus-json/regression-tests/data/interaction-element-missing-key.json @@ -0,0 +1,452 @@ +{ + "question": { + "content": "# Functions introduction\n\nA function is something that maps one value to another.\n\nHere is a function that maps an $\\orange\\text{input dot}$ on the top to an $\\blue\\text{output dot}$ on the bottom. Try dragging the $\\orange\\text{input dot}$ on the left and see what $\\blue\\text{output}$ the function maps it to below:\n\n[[☃ interaction 1]]\n\nNot all functions are quite so simple! For example, there is no rule that the $\\blue\\text{output}$ has to increase when the $\\orange\\text{input}$ increases:\n\n[[☃ interaction 2]]\n\nThere is also no rule that a function has to map to a different value for each different input value:\n\n[[☃ interaction 3]]\n\nOr that it even has to ever map to a different value at all!\n\n[[☃ interaction 4]]\n\nBut that's sort of unsatisfying! so here's another function that demonstrates all of those concepts:\n\n[[☃ interaction 5]]\n\nNext, we'll look at some other representations of functions!", + "images": { + "https://ka-perseus-graphie.s3.amazonaws.com/da8df81c78b22f5c69d477d8eabfb583968eaf84.png": { + "width": 400, + "height": 70 + }, + "https://ka-perseus-graphie.s3.amazonaws.com/b59fc02ca1aae800977b8793ed22f647a1aa75ee.png": { + "width": 425, + "height": 150 + } + }, + "widgets": { + "interaction 1": { + "type": "interaction", + "graded": true, + "options": { + "graph": { + "editableSettings": [ + "canvas", + "graph" + ], + "box": [ + 400, + 200 + ], + "labels": [ + "", + "" + ], + "range": [ + [ + 0, + 10 + ], + [ + -6, + 6 + ] + ], + "gridStep": [ + 1, + 3 + ], + "markings": "graph", + "snapStep": [ + 0.5, + 1.5 + ], + "valid": true, + "backgroundImage": { + "url": null, + "scale": 1, + "bottom": 0, + "left": 0 + }, + "showProtractor": false, + "showRuler": false, + "rulerLabel": "", + "rulerTicks": 10, + "tickStep": [ + 1, + 2 + ], + "scale": [ + 40, + 16.666666666666668 + ] + }, + "elements": [ + { + "type": "movable-point", + "options": { + "startX": "5", + "startY": "3", + "constraint": "snap", + "snap": 1, + "constraintFn": "-3", + "constraintXMin": "1", + "constraintXMax": "8", + "constraintYMin": "3", + "constraintYMax": "3", + "varSubscript": 0 + } + }, + { + "type": "point", + "options": { + "coordX": "x_0+1", + "coordY": "-3", + "color": "#6495ED" + } + } + ] + }, + "version": { + "major": 0, + "minor": 0 + } + }, + "interaction 2": { + "type": "interaction", + "graded": true, + "options": { + "graph": { + "editableSettings": [ + "canvas", + "graph" + ], + "box": [ + 400, + 200 + ], + "labels": [ + "", + "" + ], + "range": [ + [ + 0, + 10 + ], + [ + -6, + 6 + ] + ], + "gridStep": [ + 1, + 3 + ], + "markings": "graph", + "snapStep": [ + 0.5, + 1.5 + ], + "valid": true, + "backgroundImage": { + "url": null, + "scale": 1, + "bottom": 0, + "left": 0 + }, + "showProtractor": false, + "showRuler": false, + "rulerLabel": "", + "rulerTicks": 10, + "tickStep": [ + 1, + 2 + ], + "scale": [ + 40, + 16.666666666666668 + ] + }, + "elements": [ + { + "type": "movable-point", + "options": { + "startX": "5", + "startY": "3", + "constraint": "snap", + "snap": 1, + "constraintFn": "-3", + "constraintXMin": "1", + "constraintXMax": "9", + "constraintYMin": "3", + "constraintYMax": "3", + "varSubscript": 0 + } + }, + { + "type": "point", + "options": { + "coordX": "10-x_0", + "coordY": "-3", + "color": "#6495ED" + } + } + ] + }, + "version": { + "major": 0, + "minor": 0 + } + }, + "interaction 3": { + "type": "interaction", + "graded": true, + "options": { + "graph": { + "editableSettings": [ + "canvas", + "graph" + ], + "box": [ + 400, + 200 + ], + "labels": [ + "", + "" + ], + "range": [ + [ + 0, + 10 + ], + [ + -6, + 6 + ] + ], + "gridStep": [ + 1, + 3 + ], + "markings": "graph", + "snapStep": [ + 0.5, + 1.5 + ], + "valid": true, + "backgroundImage": { + "url": null, + "scale": 1, + "bottom": 0, + "left": 0 + }, + "showProtractor": false, + "showRuler": false, + "rulerLabel": "", + "rulerTicks": 10, + "tickStep": [ + 1, + 2 + ], + "scale": [ + 40, + 16.666666666666668 + ] + }, + "elements": [ + { + "type": "movable-point", + "options": { + "startX": "5", + "startY": "3", + "constraint": "snap", + "snap": 1, + "constraintFn": "-3", + "constraintXMin": "1", + "constraintXMax": "9", + "constraintYMin": "3", + "constraintYMax": "3", + "varSubscript": 0 + } + }, + { + "type": "point", + "options": { + "coordX": "\\sin\\left(x_0\\cdot\\frac{\\pi}{2}\\right)+5", + "coordY": "-3", + "color": "#6495ED" + } + } + ] + }, + "version": { + "major": 0, + "minor": 0 + } + }, + "interaction 4": { + "type": "interaction", + "graded": true, + "options": { + "graph": { + "editableSettings": [ + "canvas", + "graph" + ], + "box": [ + 400, + 200 + ], + "labels": [ + "", + "" + ], + "range": [ + [ + 0, + 10 + ], + [ + -6, + 6 + ] + ], + "gridStep": [ + 1, + 3 + ], + "markings": "graph", + "snapStep": [ + 0.5, + 1.5 + ], + "valid": true, + "backgroundImage": { + "url": null, + "scale": 1, + "bottom": 0, + "left": 0 + }, + "showProtractor": false, + "showRuler": false, + "rulerLabel": "", + "rulerTicks": 10, + "tickStep": [ + 1, + 2 + ], + "scale": [ + 40, + 16.666666666666668 + ] + }, + "elements": [ + { + "type": "movable-point", + "options": { + "startX": "5", + "startY": "3", + "constraint": "snap", + "snap": 1, + "constraintFn": "-3", + "constraintXMin": "1", + "constraintXMax": "9", + "constraintYMin": "3", + "constraintYMax": "3", + "varSubscript": 0 + } + }, + { + "type": "point", + "options": { + "coordX": "4", + "coordY": "-3", + "color": "#6495ED" + } + } + ] + }, + "version": { + "major": 0, + "minor": 0 + } + }, + "interaction 5": { + "type": "interaction", + "graded": true, + "options": { + "graph": { + "editableSettings": [ + "canvas", + "graph" + ], + "box": [ + 400, + 200 + ], + "labels": [ + "", + "" + ], + "range": [ + [ + 0, + 10 + ], + [ + -6, + 6 + ] + ], + "gridStep": [ + 1, + 3 + ], + "markings": "graph", + "snapStep": [ + 0.5, + 1.5 + ], + "valid": true, + "backgroundImage": { + "url": null, + "scale": 1, + "bottom": 0, + "left": 0 + }, + "showProtractor": false, + "showRuler": false, + "rulerLabel": "", + "rulerTicks": 10, + "tickStep": [ + 1, + 2 + ], + "scale": [ + 40, + 16.666666666666668 + ] + }, + "elements": [ + { + "type": "movable-point", + "options": { + "startX": "5", + "startY": "3", + "constraint": "snap", + "snap": 1, + "constraintFn": "-3", + "constraintXMin": "1", + "constraintXMax": "9", + "constraintYMin": "3", + "constraintYMax": "3", + "varSubscript": 0 + } + }, + { + "type": "point", + "options": { + "coordX": "5-\\left|x_0-5\\right|", + "coordY": "-3", + "color": "#6495ED" + } + } + ] + }, + "version": { + "major": 0, + "minor": 0 + } + } + } + } +} From 11a458b62022152de4f0fbdda5601de48fb5c302 Mon Sep 17 00:00:00 2001 From: Ben Christel Date: Tue, 7 Jan 2025 13:24:35 -0800 Subject: [PATCH 07/18] Inline single-use constants --- .../perseus-parsers/interaction-widget.ts | 24 +++++++------------ 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/interaction-widget.ts b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/interaction-widget.ts index fab2896522..455fac5392 100644 --- a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/interaction-widget.ts +++ b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/interaction-widget.ts @@ -29,9 +29,8 @@ const stringOrEmpty = defaulted(string, () => ""); const parseKey = pipeParsers(optional(string)).then(convert(String)).parser type FunctionElement = Extract; -const parseFunctionType = constant("function"); const parseFunctionElement: Parser = object({ - type: parseFunctionType, + type: constant("function"), key: parseKey, options: object({ value: string, @@ -45,9 +44,8 @@ const parseFunctionElement: Parser = object({ }); type LabelElement = Extract; -const parseLabelType = constant("label"); const parseLabelElement: Parser = object({ - type: parseLabelType, + type: constant("label"), key: parseKey, options: object({ label: string, @@ -58,9 +56,8 @@ const parseLabelElement: Parser = object({ }); type LineElement = Extract; -const parseLineType = constant("line"); const parseLineElement: Parser = object({ - type: parseLineType, + type: constant("line"), key: parseKey, options: object({ color: string, @@ -78,9 +75,8 @@ type MovableLineElement = Extract< PerseusInteractionElement, {type: "movable-line"} >; -const parseMovableLineType = constant("movable-line"); const parseMovableLineElement: Parser = object({ - type: parseMovableLineType, + type: constant("movable-line"), key: parseKey, options: object({ startX: string, @@ -103,9 +99,8 @@ type MovablePointElement = Extract< PerseusInteractionElement, {type: "movable-point"} >; -const parseMovablePointType = constant("movable-point"); const parseMovablePointElement: Parser = object({ - type: parseMovablePointType, + type: constant("movable-point"), key: parseKey, options: object({ startX: string, @@ -125,9 +120,8 @@ type ParametricElement = Extract< PerseusInteractionElement, {type: "parametric"} >; -const parseParametricType = constant("parametric"); const parseParametricElement: Parser = object({ - type: parseParametricType, + type: constant("parametric"), key: parseKey, options: object({ x: string, @@ -141,9 +135,8 @@ const parseParametricElement: Parser = object({ }); type PointElement = Extract; -const parsePointType = constant("point"); const parsePointElement: Parser = object({ - type: parsePointType, + type: constant("point"), key: parseKey, options: object({ color: string, @@ -153,9 +146,8 @@ const parsePointElement: Parser = object({ }); type RectangleElement = Extract; -const parseRectangleType = constant("rectangle"); const parseRectangleElement: Parser = object({ - type: parseRectangleType, + type: constant("rectangle"), key: parseKey, options: object({ color: string, From 6bf445e0d6243c16b2417e3bcfae069ef1bb2010 Mon Sep 17 00:00:00 2001 From: Ben Christel Date: Tue, 7 Jan 2025 13:26:30 -0800 Subject: [PATCH 08/18] Fix lint --- .../parse-perseus-json/perseus-parsers/grapher-widget.ts | 1 - .../perseus-parsers/interaction-widget.ts | 9 +++++---- .../perseus-parsers/widgets-map.test.ts | 8 ++++++-- .../parse-perseus-json/perseus-parsers/widgets-map.ts | 5 ++++- .../perseus/src/widgets/grapher/score-grapher.test.ts | 2 +- 5 files changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/grapher-widget.ts b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/grapher-widget.ts index 4e4c8c3285..5f8e0fdedd 100644 --- a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/grapher-widget.ts +++ b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/grapher-widget.ts @@ -11,7 +11,6 @@ import { string, union, } from "../general-purpose-parsers"; -import {defaulted} from "../general-purpose-parsers/defaulted"; import {discriminatedUnionOn} from "../general-purpose-parsers/discriminated-union"; import {parseWidget} from "./widget"; diff --git a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/interaction-widget.ts b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/interaction-widget.ts index 455fac5392..566538505a 100644 --- a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/interaction-widget.ts +++ b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/interaction-widget.ts @@ -6,27 +6,28 @@ import { number, object, optional, - pair, pipeParsers, + pair, + pipeParsers, string, union, } from "../general-purpose-parsers"; +import {convert} from "../general-purpose-parsers/convert"; import {defaulted} from "../general-purpose-parsers/defaulted"; import {discriminatedUnionOn} from "../general-purpose-parsers/discriminated-union"; import {parsePerseusImageBackground} from "./perseus-image-background"; import {parseWidget} from "./widget"; -import type {Parser} from "../parser-types"; import type { InteractionWidget, PerseusInteractionElement, } from "@khanacademy/perseus-core"; -import {convert} from "../general-purpose-parsers/convert"; +import type {Parser} from "../parser-types"; const pairOfNumbers = pair(number, number); const stringOrEmpty = defaulted(string, () => ""); -const parseKey = pipeParsers(optional(string)).then(convert(String)).parser +const parseKey = pipeParsers(optional(string)).then(convert(String)).parser; type FunctionElement = Extract; const parseFunctionElement: Parser = object({ diff --git a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/widgets-map.test.ts b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/widgets-map.test.ts index 5d6cf0b0ff..3d2b4c62da 100644 --- a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/widgets-map.test.ts +++ b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/widgets-map.test.ts @@ -50,8 +50,12 @@ describe("parseWidgetsMap", () => { }; const result = parse(widgetsMap, parseWidgetsMap); - expect(result).toEqual(failure(`At (root)["radio 0"]["(widget key)"][1] -- expected a string representing a positive integer, but got "0"`)) - }) + expect(result).toEqual( + failure( + `At (root)["radio 0"]["(widget key)"][1] -- expected a string representing a positive integer, but got "0"`, + ), + ); + }); it("accepts a categorizer widget", () => { const widgetsMap: unknown = { diff --git a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/widgets-map.ts b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/widgets-map.ts index c8ae0ad628..3bde6f115c 100644 --- a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/widgets-map.ts +++ b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/widgets-map.ts @@ -208,7 +208,10 @@ const parseDeprecatedWidget: Parser = parseWidget( const parseStringToPositiveInt: Parser = (rawValue, ctx) => { if (typeof rawValue !== "string" || !/^[1-9][0-9]*$/.test(rawValue)) { - return ctx.failure("a string representing a positive integer", rawValue); + return ctx.failure( + "a string representing a positive integer", + rawValue, + ); } return ctx.success(+rawValue); }; diff --git a/packages/perseus/src/widgets/grapher/score-grapher.test.ts b/packages/perseus/src/widgets/grapher/score-grapher.test.ts index f358e5a54c..632e9b3ae2 100644 --- a/packages/perseus/src/widgets/grapher/score-grapher.test.ts +++ b/packages/perseus/src/widgets/grapher/score-grapher.test.ts @@ -129,7 +129,7 @@ describe("scoreGrapher", () => { // Assert expect(result).toHaveInvalidInput(); - }) + }); it("can be answered correctly", () => { const coords: [Coord, Coord] = [ From b4e61402d216c3f8b2919fca85dc18c7e977008b Mon Sep 17 00:00:00 2001 From: Ben Christel Date: Tue, 7 Jan 2025 14:10:05 -0800 Subject: [PATCH 09/18] Remove PerseusCSProgramWidgetOptions.width it was unused, and it was null in some data which caused parse errors. --- packages/perseus-core/src/data-schema.ts | 3 --- .../perseus-parsers/cs-program-widget.ts | 1 - .../data/cs-program-with-null-width.json | 20 +++++++++++++++++++ .../cs-program/cs-program-ai-utils.test.ts | 1 - .../widgets/cs-program/cs-program.testdata.ts | 1 - 5 files changed, 20 insertions(+), 6 deletions(-) create mode 100644 packages/perseus/src/util/parse-perseus-json/regression-tests/data/cs-program-with-null-width.json diff --git a/packages/perseus-core/src/data-schema.ts b/packages/perseus-core/src/data-schema.ts index ac5481dcdc..c1d0bb0b47 100644 --- a/packages/perseus-core/src/data-schema.ts +++ b/packages/perseus-core/src/data-schema.ts @@ -1621,9 +1621,6 @@ export type PerseusCSProgramWidgetOptions = { showEditor: boolean; // Whether to show the execute buttons showButtons: boolean; - // TODO(benchristel): width is not used. Delete it? - // The width of the widget - width: number; // The height of the widget height: number; // TODO(benchristel): static is not used. Delete it? diff --git a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/cs-program-widget.ts b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/cs-program-widget.ts index 2d0eece4ae..7421464ef7 100644 --- a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/cs-program-widget.ts +++ b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/cs-program-widget.ts @@ -22,7 +22,6 @@ export const parseCSProgramWidget: Parser = parseWidget( settings: array(object({name: string, value: string})), showEditor: boolean, showButtons: boolean, - width: number, height: number, static: defaulted(boolean, () => false), }), diff --git a/packages/perseus/src/util/parse-perseus-json/regression-tests/data/cs-program-with-null-width.json b/packages/perseus/src/util/parse-perseus-json/regression-tests/data/cs-program-with-null-width.json new file mode 100644 index 0000000000..f587f8ebd9 --- /dev/null +++ b/packages/perseus/src/util/parse-perseus-json/regression-tests/data/cs-program-with-null-width.json @@ -0,0 +1,20 @@ +{ + "question": { + "content": "[[☃ cs-program 1]]", + "images": {}, + "widgets": { + "cs-program 1": { + "type": "cs-program", + "options": { + "settings": [], + "height": 250, + "width": null, + "programID": "4545417404481536", + "showButtons": true, + "showEditor": true + }, + "alignment": "block" + } + } + } +} diff --git a/packages/perseus/src/widget-ai-utils/cs-program/cs-program-ai-utils.test.ts b/packages/perseus/src/widget-ai-utils/cs-program/cs-program-ai-utils.test.ts index 16dc8062f5..132d26fbe7 100644 --- a/packages/perseus/src/widget-ai-utils/cs-program/cs-program-ai-utils.test.ts +++ b/packages/perseus/src/widget-ai-utils/cs-program/cs-program-ai-utils.test.ts @@ -19,7 +19,6 @@ const question1: PerseusRenderer = { {name: "", value: ""}, ], height: 540, - width: 640, programID: "6293105639817216", static: false, showButtons: false, diff --git a/packages/perseus/src/widgets/cs-program/cs-program.testdata.ts b/packages/perseus/src/widgets/cs-program/cs-program.testdata.ts index b647d43fdd..4f75277594 100644 --- a/packages/perseus/src/widgets/cs-program/cs-program.testdata.ts +++ b/packages/perseus/src/widgets/cs-program/cs-program.testdata.ts @@ -15,7 +15,6 @@ export const question1: PerseusRenderer = { {name: "", value: ""}, ], height: 540, - width: 640, programID: "6293105639817216", static: false, showButtons: false, From 36cf207ff591ff43114ed904abd9aea84bb17de8 Mon Sep 17 00:00:00 2001 From: Ben Christel Date: Tue, 7 Jan 2025 14:52:40 -0800 Subject: [PATCH 10/18] Default snapsPerLine and scaleY when parsing Plotter widgets --- .../perseus-parsers/plotter-widget.ts | 10 ++- ...otter-missing-scaleY-and-snapsPerLine.json | 88 +++++++++++++++++++ 2 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 packages/perseus/src/util/parse-perseus-json/regression-tests/data/plotter-missing-scaleY-and-snapsPerLine.json diff --git a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/plotter-widget.ts b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/plotter-widget.ts index f56397d51b..24ea3ef0d7 100644 --- a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/plotter-widget.ts +++ b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/plotter-widget.ts @@ -24,9 +24,15 @@ export const parsePlotterWidget: Parser = parseWidget( categories: array(string), type: enumeration(...plotterPlotTypes), maxY: number, - scaleY: number, + // The default value for scaleY comes from plotter.tsx. + // See parse-perseus-json/README.md for why we want to duplicate the + // defaults here. + scaleY: defaulted(number, () => 1), labelInterval: optional(nullable(number)), - snapsPerLine: number, + // The default value for snapsPerLine comes from plotter.tsx. + // See parse-perseus-json/README.md for why we want to duplicate the + // defaults here. + snapsPerLine: defaulted(number, () => 2), starting: array(number), correct: array(number), picUrl: optional(nullable(string)), diff --git a/packages/perseus/src/util/parse-perseus-json/regression-tests/data/plotter-missing-scaleY-and-snapsPerLine.json b/packages/perseus/src/util/parse-perseus-json/regression-tests/data/plotter-missing-scaleY-and-snapsPerLine.json new file mode 100644 index 0000000000..92102561ab --- /dev/null +++ b/packages/perseus/src/util/parse-perseus-json/regression-tests/data/plotter-missing-scaleY-and-snapsPerLine.json @@ -0,0 +1,88 @@ +{ + "answerArea": { + "calculator": false, + "options": { + "content": "", + "images": {}, + "widgets": {} + }, + "type": "multiple" + }, + "hints": [ + { + "content": "Barn | Antal mål\n- | :-: \nCalista | $\\blue2$ \nWilliam |$\\red3$ \nMichaela | $\\green5$ \nJames | $\\gray2$\n\n$$\n\n$\\green5 - \\red3= \\purple{2}$", + "images": {}, + "widgets": {} + }, + { + "content": "Michaela gjorde $\\purple{2}$ korgar mer än William. ", + "images": {}, + "widgets": {} + } + ], + "itemDataVersion": { + "major": 0, + "minor": 1 + }, + "question": { + "content": "En familj spelar basket. Pictogrammet visar hur många mål varje barn gjorde. \n\n**Michaela gjorde [[☃ input-number 1]] fler mål än William.**\n\n![](https://ka-perseus-graphie.s3.amazonaws.com/01794c4768ba6b824954277b869aaaefd551a0e5.png)\n\n\n![](https://ka-perseus-images.s3.amazonaws.com/2875f6cdd7dea3db2fef714b1225366c7250c49d.png)", + "images": { + "https://ka-perseus-graphie.s3.amazonaws.com/01794c4768ba6b824954277b869aaaefd551a0e5.png": { + "height": 37, + "width": 120 + }, + "https://ka-perseus-images.s3.amazonaws.com/2875f6cdd7dea3db2fef714b1225366c7250c49d.png": { + "height": 336, + "width": 474 + } + }, + "widgets": { + "input-number 1": { + "graded": true, + "options": { + "answerType": "number", + "inexact": false, + "maxError": 0.1, + "simplify": "required", + "size": "normal", + "value": 2 + }, + "type": "input-number", + "version": { + "major": 0, + "minor": 0 + } + }, + "plotter 1": { + "options": { + "categories": [ + "Calista", + "WIlliam", + "Michaela", + "James" + ], + "correct": [ + 1, + 1, + 1, + 1 + ], + "labels": [ + "Child", + "Baskets" + ], + "maxY": 5, + "picUrl": "http://i.imgur.com/B8mGnxB.png", + "starting": [ + 1, + 1, + 1, + 1 + ], + "type": "pic" + }, + "type": "plotter" + } + } + } +} From 86653d30a3a5a0bb2075b88638cdd1d63f4e4f7c Mon Sep 17 00:00:00 2001 From: Ben Christel Date: Tue, 7 Jan 2025 14:55:38 -0800 Subject: [PATCH 11/18] Default measurer image --- .../perseus-parsers/measurer-widget.ts | 9 +- .../data/measurer-missing-image.json | 89 +++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 packages/perseus/src/util/parse-perseus-json/regression-tests/data/measurer-missing-image.json diff --git a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/measurer-widget.ts b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/measurer-widget.ts index e3a50e5572..85d2c63900 100644 --- a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/measurer-widget.ts +++ b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/measurer-widget.ts @@ -17,7 +17,14 @@ import type {MeasurerWidget} from "@khanacademy/perseus-core"; export const parseMeasurerWidget: Parser = parseWidget( constant("measurer"), object({ - image: parsePerseusImageBackground, + // The default value for image comes from measurer.tsx. + // See parse-perseus-json/README.md for why we want to duplicate the + // defaults here. + image: defaulted(parsePerseusImageBackground, () => ({ + url: null, + top: 0, + left: 0, + })), showProtractor: boolean, showRuler: boolean, rulerLabel: string, diff --git a/packages/perseus/src/util/parse-perseus-json/regression-tests/data/measurer-missing-image.json b/packages/perseus/src/util/parse-perseus-json/regression-tests/data/measurer-missing-image.json new file mode 100644 index 0000000000..9fd018cae8 --- /dev/null +++ b/packages/perseus/src/util/parse-perseus-json/regression-tests/data/measurer-missing-image.json @@ -0,0 +1,89 @@ +{ + "answerArea": { + "calculator": false, + "options": { + "content": "", + "images": {}, + "widgets": {} + }, + "type": "multiple" + }, + "hints": [ + { + "content": "crwdns2931741:0crwdne2931741:0", + "images": {}, + "widgets": {} + }, + { + "content": "crwdns2931695:0crwdne2931695:0", + "images": {}, + "widgets": {} + }, + { + "content": "crwdns2931679:0crwdne2931679:0", + "images": {}, + "widgets": {} + } + ], + "question": { + "content": "crwdns3125767:0crwdne3125767:0", + "images": {}, + "widgets": { + "dropdown 1": { + "graded": true, + "options": { + "choices": [ + { + "content": "crwdns2301760:0crwdne2301760:0", + "correct": false + }, + { + "content": "crwdns3766725:0crwdne3766725:0", + "correct": false + }, + { + "content": "crwdns3395333:0crwdne3395333:0", + "correct": true + }, + { + "content": "crwdns3395334:0crwdne3395334:0", + "correct": false + }, + { + "content": "crwdns3445395:0crwdne3445395:0", + "correct": false + }, + { + "content": "crwdns3395337:0crwdne3395337:0", + "correct": false + }, + { + "content": "crwdns3395340:0crwdne3395340:0", + "correct": false + } + ] + }, + "type": "dropdown" + }, + "measurer 1": { + "graded": true, + "options": { + "box": [ + 480, + 480 + ], + "imageLeft": 0, + "imageTop": 0, + "imageUrl": "crwdns6514084:0crwdne6514084:0", + "rulerLabel": "", + "rulerLength": 10, + "rulerPixels": 40, + "rulerTicks": 10, + "showProtractor": true, + "showRuler": false + }, + "type": "measurer" + } + } + } +} From 97adbcf158a2019a4e1bdfbb4efe6f0d1c595171 Mon Sep 17 00:00:00 2001 From: Ben Christel Date: Tue, 7 Jan 2025 15:07:09 -0800 Subject: [PATCH 12/18] Default iframe allowFullScreen to false --- .../perseus-parsers/iframe-widget.ts | 2 +- .../data/iframe-missing-allowFullScreen.json | 90 +++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 packages/perseus/src/util/parse-perseus-json/regression-tests/data/iframe-missing-allowFullScreen.json diff --git a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/iframe-widget.ts b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/iframe-widget.ts index 55625c11f1..ad204ffe05 100644 --- a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/iframe-widget.ts +++ b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/iframe-widget.ts @@ -22,7 +22,7 @@ export const parseIframeWidget: Parser = parseWidget( settings: array(object({name: string, value: string})), width: union(number).or(string).parser, height: union(number).or(string).parser, - allowFullScreen: boolean, + allowFullScreen: defaulted(boolean, () => false), allowTopNavigation: optional(boolean), static: defaulted(boolean, () => false), }), diff --git a/packages/perseus/src/util/parse-perseus-json/regression-tests/data/iframe-missing-allowFullScreen.json b/packages/perseus/src/util/parse-perseus-json/regression-tests/data/iframe-missing-allowFullScreen.json new file mode 100644 index 0000000000..ddc5e6cf7e --- /dev/null +++ b/packages/perseus/src/util/parse-perseus-json/regression-tests/data/iframe-missing-allowFullScreen.json @@ -0,0 +1,90 @@ +{ + "answerArea": { + "calculator": false, + "options": { + "content": "", + "images": {}, + "widgets": {} + }, + "type": "multiple" + }, + "hints": [ + { + "content": "This is the easy step. Just drag disk 3 over to peg \"B\".\n\n[[☃ image 1]]", + "images": {}, + "widgets": { + "image 1": { + "graded": true, + "options": { + "backgroundImage": { + "height": 215, + "url": "https://s3.amazonaws.com/ka-cs-algorithms/hanoi_exercise_step2_1.png", + "width": 304 + }, + "box": [ + 304, + 215 + ], + "labels": [], + "range": [ + [ + 0, + 10 + ], + [ + 0, + 10 + ] + ] + }, + "type": "image", + "version": { + "major": 0, + "minor": 0 + } + } + } + } + ], + "itemDataVersion": { + "major": 0, + "minor": 1 + }, + "question": { + "content": "Congratulations, you have exposed disk 3, and since our goal is move 3 disks to peg \"B\", that's the disk we want on the bottom of peg \"B\". Move it to the target peg now.\n\n[[☃ iframe 1]]", + "images": {}, + "widgets": { + "iframe 1": { + "graded": true, + "options": { + "height": "400", + "settings": [ + { + "name": "step", + "value": "2" + }, + { + "name": "disk1", + "value": "2" + }, + { + "name": "disk2", + "value": "2" + }, + { + "name": "", + "value": "" + } + ], + "url": "4772835774169088", + "width": 400 + }, + "type": "iframe", + "version": { + "major": 0, + "minor": 0 + } + } + } + } +} From 81722b0981192da70c33ad29349248870f3d18b0 Mon Sep 17 00:00:00 2001 From: Ben Christel Date: Tue, 7 Jan 2025 15:18:48 -0800 Subject: [PATCH 13/18] Update snapshots --- .../parse-perseus-json-snapshot.test.ts.snap | 984 +++++++++++++++++- 1 file changed, 949 insertions(+), 35 deletions(-) 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 2b8ca68e98..19c3feed4f 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 @@ -272,6 +272,40 @@ exports[`parseAndTypecheckPerseusItem correctly parses data/cs-program-missing-s } `; +exports[`parseAndTypecheckPerseusItem correctly parses data/cs-program-with-null-width.json 1`] = ` +{ + "answer": undefined, + "answerArea": {}, + "hints": [], + "itemDataVersion": undefined, + "question": { + "content": "[[☃ cs-program 1]]", + "images": {}, + "metadata": undefined, + "widgets": { + "cs-program 1": { + "alignment": "block", + "graded": undefined, + "key": undefined, + "options": { + "height": 250, + "programID": "4545417404481536", + "programType": undefined, + "settings": [], + "showButtons": true, + "showEditor": true, + "static": false, + "width": null, + }, + "static": undefined, + "type": "cs-program", + "version": undefined, + }, + }, + }, +} +`; + exports[`parseAndTypecheckPerseusItem correctly parses data/definition-missing-static.json 1`] = ` { "answer": undefined, @@ -1671,6 +1705,118 @@ In case you would like a fuller experience, here is a taste of a skill you can l } `; +exports[`parseAndTypecheckPerseusItem correctly parses data/iframe-missing-allowFullScreen.json 1`] = ` +{ + "answer": undefined, + "answerArea": { + "calculator": false, + }, + "hints": [ + { + "content": "This is the easy step. Just drag disk 3 over to peg "B". + +[[☃ image 1]]", + "images": {}, + "metadata": undefined, + "replace": undefined, + "widgets": { + "image 1": { + "alignment": undefined, + "graded": true, + "key": undefined, + "options": { + "alt": undefined, + "backgroundImage": { + "bottom": undefined, + "height": 215, + "left": undefined, + "scale": undefined, + "top": undefined, + "url": "https://s3.amazonaws.com/ka-cs-algorithms/hanoi_exercise_step2_1.png", + "width": 304, + }, + "box": [ + 304, + 215, + ], + "caption": undefined, + "labels": [], + "range": [ + [ + 0, + 10, + ], + [ + 0, + 10, + ], + ], + "static": undefined, + "title": undefined, + }, + "static": undefined, + "type": "image", + "version": { + "major": 0, + "minor": 0, + }, + }, + }, + }, + ], + "itemDataVersion": { + "major": 0, + "minor": 1, + }, + "question": { + "content": "Congratulations, you have exposed disk 3, and since our goal is move 3 disks to peg "B", that's the disk we want on the bottom of peg "B". Move it to the target peg now. + +[[☃ iframe 1]]", + "images": {}, + "metadata": undefined, + "widgets": { + "iframe 1": { + "alignment": undefined, + "graded": true, + "key": undefined, + "options": { + "allowFullScreen": false, + "allowTopNavigation": undefined, + "height": "400", + "settings": [ + { + "name": "step", + "value": "2", + }, + { + "name": "disk1", + "value": "2", + }, + { + "name": "disk2", + "value": "2", + }, + { + "name": "", + "value": "", + }, + ], + "static": false, + "url": "4772835774169088", + "width": 400, + }, + "static": undefined, + "type": "iframe", + "version": { + "major": 0, + "minor": 0, + }, + }, + }, + }, +} +`; + exports[`parseAndTypecheckPerseusItem correctly parses data/iframe-missing-static.json 1`] = ` { "answer": undefined, @@ -1993,36 +2139,561 @@ exports[`parseAndTypecheckPerseusItem correctly parses data/interaction-element- "type": "function", }, { - "key": "movable-point-64f8ec", + "key": "movable-point-64f8ec", + "options": { + "constraint": "none", + "constraintFn": "0", + "constraintXMax": "", + "constraintXMin": "", + "constraintYMax": "", + "constraintYMin": "", + "snap": 0.5, + "startX": "0", + "startY": "10", + "varSubscript": 0, + }, + "type": "movable-point", + }, + { + "key": "movable-point-4336fb", + "options": { + "constraint": "none", + "constraintFn": "0", + "constraintXMax": "", + "constraintXMin": "", + "constraintYMax": "", + "constraintYMin": "", + "snap": 0.5, + "startX": "40", + "startY": "30", + "varSubscript": 1, + }, + "type": "movable-point", + }, + ], + "graph": { + "backgroundImage": { + "bottom": 0, + "height": undefined, + "left": 0, + "scale": 1, + "top": undefined, + "url": null, + "width": undefined, + }, + "box": [ + 400, + 400, + ], + "editableSettings": [ + "canvas", + "graph", + ], + "gridStep": [ + 2, + 2, + ], + "labels": [ + "x", + "y", + ], + "markings": "graph", + "range": [ + [ + -5, + 50, + ], + [ + -5, + 50, + ], + ], + "rulerLabel": "", + "rulerTicks": 10, + "showProtractor": false, + "showRuler": false, + "snapStep": [ + 1, + 1, + ], + "tickStep": [ + 5, + 5, + ], + "valid": true, + }, + "static": false, + }, + "static": undefined, + "type": "interaction", + "version": { + "major": 0, + "minor": 0, + }, + }, + }, + }, +} +`; + +exports[`parseAndTypecheckPerseusItem correctly parses data/interaction-element-missing-key.json 1`] = ` +{ + "answer": undefined, + "answerArea": {}, + "hints": [], + "itemDataVersion": undefined, + "question": { + "content": "# Functions introduction + +A function is something that maps one value to another. + +Here is a function that maps an $\\orange\\text{input dot}$ on the top to an $\\blue\\text{output dot}$ on the bottom. Try dragging the $\\orange\\text{input dot}$ on the left and see what $\\blue\\text{output}$ the function maps it to below: + +[[☃ interaction 1]] + +Not all functions are quite so simple! For example, there is no rule that the $\\blue\\text{output}$ has to increase when the $\\orange\\text{input}$ increases: + +[[☃ interaction 2]] + +There is also no rule that a function has to map to a different value for each different input value: + +[[☃ interaction 3]] + +Or that it even has to ever map to a different value at all! + +[[☃ interaction 4]] + +But that's sort of unsatisfying! so here's another function that demonstrates all of those concepts: + +[[☃ interaction 5]] + +Next, we'll look at some other representations of functions!", + "images": { + "https://ka-perseus-graphie.s3.amazonaws.com/b59fc02ca1aae800977b8793ed22f647a1aa75ee.png": { + "height": 150, + "width": 425, + }, + "https://ka-perseus-graphie.s3.amazonaws.com/da8df81c78b22f5c69d477d8eabfb583968eaf84.png": { + "height": 70, + "width": 400, + }, + }, + "metadata": undefined, + "widgets": { + "interaction 1": { + "alignment": undefined, + "graded": true, + "key": undefined, + "options": { + "elements": [ + { + "key": "undefined", + "options": { + "constraint": "snap", + "constraintFn": "-3", + "constraintXMax": "8", + "constraintXMin": "1", + "constraintYMax": "3", + "constraintYMin": "3", + "snap": 1, + "startX": "5", + "startY": "3", + "varSubscript": 0, + }, + "type": "movable-point", + }, + { + "key": "undefined", + "options": { + "color": "#6495ED", + "coordX": "x_0+1", + "coordY": "-3", + }, + "type": "point", + }, + ], + "graph": { + "backgroundImage": { + "bottom": 0, + "height": undefined, + "left": 0, + "scale": 1, + "top": undefined, + "url": null, + "width": undefined, + }, + "box": [ + 400, + 200, + ], + "editableSettings": [ + "canvas", + "graph", + ], + "gridStep": [ + 1, + 3, + ], + "labels": [ + "", + "", + ], + "markings": "graph", + "range": [ + [ + 0, + 10, + ], + [ + -6, + 6, + ], + ], + "rulerLabel": "", + "rulerTicks": 10, + "scale": [ + 40, + 16.666666666666668, + ], + "showProtractor": false, + "showRuler": false, + "snapStep": [ + 0.5, + 1.5, + ], + "tickStep": [ + 1, + 2, + ], + "valid": true, + }, + "static": false, + }, + "static": undefined, + "type": "interaction", + "version": { + "major": 0, + "minor": 0, + }, + }, + "interaction 2": { + "alignment": undefined, + "graded": true, + "key": undefined, + "options": { + "elements": [ + { + "key": "undefined", + "options": { + "constraint": "snap", + "constraintFn": "-3", + "constraintXMax": "9", + "constraintXMin": "1", + "constraintYMax": "3", + "constraintYMin": "3", + "snap": 1, + "startX": "5", + "startY": "3", + "varSubscript": 0, + }, + "type": "movable-point", + }, + { + "key": "undefined", + "options": { + "color": "#6495ED", + "coordX": "10-x_0", + "coordY": "-3", + }, + "type": "point", + }, + ], + "graph": { + "backgroundImage": { + "bottom": 0, + "height": undefined, + "left": 0, + "scale": 1, + "top": undefined, + "url": null, + "width": undefined, + }, + "box": [ + 400, + 200, + ], + "editableSettings": [ + "canvas", + "graph", + ], + "gridStep": [ + 1, + 3, + ], + "labels": [ + "", + "", + ], + "markings": "graph", + "range": [ + [ + 0, + 10, + ], + [ + -6, + 6, + ], + ], + "rulerLabel": "", + "rulerTicks": 10, + "scale": [ + 40, + 16.666666666666668, + ], + "showProtractor": false, + "showRuler": false, + "snapStep": [ + 0.5, + 1.5, + ], + "tickStep": [ + 1, + 2, + ], + "valid": true, + }, + "static": false, + }, + "static": undefined, + "type": "interaction", + "version": { + "major": 0, + "minor": 0, + }, + }, + "interaction 3": { + "alignment": undefined, + "graded": true, + "key": undefined, + "options": { + "elements": [ + { + "key": "undefined", + "options": { + "constraint": "snap", + "constraintFn": "-3", + "constraintXMax": "9", + "constraintXMin": "1", + "constraintYMax": "3", + "constraintYMin": "3", + "snap": 1, + "startX": "5", + "startY": "3", + "varSubscript": 0, + }, + "type": "movable-point", + }, + { + "key": "undefined", + "options": { + "color": "#6495ED", + "coordX": "\\sin\\left(x_0\\cdot\\frac{\\pi}{2}\\right)+5", + "coordY": "-3", + }, + "type": "point", + }, + ], + "graph": { + "backgroundImage": { + "bottom": 0, + "height": undefined, + "left": 0, + "scale": 1, + "top": undefined, + "url": null, + "width": undefined, + }, + "box": [ + 400, + 200, + ], + "editableSettings": [ + "canvas", + "graph", + ], + "gridStep": [ + 1, + 3, + ], + "labels": [ + "", + "", + ], + "markings": "graph", + "range": [ + [ + 0, + 10, + ], + [ + -6, + 6, + ], + ], + "rulerLabel": "", + "rulerTicks": 10, + "scale": [ + 40, + 16.666666666666668, + ], + "showProtractor": false, + "showRuler": false, + "snapStep": [ + 0.5, + 1.5, + ], + "tickStep": [ + 1, + 2, + ], + "valid": true, + }, + "static": false, + }, + "static": undefined, + "type": "interaction", + "version": { + "major": 0, + "minor": 0, + }, + }, + "interaction 4": { + "alignment": undefined, + "graded": true, + "key": undefined, + "options": { + "elements": [ + { + "key": "undefined", + "options": { + "constraint": "snap", + "constraintFn": "-3", + "constraintXMax": "9", + "constraintXMin": "1", + "constraintYMax": "3", + "constraintYMin": "3", + "snap": 1, + "startX": "5", + "startY": "3", + "varSubscript": 0, + }, + "type": "movable-point", + }, + { + "key": "undefined", + "options": { + "color": "#6495ED", + "coordX": "4", + "coordY": "-3", + }, + "type": "point", + }, + ], + "graph": { + "backgroundImage": { + "bottom": 0, + "height": undefined, + "left": 0, + "scale": 1, + "top": undefined, + "url": null, + "width": undefined, + }, + "box": [ + 400, + 200, + ], + "editableSettings": [ + "canvas", + "graph", + ], + "gridStep": [ + 1, + 3, + ], + "labels": [ + "", + "", + ], + "markings": "graph", + "range": [ + [ + 0, + 10, + ], + [ + -6, + 6, + ], + ], + "rulerLabel": "", + "rulerTicks": 10, + "scale": [ + 40, + 16.666666666666668, + ], + "showProtractor": false, + "showRuler": false, + "snapStep": [ + 0.5, + 1.5, + ], + "tickStep": [ + 1, + 2, + ], + "valid": true, + }, + "static": false, + }, + "static": undefined, + "type": "interaction", + "version": { + "major": 0, + "minor": 0, + }, + }, + "interaction 5": { + "alignment": undefined, + "graded": true, + "key": undefined, + "options": { + "elements": [ + { + "key": "undefined", "options": { - "constraint": "none", - "constraintFn": "0", - "constraintXMax": "", - "constraintXMin": "", - "constraintYMax": "", - "constraintYMin": "", - "snap": 0.5, - "startX": "0", - "startY": "10", + "constraint": "snap", + "constraintFn": "-3", + "constraintXMax": "9", + "constraintXMin": "1", + "constraintYMax": "3", + "constraintYMin": "3", + "snap": 1, + "startX": "5", + "startY": "3", "varSubscript": 0, }, "type": "movable-point", }, { - "key": "movable-point-4336fb", + "key": "undefined", "options": { - "constraint": "none", - "constraintFn": "0", - "constraintXMax": "", - "constraintXMin": "", - "constraintYMax": "", - "constraintYMin": "", - "snap": 0.5, - "startX": "40", - "startY": "30", - "varSubscript": 1, + "color": "#6495ED", + "coordX": "5-\\left|x_0-5\\right|", + "coordY": "-3", }, - "type": "movable-point", + "type": "point", }, ], "graph": { @@ -2037,42 +2708,46 @@ exports[`parseAndTypecheckPerseusItem correctly parses data/interaction-element- }, "box": [ 400, - 400, + 200, ], "editableSettings": [ "canvas", "graph", ], "gridStep": [ - 2, - 2, + 1, + 3, ], "labels": [ - "x", - "y", + "", + "", ], "markings": "graph", "range": [ [ - -5, - 50, + 0, + 10, ], [ - -5, - 50, + -6, + 6, ], ], "rulerLabel": "", "rulerTicks": 10, + "scale": [ + 40, + 16.666666666666668, + ], "showProtractor": false, "showRuler": false, "snapStep": [ - 1, - 1, + 0.5, + 1.5, ], "tickStep": [ - 5, - 5, + 1, + 2, ], "valid": true, }, @@ -4963,6 +5638,119 @@ $\\left[\\begin{array}{c} } `; +exports[`parseAndTypecheckPerseusItem correctly parses data/measurer-missing-image.json 1`] = ` +{ + "answer": undefined, + "answerArea": { + "calculator": false, + }, + "hints": [ + { + "content": "crwdns2931741:0crwdne2931741:0", + "images": {}, + "metadata": undefined, + "replace": undefined, + "widgets": {}, + }, + { + "content": "crwdns2931695:0crwdne2931695:0", + "images": {}, + "metadata": undefined, + "replace": undefined, + "widgets": {}, + }, + { + "content": "crwdns2931679:0crwdne2931679:0", + "images": {}, + "metadata": undefined, + "replace": undefined, + "widgets": {}, + }, + ], + "itemDataVersion": undefined, + "question": { + "content": "crwdns3125767:0crwdne3125767:0", + "images": {}, + "metadata": undefined, + "widgets": { + "dropdown 1": { + "alignment": undefined, + "graded": true, + "key": undefined, + "options": { + "ariaLabel": undefined, + "choices": [ + { + "content": "crwdns2301760:0crwdne2301760:0", + "correct": false, + }, + { + "content": "crwdns3766725:0crwdne3766725:0", + "correct": false, + }, + { + "content": "crwdns3395333:0crwdne3395333:0", + "correct": true, + }, + { + "content": "crwdns3395334:0crwdne3395334:0", + "correct": false, + }, + { + "content": "crwdns3445395:0crwdne3445395:0", + "correct": false, + }, + { + "content": "crwdns3395337:0crwdne3395337:0", + "correct": false, + }, + { + "content": "crwdns3395340:0crwdne3395340:0", + "correct": false, + }, + ], + "placeholder": "", + "static": false, + "visibleLabel": undefined, + }, + "static": undefined, + "type": "dropdown", + "version": undefined, + }, + "measurer 1": { + "alignment": undefined, + "graded": true, + "key": undefined, + "options": { + "box": [ + 480, + 480, + ], + "image": { + "left": 0, + "top": 0, + "url": null, + }, + "imageLeft": 0, + "imageTop": 0, + "imageUrl": "crwdns6514084:0crwdne6514084:0", + "rulerLabel": "", + "rulerLength": 10, + "rulerPixels": 40, + "rulerTicks": 10, + "showProtractor": true, + "showRuler": false, + "static": false, + }, + "static": undefined, + "type": "measurer", + "version": undefined, + }, + }, + }, +} +`; + exports[`parseAndTypecheckPerseusItem correctly parses data/measurer-missing-static.json 1`] = ` { "answer": undefined, @@ -7942,6 +8730,132 @@ Anton Peffenhauser, *Foot-Combat Armor of Prince-Elector Christian I of Saxony ( } `; +exports[`parseAndTypecheckPerseusItem correctly parses data/plotter-missing-scaleY-and-snapsPerLine.json 1`] = ` +{ + "answer": undefined, + "answerArea": { + "calculator": false, + }, + "hints": [ + { + "content": "Barn | Antal mål +- | :-: +Calista | $\\blue2$ +William |$\\red3$ +Michaela | $\\green5$ +James | $\\gray2$ + +$$ + +$\\green5 - \\red3= \\purple{2}$", + "images": {}, + "metadata": undefined, + "replace": undefined, + "widgets": {}, + }, + { + "content": "Michaela gjorde $\\purple{2}$ korgar mer än William. ", + "images": {}, + "metadata": undefined, + "replace": undefined, + "widgets": {}, + }, + ], + "itemDataVersion": { + "major": 0, + "minor": 1, + }, + "question": { + "content": "En familj spelar basket. Pictogrammet visar hur många mål varje barn gjorde. + +**Michaela gjorde [[☃ input-number 1]] fler mål än William.** + +![](https://ka-perseus-graphie.s3.amazonaws.com/01794c4768ba6b824954277b869aaaefd551a0e5.png) + + +![](https://ka-perseus-images.s3.amazonaws.com/2875f6cdd7dea3db2fef714b1225366c7250c49d.png)", + "images": { + "https://ka-perseus-graphie.s3.amazonaws.com/01794c4768ba6b824954277b869aaaefd551a0e5.png": { + "height": 37, + "width": 120, + }, + "https://ka-perseus-images.s3.amazonaws.com/2875f6cdd7dea3db2fef714b1225366c7250c49d.png": { + "height": 336, + "width": 474, + }, + }, + "metadata": undefined, + "widgets": { + "input-number 1": { + "alignment": undefined, + "graded": true, + "key": undefined, + "options": { + "answerType": "number", + "customKeypad": undefined, + "inexact": false, + "maxError": 0.1, + "rightAlign": undefined, + "simplify": "required", + "size": "normal", + "value": 2, + }, + "static": undefined, + "type": "input-number", + "version": { + "major": 0, + "minor": 0, + }, + }, + "plotter 1": { + "alignment": undefined, + "graded": undefined, + "key": undefined, + "options": { + "categories": [ + "Calista", + "WIlliam", + "Michaela", + "James", + ], + "correct": [ + 1, + 1, + 1, + 1, + ], + "labelInterval": undefined, + "labels": [ + "Child", + "Baskets", + ], + "maxY": 5, + "picBoxHeight": undefined, + "picSize": undefined, + "picUrl": "http://i.imgur.com/B8mGnxB.png", + "plotDimensions": [ + 380, + 300, + ], + "scaleY": 1, + "snapsPerLine": 2, + "starting": [ + 1, + 1, + 1, + 1, + ], + "type": "pic", + }, + "static": undefined, + "type": "plotter", + "version": undefined, + }, + }, + }, +} +`; + exports[`parseAndTypecheckPerseusItem correctly parses data/plotter-with-undefined-plotDimensions.json 1`] = ` { "answer": undefined, From 71762683ff73e8a984029ec31157f08346a309ec Mon Sep 17 00:00:00 2001 From: Ben Christel Date: Tue, 7 Jan 2025 15:36:52 -0800 Subject: [PATCH 14/18] Make iframe widget settings optional --- packages/perseus-core/src/data-schema.ts | 2 +- .../perseus-parsers/iframe-widget.ts | 2 +- .../parse-perseus-json-snapshot.test.ts.snap | 33 +++++++++++++++++++ .../data/iframe-missing-settings.json | 17 ++++++++++ 4 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 packages/perseus/src/util/parse-perseus-json/regression-tests/data/iframe-missing-settings.json diff --git a/packages/perseus-core/src/data-schema.ts b/packages/perseus-core/src/data-schema.ts index c1d0bb0b47..b84a34ae4d 100644 --- a/packages/perseus-core/src/data-schema.ts +++ b/packages/perseus-core/src/data-schema.ts @@ -1646,7 +1646,7 @@ export type PerseusIFrameWidgetOptions = { // A URL to display OR a CS Program ID url: string; // Settings that you add here are available to the program as an object returned by Program.settings() - settings: ReadonlyArray; + settings?: ReadonlyArray; // The width of the widget width: number | string; // The height of the widget diff --git a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/iframe-widget.ts b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/iframe-widget.ts index ad204ffe05..909f0c2c5a 100644 --- a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/iframe-widget.ts +++ b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/iframe-widget.ts @@ -19,7 +19,7 @@ export const parseIframeWidget: Parser = parseWidget( constant("iframe"), object({ url: string, - settings: array(object({name: string, value: string})), + settings: optional(array(object({name: string, value: string}))), width: union(number).or(string).parser, height: union(number).or(string).parser, allowFullScreen: defaulted(boolean, () => false), 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 19c3feed4f..d0cc4ff18d 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 @@ -1817,6 +1817,39 @@ exports[`parseAndTypecheckPerseusItem correctly parses data/iframe-missing-allow } `; +exports[`parseAndTypecheckPerseusItem correctly parses data/iframe-missing-settings.json 1`] = ` +{ + "answer": undefined, + "answerArea": {}, + "hints": [], + "itemDataVersion": undefined, + "question": { + "content": "[[☃ iframe 1]]", + "images": {}, + "metadata": undefined, + "widgets": { + "iframe 1": { + "alignment": "block", + "graded": undefined, + "key": undefined, + "options": { + "allowFullScreen": false, + "allowTopNavigation": undefined, + "height": "550px", + "settings": undefined, + "static": false, + "url": "https://learnstorm.typeform.com/to/fnQ2tw?", + "width": "100%", + }, + "static": undefined, + "type": "iframe", + "version": undefined, + }, + }, + }, +} +`; + exports[`parseAndTypecheckPerseusItem correctly parses data/iframe-missing-static.json 1`] = ` { "answer": undefined, diff --git a/packages/perseus/src/util/parse-perseus-json/regression-tests/data/iframe-missing-settings.json b/packages/perseus/src/util/parse-perseus-json/regression-tests/data/iframe-missing-settings.json new file mode 100644 index 0000000000..cdb1f2c86e --- /dev/null +++ b/packages/perseus/src/util/parse-perseus-json/regression-tests/data/iframe-missing-settings.json @@ -0,0 +1,17 @@ +{ + "question": { + "content": "[[☃ iframe 1]]", + "images": {}, + "widgets": { + "iframe 1": { + "alignment": "block", + "options": { + "height": "550px", + "url": "https://learnstorm.typeform.com/to/fnQ2tw?", + "width": "100%" + }, + "type": "iframe" + } + } + } +} From cd0765a2ecb0a5ed97b8bd0b2c3fb3bb5cb6bcb5 Mon Sep 17 00:00:00 2001 From: Ben Christel Date: Tue, 7 Jan 2025 16:19:30 -0800 Subject: [PATCH 15/18] docs(changeset): Internal: Enable parsePerseusItem to handle all published content and upgrade old versions to the current format. --- .changeset/hot-cougars-laugh.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/hot-cougars-laugh.md diff --git a/.changeset/hot-cougars-laugh.md b/.changeset/hot-cougars-laugh.md new file mode 100644 index 0000000000..4f73aa085e --- /dev/null +++ b/.changeset/hot-cougars-laugh.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": patch +--- + +Internal: Enable parsePerseusItem to parse all published content, upgrading old formats to the current one. From 7a1cf0431a848bd8bf01f14e8f12276c0996f20f Mon Sep 17 00:00:00 2001 From: Ben Christel Date: Wed, 8 Jan 2025 10:34:49 -0800 Subject: [PATCH 16/18] Rename 'widget key' concept to 'widget ID' --- .../perseus-parsers/widgets-map.test.ts | 64 +++++++------ .../perseus-parsers/widgets-map.ts | 92 +++++++++---------- 2 files changed, 82 insertions(+), 74 deletions(-) diff --git a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/widgets-map.test.ts b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/widgets-map.test.ts index 3d2b4c62da..693ce3b280 100644 --- a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/widgets-map.test.ts +++ b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/widgets-map.test.ts @@ -31,11 +31,15 @@ describe("parseWidgetsMap", () => { const result = parse(widgetsMap, parseWidgetsMap); - expect(result).toEqual(anyFailure); + expect(result).toEqual( + failure( + `At (root).asdf["(widget ID)"] -- expected array of length 2, but got ["asdf"]`, + ), + ); }); - it("rejects a key with ID 0", () => { - // Widget keys with ID = 0 currently cause a full-page crash when the + it("rejects a widget ID numbered 0", () => { + // Widget IDs with 0 currently cause a full-page crash when the // exercise is rendered in webapp! const widgetsMap: unknown = { @@ -52,7 +56,35 @@ describe("parseWidgetsMap", () => { const result = parse(widgetsMap, parseWidgetsMap); expect(result).toEqual( failure( - `At (root)["radio 0"]["(widget key)"][1] -- expected a string representing a positive integer, but got "0"`, + `At (root)["radio 0"]["(widget ID)"][1] -- expected a string representing a positive integer, but got "0"`, + ), + ); + }); + + it("rejects a widget ID with no number", () => { + const widgetsMap: unknown = { + categorizer: {type: "categorizer"}, + }; + + const result = parse(widgetsMap, parseWidgetsMap); + + expect(result).toEqual( + failure( + `At (root).categorizer["(widget ID)"] -- expected array of length 2, but got ["categorizer"]`, + ), + ); + }); + + it("rejects an unknown widget type", () => { + const widgetsMap: unknown = { + "transmogrifier 1": {type: "transmogrifier"}, + }; + + const result = parse(widgetsMap, parseWidgetsMap); + + expect(result).toEqual( + failure( + `At (root)["transmogrifier 1"] -- expected a valid widget type, but got "transmogrifier"`, ), ); }); @@ -754,20 +786,6 @@ describe("parseWidgetsMap", () => { expect(result).toEqual(success(expected)); }); - it("rejects an unknown widget type", () => { - const widgetsMap: unknown = { - "transmogrifier 1": {type: "transmogrifier"}, - }; - - const result = parse(widgetsMap, parseWidgetsMap); - - expect(result).toEqual( - failure( - `At (root)["transmogrifier 1"] -- expected a valid widget type, but got "transmogrifier"`, - ), - ); - }); - it("accepts a dynamically-registered widget type without checking its options", () => { registerWidget("fake-widget-for-widgets-map-parser-test", { name: "fake-widget-for-widgets-map-parser-test", @@ -786,14 +804,4 @@ describe("parseWidgetsMap", () => { expect(result).toEqual(success(widgetsMap)); }); - - it("rejects a key with no ID", () => { - const widgetsMap: unknown = { - categorizer: {type: "categorizer"}, - }; - - const result = parse(widgetsMap, parseWidgetsMap); - - expect(result).toEqual(anyFailure); - }); }); diff --git a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/widgets-map.ts b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/widgets-map.ts index 3bde6f115c..565a2d522b 100644 --- a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/widgets-map.ts +++ b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/widgets-map.ts @@ -69,15 +69,15 @@ const parseWidgetsMapEntry: ( entry: [string, unknown], widgetMap: PerseusWidgetsMap, ctx: ParseContext, -) => ParseResult = ([key, widget], widgetMap, ctx) => { - const keyComponentsResult = parseWidgetMapKeyComponents( - key.split(" "), - ctx.forSubtree("(widget key)"), +) => ParseResult = ([id, widget], widgetMap, ctx) => { + const idComponentsResult = parseWidgetIdComponents( + id.split(" "), + ctx.forSubtree("(widget ID)"), ); - if (isFailure(keyComponentsResult)) { - return keyComponentsResult; + if (isFailure(idComponentsResult)) { + return idComponentsResult; } - const [type, id] = keyComponentsResult.value; + const [type, n] = idComponentsResult.value; function parseAndAssign( key: K, @@ -93,107 +93,107 @@ const parseWidgetsMapEntry: ( switch (type) { case "categorizer": - return parseAndAssign(`categorizer ${id}`, parseCategorizerWidget); + return parseAndAssign(`categorizer ${n}`, parseCategorizerWidget); case "cs-program": - return parseAndAssign(`cs-program ${id}`, parseCSProgramWidget); + return parseAndAssign(`cs-program ${n}`, parseCSProgramWidget); case "definition": - return parseAndAssign(`definition ${id}`, parseDefinitionWidget); + return parseAndAssign(`definition ${n}`, parseDefinitionWidget); case "dropdown": - return parseAndAssign(`dropdown ${id}`, parseDropdownWidget); + return parseAndAssign(`dropdown ${n}`, parseDropdownWidget); case "explanation": - return parseAndAssign(`explanation ${id}`, parseExplanationWidget); + return parseAndAssign(`explanation ${n}`, parseExplanationWidget); case "expression": - return parseAndAssign(`expression ${id}`, parseExpressionWidget); + return parseAndAssign(`expression ${n}`, parseExpressionWidget); case "grapher": - return parseAndAssign(`grapher ${id}`, parseGrapherWidget); + return parseAndAssign(`grapher ${n}`, parseGrapherWidget); case "group": - return parseAndAssign(`group ${id}`, parseGroupWidget); + return parseAndAssign(`group ${n}`, parseGroupWidget); case "graded-group": - return parseAndAssign(`graded-group ${id}`, parseGradedGroupWidget); + return parseAndAssign(`graded-group ${n}`, parseGradedGroupWidget); case "graded-group-set": return parseAndAssign( - `graded-group-set ${id}`, + `graded-group-set ${n}`, parseGradedGroupSetWidget, ); case "iframe": - return parseAndAssign(`iframe ${id}`, parseIframeWidget); + return parseAndAssign(`iframe ${n}`, parseIframeWidget); case "image": - return parseAndAssign(`image ${id}`, parseImageWidget); + return parseAndAssign(`image ${n}`, parseImageWidget); case "input-number": - return parseAndAssign(`input-number ${id}`, parseInputNumberWidget); + return parseAndAssign(`input-number ${n}`, parseInputNumberWidget); case "interaction": - return parseAndAssign(`interaction ${id}`, parseInteractionWidget); + return parseAndAssign(`interaction ${n}`, parseInteractionWidget); case "interactive-graph": return parseAndAssign( - `interactive-graph ${id}`, + `interactive-graph ${n}`, parseInteractiveGraphWidget, ); case "label-image": - return parseAndAssign(`label-image ${id}`, parseLabelImageWidget); + return parseAndAssign(`label-image ${n}`, parseLabelImageWidget); case "matcher": - return parseAndAssign(`matcher ${id}`, parseMatcherWidget); + return parseAndAssign(`matcher ${n}`, parseMatcherWidget); case "matrix": - return parseAndAssign(`matrix ${id}`, parseMatrixWidget); + return parseAndAssign(`matrix ${n}`, parseMatrixWidget); case "measurer": - return parseAndAssign(`measurer ${id}`, parseMeasurerWidget); + return parseAndAssign(`measurer ${n}`, parseMeasurerWidget); case "molecule-renderer": return parseAndAssign( - `molecule-renderer ${id}`, + `molecule-renderer ${n}`, parseMoleculeRendererWidget, ); case "number-line": - return parseAndAssign(`number-line ${id}`, parseNumberLineWidget); + return parseAndAssign(`number-line ${n}`, parseNumberLineWidget); case "numeric-input": return parseAndAssign( - `numeric-input ${id}`, + `numeric-input ${n}`, parseNumericInputWidget, ); case "orderer": - return parseAndAssign(`orderer ${id}`, parseOrdererWidget); + return parseAndAssign(`orderer ${n}`, parseOrdererWidget); case "passage": - return parseAndAssign(`passage ${id}`, parsePassageWidget); + return parseAndAssign(`passage ${n}`, parsePassageWidget); case "passage-ref": - return parseAndAssign(`passage-ref ${id}`, parsePassageRefWidget); + return parseAndAssign(`passage-ref ${n}`, parsePassageRefWidget); case "passage-ref-target": // NOTE(benchristel): as of 2024-11-12, passage-ref-target is only // used in test content. See: // https://www.khanacademy.org/devadmin/content/search?query=widget:passage-ref-target - return parseAndAssign(`passage-ref-target ${id}`, any); + return parseAndAssign(`passage-ref-target ${n}`, any); case "phet-simulation": return parseAndAssign( - `phet-simulation ${id}`, + `phet-simulation ${n}`, parsePhetSimulationWidget, ); case "plotter": - return parseAndAssign(`plotter ${id}`, parsePlotterWidget); + return parseAndAssign(`plotter ${n}`, parsePlotterWidget); case "python-program": return parseAndAssign( - `python-program ${id}`, + `python-program ${n}`, parsePythonProgramWidget, ); case "radio": - return parseAndAssign(`radio ${id}`, parseRadioWidget); + return parseAndAssign(`radio ${n}`, parseRadioWidget); case "sorter": - return parseAndAssign(`sorter ${id}`, parseSorterWidget); + return parseAndAssign(`sorter ${n}`, parseSorterWidget); case "table": - return parseAndAssign(`table ${id}`, parseTableWidget); + return parseAndAssign(`table ${n}`, parseTableWidget); case "video": - return parseAndAssign(`video ${id}`, parseVideoWidget); + return parseAndAssign(`video ${n}`, parseVideoWidget); case "sequence": // sequence is a deprecated widget type, and the corresponding // widget component no longer exists. - return parseAndAssign(`sequence ${id}`, parseDeprecatedWidget); + return parseAndAssign(`sequence ${n}`, parseDeprecatedWidget); case "lights-puzzle": - return parseAndAssign(`lights-puzzle ${id}`, parseDeprecatedWidget); + return parseAndAssign(`lights-puzzle ${n}`, parseDeprecatedWidget); case "simulator": - return parseAndAssign(`simulator ${id}`, parseDeprecatedWidget); + return parseAndAssign(`simulator ${n}`, parseDeprecatedWidget); case "transformer": - return parseAndAssign(`transformer ${id}`, parseDeprecatedWidget); + return parseAndAssign(`transformer ${n}`, parseDeprecatedWidget); default: if (getWidget(type)) { // @ts-expect-error - 'type' is not a valid widget type - return parseAndAssign(`${type} ${id}`, any); + return parseAndAssign(`${type} ${n}`, any); } return ctx.failure("a valid widget type", type); } @@ -216,4 +216,4 @@ const parseStringToPositiveInt: Parser = (rawValue, ctx) => { return ctx.success(+rawValue); }; -const parseWidgetMapKeyComponents = pair(string, parseStringToPositiveInt); +const parseWidgetIdComponents = pair(string, parseStringToPositiveInt); From d97eda57c75e684c5ea0f507b97c8bd99c2b8076 Mon Sep 17 00:00:00 2001 From: Ben Christel Date: Fri, 10 Jan 2025 15:34:15 -0800 Subject: [PATCH 17/18] Fix lint --- .../parse-perseus-json/perseus-parsers/interaction-widget.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/interaction-widget.ts b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/interaction-widget.ts index 566538505a..0264398da7 100644 --- a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/interaction-widget.ts +++ b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/interaction-widget.ts @@ -18,11 +18,11 @@ import {discriminatedUnionOn} from "../general-purpose-parsers/discriminated-uni import {parsePerseusImageBackground} from "./perseus-image-background"; import {parseWidget} from "./widget"; +import type {Parser} from "../parser-types"; import type { InteractionWidget, PerseusInteractionElement, } from "@khanacademy/perseus-core"; -import type {Parser} from "../parser-types"; const pairOfNumbers = pair(number, number); const stringOrEmpty = defaulted(string, () => ""); From 681b0792cd3c1b6f31e5442bd5d90b3cad1d9937 Mon Sep 17 00:00:00 2001 From: Ben Christel Date: Fri, 10 Jan 2025 15:41:45 -0800 Subject: [PATCH 18/18] Include perseus-core in changeset --- .changeset/hot-cougars-laugh.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.changeset/hot-cougars-laugh.md b/.changeset/hot-cougars-laugh.md index 4f73aa085e..979314f7c9 100644 --- a/.changeset/hot-cougars-laugh.md +++ b/.changeset/hot-cougars-laugh.md @@ -1,5 +1,6 @@ --- -"@khanacademy/perseus": patch +"@khanacademy/perseus": minor +"@khanacademy/perseus-core": minor --- -Internal: Enable parsePerseusItem to parse all published content, upgrading old formats to the current one. +Enable parsePerseusItem to parse all published content, upgrading old formats to the current one.