From 0f7d4e68b1cca253b617b131074f5520745117d1 Mon Sep 17 00:00:00 2001 From: Jeremy Wiebe Date: Wed, 15 Jan 2025 11:24:44 -0800 Subject: [PATCH 1/5] Simplify MockWidget and move its types to be private to tests-only --- packages/perseus-core/src/data-schema.ts | 9 ------- .../widgets/mock-widgets/mock-widget-types.ts | 21 +++++++++++++++ .../src/widgets/mock-widgets/mock-widget.tsx | 26 +++++++++---------- .../widgets/mock-widgets/score-mock-widget.ts | 20 ++++---------- .../mock-widgets/validate-mock-widget.test.ts | 21 +++++++++++++++ .../mock-widgets/validate-mock-widget.ts | 17 ++++++++++++ 6 files changed, 77 insertions(+), 37 deletions(-) create mode 100644 packages/perseus/src/widgets/mock-widgets/mock-widget-types.ts create mode 100644 packages/perseus/src/widgets/mock-widgets/validate-mock-widget.test.ts create mode 100644 packages/perseus/src/widgets/mock-widgets/validate-mock-widget.ts diff --git a/packages/perseus-core/src/data-schema.ts b/packages/perseus-core/src/data-schema.ts index 8f544f4ea9..421fac60d6 100644 --- a/packages/perseus-core/src/data-schema.ts +++ b/packages/perseus-core/src/data-schema.ts @@ -144,7 +144,6 @@ export interface PerseusWidgetTypes { matcher: MatcherWidget; matrix: MatrixWidget; measurer: MeasurerWidget; - "mock-widget": MockWidget; "molecule-renderer": MoleculeRendererWidget; "number-line": NumberLineWidget; "numeric-input": NumericInputWidget; @@ -346,8 +345,6 @@ export type MatrixWidget = WidgetOptions<'matrix', PerseusMatrixWidgetOptions>; // prettier-ignore export type MeasurerWidget = WidgetOptions<'measurer', PerseusMeasurerWidgetOptions>; // prettier-ignore -export type MockWidget = WidgetOptions<'mock-widget', MockWidgetOptions>; -// prettier-ignore export type NumberLineWidget = WidgetOptions<'number-line', PerseusNumberLineWidgetOptions>; // prettier-ignore export type NumericInputWidget = WidgetOptions<'numeric-input', PerseusNumericInputWidgetOptions>; @@ -400,7 +397,6 @@ export type PerseusWidget = | MatcherWidget | MatrixWidget | MeasurerWidget - | MockWidget | MoleculeRendererWidget | NumberLineWidget | NumericInputWidget @@ -1720,11 +1716,6 @@ export type PerseusVideoWidgetOptions = { static?: boolean; }; -export type MockWidgetOptions = { - static?: boolean; - value: string; -}; - export type PerseusInputNumberWidgetOptions = { answerType?: | "number" diff --git a/packages/perseus/src/widgets/mock-widgets/mock-widget-types.ts b/packages/perseus/src/widgets/mock-widgets/mock-widget-types.ts new file mode 100644 index 0000000000..9746c30a92 --- /dev/null +++ b/packages/perseus/src/widgets/mock-widgets/mock-widget-types.ts @@ -0,0 +1,21 @@ +import type {WidgetOptions} from "@khanacademy/perseus-core"; + +// Extend the widget registries for testing +export interface PerseusWidgetTypes { + "mock-widget": MockWidget; +} + +export type MockWidget = WidgetOptions<"mock-widget", MockWidgetOptions>; + +export type MockWidgetOptions = { + static?: boolean; + value: string; +}; + +export type PerseusMockWidgetRubric = { + value: string; +}; + +export type PerseusMockWidgetUserInput = { + currentValue: string; +}; diff --git a/packages/perseus/src/widgets/mock-widgets/mock-widget.tsx b/packages/perseus/src/widgets/mock-widgets/mock-widget.tsx index d1a4287163..11d1c53339 100644 --- a/packages/perseus/src/widgets/mock-widgets/mock-widget.tsx +++ b/packages/perseus/src/widgets/mock-widgets/mock-widget.tsx @@ -6,18 +6,15 @@ import * as React from "react"; import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/mock-widget/prompt-utils"; import scoreMockWidget from "./score-mock-widget"; +import validateMockWidget from "./validate-mock-widget"; +import type { + MockWidgetOptions, + PerseusMockWidgetRubric, + PerseusMockWidgetUserInput, +} from "./mock-widget-types"; import type {WidgetExports, WidgetProps, Widget, FocusPath} from "../../types"; import type {MockWidgetPromptJSON} from "../../widget-ai-utils/mock-widget/prompt-utils"; -import type {MockWidgetOptions} from "@khanacademy/perseus-core"; - -export type PerseusMockWidgetRubric = { - value: string; -}; - -export type PerseusMockWidgetUserInput = { - currentValue: string; -}; type ExternalProps = WidgetProps; @@ -41,7 +38,7 @@ type Props = ExternalProps & { * * You can register this widget for your tests by calling `registerWidget("mock-widget", MockWidget);` */ -export class MockWidget extends React.Component implements Widget { +class MockWidgetComponent extends React.Component implements Widget { static defaultProps: DefaultProps = { currentValue: "", }; @@ -93,7 +90,7 @@ export class MockWidget extends React.Component implements Widget { }; getUserInput(): PerseusMockWidgetUserInput { - return MockWidget.getUserInputFromProps(this.props); + return MockWidgetComponent.getUserInputFromProps(this.props); } handleChange: ( @@ -131,9 +128,12 @@ const styles = StyleSheet.create({ export default { name: "mock-widget", displayName: "Mock Widget", - widget: MockWidget, + widget: MockWidgetComponent, isLintable: true, // TODO(LEMS-2656): remove TS suppression // @ts-expect-error: Type 'UserInput' is not assignable to type 'MockWidget'. scorer: scoreMockWidget, -} satisfies WidgetExports; + // TODO(LEMS-2656): remove TS suppression + // @ts-expect-error: Type 'UserInput' is not assignable to type 'PerseusMockWidgetUserInput'. + validator: validateMockWidget, +} satisfies WidgetExports; diff --git a/packages/perseus/src/widgets/mock-widgets/score-mock-widget.ts b/packages/perseus/src/widgets/mock-widgets/score-mock-widget.ts index e9a6302011..9e8b9e4d87 100644 --- a/packages/perseus/src/widgets/mock-widgets/score-mock-widget.ts +++ b/packages/perseus/src/widgets/mock-widgets/score-mock-widget.ts @@ -1,9 +1,7 @@ -import {KhanAnswerTypes} from "@khanacademy/perseus-score"; - import type { PerseusMockWidgetUserInput, PerseusMockWidgetRubric, -} from "./mock-widget"; +} from "./mock-widget-types"; import type {PerseusStrings} from "../../strings"; import type {PerseusScore} from "@khanacademy/perseus"; @@ -12,25 +10,17 @@ function scoreMockWidget( rubric: PerseusMockWidgetRubric, strings: PerseusStrings, ): PerseusScore { - const stringValue = `${rubric.value}`; - const val = KhanAnswerTypes.number.createValidatorFunctional( - stringValue, - strings, - ); - - const result = val(userInput.currentValue); - - if (result.empty) { + if (userInput.currentValue == null || userInput.currentValue === "") { return { type: "invalid", - message: result.message, + message: "No value provided", }; } return { type: "points", - earned: result.correct ? 1 : 0, + earned: userInput.currentValue === rubric.value ? 1 : 0, total: 1, - message: result.message, + message: "", }; } diff --git a/packages/perseus/src/widgets/mock-widgets/validate-mock-widget.test.ts b/packages/perseus/src/widgets/mock-widgets/validate-mock-widget.test.ts new file mode 100644 index 0000000000..614d0263e2 --- /dev/null +++ b/packages/perseus/src/widgets/mock-widgets/validate-mock-widget.test.ts @@ -0,0 +1,21 @@ +import validateMockWidget from "./validate-mock-widget"; + +import type {PerseusMockWidgetUserInput} from "./mock-widget-types"; + +describe("mock-widget", () => { + it("should be invalid if no value provided", () => { + const input: PerseusMockWidgetUserInput = {currentValue: ""}; + + const result = validateMockWidget(input); + + expect(result).toHaveInvalidInput(); + }); + + it("should be valid if a value provided", () => { + const input: PerseusMockWidgetUserInput = {currentValue: "a"}; + + const result = validateMockWidget(input); + + expect(result).toBeNull(); + }); +}); diff --git a/packages/perseus/src/widgets/mock-widgets/validate-mock-widget.ts b/packages/perseus/src/widgets/mock-widgets/validate-mock-widget.ts new file mode 100644 index 0000000000..99e9c8e219 --- /dev/null +++ b/packages/perseus/src/widgets/mock-widgets/validate-mock-widget.ts @@ -0,0 +1,17 @@ +import type {PerseusMockWidgetUserInput} from "./mock-widget-types"; +import type {ValidationResult} from "../../types"; + +function validateMockWidget( + userInput: PerseusMockWidgetUserInput, +): ValidationResult { + if (userInput.currentValue == null || userInput.currentValue === "") { + return { + type: "invalid", + message: "No value provided", + }; + } + + return null; +} + +export default validateMockWidget; From c099e9ec3808de1b3dcb38e8d367aa0a1c447071 Mon Sep 17 00:00:00 2001 From: Jeremy Wiebe Date: Thu, 16 Jan 2025 14:08:54 -0800 Subject: [PATCH 2/5] Type fixes so the mock-widget isn't part of our production widget type unions and maps --- packages/perseus-core/src/data-schema.ts | 38 +------------------ .../src/__testdata__/renderer.testdata.ts | 3 +- .../server-item-renderer.testdata.ts | 11 +++--- .../src/__tests__/renderer-api.test.tsx | 2 +- .../mock-widget/mock-widget.test.ts | 5 ++- .../mock-widget/prompt-utils.test.ts | 2 +- .../mock-widget/prompt-utils.ts | 2 +- .../widgets/mock-widgets/mock-widget-types.ts | 12 +++--- .../widgets/mock-widgets/score-mock-widget.ts | 11 +++--- .../mock-widgets/validate-mock-widget.ts | 2 +- 10 files changed, 29 insertions(+), 59 deletions(-) diff --git a/packages/perseus-core/src/data-schema.ts b/packages/perseus-core/src/data-schema.ts index 421fac60d6..19b73b6d95 100644 --- a/packages/perseus-core/src/data-schema.ts +++ b/packages/perseus-core/src/data-schema.ts @@ -102,7 +102,7 @@ export type MakeWidgetMap = { * `PerseusWidgets` with the one defined below. * * ```typescript - * declare module "@khanacademy/perseus" { + * declare module "@khanacademy/perseus-core" { * interface PerseusWidgetTypes { * // A new widget * "new-awesomeness": MyAwesomeNewWidget; @@ -377,41 +377,7 @@ export type VideoWidget = WidgetOptions<'video', PerseusVideoWidgetOptions>; //prettier-ignore export type DeprecatedStandinWidget = WidgetOptions<'deprecated-standin', object>; -export type PerseusWidget = - | CategorizerWidget - | CSProgramWidget - | DefinitionWidget - | DropdownWidget - | ExplanationWidget - | ExpressionWidget - | GradedGroupSetWidget - | GradedGroupWidget - | GrapherWidget - | GroupWidget - | IFrameWidget - | ImageWidget - | InputNumberWidget - | InteractionWidget - | InteractiveGraphWidget - | LabelImageWidget - | MatcherWidget - | MatrixWidget - | MeasurerWidget - | MoleculeRendererWidget - | NumberLineWidget - | NumericInputWidget - | OrdererWidget - | PassageRefWidget - | PassageWidget - | PhetSimulationWidget - | PlotterWidget - | PythonProgramWidget - | RadioWidget - | RefTargetWidget - | SorterWidget - | TableWidget - | VideoWidget - | DeprecatedStandinWidget; +export type PerseusWidget = PerseusWidgetTypes[keyof PerseusWidgetTypes]; /** * A background image applied to various widgets. diff --git a/packages/perseus/src/__testdata__/renderer.testdata.ts b/packages/perseus/src/__testdata__/renderer.testdata.ts index c95e1271de..aae892f155 100644 --- a/packages/perseus/src/__testdata__/renderer.testdata.ts +++ b/packages/perseus/src/__testdata__/renderer.testdata.ts @@ -1,10 +1,9 @@ +import type {MockWidget} from "../widgets/mock-widgets/mock-widget-types"; import type {RenderProps} from "../widgets/radio"; import type { DropdownWidget, ExpressionWidget, ImageWidget, - NumericInputWidget, - MockWidget, PerseusRenderer, } from "@khanacademy/perseus-core"; diff --git a/packages/perseus/src/__testdata__/server-item-renderer.testdata.ts b/packages/perseus/src/__testdata__/server-item-renderer.testdata.ts index b341fc4567..a2f8ae716d 100644 --- a/packages/perseus/src/__testdata__/server-item-renderer.testdata.ts +++ b/packages/perseus/src/__testdata__/server-item-renderer.testdata.ts @@ -7,9 +7,10 @@ import { type ExpressionWidget, type RadioWidget, type NumericInputWidget, - type MockWidget, } from "@khanacademy/perseus-core"; +import type {MockWidget} from "../widgets/mock-widgets/mock-widget-types"; + export const itemWithNumericInput: PerseusItem = { question: { content: @@ -40,7 +41,7 @@ export const itemWithNumericInput: PerseusItem = { labelText: "What's the answer?", size: "normal", }, - } as NumericInputWidget, + } satisfies NumericInputWidget, }, }, hints: [ @@ -64,7 +65,7 @@ export const itemWithMockWidget: PerseusItem = { options: { value: "3", }, - } as MockWidget, + } satisfies MockWidget, }, }, hints: [ @@ -158,14 +159,14 @@ export const itemWithTwoMockWidgets: PerseusItem = { options: { value: "3", }, - } as MockWidget, + } satisfies MockWidget, "mock-widget 2": { type: "mock-widget", graded: true, options: { value: "3", }, - } as MockWidget, + } satisfies MockWidget, }, }, hints: [ diff --git a/packages/perseus/src/__tests__/renderer-api.test.tsx b/packages/perseus/src/__tests__/renderer-api.test.tsx index 9693f48074..a26f3381d4 100644 --- a/packages/perseus/src/__tests__/renderer-api.test.tsx +++ b/packages/perseus/src/__tests__/renderer-api.test.tsx @@ -21,7 +21,7 @@ import mockWidget1Item from "./test-items/mock-widget-1-item"; import mockWidget2Item from "./test-items/mock-widget-2-item"; import tableItem from "./test-items/table-item"; -import type {PerseusMockWidgetUserInput} from "../widgets/mock-widgets/mock-widget"; +import type {PerseusMockWidgetUserInput} from "../widgets/mock-widgets/mock-widget-types"; import type {UserEvent} from "@testing-library/user-event"; const itemWidget = mockWidget1Item; diff --git a/packages/perseus/src/widget-ai-utils/mock-widget/mock-widget.test.ts b/packages/perseus/src/widget-ai-utils/mock-widget/mock-widget.test.ts index 366f502c92..6e7dd045bb 100644 --- a/packages/perseus/src/widget-ai-utils/mock-widget/mock-widget.test.ts +++ b/packages/perseus/src/widget-ai-utils/mock-widget/mock-widget.test.ts @@ -5,7 +5,8 @@ import {registerWidget} from "../../widgets"; import {renderQuestion} from "../../widgets/__testutils__/renderQuestion"; import MockWidgetExport from "../../widgets/mock-widgets/mock-widget"; -import type {PerseusRenderer, MockWidget} from "@khanacademy/perseus-core"; +import type {MockWidget} from "../../widgets/mock-widgets/mock-widget-types"; +import type {PerseusRenderer} from "@khanacademy/perseus-core"; import type {UserEvent} from "@testing-library/user-event"; const question: PerseusRenderer = { @@ -25,7 +26,7 @@ const question: PerseusRenderer = { value: "42", }, alignment: "default", - } as MockWidget, + } satisfies MockWidget, }, }; diff --git a/packages/perseus/src/widget-ai-utils/mock-widget/prompt-utils.test.ts b/packages/perseus/src/widget-ai-utils/mock-widget/prompt-utils.test.ts index 185be7ae32..08081be58d 100644 --- a/packages/perseus/src/widget-ai-utils/mock-widget/prompt-utils.test.ts +++ b/packages/perseus/src/widget-ai-utils/mock-widget/prompt-utils.test.ts @@ -1,6 +1,6 @@ import {getPromptJSON} from "./prompt-utils"; -import type {PerseusMockWidgetUserInput} from "../../widgets/mock-widgets/mock-widget"; +import type {PerseusMockWidgetUserInput} from "../../widgets/mock-widgets/mock-widget-types"; describe("InputNumber getPromptJSON", () => { it("it returns JSON with the expected format and fields", () => { diff --git a/packages/perseus/src/widget-ai-utils/mock-widget/prompt-utils.ts b/packages/perseus/src/widget-ai-utils/mock-widget/prompt-utils.ts index 2ecbe0fdb5..241bfcc9d3 100644 --- a/packages/perseus/src/widget-ai-utils/mock-widget/prompt-utils.ts +++ b/packages/perseus/src/widget-ai-utils/mock-widget/prompt-utils.ts @@ -1,5 +1,5 @@ -import type {PerseusMockWidgetUserInput} from "../../widgets/mock-widgets/mock-widget"; import type mockWidget from "../../widgets/mock-widgets/mock-widget"; +import type {PerseusMockWidgetUserInput} from "../../widgets/mock-widgets/mock-widget-types"; import type React from "react"; export type MockWidgetPromptJSON = { diff --git a/packages/perseus/src/widgets/mock-widgets/mock-widget-types.ts b/packages/perseus/src/widgets/mock-widgets/mock-widget-types.ts index 9746c30a92..d10d91319f 100644 --- a/packages/perseus/src/widgets/mock-widgets/mock-widget-types.ts +++ b/packages/perseus/src/widgets/mock-widgets/mock-widget-types.ts @@ -1,10 +1,5 @@ import type {WidgetOptions} from "@khanacademy/perseus-core"; -// Extend the widget registries for testing -export interface PerseusWidgetTypes { - "mock-widget": MockWidget; -} - export type MockWidget = WidgetOptions<"mock-widget", MockWidgetOptions>; export type MockWidgetOptions = { @@ -19,3 +14,10 @@ export type PerseusMockWidgetRubric = { export type PerseusMockWidgetUserInput = { currentValue: string; }; + +// Extend the widget registries for testing +declare module "@khanacademy/perseus-core" { + export interface PerseusWidgetTypes { + "mock-widget": MockWidget; + } +} diff --git a/packages/perseus/src/widgets/mock-widgets/score-mock-widget.ts b/packages/perseus/src/widgets/mock-widgets/score-mock-widget.ts index 9e8b9e4d87..12a193c4ac 100644 --- a/packages/perseus/src/widgets/mock-widgets/score-mock-widget.ts +++ b/packages/perseus/src/widgets/mock-widgets/score-mock-widget.ts @@ -1,3 +1,5 @@ +import validateMockWidget from "./validate-mock-widget"; + import type { PerseusMockWidgetUserInput, PerseusMockWidgetRubric, @@ -10,12 +12,11 @@ function scoreMockWidget( rubric: PerseusMockWidgetRubric, strings: PerseusStrings, ): PerseusScore { - if (userInput.currentValue == null || userInput.currentValue === "") { - return { - type: "invalid", - message: "No value provided", - }; + const validationResult = validateMockWidget(userInput); + if (validationResult != null) { + return validationResult; } + return { type: "points", earned: userInput.currentValue === rubric.value ? 1 : 0, diff --git a/packages/perseus/src/widgets/mock-widgets/validate-mock-widget.ts b/packages/perseus/src/widgets/mock-widgets/validate-mock-widget.ts index 99e9c8e219..ce02a642ce 100644 --- a/packages/perseus/src/widgets/mock-widgets/validate-mock-widget.ts +++ b/packages/perseus/src/widgets/mock-widgets/validate-mock-widget.ts @@ -7,7 +7,7 @@ function validateMockWidget( if (userInput.currentValue == null || userInput.currentValue === "") { return { type: "invalid", - message: "No value provided", + message: "", }; } From 9f40a535d6d6a23eea1752300a378fd1224eba37 Mon Sep 17 00:00:00 2001 From: Jeremy Wiebe Date: Thu, 16 Jan 2025 14:41:54 -0800 Subject: [PATCH 3/5] docs(changeset): Type and test fixes for new MockWidget (isolating to be seen only in tests) --- .changeset/eight-squids-repair.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/eight-squids-repair.md diff --git a/.changeset/eight-squids-repair.md b/.changeset/eight-squids-repair.md new file mode 100644 index 0000000000..f9fe6e58d6 --- /dev/null +++ b/.changeset/eight-squids-repair.md @@ -0,0 +1,7 @@ +--- +"@khanacademy/perseus": patch +"@khanacademy/perseus-core": patch +"@khanacademy/perseus-editor": patch +--- + +Type and test fixes for new MockWidget (isolating to be seen only in tests) From 29fba5522cf4023ab394fa953ec52bcb498b5863 Mon Sep 17 00:00:00 2001 From: Jeremy Wiebe Date: Thu, 16 Jan 2025 14:42:00 -0800 Subject: [PATCH 4/5] Changeset --- packages/perseus-core/src/data-schema.ts | 14 ++++++++++++-- packages/perseus/src/types.ts | 2 +- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/perseus-core/src/data-schema.ts b/packages/perseus-core/src/data-schema.ts index 19b73b6d95..d802e8e8e7 100644 --- a/packages/perseus-core/src/data-schema.ts +++ b/packages/perseus-core/src/data-schema.ts @@ -180,6 +180,18 @@ export interface PerseusWidgetTypes { */ export type PerseusWidgetsMap = MakeWidgetMap; +/** + * PerseusWidget is a union of all the different types of widget options that + * Perseus knows about. + * + * Thanks to it being based on PerseusWidgetTypes interface, this union is + * automatically extended to include widgets used in tests without those widget + * option types seeping into our production types. + * + * @see MockWidget for an example + */ +export type PerseusWidget = PerseusWidgetTypes[keyof PerseusWidgetTypes]; + /** * A "PerseusItem" is a classic Perseus item. It is rendered by the * `ServerItemRenderer` and the layout is pre-set. @@ -377,8 +389,6 @@ export type VideoWidget = WidgetOptions<'video', PerseusVideoWidgetOptions>; //prettier-ignore export type DeprecatedStandinWidget = WidgetOptions<'deprecated-standin', object>; -export type PerseusWidget = PerseusWidgetTypes[keyof PerseusWidgetTypes]; - /** * A background image applied to various widgets. */ diff --git a/packages/perseus/src/types.ts b/packages/perseus/src/types.ts index 5e213fd10c..d9151b31f8 100644 --- a/packages/perseus/src/types.ts +++ b/packages/perseus/src/types.ts @@ -213,7 +213,7 @@ export const MafsGraphTypeFlags = [ /** * APIOptions provides different ways to customize the behaviour of Perseus. * - * @see APIOptionsWithDefaults + * @see {@link APIOptionsWithDefaults} */ export type APIOptions = Readonly<{ isArticle?: boolean; From 75bab8b32b289ec6ad2de772cfea7335b5ad7405 Mon Sep 17 00:00:00 2001 From: Jeremy Wiebe Date: Tue, 21 Jan 2025 10:31:53 -0800 Subject: [PATCH 5/5] Hopefully clarify extending the widget types interface --- .../perseus/src/widgets/mock-widgets/mock-widget-types.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/perseus/src/widgets/mock-widgets/mock-widget-types.ts b/packages/perseus/src/widgets/mock-widgets/mock-widget-types.ts index d10d91319f..e6a8aa2416 100644 --- a/packages/perseus/src/widgets/mock-widgets/mock-widget-types.ts +++ b/packages/perseus/src/widgets/mock-widgets/mock-widget-types.ts @@ -16,6 +16,10 @@ export type PerseusMockWidgetUserInput = { }; // Extend the widget registries for testing +// See @khanacademy/perseus-core's PerseusWidgetTypes for a full explanation. +// Basically, we're extending the interface from that package so that our +// testing code knows of the MockWidget. In production code, there's no +// knowledge of the mock widget. declare module "@khanacademy/perseus-core" { export interface PerseusWidgetTypes { "mock-widget": MockWidget;