diff --git a/.changeset/many-cats-run.md b/.changeset/many-cats-run.md new file mode 100644 index 0000000000..9c376f27da --- /dev/null +++ b/.changeset/many-cats-run.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": patch +--- + +Internal: Redesign discriminated union type parser to have a simpler and more intuitive interface. diff --git a/.github/workflows/node-ci.yml b/.github/workflows/node-ci.yml index f6bc19192b..f092c603e0 100644 --- a/.github/workflows/node-ci.yml +++ b/.github/workflows/node-ci.yml @@ -242,7 +242,7 @@ jobs: # Releases are triggered by merging "Version Packages" PRs. # So we look for instances of the release.yml workflow, with # a title containing "Version Packages", that are in progress. - release_count=$(gh run list --workflow release.yml --json status,displayTitle --jq '[.[] | select(.status == "in_progress" and (.displayTitle | contains("Version Packages")))] | length') + release_count=$(gh run list --workflow release.yml --json status,displayTitle --jq '[.[] | select(.status == "in_progress" and ((.displayTitle | contains("Version Packages")) or (.displayTitle | contains("RELEASING:"))))] | length') echo "release_count=$release_count" >> $GITHUB_OUTPUT if [ "$release_count" -ne 0 ]; then echo "Error: There are $release_count releases in progress." diff --git a/dev/CHANGELOG.md b/dev/CHANGELOG.md index 016a1ccba2..6f3ddb6ef2 100644 --- a/dev/CHANGELOG.md +++ b/dev/CHANGELOG.md @@ -1,5 +1,12 @@ # @khanacademy/perseus-dev-ui +## 5.0.10 + +### Patch Changes + +- Updated dependencies [[`dbbc82f2d`](https://github.com/Khan/perseus/commit/dbbc82f2dd33545b12c6073174b05ebcf8d551ba)]: + - @khanacademy/math-input@22.1.0 + ## 5.0.9 ### Patch Changes diff --git a/dev/package.json b/dev/package.json index fd9b2216d9..49d9048576 100644 --- a/dev/package.json +++ b/dev/package.json @@ -3,7 +3,7 @@ "description": "Perseus dev UI", "author": "Khan Academy", "license": "MIT", - "version": "5.0.9", + "version": "5.0.10", "private": true, "repository": { "type": "git", @@ -16,7 +16,7 @@ "dependencies": { "@khanacademy/kas": "^0.4.9", "@khanacademy/kmath": "^0.1.24", - "@khanacademy/math-input": "^22.0.7", + "@khanacademy/math-input": "^22.1.0", "@khanacademy/perseus-core": "3.0.5", "@khanacademy/perseus-linter": "^1.2.11", "@khanacademy/pure-markdown": "^0.3.20", diff --git a/packages/math-input/CHANGELOG.md b/packages/math-input/CHANGELOG.md index af188b0d6e..1a2b3baea2 100644 --- a/packages/math-input/CHANGELOG.md +++ b/packages/math-input/CHANGELOG.md @@ -1,5 +1,11 @@ # @khanacademy/math-input +## 22.1.0 + +### Minor Changes + +- [#1738](https://github.com/Khan/perseus/pull/1738) [`dbbc82f2d`](https://github.com/Khan/perseus/commit/dbbc82f2dd33545b12c6073174b05ebcf8d551ba) Thanks [@anakaren-rojas](https://github.com/anakaren-rojas)! - add scientific notation button / toggle to basic keypad + ## 22.0.7 ### Patch Changes diff --git a/packages/math-input/package.json b/packages/math-input/package.json index d69e91fc84..7d2b2a170a 100644 --- a/packages/math-input/package.json +++ b/packages/math-input/package.json @@ -3,7 +3,7 @@ "description": "Khan Academy's new expression editor for the mobile web.", "author": "Khan Academy", "license": "MIT", - "version": "22.0.7", + "version": "22.1.0", "publishConfig": { "access": "public" }, diff --git a/packages/math-input/src/components/keypad/keypad-pages/numbers-page.tsx b/packages/math-input/src/components/keypad/keypad-pages/numbers-page.tsx index 4894064f8c..cca36b7889 100644 --- a/packages/math-input/src/components/keypad/keypad-pages/numbers-page.tsx +++ b/packages/math-input/src/components/keypad/keypad-pages/numbers-page.tsx @@ -8,10 +8,10 @@ import type {ClickKeyCallback} from "../../../types"; type Props = { onClickKey: ClickKeyCallback; + scientific?: boolean; }; -export default function NumbersPage(props: Props) { - const {onClickKey} = props; +export default function NumbersPage({onClickKey, scientific}: Props) { const {strings} = useMathInputI18n(); const Keys = KeyConfigs(strings); // These keys are arranged sequentially so that tabbing follows numerical order. This @@ -92,6 +92,14 @@ export default function NumbersPage(props: Props) { coord={[3, 0]} secondary /> + {scientific && ( + + )} ); } diff --git a/packages/math-input/src/components/keypad/keypad.tsx b/packages/math-input/src/components/keypad/keypad.tsx index 94aa85570e..247cbc056e 100644 --- a/packages/math-input/src/components/keypad/keypad.tsx +++ b/packages/math-input/src/components/keypad/keypad.tsx @@ -34,6 +34,7 @@ export type Props = { basicRelations?: boolean; advancedRelations?: boolean; fractionsOnly?: boolean; + scientific?: boolean; onClickKey: ClickKeyCallback; onAnalyticsEvent: AnalyticsEventHandlerFn; @@ -89,6 +90,7 @@ export default function Keypad({extraKeys = [], ...props}: Props) { logarithms, basicRelations, advancedRelations, + scientific, showDismiss, onAnalyticsEvent, fractionsOnly, @@ -155,7 +157,10 @@ export default function Keypad({extraKeys = [], ...props}: Props) { /> )} {selectedPage === "Numbers" && ( - + )} {selectedPage === "Extras" && ( expandedViewThreshold } showDismiss + scientific={ + isExpression && keypadConfig?.scientific + } /> ) : null} diff --git a/packages/math-input/src/types.ts b/packages/math-input/src/types.ts index 972d67fc3f..8008a2f5a0 100644 --- a/packages/math-input/src/types.ts +++ b/packages/math-input/src/types.ts @@ -27,6 +27,7 @@ export type KeypadConfiguration = { keypadType: KeypadType; extraKeys?: ReadonlyArray; times?: boolean; + scientific?: boolean; }; export type KeyHandler = (key: Key) => Cursor; diff --git a/packages/perseus-editor/CHANGELOG.md b/packages/perseus-editor/CHANGELOG.md index ab3c329550..51102875df 100644 --- a/packages/perseus-editor/CHANGELOG.md +++ b/packages/perseus-editor/CHANGELOG.md @@ -1,5 +1,24 @@ # @khanacademy/perseus-editor +## 17.1.0 + +### Minor Changes + +- [#1738](https://github.com/Khan/perseus/pull/1738) [`dbbc82f2d`](https://github.com/Khan/perseus/commit/dbbc82f2dd33545b12c6073174b05ebcf8d551ba) Thanks [@anakaren-rojas](https://github.com/anakaren-rojas)! - add scientific notation button / toggle to basic keypad + +### Patch Changes + +- Updated dependencies [[`d8b2f7eaf`](https://github.com/Khan/perseus/commit/d8b2f7eaff83062516ad1e273c17fd6579716265), [`bac10129b`](https://github.com/Khan/perseus/commit/bac10129b523d61904a88ef3c7dbfcad2bd18750), [`37c642f24`](https://github.com/Khan/perseus/commit/37c642f24e645db954895510ba40bede94e09889), [`617377147`](https://github.com/Khan/perseus/commit/61737714796dfb8434fc139471d1add3c18853b3), [`dbbc82f2d`](https://github.com/Khan/perseus/commit/dbbc82f2dd33545b12c6073174b05ebcf8d551ba), [`53ba9f5d1`](https://github.com/Khan/perseus/commit/53ba9f5d136f817257188ccf2696a8b91896ba72)]: + - @khanacademy/perseus@49.2.0 + - @khanacademy/math-input@22.1.0 + +## 17.0.12 + +### Patch Changes + +- Updated dependencies [[`f23b383e7`](https://github.com/Khan/perseus/commit/f23b383e797a522ddee064c79e582467dfc08f94)]: + - @khanacademy/perseus@49.1.7 + ## 17.0.11 ### Patch Changes diff --git a/packages/perseus-editor/package.json b/packages/perseus-editor/package.json index 46bb660b5c..f58b132164 100644 --- a/packages/perseus-editor/package.json +++ b/packages/perseus-editor/package.json @@ -3,7 +3,7 @@ "description": "Perseus editors", "author": "Khan Academy", "license": "MIT", - "version": "17.0.11", + "version": "17.1.0", "publishConfig": { "access": "public" }, @@ -38,8 +38,8 @@ "@khanacademy/kas": "^0.4.9", "@khanacademy/keypad-context": "^1.0.12", "@khanacademy/kmath": "^0.1.24", - "@khanacademy/math-input": "^22.0.7", - "@khanacademy/perseus": "^49.1.6", + "@khanacademy/math-input": "^22.1.0", + "@khanacademy/perseus": "^49.2.0", "@khanacademy/perseus-core": "3.0.5", "@khanacademy/pure-markdown": "^0.3.20", "mafs": "^0.19.0" diff --git a/packages/perseus-editor/src/widgets/__tests__/expression-editor.test.tsx b/packages/perseus-editor/src/widgets/__tests__/expression-editor.test.tsx index 618152f417..aa5676e169 100644 --- a/packages/perseus-editor/src/widgets/__tests__/expression-editor.test.tsx +++ b/packages/perseus-editor/src/widgets/__tests__/expression-editor.test.tsx @@ -217,6 +217,23 @@ describe("expression-editor", () => { }); }); + it("should toggle scientific checkbox", async () => { + const onChangeMock = jest.fn(); + + render(); + act(() => jest.runOnlyPendingTimers()); + + await userEvent.click( + screen.getByRole("checkbox", { + name: "scientific", + }), + ); + + expect(onChangeMock).toBeCalledWith({ + buttonSets: ["basic", "scientific"], + }); + }); + it("should be possible to add an answer", async () => { const onChangeMock = jest.fn(); diff --git a/packages/perseus-editor/src/widgets/expression-editor.tsx b/packages/perseus-editor/src/widgets/expression-editor.tsx index 1c1a6f9f39..3bc07d7681 100644 --- a/packages/perseus-editor/src/widgets/expression-editor.tsx +++ b/packages/perseus-editor/src/widgets/expression-editor.tsx @@ -53,6 +53,7 @@ const buttonSetsList: LegacyButtonSets = [ "trig", "prealgebra", "logarithms", + "scientific", "basic relations", "advanced relations", ]; diff --git a/packages/perseus/CHANGELOG.md b/packages/perseus/CHANGELOG.md index a7660876c6..9f4f0574ab 100644 --- a/packages/perseus/CHANGELOG.md +++ b/packages/perseus/CHANGELOG.md @@ -1,5 +1,32 @@ # @khanacademy/perseus +## 49.2.0 + +### Minor Changes + +- [#1990](https://github.com/Khan/perseus/pull/1990) [`37c642f24`](https://github.com/Khan/perseus/commit/37c642f24e645db954895510ba40bede94e09889) Thanks [@SonicScrewdriver](https://github.com/SonicScrewdriver)! - Allow keyboards to navigate and interact with images + +* [#1738](https://github.com/Khan/perseus/pull/1738) [`dbbc82f2d`](https://github.com/Khan/perseus/commit/dbbc82f2dd33545b12c6073174b05ebcf8d551ba) Thanks [@anakaren-rojas](https://github.com/anakaren-rojas)! - add scientific notation button / toggle to basic keypad + +### Patch Changes + +- [#2061](https://github.com/Khan/perseus/pull/2061) [`d8b2f7eaf`](https://github.com/Khan/perseus/commit/d8b2f7eaff83062516ad1e273c17fd6579716265) Thanks [@anakaren-rojas](https://github.com/anakaren-rojas)! - update terminology for angle sides + +* [#2071](https://github.com/Khan/perseus/pull/2071) [`bac10129b`](https://github.com/Khan/perseus/commit/bac10129b523d61904a88ef3c7dbfcad2bd18750) Thanks [@SonicScrewdriver](https://github.com/SonicScrewdriver)! - This patch fixes our Perseus strings to ensure that they are double escaped for Lingui. + +- [#1952](https://github.com/Khan/perseus/pull/1952) [`617377147`](https://github.com/Khan/perseus/commit/61737714796dfb8434fc139471d1add3c18853b3) Thanks [@benchristel](https://github.com/benchristel)! - Internal: add and pass more regression tests for PerseusItem parser + +* [#2059](https://github.com/Khan/perseus/pull/2059) [`53ba9f5d1`](https://github.com/Khan/perseus/commit/53ba9f5d136f817257188ccf2696a8b91896ba72) Thanks [@mark-fitzgerald](https://github.com/mark-fitzgerald)! - [Dropdown] Bugfix - Text in dropdown was shifted up after adding TeX support via Renderer + +* Updated dependencies [[`dbbc82f2d`](https://github.com/Khan/perseus/commit/dbbc82f2dd33545b12c6073174b05ebcf8d551ba)]: + - @khanacademy/math-input@22.1.0 + +## 49.1.7 + +### Patch Changes + +- [#2054](https://github.com/Khan/perseus/pull/2054) [`f23b383e7`](https://github.com/Khan/perseus/commit/f23b383e797a522ddee064c79e582467dfc08f94) Thanks [@mark-fitzgerald](https://github.com/mark-fitzgerald)! - [Dropdown] Bugfix - Render options and placeholder inline + ## 49.1.6 ### Patch Changes diff --git a/packages/perseus/package.json b/packages/perseus/package.json index b0fe93f068..e69a02aa3d 100644 --- a/packages/perseus/package.json +++ b/packages/perseus/package.json @@ -3,7 +3,7 @@ "description": "Core Perseus API (includes renderers and widgets)", "author": "Khan Academy", "license": "MIT", - "version": "49.1.6", + "version": "49.2.0", "publishConfig": { "access": "public" }, @@ -44,7 +44,7 @@ "@khanacademy/kas": "^0.4.9", "@khanacademy/keypad-context": "^1.0.12", "@khanacademy/kmath": "^0.1.24", - "@khanacademy/math-input": "^22.0.7", + "@khanacademy/math-input": "^22.1.0", "@khanacademy/perseus-core": "3.0.5", "@khanacademy/perseus-linter": "^1.2.11", "@khanacademy/pure-markdown": "^0.3.20", diff --git a/packages/perseus/src/__tests__/__snapshots__/renderer.test.tsx.snap b/packages/perseus/src/__tests__/__snapshots__/renderer.test.tsx.snap index 0b73d6e928..47546c08f2 100644 --- a/packages/perseus/src/__tests__/__snapshots__/renderer.test.tsx.snap +++ b/packages/perseus/src/__tests__/__snapshots__/renderer.test.tsx.snap @@ -355,7 +355,7 @@ exports[`renderer snapshots correct answer: correct answer 1`] = ` Test visible label
- less than or equal to +
+ less than or equal to +
@@ -443,7 +447,7 @@ exports[`renderer snapshots incorrect answer: incorrect answer 1`] = ` Test visible label
- greater than or equal to +
+ greater than or equal to +
@@ -531,7 +539,7 @@ exports[`renderer snapshots initial render: initial render 1`] = ` Test visible label
- greater -
-
- /less than or equal to +
+ greater/less than or equal to +
diff --git a/packages/perseus/src/components/__stories__/math-input.stories.tsx b/packages/perseus/src/components/__stories__/math-input.stories.tsx index dcc4be0fb6..7fb433f263 100644 --- a/packages/perseus/src/components/__stories__/math-input.stories.tsx +++ b/packages/perseus/src/components/__stories__/math-input.stories.tsx @@ -15,6 +15,7 @@ const meta: Meta = { logarithms: true, preAlgebra: true, trigonometry: true, + scientific: true, }, convertDotToTimes: false, value: "", diff --git a/packages/perseus/src/components/__tests__/__snapshots__/svg-image.test.tsx.snap b/packages/perseus/src/components/__tests__/__snapshots__/svg-image.test.tsx.snap index f90b906165..07cf156ad9 100644 --- a/packages/perseus/src/components/__tests__/__snapshots__/svg-image.test.tsx.snap +++ b/packages/perseus/src/components/__tests__/__snapshots__/svg-image.test.tsx.snap @@ -8,6 +8,7 @@ exports[`SvgImage should load and render a localized graphie svg 1`] = ` svg image @@ -21,6 +22,7 @@ exports[`SvgImage should load and render a normal graphie svg 1`] = ` svg image @@ -31,6 +33,7 @@ exports[`SvgImage should load and render a png 1`] = ` png image `; diff --git a/packages/perseus/src/components/__tests__/math-input.test.tsx b/packages/perseus/src/components/__tests__/math-input.test.tsx index 56dbe88d13..784a9a8b33 100644 --- a/packages/perseus/src/components/__tests__/math-input.test.tsx +++ b/packages/perseus/src/components/__tests__/math-input.test.tsx @@ -6,15 +6,17 @@ import {testDependencies} from "../../../../../testing/test-dependencies"; import * as Dependencies from "../../dependencies"; import MathInput from "../math-input"; +import type {KeypadButtonSets} from "../math-input"; import type {UserEvent} from "@testing-library/user-event"; -const allButtonSets = { +const allButtonSets: KeypadButtonSets = { advancedRelations: true, basicRelations: true, divisionKey: true, logarithms: true, preAlgebra: true, trigonometry: true, + scientific: true, }; describe("Perseus' MathInput", () => { @@ -142,6 +144,35 @@ describe("Perseus' MathInput", () => { expect(mockOnChange).toHaveBeenLastCalledWith("1+2-3"); }); + it("is possible to use the scientific keypad", async () => { + // Arrange + const mockOnChange = jest.fn(); + render( + Promise.resolve()} + convertDotToTimes={false} + value="" + />, + ); + act(() => jest.runOnlyPendingTimers()); + + // Act + await userEvent.click( + screen.getByRole("button", {name: /open math keypad/}), + ); + await userEvent.click(screen.getByRole("button", {name: "2"})); + await userEvent.click( + screen.getByRole("button", {name: "Custom exponent"}), + ); + await userEvent.click(screen.getByRole("button", {name: "2"})); + act(() => jest.runOnlyPendingTimers()); + + // Assert + expect(mockOnChange).toHaveBeenLastCalledWith("2^{2}"); + }); + it("is possible to use buttons with legacy props", async () => { // Arrange const mockOnChange = jest.fn(); diff --git a/packages/perseus/src/components/image-loader.tsx b/packages/perseus/src/components/image-loader.tsx index e4c3420af5..ad3953c424 100644 --- a/packages/perseus/src/components/image-loader.tsx +++ b/packages/perseus/src/components/image-loader.tsx @@ -144,6 +144,7 @@ class ImageLoader extends React.Component { return ( { case "trig": keypadButtonSets.trigonometry = true; break; + case "scientific": + keypadButtonSets.scientific = true; + break; case "basic": default: break; diff --git a/packages/perseus/src/perseus-types.ts b/packages/perseus/src/perseus-types.ts index 6655b21323..22cf7ae888 100644 --- a/packages/perseus/src/perseus-types.ts +++ b/packages/perseus/src/perseus-types.ts @@ -160,6 +160,7 @@ export interface PerseusWidgetTypes { video: VideoWidget; // Deprecated widgets + "lights-puzzle": AutoCorrectWidget; sequence: AutoCorrectWidget; } @@ -512,6 +513,7 @@ export type LegacyButtonSets = ReadonlyArray< | "logarithms" | "basic relations" | "advanced relations" + | "scientific" >; export type PerseusExpressionWidgetOptions = { @@ -1218,7 +1220,7 @@ export type PerseusNumericInputAnswer = { // Translatable Display; A description for why this answer is correct, wrong, or ungraded message: string; // The expected answer - value?: number; + value?: number | null; // Whether this answer is "correct", "wrong", or "ungraded" status: string; // The forms available for this answer. Options: "integer, ""decimal", "proper", "improper", "mixed", or "pi" @@ -1297,7 +1299,7 @@ export type PerseusPassageRefWidgetOptions = { // The reference number referenceNumber: number; // Short summary of the referenced section. This will be included in parentheses and quotes automatically. - summaryText: string; + summaryText?: string; }; export const plotterPlotTypes = [ diff --git a/packages/perseus/src/strings.ts b/packages/perseus/src/strings.ts index 2443644c8c..ed3eba5422 100644 --- a/packages/perseus/src/strings.ts +++ b/packages/perseus/src/strings.ts @@ -245,18 +245,18 @@ export type PerseusStrings = { angleMeasure, vertexX, vertexY, - isX, - isY, - tsX, - tsY, + startingSideX, + startingSideY, + endingSideX, + endingSideY, }: { angleMeasure: string; vertexX: string; vertexY: string; - isX: string; - isY: string; - tsX: string; - tsY: string; + startingSideX: string; + startingSideY: string; + endingSideX: string; + endingSideY: string; }) => string; // The above strings are used for interactive graph SR descriptions. }; @@ -264,6 +264,7 @@ export type PerseusStrings = { /** * Untranslated strings used in Perseus. To be used by an external * translator to produce translated strings, passed in as `PerseusStrings`. + * !! Note: Ensure that all escape sequences are double-escaped. (e.g. `\\text` -> `\\\\text`) */ export const strings: { [key in keyof PerseusStrings]: @@ -282,8 +283,8 @@ export const strings: { "Your answer is close, but you may " + "have approximated pi. Enter your " + "answer as a multiple of pi, like " + - "12\\ \\text{pi} or " + - "2/3\\ \\text{pi}", + "12\\\\ \\\\text{pi} or " + + "2/3\\\\ \\\\text{pi}", EXTRA_SYMBOLS_ERROR: "We could not understand your " + "answer. Please check your answer for extra " + @@ -293,7 +294,7 @@ export const strings: { MISSING_PERCENT_ERROR: "Your answer is almost correct, " + "but it is missing a " + - "\\% at the end.", + "\\\\% at the end.", MULTIPLICATION_SIGN_ERROR: "I'm a computer. I only understand " + "multiplication if you use an asterisk " + @@ -330,10 +331,11 @@ export const strings: { simplifiedProperExample: "a *simplified proper* fraction, like $3/5$", improperExample: "an *improper* fraction, like $10/7$ or $14/8$", simplifiedImproperExample: "a *simplified improper* fraction, like $7/4$", - mixedExample: "a mixed number, like $1\\ 3/4$", + mixedExample: "a mixed number, like $1\\\\ 3/4$", decimalExample: "an *exact* decimal, like $0.75$", - percentExample: "a percent, like $12.34\\%$", - piExample: "a multiple of pi, like $12\\ \\text{pi}$ or $2/3\\ \\text{pi}$", + percentExample: "a percent, like $12.34\\\\%$", + piExample: + "a multiple of pi, like $12\\\\ \\\\text{pi}$ or $2/3\\\\ \\\\text{pi}$", yourAnswer: "**Your answer should be** ", yourAnswerLabel: "Your answer:", addPoints: "Click to add points", @@ -543,12 +545,16 @@ export const strings: { message: "Point 2, vertex at %(x)s comma %(y)s. Angle %(angleMeasure)s degrees", }, - srAngleGraphAriaLabel: "An angle on a coordinate plane.", + srAngleGraphAriaLabel: { + context: + "Screenreader-accessible label of an angle graph on a coordinate plane", + message: "An angle on a coordinate plane.", + }, srAngleGraphAriaDescription: { context: "Screenreader-only description of an angle on a coordinate plane.", message: - "The angle measure is %(angleMeasure)s degrees with a vertex at %(vertexX)s comma %(vertexY)s, a point on the initial side at %(isX)s comma %(isY)s and a point on the terminal side at %(tsX)s comma %(tsY)s", + "The angle measure is %(angleMeasure)s degrees with a vertex at %(vertexX)s comma %(vertexY)s, a point on the starting side at %(startingSideX)s comma %(startingSideY)s and a point on the ending side at %(endingSideX)s comma %(endingSideY)s", }, // The above strings are used for interactive graph SR descriptions. }; @@ -761,11 +767,11 @@ export const mockStrings: PerseusStrings = { angleMeasure, vertexX, vertexY, - isX, - isY, - tsX, - tsY, + startingSideX, + startingSideY, + endingSideX, + endingSideY, }) => - `The angle measure is ${angleMeasure} degrees with a vertex at ${vertexX} comma ${vertexY}, a point on the initial side at ${isX} comma ${isY} and a point on the terminal side at ${tsX} comma ${tsY}.`, + `The angle measure is ${angleMeasure} degrees with a vertex at ${vertexX} comma ${vertexY}, a point on the starting side at ${startingSideX} comma ${startingSideY} and a point on the ending side at ${endingSideX} comma ${endingSideY}.`, // The above strings are used for interactive graph SR descriptions. }; diff --git a/packages/perseus/src/styles/perseus-renderer.less b/packages/perseus/src/styles/perseus-renderer.less index e19530ae4e..3d4cb36791 100644 --- a/packages/perseus/src/styles/perseus-renderer.less +++ b/packages/perseus/src/styles/perseus-renderer.less @@ -414,6 +414,12 @@ padding: 25px 25px 0 0; } +.perseus-dropdown .perseus-renderer .paragraph { + /* overriding overly broad selectors in .framework-perseus rules */ + margin-bottom: 0 !important; + font-size: 18px !important; +} + @import "./widgets/categorizer.less"; @import "./widgets/dropdown.less"; @import "./widgets/expression.less"; diff --git a/packages/perseus/src/util/parse-perseus-json/general-purpose-parsers/convert.ts b/packages/perseus/src/util/parse-perseus-json/general-purpose-parsers/convert.ts new file mode 100644 index 0000000000..849e112854 --- /dev/null +++ b/packages/perseus/src/util/parse-perseus-json/general-purpose-parsers/convert.ts @@ -0,0 +1,7 @@ +import type {PartialParser} from "../parser-types"; + +// Given a function, creates a PartialParser that converts one type to another +// using that function. The returned parser never fails. +export function convert(f: (value: A) => B): PartialParser { + return (rawValue, ctx) => ctx.success(f(rawValue)); +} diff --git a/packages/perseus/src/util/parse-perseus-json/general-purpose-parsers/discriminated-union.test.ts b/packages/perseus/src/util/parse-perseus-json/general-purpose-parsers/discriminated-union.test.ts new file mode 100644 index 0000000000..635de19fff --- /dev/null +++ b/packages/perseus/src/util/parse-perseus-json/general-purpose-parsers/discriminated-union.test.ts @@ -0,0 +1,119 @@ +import {parse} from "../parse"; +import {failure, success} from "../result"; + +import {constant} from "./constant"; +import {discriminatedUnionOn} from "./discriminated-union"; +import {number} from "./number"; +import {object} from "./object"; + +describe("a discriminatedUnion with no variants", () => { + const parseUnion = discriminatedUnionOn("shape").parser; + + it("fails appropriately given a non-object", () => { + expect(parse(true, parseUnion)).toEqual( + failure("At (root) -- expected object, but got true"), + ); + }); + + it("fails appropriately given an object without the discriminant key", () => { + expect(parse({}, parseUnion)).toEqual( + failure( + "At (root).shape -- expected a valid value, but got undefined", + ), + ); + }); + + it("fails appropriately given an object with the discriminant key", () => { + expect(parse({shape: "squarle"}, parseUnion)).toEqual( + failure( + `At (root).shape -- expected a valid value, but got "squarle"`, + ), + ); + }); +}); + +describe("a discriminatedUnion with one variant", () => { + const parseCircle = object({shape: constant("circle"), radius: number}); + const parseUnion = discriminatedUnionOn("shape").withBranch( + "circle", + parseCircle, + ).parser; + + it("fails appropriately given a non-object", () => { + expect(parse(true, parseUnion)).toEqual( + failure("At (root) -- expected object, but got true"), + ); + }); + + it("fails appropriately given an object without the discriminant key", () => { + expect(parse({}, parseUnion)).toEqual( + failure( + "At (root).shape -- expected a valid value, but got undefined", + ), + ); + }); + + it("fails appropriately given an object with an invalid discriminant", () => { + expect(parse({shape: "squarle"}, parseUnion)).toEqual( + failure( + `At (root).shape -- expected a valid value, but got "squarle"`, + ), + ); + }); + + it("succeeds given a valid object", () => { + const input = {shape: "circle", radius: 3}; + expect(parse(input, parseUnion)).toEqual(success(input)); + }); +}); + +describe("a discriminatedUnion with two variants", () => { + const parseCircle = object({shape: constant("circle"), radius: number}); + const parseRectangle = object({ + shape: constant("rectangle"), + width: number, + height: number, + }); + const parseUnion = discriminatedUnionOn("shape") + .withBranch("circle", parseCircle) + .withBranch("rectangle", parseRectangle).parser; + + it("fails appropriately given a non-object", () => { + expect(parse(true, parseUnion)).toEqual( + failure("At (root) -- expected object, but got true"), + ); + }); + + it("fails appropriately given an object without the discriminant key", () => { + expect(parse({}, parseUnion)).toEqual( + failure( + "At (root).shape -- expected a valid value, but got undefined", + ), + ); + }); + + it("fails appropriately given an object with an invalid discriminant", () => { + expect(parse({shape: "squarle"}, parseUnion)).toEqual( + failure( + `At (root).shape -- expected a valid value, but got "squarle"`, + ), + ); + }); + + it("successfully parses the first branch", () => { + const input = {shape: "circle", radius: 3}; + expect(parse(input, parseUnion)).toEqual(success(input)); + }); + + it("successfully parses the second branch", () => { + const input = {shape: "rectangle", width: 2, height: 4}; + expect(parse(input, parseUnion)).toEqual(success(input)); + }); + + it("doesn't try other branches after finding one that matches the discriminant key", () => { + const input = {shape: "circle", width: 2, height: 4}; + expect(parse(input, parseUnion)).toEqual( + failure(`At (root).radius -- expected number, but got undefined`), + ); + }); +}); diff --git a/packages/perseus/src/util/parse-perseus-json/general-purpose-parsers/discriminated-union.ts b/packages/perseus/src/util/parse-perseus-json/general-purpose-parsers/discriminated-union.ts new file mode 100644 index 0000000000..476bac6209 --- /dev/null +++ b/packages/perseus/src/util/parse-perseus-json/general-purpose-parsers/discriminated-union.ts @@ -0,0 +1,76 @@ +import {isObject} from "./is-object"; + +import type {ParseContext, Parser} from "../parser-types"; + +type Primitive = number | string | boolean | null | undefined; + +/** + * discriminatedUnion() should be preferred over union() when parsing a + * discriminated union type, because discriminatedUnion() produces more + * understandable failure messages. It takes the discriminant as the source of + * truth for which variant is to be parsed, and expects the other data to match + * that variant. + */ +export function discriminatedUnionOn(discriminantKey: DK) { + const noMoreBranches: Parser = (raw: unknown, ctx: ParseContext) => { + if (!isObject(raw)) { + return ctx.failure("object", raw); + } + return ctx + .forSubtree(discriminantKey) + .failure("a valid value", raw[discriminantKey]); + }; + + return new DiscriminatedUnionBuilder(discriminantKey, noMoreBranches); +} + +class DiscriminatedUnionBuilder< + DK extends string, + Union extends {[k in DK]: Primitive}, +> { + constructor( + private discriminantKey: DK, + public parser: Parser, + ) {} + + withBranch( + discriminantValue: Primitive, + parseNewVariant: Parser, + ): DiscriminatedUnionBuilder { + const parseNewBranch = discriminatedUnionBranch( + this.discriminantKey, + discriminantValue, + parseNewVariant, + this.parser, + ); + + return new DiscriminatedUnionBuilder( + this.discriminantKey, + parseNewBranch, + ); + } +} + +function discriminatedUnionBranch< + DK extends string, + DV extends Primitive, + Variant extends {[k in DK]: DV}, + Rest extends {[k in DK]: DV}, +>( + discriminantKey: DK, + discriminantValue: DV, + parseVariant: Parser, + parseOtherBranches: Parser, +): Parser { + return (raw: unknown, ctx: ParseContext) => { + if (!isObject(raw)) { + return ctx.failure("object", raw); + } + + if (raw[discriminantKey] === discriminantValue) { + return parseVariant(raw, ctx); + } + + return parseOtherBranches(raw, ctx); + }; +} diff --git a/packages/perseus/src/util/parse-perseus-json/general-purpose-parsers/discriminated-union.typetest.ts b/packages/perseus/src/util/parse-perseus-json/general-purpose-parsers/discriminated-union.typetest.ts new file mode 100644 index 0000000000..a4e060cd7b --- /dev/null +++ b/packages/perseus/src/util/parse-perseus-json/general-purpose-parsers/discriminated-union.typetest.ts @@ -0,0 +1,65 @@ +import {constant} from "./constant"; +import {discriminatedUnionOn} from "./discriminated-union"; +import {number} from "./number"; +import {object} from "./object"; + +import type {Parser} from "../parser-types"; + +type Figure = + | {shape: "circle"; radius: number} + | {shape: "rectangle"; width: number; height: number} + | {shape: "square"; sideLength: number}; + +const parseCircle = object({ + shape: constant("circle"), + radius: number, +}); + +const parseRectangle = object({ + shape: constant("rectangle"), + width: number, + height: number, +}); + +const parseSquare = object({ + shape: constant("square"), + sideLength: number, +}); + +// Test: parsed result is assignable to the union type +{ + const parser = discriminatedUnionOn("shape") + .withBranch("circle", parseCircle) + .withBranch("rectangle", parseRectangle) + .withBranch("square", parseSquare).parser; + + parser satisfies Parser
; + + // Guard against implicit 'any' type + // @ts-expect-error - Type '{ shape: "circle"; radius: number; }' is not assignable to type 'string'. + parser satisfies Parser; +} + +// Test: parse result with extra branches is not assignable to the union type +{ + const parser = discriminatedUnionOn("shape") + .withBranch("circle", parseCircle) + .withBranch("rectangle", parseRectangle) + .withBranch("square", parseSquare) + .withBranch("extra", object({shape: constant("extra")})).parser; + + // @ts-expect-error - Type '{shape: "extra"}' is not assignable to type 'Figure' + parser satisfies Parser
; +} + +// Test: each variant must contain the discriminant key +{ + // @ts-expect-error - property 'shape' is missing in type '{}' + discriminatedUnionOn("shape").withBranch("circle", object({})); +} + +// Test: each variant must be an object +{ + // @ts-expect-error - Type 'number' is not assignable to type '{ shape: Primitive; }'. + discriminatedUnionOn("shape").withBranch("circle", number); +} diff --git a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/explanation-widget.ts b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/explanation-widget.ts index 28cd4f4ac6..137f2f11fe 100644 --- a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/explanation-widget.ts +++ b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/explanation-widget.ts @@ -1,4 +1,5 @@ import {boolean, constant, object, string} from "../general-purpose-parsers"; +import {defaulted} from "../general-purpose-parsers/defaulted"; import {parseWidget} from "./widget"; import {parseWidgetsMap} from "./widgets-map"; @@ -15,7 +16,10 @@ export const parseExplanationWidget: Parser = parseWidget( // We wrap parseWidgetsMap in a function here to make sure it is not // referenced before it is defined. There is an import cycle between // this file and widgets-map.ts that could cause it to be undefined. - widgets: (rawVal, ctx) => parseWidgetsMap(rawVal, ctx), - static: boolean, + widgets: defaulted( + (rawVal, ctx) => parseWidgetsMap(rawVal, ctx), + () => ({}), + ), + static: defaulted(boolean, () => false), }), ); diff --git a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/expression-widget.ts b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/expression-widget.ts index 9bff1a6a46..f25350b465 100644 --- a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/expression-widget.ts +++ b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/expression-widget.ts @@ -10,36 +10,54 @@ import { string, union, } from "../general-purpose-parsers"; +import {convert} from "../general-purpose-parsers/convert"; +import {defaulted} from "../general-purpose-parsers/defaulted"; +import {versionedWidgetOptions} from "./versioned-widget-options"; import {parseWidgetWithVersion} from "./widget"; import type { ExpressionWidget, PerseusExpressionAnswerForm, } from "../../../perseus-types"; -import type { - ParseContext, - ParsedValue, - Parser, - ParseResult, -} from "../parser-types"; +import type {ParsedValue, Parser} from "../parser-types"; -const parseAnswerForm: Parser = object({ - value: string, - form: boolean, - simplify: boolean, +const parsePossiblyInvalidAnswerForm = object({ + // `value` is the possibly invalid part of this. It should always be a + // string, but some answer forms don't have it. The Expression widget + // ignores invalid values, so we can safely filter them out during parsing. + value: optional(string), + form: defaulted(boolean, () => false), + simplify: defaulted(boolean, () => false), considered: enumeration("correct", "wrong", "ungraded"), key: pipeParsers(optional(union(string).or(number).parser)).then( (key, ctx) => ctx.success(String(key)), ).parser, }); +function removeInvalidAnswerForms( + possiblyInvalid: Array>, +): PerseusExpressionAnswerForm[] { + const valid: PerseusExpressionAnswerForm[] = []; + for (const answerForm of possiblyInvalid) { + const {value} = answerForm; + if (value != null) { + // Copying the object seems to be needed to make TypeScript happy + valid.push({...answerForm, value}); + } + } + return valid; +} + +const version1 = object({major: constant(1), minor: number}); const parseExpressionWidgetV1: Parser = parseWidgetWithVersion( - object({major: constant(1), minor: number}), + version1, constant("expression"), object({ - answerForms: array(parseAnswerForm), + answerForms: pipeParsers( + array(parsePossiblyInvalidAnswerForm), + ).then(convert(removeInvalidAnswerForms)).parser, functions: array(string), times: boolean, visibleLabel: optional(string), @@ -59,8 +77,9 @@ const parseExpressionWidgetV1: Parser = }), ); +const version0 = optional(object({major: constant(0), minor: number})); const parseExpressionWidgetV0 = parseWidgetWithVersion( - optional(object({major: constant(0), minor: number})), + version0, constant("expression"), object({ functions: array(string), @@ -87,10 +106,9 @@ const parseExpressionWidgetV0 = parseWidgetWithVersion( function migrateV0ToV1( widget: ParsedValue, - ctx: ParseContext, -): ParseResult { +): ExpressionWidget { const {options} = widget; - return ctx.success({ + return { ...widget, version: {major: 1, minor: 0}, options: { @@ -110,9 +128,11 @@ function migrateV0ToV1( }, ], }, - }); + }; } -export const parseExpressionWidget: Parser = union( - parseExpressionWidgetV1, -).or(pipeParsers(parseExpressionWidgetV0).then(migrateV0ToV1).parser).parser; +export const parseExpressionWidget: Parser = + versionedWidgetOptions(parseExpressionWidgetV1).withMigrationFrom( + parseExpressionWidgetV0, + migrateV0ToV1, + ).parser; 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 69943a7dfc..1ded6aa94b 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,6 +11,7 @@ import { string, union, } from "../general-purpose-parsers"; +import {discriminatedUnionOn} from "../general-purpose-parsers/discriminated-union"; import {parseWidget} from "./widget"; @@ -35,45 +36,53 @@ export const parseGrapherWidget: Parser = parseWidget( "tangent", ), ), - correct: union( - object({ - type: constant("absolute_value"), - coords: pairOfPoints, - }), - ) - .or( + correct: discriminatedUnionOn("type") + .withBranch( + "absolute_value", + object({ + type: constant("absolute_value"), + coords: pairOfPoints, + }), + ) + .withBranch( + "exponential", object({ type: constant("exponential"), asymptote: pairOfPoints, coords: pairOfPoints, }), ) - .or( + .withBranch( + "linear", object({ type: constant("linear"), coords: pairOfPoints, }), ) - .or( + .withBranch( + "logarithm", object({ type: constant("logarithm"), asymptote: pairOfPoints, coords: pairOfPoints, }), ) - .or( + .withBranch( + "quadratic", object({ type: constant("quadratic"), coords: pairOfPoints, }), ) - .or( + .withBranch( + "sinusoid", object({ type: constant("sinusoid"), coords: pairOfPoints, }), ) - .or( + .withBranch( + "tangent", object({ type: constant("tangent"), coords: pairOfPoints, 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 775eea805f..189149ef7f 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 @@ -10,6 +10,8 @@ import { string, union, } from "../general-purpose-parsers"; +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"; @@ -21,10 +23,12 @@ import type { import type {Parser} from "../parser-types"; const pairOfNumbers = pair(number, number); +const stringOrEmpty = defaulted(string, () => ""); type FunctionElement = Extract; +const parseFunctionType = constant("function"); const parseFunctionElement: Parser = object({ - type: constant("function"), + type: parseFunctionType, key: string, options: object({ value: string, @@ -38,8 +42,9 @@ const parseFunctionElement: Parser = object({ }); type LabelElement = Extract; +const parseLabelType = constant("label"); const parseLabelElement: Parser = object({ - type: constant("label"), + type: parseLabelType, key: string, options: object({ label: string, @@ -50,8 +55,9 @@ const parseLabelElement: Parser = object({ }); type LineElement = Extract; +const parseLineType = constant("line"); const parseLineElement: Parser = object({ - type: constant("line"), + type: parseLineType, key: string, options: object({ color: string, @@ -69,8 +75,9 @@ type MovableLineElement = Extract< PerseusInteractionElement, {type: "movable-line"} >; +const parseMovableLineType = constant("movable-line"); const parseMovableLineElement: Parser = object({ - type: constant("movable-line"), + type: parseMovableLineType, key: string, options: object({ startX: string, @@ -93,8 +100,9 @@ type MovablePointElement = Extract< PerseusInteractionElement, {type: "movable-point"} >; +const parseMovablePointType = constant("movable-point"); const parseMovablePointElement: Parser = object({ - type: constant("movable-point"), + type: parseMovablePointType, key: string, options: object({ startX: string, @@ -103,10 +111,10 @@ const parseMovablePointElement: Parser = object({ constraint: string, snap: number, constraintFn: string, - constraintXMin: string, - constraintXMax: string, - constraintYMin: string, - constraintYMax: string, + constraintXMin: stringOrEmpty, + constraintXMax: stringOrEmpty, + constraintYMin: stringOrEmpty, + constraintYMax: stringOrEmpty, }), }); @@ -114,8 +122,9 @@ type ParametricElement = Extract< PerseusInteractionElement, {type: "parametric"} >; +const parseParametricType = constant("parametric"); const parseParametricElement: Parser = object({ - type: constant("parametric"), + type: parseParametricType, key: string, options: object({ x: string, @@ -129,8 +138,9 @@ const parseParametricElement: Parser = object({ }); type PointElement = Extract; +const parsePointType = constant("point"); const parsePointElement: Parser = object({ - type: constant("point"), + type: parsePointType, key: string, options: object({ color: string, @@ -140,8 +150,9 @@ const parsePointElement: Parser = object({ }); type RectangleElement = Extract; +const parseRectangleType = constant("rectangle"); const parseRectangleElement: Parser = object({ - type: constant("rectangle"), + type: parseRectangleType, key: string, options: object({ color: string, @@ -155,7 +166,7 @@ const parseRectangleElement: Parser = object({ export const parseInteractionWidget: Parser = parseWidget( constant("interaction"), object({ - static: boolean, + static: defaulted(boolean, () => false), graph: object({ editableSettings: optional(array(enumeration("canvas", "graph"))), box: pairOfNumbers, @@ -173,14 +184,15 @@ export const parseInteractionWidget: Parser = parseWidget( tickStep: pairOfNumbers, }), elements: array( - union(parseFunctionElement) - .or(parseLabelElement) - .or(parseLineElement) - .or(parseMovableLineElement) - .or(parseMovablePointElement) - .or(parseParametricElement) - .or(parsePointElement) - .or(parseRectangleElement).parser, + discriminatedUnionOn("type") + .withBranch("function", parseFunctionElement) + .withBranch("label", parseLabelElement) + .withBranch("line", parseLineElement) + .withBranch("movable-line", parseMovableLineElement) + .withBranch("movable-point", parseMovablePointElement) + .withBranch("parametric", parseParametricElement) + .withBranch("point", parsePointElement) + .withBranch("rectangle", parseRectangleElement).parser, ), }), ); diff --git a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/matrix-widget.ts b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/matrix-widget.ts index 71f8455784..eff446edc5 100644 --- a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/matrix-widget.ts +++ b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/matrix-widget.ts @@ -5,21 +5,28 @@ import { number, object, optional, + pipeParsers, string, + union, } from "../general-purpose-parsers"; import {defaulted} from "../general-purpose-parsers/defaulted"; +import {stringToNumber} from "../general-purpose-parsers/string-to-number"; import {parseWidget} from "./widget"; import type {MatrixWidget} from "../../../perseus-types"; import type {Parser} from "../parser-types"; +const numeric = pipeParsers(union(number).or(string).parser).then( + stringToNumber, +).parser; + export const parseMatrixWidget: Parser = parseWidget( defaulted(constant("matrix"), () => "matrix"), object({ prefix: optional(string), suffix: optional(string), - answers: array(array(number)), + answers: array(array(numeric)), cursorPosition: optional(array(number)), matrixBoardSize: array(number), static: optional(boolean), diff --git a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/numeric-input-widget.ts b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/numeric-input-widget.ts index 47bb471c13..ff82a5468b 100644 --- a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/numeric-input-widget.ts +++ b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/numeric-input-widget.ts @@ -8,7 +8,10 @@ import { enumeration, boolean, nullable, + union, + pipeParsers, } from "../general-purpose-parsers"; +import {convert} from "../general-purpose-parsers/convert"; import {defaulted} from "../general-purpose-parsers/defaulted"; import {parseWidget} from "./widget"; @@ -32,12 +35,25 @@ export const parseNumericInputWidget: Parser = parseWidget( answers: array( object({ message: string, - value: optional(number), + // TODO(benchristel): value should never be null or undefined, + // but we have some content where it is anyway. If we backfill + // the data, simplify this. + value: optional(nullable(number)), status: string, answerForms: optional(array(parseMathFormat)), strict: boolean, maxError: optional(nullable(number)), - simplify: optional(nullable(string)), + // TODO(benchristel): simplify should never be `true`, but we + // have some content where it is anyway. If we ever backfill + // the data, we should simplify `simplify`. + simplify: optional( + nullable( + union(string).or( + pipeParsers(constant(true)).then(convert(String)) + .parser, + ).parser, + ), + ), }), ), labelText: optional(string), diff --git a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/passage-ref-widget.ts b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/passage-ref-widget.ts index c91920df8d..560c7463fa 100644 --- a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/passage-ref-widget.ts +++ b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/passage-ref-widget.ts @@ -1,4 +1,10 @@ -import {constant, object, string, number} from "../general-purpose-parsers"; +import { + constant, + object, + string, + number, + optional, +} from "../general-purpose-parsers"; import {parseWidget} from "./widget"; @@ -10,6 +16,6 @@ export const parsePassageRefWidget: Parser = parseWidget( object({ passageNumber: number, referenceNumber: number, - summaryText: string, + summaryText: optional(string), }), ); diff --git a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/perseus-image-background.ts b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/perseus-image-background.ts index 248644ce53..0661db147c 100644 --- a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/perseus-image-background.ts +++ b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/perseus-image-background.ts @@ -7,22 +7,30 @@ import { string, union, } from "../general-purpose-parsers"; +import {convert} from "../general-purpose-parsers/convert"; import {stringToNumber} from "../general-purpose-parsers/string-to-number"; import type {Parser} from "../parser-types"; import type {PerseusImageBackground} from "@khanacademy/perseus"; -const numericToNumber = pipeParsers(union(number).or(string).parser).then( - stringToNumber, -).parser; +function emptyToZero(x: string | number): string | number { + return x === "" ? 0 : x; +} + +const imageDimensionToNumber = pipeParsers(union(number).or(string).parser) + // In this specific case, empty string is equivalent to zero. An empty + // string parses to either NaN (using parseInt) or 0 (using unary +) and + // CSS will treat NaN as invalid and default to 0 instead. + .then(convert(emptyToZero)) + .then(stringToNumber).parser; export const parsePerseusImageBackground: Parser = object({ url: optional(nullable(string)), - width: optional(numericToNumber), - height: optional(numericToNumber), - top: optional(numericToNumber), - left: optional(numericToNumber), - bottom: optional(numericToNumber), - scale: optional(numericToNumber), + width: optional(imageDimensionToNumber), + height: optional(imageDimensionToNumber), + top: optional(imageDimensionToNumber), + left: optional(imageDimensionToNumber), + bottom: optional(imageDimensionToNumber), + scale: optional(imageDimensionToNumber), }); diff --git a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/versioned-widget-options.test.ts b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/versioned-widget-options.test.ts new file mode 100644 index 0000000000..35e6415cda --- /dev/null +++ b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/versioned-widget-options.test.ts @@ -0,0 +1,153 @@ +import { + array, + constant, + number, + object, + string, +} from "../general-purpose-parsers"; +import {parse} from "../parse"; +import {failure, success} from "../result"; + +import {versionedWidgetOptions} from "./versioned-widget-options"; + +import type {Parser} from "../parser-types"; + +describe("versionedWidgetOptions parser", () => { + type OptionsV0 = { + type: "test-widget"; + version: {major: 0; minor: number}; + answer: string; + }; + + type OptionsV1 = { + type: "test-widget"; + version: {major: 1; minor: number}; + answers: string[]; + }; + + type OptionsV2 = { + type: "test-widget"; + version: {major: 2; minor: number}; + correctAnswers: string[]; + }; + + const parseOptionsV0: Parser = object({ + type: constant("test-widget"), + version: object({major: constant(0), minor: number}), + answer: string, + }); + + const parseOptionsV1: Parser = object({ + type: constant("test-widget"), + version: object({major: constant(1), minor: number}), + answers: array(string), + }); + + const parseOptionsV2: Parser = object({ + type: constant("test-widget"), + version: object({major: constant(2), minor: number}), + correctAnswers: array(string), + }); + + function migrateV0ToV1(v0: OptionsV0): OptionsV1 { + return { + type: "test-widget", + version: {major: 1, minor: 0}, + answers: [v0.answer], + }; + } + + function migrateV1ToV2(v1: OptionsV1): OptionsV2 { + return { + type: "test-widget", + version: {major: 2, minor: 0}, + correctAnswers: v1.answers, + }; + } + + it("parses the latest version of the data, when that is the only version", () => { + const parser = versionedWidgetOptions(parseOptionsV1).parser; + + const validData = { + type: "test-widget", + version: {major: 1, minor: 0}, + answers: ["ok"], + }; + + expect(parse(validData, parser)).toEqual(success(validData)); + }); + + it("parses the latest version of the data, when there is an earlier version", () => { + const parser = versionedWidgetOptions(parseOptionsV1).withMigrationFrom( + parseOptionsV0, + migrateV0ToV1, + ).parser; + + const validData = { + type: "test-widget", + version: {major: 1, minor: 0}, + answers: ["ok"], + }; + + expect(parse(validData, parser)).toEqual(success(validData)); + }); + + it("migrates an old version of the data to the latest version", () => { + const parser = versionedWidgetOptions(parseOptionsV1).withMigrationFrom( + parseOptionsV0, + migrateV0ToV1, + ).parser; + + const oldData = { + type: "test-widget", + version: {major: 0, minor: 0}, + answer: "ok", + }; + + expect(parse(oldData, parser)).toEqual( + success({ + type: "test-widget", + version: {major: 1, minor: 0}, + answers: ["ok"], + }), + ); + }); + + it("migrates through intermediate versions", () => { + const parser = versionedWidgetOptions(parseOptionsV2) + .withMigrationFrom(parseOptionsV1, migrateV1ToV2) + .withMigrationFrom(parseOptionsV0, migrateV0ToV1).parser; + + const oldData = { + type: "test-widget", + version: {major: 0, minor: 0}, + answer: "ok", + }; + + expect(parse(oldData, parser)).toEqual( + success({ + type: "test-widget", + version: {major: 2, minor: 0}, + correctAnswers: ["ok"], + }), + ); + }); + + it("fails to parse invalid data", () => { + const parser = versionedWidgetOptions(parseOptionsV1).withMigrationFrom( + parseOptionsV0, + migrateV0ToV1, + ).parser; + + const invalidData = { + type: "test-widget", + // version.major is invalid + version: {major: 99, minor: 0}, + answer: "ok", + }; + + expect(parse(invalidData, parser)).toEqual( + failure("At (root).version.major -- expected 0, but got 99"), + ); + }); +}); diff --git a/packages/perseus/src/util/parse-perseus-json/perseus-parsers/versioned-widget-options.ts b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/versioned-widget-options.ts new file mode 100644 index 0000000000..f3a09b7914 --- /dev/null +++ b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/versioned-widget-options.ts @@ -0,0 +1,87 @@ +import {isSuccess, success} from "../result"; + +import type {Version} from "../../../perseus-types"; +import type {ParseContext, Parser} from "../parser-types"; + +type Versioned = { + version?: Version; +}; + +/** + * Creates a parser for a widget options type with multiple major versions. Old + * versions are migrated to the latest version. The parse fails if the input + * data does not match any of the versions. + * + * @example + * const parseOptions = versionedWidgetOptions(parseOptionsV3) + * .withMigrationFrom(parseOptionsV2, migrateV2ToV3) + * .withMigrationFrom(parseOptionsV1, migrateV1ToV2) + * .withMigrationFrom(parseOptionsV0, migrateV0ToV1) + * .parser; + * + * @param parseLatest a {@link Parser} for the latest version of the widget + * options. This should check version.major and fail if it's not the latest + * version. + * @returns a builder object, to which migrations from earlier versions can be + * added. Migrations must be added in "reverse chronological" order as in the + * example above. + */ +export function versionedWidgetOptions( + parseLatest: Parser, +): VersionedWidgetOptionsParserBuilder { + return new VersionedWidgetOptionsParserBuilder( + parseLatest, + (latest) => latest, + ); +} + +class VersionedWidgetOptionsParserBuilder< + Latest extends Versioned, + Migratable extends Versioned, +> { + constructor( + public parser: Parser, + private migrate: (m: Migratable) => Latest, + ) {} + + /** + * Add a migration from an old version of the widget options. + * + * @returns a VersionedWidgetOptionsParserBuilder whose `parser` function + * is capable of migrating the old version to the latest version. The parser + * will always return the latest version of the widget options on a + * successful parse. + * @param parseOldVersion should be a {@link Parser} for the old options + * type. It should fail if version.major isn't correct. + * @param migrateToNextVersion should migrate the `Old` data to the + * `Migratable` version of the current VersionedWidgetOptionsParserBuilder. + * Usually, this means migrating to the next major version. + */ + withMigrationFrom( + parseOldVersion: Parser, + migrateToNextVersion: (old: Old) => Migratable, + ): VersionedWidgetOptionsParserBuilder { + const parseLatestVersion = this.parser; + + const migrateToLatest = (old: Old) => + this.migrate(migrateToNextVersion(old)); + + return new VersionedWidgetOptionsParserBuilder( + (raw: unknown, ctx: ParseContext) => { + const resultOfParsingLatest = parseLatestVersion(raw, ctx); + if (isSuccess(resultOfParsingLatest)) { + return resultOfParsingLatest; + } + + const resultOfParsingOld = parseOldVersion(raw, ctx); + if (isSuccess(resultOfParsingOld)) { + return success(migrateToLatest(resultOfParsingOld.value)); + } + + // If we're here, neither parse succeeded. Return the failure. + return resultOfParsingOld; + }, + migrateToLatest, + ); + } +} 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 c490f04cd5..439f916adc 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 @@ -183,6 +183,8 @@ const parseWidgetsMapEntry: ( // sequence is a deprecated widget type, and the corresponding // widget component no longer exists. return parseAndAssign(`sequence ${id}`, parseDeprecatedWidget); + case "lights-puzzle": + return parseAndAssign(`lights-puzzle ${id}`, parseDeprecatedWidget); default: if (getWidget(type)) { 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 55689676e2..9536ac3153 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 @@ -187,6 +187,227 @@ exports[`parseAndTypecheckPerseusItem correctly parses data/dropdown-missing-ver } `; +exports[`parseAndTypecheckPerseusItem correctly parses data/explanation-missing-widgets-map.json 1`] = ` +{ + "answer": undefined, + "answerArea": { + "calculator": false, + "periodicTable": false, + }, + "hints": [ + { + "content": "What times $a$ gives us $b$?", + "images": {}, + "metadata": undefined, + "replace": undefined, + "widgets": {}, + }, + { + "content": "If we multiply each $a$-value by $\\blue{0.25}$, we get each corresponding $b$-value: + +> $b=\\blue{0.25}a$", + "images": {}, + "metadata": undefined, + "replace": undefined, + "widgets": {}, + }, + { + "content": "The *constant of proportionality* $(r)$ in the equation $b=ra$ is $\\blue{0.25}$.", + "images": {}, + "metadata": undefined, + "replace": undefined, + "widgets": { + "explanation 1": { + "alignment": undefined, + "graded": true, + "key": undefined, + "options": { + "explanation": "Let's check the values of $a$ and $b$ given in the table above.", + "hidePrompt": "Okay, I'm convinced.", + "showPrompt": "Skeptical? Look at some examples.", + "static": false, + "widgets": {}, + }, + "static": undefined, + "type": "explanation", + "version": { + "major": 0, + "minor": 0, + }, + }, + }, + }, + ], + "itemDataVersion": { + "major": 0, + "minor": 1, + }, + "question": { + "content": "The quantities $a$ and $b$ are proportional. + +$a$ | $b$ | +:-: | :-: | +$8$ | $2$ +$16$ | $4$ +$32$ | $8$ + +**Find the *constant of proportionality* $(r)$ in the equation $b=ra$.** + +$r = $ [[☃ numeric-input 1]] ", + "images": {}, + "metadata": undefined, + "widgets": { + "numeric-input 1": { + "alignment": undefined, + "graded": true, + "key": undefined, + "options": { + "answerForms": undefined, + "answers": [ + { + "answerForms": undefined, + "maxError": null, + "message": "", + "simplify": "required", + "status": "correct", + "strict": false, + "value": 0.25, + }, + ], + "coefficient": false, + "labelText": "", + "rightAlign": undefined, + "size": "normal", + "static": false, + }, + "static": undefined, + "type": "numeric-input", + "version": { + "major": 0, + "minor": 0, + }, + }, + }, + }, +} +`; + +exports[`parseAndTypecheckPerseusItem correctly parses data/expression-answerForm-missing-form.json 1`] = ` +{ + "answer": undefined, + "answerArea": { + "calculator": false, + "chi2Table": false, + "periodicTable": false, + "tTable": false, + "zTable": false, + }, + "hints": [], + "itemDataVersion": { + "major": 0, + "minor": 1, + }, + "question": { + "content": "Jake is younger than Sophie. Sophie is $14$ years old. + +**Write an inequality that compares Jake's age in years, $j$, to Sophie's age.** + +[[☃ expression 1]]", + "images": {}, + "metadata": undefined, + "widgets": { + "expression 1": { + "alignment": "default", + "graded": true, + "key": undefined, + "options": { + "answerForms": [ + { + "considered": "correct", + "form": false, + "key": "0", + "simplify": false, + "value": "j<14", + }, + ], + "ariaLabel": undefined, + "buttonSets": [ + "basic", + "basic relations", + "advanced relations", + ], + "buttonsVisible": undefined, + "functions": [ + "f", + "g", + ], + "times": false, + "visibleLabel": undefined, + }, + "static": false, + "type": "expression", + "version": { + "major": 1, + "minor": 0, + }, + }, + }, + }, +} +`; + +exports[`parseAndTypecheckPerseusItem correctly parses data/expression-option-missing-value.json 1`] = ` +{ + "answer": undefined, + "answerArea": { + "calculator": false, + "periodicTable": false, + }, + "hints": [], + "itemDataVersion": { + "major": 0, + "minor": 1, + }, + "question": { + "content": "** Combine like terms to simplify the expression: ** + + \${\\dfrac{2}{5}k-\\dfrac35+\\dfrac{1}{10}k}$ + +[[☃ expression 1]]", + "images": {}, + "metadata": undefined, + "widgets": { + "expression 1": { + "alignment": "default", + "graded": true, + "key": undefined, + "options": { + "answerForms": [], + "ariaLabel": undefined, + "buttonSets": [ + "basic", + ], + "buttonsVisible": undefined, + "functions": [ + "f", + "g", + "h", + ], + "times": false, + "visibleLabel": undefined, + }, + "static": undefined, + "type": "expression", + "version": { + "major": 1, + "minor": 0, + }, + }, + }, + }, +} +`; + exports[`parseAndTypecheckPerseusItem correctly parses data/hint-missing-images.json 1`] = ` { "answer": undefined, @@ -712,58 +933,421 @@ exports[`parseAndTypecheckPerseusItem correctly parses data/input-number-with-bo } `; -exports[`parseAndTypecheckPerseusItem correctly parses data/interactive-graph-missing-graph.json 1`] = ` +exports[`parseAndTypecheckPerseusItem correctly parses data/interaction-element-missing-constraints.json 1`] = ` { "answer": undefined, "answerArea": { "calculator": false, - "chi2Table": false, - "periodicTable": false, - "tTable": false, - "zTable": false, }, - "hints": [ - { - "content": "##Strategy - -Let's remember what magnetic dip means. It is the angle that the magnetic field makes with the horizontal at a place. So, we can start by drawing a vector arrow for the magnetic field $B_E$ and take component of this vector to find the vertical component. - -*Tip: The **dip** angle is **positive** when the magnetic field **dips**!*", - "images": {}, - "metadata": undefined, - "replace": false, - "widgets": {}, - }, - { - "content": "##Solution - -Here, the dip angle is **positive**, so the magnetic field is **below** the horizontal as shown below. - -[[☃ image 1]] - -As we can see the direction of the vertical component is downwards $(\\downarrow)$, now let's find the magnitude.", - "images": {}, - "metadata": undefined, - "replace": false, - "widgets": { - "image 1": { - "alignment": "block", - "graded": true, - "key": undefined, - "options": { - "alt": "", - "backgroundImage": { - "bottom": undefined, - "height": 151, - "left": undefined, - "scale": undefined, - "top": undefined, - "url": "https://ka-perseus-images.s3.amazonaws.com/d36824c27f73d6263d9a1c548ead1fdac535243e.svg", - "width": 255, + "hints": [], + "itemDataVersion": { + "major": 0, + "minor": 1, + }, + "question": { + "content": "[[☃ interaction 1]]", + "images": {}, + "metadata": undefined, + "widgets": { + "interaction 1": { + "alignment": undefined, + "graded": true, + "key": undefined, + "options": { + "elements": [ + { + "key": "point-e4cd66", + "options": { + "color": "#28AE7B", + "coordX": "28", + "coordY": "35", + }, + "type": "point", }, - "box": [ - 255, - 151, + { + "key": "point-463a3c", + "options": { + "color": "#FF00AF", + "coordX": "28", + "coordY": "35-f\\left(28\\right)", + }, + "type": "point", + }, + { + "key": "point-d97614", + "options": { + "color": "#28AE7B", + "coordX": "17", + "coordY": "28", + }, + "type": "point", + }, + { + "key": "point-334a70", + "options": { + "color": "#FF00AF", + "coordX": "17", + "coordY": "28-f\\left(17\\right)", + }, + "type": "point", + }, + { + "key": "point-31ac1c", + "options": { + "color": "#28AE7B", + "coordX": "6", + "coordY": "18", + }, + "type": "point", + }, + { + "key": "point-94798c", + "options": { + "color": "#FF00AF", + "coordX": "6", + "coordY": "18-f\\left(6\\right)", + }, + "type": "point", + }, + { + "key": "point-deb26e", + "options": { + "color": "#28AE7B", + "coordX": "42", + "coordY": "47", + }, + "type": "point", + }, + { + "key": "point-e8d73e", + "options": { + "color": "#FF00AF", + "coordX": "42", + "coordY": "47-f\\left(42\\right)", + }, + "type": "point", + }, + { + "key": "function-cd4dbc", + "options": { + "color": "#6495ED", + "funcName": "f", + "rangeMax": "50", + "rangeMin": "-5", + "strokeDasharray": "", + "strokeWidth": 2, + "value": "\\frac{\\left(y_1-y_0\\right)}{\\left(x_1-x_0\\right)}\\left(x-x_0\\right)+y_0", + }, + "type": "function", + }, + { + "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/interactive-graph-backgroundImage-with-empty-string-coordinates.json 1`] = ` +{ + "answer": undefined, + "answerArea": { + "calculator": false, + "periodicTable": false, + }, + "hints": [ + { + "content": "We can plot the points using the equation to find $d$ for each value of $w$. + +If $w=\\blue 6$, + +$\\qquad d=\\blue 6+5\\\\~~~~~~~~~~=\\red{11}.$ + +So we place one point at $(\\blue 6,\\red{11})$.", + "images": {}, + "metadata": undefined, + "replace": undefined, + "widgets": {}, + }, + { + "content": "If $w=\\blue{10}$, + +$\\qquad d=\\blue{10}+5=\\red{15}$. + +So the second point is at $(\\blue{10},\\red{15})$.", + "images": {}, + "metadata": undefined, + "replace": undefined, + "widgets": {}, + }, + { + "content": "The graph should look like this: + +![](https://ka-perseus-graphie.s3.amazonaws.com/d29e6802062c091aac9761cf34e41438104bd6a2.png)", + "images": { + "https://ka-perseus-graphie.s3.amazonaws.com/d29e6802062c091aac9761cf34e41438104bd6a2.png": { + "height": 425, + "width": 425, + }, + }, + "metadata": undefined, + "replace": undefined, + "widgets": {}, + }, + ], + "itemDataVersion": { + "major": 0, + "minor": 1, + }, + "question": { + "content": "You are $5$ miles away from your house when you start walking directly away from your house. In the table below, $w$ represents the number of miles you have walked, and $d$ represents your distance from home in miles. + +The relationship between these two variables can be expressed by the following equation: + +$d=w+5.$ + +**Plot two points on the graph that show your distance from home if you walked $6$ miles and $10$ miles.** + +$w$ | $d$ +:-:|:-: +$0$ | $5$ +$1$ | $6$ +$2$ | $7$ +$3$ | $8$ + + + +[[☃ interactive-graph 1]]", + "images": {}, + "metadata": undefined, + "widgets": { + "interactive-graph 1": { + "alignment": "default", + "graded": true, + "key": undefined, + "options": { + "backgroundImage": { + "bottom": 0, + "height": 0, + "left": 0, + "scale": 1, + "top": undefined, + "url": null, + "width": 0, + }, + "correct": { + "coord": undefined, + "coords": [ + [ + 6, + 11, + ], + [ + 10, + 15, + ], + ], + "numPoints": 2, + "startCoords": undefined, + "type": "point", + }, + "fullGraphAriaDescription": undefined, + "fullGraphLabel": undefined, + "graph": { + "coord": undefined, + "coords": undefined, + "numPoints": 2, + "startCoords": undefined, + "type": "point", + }, + "gridStep": [ + 1, + 1, + ], + "labels": [ + "w", + "d", + ], + "lockedFigures": undefined, + "markings": "graph", + "range": [ + [ + -1, + 18, + ], + [ + -1, + 18, + ], + ], + "rulerLabel": "", + "rulerTicks": 10, + "showProtractor": false, + "showRuler": false, + "showTooltips": undefined, + "snapStep": [ + 0.5, + 0.5, + ], + "step": [ + 1, + 1, + ], + }, + "static": undefined, + "type": "interactive-graph", + "version": { + "major": 0, + "minor": 0, + }, + }, + }, + }, +} +`; + +exports[`parseAndTypecheckPerseusItem correctly parses data/interactive-graph-missing-graph.json 1`] = ` +{ + "answer": undefined, + "answerArea": { + "calculator": false, + "chi2Table": false, + "periodicTable": false, + "tTable": false, + "zTable": false, + }, + "hints": [ + { + "content": "##Strategy + +Let's remember what magnetic dip means. It is the angle that the magnetic field makes with the horizontal at a place. So, we can start by drawing a vector arrow for the magnetic field $B_E$ and take component of this vector to find the vertical component. + +*Tip: The **dip** angle is **positive** when the magnetic field **dips**!*", + "images": {}, + "metadata": undefined, + "replace": false, + "widgets": {}, + }, + { + "content": "##Solution + +Here, the dip angle is **positive**, so the magnetic field is **below** the horizontal as shown below. + +[[☃ image 1]] + +As we can see the direction of the vertical component is downwards $(\\downarrow)$, now let's find the magnitude.", + "images": {}, + "metadata": undefined, + "replace": false, + "widgets": { + "image 1": { + "alignment": "block", + "graded": true, + "key": undefined, + "options": { + "alt": "", + "backgroundImage": { + "bottom": undefined, + "height": 151, + "left": undefined, + "scale": undefined, + "top": undefined, + "url": "https://ka-perseus-images.s3.amazonaws.com/d36824c27f73d6263d9a1c548ead1fdac535243e.svg", + "width": 255, + }, + "box": [ + 255, + 151, ], "caption": "", "labels": [], @@ -1608,6 +2192,54 @@ exports[`parseAndTypecheckPerseusItem correctly parses data/interactive-graph-wi } `; +exports[`parseAndTypecheckPerseusItem correctly parses data/lights-puzzle.json 1`] = ` +{ + "answer": undefined, + "answerArea": { + "calculator": false, + }, + "hints": [], + "itemDataVersion": undefined, + "question": { + "content": "**Light up all the squares by clicking on them.** When you click on a square, it will turn on (if it's off), or off (if it's on), as will each of the squares next to it. + +[[☃ lights-puzzle 1]]", + "images": {}, + "metadata": undefined, + "widgets": { + "lights-puzzle 1": { + "alignment": undefined, + "graded": true, + "key": undefined, + "options": { + "gradeIncompleteAsWrong": false, + "startCells": [ + [ + true, + false, + true, + ], + [ + false, + true, + false, + ], + [ + true, + false, + true, + ], + ], + }, + "static": undefined, + "type": "deprecated-standin", + "version": undefined, + }, + }, + }, +} +`; + exports[`parseAndTypecheckPerseusItem correctly parses data/matrix-missing-version.json 1`] = ` { "answer": undefined, @@ -1735,6 +2367,278 @@ exports[`parseAndTypecheckPerseusItem correctly parses data/matrix-missing-versi } `; +exports[`parseAndTypecheckPerseusItem correctly parses data/matrix-with-string-answer-element.json 1`] = ` +{ + "answer": undefined, + "answerArea": { + "calculator": true, + "chi2Table": false, + "periodicTable": false, + "tTable": false, + "zTable": false, + }, + "hints": [ + { + "content": "In general terms, suppose we have + +- an $n$-dimensional square matrix whose columns are $\\vec{v_1},\\vec{v_2},...,\\,\\vec{v_n}$ and +- an $n$-dimensional vector $\\vec x=\\left[\\begin{array}{c}x_1\\\\\\\\x_2\\\\\\\\...\\\\\\\\x_n\\end{array}\\right]$, + +then this is the image of the vector under the matrix transformations: + +$x_1\\cdot\\vec{v_1}+x_2\\cdot\\vec{v_2}+...+x_n\\cdot\\vec{v_n}$ + +[[☃ explanation 1]]", + "images": {}, + "metadata": undefined, + "replace": false, + "widgets": { + "explanation 1": { + "alignment": "default", + "graded": true, + "key": undefined, + "options": { + "explanation": "We learned that for 2-dimensional matrices, the first column is the image of the unit vector $\\left[\\begin{array}{c}1\\\\\\\\0\\end{array}\\right]$ and the second column is the image of the unit vector $\\left[\\begin{array}{c}0\\\\\\\\1\\end{array}\\right]$. + +This can be generalized to higher dimensions, only now we have $n$ unit vectors which are full of zeros except for their $i^{\\text{th}}$ entry. For example, these are the four unit vectors in 4D: + +$\\left[\\begin{array}{c}1\\\\\\\\0\\\\\\\\0\\\\\\\\0\\end{array}\\right] +\\left[\\begin{array}{c}0\\\\\\\\1\\\\\\\\0\\\\\\\\0\\end{array}\\right] +\\left[\\begin{array}{c}0\\\\\\\\0\\\\\\\\1\\\\\\\\0\\end{array}\\right] +\\left[\\begin{array}{c}0\\\\\\\\0\\\\\\\\0\\\\\\\\1\\end{array}\\right]$ + +Each column of a 4D matrix tells us where it maps each unit vector, and we can write the general 4D vector $\\left[\\begin{array}{c}x_1\\\\\\\\x_2\\\\\\\\x_3\\\\\\\\x_4\\end{array}\\right]$ as: + +$x_1\\cdot\\left[\\begin{array}{c}1\\\\\\\\0\\\\\\\\0\\\\\\\\0\\end{array}\\right] ++x_2\\cdot\\left[\\begin{array}{c}0\\\\\\\\1\\\\\\\\0\\\\\\\\0\\end{array}\\right] ++x_3\\cdot\\left[\\begin{array}{c}0\\\\\\\\0\\\\\\\\1\\\\\\\\0\\end{array}\\right] ++x_4\\cdot\\left[\\begin{array}{c}0\\\\\\\\0\\\\\\\\0\\\\\\\\1\\end{array}\\right]$", + "hidePrompt": "Got it, thanks!", + "showPrompt": "Why is this true?", + "static": false, + "widgets": {}, + }, + "static": false, + "type": "explanation", + "version": { + "major": 0, + "minor": 0, + }, + }, + }, + }, + { + "content": "$\\begin{align} +&\\phantom{=}\\left[\\begin{array}{c} +\\tealE{0} & \\redE{2} & \\purpleE{1} & \\goldE{-3} +\\\\\\\\ +\\tealE{-1} & \\redE{2} & \\purpleE{-3} & \\goldE{0} +\\\\\\\\ +\\tealE{2} & \\redE{-2} & \\purpleE{0} &\\goldE{1} +\\\\\\\\ +\\tealE{1} & \\redE{1} & \\purpleE{-1} & \\goldE{-1} +\\end{array}\\right] +\\left(\\left[\\begin{array}{c} +-5 +\\\\\\\\ +1 +\\\\\\\\ +3 +\\\\\\\\ +-2 +\\end{array}\\right]\\right) +\\\\\\\\ +&=-5\\cdot \\left[\\begin{array}{c} +\\tealE{0} +\\\\\\\\ +\\tealE{-1} +\\\\\\\\ +\\tealE{2} +\\\\\\\\ +\\tealE{1} +\\end{array}\\right]+1\\cdot\\left[\\begin{array}{c} +\\redE{2} +\\\\\\\\ +\\redE{2} +\\\\\\\\ +\\redE{-2} +\\\\\\\\ +\\redE{1} +\\end{array}\\right]+3\\cdot \\left[\\begin{array}{c} +\\purpleE{1} +\\\\\\\\ +\\purpleE{-3} +\\\\\\\\ +\\purpleE{0} +\\\\\\\\ +\\purpleE{-1} +\\end{array}\\right]+(-2)\\cdot\\left[\\begin{array}{c} +\\goldE{-3} +\\\\\\\\ +\\goldE{0} +\\\\\\\\ +\\goldE{1} +\\\\\\\\ +\\goldE{-1} +\\end{array}\\right] +\\\\\\\\ +&=\\left[\\begin{array}{c} +0 +\\\\\\\\ +5 +\\\\\\\\ +-10 +\\\\\\\\ +-5 +\\end{array}\\right]+\\left[\\begin{array}{c} +2 +\\\\\\\\ +2 +\\\\\\\\ +-2 +\\\\\\\\ +1 +\\end{array}\\right]+\\left[\\begin{array}{c} +3 +\\\\\\\\ +-9 +\\\\\\\\ +0 +\\\\\\\\ +-3 +\\end{array}\\right]+\\left[\\begin{array}{c} +6 +\\\\\\\\ +0 +\\\\\\\\ +-2 +\\\\\\\\ +2 +\\end{array}\\right] +\\\\\\\\ +&=\\left[\\begin{array}{c} +11 +\\\\\\\\ +-2 +\\\\\\\\ +-14 +\\\\\\\\ +-5 +\\end{array}\\right] +\\end{align}$", + "images": {}, + "metadata": undefined, + "replace": false, + "widgets": {}, + }, + { + "content": "This is the image we obtain when we perform the transformation $\\left[\\begin{array}{c} +0 & 2 & 1 & -3 +\\\\\\\\ +-1 & 2 & -3 & 0 +\\\\\\\\ +2 & -2 & 0 &1 +\\\\\\\\ +1 & 1 & -1 & -1 +\\end{array}\\right]$ on the pre-image $\\left[\\begin{array}{c} +-5 +\\\\\\\\ +1 +\\\\\\\\ +3 +\\\\\\\\ +-2 +\\end{array}\\right]$: + +$\\left[\\begin{array}{c} +11 +\\\\\\\\ +-2 +\\\\\\\\ +-14 +\\\\\\\\ +-5 +\\end{array}\\right]$", + "images": {}, + "metadata": undefined, + "replace": false, + "widgets": {}, + }, + ], + "itemDataVersion": { + "major": 0, + "minor": 1, + }, + "question": { + "content": "Consider this matrix transformation: + +$\\left[\\begin{array}{c} +0 & 2 & 1 & -3 +\\\\\\\\ +-1 & 2 & -3 & 0 +\\\\\\\\ +2 & -2 & 0 &1 +\\\\\\\\ +1 & 1 & -1 & -1 +\\end{array}\\right]$ + +**What is the image of $\\left[\\begin{array}{c} +-5 +\\\\\\\\ +1 +\\\\\\\\ +3 +\\\\\\\\ +-2 +\\end{array}\\right]$ under this transformation?** + +[[☃ matrix 1]]", + "images": {}, + "metadata": undefined, + "widgets": { + "matrix 1": { + "alignment": "default", + "graded": true, + "key": undefined, + "options": { + "answers": [ + [ + 11, + ], + [ + -2, + ], + [ + -14, + ], + [ + -5, + ], + ], + "cursorPosition": [ + 0, + 0, + ], + "matrixBoardSize": [ + 4, + 1, + ], + "prefix": "", + "static": false, + "suffix": "", + }, + "static": false, + "type": "matrix", + "version": { + "major": 0, + "minor": 0, + }, + }, + }, + }, +} +`; + exports[`parseAndTypecheckPerseusItem correctly parses data/number-line-missing-snapDivisions.json 1`] = ` { "answer": undefined, @@ -1955,10 +2859,131 @@ The graph of the solution of the inequality, $c ≥ 8.75$, looks like this: "showTooltips": false, "snapDivisions": 2, "static": false, - "tickStep": 0.5, + "tickStep": 0.5, + }, + "static": false, + "type": "number-line", + "version": { + "major": 0, + "minor": 0, + }, + }, + }, + }, +} +`; + +exports[`parseAndTypecheckPerseusItem correctly parses data/numeric-input-answer-with-null-value.json 1`] = ` +{ + "answer": undefined, + "answerArea": { + "calculator": false, + }, + "hints": [], + "itemDataVersion": { + "major": 0, + "minor": 1, + }, + "question": { + "content": "$\\Huge36+3$ + +[[☃ numeric-input 1]]", + "images": {}, + "metadata": undefined, + "widgets": { + "numeric-input 1": { + "alignment": undefined, + "graded": true, + "key": undefined, + "options": { + "answerForms": undefined, + "answers": [ + { + "answerForms": [], + "maxError": null, + "message": "", + "simplify": "required", + "status": "correct", + "strict": false, + "value": null, + }, + ], + "coefficient": false, + "labelText": undefined, + "rightAlign": undefined, + "size": "normal", + "static": false, + }, + "static": undefined, + "type": "numeric-input", + "version": { + "major": 0, + "minor": 0, + }, + }, + }, + }, +} +`; + +exports[`parseAndTypecheckPerseusItem correctly parses data/numeric-input-answer-with-simplify-true.json 1`] = ` +{ + "answer": undefined, + "answerArea": { + "calculator": false, + }, + "hints": [ + { + "content": "$= \\dfrac{9 \\times 1}{2 \\times 4}$", + "images": {}, + "metadata": undefined, + "replace": undefined, + "widgets": {}, + }, + { + "content": "$= \\dfrac{9}{8}$", + "images": {}, + "metadata": undefined, + "replace": undefined, + "widgets": {}, + }, + ], + "itemDataVersion": { + "major": 0, + "minor": 1, + }, + "question": { + "content": "**$ \\dfrac{9}{2} \\times \\dfrac{1}{4} $** + +[[☃ numeric-input 1]]", + "images": {}, + "metadata": undefined, + "widgets": { + "numeric-input 1": { + "alignment": "default", + "graded": true, + "key": undefined, + "options": { + "answerForms": undefined, + "answers": [ + { + "answerForms": undefined, + "maxError": 0, + "message": "", + "simplify": "true", + "status": "correct", + "strict": false, + "value": 1.125, + }, + ], + "coefficient": false, + "labelText": "", + "rightAlign": undefined, + "size": "normal", + "static": false, }, - "static": false, - "type": "number-line", + "static": undefined, + "type": "numeric-input", "version": { "major": 0, "minor": 0, @@ -3749,6 +4774,293 @@ A free body diagram of the **box** is shown below. } `; +exports[`parseAndTypecheckPerseusItem correctly parses data/passage-ref-missing-summaryText.json 1`] = ` +{ + "answer": undefined, + "answerArea": { + "calculator": false, + }, + "hints": [ + { + "content": "Anybody likely to need armor would certainly want to be wearing it. Armor was worn for sport, battle, civil defense, ceremony, and in some cases for hunting. This example—commissioned in 1591 by Sophie of Brandenburg as a Christmas gift for her husband, Christian I (reigned 1586–91)—is one of a set of twelve matching armors for the courtly sport of foot combat.", + "images": {}, + "metadata": undefined, + "replace": undefined, + "widgets": {}, + }, + { + "content": "Many medieval European cities, such as London, Paris, Frankfurt, Vienna, and Milan, actually required their citizens to own armor and weapons so that they would be able to defend the city in times of war.", + "images": {}, + "metadata": undefined, + "replace": undefined, + "widgets": {}, + }, + { + "content": "The answer is: All of the above. ", + "images": {}, + "metadata": undefined, + "replace": undefined, + "widgets": {}, + }, + ], + "itemDataVersion": { + "major": 0, + "minor": 1, + }, + "question": { + "content": "[[☃ image 1]] +Anton Peffenhauser, *Foot-Combat Armor of Prince-Elector Christian I of Saxony (reigned 1586–91)*, Steel, etched, blued, and gilt; leather; gilt bronze, Augsburg, 1591. + +**Who would have worn armor in medieval and Renaissance Europe?** + +[[☃ radio 1]] + +", + "images": {}, + "metadata": undefined, + "widgets": { + "image 1": { + "alignment": undefined, + "graded": true, + "key": undefined, + "options": { + "alt": undefined, + "backgroundImage": { + "bottom": undefined, + "height": 571, + "left": undefined, + "scale": undefined, + "top": undefined, + "url": "https://ka-perseus-images.s3.amazonaws.com/3d84aeb69c515789bba289f5118b1d6e2b796ca1.jpg", + "width": 357, + }, + "box": [ + 357, + 571, + ], + "caption": "", + "labels": [], + "range": [ + [ + 0, + 10, + ], + [ + 0, + 10, + ], + ], + "static": undefined, + "title": " +", + }, + "static": undefined, + "type": "image", + "version": { + "major": 0, + "minor": 0, + }, + }, + "image 2": { + "alignment": undefined, + "graded": true, + "key": undefined, + "options": { + "alt": undefined, + "backgroundImage": { + "bottom": undefined, + "height": 0, + "left": undefined, + "scale": undefined, + "top": undefined, + "url": null, + "width": 0, + }, + "box": [ + 0, + 0, + ], + "caption": "", + "labels": [], + "range": [ + [ + 0, + 10, + ], + [ + 0, + 10, + ], + ], + "static": undefined, + "title": "", + }, + "static": undefined, + "type": "image", + "version": { + "major": 0, + "minor": 0, + }, + }, + "passage 1": { + "alignment": undefined, + "graded": true, + "key": undefined, + "options": { + "footnotes": "", + "passageText": "", + "passageTitle": "", + "showLineNumbers": true, + "static": false, + }, + "static": undefined, + "type": "passage", + "version": { + "major": 0, + "minor": 0, + }, + }, + "passage-ref 1": { + "alignment": undefined, + "graded": true, + "key": undefined, + "options": { + "passageNumber": 1, + "referenceNumber": 1, + "summaryText": undefined, + }, + "static": undefined, + "type": "passage-ref", + "version": { + "major": 0, + "minor": 0, + }, + }, + "radio 1": { + "alignment": undefined, + "graded": true, + "key": undefined, + "options": { + "choices": [ + { + "clue": undefined, + "content": "Members of the high nobility and the wealthiest of the knightly class", + "correct": false, + "isNoneOfTheAbove": undefined, + "widgets": undefined, + }, + { + "clue": undefined, + "content": "Soldiers, members of the civilian militia, and mercenaries", + "correct": false, + "isNoneOfTheAbove": undefined, + "widgets": undefined, + }, + { + "clue": undefined, + "content": "Horses and dogs", + "correct": false, + "isNoneOfTheAbove": undefined, + "widgets": undefined, + }, + { + "clue": "Armor (of different styles and quality) was worn by almost all levels of society: high nobility (emperors, dukes, and counts), knights, mercenaries, citizens, peasants, and even young boys. Horses and dogs could also be protected with armor. Price varied considerably based on materials, quality, and decoration.", + "content": "All of the above", + "correct": true, + "isNoneOfTheAbove": undefined, + "widgets": undefined, + }, + ], + "countChoices": undefined, + "deselectEnabled": undefined, + "displayCount": null, + "hasNoneOfTheAbove": undefined, + "multipleSelect": false, + "noneOfTheAbove": false, + "onePerLine": true, + "randomize": false, + }, + "static": undefined, + "type": "radio", + "version": { + "major": 0, + "minor": 0, + }, + }, + "radio 2": { + "alignment": undefined, + "graded": true, + "key": undefined, + "options": { + "choices": [ + { + "clue": undefined, + "content": "", + "correct": undefined, + "isNoneOfTheAbove": undefined, + "widgets": undefined, + }, + { + "clue": undefined, + "content": "", + "correct": undefined, + "isNoneOfTheAbove": undefined, + "widgets": undefined, + }, + ], + "countChoices": undefined, + "deselectEnabled": undefined, + "displayCount": null, + "hasNoneOfTheAbove": undefined, + "multipleSelect": false, + "noneOfTheAbove": false, + "onePerLine": true, + "randomize": false, + }, + "static": undefined, + "type": "radio", + "version": { + "major": 0, + "minor": 0, + }, + }, + "table 1": { + "alignment": undefined, + "graded": true, + "key": undefined, + "options": { + "answers": [ + [ + "", + ], + [ + "", + ], + [ + "", + ], + [ + "", + ], + ], + "columns": 1, + "headers": [ + "", + ], + "rows": 4, + }, + "static": undefined, + "type": "table", + "version": { + "major": 0, + "minor": 0, + }, + }, + }, + }, +} +`; + exports[`parseAndTypecheckPerseusItem correctly parses data/plotter-with-undefined-plotDimensions.json 1`] = ` { "answer": undefined, diff --git a/packages/perseus/src/util/parse-perseus-json/regression-tests/data/explanation-missing-widgets-map.json b/packages/perseus/src/util/parse-perseus-json/regression-tests/data/explanation-missing-widgets-map.json new file mode 100644 index 0000000000..1dbce74eda --- /dev/null +++ b/packages/perseus/src/util/parse-perseus-json/regression-tests/data/explanation-missing-widgets-map.json @@ -0,0 +1,76 @@ +{ + "answerArea": { + "calculator": false, + "options": { + "content": "", + "images": {}, + "widgets": {} + }, + "periodicTable": false, + "type": "multiple" + }, + "hints": [ + { + "content": "What times $a$ gives us $b$?", + "images": {}, + "widgets": {} + }, + { + "content": "If we multiply each $a$-value by $\\blue{0.25}$, we get each corresponding $b$-value:\n\n> $b=\\blue{0.25}a$", + "images": {}, + "widgets": {} + }, + { + "content": "The *constant of proportionality* $(r)$ in the equation $b=ra$ is $\\blue{0.25}$.", + "images": {}, + "widgets": { + "explanation 1": { + "graded": true, + "options": { + "explanation": "Let's check the values of $a$ and $b$ given in the table above.", + "hidePrompt": "Okay, I'm convinced.", + "showPrompt": "Skeptical? Look at some examples." + }, + "type": "explanation", + "version": { + "major": 0, + "minor": 0 + } + } + } + } + ], + "itemDataVersion": { + "major": 0, + "minor": 1 + }, + "question": { + "content": "The quantities $a$ and $b$ are proportional.\n\n$a$ | $b$ | \n:-: | :-: |\n$8$ | $2$ \n$16$ | $4$\n$32$ | $8$\n\n**Find the *constant of proportionality* $(r)$ in the equation $b=ra$.**\n\n$r = $ [[☃ numeric-input 1]] ", + "images": {}, + "widgets": { + "numeric-input 1": { + "graded": true, + "options": { + "answers": [ + { + "maxError": null, + "message": "", + "simplify": "required", + "status": "correct", + "strict": false, + "value": 0.25 + } + ], + "coefficient": false, + "labelText": "", + "size": "normal" + }, + "type": "numeric-input", + "version": { + "major": 0, + "minor": 0 + } + } + } + } +} diff --git a/packages/perseus/src/util/parse-perseus-json/regression-tests/data/expression-answerForm-missing-form.json b/packages/perseus/src/util/parse-perseus-json/regression-tests/data/expression-answerForm-missing-form.json new file mode 100644 index 0000000000..635b044058 --- /dev/null +++ b/packages/perseus/src/util/parse-perseus-json/regression-tests/data/expression-answerForm-missing-form.json @@ -0,0 +1,49 @@ +{ + "question": { + "content": "Jake is younger than Sophie. Sophie is $14$ years old. \n\n**Write an inequality that compares Jake's age in years, $j$, to Sophie's age.**\n\n[[☃ expression 1]]", + "images": {}, + "widgets": { + "expression 1": { + "type": "expression", + "alignment": "default", + "static": false, + "graded": true, + "options": { + "answerForms": [ + { + "value": "j<14", + "considered": "correct", + "key": 0 + } + ], + "buttonSets": [ + "basic", + "basic relations", + "advanced relations" + ], + "functions": [ + "f", + "g" + ], + "times": false + }, + "version": { + "major": 1, + "minor": 0 + } + } + } + }, + "answerArea": { + "calculator": false, + "chi2Table": false, + "periodicTable": false, + "tTable": false, + "zTable": false + }, + "itemDataVersion": { + "major": 0, + "minor": 1 + }, + "hints": [] +} diff --git a/packages/perseus/src/util/parse-perseus-json/regression-tests/data/expression-option-missing-value.json b/packages/perseus/src/util/parse-perseus-json/regression-tests/data/expression-option-missing-value.json new file mode 100644 index 0000000000..6e679a4c5a --- /dev/null +++ b/packages/perseus/src/util/parse-perseus-json/regression-tests/data/expression-option-missing-value.json @@ -0,0 +1,51 @@ +{ + "answerArea": { + "calculator": false, + "options": { + "content": "", + "images": {}, + "widgets": {} + }, + "periodicTable": false, + "type": "multiple" + }, + "hints": [], + "itemDataVersion": { + "major": 0, + "minor": 1 + }, + "question": { + "content": "** Combine like terms to simplify the expression: **\n\n ${\\dfrac{2}{5}k-\\dfrac35+\\dfrac{1}{10}k}$\n\n[[☃ expression 1]]", + "images": {}, + "widgets": { + "expression 1": { + "alignment": "default", + "graded": true, + "options": { + "answerForms": [ + { + "considered": "correct", + "form": true, + "key": 0, + "simplify": true + } + ], + "buttonSets": [ + "basic" + ], + "functions": [ + "f", + "g", + "h" + ], + "times": false + }, + "type": "expression", + "version": { + "major": 1, + "minor": 0 + } + } + } + } +} diff --git a/packages/perseus/src/util/parse-perseus-json/regression-tests/data/interaction-element-missing-constraints.json b/packages/perseus/src/util/parse-perseus-json/regression-tests/data/interaction-element-missing-constraints.json new file mode 100644 index 0000000000..cd3d0b3e5e --- /dev/null +++ b/packages/perseus/src/util/parse-perseus-json/regression-tests/data/interaction-element-missing-constraints.json @@ -0,0 +1,191 @@ +{ + "answerArea": { + "calculator": false, + "options": { + "content": "", + "images": {}, + "widgets": {} + }, + "type": "multiple" + }, + "hints": [], + "itemDataVersion": { + "major": 0, + "minor": 1 + }, + "question": { + "content": "[[☃ interaction 1]]", + "images": {}, + "widgets": { + "interaction 1": { + "graded": true, + "options": { + "elements": [ + { + "key": "point-e4cd66", + "options": { + "color": "#28AE7B", + "coordX": "28", + "coordY": "35" + }, + "type": "point" + }, + { + "key": "point-463a3c", + "options": { + "color": "#FF00AF", + "coordX": "28", + "coordY": "35-f\\left(28\\right)" + }, + "type": "point" + }, + { + "key": "point-d97614", + "options": { + "color": "#28AE7B", + "coordX": "17", + "coordY": "28" + }, + "type": "point" + }, + { + "key": "point-334a70", + "options": { + "color": "#FF00AF", + "coordX": "17", + "coordY": "28-f\\left(17\\right)" + }, + "type": "point" + }, + { + "key": "point-31ac1c", + "options": { + "color": "#28AE7B", + "coordX": "6", + "coordY": "18" + }, + "type": "point" + }, + { + "key": "point-94798c", + "options": { + "color": "#FF00AF", + "coordX": "6", + "coordY": "18-f\\left(6\\right)" + }, + "type": "point" + }, + { + "key": "point-deb26e", + "options": { + "color": "#28AE7B", + "coordX": "42", + "coordY": "47" + }, + "type": "point" + }, + { + "key": "point-e8d73e", + "options": { + "color": "#FF00AF", + "coordX": "42", + "coordY": "47-f\\left(42\\right)" + }, + "type": "point" + }, + { + "key": "function-cd4dbc", + "options": { + "color": "#6495ED", + "funcName": "f", + "rangeMax": "50", + "rangeMin": "-5", + "strokeDasharray": "", + "strokeWidth": 2, + "value": "\\frac{\\left(y_1-y_0\\right)}{\\left(x_1-x_0\\right)}\\left(x-x_0\\right)+y_0" + }, + "type": "function" + }, + { + "key": "movable-point-64f8ec", + "options": { + "constraint": "none", + "constraintFn": "0", + "snap": 0.5, + "startX": "0", + "startY": "10", + "varSubscript": 0 + }, + "type": "movable-point" + }, + { + "key": "movable-point-4336fb", + "options": { + "constraint": "none", + "constraintFn": "0", + "snap": 0.5, + "startX": "40", + "startY": "30", + "varSubscript": 1 + }, + "type": "movable-point" + } + ], + "graph": { + "backgroundImage": { + "bottom": 0, + "left": 0, + "scale": 1, + "url": null + }, + "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 + } + }, + "type": "interaction", + "version": { + "major": 0, + "minor": 0 + } + } + } + } +} diff --git a/packages/perseus/src/util/parse-perseus-json/regression-tests/data/interactive-graph-backgroundImage-with-empty-string-coordinates.json b/packages/perseus/src/util/parse-perseus-json/regression-tests/data/interactive-graph-backgroundImage-with-empty-string-coordinates.json new file mode 100644 index 0000000000..f17767327d --- /dev/null +++ b/packages/perseus/src/util/parse-perseus-json/regression-tests/data/interactive-graph-backgroundImage-with-empty-string-coordinates.json @@ -0,0 +1,112 @@ +{ + "answerArea": { + "calculator": false, + "options": { + "content": "", + "images": {}, + "widgets": {} + }, + "periodicTable": false, + "type": "multiple" + }, + "hints": [ + { + "content": "We can plot the points using the equation to find $d$ for each value of $w$. \n\nIf $w=\\blue 6$, \n\n$\\qquad d=\\blue 6+5\\\\~~~~~~~~~~=\\red{11}.$ \n\nSo we place one point at $(\\blue 6,\\red{11})$.", + "images": {}, + "widgets": {} + }, + { + "content": "If $w=\\blue{10}$,\n\n$\\qquad d=\\blue{10}+5=\\red{15}$.\n\nSo the second point is at $(\\blue{10},\\red{15})$.", + "images": {}, + "widgets": {} + }, + { + "content": "The graph should look like this:\n\n![](https://ka-perseus-graphie.s3.amazonaws.com/d29e6802062c091aac9761cf34e41438104bd6a2.png)", + "images": { + "https://ka-perseus-graphie.s3.amazonaws.com/d29e6802062c091aac9761cf34e41438104bd6a2.png": { + "height": 425, + "width": 425 + } + }, + "widgets": {} + } + ], + "itemDataVersion": { + "major": 0, + "minor": 1 + }, + "question": { + "content": "You are $5$ miles away from your house when you start walking directly away from your house. In the table below, $w$ represents the number of miles you have walked, and $d$ represents your distance from home in miles.\n\nThe relationship between these two variables can be expressed by the following equation:\n\n$d=w+5.$\n\n**Plot two points on the graph that show your distance from home if you walked $6$ miles and $10$ miles.**\n\n$w$ | $d$\n:-:|:-:\n$0$ | $5$\n$1$ | $6$\n$2$ | $7$\n$3$ | $8$\n\n\n\n[[☃ interactive-graph 1]]", + "images": {}, + "widgets": { + "interactive-graph 1": { + "alignment": "default", + "graded": true, + "options": { + "backgroundImage": { + "bottom": "", + "height": 0, + "left": "", + "scale": "1", + "url": null, + "width": 0 + }, + "correct": { + "coords": [ + [ + 6, + 11 + ], + [ + 10, + 15 + ] + ], + "numPoints": 2, + "type": "point" + }, + "graph": { + "numPoints": 2, + "type": "point" + }, + "gridStep": [ + 1, + 1 + ], + "labels": [ + "w", + "d" + ], + "markings": "graph", + "range": [ + [ + -1, + 18 + ], + [ + -1, + 18 + ] + ], + "rulerLabel": "", + "rulerTicks": 10, + "showProtractor": false, + "showRuler": false, + "snapStep": [ + 0.5, + 0.5 + ], + "step": [ + 1, + 1 + ] + }, + "type": "interactive-graph", + "version": { + "major": 0, + "minor": 0 + } + } + } + } +} diff --git a/packages/perseus/src/util/parse-perseus-json/regression-tests/data/lights-puzzle.json b/packages/perseus/src/util/parse-perseus-json/regression-tests/data/lights-puzzle.json new file mode 100644 index 0000000000..6568b15938 --- /dev/null +++ b/packages/perseus/src/util/parse-perseus-json/regression-tests/data/lights-puzzle.json @@ -0,0 +1,42 @@ +{ + "answerArea": { + "calculator": false, + "options": { + "content": "", + "images": {}, + "widgets": {} + }, + "type": "multiple" + }, + "hints": [], + "question": { + "content": "**Light up all the squares by clicking on them.** When you click on a square, it will turn on (if it's off), or off (if it's on), as will each of the squares next to it.\n\n[[☃ lights-puzzle 1]]", + "images": {}, + "widgets": { + "lights-puzzle 1": { + "graded": true, + "options": { + "gradeIncompleteAsWrong": false, + "startCells": [ + [ + true, + false, + true + ], + [ + false, + true, + false + ], + [ + true, + false, + true + ] + ] + }, + "type": "lights-puzzle" + } + } + } +} diff --git a/packages/perseus/src/util/parse-perseus-json/regression-tests/data/matrix-with-string-answer-element.json b/packages/perseus/src/util/parse-perseus-json/regression-tests/data/matrix-with-string-answer-element.json new file mode 100644 index 0000000000..d9dc4efe72 --- /dev/null +++ b/packages/perseus/src/util/parse-perseus-json/regression-tests/data/matrix-with-string-answer-element.json @@ -0,0 +1,94 @@ +{ + "question": { + "content": "Consider this matrix transformation:\n\n$\\left[\\begin{array}{c}\n0 & 2 & 1 & -3\n\\\\\\\\ \n-1 & 2 & -3 & 0\n\\\\\\\\\n2 & -2 & 0 &1\n\\\\\\\\\n1 & 1 & -1 & -1\n\\end{array}\\right]$\n\n**What is the image of $\\left[\\begin{array}{c}\n-5\n\\\\\\\\\n1\n\\\\\\\\\n3\n\\\\\\\\\n-2\n\\end{array}\\right]$ under this transformation?**\n\n[[☃ matrix 1]]", + "images": {}, + "widgets": { + "matrix 1": { + "type": "matrix", + "alignment": "default", + "static": false, + "graded": true, + "options": { + "static": false, + "matrixBoardSize": [ + 4, + 1 + ], + "answers": [ + [ + "11" + ], + [ + -2 + ], + [ + -14 + ], + [ + -5 + ] + ], + "prefix": "", + "suffix": "", + "cursorPosition": [ + 0, + 0 + ] + }, + "version": { + "major": 0, + "minor": 0 + } + } + } + }, + "answerArea": { + "calculator": true, + "chi2Table": false, + "periodicTable": false, + "tTable": false, + "zTable": false + }, + "itemDataVersion": { + "major": 0, + "minor": 1 + }, + "hints": [ + { + "replace": false, + "content": "In general terms, suppose we have\n\n- an $n$-dimensional square matrix whose columns are $\\vec{v_1},\\vec{v_2},...,\\,\\vec{v_n}$ and\n- an $n$-dimensional vector $\\vec x=\\left[\\begin{array}{c}x_1\\\\\\\\x_2\\\\\\\\...\\\\\\\\x_n\\end{array}\\right]$,\n\nthen this is the image of the vector under the matrix transformations:\n\n$x_1\\cdot\\vec{v_1}+x_2\\cdot\\vec{v_2}+...+x_n\\cdot\\vec{v_n}$\n\n[[☃ explanation 1]]", + "images": {}, + "widgets": { + "explanation 1": { + "type": "explanation", + "alignment": "default", + "static": false, + "graded": true, + "options": { + "static": false, + "showPrompt": "Why is this true?", + "hidePrompt": "Got it, thanks!", + "explanation": "We learned that for 2-dimensional matrices, the first column is the image of the unit vector $\\left[\\begin{array}{c}1\\\\\\\\0\\end{array}\\right]$ and the second column is the image of the unit vector $\\left[\\begin{array}{c}0\\\\\\\\1\\end{array}\\right]$.\n\nThis can be generalized to higher dimensions, only now we have $n$ unit vectors which are full of zeros except for their $i^{\\text{th}}$ entry. For example, these are the four unit vectors in 4D:\n\n$\\left[\\begin{array}{c}1\\\\\\\\0\\\\\\\\0\\\\\\\\0\\end{array}\\right]\n\\left[\\begin{array}{c}0\\\\\\\\1\\\\\\\\0\\\\\\\\0\\end{array}\\right]\n\\left[\\begin{array}{c}0\\\\\\\\0\\\\\\\\1\\\\\\\\0\\end{array}\\right]\n\\left[\\begin{array}{c}0\\\\\\\\0\\\\\\\\0\\\\\\\\1\\end{array}\\right]$\n\nEach column of a 4D matrix tells us where it maps each unit vector, and we can write the general 4D vector $\\left[\\begin{array}{c}x_1\\\\\\\\x_2\\\\\\\\x_3\\\\\\\\x_4\\end{array}\\right]$ as:\n\n$x_1\\cdot\\left[\\begin{array}{c}1\\\\\\\\0\\\\\\\\0\\\\\\\\0\\end{array}\\right]\n+x_2\\cdot\\left[\\begin{array}{c}0\\\\\\\\1\\\\\\\\0\\\\\\\\0\\end{array}\\right]\n+x_3\\cdot\\left[\\begin{array}{c}0\\\\\\\\0\\\\\\\\1\\\\\\\\0\\end{array}\\right]\n+x_4\\cdot\\left[\\begin{array}{c}0\\\\\\\\0\\\\\\\\0\\\\\\\\1\\end{array}\\right]$", + "widgets": {} + }, + "version": { + "major": 0, + "minor": 0 + } + } + } + }, + { + "replace": false, + "content": "$\\begin{align}\n&\\phantom{=}\\left[\\begin{array}{c}\n\\tealE{0} & \\redE{2} & \\purpleE{1} & \\goldE{-3}\n\\\\\\\\ \n\\tealE{-1} & \\redE{2} & \\purpleE{-3} & \\goldE{0}\n\\\\\\\\\n\\tealE{2} & \\redE{-2} & \\purpleE{0} &\\goldE{1}\n\\\\\\\\\n\\tealE{1} & \\redE{1} & \\purpleE{-1} & \\goldE{-1}\n\\end{array}\\right]\n\\left(\\left[\\begin{array}{c}\n-5\n\\\\\\\\\n1\n\\\\\\\\\n3\n\\\\\\\\\n-2\n\\end{array}\\right]\\right)\n\\\\\\\\\n&=-5\\cdot \\left[\\begin{array}{c}\n\\tealE{0}\n\\\\\\\\ \n\\tealE{-1}\n\\\\\\\\\n\\tealE{2}\n\\\\\\\\\n\\tealE{1}\n\\end{array}\\right]+1\\cdot\\left[\\begin{array}{c}\n\\redE{2}\n\\\\\\\\ \n\\redE{2}\n\\\\\\\\\n\\redE{-2}\n\\\\\\\\\n\\redE{1}\n\\end{array}\\right]+3\\cdot \\left[\\begin{array}{c}\n\\purpleE{1}\n\\\\\\\\ \n\\purpleE{-3}\n\\\\\\\\\n\\purpleE{0}\n\\\\\\\\\n\\purpleE{-1}\n\\end{array}\\right]+(-2)\\cdot\\left[\\begin{array}{c}\n\\goldE{-3}\n\\\\\\\\ \n\\goldE{0}\n\\\\\\\\\n\\goldE{1}\n\\\\\\\\\n\\goldE{-1}\n\\end{array}\\right]\n\\\\\\\\\n&=\\left[\\begin{array}{c}\n0\n\\\\\\\\ \n5\n\\\\\\\\\n-10\n\\\\\\\\\n-5\n\\end{array}\\right]+\\left[\\begin{array}{c}\n2\n\\\\\\\\ \n2\n\\\\\\\\\n-2\n\\\\\\\\\n1\n\\end{array}\\right]+\\left[\\begin{array}{c}\n3\n\\\\\\\\ \n-9\n\\\\\\\\\n0\n\\\\\\\\\n-3\n\\end{array}\\right]+\\left[\\begin{array}{c}\n6\n\\\\\\\\ \n0\n\\\\\\\\\n-2\n\\\\\\\\\n2\n\\end{array}\\right]\n\\\\\\\\\n&=\\left[\\begin{array}{c}\n11\n\\\\\\\\\n-2\n\\\\\\\\\n-14\n\\\\\\\\\n-5\n\\end{array}\\right]\n\\end{align}$", + "images": {}, + "widgets": {} + }, + { + "replace": false, + "content": "This is the image we obtain when we perform the transformation $\\left[\\begin{array}{c}\n0 & 2 & 1 & -3\n\\\\\\\\ \n-1 & 2 & -3 & 0\n\\\\\\\\\n2 & -2 & 0 &1\n\\\\\\\\\n1 & 1 & -1 & -1\n\\end{array}\\right]$ on the pre-image $\\left[\\begin{array}{c}\n-5\n\\\\\\\\\n1\n\\\\\\\\\n3\n\\\\\\\\\n-2\n\\end{array}\\right]$:\n\n$\\left[\\begin{array}{c}\n11\n\\\\\\\\\n-2\n\\\\\\\\\n-14\n\\\\\\\\\n-5\n\\end{array}\\right]$", + "images": {}, + "widgets": {} + } + ] +} diff --git a/packages/perseus/src/util/parse-perseus-json/regression-tests/data/numeric-input-answer-with-null-value.json b/packages/perseus/src/util/parse-perseus-json/regression-tests/data/numeric-input-answer-with-null-value.json new file mode 100644 index 0000000000..e322f623cf --- /dev/null +++ b/packages/perseus/src/util/parse-perseus-json/regression-tests/data/numeric-input-answer-with-null-value.json @@ -0,0 +1,45 @@ +{ + "answerArea": { + "calculator": false, + "options": { + "content": "", + "images": {}, + "widgets": {} + }, + "type": "multiple" + }, + "hints": [], + "itemDataVersion": { + "major": 0, + "minor": 1 + }, + "question": { + "content": "$\\Huge36+3$\n\n[[☃ numeric-input 1]]", + "images": {}, + "widgets": { + "numeric-input 1": { + "graded": true, + "options": { + "answers": [ + { + "answerForms": [], + "maxError": null, + "message": "", + "simplify": "required", + "status": "correct", + "strict": false, + "value": null + } + ], + "coefficient": false, + "size": "normal" + }, + "type": "numeric-input", + "version": { + "major": 0, + "minor": 0 + } + } + } + } +} diff --git a/packages/perseus/src/util/parse-perseus-json/regression-tests/data/numeric-input-answer-with-simplify-true.json b/packages/perseus/src/util/parse-perseus-json/regression-tests/data/numeric-input-answer-with-simplify-true.json new file mode 100644 index 0000000000..1959fb5987 --- /dev/null +++ b/packages/perseus/src/util/parse-perseus-json/regression-tests/data/numeric-input-answer-with-simplify-true.json @@ -0,0 +1,46 @@ +{ + "answerArea": { + "calculator": false + }, + "hints": [ + { + "content": "$= \\dfrac{9 \\times 1}{2 \\times 4}$" + }, + { + "content": "$= \\dfrac{9}{8}$" + } + ], + "itemDataVersion": { + "major": 0, + "minor": 1 + }, + "question": { + "content": "**$ \\dfrac{9}{2} \\times \\dfrac{1}{4} $**\n\n[[☃ numeric-input 1]]", + "widgets": { + "numeric-input 1": { + "alignment": "default", + "graded": true, + "options": { + "answers": [ + { + "maxError": 0, + "message": "", + "simplify": true, + "status": "correct", + "strict": false, + "value": 1.125 + } + ], + "coefficient": false, + "labelText": "", + "size": "normal" + }, + "type": "numeric-input", + "version": { + "major": 0, + "minor": 0 + } + } + } + } +} diff --git a/packages/perseus/src/util/parse-perseus-json/regression-tests/data/passage-ref-missing-summaryText.json b/packages/perseus/src/util/parse-perseus-json/regression-tests/data/passage-ref-missing-summaryText.json new file mode 100644 index 0000000000..1f1395f13a --- /dev/null +++ b/packages/perseus/src/util/parse-perseus-json/regression-tests/data/passage-ref-missing-summaryText.json @@ -0,0 +1,210 @@ +{ + "answerArea": { + "calculator": false, + "options": { + "content": "", + "images": {}, + "widgets": {} + }, + "type": "multiple" + }, + "hints": [ + { + "content": "Anybody likely to need armor would certainly want to be wearing it. Armor was worn for sport, battle, civil defense, ceremony, and in some cases for hunting. This example—commissioned in 1591 by Sophie of Brandenburg as a Christmas gift for her husband, Christian I (reigned 1586–91)—is one of a set of twelve matching armors for the courtly sport of foot combat.", + "images": {}, + "widgets": {} + }, + { + "content": "Many medieval European cities, such as London, Paris, Frankfurt, Vienna, and Milan, actually required their citizens to own armor and weapons so that they would be able to defend the city in times of war.", + "images": {}, + "widgets": {} + }, + { + "content": "The answer is: All of the above. ", + "images": {}, + "widgets": {} + } + ], + "itemDataVersion": { + "major": 0, + "minor": 1 + }, + "question": { + "content": "[[☃ image 1]]\nAnton Peffenhauser, *Foot-Combat Armor of Prince-Elector Christian I of Saxony (reigned 1586–91)*, Steel, etched, blued, and gilt; leather; gilt bronze, Augsburg, 1591. \n\n**Who would have worn armor in medieval and Renaissance Europe?**\n\n[[☃ radio 1]]\n\n", + "images": {}, + "widgets": { + "image 1": { + "graded": true, + "options": { + "backgroundImage": { + "height": 571, + "url": "https://ka-perseus-images.s3.amazonaws.com/3d84aeb69c515789bba289f5118b1d6e2b796ca1.jpg", + "width": 357 + }, + "box": [ + 357, + 571 + ], + "caption": "", + "labels": [], + "range": [ + [ + 0, + 10 + ], + [ + 0, + 10 + ] + ], + "title": "\n" + }, + "type": "image", + "version": { + "major": 0, + "minor": 0 + } + }, + "image 2": { + "graded": true, + "options": { + "backgroundImage": { + "height": 0, + "url": null, + "width": 0 + }, + "box": [ + 0, + 0 + ], + "caption": "", + "labels": [], + "range": [ + [ + 0, + 10 + ], + [ + 0, + 10 + ] + ], + "title": "" + }, + "type": "image", + "version": { + "major": 0, + "minor": 0 + } + }, + "passage 1": { + "graded": true, + "options": { + "footnotes": "", + "passageText": "", + "passageTitle": "", + "showLineNumbers": true + }, + "type": "passage", + "version": { + "major": 0, + "minor": 0 + } + }, + "passage-ref 1": { + "graded": true, + "options": { + "passageNumber": 1, + "referenceNumber": 1 + }, + "type": "passage-ref", + "version": { + "major": 0, + "minor": 0 + } + }, + "radio 1": { + "graded": true, + "options": { + "choices": [ + { + "content": "Members of the high nobility and the wealthiest of the knightly class", + "correct": false + }, + { + "content": "Soldiers, members of the civilian militia, and mercenaries", + "correct": false + }, + { + "content": "Horses and dogs", + "correct": false + }, + { + "clue": "Armor (of different styles and quality) was worn by almost all levels of society: high nobility (emperors, dukes, and counts), knights, mercenaries, citizens, peasants, and even young boys. Horses and dogs could also be protected with armor. Price varied considerably based on materials, quality, and decoration.", + "content": "All of the above", + "correct": true + } + ], + "displayCount": null, + "multipleSelect": false, + "noneOfTheAbove": false, + "onePerLine": true, + "randomize": false + }, + "type": "radio", + "version": { + "major": 0, + "minor": 0 + } + }, + "radio 2": { + "graded": true, + "options": { + "choices": [ + {}, + {} + ], + "displayCount": null, + "multipleSelect": false, + "noneOfTheAbove": false, + "onePerLine": true, + "randomize": false + }, + "type": "radio", + "version": { + "major": 0, + "minor": 0 + } + }, + "table 1": { + "graded": true, + "options": { + "answers": [ + [ + "" + ], + [ + "" + ], + [ + "" + ], + [ + "" + ] + ], + "columns": 1, + "headers": [ + "" + ], + "rows": 4 + }, + "type": "table", + "version": { + "major": 0, + "minor": 0 + } + } + } + } +} diff --git a/packages/perseus/src/widgets/dropdown/__snapshots__/dropdown.test.ts.snap b/packages/perseus/src/widgets/dropdown/__snapshots__/dropdown.test.ts.snap index f7d61d9ea6..c5e1f1f777 100644 --- a/packages/perseus/src/widgets/dropdown/__snapshots__/dropdown.test.ts.snap +++ b/packages/perseus/src/widgets/dropdown/__snapshots__/dropdown.test.ts.snap @@ -20,7 +20,7 @@ exports[`Dropdown widget should snapshot when opened: dropdown open 1`] = ` class="default_xu2jcg" >
- greater -
-
- /less than or equal to +
+ greater/less than or equal to +
@@ -110,7 +108,7 @@ exports[`Dropdown widget should snapshot: initial render 1`] = ` class="default_xu2jcg" >
- greater -
-
- /less than or equal to +
+ greater/less than or equal to +
diff --git a/packages/perseus/src/widgets/dropdown/dropdown.tsx b/packages/perseus/src/widgets/dropdown/dropdown.tsx index d9dd934165..b09262bccc 100644 --- a/packages/perseus/src/widgets/dropdown/dropdown.tsx +++ b/packages/perseus/src/widgets/dropdown/dropdown.tsx @@ -80,7 +80,6 @@ class Dropdown extends React.Component implements Widget { } labelAsText={this.props.placeholder} @@ -93,7 +92,6 @@ class Dropdown extends React.Component implements Widget { } labelAsText={choice} @@ -122,6 +120,7 @@ class Dropdown extends React.Component implements Widget { this._handleChange(parseInt(value)) } diff --git a/packages/perseus/src/widgets/expression/expression.test.tsx b/packages/perseus/src/widgets/expression/expression.test.tsx index 9f2e9a799c..4a69541535 100644 --- a/packages/perseus/src/widgets/expression/expression.test.tsx +++ b/packages/perseus/src/widgets/expression/expression.test.tsx @@ -1,4 +1,5 @@ import {it, describe, beforeEach} from "@jest/globals"; +import {KeypadType} from "@khanacademy/math-input"; import {act, screen, waitFor} from "@testing-library/react"; import {userEvent as userEventLib} from "@testing-library/user-event"; @@ -12,7 +13,9 @@ import {mockStrings} from "../../strings"; import {scorePerseusItemTesting} from "../../util/test-utils"; import {renderQuestion} from "../__testutils__/renderQuestion"; -import ExpressionWidgetExport from "./expression"; +import ExpressionWidgetExport, { + keypadConfigurationForProps, +} from "./expression"; import { expressionItem2, expressionItem3, @@ -21,9 +24,10 @@ import { } from "./expression.testdata"; import type { - PerseusExpressionWidgetOptions, PerseusItem, + PerseusExpressionWidgetOptions, } from "../../perseus-types"; +import type {KeypadConfiguration} from "@khanacademy/math-input"; import type {UserEvent} from "@testing-library/user-event"; const renderAndAnswer = async ( @@ -588,3 +592,77 @@ describe("Expression Widget", function () { }); }); }); + +describe("Keypad configuration", () => { + it("should handle basic button set", async () => { + // Arrange + const widgetOptions: PerseusExpressionWidgetOptions = { + answerForms: [], + buttonSets: ["basic"], + times: false, + functions: [], + }; + + const expected: KeypadConfiguration = { + keypadType: KeypadType.EXPRESSION, + times: false, + extraKeys: ["PI"], + }; + + // Act + const result = keypadConfigurationForProps(widgetOptions); + + // Assert + expect(result).toEqual(expected); + }); + + it("should handle basic+div button set", async () => { + // Arrange + // Act + // Assert + expect( + keypadConfigurationForProps({ + answerForms: [], + buttonSets: ["basic+div"], + times: false, + functions: [], + }), + ).toEqual({ + keypadType: KeypadType.EXPRESSION, + times: false, + extraKeys: ["PI"], + }); + }); + + it("should return expression keypad configuration by default", async () => { + // Arrange + // Act + const result = keypadConfigurationForProps({ + answerForms: [], + buttonSets: [], + times: false, + functions: [], + }); + + // Assert + expect(result.keypadType).toEqual(KeypadType.EXPRESSION); + }); + + it("should handle scientific button set", async () => { + // Arrange + // Act + // Assert + expect( + keypadConfigurationForProps({ + answerForms: [], + buttonSets: ["scientific"], + times: false, + functions: [], + }), + ).toEqual({ + keypadType: KeypadType.EXPRESSION, + times: false, + extraKeys: ["PI"], + }); + }); +}); diff --git a/packages/perseus/src/widgets/expression/expression.tsx b/packages/perseus/src/widgets/expression/expression.tsx index 7ebf9d314f..52b06efb27 100644 --- a/packages/perseus/src/widgets/expression/expression.tsx +++ b/packages/perseus/src/widgets/expression/expression.tsx @@ -425,7 +425,7 @@ const styles = StyleSheet.create({ * to be included as keys on the keypad. These are scraped from the answer * forms. */ -const keypadConfigurationForProps = ( +export const keypadConfigurationForProps = ( widgetOptions: PerseusExpressionWidgetOptions, ): KeypadConfiguration => { // Always use the Expression keypad, regardless of the button sets that have diff --git a/packages/perseus/src/widgets/image/image.cypress.ts b/packages/perseus/src/widgets/image/image.cypress.ts new file mode 100644 index 0000000000..f90e9f60a0 --- /dev/null +++ b/packages/perseus/src/widgets/image/image.cypress.ts @@ -0,0 +1,58 @@ +import renderQuestionWithCypress from "../../../../../testing/render-question-with-cypress"; +import {cypressTestDependencies} from "../../../../../testing/test-dependencies"; +import * as Dependencies from "../../dependencies"; +import * as Perseus from "../../index"; + +import {questionWithZoom} from "./image.testdata"; + +// NOTE: The regression tests in this file use Cypress because they are intended to validate styling that is applied. +// Since React Testing Library isn't applying the CSS to the elements, +// we can't use Jest to verify that some keyboard interactions work properly. + +describe("Image Widget", () => { + beforeEach(() => { + Dependencies.setDependencies(cypressTestDependencies); + Perseus.init(); + window.innerWidth = 1024; + }); + + afterEach(() => { + // remove classes that are added to the body + cy.get("body").invoke("removeClass", "zoom-overlay-open"); + cy.get("img.zoom-img").invoke("remove"); + }); + + it("opens and closes zoomable images on click", () => { + // Arrange + renderQuestionWithCypress(questionWithZoom); + + // Act - click on the image + cy.get(".zoomable img").click(); + + // Assert + // The zoomed image should be visible and the zoomable image should be hidden + cy.get("img.zoom-img").should("be.visible"); + cy.get(".zoomable img").should("not.be.visible"); + + cy.get(".zoom-img").click(); + + // Assert - the zoomable image should be hidden + cy.get("img.zoom-img").should("not.be.visible"); + }); + + it("opens and closes on keyboard interaction", () => { + // Arrange + renderQuestionWithCypress(questionWithZoom); + + // Act - focus on the zoomable image and press enter + cy.get(".zoomable img").focus().type("{enter}"); + + // Assert + // The zoomed image should be visible and the zoomable image should be hidden + cy.get("img.zoom-img").should("be.visible"); + cy.get(".zoomable img").should("not.be.visible"); + + // Act - focus on the zoomed image and press escape + cy.get("img.zoom-img").focus().type("{esc}"); + }); +}); diff --git a/packages/perseus/src/widgets/image/image.stories.tsx b/packages/perseus/src/widgets/image/image.stories.tsx index fc743efd4a..cc26409bd1 100644 --- a/packages/perseus/src/widgets/image/image.stories.tsx +++ b/packages/perseus/src/widgets/image/image.stories.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import {RendererWithDebugUI} from "../../../../../testing/renderer-with-debug-ui"; -import {question} from "./image.testdata"; +import {question, questionWithZoom} from "./image.testdata"; import type {APIOptions} from "../../types"; @@ -76,6 +76,36 @@ export const Question2 = (args: StoryArgs): React.ReactElement => { ); }; +export const ImageWithZoom = (args: StoryArgs): React.ReactElement => { + const apiOptions: APIOptions = { + isMobile: args.isMobile, + }; + const imageOptions = questionWithZoom.widgets["image 1"].options; + + const questionWithCaptionAndArgs = { + ...questionWithZoom, + widgets: { + ...questionWithZoom.widgets, + "image 1": { + ...questionWithZoom.widgets["image 1"], + options: { + ...imageOptions, + alignment: "full-width", + title: args.title, + caption: + "There is neither happiness nor unhappiness in this world; there is only the comparison of one state with another. Only a man who has felt ultimate despair is capable of feeling ultimate bliss. It is necessary to have wished for death in order to know how good it is to live.....the sum of all human wisdom will be contained in these two words: Wait and Hope", + }, + }, + }, + } as const; + return ( + + ); +}; + export default { title: "Perseus/Widgets/Image", args: { diff --git a/packages/perseus/src/widgets/image/image.testdata.ts b/packages/perseus/src/widgets/image/image.testdata.ts index 83be27ef10..ddf0ddf57b 100644 --- a/packages/perseus/src/widgets/image/image.testdata.ts +++ b/packages/perseus/src/widgets/image/image.testdata.ts @@ -34,3 +34,38 @@ export const question = { } as ImageWidget, }, } as const; + +export const questionWithZoom = { + content: + "[[☃ image 1]]\n\n=====\n\nA quilter wants to make the design shown at left using the Golden Ratio. Specifically, he wants the ratio of the triangle heights $A:B$ and $B:C$ to each equal $1.62$. If the quilter makes the triangle height $A=8\\ \\text{in}$, approximately how tall should he make triangle height $C$?", + images: { + "https://cdn.kastatic.org/ka-perseus-images/01f44d5b73290da6bec97c75a5316fb05ab61f12.jpg": + {height: 955, width: 1698}, + }, + widgets: { + "image 1": { + alignment: "block", + graded: true, + options: { + alt: "An array of isosceles triangles. A triangle has height A. Two smaller triangle, one with height B and one with height C, have approximately the same combined height as A.", + title: "Image Title", + caption: "Image Caption", + backgroundImage: { + height: 955, + url: "https://cdn.kastatic.org/ka-perseus-images/01f44d5b73290da6bec97c75a5316fb05ab61f12.jpg", + width: 1698, + }, + box: [1698, 955], + labels: [], + range: [ + [0, 10], + [0, 10], + ], + static: false, + }, + static: false, + type: "image", + version: {major: 0, minor: 0}, + } as ImageWidget, + }, +} as const; diff --git a/packages/perseus/src/widgets/interaction/__snapshots__/interaction.test.ts.snap b/packages/perseus/src/widgets/interaction/__snapshots__/interaction.test.ts.snap index bdfd9634dc..e632f14b88 100644 --- a/packages/perseus/src/widgets/interaction/__snapshots__/interaction.test.ts.snap +++ b/packages/perseus/src/widgets/interaction/__snapshots__/interaction.test.ts.snap @@ -1,5 +1,817 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`interaction widget renders movable point elements with blank constraintXMin, constraintXMax, etc. 1`] = ` +
+
+
+
+
+
+ +
+
+
+
+
+
+`; + exports[`interaction widget should render 1`] = `
{ beforeEach(() => { @@ -38,4 +41,13 @@ describe("interaction widget", () => { // what state its in. expect(score).toHaveBeenAnsweredIncorrectly(); }); + + it("renders movable point elements with blank constraintXMin, constraintXMax, etc.", async () => { + const {container} = renderQuestion( + questionWithMovablePointMissingConstraints, + ); + await waitForInitialGraphieRender(); + + expect(container).toMatchSnapshot(); + }); }); diff --git a/packages/perseus/src/widgets/interaction/interaction.testdata.ts b/packages/perseus/src/widgets/interaction/interaction.testdata.ts index 03e8edbacc..9ff4f9c8b4 100644 --- a/packages/perseus/src/widgets/interaction/interaction.testdata.ts +++ b/packages/perseus/src/widgets/interaction/interaction.testdata.ts @@ -259,3 +259,60 @@ export const question1: PerseusRenderer = { }, }, }; + +export const questionWithMovablePointMissingConstraints: PerseusRenderer = { + content: "[[☃ interaction 1]]", + images: {}, + widgets: { + "interaction 1": { + graded: true, + options: { + static: false, + elements: [ + { + key: "movable-point-64f8ec", + options: { + constraint: "none", + constraintFn: "0", + constraintXMin: "", + constraintXMax: "", + constraintYMin: "", + constraintYMax: "", + snap: 0.5, + startX: "0", + startY: "10", + varSubscript: 0, + }, + type: "movable-point", + }, + ], + graph: { + backgroundImage: { + bottom: 0, + left: 0, + scale: 1, + url: null, + }, + 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, + }, + }, + type: "interaction", + version: {major: 0, minor: 0}, + }, + }, +}; diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/angle.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/angle.tsx index ecc93225dd..0ecb3f8e88 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/angle.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/angle.tsx @@ -112,10 +112,10 @@ function AngleGraph(props: AngleGraphProps) { angleMeasure, vertexX: srFormatNumber(coords[1][X], locale), vertexY: srFormatNumber(coords[1][Y], locale), - isX: srFormatNumber(coords[2][X], locale), - isY: srFormatNumber(coords[2][Y], locale), - tsX: srFormatNumber(coords[0][X], locale), - tsY: srFormatNumber(coords[0][Y], locale), + startingSideX: srFormatNumber(coords[2][X], locale), + startingSideY: srFormatNumber(coords[2][Y], locale), + endingSideX: srFormatNumber(coords[0][X], locale), + endingSideY: srFormatNumber(coords[0][Y], locale), }); const formatCoordinates = (x: number, y: number) => ({ diff --git a/packages/perseus/src/widgets/interactive-graphs/mafs-graph.test.tsx b/packages/perseus/src/widgets/interactive-graphs/mafs-graph.test.tsx index d9b4f0d03f..6acf462f4b 100644 --- a/packages/perseus/src/widgets/interactive-graphs/mafs-graph.test.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/mafs-graph.test.tsx @@ -569,7 +569,7 @@ describe("MafsGraph", () => { "angle-description", ); expect(angleGraph).toHaveTextContent( - "The angle measure is 270 degrees with a vertex at 0 comma 0, a point on the initial side at 1 comma 1 and a point on the terminal side at -1 comma 1.", + "The angle measure is 270 degrees with a vertex at 0 comma 0, a point on the starting side at 1 comma 1 and a point on the ending side at -1 comma 1.", ); }); diff --git a/packages/perseus/src/widgets/numeric-input/score-numeric-input.test.ts b/packages/perseus/src/widgets/numeric-input/score-numeric-input.test.ts index ddf569c325..b6a989eacf 100644 --- a/packages/perseus/src/widgets/numeric-input/score-numeric-input.test.ts +++ b/packages/perseus/src/widgets/numeric-input/score-numeric-input.test.ts @@ -315,6 +315,10 @@ describe("scoreNumericInput", () => { }); it("rejects responses formatted as a percentage when any answer has no value field", () => { + // NOTE(benchristel): this is a characterization test for existing + // behavior. Ideally, answers should always have a value field, but + // some don't, so this test documents how we handle that. + // TODO(benchristel): Fix the data so we can remove this test. const scoringData: PerseusNumericInputScoringData = { answers: [ { @@ -347,6 +351,43 @@ describe("scoreNumericInput", () => { expect(score).toHaveBeenAnsweredIncorrectly(); }); + it("rejects responses formatted as a percentage when any answer has a null value", () => { + // NOTE(benchristel): this is a characterization test for existing + // behavior. Ideally, answers should always have a value field, but + // some don't, so this test documents how we handle that. + // TODO(benchristel): Fix the data so we can remove this test. + const rubric: PerseusNumericInputScoringData = { + answers: [ + { + value: null, + status: "correct", + maxError: 0, + simplify: "", + strict: false, + message: "", + }, + { + // This is the actual correct answer + value: 0.5, + status: "correct", + maxError: 0, + simplify: "", + strict: false, + message: "", + }, + ], + coefficient: true, + }; + + const score = scoreNumericInput( + {currentValue: "50%"}, + rubric, + mockStrings, + ); + + expect(score).toHaveBeenAnsweredIncorrectly(); + }); + it("converts a percentage input value to a decimal", () => { const scoringData: PerseusNumericInputScoringData = { answers: [ diff --git a/packages/perseus/src/widgets/numeric-input/score-numeric-input.ts b/packages/perseus/src/widgets/numeric-input/score-numeric-input.ts index df12f20ef3..f8d26f72b8 100644 --- a/packages/perseus/src/widgets/numeric-input/score-numeric-input.ts +++ b/packages/perseus/src/widgets/numeric-input/score-numeric-input.ts @@ -111,10 +111,7 @@ function scoreNumericInput( const normalizedAnswerExpected = scoringData.answers .filter((answer) => answer.status === "correct") - .every( - (answer) => - answer.value !== undefined && Math.abs(answer.value) <= 1, - ); + .every((answer) => answer.value != null && Math.abs(answer.value) <= 1); // The coefficient is an attribute of the widget let localValue: string | number = currentValue; diff --git a/packages/perseus/src/widgets/passage-ref/passage-ref.tsx b/packages/perseus/src/widgets/passage-ref/passage-ref.tsx index f6f1007621..ed91993a02 100644 --- a/packages/perseus/src/widgets/passage-ref/passage-ref.tsx +++ b/packages/perseus/src/widgets/passage-ref/passage-ref.tsx @@ -11,13 +11,14 @@ import {isPassageWidget} from "../passage/utils"; import type {PerseusPassageRefWidgetOptions} from "../../perseus-types"; import type {ChangeFn, Widget, WidgetExports, WidgetProps} from "../../types"; import type {PassageRefPromptJSON} from "../../widget-ai-utils/passage-ref/passage-ref-ai-utils"; +import type {PropsFor} from "@khanacademy/wonder-blocks-core"; const EN_DASH = "\u2013"; type RenderProps = { passageNumber: PerseusPassageRefWidgetOptions["passageNumber"]; referenceNumber: PerseusPassageRefWidgetOptions["referenceNumber"]; - summaryText: PerseusPassageRefWidgetOptions["summaryText"]; + summaryText: string; }; type Props = WidgetProps; @@ -25,7 +26,7 @@ type Props = WidgetProps; type DefaultProps = { passageNumber: Props["passageNumber"]; referenceNumber: Props["referenceNumber"]; - summaryText: Props["summaryText"]; + summaryText: string; }; type State = { @@ -33,6 +34,10 @@ type State = { content: string | null | undefined; }; +0 as any as WidgetProps satisfies PropsFor< + typeof PassageRef +>; + class PassageRef extends React.Component implements Widget { static contextType = PerseusI18nContext; declare context: React.ContextType; @@ -175,9 +180,7 @@ export default { hidden: true, defaultAlignment: "inline", widget: PassageRef, - transform: ( - widgetOptions: PerseusPassageRefWidgetOptions, - ): RenderProps => ({ + transform: (widgetOptions: PerseusPassageRefWidgetOptions) => ({ passageNumber: widgetOptions.passageNumber, referenceNumber: widgetOptions.referenceNumber, summaryText: widgetOptions.summaryText, diff --git a/packages/perseus/src/zoom.ts b/packages/perseus/src/zoom.ts index daca6f9c56..747c4d4c54 100644 --- a/packages/perseus/src/zoom.ts +++ b/packages/perseus/src/zoom.ts @@ -265,7 +265,9 @@ ZoomServiceClass.prototype._scrollHandler = function (e: any) { }; ZoomServiceClass.prototype._keyHandler = function (e: any) { - if (e.keyCode === 27) { + // 27: Esc, 13: Enter, 32: Space + const keyCodes = [27, 13, 32]; + if (keyCodes.includes(e.keyCode)) { this._activeZoomClose(); } }; @@ -367,12 +369,14 @@ Zoom.prototype.zoomImage = function () { img.src = this._targetImage.src; img.alt = this._targetImage.alt; + img.tabIndex = 0; this.$zoomedImage = $zoomedImage; }; Zoom.prototype._zoomOriginal = function () { this.$zoomedImage.addClass("zoom-img").attr("data-action", "zoom-out"); + $(this._targetImage).css("visibility", "hidden"); this._backdrop = document.createElement("div");