From 8259c23e0e008c66608b5721b6a496f16edb8818 Mon Sep 17 00:00:00 2001 From: Jeff Yates Date: Fri, 20 Dec 2024 17:14:08 -0600 Subject: [PATCH 01/11] =?UTF-8?q?[=F0=9F=94=A5AUDIT=F0=9F=94=A5]=20Ensure?= =?UTF-8?q?=20we=20cope=20with=20release=20commit=20as=20well=20as=20versi?= =?UTF-8?q?on=20packages=20(#2055)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🖍 _This is an audit!_ 🖍 ## Summary: Discovered during WB testing of similar workflow that if the version packages PR only has one commit, instead of squashing to a commit with the PR name, it may just merge that commit. So this copes with workflows that are named via that automated commit from changesets. Issue: FEI-6062 ## Test plan: Already tested in wonder blocks. Author: somewhatabstract Auditors: #perseus Required Reviewers: Approved By: Checks: ⌛ Publish npm snapshot (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ⌛ Check builds for changes in size (ubuntu-latest, 20.x), ⌛ Cypress (ubuntu-latest, 20.x), ✅ Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ⌛ Publish Storybook to Chromatic (ubuntu-latest, 20.x) Pull Request URL: https://github.com/Khan/perseus/pull/2055 --- .changeset/fluffy-rivers-turn.md | 2 ++ .github/workflows/node-ci.yml | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 .changeset/fluffy-rivers-turn.md diff --git a/.changeset/fluffy-rivers-turn.md b/.changeset/fluffy-rivers-turn.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/fluffy-rivers-turn.md @@ -0,0 +1,2 @@ +--- +--- 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." From f23b383e797a522ddee064c79e582467dfc08f94 Mon Sep 17 00:00:00 2001 From: Mark Fitzgerald <13896410+mark-fitzgerald@users.noreply.github.com> Date: Fri, 20 Dec 2024 15:33:25 -0800 Subject: [PATCH 02/11] [Dropdown] Bugfix - Render options and placeholder inline (#2054) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary: ## The dropdown widget was not rendering content properly. Punctuation and other non-alphanumeric characters were causing the content to break to their own lines, even without TeX markup. Removing the `inline` setting for the `` usages resolved this. Issue: LEMS-2741 ## Test plan: 1. Open Storybook 1. Navigate to the Editor Demo page 1. Add a Dropdown widget 1. Type in TeX content for one of the option 1. Add another option with regular text (all alphanumeric characters) 1. Add another option with punctuation or other non-alphanumeric characters ($%^<>,.:) 1. Viewing the dropdown options in the preview section should show all of the options on their own lines (single line for each) 1. Repeat these steps for the Placeholder portion of the widget Author: mark-fitzgerald Reviewers: jeremywiebe Required Reviewers: Approved By: jeremywiebe Checks: ✅ Publish npm snapshot (ubuntu-latest, 20.x), ✅ Cypress (ubuntu-latest, 20.x), ✅ Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ Check builds for changes in size (ubuntu-latest, 20.x), ✅ Publish npm snapshot (ubuntu-latest, 20.x), ✅ Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ✅ Cypress (ubuntu-latest, 20.x), ✅ Check builds for changes in size (ubuntu-latest, 20.x), ✅ Publish Storybook to Chromatic (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x) Pull Request URL: https://github.com/Khan/perseus/pull/2054 --- .changeset/hungry-taxis-clap.md | 5 ++++ .../__snapshots__/renderer.test.tsx.snap | 30 +++++++++++-------- .../__snapshots__/dropdown.test.ts.snap | 28 ++++++++--------- .../perseus/src/widgets/dropdown/dropdown.tsx | 2 -- 4 files changed, 35 insertions(+), 30 deletions(-) create mode 100644 .changeset/hungry-taxis-clap.md diff --git a/.changeset/hungry-taxis-clap.md b/.changeset/hungry-taxis-clap.md new file mode 100644 index 0000000000..f448597617 --- /dev/null +++ b/.changeset/hungry-taxis-clap.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": patch +--- + +[Dropdown] Bugfix - Render options and placeholder inline diff --git a/packages/perseus/src/__tests__/__snapshots__/renderer.test.tsx.snap b/packages/perseus/src/__tests__/__snapshots__/renderer.test.tsx.snap index 0b73d6e928..f6cb3327b3 100644 --- a/packages/perseus/src/__tests__/__snapshots__/renderer.test.tsx.snap +++ b/packages/perseus/src/__tests__/__snapshots__/renderer.test.tsx.snap @@ -383,10 +383,14 @@ exports[`renderer snapshots correct answer: correct answer 1`] = ` class="perseus-renderer perseus-renderer-responsive" >
- less than or equal to +
+ less than or equal to +
@@ -471,10 +475,14 @@ exports[`renderer snapshots incorrect answer: incorrect answer 1`] = ` class="perseus-renderer perseus-renderer-responsive" >
- greater than or equal to +
+ greater than or equal to +
@@ -559,16 +567,14 @@ exports[`renderer snapshots initial render: initial render 1`] = ` class="perseus-renderer perseus-renderer-responsive" >
- greater -
-
- /less than or equal to +
+ greater/less than or equal to +
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..0cba374a65 100644 --- a/packages/perseus/src/widgets/dropdown/__snapshots__/dropdown.test.ts.snap +++ b/packages/perseus/src/widgets/dropdown/__snapshots__/dropdown.test.ts.snap @@ -50,16 +50,14 @@ exports[`Dropdown widget should snapshot when opened: dropdown open 1`] = ` class="perseus-renderer perseus-renderer-responsive" >
- greater -
-
- /less than or equal to +
+ greater/less than or equal to +
@@ -138,16 +136,14 @@ exports[`Dropdown widget should snapshot: initial render 1`] = ` class="perseus-renderer perseus-renderer-responsive" >
- 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 b10d6b1020..19494c8f35 100644 --- a/packages/perseus/src/widgets/dropdown/dropdown.tsx +++ b/packages/perseus/src/widgets/dropdown/dropdown.tsx @@ -79,7 +79,6 @@ class Dropdown extends React.Component implements Widget { } labelAsText={this.props.placeholder} @@ -92,7 +91,6 @@ class Dropdown extends React.Component implements Widget { } labelAsText={choice} From d8a714a61853b5b17d84f625e481f21e36b3638c Mon Sep 17 00:00:00 2001 From: Khan Actions Bot <56267880+khan-actions-bot@users.noreply.github.com> Date: Fri, 20 Dec 2024 18:44:16 -0500 Subject: [PATCH 03/11] Version Packages (#2056) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR was opened by the [Changesets release](https://github.com/changesets/action) GitHub action. When you're ready to do a release, you can merge this and the packages will be published to npm automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated. # Releases ## @khanacademy/perseus@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 ## @khanacademy/perseus-editor@17.0.12 ### Patch Changes - Updated dependencies \[[`f23b383e7`](https://github.com/Khan/perseus/commit/f23b383e797a522ddee064c79e582467dfc08f94)]: - @khanacademy/perseus@49.1.7 Author: khan-actions-bot Reviewers: jeremywiebe Required Reviewers: Approved By: jeremywiebe Checks: ⏭ī¸ Publish npm snapshot, ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ✅ Cypress (ubuntu-latest, 20.x), ✅ Check builds for changes in size (ubuntu-latest, 20.x), ✅ Publish Storybook to Chromatic (ubuntu-latest, 20.x) Pull Request URL: https://github.com/Khan/perseus/pull/2056 --- .changeset/fluffy-rivers-turn.md | 2 -- .changeset/hungry-taxis-clap.md | 5 ----- packages/perseus-editor/CHANGELOG.md | 7 +++++++ packages/perseus-editor/package.json | 4 ++-- packages/perseus/CHANGELOG.md | 6 ++++++ packages/perseus/package.json | 2 +- 6 files changed, 16 insertions(+), 10 deletions(-) delete mode 100644 .changeset/fluffy-rivers-turn.md delete mode 100644 .changeset/hungry-taxis-clap.md diff --git a/.changeset/fluffy-rivers-turn.md b/.changeset/fluffy-rivers-turn.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/fluffy-rivers-turn.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/hungry-taxis-clap.md b/.changeset/hungry-taxis-clap.md deleted file mode 100644 index f448597617..0000000000 --- a/.changeset/hungry-taxis-clap.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@khanacademy/perseus": patch ---- - -[Dropdown] Bugfix - Render options and placeholder inline diff --git a/packages/perseus-editor/CHANGELOG.md b/packages/perseus-editor/CHANGELOG.md index ab3c329550..14271e896c 100644 --- a/packages/perseus-editor/CHANGELOG.md +++ b/packages/perseus-editor/CHANGELOG.md @@ -1,5 +1,12 @@ # @khanacademy/perseus-editor +## 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..efb54961de 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.0.12", "publishConfig": { "access": "public" }, @@ -39,7 +39,7 @@ "@khanacademy/keypad-context": "^1.0.12", "@khanacademy/kmath": "^0.1.24", "@khanacademy/math-input": "^22.0.7", - "@khanacademy/perseus": "^49.1.6", + "@khanacademy/perseus": "^49.1.7", "@khanacademy/perseus-core": "3.0.5", "@khanacademy/pure-markdown": "^0.3.20", "mafs": "^0.19.0" diff --git a/packages/perseus/CHANGELOG.md b/packages/perseus/CHANGELOG.md index a7660876c6..bfe8251351 100644 --- a/packages/perseus/CHANGELOG.md +++ b/packages/perseus/CHANGELOG.md @@ -1,5 +1,11 @@ # @khanacademy/perseus +## 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..a35a843e0e 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.1.7", "publishConfig": { "access": "public" }, From 61737714796dfb8434fc139471d1add3c18853b3 Mon Sep 17 00:00:00 2001 From: Ben Christel Date: Thu, 2 Jan 2025 16:40:49 -0800 Subject: [PATCH 04/11] Add and pass more regression tests for PerseusItem parser (#1952) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: LEMS-2582 ## Test plan: `yarn test` Author: benchristel Reviewers: jeremywiebe, benchristel Required Reviewers: Approved By: jeremywiebe Checks: ✅ Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ✅ Publish npm snapshot (ubuntu-latest, 20.x), ✅ Cypress (ubuntu-latest, 20.x), ✅ Check builds for changes in size (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ Publish Storybook to Chromatic (ubuntu-latest, 20.x) Pull Request URL: https://github.com/Khan/perseus/pull/1952 --- .changeset/sixty-vans-end.md | 5 + packages/perseus/src/perseus-types.ts | 5 +- .../general-purpose-parsers/convert.ts | 7 + .../discriminated-union.test.ts | 84 + .../discriminated-union.ts | 46 + .../perseus-parsers/explanation-widget.ts | 8 +- .../perseus-parsers/expression-widget.ts | 60 +- .../perseus-parsers/grapher-widget.ts | 10 +- .../perseus-parsers/interaction-widget.ts | 63 +- .../perseus-parsers/matrix-widget.ts | 9 +- .../perseus-parsers/numeric-input-widget.ts | 20 +- .../perseus-parsers/passage-ref-widget.ts | 10 +- .../perseus-image-background.ts | 26 +- .../versioned-widget-options.test.ts | 153 ++ .../versioned-widget-options.ts | 87 + .../perseus-parsers/widgets-map.ts | 2 + .../parse-perseus-json-snapshot.test.ts.snap | 1410 ++++++++++++++++- .../data/explanation-missing-widgets-map.json | 76 + .../expression-answerForm-missing-form.json | 49 + .../data/expression-option-missing-value.json | 51 + ...teraction-element-missing-constraints.json | 191 +++ ...ndImage-with-empty-string-coordinates.json | 112 ++ .../regression-tests/data/lights-puzzle.json | 42 + .../matrix-with-string-answer-element.json | 94 ++ .../numeric-input-answer-with-null-value.json | 45 + ...meric-input-answer-with-simplify-true.json | 46 + .../data/passage-ref-missing-summaryText.json | 210 +++ .../__snapshots__/interaction.test.ts.snap | 812 ++++++++++ .../widgets/interaction/interaction.test.ts | 14 +- .../interaction/interaction.testdata.ts | 57 + .../numeric-input/score-numeric-input.test.ts | 41 + .../numeric-input/score-numeric-input.ts | 5 +- .../src/widgets/passage-ref/passage-ref.tsx | 13 +- 33 files changed, 3744 insertions(+), 119 deletions(-) create mode 100644 .changeset/sixty-vans-end.md create mode 100644 packages/perseus/src/util/parse-perseus-json/general-purpose-parsers/convert.ts create mode 100644 packages/perseus/src/util/parse-perseus-json/general-purpose-parsers/discriminated-union.test.ts create mode 100644 packages/perseus/src/util/parse-perseus-json/general-purpose-parsers/discriminated-union.ts create mode 100644 packages/perseus/src/util/parse-perseus-json/perseus-parsers/versioned-widget-options.test.ts create mode 100644 packages/perseus/src/util/parse-perseus-json/perseus-parsers/versioned-widget-options.ts create mode 100644 packages/perseus/src/util/parse-perseus-json/regression-tests/data/explanation-missing-widgets-map.json create mode 100644 packages/perseus/src/util/parse-perseus-json/regression-tests/data/expression-answerForm-missing-form.json create mode 100644 packages/perseus/src/util/parse-perseus-json/regression-tests/data/expression-option-missing-value.json create mode 100644 packages/perseus/src/util/parse-perseus-json/regression-tests/data/interaction-element-missing-constraints.json create mode 100644 packages/perseus/src/util/parse-perseus-json/regression-tests/data/interactive-graph-backgroundImage-with-empty-string-coordinates.json create mode 100644 packages/perseus/src/util/parse-perseus-json/regression-tests/data/lights-puzzle.json create mode 100644 packages/perseus/src/util/parse-perseus-json/regression-tests/data/matrix-with-string-answer-element.json create mode 100644 packages/perseus/src/util/parse-perseus-json/regression-tests/data/numeric-input-answer-with-null-value.json create mode 100644 packages/perseus/src/util/parse-perseus-json/regression-tests/data/numeric-input-answer-with-simplify-true.json create mode 100644 packages/perseus/src/util/parse-perseus-json/regression-tests/data/passage-ref-missing-summaryText.json diff --git a/.changeset/sixty-vans-end.md b/.changeset/sixty-vans-end.md new file mode 100644 index 0000000000..2c4bfbf514 --- /dev/null +++ b/.changeset/sixty-vans-end.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": patch +--- + +Internal: add and pass more regression tests for PerseusItem parser diff --git a/packages/perseus/src/perseus-types.ts b/packages/perseus/src/perseus-types.ts index 630ef58abc..2e7fdcf1c0 100644 --- a/packages/perseus/src/perseus-types.ts +++ b/packages/perseus/src/perseus-types.ts @@ -116,6 +116,7 @@ export interface PerseusWidgetTypes { video: VideoWidget; // Deprecated widgets + "lights-puzzle": AutoCorrectWidget; sequence: AutoCorrectWidget; } @@ -1176,7 +1177,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" @@ -1255,7 +1256,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/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..65ddab991d --- /dev/null +++ b/packages/perseus/src/util/parse-perseus-json/general-purpose-parsers/discriminated-union.test.ts @@ -0,0 +1,84 @@ +import {parse} from "../parse"; +import {failure, success} from "../result"; + +import {constant} from "./constant"; +import {discriminatedUnion} from "./discriminated-union"; +import {number} from "./number"; +import {object} from "./object"; + +describe("a discriminatedUnion with one variant", () => { + const unionParser = discriminatedUnion( + object({type: constant("ok")}), + object({type: constant("ok"), value: number}), + ).parser; + + it("parses a valid value", () => { + const input = {type: "ok", value: 3}; + + expect(parse(input, unionParser)).toEqual(success(input)); + }); + + it("rejects a value with the wrong `type`", () => { + const input = {type: "bad", value: 3}; + + expect(parse(input, unionParser)).toEqual( + failure(`At (root).type -- expected "ok", but got "bad"`), + ); + }); + + it("rejects a value with a valid type but wrong fields", () => { + const input = {type: "ok", value: "foobar"}; + + expect(parse(input, unionParser)).toEqual( + failure(`At (root).value -- expected number, but got "foobar"`), + ); + }); +}); + +describe("a discriminatedUnion with two variants", () => { + const unionParser = discriminatedUnion( + object({type: constant("rectangle")}), + object({type: constant("rectangle"), width: number}), + ).or( + object({type: constant("circle")}), + object({type: constant("circle"), radius: number}), + ).parser; + + it("parses a valid rectangle", () => { + const input = {type: "rectangle", width: 42}; + + expect(parse(input, unionParser)).toEqual(success(input)); + }); + + it("rejects a rectangle with no width", () => { + const input = {type: "rectangle", radius: 99}; + + expect(parse(input, unionParser)).toEqual( + failure(`At (root).width -- expected number, but got undefined`), + ); + }); + + it("parses a valid circle", () => { + const input = {type: "circle", radius: 7}; + + expect(parse(input, unionParser)).toEqual(success(input)); + }); + + it("rejects a circle with no radius", () => { + const input = {type: "circle", width: 99}; + + expect(parse(input, unionParser)).toEqual( + failure(`At (root).radius -- expected number, but got undefined`), + ); + }); + + it("rejects a value with an unrecognized `type`", () => { + const input = {type: "triangle", width: -1, radius: 99}; + + expect(parse(input, unionParser)).toEqual( + failure( + `At (root).type -- expected "rectangle", but got "triangle"`, + ), + ); + }); +}); 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..35e30cecc7 --- /dev/null +++ b/packages/perseus/src/util/parse-perseus-json/general-purpose-parsers/discriminated-union.ts @@ -0,0 +1,46 @@ +import {isSuccess} from "../result"; + +import {pipeParsers} from "./pipe-parsers"; + +import type {Parser} from "../parser-types"; + +// 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 discriminatedUnion( + narrow: Parser, + parseVariant: Parser, +): DiscriminatedUnionBuilder { + return new DiscriminatedUnionBuilder( + pipeParsers(narrow).then(parseVariant).parser, + ); +} + +class DiscriminatedUnionBuilder { + constructor(public parser: Parser) {} + + or( + narrow: Parser, + parseVariant: Parser, + ): DiscriminatedUnionBuilder { + return new DiscriminatedUnionBuilder( + either(narrow, parseVariant, this.parser), + ); + } +} + +function either( + narrowToA: Parser, + parseA: Parser, + parseB: Parser, +): Parser { + return (rawValue, ctx) => { + if (isSuccess(narrowToA(rawValue, ctx))) { + return parseA(rawValue, ctx); + } + + return parseB(rawValue, ctx); + }; +} 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..924c12ccad 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 {discriminatedUnion} from "../general-purpose-parsers/discriminated-union"; import {parseWidget} from "./widget"; @@ -35,13 +36,15 @@ export const parseGrapherWidget: Parser = parseWidget( "tangent", ), ), - correct: union( + correct: discriminatedUnion( + object({type: constant("absolute_value")}), object({ type: constant("absolute_value"), coords: pairOfPoints, }), ) .or( + object({type: constant("exponential")}), object({ type: constant("exponential"), asymptote: pairOfPoints, @@ -49,12 +52,14 @@ export const parseGrapherWidget: Parser = parseWidget( }), ) .or( + object({type: constant("linear")}), object({ type: constant("linear"), coords: pairOfPoints, }), ) .or( + object({type: constant("logarithm")}), object({ type: constant("logarithm"), asymptote: pairOfPoints, @@ -62,18 +67,21 @@ export const parseGrapherWidget: Parser = parseWidget( }), ) .or( + object({type: constant("quadratic")}), object({ type: constant("quadratic"), coords: pairOfPoints, }), ) .or( + object({type: constant("sinusoid")}), object({ type: constant("sinusoid"), coords: pairOfPoints, }), ) .or( + object({type: constant("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..4bf9a3c3bb 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 {discriminatedUnion} 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,24 @@ 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, + discriminatedUnion( + object({type: parseFunctionType}), + parseFunctionElement, + ) + .or(object({type: parseLabelType}), parseLabelElement) + .or(object({type: parseLineType}), parseLineElement) + .or( + object({type: parseMovableLineType}), + parseMovableLineElement, + ) + .or( + object({type: parseMovablePointType}), + parseMovablePointElement, + ) + .or(object({type: parseParametricType}), parseParametricElement) + .or(object({type: parsePointType}), parsePointElement) + .or(object({type: parseRectangleType}), 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/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/numeric-input/score-numeric-input.test.ts b/packages/perseus/src/widgets/numeric-input/score-numeric-input.test.ts index 144fc2a63b..1167eb59d7 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 @@ -289,6 +289,10 @@ describe("static function validate", () => { }); 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 rubric: PerseusNumericInputRubric = { answers: [ { @@ -321,6 +325,43 @@ describe("static function validate", () => { 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: PerseusNumericInputRubric = { + 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 rubric: PerseusNumericInputRubric = { 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 cf23c5ad28..2c1af5890f 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 = rubric.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, From dbbc82f2dd33545b12c6073174b05ebcf8d551ba Mon Sep 17 00:00:00 2001 From: Anakaren Date: Mon, 6 Jan 2025 09:11:55 -0800 Subject: [PATCH 05/11] feat(LEMS-2272): Add scientific notation button to the first tab of expression widget (#1738) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary: Adds scientific notation button to the first tab of expression widget when added via `button sets` in expression editor Issue: LEMS-2272 ## Test plan: In Expression Widget Editor, add scientific button to first tab by toggling the `scientific` option under the `Button Sets` section of the editor. ### Expected Behavior 1. Within the expression editor, toggle adds and removes scientific notation button 2. When toggled, the first tab of expression widget has scientific notation button in the `Shared Keys` section and subsequent tabs of expression widget do NOT have scientific notation button in the `Shared Keys` section 3. When not toggled, none of the tabs have the scientific notation button in the `Shared Keys` section of the widget 4. Scientific Notation button maintains the same functionality as the original ## Screen Recording: https://github.com/user-attachments/assets/c6814815-5654-4b51-8dc6-7eb8173a0bcf Author: anakaren-rojas Reviewers: catandthemachines Required Reviewers: Approved By: catandthemachines Checks: ✅ Publish npm snapshot (ubuntu-latest, 20.x), ✅ Cypress (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ✅ Check builds for changes in size (ubuntu-latest, 20.x), ✅ Publish npm snapshot (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ Cypress (ubuntu-latest, 20.x), ✅ Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ✅ Check builds for changes in size (ubuntu-latest, 20.x), ⏚ī¸ [cancelled] Publish npm snapshot (ubuntu-latest, 20.x), ⏚ī¸ [cancelled] Cypress (ubuntu-latest, 20.x), ⏚ī¸ [cancelled] Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ⏚ī¸ [cancelled] Check builds for changes in size (ubuntu-latest, 20.x), ⏚ī¸ [cancelled] Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ Publish npm snapshot (ubuntu-latest, 20.x), ✅ Cypress (ubuntu-latest, 20.x), ✅ Check builds for changes in size (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ Publish Storybook to Chromatic (ubuntu-latest, 20.x), ✅ Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ✅ .github/dependabot.yml Pull Request URL: https://github.com/Khan/perseus/pull/1738 --- .changeset/tidy-suns-wait.md | 7 ++ .../keypad/keypad-pages/numbers-page.tsx | 12 ++- .../src/components/keypad/keypad.tsx | 7 +- .../keypad/mobile-keypad-internals.tsx | 3 + packages/math-input/src/types.ts | 1 + .../__tests__/expression-editor.test.tsx | 17 ++++ .../src/widgets/expression-editor.tsx | 1 + .../__stories__/math-input.stories.tsx | 1 + .../components/__tests__/math-input.test.tsx | 33 +++++++- .../perseus/src/components/math-input.tsx | 6 +- packages/perseus/src/perseus-types.ts | 1 + .../widgets/expression/expression.test.tsx | 82 ++++++++++++++++++- .../src/widgets/expression/expression.tsx | 2 +- 13 files changed, 165 insertions(+), 8 deletions(-) create mode 100644 .changeset/tidy-suns-wait.md diff --git a/.changeset/tidy-suns-wait.md b/.changeset/tidy-suns-wait.md new file mode 100644 index 0000000000..4a4c34e6a9 --- /dev/null +++ b/.changeset/tidy-suns-wait.md @@ -0,0 +1,7 @@ +--- +"@khanacademy/math-input": minor +"@khanacademy/perseus": minor +"@khanacademy/perseus-editor": minor +--- + +add scientific notation button / toggle to basic keypad 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/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/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__/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/math-input.tsx b/packages/perseus/src/components/math-input.tsx index 84ad610d92..4bcd427984 100644 --- a/packages/perseus/src/components/math-input.tsx +++ b/packages/perseus/src/components/math-input.tsx @@ -32,13 +32,14 @@ import type {AnalyticsEventHandlerFn} from "@khanacademy/perseus-core"; type ButtonsVisibleType = "always" | "never" | "focused"; -type KeypadButtonSets = { +export type KeypadButtonSets = { advancedRelations?: boolean; basicRelations?: boolean; divisionKey?: boolean; logarithms?: boolean; preAlgebra?: boolean; trigonometry?: boolean; + scientific?: boolean; }; type Props = { @@ -496,6 +497,9 @@ const mapButtonSets = (buttonSets?: LegacyButtonSets) => { 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 2e7fdcf1c0..3bc0512172 100644 --- a/packages/perseus/src/perseus-types.ts +++ b/packages/perseus/src/perseus-types.ts @@ -471,6 +471,7 @@ export type LegacyButtonSets = ReadonlyArray< | "logarithms" | "basic relations" | "advanced relations" + | "scientific" >; export type PerseusExpressionWidgetOptions = { diff --git a/packages/perseus/src/widgets/expression/expression.test.tsx b/packages/perseus/src/widgets/expression/expression.test.tsx index 04cb9a6591..c288397f24 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 ( @@ -582,3 +586,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 e2b649062d..674b8efcc5 100644 --- a/packages/perseus/src/widgets/expression/expression.tsx +++ b/packages/perseus/src/widgets/expression/expression.tsx @@ -424,7 +424,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 From d8b2f7eaff83062516ad1e273c17fd6579716265 Mon Sep 17 00:00:00 2001 From: Anakaren Date: Mon, 6 Jan 2025 09:12:58 -0800 Subject: [PATCH 06/11] fix(LEMS-2745): update terminology for angle graphs (#2061) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary: Updates terminology for angle graph - changes Initial and Terminal to Starting and Ending Issue: LEMS-2745 Author: anakaren-rojas Reviewers: catandthemachines, nishasy Required Reviewers: Approved By: catandthemachines Checks: ✅ Publish npm snapshot (ubuntu-latest, 20.x), ✅ Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ✅ Check builds for changes in size (ubuntu-latest, 20.x), ✅ Cypress (ubuntu-latest, 20.x), ✅ Publish Storybook to Chromatic (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x) Pull Request URL: https://github.com/Khan/perseus/pull/2061 --- .changeset/happy-mugs-yell.md | 5 +++ packages/perseus/src/strings.ts | 34 +++++++++++-------- .../interactive-graphs/graphs/angle.tsx | 8 ++--- .../interactive-graphs/mafs-graph.test.tsx | 2 +- 4 files changed, 29 insertions(+), 20 deletions(-) create mode 100644 .changeset/happy-mugs-yell.md diff --git a/.changeset/happy-mugs-yell.md b/.changeset/happy-mugs-yell.md new file mode 100644 index 0000000000..a4e85e4bdc --- /dev/null +++ b/.changeset/happy-mugs-yell.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": patch +--- + +update terminology for angle sides diff --git a/packages/perseus/src/strings.ts b/packages/perseus/src/strings.ts index 2443644c8c..fd4e86265c 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. }; @@ -543,12 +543,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 +765,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/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.", ); }); From 37c642f24e645db954895510ba40bede94e09889 Mon Sep 17 00:00:00 2001 From: Sarah Third Date: Mon, 6 Jan 2025 13:54:27 -0800 Subject: [PATCH 07/11] Allow keyboard navigation of images (#1990) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary: This PR allows the keyboard navigation of our images so that users can easily interact with the zoom functionality. I have also added the spacebar and enter keys as optional methods for closing the image, just to help avoid any confusion for our users. Issue: LEMS-2697 ## Test plan: - manual testing - a new test that I should add tomorrow morning. Author: SonicScrewdriver Reviewers: mark-fitzgerald Required Reviewers: Approved By: mark-fitzgerald Checks: ✅ Publish npm snapshot (ubuntu-latest, 20.x), ✅ Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ✅ Cypress (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ Publish Storybook to Chromatic (ubuntu-latest, 20.x), ✅ Check builds for changes in size (ubuntu-latest, 20.x) Pull Request URL: https://github.com/Khan/perseus/pull/1990 --- .changeset/itchy-phones-look.md | 5 ++ .../__snapshots__/svg-image.test.tsx.snap | 3 + .../perseus/src/components/image-loader.tsx | 1 + .../src/widgets/image/image.cypress.ts | 58 +++++++++++++++++++ .../src/widgets/image/image.stories.tsx | 32 +++++++++- .../src/widgets/image/image.testdata.ts | 35 +++++++++++ packages/perseus/src/zoom.ts | 6 +- 7 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 .changeset/itchy-phones-look.md create mode 100644 packages/perseus/src/widgets/image/image.cypress.ts diff --git a/.changeset/itchy-phones-look.md b/.changeset/itchy-phones-look.md new file mode 100644 index 0000000000..b3172c2df3 --- /dev/null +++ b/.changeset/itchy-phones-look.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": minor +--- + +Allow keyboards to navigate and interact with images 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/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 ( { + 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/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"); From bac10129b523d61904a88ef3c7dbfcad2bd18750 Mon Sep 17 00:00:00 2001 From: Sarah Third Date: Mon, 6 Jan 2025 13:56:31 -0800 Subject: [PATCH 08/11] Bugfix - Ensure that all strings are double escaped. (#2071) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary: After our migration to Lingui (which included a new approach to loading in the Perseus strings in the `extract-perseus-strings.tsx` file), it was discovered that we now need to ensure that we always double escape our tex. This is a simple PR that adds these double escapes, and a comment for our future selves. Issue: LEMS-2746 ## Test plan: - manual testing locally and on a znd Author: SonicScrewdriver Reviewers: mark-fitzgerald Required Reviewers: Approved By: mark-fitzgerald Checks: ✅ Publish npm snapshot (ubuntu-latest, 20.x), ✅ Check builds for changes in size (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ Cypress (ubuntu-latest, 20.x), ✅ Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ✅ Publish npm snapshot (ubuntu-latest, 20.x), ✅ Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ✅ Cypress (ubuntu-latest, 20.x), ✅ Check builds for changes in size (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ⏚ī¸ [cancelled] Publish npm snapshot (ubuntu-latest, 20.x), ⏚ī¸ [cancelled] Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ⏚ī¸ [cancelled] Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ⏚ī¸ [cancelled] Cypress (ubuntu-latest, 20.x), ⏚ī¸ [cancelled] Check builds for changes in size (ubuntu-latest, 20.x), ✅ Publish Storybook to Chromatic (ubuntu-latest, 20.x) Pull Request URL: https://github.com/Khan/perseus/pull/2071 --- .changeset/hot-eels-arrive.md | 5 +++++ packages/perseus/src/strings.ts | 14 ++++++++------ 2 files changed, 13 insertions(+), 6 deletions(-) create mode 100644 .changeset/hot-eels-arrive.md diff --git a/.changeset/hot-eels-arrive.md b/.changeset/hot-eels-arrive.md new file mode 100644 index 0000000000..5ee9c0228b --- /dev/null +++ b/.changeset/hot-eels-arrive.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": patch +--- + +This patch fixes our Perseus strings to ensure that they are double escaped for Lingui. diff --git a/packages/perseus/src/strings.ts b/packages/perseus/src/strings.ts index fd4e86265c..ed3eba5422 100644 --- a/packages/perseus/src/strings.ts +++ b/packages/perseus/src/strings.ts @@ -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", From 53ba9f5d136f817257188ccf2696a8b91896ba72 Mon Sep 17 00:00:00 2001 From: Mark Fitzgerald <13896410+mark-fitzgerald@users.noreply.github.com> Date: Mon, 6 Jan 2025 13:57:50 -0800 Subject: [PATCH 09/11] [Dropdown] Bugfix - Text in dropdown was shifted up after adding TeX support via Renderer (#2059) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary: ## With the recent change in how dropdown content is rendered, the use of the `` component brought along styling that is overly broad and caused text to be enlarged and shifted up. This bugfix adds styling that targets only instances of renderer styling within a dropdown. Issue: LEMS-2742 ## Test plan: 1. Open any article that contains dropdown widgets, like this one in [Test Everything](https://www.khanacademy.org/internal-courses/test-everything/test-everything-1/te-dropdown/a/dropdown-with-labels). (Note: this issue is only present in Articles - exercises are unaffected by this bug) 2. Dropdown answers and placeholders should appear centered within the border of the dropdown. ## Affected UI ### Before: ![Dropdown - Before](https://github.com/user-attachments/assets/0f04b89c-0747-4f02-a7dd-61c59b27c917) ### After: ![Dropdown - After](https://github.com/user-attachments/assets/ac9fb004-89fe-471f-84a2-70f1d591574a) Author: mark-fitzgerald Reviewers: catandthemachines, mark-fitzgerald, anakaren-rojas Required Reviewers: Approved By: catandthemachines Checks: ✅ Publish npm snapshot (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ✅ Check builds for changes in size (ubuntu-latest, 20.x), ✅ Publish Storybook to Chromatic (ubuntu-latest, 20.x), ✅ Cypress (ubuntu-latest, 20.x) Pull Request URL: https://github.com/Khan/perseus/pull/2059 --- .changeset/twenty-cats-invent.md | 5 +++++ .../src/__tests__/__snapshots__/renderer.test.tsx.snap | 6 +++--- packages/perseus/src/styles/perseus-renderer.less | 6 ++++++ .../widgets/dropdown/__snapshots__/dropdown.test.ts.snap | 4 ++-- packages/perseus/src/widgets/dropdown/dropdown.tsx | 1 + 5 files changed, 17 insertions(+), 5 deletions(-) create mode 100644 .changeset/twenty-cats-invent.md diff --git a/.changeset/twenty-cats-invent.md b/.changeset/twenty-cats-invent.md new file mode 100644 index 0000000000..49c664f64f --- /dev/null +++ b/.changeset/twenty-cats-invent.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": patch +--- + +[Dropdown] Bugfix - Text in dropdown was shifted up after adding TeX support via Renderer diff --git a/packages/perseus/src/__tests__/__snapshots__/renderer.test.tsx.snap b/packages/perseus/src/__tests__/__snapshots__/renderer.test.tsx.snap index f6cb3327b3..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
implements Widget { this._handleChange(parseInt(value)) } From 98dbc7f2c75fe24c04a0776d945ca578675d4fa0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 6 Jan 2025 22:00:56 +0000 Subject: [PATCH 10/11] RELEASING: Releasing 4 package(s) Releases: @khanacademy/perseus@49.2.0 @khanacademy/math-input@22.1.0 @khanacademy/perseus-editor@17.1.0 @khanacademy/perseus-dev-ui@5.0.10 --- .changeset/happy-mugs-yell.md | 5 ----- .changeset/hot-eels-arrive.md | 5 ----- .changeset/itchy-phones-look.md | 5 ----- .changeset/sixty-vans-end.md | 5 ----- .changeset/tidy-suns-wait.md | 7 ------- .changeset/twenty-cats-invent.md | 5 ----- dev/CHANGELOG.md | 7 +++++++ dev/package.json | 4 ++-- packages/math-input/CHANGELOG.md | 6 ++++++ packages/math-input/package.json | 2 +- packages/perseus-editor/CHANGELOG.md | 12 ++++++++++++ packages/perseus-editor/package.json | 6 +++--- packages/perseus/CHANGELOG.md | 21 +++++++++++++++++++++ packages/perseus/package.json | 4 ++-- 14 files changed, 54 insertions(+), 40 deletions(-) delete mode 100644 .changeset/happy-mugs-yell.md delete mode 100644 .changeset/hot-eels-arrive.md delete mode 100644 .changeset/itchy-phones-look.md delete mode 100644 .changeset/sixty-vans-end.md delete mode 100644 .changeset/tidy-suns-wait.md delete mode 100644 .changeset/twenty-cats-invent.md diff --git a/.changeset/happy-mugs-yell.md b/.changeset/happy-mugs-yell.md deleted file mode 100644 index a4e85e4bdc..0000000000 --- a/.changeset/happy-mugs-yell.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@khanacademy/perseus": patch ---- - -update terminology for angle sides diff --git a/.changeset/hot-eels-arrive.md b/.changeset/hot-eels-arrive.md deleted file mode 100644 index 5ee9c0228b..0000000000 --- a/.changeset/hot-eels-arrive.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@khanacademy/perseus": patch ---- - -This patch fixes our Perseus strings to ensure that they are double escaped for Lingui. diff --git a/.changeset/itchy-phones-look.md b/.changeset/itchy-phones-look.md deleted file mode 100644 index b3172c2df3..0000000000 --- a/.changeset/itchy-phones-look.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@khanacademy/perseus": minor ---- - -Allow keyboards to navigate and interact with images diff --git a/.changeset/sixty-vans-end.md b/.changeset/sixty-vans-end.md deleted file mode 100644 index 2c4bfbf514..0000000000 --- a/.changeset/sixty-vans-end.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@khanacademy/perseus": patch ---- - -Internal: add and pass more regression tests for PerseusItem parser diff --git a/.changeset/tidy-suns-wait.md b/.changeset/tidy-suns-wait.md deleted file mode 100644 index 4a4c34e6a9..0000000000 --- a/.changeset/tidy-suns-wait.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@khanacademy/math-input": minor -"@khanacademy/perseus": minor -"@khanacademy/perseus-editor": minor ---- - -add scientific notation button / toggle to basic keypad diff --git a/.changeset/twenty-cats-invent.md b/.changeset/twenty-cats-invent.md deleted file mode 100644 index 49c664f64f..0000000000 --- a/.changeset/twenty-cats-invent.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@khanacademy/perseus": patch ---- - -[Dropdown] Bugfix - Text in dropdown was shifted up after adding TeX support via Renderer 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/perseus-editor/CHANGELOG.md b/packages/perseus-editor/CHANGELOG.md index 14271e896c..51102875df 100644 --- a/packages/perseus-editor/CHANGELOG.md +++ b/packages/perseus-editor/CHANGELOG.md @@ -1,5 +1,17 @@ # @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 diff --git a/packages/perseus-editor/package.json b/packages/perseus-editor/package.json index efb54961de..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.12", + "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.7", + "@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/CHANGELOG.md b/packages/perseus/CHANGELOG.md index bfe8251351..9f4f0574ab 100644 --- a/packages/perseus/CHANGELOG.md +++ b/packages/perseus/CHANGELOG.md @@ -1,5 +1,26 @@ # @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 diff --git a/packages/perseus/package.json b/packages/perseus/package.json index a35a843e0e..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.7", + "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", From 265a9310486e5c1524af9b502619db9de2f7c01d Mon Sep 17 00:00:00 2001 From: Ben Christel Date: Mon, 6 Jan 2025 16:02:53 -0800 Subject: [PATCH 11/11] Redesign discriminated union type parser (#2068) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, the `discriminatedUnion` parser required you to specify the discriminant via an object parser, which was confusing and verbose. Now you only have to specify the discriminant key once, and provide the values for each branch. Issue: LEMS-2582 ## Test plan: `yarn test` Author: benchristel Reviewers: jeremywiebe, benchristel, anakaren-rojas, catandthemachines, nishasy Required Reviewers: Approved By: jeremywiebe Checks: ✅ Publish npm snapshot (ubuntu-latest, 20.x), ✅ Cypress (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ Check builds for changes in size (ubuntu-latest, 20.x), ✅ Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ✅ Publish Storybook to Chromatic (ubuntu-latest, 20.x) Pull Request URL: https://github.com/Khan/perseus/pull/2068 --- .changeset/many-cats-run.md | 5 + .../discriminated-union.test.ts | 129 +++++++++++------- .../discriminated-union.ts | 98 ++++++++----- .../discriminated-union.typetest.ts | 65 +++++++++ .../perseus-parsers/grapher-widget.ts | 41 +++--- .../perseus-parsers/interaction-widget.ts | 29 ++-- 6 files changed, 247 insertions(+), 120 deletions(-) create mode 100644 .changeset/many-cats-run.md create mode 100644 packages/perseus/src/util/parse-perseus-json/general-purpose-parsers/discriminated-union.typetest.ts 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/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 index 65ddab991d..635de19fff 100644 --- 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 @@ -2,83 +2,118 @@ import {parse} from "../parse"; import {failure, success} from "../result"; import {constant} from "./constant"; -import {discriminatedUnion} from "./discriminated-union"; +import {discriminatedUnionOn} from "./discriminated-union"; import {number} from "./number"; import {object} from "./object"; -describe("a discriminatedUnion with one variant", () => { - const unionParser = discriminatedUnion( - object({type: constant("ok")}), - object({type: constant("ok"), value: number}), - ).parser; - - it("parses a valid value", () => { - const input = {type: "ok", value: 3}; +describe("a discriminatedUnion with no variants", () => { + const parseUnion = discriminatedUnionOn("shape").parser; - expect(parse(input, unionParser)).toEqual(success(input)); + it("fails appropriately given a non-object", () => { + expect(parse(true, parseUnion)).toEqual( + failure("At (root) -- expected object, but got true"), + ); }); - it("rejects a value with the wrong `type`", () => { - const input = {type: "bad", value: 3}; - - expect(parse(input, unionParser)).toEqual( - failure(`At (root).type -- expected "ok", but got "bad"`), + 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("rejects a value with a valid type but wrong fields", () => { - const input = {type: "ok", value: "foobar"}; - - expect(parse(input, unionParser)).toEqual( - failure(`At (root).value -- expected number, but got "foobar"`), + 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 two variants", () => { - const unionParser = discriminatedUnion( - object({type: constant("rectangle")}), - object({type: constant("rectangle"), width: number}), - ).or( - object({type: constant("circle")}), - object({type: constant("circle"), radius: number}), +describe("a discriminatedUnion with one variant", () => { + const parseCircle = object({shape: constant("circle"), radius: number}); + const parseUnion = discriminatedUnionOn("shape").withBranch( + "circle", + parseCircle, ).parser; - it("parses a valid rectangle", () => { - const input = {type: "rectangle", width: 42}; - - expect(parse(input, unionParser)).toEqual(success(input)); + it("fails appropriately given a non-object", () => { + expect(parse(true, parseUnion)).toEqual( + failure("At (root) -- expected object, but got true"), + ); }); - it("rejects a rectangle with no width", () => { - const input = {type: "rectangle", radius: 99}; - - expect(parse(input, unionParser)).toEqual( - failure(`At (root).width -- expected number, but got undefined`), + 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("parses a valid circle", () => { - const input = {type: "circle", radius: 7}; + 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"`, + ), + ); + }); - expect(parse(input, unionParser)).toEqual(success(input)); + it("succeeds given a valid object", () => { + const input = {shape: "circle", radius: 3}; + expect(parse(input, parseUnion)).toEqual(success(input)); }); +}); - it("rejects a circle with no radius", () => { - const input = {type: "circle", width: 99}; +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; - expect(parse(input, unionParser)).toEqual( - failure(`At (root).radius -- expected number, but got undefined`), + it("fails appropriately given a non-object", () => { + expect(parse(true, parseUnion)).toEqual( + failure("At (root) -- expected object, but got true"), ); }); - it("rejects a value with an unrecognized `type`", () => { - const input = {type: "triangle", width: -1, radius: 99}; + 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", + ), + ); + }); - expect(parse(input, unionParser)).toEqual( + it("fails appropriately given an object with an invalid discriminant", () => { + expect(parse({shape: "squarle"}, parseUnion)).toEqual( failure( - `At (root).type -- expected "rectangle", but got "triangle"`, + `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 index 35e30cecc7..476bac6209 100644 --- 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 @@ -1,46 +1,76 @@ -import {isSuccess} from "../result"; - -import {pipeParsers} from "./pipe-parsers"; - -import type {Parser} from "../parser-types"; - -// 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 discriminatedUnion( - narrow: Parser, - parseVariant: Parser, -): DiscriminatedUnionBuilder { - return new DiscriminatedUnionBuilder( - pipeParsers(narrow).then(parseVariant).parser, - ); +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 { - constructor(public parser: Parser) {} +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, + ); - or( - narrow: Parser, - parseVariant: Parser, - ): DiscriminatedUnionBuilder { return new DiscriminatedUnionBuilder( - either(narrow, parseVariant, this.parser), + this.discriminantKey, + parseNewBranch, ); } } -function either( - narrowToA: Parser, - parseA: Parser, - parseB: Parser, -): Parser { - return (rawValue, ctx) => { - if (isSuccess(narrowToA(rawValue, ctx))) { - return parseA(rawValue, ctx); +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 parseB(rawValue, 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/grapher-widget.ts b/packages/perseus/src/util/parse-perseus-json/perseus-parsers/grapher-widget.ts index 924c12ccad..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,7 +11,7 @@ import { string, union, } from "../general-purpose-parsers"; -import {discriminatedUnion} from "../general-purpose-parsers/discriminated-union"; +import {discriminatedUnionOn} from "../general-purpose-parsers/discriminated-union"; import {parseWidget} from "./widget"; @@ -36,52 +36,53 @@ export const parseGrapherWidget: Parser = parseWidget( "tangent", ), ), - correct: discriminatedUnion( - object({type: constant("absolute_value")}), - object({ - type: constant("absolute_value"), - coords: pairOfPoints, - }), - ) - .or( - object({type: constant("exponential")}), + correct: discriminatedUnionOn("type") + .withBranch( + "absolute_value", + object({ + type: constant("absolute_value"), + coords: pairOfPoints, + }), + ) + .withBranch( + "exponential", object({ type: constant("exponential"), asymptote: pairOfPoints, coords: pairOfPoints, }), ) - .or( - object({type: constant("linear")}), + .withBranch( + "linear", object({ type: constant("linear"), coords: pairOfPoints, }), ) - .or( - object({type: constant("logarithm")}), + .withBranch( + "logarithm", object({ type: constant("logarithm"), asymptote: pairOfPoints, coords: pairOfPoints, }), ) - .or( - object({type: constant("quadratic")}), + .withBranch( + "quadratic", object({ type: constant("quadratic"), coords: pairOfPoints, }), ) - .or( - object({type: constant("sinusoid")}), + .withBranch( + "sinusoid", object({ type: constant("sinusoid"), coords: pairOfPoints, }), ) - .or( - object({type: constant("tangent")}), + .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 4bf9a3c3bb..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 @@ -11,7 +11,7 @@ import { union, } from "../general-purpose-parsers"; import {defaulted} from "../general-purpose-parsers/defaulted"; -import {discriminatedUnion} from "../general-purpose-parsers/discriminated-union"; +import {discriminatedUnionOn} from "../general-purpose-parsers/discriminated-union"; import {parsePerseusImageBackground} from "./perseus-image-background"; import {parseWidget} from "./widget"; @@ -184,24 +184,15 @@ export const parseInteractionWidget: Parser = parseWidget( tickStep: pairOfNumbers, }), elements: array( - discriminatedUnion( - object({type: parseFunctionType}), - parseFunctionElement, - ) - .or(object({type: parseLabelType}), parseLabelElement) - .or(object({type: parseLineType}), parseLineElement) - .or( - object({type: parseMovableLineType}), - parseMovableLineElement, - ) - .or( - object({type: parseMovablePointType}), - parseMovablePointElement, - ) - .or(object({type: parseParametricType}), parseParametricElement) - .or(object({type: parsePointType}), parsePointElement) - .or(object({type: parseRectangleType}), 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, ), }), );