diff --git a/.changeset/angry-penguins-itch.md b/.changeset/angry-penguins-itch.md deleted file mode 100644 index 3b9f2af702..0000000000 --- a/.changeset/angry-penguins-itch.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@khanacademy/perseus": major ---- - -Remove scoreWidgets from ServerItemRenderer diff --git a/.changeset/breezy-rockets-greet.md b/.changeset/breezy-rockets-greet.md deleted file mode 100644 index 9035aeb03e..0000000000 --- a/.changeset/breezy-rockets-greet.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@khanacademy/perseus": patch ---- - -Updating protractor's default position within a graph. diff --git a/.changeset/early-mice-flash.md b/.changeset/curly-beers-live.md similarity index 52% rename from .changeset/early-mice-flash.md rename to .changeset/curly-beers-live.md index 552dc3b37f..e51475d965 100644 --- a/.changeset/early-mice-flash.md +++ b/.changeset/curly-beers-live.md @@ -2,4 +2,4 @@ "@khanacademy/perseus": patch --- -Split out validation logic in Radio +Move and test Grapher's validator diff --git a/.changeset/dry-parents-invite.md b/.changeset/dry-parents-invite.md deleted file mode 100644 index 4d8d8f87a2..0000000000 --- a/.changeset/dry-parents-invite.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@khanacademy/perseus": minor -"@khanacademy/perseus-editor": minor ---- - -[Locked Figure Labels] Add/edit/delete locked ellipse labels diff --git a/.changeset/dull-kids-pretend.md b/.changeset/dull-kids-pretend.md deleted file mode 100644 index 3cc5052d5b..0000000000 --- a/.changeset/dull-kids-pretend.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@khanacademy/perseus": major ---- - -Refactor virtally all widget types and consolidate user input diff --git a/.changeset/early-rivers-tell.md b/.changeset/early-rivers-tell.md deleted file mode 100644 index d7be8e50b0..0000000000 --- a/.changeset/early-rivers-tell.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@khanacademy/perseus": minor -"@khanacademy/perseus-editor": minor ---- - -[Locked Figure Labels] View locked ellipse labels diff --git a/.changeset/eleven-elephants-trade.md b/.changeset/eleven-elephants-trade.md new file mode 100644 index 0000000000..9e79c16ece --- /dev/null +++ b/.changeset/eleven-elephants-trade.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": patch +--- + +Omit unused data from interactive graph onChange callback diff --git a/.changeset/popular-drinks-sniff.md b/.changeset/great-lions-switch.md similarity index 50% rename from .changeset/popular-drinks-sniff.md rename to .changeset/great-lions-switch.md index a08b7a702b..5125f835af 100644 --- a/.changeset/popular-drinks-sniff.md +++ b/.changeset/great-lions-switch.md @@ -2,4 +2,4 @@ "@khanacademy/perseus": patch --- -Split validation logic from LabelImage +Port some tests to new custom matcher diff --git a/.changeset/honest-games-do.md b/.changeset/honest-games-do.md new file mode 100644 index 0000000000..050ecdf11e --- /dev/null +++ b/.changeset/honest-games-do.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": patch +--- + +Internal: remove dead code from InteractiveGraph.validate() diff --git a/.changeset/loud-parrots-run.md b/.changeset/loud-parrots-run.md deleted file mode 100644 index 72014a0c3f..0000000000 --- a/.changeset/loud-parrots-run.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@khanacademy/perseus": patch ---- - -Move validation logic out of the sorter widget and add tests diff --git a/.changeset/many-apricots-repair.md b/.changeset/many-apricots-repair.md deleted file mode 100644 index 89cb0f95dc..0000000000 --- a/.changeset/many-apricots-repair.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@khanacademy/perseus": patch ---- - -Translations and polish for unlimited point diff --git a/.changeset/modern-sheep-bathe.md b/.changeset/modern-sheep-bathe.md deleted file mode 100644 index 5f8f73a020..0000000000 --- a/.changeset/modern-sheep-bathe.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@khanacademy/perseus": minor -"@khanacademy/perseus-editor": minor ---- - -[Locked Figure Labels] Add/edit/delete locked vector labels diff --git a/.changeset/nasty-coats-sin.md b/.changeset/nasty-coats-sin.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/nasty-coats-sin.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/nervous-otters-fly.md b/.changeset/nervous-otters-fly.md new file mode 100644 index 0000000000..818680d6ad --- /dev/null +++ b/.changeset/nervous-otters-fly.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": patch +--- + +Split out InteractiveGraph validator diff --git a/.changeset/rude-lamps-warn.md b/.changeset/rude-lamps-warn.md deleted file mode 100644 index a9a64b5636..0000000000 --- a/.changeset/rude-lamps-warn.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@khanacademy/perseus": minor -"@khanacademy/perseus-editor": minor ---- - -[Locked Figure Labels] Add/edit/delete locked function labels diff --git a/.changeset/sour-spoons-rush.md b/.changeset/sour-spoons-rush.md new file mode 100644 index 0000000000..61dc3416f1 --- /dev/null +++ b/.changeset/sour-spoons-rush.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": patch +--- + +Split validation logic out of Matrix diff --git a/.changeset/spotty-days-burn.md b/.changeset/spotty-days-burn.md deleted file mode 100644 index 6bb4e910e4..0000000000 --- a/.changeset/spotty-days-burn.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@khanacademy/perseus": minor -"@khanacademy/perseus-editor": minor ---- - -[Locked Figure Labels] View locked function labels diff --git a/.changeset/strange-houses-breathe.md b/.changeset/strange-houses-breathe.md deleted file mode 100644 index 139c7d48c1..0000000000 --- a/.changeset/strange-houses-breathe.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@khanacademy/perseus": minor -"@khanacademy/perseus-editor": minor ---- - -[Locked Figure Labels] View locked vector labels diff --git a/.changeset/strong-numbers-pretend.md b/.changeset/strong-numbers-pretend.md new file mode 100644 index 0000000000..4ae9e6a808 --- /dev/null +++ b/.changeset/strong-numbers-pretend.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": patch +--- + +Move validation logic out of the cs-program widget and add tests diff --git a/.changeset/ten-seas-chew.md b/.changeset/ten-seas-chew.md deleted file mode 100644 index 2c77ec7d99..0000000000 --- a/.changeset/ten-seas-chew.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@khanacademy/perseus": patch ---- - -Internal: revert buggy change to interactive graphs (never shipped) diff --git a/.changeset/tough-snails-double.md b/.changeset/tough-snails-double.md deleted file mode 100644 index 5e6fa48952..0000000000 --- a/.changeset/tough-snails-double.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@khanacademy/perseus": major -"@khanacademy/perseus-editor": patch ---- - -Remove unused onInputError from APIOptions diff --git a/.changeset/violet-icons-breathe.md b/.changeset/violet-icons-breathe.md deleted file mode 100644 index 52b6b60301..0000000000 --- a/.changeset/violet-icons-breathe.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@khanacademy/perseus-editor": patch ---- - -[Interactive Graph Editor] Save empty full graph aria label/description as undefined diff --git a/.changeset/yellow-poets-poke.md b/.changeset/yellow-poets-poke.md deleted file mode 100644 index f846e76ef7..0000000000 --- a/.changeset/yellow-poets-poke.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@khanacademy/perseus": patch -"@khanacademy/perseus-editor": patch ---- - -[Locked Figures Labels] Make labels optional to increase type safety diff --git a/packages/perseus-editor/CHANGELOG.md b/packages/perseus-editor/CHANGELOG.md index f94474ab44..471131b8c0 100644 --- a/packages/perseus-editor/CHANGELOG.md +++ b/packages/perseus-editor/CHANGELOG.md @@ -1,5 +1,32 @@ # @khanacademy/perseus-editor +## 14.5.0 + +### Minor Changes + +- [#1655](https://github.com/Khan/perseus/pull/1655) [`790e189a7`](https://github.com/Khan/perseus/commit/790e189a7fdcd215d78d1999879ab2fc7417e123) Thanks [@nishasy](https://github.com/nishasy)! - [Locked Figure Labels] Add/edit/delete locked ellipse labels + +* [#1653](https://github.com/Khan/perseus/pull/1653) [`ca4be05ab`](https://github.com/Khan/perseus/commit/ca4be05ab7367007330784796ad2561e3f5bb1c8) Thanks [@nishasy](https://github.com/nishasy)! - [Locked Figure Labels] View locked ellipse labels + +- [#1652](https://github.com/Khan/perseus/pull/1652) [`1ed045583`](https://github.com/Khan/perseus/commit/1ed045583fec01be5baf5d4e86a8b582cbf782c2) Thanks [@nishasy](https://github.com/nishasy)! - [Locked Figure Labels] Add/edit/delete locked vector labels + +* [#1659](https://github.com/Khan/perseus/pull/1659) [`3dcb1fdf2`](https://github.com/Khan/perseus/commit/3dcb1fdf247eda0f0b78966daf04a9e4278d4373) Thanks [@nishasy](https://github.com/nishasy)! - [Locked Figure Labels] Add/edit/delete locked function labels + +- [#1658](https://github.com/Khan/perseus/pull/1658) [`20b3a2485`](https://github.com/Khan/perseus/commit/20b3a2485e2ba8deea798acc2732d9570c0dac45) Thanks [@nishasy](https://github.com/nishasy)! - [Locked Figure Labels] View locked function labels + +* [#1650](https://github.com/Khan/perseus/pull/1650) [`03cddb6c3`](https://github.com/Khan/perseus/commit/03cddb6c39570e87ff2437273eb1287ff1417eec) Thanks [@nishasy](https://github.com/nishasy)! - [Locked Figure Labels] View locked vector labels + +### Patch Changes + +- [#1661](https://github.com/Khan/perseus/pull/1661) [`391641acb`](https://github.com/Khan/perseus/commit/391641acb153d2d6c0f8c29f5026a392ac1b3a62) Thanks [@handeyeco](https://github.com/handeyeco)! - Remove unused onInputError from APIOptions + +* [#1674](https://github.com/Khan/perseus/pull/1674) [`f38d104d5`](https://github.com/Khan/perseus/commit/f38d104d580775cd67a0586143eacf7b864e4814) Thanks [@nishasy](https://github.com/nishasy)! - [Interactive Graph Editor] Save empty full graph aria label/description as undefined + +- [#1673](https://github.com/Khan/perseus/pull/1673) [`6f4702e41`](https://github.com/Khan/perseus/commit/6f4702e418ffdfaae01aa3f3a126b304b3250e34) Thanks [@nishasy](https://github.com/nishasy)! - [Locked Figures Labels] Make labels optional to increase type safety + +- Updated dependencies [[`063159313`](https://github.com/Khan/perseus/commit/063159313c8b146589912ce42c14f06aa23d3e51), [`13d79edb9`](https://github.com/Khan/perseus/commit/13d79edb94fd7009b18a176b5c93b43fb03fee72), [`790e189a7`](https://github.com/Khan/perseus/commit/790e189a7fdcd215d78d1999879ab2fc7417e123), [`ae51ccdb8`](https://github.com/Khan/perseus/commit/ae51ccdb820894f6fc5c1b23556823efdd4edba6), [`3a10f6b1f`](https://github.com/Khan/perseus/commit/3a10f6b1fe85d915fbf947434d7ebdc0b35607f5), [`ca4be05ab`](https://github.com/Khan/perseus/commit/ca4be05ab7367007330784796ad2561e3f5bb1c8), [`b9d1af181`](https://github.com/Khan/perseus/commit/b9d1af181efeb093407d59ba0a8efe8912524757), [`9f9d42c4e`](https://github.com/Khan/perseus/commit/9f9d42c4e2d041408cf508f5bfaeafe03dc2acbc), [`1ed045583`](https://github.com/Khan/perseus/commit/1ed045583fec01be5baf5d4e86a8b582cbf782c2), [`9efad87d0`](https://github.com/Khan/perseus/commit/9efad87d00c58f16c5a5a95c6c7148bde62fe71a), [`3dcb1fdf2`](https://github.com/Khan/perseus/commit/3dcb1fdf247eda0f0b78966daf04a9e4278d4373), [`20b3a2485`](https://github.com/Khan/perseus/commit/20b3a2485e2ba8deea798acc2732d9570c0dac45), [`03cddb6c3`](https://github.com/Khan/perseus/commit/03cddb6c39570e87ff2437273eb1287ff1417eec), [`1642ad9c0`](https://github.com/Khan/perseus/commit/1642ad9c0cadaf2e4db316e5e4cb38a5c9a9f5fe), [`391641acb`](https://github.com/Khan/perseus/commit/391641acb153d2d6c0f8c29f5026a392ac1b3a62), [`6f4702e41`](https://github.com/Khan/perseus/commit/6f4702e418ffdfaae01aa3f3a126b304b3250e34), [`56f33ae01`](https://github.com/Khan/perseus/commit/56f33ae010390abd2050028db98c9a72fc604e1a)]: + - @khanacademy/perseus@35.0.0 + ## 14.4.0 ### Minor Changes diff --git a/packages/perseus-editor/package.json b/packages/perseus-editor/package.json index 4c9ad4ef7b..80a047782c 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": "14.4.0", + "version": "14.5.0", "publishConfig": { "access": "public" }, @@ -38,7 +38,7 @@ "@khanacademy/keypad-context": "^1.0.1", "@khanacademy/kmath": "^0.1.13", "@khanacademy/math-input": "^21.0.2", - "@khanacademy/perseus": "^34.1.0", + "@khanacademy/perseus": "^35.0.0", "@khanacademy/perseus-core": "1.5.0", "mafs": "^0.19.0" }, diff --git a/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-line-settings.tsx b/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-line-settings.tsx index 0c74a3f3c6..5bf69ad6ac 100644 --- a/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-line-settings.tsx +++ b/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-line-settings.tsx @@ -82,13 +82,8 @@ const LockedLineSettings = (props: Props) => { str += "s"; } - for (let i = 0; i < labels.length; i++) { - // Separate additional labels with commas. - if (i > 0) { - str += ","; - } - str += ` ${labels[i].text}`; - } + // Separate additional labels with commas. + str += ` ${labels.map((l) => l.text).join(", ")}`; } return str; diff --git a/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-point-settings.tsx b/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-point-settings.tsx index 4373a9651b..07f09eaea2 100644 --- a/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-point-settings.tsx +++ b/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-point-settings.tsx @@ -121,13 +121,8 @@ const LockedPointSettings = (props: Props) => { str += "s"; } - for (let i = 0; i < labels.length; i++) { - // Separate additional labels with commas. - if (i > 0) { - str += ","; - } - str += ` ${labels[i].text}`; - } + // Separate additional labels with commas. + str += ` ${labels.map((l) => l.text).join(", ")}`; } return str; diff --git a/packages/perseus/CHANGELOG.md b/packages/perseus/CHANGELOG.md index fc1c8cbbf6..699b2b4213 100644 --- a/packages/perseus/CHANGELOG.md +++ b/packages/perseus/CHANGELOG.md @@ -1,5 +1,47 @@ # @khanacademy/perseus +## 35.0.0 + +### Major Changes + +- [#1668](https://github.com/Khan/perseus/pull/1668) [`063159313`](https://github.com/Khan/perseus/commit/063159313c8b146589912ce42c14f06aa23d3e51) Thanks [@handeyeco](https://github.com/handeyeco)! - Remove scoreWidgets from ServerItemRenderer + +* [#1639](https://github.com/Khan/perseus/pull/1639) [`ae51ccdb8`](https://github.com/Khan/perseus/commit/ae51ccdb820894f6fc5c1b23556823efdd4edba6) Thanks [@handeyeco](https://github.com/handeyeco)! - Refactor virtally all widget types and consolidate user input + +- [#1661](https://github.com/Khan/perseus/pull/1661) [`391641acb`](https://github.com/Khan/perseus/commit/391641acb153d2d6c0f8c29f5026a392ac1b3a62) Thanks [@handeyeco](https://github.com/handeyeco)! - Remove unused onInputError from APIOptions + +### Minor Changes + +- [#1655](https://github.com/Khan/perseus/pull/1655) [`790e189a7`](https://github.com/Khan/perseus/commit/790e189a7fdcd215d78d1999879ab2fc7417e123) Thanks [@nishasy](https://github.com/nishasy)! - [Locked Figure Labels] Add/edit/delete locked ellipse labels + +* [#1653](https://github.com/Khan/perseus/pull/1653) [`ca4be05ab`](https://github.com/Khan/perseus/commit/ca4be05ab7367007330784796ad2561e3f5bb1c8) Thanks [@nishasy](https://github.com/nishasy)! - [Locked Figure Labels] View locked ellipse labels + +- [#1652](https://github.com/Khan/perseus/pull/1652) [`1ed045583`](https://github.com/Khan/perseus/commit/1ed045583fec01be5baf5d4e86a8b582cbf782c2) Thanks [@nishasy](https://github.com/nishasy)! - [Locked Figure Labels] Add/edit/delete locked vector labels + +* [#1659](https://github.com/Khan/perseus/pull/1659) [`3dcb1fdf2`](https://github.com/Khan/perseus/commit/3dcb1fdf247eda0f0b78966daf04a9e4278d4373) Thanks [@nishasy](https://github.com/nishasy)! - [Locked Figure Labels] Add/edit/delete locked function labels + +- [#1658](https://github.com/Khan/perseus/pull/1658) [`20b3a2485`](https://github.com/Khan/perseus/commit/20b3a2485e2ba8deea798acc2732d9570c0dac45) Thanks [@nishasy](https://github.com/nishasy)! - [Locked Figure Labels] View locked function labels + +* [#1650](https://github.com/Khan/perseus/pull/1650) [`03cddb6c3`](https://github.com/Khan/perseus/commit/03cddb6c39570e87ff2437273eb1287ff1417eec) Thanks [@nishasy](https://github.com/nishasy)! - [Locked Figure Labels] View locked vector labels + +- [#1679](https://github.com/Khan/perseus/pull/1679) [`56f33ae01`](https://github.com/Khan/perseus/commit/56f33ae010390abd2050028db98c9a72fc604e1a) Thanks [@handeyeco](https://github.com/handeyeco)! - Don't serialize widgetIsOpen + +### Patch Changes + +- [#1669](https://github.com/Khan/perseus/pull/1669) [`13d79edb9`](https://github.com/Khan/perseus/commit/13d79edb94fd7009b18a176b5c93b43fb03fee72) Thanks [@catandthemachines](https://github.com/catandthemachines)! - Updating protractor's default position within a graph. + +* [#1662](https://github.com/Khan/perseus/pull/1662) [`3a10f6b1f`](https://github.com/Khan/perseus/commit/3a10f6b1fe85d915fbf947434d7ebdc0b35607f5) Thanks [@handeyeco](https://github.com/handeyeco)! - Split out validation logic in Radio + +- [#1656](https://github.com/Khan/perseus/pull/1656) [`b9d1af181`](https://github.com/Khan/perseus/commit/b9d1af181efeb093407d59ba0a8efe8912524757) Thanks [@Myranae](https://github.com/Myranae)! - Move validation logic out of the sorter widget and add tests + +* [#1665](https://github.com/Khan/perseus/pull/1665) [`9f9d42c4e`](https://github.com/Khan/perseus/commit/9f9d42c4e2d041408cf508f5bfaeafe03dc2acbc) Thanks [@nicolecomputer](https://github.com/nicolecomputer)! - Translations and polish for unlimited point + +- [#1667](https://github.com/Khan/perseus/pull/1667) [`9efad87d0`](https://github.com/Khan/perseus/commit/9efad87d00c58f16c5a5a95c6c7148bde62fe71a) Thanks [@handeyeco](https://github.com/handeyeco)! - Split validation logic from LabelImage + +* [#1663](https://github.com/Khan/perseus/pull/1663) [`1642ad9c0`](https://github.com/Khan/perseus/commit/1642ad9c0cadaf2e4db316e5e4cb38a5c9a9f5fe) Thanks [@benchristel](https://github.com/benchristel)! - Internal: revert buggy change to interactive graphs (never shipped) + +- [#1673](https://github.com/Khan/perseus/pull/1673) [`6f4702e41`](https://github.com/Khan/perseus/commit/6f4702e418ffdfaae01aa3f3a126b304b3250e34) Thanks [@nishasy](https://github.com/nishasy)! - [Locked Figures Labels] Make labels optional to increase type safety + ## 34.1.0 ### Minor Changes diff --git a/packages/perseus/package.json b/packages/perseus/package.json index 76259404dc..5c8135a441 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": "34.1.0", + "version": "35.0.0", "publishConfig": { "access": "public" }, diff --git a/packages/perseus/src/mixins/widget-prop-denylist.ts b/packages/perseus/src/mixins/widget-prop-denylist.ts index 70893c2c82..8d8d63500a 100644 --- a/packages/perseus/src/mixins/widget-prop-denylist.ts +++ b/packages/perseus/src/mixins/widget-prop-denylist.ts @@ -21,6 +21,7 @@ const denylist = [ "onChange", "problemNum", "apiOptions", + "widgetIsOpen", "questionCompleted", "findWidgets", // added by src/editor.jsx, for widgets removing themselves diff --git a/packages/perseus/src/types.ts b/packages/perseus/src/types.ts index d214976525..ecdbb54a08 100644 --- a/packages/perseus/src/types.ts +++ b/packages/perseus/src/types.ts @@ -3,12 +3,12 @@ import type {Item} from "./multi-items/item-types"; import type { Hint, PerseusAnswerArea, + PerseusGraphType, PerseusWidget, PerseusWidgetsMap, } from "./perseus-types"; import type {PerseusStrings} from "./strings"; import type {SizeClass} from "./util/sizing-utils"; -import type {InteractiveGraphState} from "./widgets/interactive-graphs/types"; import type {KeypadAPI} from "@khanacademy/math-input"; import type {AnalyticsEventHandlerFn} from "@khanacademy/perseus-core"; import type {LinterContextProps} from "@khanacademy/perseus-linter"; @@ -90,7 +90,7 @@ export type ChangeHandler = ( // perseus-all-package/widgets/grapher.jsx plot?: any; // Interactive Graph callback (see legacy: interactive-graph.tsx) - graph?: InteractiveGraphState; + graph?: PerseusGraphType; }, callback?: () => unknown | null | undefined, silent?: boolean, diff --git a/packages/perseus/src/widgets/cs-program/cs-program-validator.test.ts b/packages/perseus/src/widgets/cs-program/cs-program-validator.test.ts new file mode 100644 index 0000000000..af61626dfd --- /dev/null +++ b/packages/perseus/src/widgets/cs-program/cs-program-validator.test.ts @@ -0,0 +1,49 @@ +import {csProgramValidator} from "./cs-program-validator"; + +import type {PerseusCSProgramUserInput} from "../../validation.types"; + +describe("csProgramValidator", () => { + it("is correct when the state from the iframe shows the status is correct", () => { + // Arrange + const state: PerseusCSProgramUserInput = { + status: "correct", + message: "Good job!", + }; + + // Act + const result = csProgramValidator(state); + + // Assert + expect(result).toHaveBeenAnsweredCorrectly(); + }); + + it("is incorrect when the state from the iframe shows the status is incorrect", () => { + // Arrange + const state: PerseusCSProgramUserInput = { + status: "incorrect", + message: "Try again!", + }; + + // Act + const result = csProgramValidator(state); + + // Assert + expect(result).toHaveBeenAnsweredIncorrectly(); + }); + + // Note: It looks like the iframe only says if the answer is correct or + // incorrect, but status is set to "incomplete" by default. + it("should return invalid score before user interactions", () => { + // Arrange + const state: PerseusCSProgramUserInput = { + status: "incomplete", + message: null, + }; + + // Act + const result = csProgramValidator(state); + + // Assert + expect(result).toHaveInvalidInput("Keep going, you're not there yet!"); + }); +}); diff --git a/packages/perseus/src/widgets/cs-program/cs-program-validator.ts b/packages/perseus/src/widgets/cs-program/cs-program-validator.ts new file mode 100644 index 0000000000..7d1f1445f4 --- /dev/null +++ b/packages/perseus/src/widgets/cs-program/cs-program-validator.ts @@ -0,0 +1,29 @@ +import type {PerseusScore} from "../../types"; +import type {PerseusCSProgramUserInput} from "../../validation.types"; + +export function csProgramValidator( + state: PerseusCSProgramUserInput, +): PerseusScore { + // The iframe can tell us whether it's correct or incorrect, + // and pass an optional message + if (state.status === "correct") { + return { + type: "points", + earned: 1, + total: 1, + message: state.message || null, + }; + } + if (state.status === "incorrect") { + return { + type: "points", + earned: 0, + total: 1, + message: state.message || null, + }; + } + return { + type: "invalid", + message: "Keep going, you're not there yet!", + }; +} diff --git a/packages/perseus/src/widgets/cs-program/cs-program.tsx b/packages/perseus/src/widgets/cs-program/cs-program.tsx index 453819433e..64e4215eb7 100644 --- a/packages/perseus/src/widgets/cs-program/cs-program.tsx +++ b/packages/perseus/src/widgets/cs-program/cs-program.tsx @@ -14,6 +14,8 @@ import Util from "../../util"; import {isFileProtocol} from "../../util/mobile-native-utils"; import {toAbsoluteUrl} from "../../util/url-utils"; +import {csProgramValidator} from "./cs-program-validator"; + import type {PerseusCSProgramWidgetOptions} from "../../perseus-types"; import type {PerseusScore, WidgetExports, WidgetProps} from "../../types"; import type { @@ -67,32 +69,8 @@ class CSProgram extends React.Component { }; // The widget's grading function - static validate( - state: PerseusCSProgramUserInput, - rubric: any, - ): PerseusScore { - // The iframe can tell us whether it's correct or incorrect, - // and pass an optional message - if (state.status === "correct") { - return { - type: "points", - earned: 1, - total: 1, - message: state.message || null, - }; - } - if (state.status === "incorrect") { - return { - type: "points", - earned: 0, - total: 1, - message: state.message || null, - }; - } - return { - type: "invalid", - message: "Keep going, you're not there yet!", - }; + static validate(state: PerseusCSProgramUserInput): PerseusScore { + return csProgramValidator(state); } componentDidMount() { @@ -130,14 +108,11 @@ class CSProgram extends React.Component { return Changeable.change.apply(this, args); }; - simpleValidate(rubric): PerseusScore { - return CSProgram.validate( - { - status: this.props.status, - message: this.props.message, - }, - rubric, - ); + simpleValidate(): PerseusScore { + return csProgramValidator({ + status: this.props.status, + message: this.props.message, + }); } render(): React.ReactNode { diff --git a/packages/perseus/src/widgets/definition/definition.test.ts b/packages/perseus/src/widgets/definition/definition.test.ts index ca931b4a60..499cc1f017 100644 --- a/packages/perseus/src/widgets/definition/definition.test.ts +++ b/packages/perseus/src/widgets/definition/definition.test.ts @@ -128,11 +128,6 @@ describe("Definition widget", () => { const result = renderer.scoreWidgets(); // Assert - expect(result["definition 1"]).toMatchObject({ - type: "points", - earned: 0, - total: 0, - message: null, - }); + expect(result["definition 1"]).toHaveBeenAnsweredCorrectly(); }); }); diff --git a/packages/perseus/src/widgets/grapher/grapher-validator.test.ts b/packages/perseus/src/widgets/grapher/grapher-validator.test.ts new file mode 100644 index 0000000000..70614e3932 --- /dev/null +++ b/packages/perseus/src/widgets/grapher/grapher-validator.test.ts @@ -0,0 +1,196 @@ +import grapherValidator from "./grapher-validator"; + +import type {Coord} from "../../interactive2/types"; +import type { + PerseusGrapherRubric, + PerseusGrapherUserInput, +} from "../../validation.types"; + +describe("grapherValidator", () => { + it("is incorrect when user input type doesn't match rubric type", () => { + const asymptote: [Coord, Coord] = [ + [-10, -10], + [10, 10], + ]; + const coords: [Coord, Coord] = [ + [-10, -10], + [10, 10], + ]; + + // Arrange + const userInput: PerseusGrapherUserInput = { + type: "exponential", + asymptote, + coords, + }; + + const rubric: PerseusGrapherRubric = { + availableTypes: ["exponential", "logarithm"], + correct: { + type: "logarithm", + asymptote, + coords, + }, + // The rubric type is probably wrong, + // the validator doesn't use graph + graph: {} as any, + }; + + // Act + const result = grapherValidator(userInput, rubric); + + // Assert + expect(result).toHaveBeenAnsweredIncorrectly(); + }); + + it("is invalid when user input doesn't have coords", () => { + const asymptote: [Coord, Coord] = [ + [-10, -10], + [10, 10], + ]; + const coords: [Coord, Coord] = [ + [-10, -10], + [10, 10], + ]; + + // Arrange + const userInput: PerseusGrapherUserInput = { + type: "exponential", + asymptote, + // TODO: either the types or logic is wrong, + // but the existing validator checks for null coords + // @ts-expect-error - TS(2322) - Type 'null' is not assignable to type 'readonly Coord[]'. + coords: null, + }; + + const rubric: PerseusGrapherRubric = { + availableTypes: ["exponential", "logarithm"], + correct: { + type: "exponential", + asymptote, + coords, + }, + // The rubric type is probably wrong, + // the validator doesn't use graph + graph: {} as any, + }; + + // Act + const result = grapherValidator(userInput, rubric); + + // Assert + expect(result).toHaveInvalidInput(); + }); + + it("is invalid when coefficients are unexpected", () => { + // I honestly don't understand what a coefficient is + // but this seems to get triggered when the type is "linear" + // and the points are in the same spot + const asymptote: [Coord, Coord] = [ + [-10, -10], + [-10, -10], + ]; + const coords: [Coord, Coord] = [ + [-10, -10], + [-10, -10], + ]; + + // Arrange + const userInput: PerseusGrapherUserInput = { + type: "linear", + asymptote, + coords, + }; + + const rubric: PerseusGrapherRubric = { + availableTypes: ["linear"], + correct: { + type: "linear", + coords, + }, + // The rubric type is probably wrong, + // the validator doesn't use graph + graph: {} as any, + }; + + // Act + const result = grapherValidator(userInput, rubric); + + // Assert + expect(result).toHaveInvalidInput(); + }); + + it("can be answered correctly", () => { + const asymptote: [Coord, Coord] = [ + [-10, -10], + [10, 10], + ]; + const coords: [Coord, Coord] = [ + [-10, -10], + [10, 10], + ]; + + // Arrange + const userInput: PerseusGrapherUserInput = { + type: "linear", + asymptote, + coords, + }; + + const rubric: PerseusGrapherRubric = { + availableTypes: ["linear"], + correct: { + type: "linear", + coords, + }, + // The rubric type is probably wrong, + // the validator doesn't use graph + graph: {} as any, + }; + + // Act + const result = grapherValidator(userInput, rubric); + + // Assert + expect(result).toHaveBeenAnsweredCorrectly(); + }); + + it("can be answered incorrectly when user input and rubric coords don't match", () => { + // TODO: user input type is probably wrong, + // I don't think asymptote is needed for all types + const asymptote: [Coord, Coord] = [ + [10, 10], + [-10, -10], + ]; + + // Arrange + const userInput: PerseusGrapherUserInput = { + type: "linear", + asymptote, + coords: [ + [2, 3], + [-4, -5], + ], + }; + + const rubric: PerseusGrapherRubric = { + availableTypes: ["linear"], + correct: { + type: "linear", + coords: [ + [-10, -10], + [10, 10], + ], + }, + // The rubric type is probably wrong, + // the validator doesn't use graph + graph: {} as any, + }; + + // Act + const result = grapherValidator(userInput, rubric); + + // Assert + expect(result).toHaveBeenAnsweredIncorrectly(); + }); +}); diff --git a/packages/perseus/src/widgets/grapher/grapher-validator.ts b/packages/perseus/src/widgets/grapher/grapher-validator.ts new file mode 100644 index 0000000000..74e2e93600 --- /dev/null +++ b/packages/perseus/src/widgets/grapher/grapher-validator.ts @@ -0,0 +1,61 @@ +import {functionForType} from "./util"; + +import type {PerseusScore} from "../../types"; +import type { + PerseusGrapherRubric, + PerseusGrapherUserInput, +} from "../../validation.types"; + +function grapherValidator( + state: PerseusGrapherUserInput, + rubric: PerseusGrapherRubric, +): PerseusScore { + if (state.type !== rubric.correct.type) { + return { + type: "points", + earned: 0, + total: 1, + message: null, + }; + } + + // We haven't moved the coords + if (state.coords == null) { + return { + type: "invalid", + message: null, + }; + } + + // Get new function handler for grading + const grader = functionForType(state.type); + const guessCoeffs = grader.getCoefficients(state.coords, state.asymptote); + const correctCoeffs = grader.getCoefficients( + rubric.correct.coords, + // @ts-expect-error - TS(2339) - Property 'asymptote' does not exist on type '{ type: "absolute_value"; coords: [Coord, Coord]; }' + rubric.correct.asymptote, + ); + + if (guessCoeffs == null || correctCoeffs == null) { + return { + type: "invalid", + message: null, + }; + } + if (grader.areEqual(guessCoeffs, correctCoeffs)) { + return { + type: "points", + earned: 1, + total: 1, + message: null, + }; + } + return { + type: "points", + earned: 0, + total: 1, + message: null, + }; +} + +export default grapherValidator; diff --git a/packages/perseus/src/widgets/grapher/grapher.tsx b/packages/perseus/src/widgets/grapher/grapher.tsx index 8301ab5e63..587c5a58e3 100644 --- a/packages/perseus/src/widgets/grapher/grapher.tsx +++ b/packages/perseus/src/widgets/grapher/grapher.tsx @@ -19,6 +19,7 @@ import {getInteractiveBoxFromSizeClass} from "../../util/sizing-utils"; /* Graphie and relevant components. */ /* Mixins. */ +import grapherValidator from "./grapher-validator"; import { DEFAULT_GRAPHER_PROPS, chooseType, @@ -27,7 +28,6 @@ import { getGridAndSnapSteps, maybePointsFromNormalized, typeToButton, - validate as grapherValidate, } from "./util"; import type {Coord, Line} from "../../interactive2/types"; @@ -371,7 +371,7 @@ class Grapher extends React.Component { state: PerseusGrapherUserInput, rubric: PerseusGrapherRubric, ): PerseusScore { - return grapherValidate(state, rubric); + return grapherValidator(state, rubric); } static getUserInputFromProps(props: Props): PerseusGrapherUserInput { @@ -541,7 +541,7 @@ class Grapher extends React.Component { }; simpleValidate(rubric: PerseusGrapherRubric): PerseusScore { - return grapherValidate(this.getUserInput(), rubric); + return grapherValidator(this.getUserInput(), rubric); } getUserInput(): PerseusGrapherUserInput { diff --git a/packages/perseus/src/widgets/grapher/util.tsx b/packages/perseus/src/widgets/grapher/util.tsx index 9150dc0e43..3058555c9d 100644 --- a/packages/perseus/src/widgets/grapher/util.tsx +++ b/packages/perseus/src/widgets/grapher/util.tsx @@ -8,7 +8,6 @@ import {getDependencies} from "../../dependencies"; import Util from "../../util"; import type {Coord} from "../../interactive2/types"; -import type {PerseusScore} from "../../types"; // @ts-expect-error - TS2339 - Property 'Plot' does not exist on type 'typeof Graphie'. const Plot = Graphie.Plot; @@ -596,54 +595,6 @@ export function functionForType( return functionTypeMapping[type]; } -export const validate = (state: any, rubric: any): PerseusScore => { - if (state.type !== rubric.correct.type) { - return { - type: "points", - earned: 0, - total: 1, - message: null, - }; - } - - // We haven't moved the coords - if (state.coords == null) { - return { - type: "invalid", - message: null, - }; - } - - // Get new function handler for grading - const grader = functionForType(state.type); - const guessCoeffs = grader.getCoefficients(state.coords, state.asymptote); - const correctCoeffs = grader.getCoefficients( - rubric.correct.coords, - rubric.correct.asymptote, - ); - - if (guessCoeffs == null || correctCoeffs == null) { - return { - type: "invalid", - message: null, - }; - } - if (grader.areEqual(guessCoeffs, correctCoeffs)) { - return { - type: "points", - earned: 1, - total: 1, - message: null, - }; - } - return { - type: "points", - earned: 0, - total: 1, - message: null, - }; -}; - export const getEquationString = (props: any): string => { const plot = props.plot; if (plot.type && plot.coords) { diff --git a/packages/perseus/src/widgets/interactive-graph.test.tsx b/packages/perseus/src/widgets/interactive-graph.test.tsx index 7f0973ca72..8185210ae0 100644 --- a/packages/perseus/src/widgets/interactive-graph.test.tsx +++ b/packages/perseus/src/widgets/interactive-graph.test.tsx @@ -1,194 +1,11 @@ -import invariant from "tiny-invariant"; - -import InteractiveGraph, {shouldUseMafs} from "./interactive-graph"; +import {shouldUseMafs} from "./interactive-graph"; import type { PerseusGraphTypeLinear, PerseusGraphTypePoint, PerseusGraphTypePolygon, - PerseusGraphType, PerseusGraphTypeNone, } from "../perseus-types"; -import type {PerseusInteractiveGraphRubric} from "../validation.types"; - -function createRubric(graph: PerseusGraphType): PerseusInteractiveGraphRubric { - return {graph, correct: graph}; -} - -describe("InteractiveGraph.validate on a segment question", () => { - it("marks the answer invalid if guess.coords is missing", () => { - const guess: PerseusGraphType = {type: "segment"}; - const rubric: PerseusInteractiveGraphRubric = createRubric({ - type: "segment", - coords: [ - [ - [0, 0], - [1, 1], - ], - ], - }); - - const result = InteractiveGraph.widget.validate(guess, rubric, null); - - expect(result).toEqual({ - type: "invalid", - message: null, - }); - }); - - it("does not award points if guess.coords is wrong", () => { - const guess: PerseusGraphType = { - type: "segment", - coords: [ - [ - [99, 0], - [1, 1], - ], - ], - }; - const rubric: PerseusInteractiveGraphRubric = createRubric({ - type: "segment", - coords: [ - [ - [0, 0], - [1, 1], - ], - ], - }); - - const result = InteractiveGraph.widget.validate(guess, rubric, null); - - expect(result).toEqual({ - type: "points", - earned: 0, - total: 1, - message: null, - }); - }); - - it("awards points if guess.coords is right", () => { - const guess: PerseusGraphType = { - type: "segment", - coords: [ - [ - [0, 0], - [1, 1], - ], - ], - }; - const rubric: PerseusInteractiveGraphRubric = createRubric({ - type: "segment", - coords: [ - [ - [0, 0], - [1, 1], - ], - ], - }); - - const result = InteractiveGraph.widget.validate(guess, rubric, null); - - expect(result).toEqual({ - type: "points", - earned: 1, - total: 1, - message: null, - }); - }); - - it("allows points of a segment to be specified in reverse order", () => { - const guess: PerseusGraphType = { - type: "segment", - coords: [ - [ - [1, 1], - [0, 0], - ], - ], - }; - const rubric: PerseusInteractiveGraphRubric = createRubric({ - type: "segment", - coords: [ - [ - [0, 0], - [1, 1], - ], - ], - }); - - const result = InteractiveGraph.widget.validate(guess, rubric, null); - - expect(result).toEqual({ - type: "points", - earned: 1, - total: 1, - message: null, - }); - }); - - it("does not modify the `guess` data", () => { - const guess: PerseusGraphType = { - type: "segment", - coords: [ - [ - [1, 1], - [0, 0], - ], - ], - }; - const rubric: PerseusInteractiveGraphRubric = createRubric({ - type: "segment", - coords: [ - [ - [0, 0], - [1, 1], - ], - ], - }); - - InteractiveGraph.widget.validate(guess, rubric, null); - - expect(guess.coords).toEqual([ - [ - [1, 1], - [0, 0], - ], - ]); - }); - - it("does not modify the `rubric` data", () => { - const guess: PerseusGraphType = { - type: "segment", - coords: [ - [ - [1, 1], - [0, 0], - ], - ], - }; - const rubric: PerseusInteractiveGraphRubric = createRubric({ - type: "segment", - coords: [ - [ - [1, 1], - [0, 0], - ], - ], - }); - - InteractiveGraph.widget.validate(guess, rubric, null); - - // Narrow the type of `rubric.correct` to segment graph; otherwise TS - // thinks it might not have a `coords` property. - invariant(rubric.correct.type === "segment"); - expect(rubric.correct.coords).toEqual([ - [ - [1, 1], - [0, 0], - ], - ]); - }); -}); describe("shouldUseMafs", () => { it("is false given no mafs flags", () => { diff --git a/packages/perseus/src/widgets/interactive-graph.tsx b/packages/perseus/src/widgets/interactive-graph.tsx index 5086143532..8886012c28 100644 --- a/packages/perseus/src/widgets/interactive-graph.tsx +++ b/packages/perseus/src/widgets/interactive-graph.tsx @@ -14,7 +14,6 @@ import Util from "../util"; import KhanColors from "../util/colors"; import { angleMeasures, - canonicalSineCoefficients, ccw, collinear, getLineEquation, @@ -24,7 +23,6 @@ import { magnitude, rotate, sign, - similar, vector, } from "../util/geometry"; import GraphUtils from "../util/graph-utils"; @@ -32,6 +30,7 @@ import {polar} from "../util/graphie"; import {getInteractiveBoxFromSizeClass} from "../util/sizing-utils"; import {StatefulMafsGraph} from "./interactive-graphs"; +import interactiveGraphValidator from "./interactive-graphs/interactive-graph-validator"; import type {StatefulMafsGraphType} from "./interactive-graphs/stateful-mafs-graph"; import type {QuadraticGraphState} from "./interactive-graphs/types"; @@ -68,7 +67,6 @@ const defaultBackgroundImage = { }; const eq = Util.eq; -const deepEq = Util.deepEq; const UNLIMITED = "unlimited" as const; @@ -135,6 +133,57 @@ type DefaultProps = { graph: Props["graph"]; }; +// TODO: there's another, very similar getSinusoidCoefficients function +// they should probably be merged +export function getSinusoidCoefficients( + coords: ReadonlyArray, +): SineCoefficient { + // It's assumed that p1 is the root and p2 is the first peak + const p1 = coords[0]; + const p2 = coords[1]; + + // Resulting coefficients are canonical for this sine curve + const amplitude = p2[1] - p1[1]; + const angularFrequency = Math.PI / (2 * (p2[0] - p1[0])); + const phase = p1[0] * angularFrequency; + const verticalOffset = p1[1]; + + return [amplitude, angularFrequency, phase, verticalOffset]; +} + +// TODO: there's another, very similar getQuadraticCoefficients function +// they should probably be merged +export function getQuadraticCoefficients( + coords: ReadonlyArray, +): QuadraticCoefficient { + const p1 = coords[0]; + const p2 = coords[1]; + const p3 = coords[2]; + + const denom = (p1[0] - p2[0]) * (p1[0] - p3[0]) * (p2[0] - p3[0]); + if (denom === 0) { + // Many of the callers assume that the return value is always defined. + // @ts-expect-error - TS2322 - Type 'undefined' is not assignable to type 'QuadraticCoefficient'. + return; + } + const a = + (p3[0] * (p2[1] - p1[1]) + + p2[0] * (p1[1] - p3[1]) + + p1[0] * (p3[1] - p2[1])) / + denom; + const b = + (p3[0] * p3[0] * (p1[1] - p2[1]) + + p2[0] * p2[0] * (p3[1] - p1[1]) + + p1[0] * p1[0] * (p2[1] - p3[1])) / + denom; + const c = + (p2[0] * p3[0] * (p2[0] - p3[0]) * p1[1] + + p3[0] * p1[0] * (p3[0] - p1[0]) * p2[1] + + p1[0] * p2[0] * (p1[0] - p2[0]) * p3[1]) / + denom; + return [a, b, c]; +} + // (LEMS-2190): Move the Mafs Angle Graph coordinate reversal logic in interactive-graph-state.ts // to this file when we remove the legacy graph. This logic allows us to support bi-directional angles // for the new (non-reflexive) Mafs graphs, while maintaining the same scoring behaviour as the legacy graph. @@ -1655,7 +1704,6 @@ class LegacyInteractiveGraph extends React.Component { $(this.angle).on("move", () => { this.onChange({ - // @ts-expect-error Type '{ coords: any; type: "angle"; showAngles?: boolean | undefined; allowReflexAngles?: boolean | undefined; angleOffsetDeg?: number | undefined; snapDegrees?: number | undefined; match?: "congruent" | undefined; }' is not assignable to type 'InteractiveGraphState | undefined'. graph: {...graph, coords: this.angle?.getClockwiseCoords()}, }); }); @@ -1681,7 +1729,7 @@ class LegacyInteractiveGraph extends React.Component { } simpleValidate(rubric: PerseusInteractiveGraphRubric) { - return InteractiveGraph.validate(this.getUserInput(), rubric, this); + return interactiveGraphValidator(this.getUserInput(), rubric); } focus: () => void = $.noop; @@ -1791,7 +1839,7 @@ class InteractiveGraph extends React.Component { } simpleValidate(rubric: PerseusInteractiveGraphRubric) { - return InteractiveGraph.validate(this.getUserInput(), rubric, this); + return interactiveGraphValidator(this.getUserInput(), rubric); } render() { @@ -1825,53 +1873,6 @@ class InteractiveGraph extends React.Component { ); } - static getQuadraticCoefficients( - coords: ReadonlyArray, - ): QuadraticCoefficient { - const p1 = coords[0]; - const p2 = coords[1]; - const p3 = coords[2]; - - const denom = (p1[0] - p2[0]) * (p1[0] - p3[0]) * (p2[0] - p3[0]); - if (denom === 0) { - // Many of the callers assume that the return value is always defined. - // @ts-expect-error - TS2322 - Type 'undefined' is not assignable to type 'QuadraticCoefficient'. - return; - } - const a = - (p3[0] * (p2[1] - p1[1]) + - p2[0] * (p1[1] - p3[1]) + - p1[0] * (p3[1] - p2[1])) / - denom; - const b = - (p3[0] * p3[0] * (p1[1] - p2[1]) + - p2[0] * p2[0] * (p3[1] - p1[1]) + - p1[0] * p1[0] * (p2[1] - p3[1])) / - denom; - const c = - (p2[0] * p3[0] * (p2[0] - p3[0]) * p1[1] + - p3[0] * p1[0] * (p3[0] - p1[0]) * p2[1] + - p1[0] * p2[0] * (p1[0] - p2[0]) * p3[1]) / - denom; - return [a, b, c]; - } - - static getSinusoidCoefficients( - coords: ReadonlyArray, - ): SineCoefficient { - // It's assumed that p1 is the root and p2 is the first peak - const p1 = coords[0]; - const p2 = coords[1]; - - // Resulting coefficients are canonical for this sine curve - const amplitude = p2[1] - p1[1]; - const angularFrequency = Math.PI / (2 * (p2[0] - p1[0])); - const phase = p1[0] * angularFrequency; - const verticalOffset = p1[1]; - - return [amplitude, angularFrequency, phase, verticalOffset]; - } - /** * @param {object} graph Like props.graph or props.correct * @param {object} props of an InteractiveGraph instance @@ -2199,7 +2200,7 @@ class InteractiveGraph extends React.Component { // @ts-expect-error - TS2339 - Property 'coords' does not exist on type 'PerseusGraphType'. props.graph.coords || InteractiveGraph.defaultQuadraticCoords(props); - return InteractiveGraph.getQuadraticCoefficients(coords); + return getQuadraticCoefficients(coords); } static defaultQuadraticCoords(props: Props): QuadraticGraphState["coords"] { @@ -2228,7 +2229,7 @@ class InteractiveGraph extends React.Component { const coords = // @ts-expect-error - TS2339 - Property 'coords' does not exist on type 'PerseusGraphType'. props.graph.coords || InteractiveGraph.defaultSinusoidCoords(props); - return InteractiveGraph.getSinusoidCoefficients(coords); + return getSinusoidCoefficients(coords); } static defaultSinusoidCoords(props: Props): ReadonlyArray { @@ -2363,294 +2364,8 @@ class InteractiveGraph extends React.Component { static validate( userInput: PerseusGraphType, rubric: PerseusInteractiveGraphRubric, - component: any, ): PerseusScore { - // None-type graphs are not graded - if (userInput.type === "none" && rubric.correct.type === "none") { - return { - type: "points", - earned: 0, - total: 0, - message: null, - }; - } - - // When nothing has moved, there will neither be coords nor the - // circle's center/radius fields. When those fields are absent, skip - // all these checks; just go mark the answer as empty. - const hasValue = Boolean( - // @ts-expect-error - TS2339 - Property 'coords' does not exist on type 'PerseusGraphType'. - userInput.coords || - // @ts-expect-error - TS2339 - Property 'center' does not exist on type 'PerseusGraphType'. | TS2339 - Property 'radius' does not exist on type 'PerseusGraphType'. - (userInput.center && userInput.radius), - ); - - if (userInput.type === rubric.correct.type && hasValue) { - if ( - userInput.type === "linear" && - rubric.correct.type === "linear" && - userInput.coords != null - ) { - const guess = userInput.coords; - const correct = rubric.correct.coords; - - // If both of the guess points are on the correct line, it's - // correct. - if ( - // @ts-expect-error - TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'. - collinear(correct[0], correct[1], guess[0]) && - // @ts-expect-error - TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'. - collinear(correct[0], correct[1], guess[1]) - ) { - return { - type: "points", - earned: 1, - total: 1, - message: null, - }; - } - } else if ( - userInput.type === "linear-system" && - rubric.correct.type === "linear-system" && - userInput.coords != null - ) { - const guess = userInput.coords; - const correct = rubric.correct.coords as ReadonlyArray< - ReadonlyArray - >; - - if ( - (collinear(correct[0][0], correct[0][1], guess[0][0]) && - collinear(correct[0][0], correct[0][1], guess[0][1]) && - collinear(correct[1][0], correct[1][1], guess[1][0]) && - collinear(correct[1][0], correct[1][1], guess[1][1])) || - (collinear(correct[0][0], correct[0][1], guess[1][0]) && - collinear(correct[0][0], correct[0][1], guess[1][1]) && - collinear(correct[1][0], correct[1][1], guess[0][0]) && - collinear(correct[1][0], correct[1][1], guess[0][1])) - ) { - return { - type: "points", - earned: 1, - total: 1, - message: null, - }; - } - } else if ( - userInput.type === "quadratic" && - rubric.correct.type === "quadratic" && - userInput.coords != null - ) { - // If the parabola coefficients match, it's correct. - const guessCoeffs = this.getQuadraticCoefficients( - userInput.coords, - ); - const correctCoeffs = this.getQuadraticCoefficients( - // @ts-expect-error - TS2345 - Argument of type 'readonly Coord[] | undefined' is not assignable to parameter of type 'readonly Coord[]'. - rubric.correct.coords, - ); - if (deepEq(guessCoeffs, correctCoeffs)) { - return { - type: "points", - earned: 1, - total: 1, - message: null, - }; - } - } else if ( - userInput.type === "sinusoid" && - rubric.correct.type === "sinusoid" && - userInput.coords != null - ) { - const guessCoeffs = this.getSinusoidCoefficients( - userInput.coords, - ); - const correctCoeffs = this.getSinusoidCoefficients( - // @ts-expect-error - TS2345 - Argument of type 'readonly Coord[] | undefined' is not assignable to parameter of type 'readonly Coord[]'. - rubric.correct.coords, - ); - - const canonicalGuessCoeffs = - canonicalSineCoefficients(guessCoeffs); - const canonicalCorrectCoeffs = - canonicalSineCoefficients(correctCoeffs); - // If the canonical coefficients match, it's correct. - if (deepEq(canonicalGuessCoeffs, canonicalCorrectCoeffs)) { - return { - type: "points", - earned: 1, - total: 1, - message: null, - }; - } - } else if ( - userInput.type === "circle" && - rubric.correct.type === "circle" - ) { - if ( - deepEq(userInput.center, rubric.correct.center) && - eq(userInput.radius, rubric.correct.radius) - ) { - return { - type: "points", - earned: 1, - total: 1, - message: null, - }; - } - } else if ( - userInput.type === "point" && - rubric.correct.type === "point" && - userInput.coords != null - ) { - let correct = InteractiveGraph.getPointCoords( - rubric.correct, - component, - ); - const guess = userInput.coords.slice(); - correct = correct.slice(); - // Everything's already rounded so we shouldn't need to do an - // eq() comparison but _.isEqual(0, -0) is false, so we'll use - // eq() anyway. The sort should be fine because it'll stringify - // it and -0 converted to a string is "0" - guess?.sort(); - // @ts-expect-error - TS2339 - Property 'sort' does not exist on type 'readonly Coord[]'. - correct.sort(); - if (deepEq(guess, correct)) { - return { - type: "points", - earned: 1, - total: 1, - message: null, - }; - } - } else if ( - userInput.type === "polygon" && - rubric.correct.type === "polygon" && - userInput.coords != null - ) { - const guess: Array = userInput.coords?.slice(); - // @ts-expect-error - TS2322 - Type 'Coord[] | undefined' is not assignable to type 'Coord[]'. - const correct: Array = rubric.correct.coords?.slice(); - - let match; - if (rubric.correct.match === "similar") { - match = similar(guess, correct, Number.POSITIVE_INFINITY); - } else if (rubric.correct.match === "congruent") { - match = similar(guess, correct, knumber.DEFAULT_TOLERANCE); - } else if (rubric.correct.match === "approx") { - match = similar(guess, correct, 0.1); - } else { - /* exact */ - guess.sort(); - correct.sort(); - match = deepEq(guess, correct); - } - - if (match) { - return { - type: "points", - earned: 1, - total: 1, - message: null, - }; - } - } else if ( - userInput.type === "segment" && - rubric.correct.type === "segment" && - userInput.coords != null - ) { - let guess = Util.deepClone(userInput.coords); - let correct = Util.deepClone(rubric.correct?.coords); - guess = _.invoke(guess, "sort").sort(); - // @ts-expect-error - TS2345 - Argument of type '(readonly Coord[])[] | undefined' is not assignable to parameter of type 'Collection'. - correct = _.invoke(correct, "sort").sort(); - if (deepEq(guess, correct)) { - return { - type: "points", - earned: 1, - total: 1, - message: null, - }; - } - } else if ( - userInput.type === "ray" && - rubric.correct.type === "ray" && - userInput.coords != null - ) { - const guess = userInput.coords; - const correct = rubric.correct.coords; - if ( - // @ts-expect-error - TS2532 - Object is possibly 'undefined'. - deepEq(guess[0], correct[0]) && - // @ts-expect-error - TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'. - collinear(correct[0], correct[1], guess[1]) - ) { - return { - type: "points", - earned: 1, - total: 1, - message: null, - }; - } - } else if ( - userInput.type === "angle" && - rubric.correct.type === "angle" - ) { - const guess = userInput.coords; - const correct = rubric.correct.coords; - - let match; - if (rubric.correct.match === "congruent") { - const angles = _.map([guess, correct], function (coords) { - const angle = GraphUtils.findAngle( - // @ts-expect-error - TS2532 - Object is possibly 'undefined'. - coords[2], - // @ts-expect-error - TS2532 - Object is possibly 'undefined'. - coords[0], - // @ts-expect-error - TS2532 - Object is possibly 'undefined'. - coords[1], - ); - return (angle + 360) % 360; - }); - // @ts-expect-error - TS2556 - A spread argument must either have a tuple type or be passed to a rest parameter. - match = eq(...angles); - } else { - /* exact */ - match = // @ts-expect-error - TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'. - deepEq(guess[1], correct[1]) && - // @ts-expect-error - TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'. - collinear(correct[1], correct[0], guess[0]) && - // @ts-expect-error - TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'. - collinear(correct[1], correct[2], guess[2]); - } - - if (match) { - return { - type: "points", - earned: 1, - total: 1, - message: null, - }; - } - } - } - - // The input wasn't correct, so check if it's a blank input or if it's - // actually just wrong - if (!hasValue || _.isEqual(userInput, rubric.graph)) { - // We're where we started. - return { - type: "invalid", - message: null, - }; - } - return { - type: "points", - earned: 0, - total: 1, - message: null, - }; + return interactiveGraphValidator(userInput, rubric); } static getUserInputFromProps(props: Props): PerseusGraphType { diff --git a/packages/perseus/src/widgets/interactive-graphs/interactive-graph-validator.test.ts b/packages/perseus/src/widgets/interactive-graphs/interactive-graph-validator.test.ts new file mode 100644 index 0000000000..22fb8046cc --- /dev/null +++ b/packages/perseus/src/widgets/interactive-graphs/interactive-graph-validator.test.ts @@ -0,0 +1,299 @@ +import invariant from "tiny-invariant"; + +import {clone} from "../../../../../testing/object-utils"; + +import interactiveGraphValidator from "./interactive-graph-validator"; + +import type {PerseusGraphType} from "../../perseus-types"; +import type {PerseusInteractiveGraphRubric} from "../../validation.types"; + +function createRubric(graph: PerseusGraphType): PerseusInteractiveGraphRubric { + return {graph, correct: graph}; +} + +describe("InteractiveGraph.validate on a segment question", () => { + it("marks the answer invalid if guess.coords is missing", () => { + const guess: PerseusGraphType = {type: "segment"}; + const rubric: PerseusInteractiveGraphRubric = createRubric({ + type: "segment", + coords: [ + [ + [0, 0], + [1, 1], + ], + ], + }); + + const result = interactiveGraphValidator(guess, rubric); + + expect(result).toHaveInvalidInput(); + }); + + it("does not award points if guess.coords is wrong", () => { + const guess: PerseusGraphType = { + type: "segment", + coords: [ + [ + [99, 0], + [1, 1], + ], + ], + }; + const rubric: PerseusInteractiveGraphRubric = createRubric({ + type: "segment", + coords: [ + [ + [0, 0], + [1, 1], + ], + ], + }); + + const result = interactiveGraphValidator(guess, rubric); + + expect(result).toHaveBeenAnsweredIncorrectly(); + }); + + it("awards points if guess.coords is right", () => { + const guess: PerseusGraphType = { + type: "segment", + coords: [ + [ + [0, 0], + [1, 1], + ], + ], + }; + const rubric: PerseusInteractiveGraphRubric = createRubric({ + type: "segment", + coords: [ + [ + [0, 0], + [1, 1], + ], + ], + }); + + const result = interactiveGraphValidator(guess, rubric); + + expect(result).toHaveBeenAnsweredCorrectly(); + }); + + it("allows points of a segment to be specified in reverse order", () => { + const guess: PerseusGraphType = { + type: "segment", + coords: [ + [ + [1, 1], + [0, 0], + ], + ], + }; + const rubric: PerseusInteractiveGraphRubric = createRubric({ + type: "segment", + coords: [ + [ + [0, 0], + [1, 1], + ], + ], + }); + + const result = interactiveGraphValidator(guess, rubric); + + expect(result).toHaveBeenAnsweredCorrectly(); + }); + + it("does not modify the `guess` data", () => { + const guess: PerseusGraphType = { + type: "segment", + coords: [ + [ + [1, 1], + [0, 0], + ], + ], + }; + const rubric: PerseusInteractiveGraphRubric = createRubric({ + type: "segment", + coords: [ + [ + [0, 0], + [1, 1], + ], + ], + }); + + interactiveGraphValidator(guess, rubric); + + expect(guess.coords).toEqual([ + [ + [1, 1], + [0, 0], + ], + ]); + }); + + it("does not modify the `rubric` data", () => { + const guess: PerseusGraphType = { + type: "segment", + coords: [ + [ + [1, 1], + [0, 0], + ], + ], + }; + const rubric: PerseusInteractiveGraphRubric = createRubric({ + type: "segment", + coords: [ + [ + [1, 1], + [0, 0], + ], + ], + }); + + interactiveGraphValidator(guess, rubric); + + // Narrow the type of `rubric.correct` to segment graph; otherwise TS + // thinks it might not have a `coords` property. + invariant(rubric.correct.type === "segment"); + expect(rubric.correct.coords).toEqual([ + [ + [1, 1], + [0, 0], + ], + ]); + }); +}); + +describe("InteractiveGraph.validate on a point question", () => { + it("marks the answer invalid if guess.coords is missing", () => { + const guess: PerseusGraphType = {type: "point"}; + const rubric: PerseusInteractiveGraphRubric = createRubric({ + type: "point", + coords: [[0, 0]], + }); + + const result = interactiveGraphValidator(guess, rubric); + + expect(result).toHaveInvalidInput(); + }); + + it("throws an exception if correct.coords is missing", () => { + // Characterization test: this might not be desirable behavior, but + // it's the current behavior as of 2024-09-25. + const guess: PerseusGraphType = { + type: "point", + coords: [[0, 0]], + }; + const rubric: PerseusInteractiveGraphRubric = createRubric({ + type: "point", + }); + + expect(() => interactiveGraphValidator(guess, rubric)).toThrowError(); + }); + + it("does not award points if guess.coords is wrong", () => { + const guess: PerseusGraphType = { + type: "point", + coords: [[9, 9]], + }; + const rubric: PerseusInteractiveGraphRubric = createRubric({ + type: "point", + coords: [[0, 0]], + }); + + const result = interactiveGraphValidator(guess, rubric); + + expect(result).toHaveBeenAnsweredIncorrectly(); + }); + + it("awards points if guess.coords is right", () => { + const guess: PerseusGraphType = { + type: "point", + coords: [[7, 8]], + }; + const rubric: PerseusInteractiveGraphRubric = createRubric({ + type: "point", + coords: [[7, 8]], + }); + + const result = interactiveGraphValidator(guess, rubric); + + expect(result).toEqual({ + type: "points", + earned: 1, + total: 1, + message: null, + }); + }); + + it("allows points to be specified in any order", () => { + const guess: PerseusGraphType = { + type: "point", + coords: [ + [7, 8], + [5, 6], + ], + }; + const rubric: PerseusInteractiveGraphRubric = createRubric({ + type: "point", + coords: [ + [5, 6], + [7, 8], + ], + }); + + const result = interactiveGraphValidator(guess, rubric); + + expect(result).toHaveBeenAnsweredCorrectly(); + }); + + it("does not modify the `guess` data", () => { + const guess: PerseusGraphType = { + type: "point", + coords: [ + [7, 8], + [5, 6], + ], + }; + const rubric: PerseusInteractiveGraphRubric = createRubric({ + type: "point", + coords: [ + [5, 6], + [7, 8], + ], + }); + + const guessClone = clone(guess); + + interactiveGraphValidator(guess, rubric); + + expect(guess).toEqual(guessClone); + }); + + it("does not modify the `rubric` data", () => { + const guess: PerseusGraphType = { + type: "point", + coords: [ + [7, 8], + [5, 6], + ], + }; + const rubric: PerseusInteractiveGraphRubric = createRubric({ + type: "point", + coords: [ + [5, 6], + [7, 8], + ], + }); + + const rubricClone = clone(rubric); + + interactiveGraphValidator(guess, rubric); + + expect(rubric).toEqual(rubricClone); + }); +}); diff --git a/packages/perseus/src/widgets/interactive-graphs/interactive-graph-validator.ts b/packages/perseus/src/widgets/interactive-graphs/interactive-graph-validator.ts new file mode 100644 index 0000000000..24ac5f90c9 --- /dev/null +++ b/packages/perseus/src/widgets/interactive-graphs/interactive-graph-validator.ts @@ -0,0 +1,313 @@ +import {number as knumber} from "@khanacademy/kmath"; +import _ from "underscore"; + +import Util from "../../util"; +import { + canonicalSineCoefficients, + collinear, + similar, +} from "../../util/geometry"; +import GraphUtils from "../../util/graph-utils"; +import { + getQuadraticCoefficients, + getSinusoidCoefficients, +} from "../interactive-graph"; + +import type {Coord} from "../../interactive2/types"; +import type {PerseusScore} from "../../types"; +import type { + PerseusInteractiveGraphRubric, + PerseusInteractiveGraphUserInput, +} from "../../validation.types"; + +const eq = Util.eq; +const deepEq = Util.deepEq; + +function interactiveGraphValidator( + userInput: PerseusInteractiveGraphUserInput, + rubric: PerseusInteractiveGraphRubric, +): PerseusScore { + // None-type graphs are not graded + if (userInput.type === "none" && rubric.correct.type === "none") { + return { + type: "points", + earned: 0, + total: 0, + message: null, + }; + } + + // When nothing has moved, there will neither be coords nor the + // circle's center/radius fields. When those fields are absent, skip + // all these checks; just go mark the answer as empty. + const hasValue = Boolean( + // @ts-expect-error - TS2339 - Property 'coords' does not exist on type 'PerseusGraphType'. + userInput.coords || + // @ts-expect-error - TS2339 - Property 'center' does not exist on type 'PerseusGraphType'. | TS2339 - Property 'radius' does not exist on type 'PerseusGraphType'. + (userInput.center && userInput.radius), + ); + + if (userInput.type === rubric.correct.type && hasValue) { + if ( + userInput.type === "linear" && + rubric.correct.type === "linear" && + userInput.coords != null + ) { + const guess = userInput.coords; + const correct = rubric.correct.coords; + + // If both of the guess points are on the correct line, it's + // correct. + if ( + // @ts-expect-error - TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'. + collinear(correct[0], correct[1], guess[0]) && + // @ts-expect-error - TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'. + collinear(correct[0], correct[1], guess[1]) + ) { + return { + type: "points", + earned: 1, + total: 1, + message: null, + }; + } + } else if ( + userInput.type === "linear-system" && + rubric.correct.type === "linear-system" && + userInput.coords != null + ) { + const guess = userInput.coords; + const correct = rubric.correct.coords as ReadonlyArray< + ReadonlyArray + >; + + if ( + (collinear(correct[0][0], correct[0][1], guess[0][0]) && + collinear(correct[0][0], correct[0][1], guess[0][1]) && + collinear(correct[1][0], correct[1][1], guess[1][0]) && + collinear(correct[1][0], correct[1][1], guess[1][1])) || + (collinear(correct[0][0], correct[0][1], guess[1][0]) && + collinear(correct[0][0], correct[0][1], guess[1][1]) && + collinear(correct[1][0], correct[1][1], guess[0][0]) && + collinear(correct[1][0], correct[1][1], guess[0][1])) + ) { + return { + type: "points", + earned: 1, + total: 1, + message: null, + }; + } + } else if ( + userInput.type === "quadratic" && + rubric.correct.type === "quadratic" && + userInput.coords != null + ) { + // If the parabola coefficients match, it's correct. + const guessCoeffs = getQuadraticCoefficients(userInput.coords); + const correctCoeffs = getQuadraticCoefficients( + // @ts-expect-error - TS2345 - Argument of type 'readonly Coord[] | undefined' is not assignable to parameter of type 'readonly Coord[]'. + rubric.correct.coords, + ); + if (deepEq(guessCoeffs, correctCoeffs)) { + return { + type: "points", + earned: 1, + total: 1, + message: null, + }; + } + } else if ( + userInput.type === "sinusoid" && + rubric.correct.type === "sinusoid" && + userInput.coords != null + ) { + const guessCoeffs = getSinusoidCoefficients(userInput.coords); + const correctCoeffs = getSinusoidCoefficients( + // @ts-expect-error - TS2345 - Argument of type 'readonly Coord[] | undefined' is not assignable to parameter of type 'readonly Coord[]'. + rubric.correct.coords, + ); + + const canonicalGuessCoeffs = canonicalSineCoefficients(guessCoeffs); + const canonicalCorrectCoeffs = + canonicalSineCoefficients(correctCoeffs); + // If the canonical coefficients match, it's correct. + if (deepEq(canonicalGuessCoeffs, canonicalCorrectCoeffs)) { + return { + type: "points", + earned: 1, + total: 1, + message: null, + }; + } + } else if ( + userInput.type === "circle" && + rubric.correct.type === "circle" + ) { + if ( + deepEq(userInput.center, rubric.correct.center) && + eq(userInput.radius, rubric.correct.radius) + ) { + return { + type: "points", + earned: 1, + total: 1, + message: null, + }; + } + } else if ( + userInput.type === "point" && + rubric.correct.type === "point" && + userInput.coords != null + ) { + let correct = rubric.correct.coords; + if (correct == null) { + throw new Error("Point graph rubric has null coords"); + } + const guess = userInput.coords.slice(); + correct = correct.slice(); + // Everything's already rounded so we shouldn't need to do an + // eq() comparison but _.isEqual(0, -0) is false, so we'll use + // eq() anyway. The sort should be fine because it'll stringify + // it and -0 converted to a string is "0" + guess?.sort(); + // @ts-expect-error - TS2339 - Property 'sort' does not exist on type 'readonly Coord[]'. + correct.sort(); + if (deepEq(guess, correct)) { + return { + type: "points", + earned: 1, + total: 1, + message: null, + }; + } + } else if ( + userInput.type === "polygon" && + rubric.correct.type === "polygon" && + userInput.coords != null + ) { + const guess: Array = userInput.coords?.slice(); + // @ts-expect-error - TS2322 - Type 'Coord[] | undefined' is not assignable to type 'Coord[]'. + const correct: Array = rubric.correct.coords?.slice(); + + let match; + if (rubric.correct.match === "similar") { + match = similar(guess, correct, Number.POSITIVE_INFINITY); + } else if (rubric.correct.match === "congruent") { + match = similar(guess, correct, knumber.DEFAULT_TOLERANCE); + } else if (rubric.correct.match === "approx") { + match = similar(guess, correct, 0.1); + } else { + /* exact */ + guess.sort(); + correct.sort(); + match = deepEq(guess, correct); + } + + if (match) { + return { + type: "points", + earned: 1, + total: 1, + message: null, + }; + } + } else if ( + userInput.type === "segment" && + rubric.correct.type === "segment" && + userInput.coords != null + ) { + let guess = Util.deepClone(userInput.coords); + let correct = Util.deepClone(rubric.correct?.coords); + guess = _.invoke(guess, "sort").sort(); + // @ts-expect-error - TS2345 - Argument of type '(readonly Coord[])[] | undefined' is not assignable to parameter of type 'Collection'. + correct = _.invoke(correct, "sort").sort(); + if (deepEq(guess, correct)) { + return { + type: "points", + earned: 1, + total: 1, + message: null, + }; + } + } else if ( + userInput.type === "ray" && + rubric.correct.type === "ray" && + userInput.coords != null + ) { + const guess = userInput.coords; + const correct = rubric.correct.coords; + if ( + // @ts-expect-error - TS2532 - Object is possibly 'undefined'. + deepEq(guess[0], correct[0]) && + // @ts-expect-error - TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'. + collinear(correct[0], correct[1], guess[1]) + ) { + return { + type: "points", + earned: 1, + total: 1, + message: null, + }; + } + } else if ( + userInput.type === "angle" && + rubric.correct.type === "angle" + ) { + const guess = userInput.coords; + const correct = rubric.correct.coords; + + let match; + if (rubric.correct.match === "congruent") { + const angles = _.map([guess, correct], function (coords) { + const angle = GraphUtils.findAngle( + // @ts-expect-error - TS2532 - Object is possibly 'undefined'. + coords[2], + // @ts-expect-error - TS2532 - Object is possibly 'undefined'. + coords[0], + // @ts-expect-error - TS2532 - Object is possibly 'undefined'. + coords[1], + ); + return (angle + 360) % 360; + }); + // @ts-expect-error - TS2556 - A spread argument must either have a tuple type or be passed to a rest parameter. + match = eq(...angles); + } else { + /* exact */ + match = // @ts-expect-error - TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'. + deepEq(guess[1], correct[1]) && + // @ts-expect-error - TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'. + collinear(correct[1], correct[0], guess[0]) && + // @ts-expect-error - TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'. + collinear(correct[1], correct[2], guess[2]); + } + + if (match) { + return { + type: "points", + earned: 1, + total: 1, + message: null, + }; + } + } + } + + // The input wasn't correct, so check if it's a blank input or if it's + // actually just wrong + if (!hasValue || _.isEqual(userInput, rubric.graph)) { + // We're where we started. + return { + type: "invalid", + message: null, + }; + } + return { + type: "points", + earned: 0, + total: 1, + message: null, + }; +} + +export default interactiveGraphValidator; diff --git a/packages/perseus/src/widgets/interactive-graphs/locked-figures/locked-point.tsx b/packages/perseus/src/widgets/interactive-graphs/locked-figures/locked-point.tsx index 15f3aec203..fd854d67b3 100644 --- a/packages/perseus/src/widgets/interactive-graphs/locked-figures/locked-point.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/locked-figures/locked-point.tsx @@ -21,11 +21,6 @@ const LockedPoint = (props: Props) => { className="locked-point" aria-label={hasAria ? ariaLabel : undefined} aria-hidden={!hasAria} - style={{ - // Outline styles on focus. - outlineOffset: spacing.xxxSmall_4, - borderRadius: "50%", - }} > { + it("converts the state of an angle graph", () => { + const graph: PerseusGraphType = { + type: "angle", + match: "congruent", + }; + const state: AngleGraphState = { + ...commonGraphState, + type: "angle", + showAngles: true, + allowReflexAngles: true, + angleOffsetDeg: 7, + snapDegrees: 8, + coords: [ + [9, 10], + [11, 12], + [13, 14], + ], + }; + + const result: PerseusGraphType = mafsStateToInteractiveGraph( + state, + graph, + ); + + expect(result).toEqual({ + type: "angle", + match: "congruent", + coords: [ + [9, 10], + [11, 12], + [13, 14], + ], + }); + }); + + it("converts the state of a circle graph", () => { + const graph: PerseusGraphType = { + type: "circle", + startCoords: { + radius: 3, + center: [4, 5], + }, + }; + const state: CircleGraphState = { + type: "circle", + center: [1, 2], + radiusPoint: [3, 2], + hasBeenInteractedWith: true, + range: [ + [-9, 9], + [-9, 9], + ], + snapStep: [9, 9], + }; + + const result: PerseusGraphType = mafsStateToInteractiveGraph( + state, + graph, + ); + + expect(result).toEqual({ + type: "circle", + radius: 2, + center: [1, 2], + startCoords: { + radius: 3, + center: [4, 5], + }, + }); + }); + + it("converts the state of a segment graph", () => { + const graph: PerseusGraphType = { + type: "segment", + numSegments: 7, + }; + const state: SegmentGraphState = { + ...commonGraphState, + type: "segment", + coords: [ + [ + [1, 2], + [3, 4], + ], + ], + }; + + const result: PerseusGraphType = mafsStateToInteractiveGraph( + state, + graph, + ); + + expect(result).toEqual({ + type: "segment", + numSegments: 7, + coords: [ + [ + [1, 2], + [3, 4], + ], + ], + }); + }); + + it("converts the state of a linear graph", () => { + const graph: PerseusGraphType = { + type: "linear", + startCoords: [ + [5, 6], + [7, 8], + ], + }; + const state: LinearGraphState = { + ...commonGraphState, + type: "linear", + coords: [ + [1, 2], + [3, 4], + ], + }; + + const result: PerseusGraphType = mafsStateToInteractiveGraph( + state, + graph, + ); + + expect(result).toEqual({ + type: "linear", + coords: [ + [1, 2], + [3, 4], + ], + startCoords: [ + [5, 6], + [7, 8], + ], + }); + }); + + it("converts the state of a linear-system graph", () => { + const graph: PerseusGraphType = { + type: "linear-system", + startCoords: [ + [ + [9, 10], + [11, 12], + ], + ], + }; + const state: LinearSystemGraphState = { + ...commonGraphState, + type: "linear-system", + coords: [ + [ + [1, 2], + [3, 4], + ], + [ + [5, 6], + [7, 8], + ], + ], + }; + + const result: PerseusGraphType = mafsStateToInteractiveGraph( + state, + graph, + ); + + expect(result).toEqual({ + type: "linear-system", + coords: [ + [ + [1, 2], + [3, 4], + ], + [ + [5, 6], + [7, 8], + ], + ], + startCoords: [ + [ + [9, 10], + [11, 12], + ], + ], + }); + }); + + it("converts the state of a ray graph", () => { + const graph: PerseusGraphType = { + type: "ray", + startCoords: [ + [5, 6], + [7, 8], + ], + }; + const state: RayGraphState = { + ...commonGraphState, + type: "ray", + coords: [ + [1, 2], + [3, 4], + ], + }; + + const result: PerseusGraphType = mafsStateToInteractiveGraph( + state, + graph, + ); + + expect(result).toEqual({ + type: "ray", + coords: [ + [1, 2], + [3, 4], + ], + startCoords: [ + [5, 6], + [7, 8], + ], + }); + }); + + it("converts the state of a polygon graph", () => { + const graph: PerseusGraphType = { + type: "polygon", + match: "approx", + }; + const state: PolygonGraphState = { + ...commonGraphState, + type: "polygon", + showAngles: true, + showSides: true, + snapTo: "sides", + coords: [ + [1, 2], + [3, 4], + [5, 6], + ], + }; + + const result: PerseusGraphType = mafsStateToInteractiveGraph( + state, + graph, + ); + + expect(result).toEqual({ + type: "polygon", + match: "approx", + coords: [ + [1, 2], + [3, 4], + [5, 6], + ], + }); + }); + + it("converts the state of a point graph", () => { + const graph: PerseusGraphType = { + type: "point", + numPoints: "unlimited", + startCoords: [[7, 8]], + }; + const state: PointGraphState = { + ...commonGraphState, + type: "point", + numPoints: "unlimited", + focusedPointIndex: 99, + showRemovePointButton: true, + showKeyboardInteractionInvitation: true, + interactionMode: "mouse", + coords: [ + [1, 2], + [3, 4], + [5, 6], + ], + }; + + const result: PerseusGraphType = mafsStateToInteractiveGraph( + state, + graph, + ); + + expect(result).toEqual({ + type: "point", + numPoints: "unlimited", + coords: [ + [1, 2], + [3, 4], + [5, 6], + ], + startCoords: [[7, 8]], + }); + }); + + it("converts the state of a quadratic graph", () => { + const graph: PerseusGraphType = { + type: "quadratic", + startCoords: [ + [7, 8], + [9, 10], + [11, 12], + ], + }; + const state: QuadraticGraphState = { + ...commonGraphState, + type: "quadratic", + coords: [ + [1, 2], + [3, 4], + [5, 6], + ], + }; + + const result: PerseusGraphType = mafsStateToInteractiveGraph( + state, + graph, + ); + + expect(result).toEqual({ + type: "quadratic", + coords: [ + [1, 2], + [3, 4], + [5, 6], + ], + startCoords: [ + [7, 8], + [9, 10], + [11, 12], + ], + }); + }); + + it("converts the state of a sinusoid graph", () => { + const graph: PerseusGraphType = { + type: "sinusoid", + startCoords: [ + [5, 6], + [7, 8], + ], + }; + const state: SinusoidGraphState = { + ...commonGraphState, + type: "sinusoid", + coords: [ + [1, 2], + [3, 4], + ], + }; + + const result: PerseusGraphType = mafsStateToInteractiveGraph( + state, + graph, + ); + + expect(result).toEqual({ + type: "sinusoid", + coords: [ + [1, 2], + [3, 4], + ], + startCoords: [ + [5, 6], + [7, 8], + ], + }); + }); + + it("converts the state of a none-type graph", () => { + const graph: PerseusGraphType = { + type: "none", + }; + const state: NoneGraphState = { + ...commonGraphState, + type: "none", + }; + + const result: PerseusGraphType = mafsStateToInteractiveGraph( + state, + graph, + ); + + expect(result).toEqual({ + type: "none", + }); + }); +}); diff --git a/packages/perseus/src/widgets/interactive-graphs/mafs-state-to-interactive-graph.ts b/packages/perseus/src/widgets/interactive-graphs/mafs-state-to-interactive-graph.ts new file mode 100644 index 0000000000..df7bf95806 --- /dev/null +++ b/packages/perseus/src/widgets/interactive-graphs/mafs-state-to-interactive-graph.ts @@ -0,0 +1,97 @@ +import {UnreachableCaseError} from "@khanacademy/wonder-stuff-core"; +import invariant from "tiny-invariant"; + +import {getRadius} from "./reducer/interactive-graph-state"; + +import type {InteractiveGraphState} from "./types"; +import type {PerseusGraphType} from "@khanacademy/perseus"; + +// Converts the state of a StatefulMafsGraph back to the format used to +// represent graph state in the widget JSON. +// +// Rather than be tightly bound to how data was structured in +// the legacy interactive graph, this lets us store state +// however we want and we just transform it before handing it off +// the the parent InteractiveGraph. +// +// The transformed data is used in the interactive graph widget editor, to +// set the `correct` field of the graph options. In the learner-facing UI, the +// data is also stored by the Renderer, and passed back down to the graph +// widget via the `graph` prop. Because the data returned by this function +// completely replaces the Renderer's representation of the widget, rather than +// being merged into it, we take it upon ourselves to merge in the original +// widget data here. If we didn't do this merging, the graph's configuration +// would reset to the defaults when the learner interacted with it. +export function mafsStateToInteractiveGraph( + state: InteractiveGraphState, + originalGraph: PerseusGraphType, +): PerseusGraphType { + switch (state.type) { + case "angle": + invariant(originalGraph.type === "angle"); + return { + ...originalGraph, + coords: state.coords, + }; + case "quadratic": + invariant(originalGraph.type === "quadratic"); + return { + ...originalGraph, + coords: state.coords, + }; + case "circle": + invariant(originalGraph.type === "circle"); + return { + ...originalGraph, + center: state.center, + radius: getRadius(state), + }; + case "linear": + invariant(originalGraph.type === "linear"); + return { + ...originalGraph, + coords: state.coords, + }; + case "ray": + invariant(originalGraph.type === "ray"); + return { + ...originalGraph, + coords: state.coords, + }; + case "sinusoid": + invariant(originalGraph.type === "sinusoid"); + return { + ...originalGraph, + coords: state.coords, + }; + case "segment": + invariant(originalGraph.type === "segment"); + return { + ...originalGraph, + coords: state.coords, + }; + case "linear-system": + invariant(originalGraph.type === "linear-system"); + return { + ...originalGraph, + coords: state.coords, + }; + case "polygon": + invariant(originalGraph.type === "polygon"); + return { + ...originalGraph, + coords: state.coords, + }; + case "point": + invariant(originalGraph.type === "point"); + return { + ...originalGraph, + coords: state.coords, + }; + case "none": + invariant(originalGraph.type === "none"); + return {...originalGraph}; + default: + throw new UnreachableCaseError(state); + } +} diff --git a/packages/perseus/src/widgets/interactive-graphs/stateful-mafs-graph.tsx b/packages/perseus/src/widgets/interactive-graphs/stateful-mafs-graph.tsx index 148f2277cb..446305e0dd 100644 --- a/packages/perseus/src/widgets/interactive-graphs/stateful-mafs-graph.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/stateful-mafs-graph.tsx @@ -3,6 +3,7 @@ import * as React from "react"; import {useEffect, useImperativeHandle, useRef} from "react"; import {MafsGraph} from "./mafs-graph"; +import {mafsStateToInteractiveGraph} from "./mafs-state-to-interactive-graph"; import {initializeGraphState} from "./reducer/initialize-graph-state"; import { changeRange, @@ -10,7 +11,7 @@ import { reinitialize, } from "./reducer/interactive-graph-action"; import {interactiveGraphReducer} from "./reducer/interactive-graph-reducer"; -import {getGradableGraph, getRadius} from "./reducer/interactive-graph-state"; +import {getGradableGraph} from "./reducer/interactive-graph-state"; import type {InteractiveGraphProps, InteractiveGraphState} from "./types"; import type {PerseusGraphType} from "../../perseus-types"; @@ -45,25 +46,6 @@ export type StatefulMafsGraphType = { getUserInput: () => PerseusInteractiveGraphUserInput; }; -// Rather than be tightly bound to how data was structured in -// the legacy interactive graph, this lets us store state -// however we want and we just transform it before handing it off -// the the parent InteractiveGraph -function mafsStateToInteractiveGraph(state: {graph: InteractiveGraphState}) { - if (state.graph.type === "circle") { - return { - ...state, - graph: { - ...state.graph, - radius: getRadius(state.graph), - }, - }; - } - return { - ...state, - }; -} - export const StatefulMafsGraph = React.forwardRef< StatefulMafsGraphType, StatefulMafsGraphProps @@ -84,10 +66,10 @@ export const StatefulMafsGraph = React.forwardRef< useEffect(() => { if (prevState.current !== state) { - onChange(mafsStateToInteractiveGraph({graph: state})); + onChange({graph: mafsStateToInteractiveGraph(state, graph)}); } prevState.current = state; - }, [onChange, state]); + }, [onChange, state, graph]); // Destructuring first to keep useEffect from making excess calls const [xSnap, ySnap] = props.snapStep; diff --git a/packages/perseus/src/widgets/matrix/matrix-validator.test.ts b/packages/perseus/src/widgets/matrix/matrix-validator.test.ts new file mode 100644 index 0000000000..5e4af99f0d --- /dev/null +++ b/packages/perseus/src/widgets/matrix/matrix-validator.test.ts @@ -0,0 +1,176 @@ +import {mockStrings} from "../../strings"; + +import matrixValidator from "./matrix-validator"; + +import type { + PerseusMatrixRubric, + PerseusMatrixUserInput, +} from "../../validation.types"; + +describe("matrixValidator", () => { + it("can be answered correctly", () => { + // Arrange + const rubric: PerseusMatrixRubric = { + prefix: "", + suffix: "", + answers: [ + [0, 1, 2], + [3, 4, 5], + [6, 7, 8], + ], + cursorPosition: [], + matrixBoardSize: [], + static: false, + }; + + const userInput: PerseusMatrixUserInput = { + answers: rubric.answers, + }; + + // Act + const result = matrixValidator(userInput, rubric, mockStrings); + + // Assert + expect(result).toHaveBeenAnsweredCorrectly(); + }); + + it("can be answered incorrectly", () => { + // Arrange + const rubric: PerseusMatrixRubric = { + prefix: "", + suffix: "", + answers: [ + [0, 1, 2], + [3, 4, 5], + [6, 7, 8], + ], + cursorPosition: [], + matrixBoardSize: [], + static: false, + }; + + const userInput: PerseusMatrixUserInput = { + answers: [ + [0, 0, 0], + [0, 0, 0], + [0, 0, 0], + ], + }; + + // Act + const result = matrixValidator(userInput, rubric, mockStrings); + + // Assert + expect(result).toHaveBeenAnsweredIncorrectly(); + }); + + it("is invalid when there's an empty cell: null", () => { + // Arrange + const rubric: PerseusMatrixRubric = { + prefix: "", + suffix: "", + answers: [ + [0, 1, 2], + [3, 4, 5], + [6, 7, 8], + ], + cursorPosition: [], + matrixBoardSize: [], + static: false, + }; + + const userInput: PerseusMatrixUserInput = { + answers: [ + // TODO: this is either legacy logic or an incorrect type, + // but this is what the validator is checking for + // @ts-expect-error - TS(2322) - Type 'null' is not assignable to type 'number'. + [0, 0, null], + [0, 0, 0], + [0, 0, 0], + ], + }; + + // Act + const result = matrixValidator(userInput, rubric, mockStrings); + + // Assert + expect(result).toHaveInvalidInput(); + }); + + it("is invalid when there's an empty cell: empty string", () => { + // Arrange + const rubric: PerseusMatrixRubric = { + prefix: "", + suffix: "", + answers: [ + [0, 1, 2], + [3, 4, 5], + [6, 7, 8], + ], + cursorPosition: [], + matrixBoardSize: [], + static: false, + }; + + const userInput: PerseusMatrixUserInput = { + answers: [ + // TODO: this is either legacy logic or an incorrect type, + // but this is what the validator is checking for + // @ts-expect-error - TS(2322) - Type 'null' is not assignable to type 'number'. + [0, 0, ""], + [0, 0, 0], + [0, 0, 0], + ], + }; + + // Act + const result = matrixValidator(userInput, rubric, mockStrings); + + // Assert + expect(result).toHaveInvalidInput(); + }); + + it("is considered incorrect when the size is wrong", () => { + // Arrange + const rubric: PerseusMatrixRubric = { + prefix: "", + suffix: "", + answers: [ + [0, 1, 2], + [3, 4, 5], + [6, 7, 8], + ], + cursorPosition: [], + matrixBoardSize: [], + static: false, + }; + + const correctUserInput: PerseusMatrixUserInput = { + answers: rubric.answers, + }; + + const incorrectUserInput: PerseusMatrixUserInput = { + // Base the incorrect answer off of the correct answer. + // This is so we can check that it's considered incorrect + // if it has the wrong length, even though it otherwise + // would be a partial match. + answers: [...rubric.answers, [8, 6, 7]], + }; + + // Act + const correctResult = matrixValidator( + correctUserInput, + rubric, + mockStrings, + ); + const incorrectResult = matrixValidator( + incorrectUserInput, + rubric, + mockStrings, + ); + + // Assert + expect(correctResult).toHaveBeenAnsweredCorrectly(); + expect(incorrectResult).toHaveBeenAnsweredIncorrectly(); + }); +}); diff --git a/packages/perseus/src/widgets/matrix/matrix-validator.ts b/packages/perseus/src/widgets/matrix/matrix-validator.ts new file mode 100644 index 0000000000..599224d5f6 --- /dev/null +++ b/packages/perseus/src/widgets/matrix/matrix-validator.ts @@ -0,0 +1,85 @@ +import _ from "underscore"; + +import KhanAnswerTypes from "../../util/answer-types"; + +import {getMatrixSize} from "./matrix"; + +import type {PerseusStrings} from "../../strings"; +import type {PerseusScore} from "../../types"; +import type { + PerseusMatrixRubric, + PerseusMatrixUserInput, +} from "../../validation.types"; + +function matrixValidator( + state: PerseusMatrixUserInput, + rubric: PerseusMatrixRubric, + strings: PerseusStrings, +): PerseusScore { + const solution = rubric.answers; + const supplied = state.answers; + const solutionSize = getMatrixSize(solution); + const suppliedSize = getMatrixSize(supplied); + + const incorrectSize = + solutionSize[0] !== suppliedSize[0] || + solutionSize[1] !== suppliedSize[1]; + + const createValidator = KhanAnswerTypes.number.createValidatorFunctional; + let message = null; + let hasEmptyCell = false; + let incorrect = false; + _(suppliedSize[0]).times((row) => { + _(suppliedSize[1]).times((col) => { + if ( + supplied[row][col] == null || + supplied[row][col].toString().length === 0 + ) { + hasEmptyCell = true; + } + if (!incorrectSize) { + const validator = createValidator( + // @ts-expect-error - TS2345 - Argument of type 'number' is not assignable to parameter of type 'string'. + solution[row][col], + { + simplify: true, + }, + strings, + ); + const result = validator(supplied[row][col]); + if (result.message) { + // @ts-expect-error - TS2322 - Type 'string' is not assignable to type 'null'. + message = result.message; + } + if (!result.correct) { + incorrect = true; + } + } + }); + }); + + if (hasEmptyCell) { + return { + type: "invalid", + message: strings.fillAllCells, + }; + } + + if (incorrectSize) { + return { + type: "points", + earned: 0, + total: 1, + message: null, + }; + } + + return { + type: "points", + earned: incorrect ? 0 : 1, + total: 1, + message: message, + }; +} + +export default matrixValidator; diff --git a/packages/perseus/src/widgets/matrix/matrix.tsx b/packages/perseus/src/widgets/matrix/matrix.tsx index be9d5e515b..59e1edb629 100644 --- a/packages/perseus/src/widgets/matrix/matrix.tsx +++ b/packages/perseus/src/widgets/matrix/matrix.tsx @@ -13,9 +13,9 @@ import InteractiveUtil from "../../interactive2/interactive-util"; import {ApiOptions} from "../../perseus-api"; import Renderer from "../../renderer"; import Util from "../../util"; -import KhanAnswerTypes from "../../util/answer-types"; -// Type imports +import matrixValidator from "./matrix-validator"; + import type {PerseusMatrixWidgetOptions} from "../../perseus-types"; import type {PerseusStrings} from "../../strings"; import type {WidgetExports, WidgetProps, PerseusScore} from "../../types"; @@ -74,7 +74,7 @@ const getRefForPath = function (path) { return "answer" + row + "," + column; }; -const getMatrixSize = function (matrix: ReadonlyArray>) { +export function getMatrixSize(matrix: ReadonlyArray>) { const matrixSize = [1, 1]; // We need to find the widest row and tallest column to get the correct @@ -96,7 +96,7 @@ const getMatrixSize = function (matrix: ReadonlyArray>) { } }); return matrixSize; -}; +} type ExternalProps = WidgetProps< PerseusMatrixWidgetOptions, @@ -149,71 +149,7 @@ class Matrix extends React.Component { rubric: PerseusMatrixRubric, strings: PerseusStrings, ): PerseusScore { - const solution = rubric.answers; - const supplied = state.answers; - const solutionSize = getMatrixSize(solution); - const suppliedSize = getMatrixSize(supplied); - - const incorrectSize = - solutionSize[0] !== suppliedSize[0] || - solutionSize[1] !== suppliedSize[1]; - - const createValidator = - KhanAnswerTypes.number.createValidatorFunctional; - let message = null; - let hasEmptyCell = false; - let incorrect = false; - _(suppliedSize[0]).times((row) => { - _(suppliedSize[1]).times((col) => { - if ( - supplied[row][col] == null || - supplied[row][col].toString().length === 0 - ) { - hasEmptyCell = true; - } - if (!incorrectSize) { - const validator = createValidator( - // @ts-expect-error - TS2345 - Argument of type 'number' is not assignable to parameter of type 'string'. - solution[row][col], - { - simplify: true, - }, - strings, - ); - const result = validator(supplied[row][col]); - if (result.message) { - // @ts-expect-error - TS2322 - Type 'string' is not assignable to type 'null'. - message = result.message; - } - if (!result.correct) { - incorrect = true; - } - } - }); - }); - - if (hasEmptyCell) { - return { - type: "invalid", - message: strings.fillAllCells, - }; - } - - if (incorrectSize) { - return { - type: "points", - earned: 0, - total: 1, - message: null, - }; - } - - return { - type: "points", - earned: incorrect ? 0 : 1, - total: 1, - message: message, - }; + return matrixValidator(state, rubric, strings); } state: State = { @@ -397,7 +333,7 @@ class Matrix extends React.Component { } simpleValidate(rubric: PerseusMatrixRubric) { - return Matrix.validate( + return matrixValidator( this.getUserInput(), rubric, this.context.strings, diff --git a/yarn.lock b/yarn.lock index 1a6d4443d8..f21ec054e3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6146,9 +6146,9 @@ caniuse-api@^3.0.0: lodash.uniq "^4.5.0" caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001332, caniuse-lite@^1.0.30001541, caniuse-lite@^1.0.30001587: - version "1.0.30001659" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001659.tgz" - integrity sha512-Qxxyfv3RdHAfJcXelgf0hU4DFUVXBGTjqrBUZLUh8AtlGnsDo+CnncYtTd95+ZKfnANUOzxyIQCuU/UeBZBYoA== + version "1.0.30001663" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001663.tgz" + integrity sha512-o9C3X27GLKbLeTYZ6HBOLU1tsAcBZsLis28wrVzddShCS16RujjHp9GDHKZqrB3meE0YjhawvMFsGb/igqiPzA== caseless@~0.12.0: version "0.12.0" @@ -15078,7 +15078,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -15096,6 +15096,15 @@ string-width@^1.0.1: is-fullwidth-code-point "^1.0.0" strip-ansi "^3.0.0" +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + string-width@^5.0.1, string-width@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" @@ -15180,7 +15189,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -15194,6 +15203,13 @@ strip-ansi@^3.0.0, strip-ansi@^3.0.1: dependencies: ansi-regex "^2.0.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -16412,7 +16428,7 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -16430,6 +16446,15 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"