From 27126aa00b92ce1facd97abd38989e6981836e3f Mon Sep 17 00:00:00 2001 From: Sarah Third Date: Thu, 14 Nov 2024 12:44:30 -0800 Subject: [PATCH] Input Number Deprecation and Conversion into Numeric Input (#1731) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary: This PR contains all the work necessary to deprecate our Input Number Widget, and convert it into the Numeric Input widget (sans actually deleting the Input Number Files, which will be handled in LEMS-2411). The changes include: - Using replaceWidget/Editor to change the rendering of Input Number widgets to Numeric Input - New conversion util functions to automatically convert the json on the Editor pages for pre-existing Input Numbers in content, as an effort to avoid a backfill. - Cleanup of MANY tests across the repo that used Input Number for their test data - New PropUpgrades function for Numeric Input that converts Input Number widgetOptions into Numeric Input's format. NOTE: There is a temporary wrapper div around the Numeric Input component in order to help QA be able to determine that the widget has been converted, as InputNumber/NumericInput are virtually identical otherwise. This will be removed prior to landing the ticket, and after QA testing has completed. Issue: LEMS-2408 & LEMS-2409 ## Test plan: - Extensive Manual Testing - New Jest Tests - QA Testing Round Author: SonicScrewdriver Reviewers: SonicScrewdriver, mark-fitzgerald, #perseus Required Reviewers: Approved By: mark-fitzgerald Checks: ✅ Publish npm snapshot (ubuntu-latest, 20.x), ✅ Cypress (ubuntu-latest, 20.x), ✅ Check builds for changes in size (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ✅ Publish Storybook to Chromatic (ubuntu-latest, 20.x), ✅ gerald Pull Request URL: https://github.com/Khan/perseus/pull/1731 --- .changeset/rude-adults-lie.md | 6 + .../__stories__/article-editor.stories.tsx | 40 +- .../src/__stories__/editor-page.stories.tsx | 38 + .../src/__tests__/traversal.test.ts | 37 +- .../perseus-editor/src/article-editor.tsx | 59 +- packages/perseus-editor/src/editor-page.tsx | 29 +- packages/perseus-editor/src/editor.tsx | 2 +- .../util/deprecated-widgets/input-number.ts | 156 ++++ .../modernize-widgets-utils.test.ts | 70 ++ .../modernize-widgets-utils.testdata.ts | 678 +++++++++++++++ .../modernize-widgets-utils.ts | 48 ++ .../server-item-renderer.stories.tsx | 6 +- .../extract-perseus-data.testdata.ts | 34 +- .../src/__testdata__/renderer.testdata.ts | 58 +- .../server-item-renderer.testdata.ts | 118 +-- .../server-item-renderer.test.tsx.snap | 57 +- .../__tests__/extract-perseus-data.test.ts | 82 +- .../src/__tests__/renderability.test.ts | 37 +- .../src/__tests__/renderer-api.test.tsx | 50 +- .../perseus/src/__tests__/renderer.test.tsx | 171 ++-- .../__tests__/server-item-renderer.test.tsx | 38 +- .../test-items/input-number-1-item.ts | 26 - .../test-items/input-number-2-item.ts | 38 - .../test-items/numeric-input-1-item.ts | 35 + .../test-items/numeric-input-2-item.ts | 56 ++ packages/perseus/src/index.ts | 3 +- .../__testdata__/multi-renderer.testdata.ts | 25 +- .../multi-renderer.test.tsx.snap | 57 +- .../__tests__/multi-renderer.test.tsx | 52 +- packages/perseus/src/perseus-types.ts | 2 +- packages/perseus/src/server-item-renderer.tsx | 2 +- packages/perseus/src/styles/styles.less | 3 + .../input-number/input-number.test.ts | 72 -- .../input-number/prompt-utils.test.ts | 29 - packages/perseus/src/widgets.ts | 11 +- .../graded-group-set-jipt.test.ts.snap | 786 +++++++++--------- .../graded-group-set.test.ts.snap | 262 +++--- .../group/__snapshots__/group.test.tsx.snap | 524 ++++++------ .../__snapshots__/input-number.test.ts.snap | 495 ----------- .../widgets/input-number/input-number.test.ts | 363 -------- .../input-number/input-number.testdata.ts | 1 + .../__snapshots__/numeric-input.test.ts.snap | 524 ++++++------ .../widgets/numeric-input/numeric-input.tsx | 78 +- 43 files changed, 2782 insertions(+), 2476 deletions(-) create mode 100644 .changeset/rude-adults-lie.md create mode 100644 packages/perseus-editor/src/util/deprecated-widgets/input-number.ts create mode 100644 packages/perseus-editor/src/util/deprecated-widgets/modernize-widgets-utils.test.ts create mode 100644 packages/perseus-editor/src/util/deprecated-widgets/modernize-widgets-utils.testdata.ts create mode 100644 packages/perseus-editor/src/util/deprecated-widgets/modernize-widgets-utils.ts delete mode 100644 packages/perseus/src/__tests__/test-items/input-number-1-item.ts delete mode 100644 packages/perseus/src/__tests__/test-items/input-number-2-item.ts create mode 100644 packages/perseus/src/__tests__/test-items/numeric-input-1-item.ts create mode 100644 packages/perseus/src/__tests__/test-items/numeric-input-2-item.ts delete mode 100644 packages/perseus/src/widget-ai-utils/input-number/input-number.test.ts delete mode 100644 packages/perseus/src/widget-ai-utils/input-number/prompt-utils.test.ts delete mode 100644 packages/perseus/src/widgets/input-number/__snapshots__/input-number.test.ts.snap delete mode 100644 packages/perseus/src/widgets/input-number/input-number.test.ts diff --git a/.changeset/rude-adults-lie.md b/.changeset/rude-adults-lie.md new file mode 100644 index 0000000000..a0bee4728d --- /dev/null +++ b/.changeset/rude-adults-lie.md @@ -0,0 +1,6 @@ +--- +"@khanacademy/perseus": minor +"@khanacademy/perseus-editor": minor +--- + +Conversion of Input Number to Numeric Input diff --git a/packages/perseus-editor/src/__stories__/article-editor.stories.tsx b/packages/perseus-editor/src/__stories__/article-editor.stories.tsx index 75dabe601e..7ee98ca64f 100644 --- a/packages/perseus-editor/src/__stories__/article-editor.stories.tsx +++ b/packages/perseus-editor/src/__stories__/article-editor.stories.tsx @@ -5,14 +5,50 @@ import {useRef, useState} from "react"; import ArticleEditor from "../article-editor"; import {registerAllWidgetsAndEditorsForTesting} from "../util/register-all-widgets-and-editors-for-testing"; +import type {PerseusRenderer} from "@khanacademy/perseus"; + registerAllWidgetsAndEditorsForTesting(); export default { title: "PerseusEditor/ArticleEditor", }; - +const articleSectionWithInputNumber: PerseusRenderer = { + content: + "### Practice Problem\n\n$8\\cdot(11i+2)=$ [[☃ input-number 1]]. Also [[☃ input-number 2]] \n*.*", + images: {}, + widgets: { + "input-number 1": { + type: "input-number", + graded: true, + alignment: "default", + options: { + maxError: 0.1, + inexact: false, + value: 0.4, + simplify: "optional", + answerType: "rational", + size: "normal", + }, + version: {major: 1, minor: 0}, + }, + "input-number 2": { + type: "input-number", + graded: true, + alignment: "default", + options: { + maxError: 0.1, + inexact: false, + value: 0.5, + simplify: "optional", + answerType: "rational", + size: "normal", + }, + version: {major: 1, minor: 0}, + }, + }, +}; export const Base = (): React.ReactElement => { - const [state, setState] = useState(); + const [state, setState] = useState(articleSectionWithInputNumber); const articleEditorRef = useRef(); function handleChange(value) { diff --git a/packages/perseus-editor/src/__stories__/editor-page.stories.tsx b/packages/perseus-editor/src/__stories__/editor-page.stories.tsx index 33569fa99d..174f9f73ed 100644 --- a/packages/perseus-editor/src/__stories__/editor-page.stories.tsx +++ b/packages/perseus-editor/src/__stories__/editor-page.stories.tsx @@ -4,12 +4,50 @@ import {registerAllWidgetsAndEditorsForTesting} from "../util/register-all-widge import EditorPageWithStorybookPreview from "./editor-page-with-storybook-preview"; +import type {InputNumberWidget, PerseusRenderer} from "@khanacademy/perseus"; + registerAllWidgetsAndEditorsForTesting(); // SIDE_EFFECTY!!!! :cry: export default { title: "PerseusEditor/EditorPage", }; +const question1: PerseusRenderer = { + content: + "Denis baked a peach pie and cut it into $3$ equal-sized pieces. Denis's dad eats $1$ section of the pie. \n\n**What fraction of the pie did Denis's dad eat?** \n![](https://ka-perseus-graphie.s3.amazonaws.com/74a2b7583a2c26ebfb3ad714e29867541253fc97.png) \n[[\u2603 input-number 1]] \n\n\n\n", + images: { + "https://ka-perseus-graphie.s3.amazonaws.com/74a2b7583a2c26ebfb3ad714e29867541253fc97.png": + { + width: 200, + height: 200, + }, + }, + widgets: { + "input-number 1": { + version: { + major: 0, + minor: 0, + }, + type: "input-number", + graded: true, + alignment: "default", + options: { + maxError: 0.1, + inexact: false, + value: 0.5, + simplify: "optional", + answerType: "rational", + size: "normal", + }, + } as InputNumberWidget, + }, +}; + export const Demo = (): React.ReactElement => { return ; }; + +// Used to test that Input Numbers are being automatically converted to Numeric Inputs +export const InputNumberDemo = (): React.ReactElement => { + return ; +}; diff --git a/packages/perseus-editor/src/__tests__/traversal.test.ts b/packages/perseus-editor/src/__tests__/traversal.test.ts index ee135cd226..4634f0dda8 100644 --- a/packages/perseus-editor/src/__tests__/traversal.test.ts +++ b/packages/perseus-editor/src/__tests__/traversal.test.ts @@ -35,24 +35,31 @@ const missingOptions = { const clonedMissingOptions = JSON.parse(JSON.stringify(missingOptions)); const sampleOptions = { - content: "[[☃ input-number 1]]", + content: "[[☃ numeric-input 1]]", images: {}, widgets: { - "input-number 1": { - type: "input-number", + "numeric-input 1": { + type: "numeric-input", graded: true, - static: false, options: { - value: "0", - simplify: "required", + answers: [ + { + value: 0, + status: "correct", + message: "", + simplify: "required", + strict: true, + maxError: 0.1, + }, + ], size: "normal", - inexact: false, - maxError: 0.1, - answerType: "number", + coefficient: false, + labelText: "", rightAlign: false, }, + static: false, version: { - major: 0, + major: 1, minor: 0, }, alignment: "default", @@ -258,7 +265,7 @@ describe("Traversal", () => { readContent = content; }); - expect(readContent).toBe("[[☃ input-number 1]]"); + expect(readContent).toBe("[[☃ numeric-input 1]]"); assertNonMutative(); }); @@ -280,7 +287,7 @@ describe("Traversal", () => { widgetMap[widgetInfo.type] = (widgetMap[widgetInfo.type] || 0) + 1; }); expect(widgetMap).toEqual({ - "input-number": 1, + "numeric-input": 1, }); assertNonMutative(); }); @@ -294,9 +301,9 @@ describe("Traversal", () => { expect(newOptions).toEqual( _.extend({}, sampleOptions, { widgets: { - "input-number 1": _.extend( + "numeric-input 1": _.extend( {}, - sampleOptions.widgets["input-number 1"], + sampleOptions.widgets["numeric-input 1"], {graded: false}, ), }, @@ -312,7 +319,7 @@ describe("Traversal", () => { }); }); expect(newOptions.content).toBe( - "[[☃ input-number 1]]\n\nnew content!", + "[[☃ numeric-input 1]]\n\nnew content!", ); expect(newOptions.widgets).toEqual(sampleOptions.widgets); assertNonMutative(); diff --git a/packages/perseus-editor/src/article-editor.tsx b/packages/perseus-editor/src/article-editor.tsx index bc2e20015b..89fbcfbca1 100644 --- a/packages/perseus-editor/src/article-editor.tsx +++ b/packages/perseus-editor/src/article-editor.tsx @@ -19,18 +19,18 @@ import { iconCircleArrowUp, iconPlus, } from "./styles/icon-paths"; +import {convertDeprecatedWidgets} from "./util/deprecated-widgets/modernize-widgets-utils"; -import type {APIOptions, Changeable, ImageUploader} from "@khanacademy/perseus"; +import type { + APIOptions, + Changeable, + ImageUploader, + PerseusRenderer, +} from "@khanacademy/perseus"; const {HUD, InlineIcon} = components; -type RendererProps = { - content?: string; - widgets?: any; - images?: any; -}; - -type JsonType = RendererProps | ReadonlyArray; +type JsonType = PerseusRenderer | PerseusRenderer[]; type DefaultProps = { contentPaths?: ReadonlyArray; json: JsonType; @@ -50,20 +50,27 @@ type Props = DefaultProps & { type State = { highlightLint: boolean; + json: JsonType; }; export default class ArticleEditor extends React.Component { static defaultProps: DefaultProps = { contentPaths: [], - json: [{}], + json: [], mode: "edit", screen: "desktop", sectionImageUploadGenerator: () => , useNewStyles: false, }; - state: State = { - highlightLint: true, - }; + constructor(props: Props) { + super(props); + this.state = { + highlightLint: true, + json: Array.isArray(props.json) + ? props.json.map(convertDeprecatedWidgets) + : convertDeprecatedWidgets(props.json as PerseusRenderer), + }; + } componentDidMount() { this._updatePreviewFrames(); @@ -95,7 +102,7 @@ export default class ArticleEditor extends React.Component { } } - _apiOptionsForSection(section: RendererProps, sectionIndex: number): any { + _apiOptionsForSection(section: PerseusRenderer, sectionIndex: number): any { // eslint-disable-next-line react/no-string-refs const editor = this.refs[`editor${sectionIndex}`]; return { @@ -120,10 +127,13 @@ export default class ArticleEditor extends React.Component { }; } - _sections(): ReadonlyArray { - return Array.isArray(this.props.json) - ? this.props.json - : [this.props.json]; + _sections(): PerseusRenderer[] { + const sections = Array.isArray(this.state.json) + ? this.state.json + : [this.state.json]; + return sections.filter( + (section): section is PerseusRenderer => section !== null, + ); } _renderEditor(): React.ReactElement> { @@ -297,13 +307,13 @@ export default class ArticleEditor extends React.Component { this.props.onChange({json: newJson}); }; - _handleEditorChange: (i: number, newProps: RendererProps) => void = ( + _handleEditorChange: (i: number, newProps: PerseusRenderer) => void = ( i, newProps, ) => { const sections = _.clone(this._sections()); - // @ts-expect-error - TS2542 - Index signature in type 'readonly RendererProps[]' only permits reading. sections[i] = _.extend({}, sections[i], newProps); + this.setState({json: sections}); this.props.onChange({json: sections}); }; @@ -313,9 +323,7 @@ export default class ArticleEditor extends React.Component { } const sections = _.clone(this._sections()); const section = sections[i]; - // @ts-expect-error - TS2551 - Property 'splice' does not exist on type 'readonly RendererProps[]'. Did you mean 'slice'? sections.splice(i, 1); - // @ts-expect-error - TS2551 - Property 'splice' does not exist on type 'readonly RendererProps[]'. Did you mean 'slice'? sections.splice(i - 1, 0, section); this.props.onChange({ json: sections, @@ -328,9 +336,7 @@ export default class ArticleEditor extends React.Component { return; } const section = sections[i]; - // @ts-expect-error - TS2551 - Property 'splice' does not exist on type 'readonly RendererProps[]'. Did you mean 'slice'? sections.splice(i, 1); - // @ts-expect-error - TS2551 - Property 'splice' does not exist on type 'readonly RendererProps[]'. Did you mean 'slice'? sections.splice(i + 1, 0, section); this.props.onChange({ json: sections, @@ -362,7 +368,6 @@ export default class ArticleEditor extends React.Component { _handleRemoveSection(i: number) { const sections = _.clone(this._sections()); - // @ts-expect-error - TS2551 - Property 'splice' does not exist on type 'readonly RendererProps[]'. Did you mean 'slice'? sections.splice(i, 1); this.props.onChange({ json: sections, @@ -378,7 +383,7 @@ export default class ArticleEditor extends React.Component { }); } if (this.props.mode === "preview" || this.props.mode === "json") { - return this.props.json; + return this.state.json; } throw new PerseusError( "Could not serialize; mode " + this.props.mode + " not found", @@ -392,7 +397,7 @@ export default class ArticleEditor extends React.Component { * * This function can currently only be called in edit mode. */ - getSaveWarnings(): ReadonlyArray { + getSaveWarnings(): ReadonlyArray { if (this.props.mode !== "edit") { // TODO(joshuan): We should be able to get save warnings in // preview mode. @@ -427,7 +432,7 @@ export default class ArticleEditor extends React.Component { )} diff --git a/packages/perseus-editor/src/editor-page.tsx b/packages/perseus-editor/src/editor-page.tsx index 2f6c1bd684..a46c3ca8cc 100644 --- a/packages/perseus-editor/src/editor-page.tsx +++ b/packages/perseus-editor/src/editor-page.tsx @@ -6,6 +6,7 @@ import JsonEditor from "./components/json-editor"; import ViewportResizer from "./components/viewport-resizer"; import CombinedHintsEditor from "./hint-editor"; import ItemEditor from "./item-editor"; +import {convertDeprecatedWidgets} from "./util/deprecated-widgets/modernize-widgets-utils"; import type { APIOptions, @@ -16,6 +17,7 @@ import type { ImageUploader, Version, PerseusItem, + PerseusRenderer, } from "@khanacademy/perseus"; const {HUD} = components; @@ -57,6 +59,7 @@ type Props = { type State = { json: PerseusItem; + question: PerseusRenderer; gradeMessage: string; wasAnswered: boolean; highlightLint: boolean; @@ -83,15 +86,22 @@ class EditorPage extends React.Component { constructor(props: Props) { super(props); + // Convert any widgets that need to be converted to newer widget types + let convertedQuestionJson: PerseusRenderer = props.question; + if (props.question) { + convertedQuestionJson = convertDeprecatedWidgets(props.question); + } + + const json = { + answerArea: this.props.answerArea, + hints: this.props.hints, + itemDataVersion: this.props.itemDataVersion, + question: convertedQuestionJson, + }; + this.state = { // @ts-expect-error - TS2322 - Type 'Pick & Readonly<{ children?: ReactNode; }>, "hints" | "question" | "answerArea" | "itemDataVersion">' is not assignable to type 'PerseusJson'. - json: _.pick( - this.props, - "question", - "answerArea", - "hints", - "itemDataVersion", - ), + json: json, gradeMessage: "", wasAnswered: false, highlightLint: true, @@ -287,7 +297,10 @@ class EditorPage extends React.Component { { + // First we need to create a map of the old input-number keys to the new numeric-input keys + // so that we can ensure we update the content and widgets accordingly + const renameMap = getInputNumberRenameMap(json); + + // Then we can use this to update the JSON + return convertInputNumberJson(json, renameMap); +}; + +// We need to be able to run this code recursively in order to convert nested input-number widgets +const convertInputNumberJson = ( + json: PerseusRenderer, + renameMap: WidgetRenameMap, +): PerseusRenderer => { + const updatedContent = convertDeprecatedWidgetsInContent(json, renameMap); + const updatedWidgets = convertInputNumberWidgetOptions(json, renameMap); + const modernizedJson = { + ...json, + content: updatedContent, + widgets: updatedWidgets, + }; + + return modernizedJson; +}; + +// Convert the input-number json in the widgets of a PerseusRenderer object +export const convertInputNumberWidgetOptions = ( + json: PerseusRenderer, + renameMap: WidgetRenameMap, +): PerseusWidgetsMap => { + const widgets: PerseusWidgetsMap = {...json.widgets}; + // The question.widgets is a dictionary map of widgets, so we need to loop through in order to convert input-number to numeric-input + // which can exist as both the key or as a value on widget.type. + for (const key of Object.keys(widgets)) { + // Loop through the keys of the widgets dictionary + if (widgets[key].options.widgets) { + widgets[key].options = { + ...inputNumberToNumericInput(widgets[key].options), + }; + } + // Check if the widget is an input-number + if (widgets[key].type === "input-number") { + const provideAnswerForm = + widgets[key].options.answerType !== "number"; + // We need to determine the mathFormat for the numeric-input widget + const mathFormat = + widgets[key].options.answerType === "rational" + ? "proper" // input-number uses "rational" for proper fractions + : widgets[key].options.answerType; // Otherwise, we can use the answerType directly + + // We need to update the answers prop to match the numeric-input widget format + const answers: any = [ + { + value: widgets[key].options.value, + simplify: widgets[key].options.simplify, + // Input Number doesn't have a strict prop, so we default to false + strict: false, + // We only want to set maxError if the inexact prop is true + maxError: widgets[key].options.inexact + ? widgets[key].options.maxError + : 0, + status: "correct", // Input-number only allows correct answers + message: "", + }, + ]; + + // Add the required answerForms if provided/applicable + if (provideAnswerForm) { + answers[0].answerForms = [mathFormat]; + } + + // Update the options prop to match the numeric-input widget format + const upgradedWidgetOptions = { + answers, + size: widgets[key].options.size, + coefficient: false, // input-number doesn't have a coefficient prop + labelText: "", // input-number doesn't have a labelText prop + static: false, // static is always false for numeric-input + rightAlign: widgets[key].options.rightAlign || false, + }; + const upgradedWidget: NumericInputWidget = { + options: upgradedWidgetOptions, + type: "numeric-input", + }; + + const newWidgetName = renameMap[key]; + // Create the new key entry + widgets[newWidgetName] = upgradedWidget; + + // We need to delete the old widget key + delete widgets[key]; + } + } + return widgets; +}; + +// Convert the deprecated widget refs in the content string +// of a PerseusRenderer object to their renamed equivalents +const convertDeprecatedWidgetsInContent = ( + json: PerseusRenderer, + renameMap: WidgetRenameMap, +): string => { + return Object.keys(renameMap).reduce((newContent, oldKey) => { + const newKey = renameMap[oldKey]; + return newKey ? newContent.replace(oldKey, newKey) : newContent; + }, json.content); +}; + +// Create a map of the old input-number keys to the new numeric-input keys +export const getInputNumberRenameMap = ( + json: PerseusRenderer, +): WidgetRenameMap => { + const numericRegex = /(?<=\[\[\u2603 )(numeric-input \d+)(?=\]\])/g; + const inputNumberRegex = /(?<=\[\[\u2603 )(input-number \d+)(?=\]\])/g; + + // Get all the content strings within the json, which might be nested within widgets + const allContentStrings = json.content; + + // Loop through the content strings to get all the input-number widgets + const renameMap: WidgetRenameMap = {}; + const inputNumberMatches: string[] = [ + ...(allContentStrings.match(inputNumberRegex) || []), + ]; + + // We want to count any pre-existing numeric-input widgets + // so that we can start the new ids at the next number + const numericMatches: string[] = [ + ...(allContentStrings.match(numericRegex) || []), + ]; + let currentNumericCount = numericMatches.reduce((count, match) => { + const id = parseInt(match.split(" ")[1], 10); + return id >= count ? id + 1 : count; // We want to start at the next highest number + }, 1); + + // Now that we have all the necessary information, we can create the renameMap + for (const match of inputNumberMatches) { + const oldKey = match; + const newKey = `numeric-input ${currentNumericCount}`; + renameMap[oldKey] = newKey; + currentNumericCount++; + } + + return renameMap; +}; diff --git a/packages/perseus-editor/src/util/deprecated-widgets/modernize-widgets-utils.test.ts b/packages/perseus-editor/src/util/deprecated-widgets/modernize-widgets-utils.test.ts new file mode 100644 index 0000000000..4073e0de37 --- /dev/null +++ b/packages/perseus-editor/src/util/deprecated-widgets/modernize-widgets-utils.test.ts @@ -0,0 +1,70 @@ +import {convertDeprecatedWidgets} from "./modernize-widgets-utils"; +import { + inputNumberMultiNested, + inputNumberNested, + inputNumberNestedWithNumeric, + inputNumberSimple, + numericInputMultiNested, + numericInputNested, + numericInputNestedWithNumeric, + numericInputSimple, +} from "./modernize-widgets-utils.testdata"; + +describe("convertDeprecatedWidgets", () => { + it("should be able to convert a simple input number widget into numeric input", () => { + // Arrange + const input = inputNumberSimple; + const expected = numericInputSimple; + + // Act + const result = convertDeprecatedWidgets(input); + + // Assert + expect(result.content).toEqual(expected.content); + expect(result.widgets).toEqual(expected.widgets); + }); + + it("should be able to convert a nested input number widget", () => { + // This test has the inputNumber widget nested within a gradedGroup widget + + // Arrange + const input = inputNumberNested; + const expected = numericInputNested; + + // Act + const result = convertDeprecatedWidgets(input); + + // Assert + expect(result).toEqual(expected); + }); + + it("should be scope the widget ids of nested widgets", () => { + // This test has 2 pre-existing numericInput widgets, with one of them being nested + // within a graded group. As a result, the inputNumber widget should become "numeric-input 3". + + // Arrange + const input = inputNumberNestedWithNumeric; + const expected = numericInputNestedWithNumeric; + + // Act + const result = convertDeprecatedWidgets(input); + + // Assert + expect(result).toEqual(expected); + }); + + it("should be able to correctly generate ids for both top-level and nested widgets", () => { + // This test has 2 pre-existing numericInput widgets, with one of them being nested + // within a graded group. As a result, the inputNumber widget should become "numeric-input 3". + + // Arrange + const input = inputNumberMultiNested; + const expected = numericInputMultiNested; + + // Act + const result = convertDeprecatedWidgets(input); + + // Assert + expect(result).toEqual(expected); + }); +}); diff --git a/packages/perseus-editor/src/util/deprecated-widgets/modernize-widgets-utils.testdata.ts b/packages/perseus-editor/src/util/deprecated-widgets/modernize-widgets-utils.testdata.ts new file mode 100644 index 0000000000..f7fcc8497d --- /dev/null +++ b/packages/perseus-editor/src/util/deprecated-widgets/modernize-widgets-utils.testdata.ts @@ -0,0 +1,678 @@ +import type { + InputNumberWidget, + PerseusRenderer, + NumericInputWidget, + PerseusWidgetsMap, +} from "@khanacademy/perseus"; + +export const inputNumberSimple: PerseusRenderer = { + content: + "Denis baked a peach pie and cut it into $3$ equal-sized pieces. Denis's dad eats $1$ section of the pie. \n\n**What fraction of the pie did Denis's dad eat?** \n![](https://ka-perseus-graphie.s3.amazonaws.com/74a2b7583a2c26ebfb3ad714e29867541253fc97.png) \n[[\u2603 input-number 1]] \n\n\n\n", + images: { + "https://ka-perseus-graphie.s3.amazonaws.com/74a2b7583a2c26ebfb3ad714e29867541253fc97.png": + { + width: 200, + height: 200, + }, + }, + widgets: { + "input-number 1": { + type: "input-number", + graded: true, + alignment: "default", + options: { + maxError: 0, + inexact: false, + value: 0.3333333333333333, + simplify: "optional", + answerType: "rational", + size: "normal", + }, + } as InputNumberWidget, + }, +}; + +export const numericInputSimple: PerseusRenderer = { + content: + "Denis baked a peach pie and cut it into $3$ equal-sized pieces. Denis's dad eats $1$ section of the pie. \n\n**What fraction of the pie did Denis's dad eat?** \n![](https://ka-perseus-graphie.s3.amazonaws.com/74a2b7583a2c26ebfb3ad714e29867541253fc97.png) \n[[\u2603 numeric-input 1]] \n\n\n\n", + images: { + "https://ka-perseus-graphie.s3.amazonaws.com/74a2b7583a2c26ebfb3ad714e29867541253fc97.png": + { + width: 200, + height: 200, + }, + }, + widgets: { + "numeric-input 1": { + type: "numeric-input", + options: { + static: false, + answers: [ + { + value: 0.3333333333333333, + status: "correct", + message: "", + simplify: "optional", + strict: false, + maxError: 0, + answerForms: ["proper"], + }, + ], + size: "normal", + coefficient: false, + labelText: "", + rightAlign: false, + }, + } as NumericInputWidget, + }, +}; + +export const inputNumberNestedWithNumeric: PerseusRenderer = { + content: + "Denis baked a peach pie and cut it into $3$ equal-sized pieces. Denis's dad eats $1$ section of the pie. \n\n**What fraction of the pie did Denis's dad eat?** \n[[\u2603 numeric-input 1]] \n![](https://ka-perseus-graphie.s3.amazonaws.com/74a2b7583a2c26ebfb3ad714e29867541253fc97.png) \n[[\u2603 graded-group 1]] \n\n\n\n", + images: { + "https://ka-perseus-graphie.s3.amazonaws.com/74a2b7583a2c26ebfb3ad714e29867541253fc97.png": + { + width: 200, + height: 200, + }, + }, + widgets: { + "numeric-input 1": { + type: "numeric-input", + options: { + static: false, + answers: [ + { + value: 0.1, + status: "correct", + message: "", + simplify: "optional", + strict: false, + maxError: 0, + }, + ], + size: "normal", + coefficient: false, + labelText: "", + rightAlign: false, + }, + } as NumericInputWidget, + "graded-group 1": { + type: "graded-group", + alignment: "default", + static: false, + graded: true, + options: { + title: "Group 1", + content: + "This is just a couple of cute lil' [[\u2603 numeric-input 1]]'s and [[\u2603 input-number 1]]'s.", + images: {}, + widgets: { + "numeric-input 1": { + type: "numeric-input", + options: { + static: false, + answers: [ + { + value: 0.2, + status: "correct", + message: "", + simplify: "optional", + strict: false, + maxError: 0, + }, + ], + size: "normal", + coefficient: false, + labelText: "", + rightAlign: false, + }, + } as NumericInputWidget, + "input-number 1": { + type: "input-number", + graded: true, + alignment: "default", + options: { + maxError: 0, + inexact: false, + value: 0.3333333333333333, + simplify: "optional", + answerType: "rational", + size: "normal", + }, + } as InputNumberWidget, + }, + }, + } as PerseusWidgetsMap["graded-group 1"], + }, +}; + +export const numericInputNestedWithNumeric: PerseusRenderer = { + content: + "Denis baked a peach pie and cut it into $3$ equal-sized pieces. Denis's dad eats $1$ section of the pie. \n\n**What fraction of the pie did Denis's dad eat?** \n[[\u2603 numeric-input 1]] \n![](https://ka-perseus-graphie.s3.amazonaws.com/74a2b7583a2c26ebfb3ad714e29867541253fc97.png) \n[[\u2603 graded-group 1]] \n\n\n\n", + images: { + "https://ka-perseus-graphie.s3.amazonaws.com/74a2b7583a2c26ebfb3ad714e29867541253fc97.png": + { + width: 200, + height: 200, + }, + }, + widgets: { + "numeric-input 1": { + type: "numeric-input", + options: { + static: false, + answers: [ + { + value: 0.1, + status: "correct", + message: "", + simplify: "optional", + strict: false, + maxError: 0, + }, + ], + size: "normal", + coefficient: false, + labelText: "", + rightAlign: false, + }, + } as NumericInputWidget, + "graded-group 1": { + type: "graded-group", + alignment: "default", + static: false, + graded: true, + options: { + title: "Group 1", + content: + "This is just a couple of cute lil' [[\u2603 numeric-input 1]]'s and [[\u2603 numeric-input 2]]'s.", + images: {}, + widgets: { + "numeric-input 1": { + type: "numeric-input", + options: { + static: false, + answers: [ + { + value: 0.2, + status: "correct", + message: "", + simplify: "optional", + strict: false, + maxError: 0, + }, + ], + size: "normal", + coefficient: false, + labelText: "", + rightAlign: false, + }, + } as NumericInputWidget, + "numeric-input 2": { + type: "numeric-input", + options: { + static: false, + answers: [ + { + value: 0.3333333333333333, + status: "correct", + message: "", + simplify: "optional", + strict: false, + maxError: 0, + answerForms: ["proper"], + }, + ], + size: "normal", + coefficient: false, + labelText: "", + rightAlign: false, + }, + } as NumericInputWidget, + }, + }, + } as PerseusWidgetsMap["graded-group 1"], + }, +}; + +export const inputNumberNested: PerseusRenderer = { + content: + "Denis baked a peach pie and cut it into $3$ equal-sized pieces. Denis's dad eats $1$ section of the pie. \n\n**What fraction of the pie did Denis's dad eat?** \n![](https://ka-perseus-graphie.s3.amazonaws.com/74a2b7583a2c26ebfb3ad714e29867541253fc97.png) \n[[\u2603 graded-group 1]] \n\n\n\n", + images: { + "https://ka-perseus-graphie.s3.amazonaws.com/74a2b7583a2c26ebfb3ad714e29867541253fc97.png": + { + width: 200, + height: 200, + }, + }, + widgets: { + "graded-group 1": { + type: "graded-group", + alignment: "default", + static: false, + graded: true, + options: { + title: "Group 1", + content: "This is just a cute lil' [[\u2603 input-number 1]].", + images: {}, + widgets: { + "input-number 1": { + type: "input-number", + graded: true, + alignment: "default", + options: { + maxError: 0, + inexact: false, + value: 0.3333333333333333, + simplify: "optional", + answerType: "rational", + size: "normal", + }, + } as InputNumberWidget, + }, + }, + } as PerseusWidgetsMap["graded-group 1"], + }, +}; + +export const numericInputNested: PerseusRenderer = { + content: + "Denis baked a peach pie and cut it into $3$ equal-sized pieces. Denis's dad eats $1$ section of the pie. \n\n**What fraction of the pie did Denis's dad eat?** \n![](https://ka-perseus-graphie.s3.amazonaws.com/74a2b7583a2c26ebfb3ad714e29867541253fc97.png) \n[[\u2603 graded-group 1]] \n\n\n\n", + images: { + "https://ka-perseus-graphie.s3.amazonaws.com/74a2b7583a2c26ebfb3ad714e29867541253fc97.png": + { + width: 200, + height: 200, + }, + }, + widgets: { + "graded-group 1": { + type: "graded-group", + alignment: "default", + static: false, + graded: true, + options: { + title: "Group 1", + content: "This is just a cute lil' [[\u2603 numeric-input 1]].", + images: {}, + widgets: { + "numeric-input 1": { + type: "numeric-input", + options: { + static: false, + answers: [ + { + value: 0.3333333333333333, + status: "correct", + message: "", + simplify: "optional", + strict: false, + maxError: 0, + answerForms: ["proper"], + }, + ], + size: "normal", + coefficient: false, + labelText: "", + rightAlign: false, + }, + } as NumericInputWidget, + }, + }, + } as PerseusWidgetsMap["graded-group 1"], + }, +}; + +export const inputNumberMultiNested: PerseusRenderer = { + content: + "Denis baked a peach pie and cut it into $3$ equal-sized pieces. \n[[\u2603 graded-group 1]] \n[[\u2603 graded-group 2]] \n[[\u2603 numeric-input 1]] \n[[\u2603 input-number 1]] \n[[\u2603 input-number 2]] \n\n\n\n", + images: { + "https://ka-perseus-graphie.s3.amazonaws.com/74a2b7583a2c26ebfb3ad714e29867541253fc97.png": + { + width: 200, + height: 200, + }, + }, + widgets: { + "numeric-input 1": { + type: "numeric-input", + options: { + static: false, + answers: [ + { + value: 0.3333333333333333, + status: "correct", + message: "", + simplify: "optional", + strict: false, + maxError: 0, + }, + ], + size: "normal", + coefficient: false, + labelText: "", + rightAlign: false, + }, + } as NumericInputWidget, + "input-number 1": { + type: "input-number", + graded: true, + alignment: "default", + options: { + maxError: 0, + inexact: false, + value: 0.3333333333333333, + simplify: "optional", + answerType: "rational", + size: "normal", + }, + } as InputNumberWidget, + "input-number 2": { + type: "input-number", + graded: true, + alignment: "default", + options: { + maxError: 0, + inexact: false, + value: 0.3333333333333333, + simplify: "optional", + answerType: "rational", + size: "normal", + }, + } as InputNumberWidget, + "graded-group 1": { + type: "graded-group", + alignment: "default", + static: false, + graded: true, + options: { + title: "Group 1", + content: + "This is just a cute lil' [[\u2603 input-number 1]] \n[[\u2603 input-number 2]].", + images: {}, + widgets: { + "input-number 1": { + type: "input-number", + graded: true, + alignment: "default", + options: { + maxError: 0, + inexact: false, + value: 0.3333333333333333, + simplify: "optional", + answerType: "rational", + size: "normal", + }, + } as InputNumberWidget, + "input-number 2": { + type: "input-number", + graded: true, + alignment: "default", + options: { + maxError: 0, + inexact: false, + value: 0.3333333333333333, + simplify: "optional", + answerType: "rational", + size: "normal", + }, + } as InputNumberWidget, + }, + }, + } as PerseusWidgetsMap["graded-group 1"], + "graded-group 2": { + type: "graded-group", + alignment: "default", + static: false, + graded: true, + options: { + title: "Group 2", + content: "This is just a cute lil' [[\u2603 input-number 1]].", + images: {}, + widgets: { + "input-number 1": { + type: "input-number", + graded: true, + alignment: "default", + options: { + maxError: 0, + inexact: false, + value: 0.3333333333333333, + simplify: "optional", + answerType: "rational", + size: "normal", + }, + } as InputNumberWidget, + }, + }, + } as PerseusWidgetsMap["graded-group 2"], + "graded-group 3": { + type: "graded-group", + alignment: "default", + static: false, + graded: true, + options: { + title: "Group 3", + content: "This is just a cute lil' [[\u2603 input-number 1]].", + images: {}, + widgets: { + "input-number 1": { + type: "input-number", + graded: true, + alignment: "default", + options: { + maxError: 0, + inexact: false, + value: 0.3333333333333333, + simplify: "optional", + answerType: "rational", + size: "normal", + }, + } as InputNumberWidget, + }, + }, + } as PerseusWidgetsMap["graded-group 3"], + }, +}; + +export const numericInputMultiNested: PerseusRenderer = { + content: + "Denis baked a peach pie and cut it into $3$ equal-sized pieces. \n[[\u2603 graded-group 1]] \n[[\u2603 graded-group 2]] \n[[\u2603 numeric-input 1]] \n[[\u2603 numeric-input 2]] \n[[\u2603 numeric-input 3]] \n\n\n\n", + images: { + "https://ka-perseus-graphie.s3.amazonaws.com/74a2b7583a2c26ebfb3ad714e29867541253fc97.png": + { + width: 200, + height: 200, + }, + }, + widgets: { + "numeric-input 1": { + type: "numeric-input", + options: { + static: false, + answers: [ + { + value: 0.3333333333333333, + status: "correct", + message: "", + simplify: "optional", + strict: false, + maxError: 0, + }, + ], + size: "normal", + coefficient: false, + labelText: "", + rightAlign: false, + }, + } as NumericInputWidget, + "numeric-input 2": { + type: "numeric-input", + options: { + static: false, + answers: [ + { + value: 0.3333333333333333, + status: "correct", + message: "", + simplify: "optional", + strict: false, + maxError: 0, + answerForms: ["proper"], + }, + ], + size: "normal", + coefficient: false, + labelText: "", + rightAlign: false, + }, + } as NumericInputWidget, + "numeric-input 3": { + type: "numeric-input", + options: { + static: false, + answers: [ + { + value: 0.3333333333333333, + status: "correct", + message: "", + simplify: "optional", + strict: false, + maxError: 0, + answerForms: ["proper"], + }, + ], + size: "normal", + coefficient: false, + labelText: "", + rightAlign: false, + }, + } as NumericInputWidget, + "graded-group 1": { + type: "graded-group", + alignment: "default", + static: false, + graded: true, + options: { + title: "Group 1", + content: + "This is just a cute lil' [[\u2603 numeric-input 1]] \n[[\u2603 numeric-input 2]].", + images: {}, + widgets: { + "numeric-input 1": { + type: "numeric-input", + options: { + static: false, + answers: [ + { + value: 0.3333333333333333, + status: "correct", + message: "", + simplify: "optional", + strict: false, + maxError: 0, + answerForms: ["proper"], + }, + ], + size: "normal", + coefficient: false, + labelText: "", + rightAlign: false, + }, + } as NumericInputWidget, + "numeric-input 2": { + type: "numeric-input", + options: { + static: false, + answers: [ + { + value: 0.3333333333333333, + status: "correct", + message: "", + simplify: "optional", + strict: false, + maxError: 0, + answerForms: ["proper"], + }, + ], + size: "normal", + coefficient: false, + labelText: "", + rightAlign: false, + }, + } as NumericInputWidget, + }, + }, + } as PerseusWidgetsMap["graded-group 1"], + "graded-group 2": { + type: "graded-group", + alignment: "default", + static: false, + graded: true, + options: { + title: "Group 2", + content: "This is just a cute lil' [[\u2603 numeric-input 1]].", + images: {}, + widgets: { + "numeric-input 1": { + type: "numeric-input", + options: { + static: false, + answers: [ + { + value: 0.3333333333333333, + status: "correct", + message: "", + simplify: "optional", + strict: false, + maxError: 0, + answerForms: ["proper"], + }, + ], + size: "normal", + coefficient: false, + labelText: "", + rightAlign: false, + }, + } as NumericInputWidget, + }, + }, + } as PerseusWidgetsMap["graded-group 2"], + "graded-group 3": { + type: "graded-group", + alignment: "default", + static: false, + graded: true, + options: { + title: "Group 3", + content: "This is just a cute lil' [[\u2603 numeric-input 1]].", + images: {}, + widgets: { + "numeric-input 1": { + type: "numeric-input", + options: { + static: false, + answers: [ + { + value: 0.3333333333333333, + status: "correct", + message: "", + simplify: "optional", + strict: false, + maxError: 0, + answerForms: ["proper"], + }, + ], + size: "normal", + coefficient: false, + labelText: "", + rightAlign: false, + }, + } as NumericInputWidget, + }, + }, + } as PerseusWidgetsMap["graded-group 3"], + }, +}; diff --git a/packages/perseus-editor/src/util/deprecated-widgets/modernize-widgets-utils.ts b/packages/perseus-editor/src/util/deprecated-widgets/modernize-widgets-utils.ts new file mode 100644 index 0000000000..8876e3f715 --- /dev/null +++ b/packages/perseus-editor/src/util/deprecated-widgets/modernize-widgets-utils.ts @@ -0,0 +1,48 @@ +import {inputNumberToNumericInput} from "./input-number"; + +import type {PerseusRenderer} from "@khanacademy/perseus"; + +const widgetRegExes = [/input-number \d+/]; // We can add more regexes here in the future + +// This utils in this file are used to modernize our Perseus JSON structure, +// so that we can convert deprecated widgets to their modern equivalents when +// content creators use the Editor Page to update content containing these widgets. +// Currently, we're only converting input-number to numeric-input, +// but we can add more conversions here in the future. +// Modernize the json content of a PerseusRenderer object +// by converting deprecated widgets to their modern equivalents +export const convertDeprecatedWidgets = ( + json: PerseusRenderer, +): PerseusRenderer => { + // If there's no widgets that require conversion, return the original json + if (!conversionRequired(json)) { + return json; + } + + // Currently we're only converting input-number to numeric-input, + // But we can add more conversions here in the future + return inputNumberToNumericInput(json); +}; + +const conversionRequired = (json: PerseusRenderer): boolean => { + // If there's no content, then there's no conversion required + if (!json.content) { + return false; + } + // Check the content string for any top-level input-number widgets + if (widgetRegExes.some((regex) => regex.test(json.content))) { + return true; + } + + // If there's no deprecated widget in the top-level, then check for any nested widgets + for (const key of Object.keys(json.widgets)) { + if (json.widgets[key].options.widgets) { + const nestedJson = json.widgets[key].options; + if (conversionRequired(nestedJson)) { + return true; + } + } + } + + return false; +}; diff --git a/packages/perseus/src/__stories__/server-item-renderer.stories.tsx b/packages/perseus/src/__stories__/server-item-renderer.stories.tsx index d3c92a9fee..e3fd9ecd96 100644 --- a/packages/perseus/src/__stories__/server-item-renderer.stories.tsx +++ b/packages/perseus/src/__stories__/server-item-renderer.stories.tsx @@ -8,7 +8,7 @@ import { itemWithLintingError, labelImageItem, itemWithImages, - itemWithMultipleInputNumbers, + itemWithMultipleNumericInputs, itemWithRadioAndExpressionWidgets, } from "../__testdata__/server-item-renderer.testdata"; import {ServerItemRenderer} from "../server-item-renderer"; @@ -23,7 +23,7 @@ export default { title: "Perseus/Renderers/Server Item Renderer", } as Story; -export const InputNumberItem = (args: StoryArgs): React.ReactElement => { +export const NumericInputItem = (args: StoryArgs): React.ReactElement => { return ; }; @@ -56,7 +56,7 @@ export const InputNumberWithInteractionCallback = ( ): React.ReactElement => { return ( { // We are logging the interaction callback data to the console diff --git a/packages/perseus/src/__testdata__/extract-perseus-data.testdata.ts b/packages/perseus/src/__testdata__/extract-perseus-data.testdata.ts index 0e51260dbf..a67bd1e69c 100644 --- a/packages/perseus/src/__testdata__/extract-perseus-data.testdata.ts +++ b/packages/perseus/src/__testdata__/extract-perseus-data.testdata.ts @@ -84,27 +84,31 @@ export const PerseusItemWithRadioWidget = generateTestPerseusItem({ answer: null, }); -export const PerseusItemWithInputNumber = generateTestPerseusItem({ +export const PerseusItemWithNumericInput = generateTestPerseusItem({ question: { - content: "$6 \\text{ tens}+6 \\text { ones} =$ \n[[☃ input-number 1]]", + content: + "$6 \\text{ tens}+6 \\text { ones} =$ \n[[☃ numeric-input 1]]", images: {} as Record, widgets: { - "input-number 1": { - type: "input-number", - alignment: "default", - static: false, + "numeric-input 1": { + type: "numeric-input", graded: true, options: { - value: 66, - simplify: "required", + static: false, + answers: [ + { + value: 66, + status: "correct", + message: "", + simplify: "required", + strict: true, + maxError: 0, + }, + ], size: "normal", - inexact: false, - maxError: 0.1, - answerType: "number", - }, - version: { - major: 0, - minor: 0, + coefficient: false, + labelText: "", + rightAlign: false, }, }, }, diff --git a/packages/perseus/src/__testdata__/renderer.testdata.ts b/packages/perseus/src/__testdata__/renderer.testdata.ts index 66dde7d706..0972e48ec3 100644 --- a/packages/perseus/src/__testdata__/renderer.testdata.ts +++ b/packages/perseus/src/__testdata__/renderer.testdata.ts @@ -1,7 +1,7 @@ import type { DropdownWidget, ImageWidget, - InputNumberWidget, + NumericInputWidget, PerseusRenderer, } from "../perseus-types"; import type {RenderProps} from "../widgets/radio"; @@ -47,21 +47,59 @@ export const imageWidget: ImageWidget = { version: {major: 0, minor: 0}, }; -export const inputNumberWidget: InputNumberWidget = { +export const numericInputWidget: NumericInputWidget = { version: { major: 0, minor: 0, }, - type: "input-number", + type: "numeric-input", graded: true, alignment: "default", options: { - maxError: 0.1, - inexact: false, - value: 0.3333333333333333, - simplify: "optional", - answerType: "rational", + static: false, + answers: [ + { + value: 0.3333333333333333, + status: "correct", + message: "", + simplify: "optional", + strict: true, + maxError: 0.1, + answerForms: [], + }, + ], + size: "normal", + coefficient: false, + labelText: "", + rightAlign: false, + }, +}; + +export const numericInputWidget2: NumericInputWidget = { + version: { + major: 0, + minor: 0, + }, + type: "numeric-input", + graded: true, + alignment: "default", + options: { + static: false, + answers: [ + { + value: 0.3333333333333333, + status: "correct", + message: "", + simplify: "optional", + strict: true, + maxError: 0.1, + answerForms: ["decimal"], + }, + ], size: "normal", + coefficient: false, + labelText: "", + rightAlign: false, }, }; @@ -74,7 +112,7 @@ export const question1: PerseusRenderer = { export const question2: PerseusRenderer = { content: - "Denis baked a peach pie and cut it into $3$ equal-sized pieces. Denis's dad eats $1$ section of the pie. \n\n**What fraction of the pie did Denis's dad eat?** \n![](https://ka-perseus-graphie.s3.amazonaws.com/74a2b7583a2c26ebfb3ad714e29867541253fc97.png) \n[[\u2603 input-number 1]] \n\n\n\n", + "Denis baked a peach pie and cut it into $3$ equal-sized pieces. Denis's dad eats $1$ section of the pie. \n\n**What fraction of the pie did Denis's dad eat?** \n![](https://ka-perseus-graphie.s3.amazonaws.com/74a2b7583a2c26ebfb3ad714e29867541253fc97.png) \n[[\u2603 numeric-input 1]] \n\n\n\n", images: { "https://ka-perseus-graphie.s3.amazonaws.com/74a2b7583a2c26ebfb3ad714e29867541253fc97.png": { @@ -82,7 +120,7 @@ export const question2: PerseusRenderer = { height: 200, }, }, - widgets: {"input-number 1": inputNumberWidget}, + widgets: {"numeric-input 1": numericInputWidget}, }; export const definitionItem: PerseusRenderer = { diff --git a/packages/perseus/src/__testdata__/server-item-renderer.testdata.ts b/packages/perseus/src/__testdata__/server-item-renderer.testdata.ts index 711af632aa..d9c74cb6e8 100644 --- a/packages/perseus/src/__testdata__/server-item-renderer.testdata.ts +++ b/packages/perseus/src/__testdata__/server-item-renderer.testdata.ts @@ -1,6 +1,5 @@ import { ItemExtras, - type InputNumberWidget, type LabelImageWidget, type PerseusItem, type PerseusRenderer, @@ -13,63 +12,30 @@ import { export const itemWithInput: PerseusItem = { question: { content: - "Enter the number $$-42$$ in the box: [[\u2603 input-number 1]]", + "Enter the number $$-42$$ in the box: [[\u2603 numeric-input 1]]", images: {}, widgets: { - "input-number 1": { - type: "input-number", - graded: true, - options: { - answerType: "number", - value: "-42", - simplify: "required", - size: "normal", - inexact: false, - maxError: 0.1, - }, - } as InputNumberWidget, - }, - }, - hints: [ - {content: "Hint #1", images: {}, widgets: {}}, - {content: "Hint #2", images: {}, widgets: {}}, - {content: "Hint #3", images: {}, widgets: {}}, - ], - answerArea: null, - itemDataVersion: {major: 0, minor: 0}, - answer: null, -}; - -export const itemWithMultipleInputNumbers: PerseusItem = { - question: { - content: - "Enter the number $$1$$ in box one: [[\u2603 input-number 1]] \n\n Enter the number $$2$$ in box two: [[\u2603 input-number 2]]", - images: {}, - widgets: { - "input-number 1": { - type: "input-number", - graded: true, - options: { - answerType: "number", - value: "1", - simplify: "required", - size: "normal", - inexact: false, - maxError: 0.1, - }, - } as InputNumberWidget, - "input-number 2": { - type: "input-number", + "numeric-input 1": { + type: "numeric-input", graded: true, options: { - answerType: "number", - value: "2", - simplify: "required", + static: false, + answers: [ + { + value: -42, + status: "correct", + message: "", + simplify: "required", + strict: true, + maxError: 0.1, + }, + ], size: "normal", - inexact: false, - maxError: 0.1, + coefficient: false, + labelText: "", + rightAlign: false, }, - } as InputNumberWidget, + } as NumericInputWidget, }, }, hints: [ @@ -82,45 +48,53 @@ export const itemWithMultipleInputNumbers: PerseusItem = { answer: null, }; -export const itemWithNumericAndNumberInputs: PerseusItem = { +export const itemWithMultipleNumericInputs: PerseusItem = { question: { content: - "Enter the number $$1$$ in box one: [[\u2603 input-number 1]] \n\n Enter the number $$2$$ in box two: [[\u2603 numeric-input 1]]", + "Enter the number $$1$$ in box one: [[\u2603 numeric-input 1]] \n\n Enter the number $$2$$ in box two: [[\u2603 numeric-input 2]]", images: {}, widgets: { - "input-number 1": { - type: "input-number", + "numeric-input 1": { + type: "numeric-input", graded: true, options: { - answerType: "number", - value: "1", - simplify: "required", + static: false, + answers: [ + { + value: 1, + status: "correct", + message: "", + simplify: "required", + strict: true, + maxError: 0.1, + }, + ], size: "normal", - inexact: false, - maxError: 0.1, + coefficient: false, + labelText: "", + rightAlign: false, }, - } as InputNumberWidget, - "numeric-input 1": { - graded: true, - static: false, + } as NumericInputWidget, + "numeric-input 2": { type: "numeric-input", + graded: true, options: { - coefficient: false, static: false, answers: [ { + value: 2, status: "correct", - maxError: null, - strict: false, - value: 1252, - simplify: "required", message: "", + simplify: "required", + strict: true, + maxError: 0.1, }, ], - labelText: "", size: "normal", + coefficient: false, + labelText: "", + rightAlign: false, }, - alignment: "default", } as NumericInputWidget, }, }, diff --git a/packages/perseus/src/__tests__/__snapshots__/server-item-renderer.test.tsx.snap b/packages/perseus/src/__tests__/__snapshots__/server-item-renderer.test.tsx.snap index 08232c08ee..da69cf654c 100644 --- a/packages/perseus/src/__tests__/__snapshots__/server-item-renderer.test.tsx.snap +++ b/packages/perseus/src/__tests__/__snapshots__/server-item-renderer.test.tsx.snap @@ -42,11 +42,12 @@ exports[`server item renderer should snapshot: initial render 1`] = `
@@ -73,7 +74,7 @@ exports[`server item renderer should snapshot: initial render 1`] = ` style="position: relative; top: 0px; left: 0px; border: 1px solid #ccc; box-shadow: 0 1px 3px #ccc; z-index: 9;" >
  • - an + a - exact + simplified proper - decimal, like + fraction, like @@ -115,7 +116,7 @@ exports[`server item renderer should snapshot: initial render 1`] = ` - 0.75 + 3/5 @@ -123,7 +124,7 @@ exports[`server item renderer should snapshot: initial render 1`] = `
  • a - simplified proper + simplified improper fraction, like - 3/5 + 7/4
  • - a + a mixed number, like + + + + 1\\ 3/4 + + + +
  • +
  • + an - simplified improper + exact - fraction, like + decimal, like @@ -151,13 +166,13 @@ exports[`server item renderer should snapshot: initial render 1`] = ` - 7/4 + 0.75
  • - a mixed number, like + a multiple of pi, like @@ -165,7 +180,19 @@ exports[`server item renderer should snapshot: initial render 1`] = ` - 1\\ 3/4 + 12\\ \\text{pi} + + + + or + + + + 2/3\\ \\text{pi} diff --git a/packages/perseus/src/__tests__/extract-perseus-data.test.ts b/packages/perseus/src/__tests__/extract-perseus-data.test.ts index 9788dbb8b2..7d155af0ee 100644 --- a/packages/perseus/src/__tests__/extract-perseus-data.test.ts +++ b/packages/perseus/src/__tests__/extract-perseus-data.test.ts @@ -1,8 +1,8 @@ import {describe, it, expect} from "@jest/globals"; -import {InputNumber, Radio} from ".."; +import {Radio, NumericInput} from ".."; import { - PerseusItemWithInputNumber, + PerseusItemWithNumericInput, PerseusItemWithRadioWidget, } from "../__testdata__/extract-perseus-data.testdata"; import { @@ -99,16 +99,28 @@ describe("ExtractPerseusData", () => { `); }); - it("should get the answer from a input-number widget", () => { + it("should get the answer from a numeric-input widget", () => { const widget = { - type: "input-number", + type: "numeric-input", options: { - value: 42, - simplify: "required", + static: false, + answers: [ + { + value: 42, + status: "correct", + message: "", + simplify: "required", + strict: true, + maxError: 0.1, + }, + ], size: "normal", + coefficient: false, + labelText: "", + rightAlign: false, }, } as const; - const answer = getAnswersFromWidgets({"input-number 1": widget}); + const answer = getAnswersFromWidgets({"numeric-input 1": widget}); expect(answer).toEqual(["42"]); }); @@ -193,12 +205,24 @@ describe("ExtractPerseusData", () => { ], }, }, - "input-number 1": { - type: "input-number", + "numeric-input 1": { + type: "numeric-input", options: { - value: 42, - simplify: "required", + static: false, + answers: [ + { + value: 42, + status: "correct", + message: "", + simplify: "required", + strict: true, + maxError: 0.1, + }, + ], size: "normal", + coefficient: false, + labelText: "", + rightAlign: false, }, }, }, @@ -999,12 +1023,24 @@ describe("ExtractPerseusData", () => { static: false, }, }, - "input-number 1": { - type: "input-number", + "numeric-input 2": { + type: "numeric-input", options: { - value: 42, - simplify: "required", + static: false, + answers: [ + { + value: 42, + status: "correct", + message: "", + simplify: "required", + strict: true, + maxError: 0.1, + }, + ], size: "normal", + coefficient: false, + labelText: "", + rightAlign: false, }, }, "expression 1": { @@ -1026,11 +1062,11 @@ describe("ExtractPerseusData", () => { }, } as const; const content = injectWidgets( - "Enter your numeric-input [[☃ numeric-input 1]], Enter your input-number [[☃ input-number 1]], Enter your expression [[☃ expression 1]]", + "Enter your numeric-input [[☃ numeric-input 1]], Enter your numeric-input [[☃ numeric-input 2]], Enter your expression [[☃ expression 1]]", widgets, ); expect(content).toEqual( - "Enter your numeric-input ?, Enter your input-number ?, Enter your expression ?", + "Enter your numeric-input ?, Enter your numeric-input ?, Enter your expression ?", ); }); @@ -1118,11 +1154,11 @@ describe("ExtractPerseusData", () => { ).toBeUndefined(); }); it("returns a correct answer if the widget type supports one correct answer", () => { - stub.mockReturnValue(InputNumber.widget); + stub.mockReturnValue(NumericInput.widget); expect( getCorrectAnswerForWidgetId( - "input-number 1", - PerseusItemWithInputNumber, + "numeric-input 1", + PerseusItemWithNumericInput, ), ).toEqual("66"); }); @@ -1135,14 +1171,14 @@ describe("ExtractPerseusData", () => { ).toBe(true); expect( isWidgetIdInContent( - PerseusItemWithInputNumber, - "input-number 1", + PerseusItemWithNumericInput, + "numeric-input 1", ), ).toBe(true); }); it("returns false if the widget ID is NOT in the content", () => { expect( - isWidgetIdInContent(PerseusItemWithInputNumber, "not-found"), + isWidgetIdInContent(PerseusItemWithNumericInput, "not-found"), ).toBe(false); }); }); diff --git a/packages/perseus/src/__tests__/renderability.test.ts b/packages/perseus/src/__tests__/renderability.test.ts index 7226d73d96..c384d94d18 100644 --- a/packages/perseus/src/__tests__/renderability.test.ts +++ b/packages/perseus/src/__tests__/renderability.test.ts @@ -18,21 +18,30 @@ const sampleItemNoWidgets = { hints: [], } as const; -const sampleV0InputNumberItem = { +const sampleV0NumericInputItem = { question: { - content: "[[☃ input-number 1]]", + content: "[[☃ numeric-input 1]]", images: {}, widgets: { - "input-number 1": { - type: "input-number", + "numeric-input 1": { + type: "numeric-input", graded: true, options: { - value: "0", - simplify: "required", + static: false, + answers: [ + { + value: 0, + status: "correct", + message: "", + simplify: "required", + strict: true, + maxError: 0.1, + }, + ], size: "normal", - inexact: false, - maxError: 0.1, - answerType: "number", + coefficient: false, + labelText: "", + rightAlign: false, }, version: { major: 0, @@ -329,7 +338,7 @@ describe("Renderability", () => { it("should be able to render v0 or v1 widgets", () => { const result1 = isItemRenderableByVersion( - sampleV0InputNumberItem, + sampleV0NumericInputItem, PerseusItemVersion, ); const result2 = isItemRenderableByVersion( @@ -376,9 +385,9 @@ describe("Renderability", () => { expect(result).toBe(true); }); - it("should be able to render just an input-number", () => { + it("should be able to render just a numeric-input", () => { const result = isItemRenderableByVersion( - sampleV0InputNumberItem, + sampleV0NumericInputItem, inputOnlyPerseusVersion, ); expect(result).toBe(true); @@ -436,7 +445,7 @@ describe("Renderability", () => { _multi: { sharedContext: { __type: "content", - ...sampleV0InputNumberItem.question, + ...sampleV0NumericInputItem.question, }, questions: [ { @@ -457,7 +466,7 @@ describe("Renderability", () => { _multi: { sharedContext: { __type: "content", - ...sampleV0InputNumberItem.question, + ...sampleV0NumericInputItem.question, }, questions: [ { diff --git a/packages/perseus/src/__tests__/renderer-api.test.tsx b/packages/perseus/src/__tests__/renderer-api.test.tsx index f39e19bea8..103b2ffa36 100644 --- a/packages/perseus/src/__tests__/renderer-api.test.tsx +++ b/packages/perseus/src/__tests__/renderer-api.test.tsx @@ -15,14 +15,14 @@ import {registerAllWidgetsForTesting} from "../util/register-all-widgets-for-tes import {renderQuestion} from "../widgets/__testutils__/renderQuestion"; import imageItem from "./test-items/image-item"; -import inputNumber1Item from "./test-items/input-number-1-item"; -import inputNumber2Item from "./test-items/input-number-2-item"; +import numericInput1Item from "./test-items/numeric-input-1-item"; +import numericInput2Item from "./test-items/numeric-input-2-item"; import tableItem from "./test-items/table-item"; -import type {PerseusInputNumberUserInput} from "../validation.types"; +import type {PerseusNumericInputUserInput} from "../validation.types"; import type {UserEvent} from "@testing-library/user-event"; -const itemWidget = inputNumber1Item; +const itemWidget = numericInput1Item; describe("Perseus API", function () { let userEvent: UserEvent; @@ -41,10 +41,10 @@ describe("Perseus API", function () { describe("setInputValue", function () { it("should be able to produce a correctly graded value", function () { // Arrange - const {renderer} = renderQuestion(inputNumber1Item.question); + const {renderer} = renderQuestion(numericInput1Item.question); // Act - act(() => renderer.setInputValue(["input-number 1"], "5")); + act(() => renderer.setInputValue(["numeric-input 1"], "5")); // Assert expect(renderer).toHaveBeenAnsweredCorrectly(); @@ -52,10 +52,10 @@ describe("Perseus API", function () { it("should be able to produce a wrong value", function () { // Arrange - const {renderer} = renderQuestion(inputNumber1Item.question); + const {renderer} = renderQuestion(numericInput1Item.question); // Act - act(() => renderer.setInputValue(["input-number 1"], "3")); + act(() => renderer.setInputValue(["numeric-input 1"], "3")); // Assert expect(renderer).toHaveBeenAnsweredIncorrectly(); @@ -63,21 +63,21 @@ describe("Perseus API", function () { it("should be able to produce an empty score", function () { // Arrange - const {renderer} = renderQuestion(inputNumber1Item.question); + const {renderer} = renderQuestion(numericInput1Item.question); - act(() => renderer.setInputValue(["input-number 1"], "3")); + act(() => renderer.setInputValue(["numeric-input 1"], "3")); expect(renderer).toHaveBeenAnsweredIncorrectly(); - act(() => renderer.setInputValue(["input-number 1"], "")); + act(() => renderer.setInputValue(["numeric-input 1"], "")); expect(renderer).toHaveInvalidInput(); }); it("should be able to accept a callback", function (done) { - const {renderer} = renderQuestion(inputNumber1Item.question); + const {renderer} = renderQuestion(numericInput1Item.question); act(() => - renderer.setInputValue(["input-number 1"], "3", function () { + renderer.setInputValue(["numeric-input 1"], "3", function () { const guess = - renderer.getUserInput()[0] as PerseusInputNumberUserInput; + renderer.getUserInput()[0] as PerseusNumericInputUserInput; expect(guess?.currentValue).toBe("3"); done(); }), @@ -88,7 +88,7 @@ describe("Perseus API", function () { describe("getInputPaths", function () { it("should be able to find all the input widgets", function () { - const {renderer} = renderQuestion(inputNumber2Item.question); + const {renderer} = renderQuestion(numericInput2Item.question); const numPaths = renderer.getInputPaths().length; expect(numPaths).toBe(2); }); @@ -102,7 +102,7 @@ describe("Perseus API", function () { describe("getDOMNodeForPath", function () { it("should find one DOM node per ", function () { - const {renderer} = renderQuestion(inputNumber2Item.question); + const {renderer} = renderQuestion(numericInput2Item.question); const inputPaths = renderer.getInputPaths(); const allInputs = screen.queryAllByRole("textbox"); @@ -111,7 +111,7 @@ describe("Perseus API", function () { }); it("should find the right DOM nodes for the s", function () { - const {renderer} = renderQuestion(inputNumber2Item.question); + const {renderer} = renderQuestion(numericInput2Item.question); const inputPaths = renderer.getInputPaths(); const allInputs = screen.queryAllByRole("textbox"); @@ -130,13 +130,13 @@ describe("Perseus API", function () { describe("CSS ClassNames", function () { describe("perseus-focused", function () { - it("should be on an input-number exactly when focused", async function () { + it("should be on an numeric-input exactly when focused", async function () { // Feel free to change this if you change the class name, // but if you do, you must up the perseus api [major] // version expect(ClassNames.FOCUSED).toBe("perseus-focused"); - renderQuestion(inputNumber1Item.question); + renderQuestion(numericInput1Item.question); const input = screen.getByRole("textbox"); expect(input).not.toHaveFocus(); @@ -153,7 +153,7 @@ describe("Perseus API", function () { describe("onFocusChange", function () { it("should be called from focused to blurred to back on one input", async function () { const onFocusChange = jest.fn(); - renderQuestion(inputNumber1Item.question, {onFocusChange}); + renderQuestion(numericInput1Item.question, {onFocusChange}); const input = screen.getByRole("textbox"); @@ -164,7 +164,7 @@ describe("Perseus API", function () { // Assert expect(onFocusChange).toHaveBeenCalledTimes(1); expect(onFocusChange).toHaveBeenCalledWith( - ["input-number 1"], + ["numeric-input 1"], null, ); @@ -176,13 +176,13 @@ describe("Perseus API", function () { // Assert expect(onFocusChange).toHaveBeenCalledTimes(1); expect(onFocusChange).toHaveBeenCalledWith(null, [ - "input-number 1", + "numeric-input 1", ]); }); it("should be called focusing between two inputs", async function () { const onFocusChange = jest.fn(); - renderQuestion(inputNumber2Item.question, {onFocusChange}); + renderQuestion(numericInput2Item.question, {onFocusChange}); const inputs = screen.getAllByRole("textbox"); const input1 = inputs[0]; @@ -197,8 +197,8 @@ describe("Perseus API", function () { expect(onFocusChange).toHaveBeenCalledTimes(1); expect(onFocusChange).toHaveBeenCalledWith( - ["input-number 2"], - ["input-number 1"], + ["numeric-input 2"], + ["numeric-input 1"], ); }); }); diff --git a/packages/perseus/src/__tests__/renderer.test.tsx b/packages/perseus/src/__tests__/renderer.test.tsx index bf875448e3..211ba1b73c 100644 --- a/packages/perseus/src/__tests__/renderer.test.tsx +++ b/packages/perseus/src/__tests__/renderer.test.tsx @@ -9,18 +9,19 @@ import {testDependencies} from "../../../../testing/test-dependencies"; import { dropdownWidget, imageWidget, - inputNumberWidget, + numericInputWidget, question1, question2, definitionItem, mockedRandomItem, mockedShuffledRadioProps, + numericInputWidget2, } from "../__testdata__/renderer.testdata"; import * as Dependencies from "../dependencies"; import {registerWidget} from "../widgets"; import {renderQuestion} from "../widgets/__testutils__/renderQuestion"; import {simpleGroupQuestion} from "../widgets/group/group.testdata"; -import InputNumberExport from "../widgets/input-number"; +import InputNumberExport from "../widgets/numeric-input"; import RadioWidgetExport from "../widgets/radio"; import type {DropdownWidget} from "../perseus-types"; @@ -46,7 +47,7 @@ jest.mock("../translation-linter", () => { describe("renderer", () => { beforeAll(() => { - registerWidget("input-number", InputNumberExport); + registerWidget("numeric-input", InputNumberExport); registerWidget("radio", RadioWidgetExport); }); @@ -840,11 +841,11 @@ describe("renderer", () => { // Arrange const question = { content: - "A dropdown [[☃ dropdown 1]]\nAn input [[☃ input-number 1]]\n\nAnd an image [[☃ image 1]].", + "A dropdown [[☃ dropdown 1]]\nAn input [[☃ numeric-input 1]]\n\nAnd an image [[☃ image 1]].", images: {}, widgets: { "dropdown 1": dropdownWidget, - "input-number 1": inputNumberWidget, + "numeric-input 1": numericInputWidget, "image 1": imageWidget, }, } as const; @@ -906,11 +907,11 @@ describe("renderer", () => { { ...question2, content: - "Enter 1 in this field: [[☃ input-number 1]].\n\n" + - "Enter 2 in this field: [[☃ input-number 2]] $60$.", + "Enter 1 in this field: [[☃ numeric-input 1]].\n\n" + + "Enter 2 in this field: [[☃ numeric-input 2]] $60$.", widgets: { - "input-number 1": question2.widgets["input-number 1"], - "input-number 2": question2.widgets["input-number 1"], + "numeric-input 1": question2.widgets["numeric-input 1"], + "numeric-input 2": question2.widgets["numeric-input 1"], }, }, {onFocusChange}, @@ -921,7 +922,7 @@ describe("renderer", () => { // Assert expect(onFocusChange).toHaveBeenCalledWith( - /* new focus path */ ["input-number 2"], + /* new focus path */ ["numeric-input 2"], /* old focus path */ null, ); }); @@ -933,11 +934,11 @@ describe("renderer", () => { { ...question2, content: - "Enter 1 in this field: [[☃ input-number 1]].\n\n" + - "Enter 2 in this field: [[☃ input-number 2]] $60$.", + "Enter 1 in this field: [[☃ numeric-input 1]].\n\n" + + "Enter 2 in this field: [[☃ numeric-input 2]] $60$.", widgets: { - "input-number 1": question2.widgets["input-number 1"], - "input-number 2": question2.widgets["input-number 1"], + "numeric-input 1": question2.widgets["numeric-input 1"], + "numeric-input 2": question2.widgets["numeric-input 1"], }, }, {onFocusChange}, @@ -953,7 +954,7 @@ describe("renderer", () => { // Assert expect(onFocusChange).toHaveBeenCalledWith( /* new focus path */ null, - /* old focus path */ ["input-number 2"], + /* old focus path */ ["numeric-input 2"], ); }); @@ -976,7 +977,7 @@ describe("renderer", () => { const {renderer} = renderQuestion(question2); // Act - act(() => renderer.focusPath(["input-number 1"])); + act(() => renderer.focusPath(["numeric-input 1"])); // Assert expect(screen.getByRole("textbox")).toHaveFocus(); @@ -988,11 +989,11 @@ describe("renderer", () => { const {renderer} = renderQuestion(question2, { onFocusChange, }); - act(() => renderer.focusPath(["input-number 1"])); + act(() => renderer.focusPath(["numeric-input 1"])); onFocusChange.mockClear(); // Act - act(() => renderer.focusPath(["input-number 1"])); + act(() => renderer.focusPath(["numeric-input 1"])); // Assert expect(onFocusChange).not.toHaveBeenCalled(); @@ -1005,25 +1006,25 @@ describe("renderer", () => { { ...question2, content: - "Input 1: [[☃ input-number 1]]\n\n" + - "Input 2: [[☃ input-number 2]]", + "Input 1: [[☃ numeric-input 1]]\n\n" + + "Input 2: [[☃ numeric-input 2]]", widgets: { ...question2.widgets, - "input-number 2": question2.widgets["input-number 1"], + "numeric-input 2": question2.widgets["numeric-input 1"], }, }, {onFocusChange}, ); - act(() => renderer.focusPath(["input-number 1"])); + act(() => renderer.focusPath(["numeric-input 1"])); onFocusChange.mockClear(); // Act - act(() => renderer.focusPath(["input-number 2"])); + act(() => renderer.focusPath(["numeric-input 2"])); // Assert expect(onFocusChange).toHaveBeenCalledWith( - ["input-number 2"], // New focus - ["input-number 1"], // Old focus + ["numeric-input 2"], // New focus + ["numeric-input 1"], // Old focus ); }); @@ -1034,11 +1035,11 @@ describe("renderer", () => { { ...question2, content: - "Input 1: [[☃ input-number 1]]\n\n" + - "Input 2: [[☃ input-number 2]]", + "Input 1: [[☃ numeric-input 1]]\n\n" + + "Input 2: [[☃ numeric-input 2]]", widgets: { ...question2.widgets, - "input-number 2": question2.widgets["input-number 1"], + "numeric-input 2": question2.widgets["numeric-input 1"], }, }, {onFocusChange}, @@ -1048,7 +1049,7 @@ describe("renderer", () => { onFocusChange.mockClear(); // Act - act(() => renderer.blurPath(["input-number 1"])); + act(() => renderer.blurPath(["numeric-input 1"])); // Assert expect(onFocusChange).not.toHaveBeenCalled(); @@ -1061,11 +1062,11 @@ describe("renderer", () => { { ...question2, content: - "Input 1: [[☃ input-number 1]]\n\n" + - "Input 2: [[☃ input-number 2]]", + "Input 1: [[☃ numeric-input 1]]\n\n" + + "Input 2: [[☃ numeric-input 2]]", widgets: { ...question2.widgets, - "input-number 2": question2.widgets["input-number 1"], + "numeric-input 2": question2.widgets["numeric-input 1"], }, }, {onFocusChange}, @@ -1081,7 +1082,7 @@ describe("renderer", () => { // Assert expect(onFocusChange).toHaveBeenCalledWith( null, // New focus - ["input-number 2"], // Old focus + ["numeric-input 2"], // Old focus ); }); @@ -1092,11 +1093,11 @@ describe("renderer", () => { { ...question2, content: - "Input 1: [[☃ input-number 1]]\n\n" + - "Input 2: [[☃ input-number 2]]", + "Input 1: [[☃ numeric-input 1]]\n\n" + + "Input 2: [[☃ numeric-input 2]]", widgets: { ...question2.widgets, - "input-number 2": question2.widgets["input-number 1"], + "numeric-input 2": question2.widgets["numeric-input 1"], }, }, {onFocusChange}, @@ -1401,13 +1402,13 @@ describe("renderer", () => { const {renderer} = renderQuestion({ ...question2, content: - "Input 1: [[☃ input-number 1]]\n\n" + - "Input 2: [[☃ input-number 2]]\n\n" + + "Input 1: [[☃ numeric-input 1]]\n\n" + + "Input 2: [[☃ numeric-input 2]]\n\n" + "A widget that doesn't implement getUserInput: [[☃ image 1]]", widgets: { ...question2.widgets, - "input-number 2": { - ...question2.widgets["input-number 1"], + "numeric-input 2": { + ...question2.widgets["numeric-input 1"], static: true, }, "image 1": { @@ -1445,13 +1446,13 @@ describe("renderer", () => { const {renderer} = renderQuestion({ ...question2, content: - "Input 1: [[☃ input-number 1]]\n\n" + - "Input 2: [[☃ input-number 2]]\n\n" + + "Input 1: [[☃ numeric-input 1]]\n\n" + + "Input 2: [[☃ numeric-input 2]]\n\n" + "A widget that doesn't implement getUserInput: [[☃ image 1]]", widgets: { ...question2.widgets, - "input-number 2": { - ...question2.widgets["input-number 1"], + "numeric-input 2": { + ...question2.widgets["numeric-input 1"], static: true, }, "image 1": { @@ -1473,8 +1474,8 @@ describe("renderer", () => { // Assert expect(widgetIds).toStrictEqual([ - "input-number 1", - "input-number 2", + "numeric-input 1", + "numeric-input 2", "image 1", ]); }); @@ -1564,11 +1565,11 @@ describe("renderer", () => { const {renderer} = renderQuestion({ ...question2, content: - "Input 1: [[☃ input-number 1]]\n\n" + - "Input 2: [[☃ input-number 2]]", + "Input 1: [[☃ numeric-input 1]]\n\n" + + "Input 2: [[☃ numeric-input 2]]", widgets: { ...question2.widgets, - "input-number 2": question2.widgets["input-number 1"], + "numeric-input 2": question2.widgets["numeric-input 1"], }, }); await userEvent.type(screen.getAllByRole("textbox")[0], "150"); @@ -1577,7 +1578,7 @@ describe("renderer", () => { const emptyWidgets = renderer.emptyWidgets(); // Assert - expect(emptyWidgets).toStrictEqual(["input-number 2"]); + expect(emptyWidgets).toStrictEqual(["numeric-input 2"]); }); it("should not return static widgets even if empty", () => { @@ -1585,12 +1586,12 @@ describe("renderer", () => { const {renderer} = renderQuestion({ ...question2, content: - "Input 1: [[☃ input-number 1]]\n\n" + - "Input 2: [[☃ input-number 2]]", + "Input 1: [[☃ numeric-input 1]]\n\n" + + "Input 2: [[☃ numeric-input 2]]", widgets: { ...question2.widgets, - "input-number 2": { - ...question2.widgets["input-number 1"], + "numeric-input 2": { + ...question2.widgets["numeric-input 1"], static: true, }, }, @@ -1600,7 +1601,7 @@ describe("renderer", () => { const emptyWidgets = renderer.emptyWidgets(); // Assert - expect(emptyWidgets).toStrictEqual(["input-number 1"]); + expect(emptyWidgets).toStrictEqual(["numeric-input 1"]); }); it("should return widget ID for group with empty widget", () => { @@ -1650,12 +1651,12 @@ describe("renderer", () => { const {renderer} = renderQuestion({ ...question2, content: - "Input 1: [[☃ input-number 1]]\n\n" + - "Input 2: [[☃ input-number 2]]", + "Input 1: [[☃ numeric-input 1]]\n\n" + + "Input 2: [[☃ numeric-input 2]]", widgets: { ...question2.widgets, - "input-number 2": { - ...question2.widgets["input-number 1"], + "numeric-input 2": { + ...question2.widgets["numeric-input 1"], static: true, }, }, @@ -1663,7 +1664,7 @@ describe("renderer", () => { const cb = jest.fn(); // Act - act(() => renderer.setInputValue(["input-number 2"], "1000", cb)); + act(() => renderer.setInputValue(["numeric-input 2"], "1000", cb)); // Assert expect(screen.getAllByRole("textbox")[0]).toHaveValue(""); @@ -1675,12 +1676,12 @@ describe("renderer", () => { const {renderer} = renderQuestion({ ...question2, content: - "Input 1: [[☃ input-number 1]]\n\n" + - "Input 2: [[☃ input-number 2]]", + "Input 1: [[☃ numeric-input 1]]\n\n" + + "Input 2: [[☃ numeric-input 2]]", widgets: { ...question2.widgets, - "input-number 2": { - ...question2.widgets["input-number 1"], + "numeric-input 2": { + ...question2.widgets["numeric-input 1"], static: true, }, }, @@ -1688,7 +1689,7 @@ describe("renderer", () => { const cb = jest.fn(); // Act - act(() => renderer.setInputValue(["input-number 2"], "1000", cb)); + act(() => renderer.setInputValue(["numeric-input 2"], "1000", cb)); act(() => jest.runOnlyPendingTimers()); // Assert @@ -1701,14 +1702,14 @@ describe("renderer", () => { // Arrange const {renderer} = renderQuestion({ content: - "Input widget: [[\u2603 input-number 1]]\n\n" + + "Input widget: [[\u2603 numeric-input 1]]\n\n" + "Dropdown widget: [[\u2603 dropdown 1]]\n\n" + "Image widget (won't have user input): [[\u2603 image 1]]\n\n" + - "Another input widget: [[\u2603 input-number 2]]", + "Another input widget: [[\u2603 numeric-input 2]]", widgets: { "image 1": imageWidget, - "input-number 1": inputNumberWidget, - "input-number 2": inputNumberWidget, + "numeric-input 1": numericInputWidget, + "numeric-input 2": numericInputWidget, "dropdown 1": dropdownWidget, }, images: {}, @@ -1732,10 +1733,10 @@ describe("renderer", () => { "dropdown 1": { "value": 1, }, - "input-number 1": { + "numeric-input 1": { "currentValue": "100", }, - "input-number 2": { + "numeric-input 2": { "currentValue": "200", }, } @@ -1764,14 +1765,14 @@ describe("renderer", () => { // Arrange const {renderer} = renderQuestion({ content: - "Input widget: [[\u2603 input-number 1]]\n\n" + + "Input widget: [[\u2603 numeric-input 1]]\n\n" + "Dropdown widget: [[\u2603 dropdown 1]]\n\n" + "Image widget (won't have user input): [[\u2603 image 1]]\n\n" + - "Another input widget: [[\u2603 input-number 2]]", + "Another input widget: [[\u2603 numeric-input 2]]", widgets: { "image 1": imageWidget, - "input-number 1": inputNumberWidget, - "input-number 2": inputNumberWidget, + "numeric-input 1": numericInputWidget, + "numeric-input 2": numericInputWidget, "dropdown 1": dropdownWidget, }, images: {}, @@ -1781,13 +1782,16 @@ describe("renderer", () => { const examples = renderer.examples(); // Assert + expect(examples).toMatchInlineSnapshot(` [ "**Your answer should be** ", "an integer, like $6$", - "a *proper* fraction, like $1/2$ or $6/10$", - "an *improper* fraction, like $10/7$ or $14/8$", + "a *simplified proper* fraction, like $3/5$", + "a *simplified improper* fraction, like $7/4$", "a mixed number, like $1\\ 3/4$", + "an *exact* decimal, like $0.75$", + "a multiple of pi, like $12\\ \\text{pi}$ or $2/3\\ \\text{pi}$", ] `); }); @@ -1800,18 +1804,17 @@ describe("renderer", () => { // Arrange const {renderer} = renderQuestion({ content: - "Input widget: [[\u2603 input-number 1]]\n\n" + + "Input widget: [[\u2603 numeric-input 1]]\n\n" + "Dropdown widget: [[\u2603 dropdown 1]]\n\n" + "Image widget (won't have user input): [[\u2603 image 1]]\n\n" + - "Another input widget: [[\u2603 input-number 2]]", + "Another input widget: [[\u2603 numeric-input 2]]", widgets: { "image 1": imageWidget, - "input-number 1": inputNumberWidget, - "input-number 2": { - ...inputNumberWidget, + "numeric-input 1": numericInputWidget, + "numeric-input 2": { + ...numericInputWidget2, options: { - ...inputNumberWidget.options, - answerType: "percent", + ...numericInputWidget2.options, }, }, "dropdown 1": dropdownWidget, diff --git a/packages/perseus/src/__tests__/server-item-renderer.test.tsx b/packages/perseus/src/__tests__/server-item-renderer.test.tsx index 9ef6c86317..3b7cd0a1d1 100644 --- a/packages/perseus/src/__tests__/server-item-renderer.test.tsx +++ b/packages/perseus/src/__tests__/server-item-renderer.test.tsx @@ -10,7 +10,7 @@ import { import { itemWithInput, itemWithLintingError, - itemWithNumericAndNumberInputs, + itemWithMultipleNumericInputs, itemWithRadioAndExpressionWidgets, definitionItem, } from "../__testdata__/server-item-renderer.testdata"; @@ -19,7 +19,7 @@ import WrappedServerItemRenderer, { ServerItemRenderer, } from "../server-item-renderer"; import {registerWidget} from "../widgets"; -import InputNumberExport from "../widgets/input-number/input-number"; +import NumericInputExport from "../widgets/numeric-input"; import RadioWidgetExport from "../widgets/radio"; import MockAssetLoadingWidgetExport, { @@ -68,7 +68,7 @@ const renderQuestion = ( describe("server item renderer", () => { beforeAll(() => { - registerWidget("input-number", InputNumberExport); + registerWidget("numeric-input", NumericInputExport); registerWidget("radio", RadioWidgetExport); }); @@ -154,7 +154,7 @@ describe("server item renderer", () => { it("calls onInteraction callback with the current user data", async () => { // Arrange const interactionCallback = jest.fn(); - renderQuestion(itemWithNumericAndNumberInputs, { + renderQuestion(itemWithMultipleNumericInputs, { interactionCallback, }); @@ -166,8 +166,8 @@ describe("server item renderer", () => { // Assert expect(interactionCallback).toHaveBeenCalledWith({ - "input-number 1": {currentValue: "1"}, - "numeric-input 1": {currentValue: "2"}, + "numeric-input 1": {currentValue: "1"}, + "numeric-input 2": {currentValue: "2"}, }); }); @@ -176,7 +176,7 @@ describe("server item renderer", () => { const {renderer} = renderQuestion(itemWithInput); // Act - const node = renderer.getDOMNodeForPath(["input-number 1"]); + const node = renderer.getDOMNodeForPath(["numeric-input 1"]); // Assert // @ts-expect-error - TS2345 - Argument of type 'Element | Text | null | undefined' is not assignable to parameter of type 'HTMLElement'. @@ -348,7 +348,7 @@ describe("server item renderer", () => { // Assert expect(gotFocus).toBe(true); expect(onFocusChange).toHaveBeenCalledWith( - ["input-number 1"], + ["numeric-input 1"], null, 0, expect.any(Object), @@ -393,7 +393,7 @@ describe("server item renderer", () => { expect(keypadElement.activate).toHaveBeenCalled(); expect(gotFocus).toBe(true); expect(onFocusChange).toHaveBeenCalledWith( - ["input-number 1"], + ["numeric-input 1"], null, 250, expect.any(Object), @@ -418,7 +418,7 @@ describe("server item renderer", () => { expect(onFocusChange).toHaveBeenCalledTimes(2); expect(onFocusChange).toHaveBeenLastCalledWith( null, - ["input-number 1"], + ["numeric-input 1"], 0, null, ); @@ -464,7 +464,7 @@ describe("server item renderer", () => { expect(onFocusChange).toHaveBeenCalledTimes(2); expect(onFocusChange).toHaveBeenLastCalledWith( null, - ["input-number 1"], + ["numeric-input 1"], 0, null, ); @@ -478,14 +478,14 @@ describe("server item renderer", () => { }); // Act - act(() => renderer.focusPath(["input-number 1"])); + act(() => renderer.focusPath(["numeric-input 1"])); // We have some async processes that need to be resolved here jest.runAllTimers(); // Assert expect(onFocusChange).toHaveBeenCalledWith( - ["input-number 1"], + ["numeric-input 1"], null, 0, expect.any(Object), @@ -518,12 +518,14 @@ describe("server item renderer", () => { {}, ], "question": { - "input-number 1": { - "answerType": "number", + "numeric-input 1": { + "answerForms": [], + "coefficient": false, "currentValue": "-42", - "rightAlign": undefined, - "simplify": "required", + "labelText": "", + "rightAlign": false, "size": "normal", + "static": false, }, }, } @@ -541,7 +543,7 @@ describe("server item renderer", () => { { hints: [{}, {}, {}], question: { - "input-number 1": { + "numeric-input 1": { answerType: "number", currentValue: "-42", rightAlign: undefined, diff --git a/packages/perseus/src/__tests__/test-items/input-number-1-item.ts b/packages/perseus/src/__tests__/test-items/input-number-1-item.ts deleted file mode 100644 index 1e0e893519..0000000000 --- a/packages/perseus/src/__tests__/test-items/input-number-1-item.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type {PerseusRenderer} from "../../perseus-types"; - -export default { - question: { - content: "[[☃ input-number 1]]", - images: {}, - widgets: { - "input-number 1": { - type: "input-number", - graded: true, - options: { - value: 5, - simplify: "required", - size: "normal", - inexact: false, - maxError: 0.1, - answerType: "number", - }, - }, - }, - } as PerseusRenderer, - answerArea: { - calculator: false, - }, - hints: [] as ReadonlyArray, -}; diff --git a/packages/perseus/src/__tests__/test-items/input-number-2-item.ts b/packages/perseus/src/__tests__/test-items/input-number-2-item.ts deleted file mode 100644 index 24eaecbee0..0000000000 --- a/packages/perseus/src/__tests__/test-items/input-number-2-item.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type {PerseusRenderer} from "../../perseus-types"; - -export default { - question: { - content: "[[☃ input-number 1]] [[☃ input-number 2]]", - images: {}, - widgets: { - "input-number 1": { - type: "input-number", - graded: true, - options: { - value: 5, - simplify: "required", - size: "normal", - inexact: false, - maxError: 0.1, - answerType: "number", - }, - }, - "input-number 2": { - type: "input-number", - graded: true, - options: { - value: 6, - simplify: "required", - size: "normal", - inexact: false, - maxError: 0.1, - answerType: "number", - }, - }, - }, - } as PerseusRenderer, - answerArea: { - calculator: false, - }, - hints: [] as ReadonlyArray, -}; diff --git a/packages/perseus/src/__tests__/test-items/numeric-input-1-item.ts b/packages/perseus/src/__tests__/test-items/numeric-input-1-item.ts new file mode 100644 index 0000000000..4306c6b1b1 --- /dev/null +++ b/packages/perseus/src/__tests__/test-items/numeric-input-1-item.ts @@ -0,0 +1,35 @@ +import type {PerseusRenderer} from "../../perseus-types"; + +export default { + question: { + content: "[[☃ numeric-input 1]]", + images: {}, + widgets: { + "numeric-input 1": { + type: "numeric-input", + graded: true, + options: { + static: false, + answers: [ + { + value: 5, + status: "correct", + message: "", + simplify: "required", + strict: true, + maxError: 0.1, + }, + ], + size: "normal", + coefficient: false, + labelText: "", + rightAlign: false, + }, + }, + }, + } as PerseusRenderer, + answerArea: { + calculator: false, + }, + hints: [] as ReadonlyArray, +}; diff --git a/packages/perseus/src/__tests__/test-items/numeric-input-2-item.ts b/packages/perseus/src/__tests__/test-items/numeric-input-2-item.ts new file mode 100644 index 0000000000..2d7d0dfbe8 --- /dev/null +++ b/packages/perseus/src/__tests__/test-items/numeric-input-2-item.ts @@ -0,0 +1,56 @@ +import type {PerseusRenderer} from "../../perseus-types"; + +export default { + question: { + content: "[[☃ numeric-input 1]] [[☃ numeric-input 2]]", + images: {}, + widgets: { + "numeric-input 1": { + type: "numeric-input", + graded: true, + options: { + static: false, + answers: [ + { + value: 5, + status: "correct", + message: "", + simplify: "required", + strict: true, + maxError: 0.1, + }, + ], + size: "normal", + coefficient: false, + labelText: "", + rightAlign: false, + }, + }, + "numeric-input 2": { + type: "numeric-input", + graded: true, + options: { + static: false, + answers: [ + { + value: 6, + status: "correct", + message: "", + simplify: "required", + strict: true, + maxError: 0.1, + }, + ], + size: "normal", + coefficient: false, + labelText: "", + rightAlign: false, + }, + }, + }, + } as PerseusRenderer, + answerArea: { + calculator: false, + }, + hints: [] as ReadonlyArray, +}; diff --git a/packages/perseus/src/index.ts b/packages/perseus/src/index.ts index 93b4e91bb4..1571da3bfb 100644 --- a/packages/perseus/src/index.ts +++ b/packages/perseus/src/index.ts @@ -207,8 +207,9 @@ export type { Size, CollinearTuple, MathFormat, - InputNumberWidget, // TODO(jeremy): remove? PerseusArticle, + InputNumberWidget, // Used for usurpation of InputNumberWidget in perseus-editor + NumericInputWidget, // Used for usurpation of InputNumberWidget in perseus-editor // Widget configuration types PerseusImageBackground, PerseusInputNumberWidgetOptions, diff --git a/packages/perseus/src/multi-items/__testdata__/multi-renderer.testdata.ts b/packages/perseus/src/multi-items/__testdata__/multi-renderer.testdata.ts index 0bb6ab4e6b..89d1a52c1a 100644 --- a/packages/perseus/src/multi-items/__testdata__/multi-renderer.testdata.ts +++ b/packages/perseus/src/multi-items/__testdata__/multi-renderer.testdata.ts @@ -47,7 +47,7 @@ export const question1: Item = { question: { __type: "content", content: - "Triangle $ABC$ has side lengths of $12$, $14$, and $20$. Which of the following triangles is congruent to triangle $ABC$ ?\n\n[[☃ radio 1]]\n\nEnter the number 3 into this field: [[☃ input-number 1]]", + "Triangle $ABC$ has side lengths of $12$, $14$, and $20$. Which of the following triangles is congruent to triangle $ABC$ ?\n\n[[☃ radio 1]]\n\nEnter the number 3 into this field: [[☃ numeric-input 1]]", widgets: { "radio 1": { alignment: "default", @@ -102,16 +102,25 @@ export const question1: Item = { minor: 0, }, }, - "input-number 1": { - type: "input-number", + "numeric-input 1": { + type: "numeric-input", graded: true, options: { - answerType: "number", - value: "-42", - simplify: "required", + static: false, + answers: [ + { + value: -42, + status: "correct", + message: "", + simplify: "required", + strict: true, + maxError: 0.1, + }, + ], size: "normal", - inexact: false, - maxError: 0.1, + coefficient: false, + labelText: "", + rightAlign: false, }, }, }, diff --git a/packages/perseus/src/multi-items/__tests__/__snapshots__/multi-renderer.test.tsx.snap b/packages/perseus/src/multi-items/__tests__/__snapshots__/multi-renderer.test.tsx.snap index 078e779ff6..dd92cd9966 100644 --- a/packages/perseus/src/multi-items/__tests__/__snapshots__/multi-renderer.test.tsx.snap +++ b/packages/perseus/src/multi-items/__tests__/__snapshots__/multi-renderer.test.tsx.snap @@ -976,11 +976,12 @@ exports[`multi-item renderer should snapshot: initial render 1`] = `
    @@ -1007,7 +1008,7 @@ exports[`multi-item renderer should snapshot: initial render 1`] = ` style="position: relative; top: 0px; left: 0px; border: 1px solid #ccc; box-shadow: 0 1px 3px #ccc; z-index: 9;" >
  • - an + a - exact + simplified proper - decimal, like + fraction, like @@ -1049,7 +1050,7 @@ exports[`multi-item renderer should snapshot: initial render 1`] = ` - 0.75 + 3/5 @@ -1057,7 +1058,7 @@ exports[`multi-item renderer should snapshot: initial render 1`] = `
  • a - simplified proper + simplified improper fraction, like - 3/5 + 7/4
  • - a + a mixed number, like + + + + 1\\ 3/4 + + + +
  • +
  • + an - simplified improper + exact - fraction, like + decimal, like @@ -1085,13 +1100,13 @@ exports[`multi-item renderer should snapshot: initial render 1`] = ` - 7/4 + 0.75
  • - a mixed number, like + a multiple of pi, like @@ -1099,7 +1114,19 @@ exports[`multi-item renderer should snapshot: initial render 1`] = ` - 1\\ 3/4 + 12\\ \\text{pi} + + + + or + + + + 2/3\\ \\text{pi} diff --git a/packages/perseus/src/multi-items/__tests__/multi-renderer.test.tsx b/packages/perseus/src/multi-items/__tests__/multi-renderer.test.tsx index 11bd727b6c..db92ceaad6 100644 --- a/packages/perseus/src/multi-items/__tests__/multi-renderer.test.tsx +++ b/packages/perseus/src/multi-items/__tests__/multi-renderer.test.tsx @@ -142,9 +142,9 @@ describe("multi-item renderer", () => { // Nudge the widget to a non-default state (ie. an item is // selected, a value is entered). You can see the result of this in the `choiceStates` // array in the captured state below where the choice at index 2 has - // `"selected": true` (instead of false) and the input-number has a `currentValue`. + // `"selected": true` (instead of false) and the numeric-input has a `currentValue`. await userEvent.click(screen.getAllByRole("radio")[2]); // Correct - await userEvent.type(screen.getByRole("textbox"), "+42"); // Correct + await userEvent.type(screen.getByRole("textbox"), "42"); // Correct // Act // @ts-expect-error - TS2339 - Property '_getSerializedState' does not exist on type 'never'. @@ -160,12 +160,14 @@ describe("multi-item renderer", () => { null, ], "question": { - "input-number 1": { - "answerType": "number", - "currentValue": "+42", - "rightAlign": undefined, - "simplify": "required", + "numeric-input 1": { + "answerForms": [], + "coefficient": false, + "currentValue": "42", + "labelText": "", + "rightAlign": false, "size": "normal", + "static": false, }, "radio 1": { "choiceStates": [ @@ -296,12 +298,14 @@ describe("multi-item renderer", () => { undefined, ], "question": { - "input-number 1": { - "answerType": "number", + "numeric-input 1": { + "answerForms": [], + "coefficient": false, "currentValue": "99", - "rightAlign": undefined, - "simplify": "required", + "labelText": "", + "rightAlign": false, "size": "normal", + "static": false, }, "radio 1": { "choiceStates": [ @@ -419,9 +423,9 @@ describe("multi-item renderer", () => { blurb: {}, hints: [null, null, null], question: { - "input-number 1": { + "numeric-input 1": { answerType: "number", - currentValue: "+42", + currentValue: "42", rightAlign: false, simplify: "required", size: "normal", @@ -540,7 +544,7 @@ describe("multi-item renderer", () => { expect(screen.getAllByRole("radio")[2]).toBeChecked(); expect(screen.getAllByRole("radio")[3]).not.toBeChecked(); expect(screen.getAllByRole("radio")[4]).not.toBeChecked(); - expect(screen.getByRole("textbox")).toHaveValue("+42"); + expect(screen.getByRole("textbox")).toHaveValue("42"); }); it("should call callback when restore serialized state is complete", () => { @@ -636,12 +640,14 @@ describe("multi-item renderer", () => { ], "message": null, "state": { - "input-number 1": { - "answerType": "number", + "numeric-input 1": { + "answerForms": [], + "coefficient": false, "currentValue": "-42", - "rightAlign": undefined, - "simplify": "required", + "labelText": "", + "rightAlign": false, "size": "normal", + "static": false, }, "radio 1": { "choiceStates": [ @@ -791,12 +797,14 @@ describe("multi-item renderer", () => { "state": [ {}, { - "input-number 1": { - "answerType": "number", + "numeric-input 1": { + "answerForms": [], + "coefficient": false, "currentValue": "-42", - "rightAlign": undefined, - "simplify": "required", + "labelText": "", + "rightAlign": false, "size": "normal", + "static": false, }, "radio 1": { "choiceStates": [ diff --git a/packages/perseus/src/perseus-types.ts b/packages/perseus/src/perseus-types.ts index 786c00fbda..6b13316354 100644 --- a/packages/perseus/src/perseus-types.ts +++ b/packages/perseus/src/perseus-types.ts @@ -259,7 +259,7 @@ export type SorterWidget = WidgetOptions<'sorter', PerseusSorterWidgetOptions>; // prettier-ignore export type TableWidget = WidgetOptions<'table', PerseusTableWidgetOptions>; // prettier-ignore -export type InputNumberWidget = WidgetOptions<'input-number', PerseusInputNumberWidgetOptions>; +export type InputNumberWidget = WidgetOptions<'input-number', PerseusInputNumberWidgetOptions>; // While this widget is deprecated, we still need the type for conversion purposes // prettier-ignore export type MoleculeRendererWidget = WidgetOptions<'molecule-renderer', PerseusMoleculeRendererWidgetOptions>; // prettier-ignore diff --git a/packages/perseus/src/server-item-renderer.tsx b/packages/perseus/src/server-item-renderer.tsx index 519eafecb2..74d711cf17 100644 --- a/packages/perseus/src/server-item-renderer.tsx +++ b/packages/perseus/src/server-item-renderer.tsx @@ -240,7 +240,7 @@ export class ServerItemRenderer /** * Accepts a question area widgetId, or an answer area widgetId of - * the form "answer-input-number 1", or the string "answer-area" + * the form "answer-numeric-input 1", or the string "answer-area" * for the whole answer area (if the answer area is a single widget). */ _setWidgetProps(widgetId: string, newProps: Props, callback: any) { diff --git a/packages/perseus/src/styles/styles.less b/packages/perseus/src/styles/styles.less index 8d463a718c..82e164812a 100644 --- a/packages/perseus/src/styles/styles.less +++ b/packages/perseus/src/styles/styles.less @@ -674,5 +674,8 @@ } } } +.im-just-a-sweet-lil-numeric-input { + opacity: 1; +} @import "./zoom.less"; diff --git a/packages/perseus/src/widget-ai-utils/input-number/input-number.test.ts b/packages/perseus/src/widget-ai-utils/input-number/input-number.test.ts deleted file mode 100644 index 7e51c2ab5c..0000000000 --- a/packages/perseus/src/widget-ai-utils/input-number/input-number.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import {screen} from "@testing-library/react"; -import {userEvent as userEventLib} from "@testing-library/user-event"; - -import {renderQuestion} from "../../widgets/__testutils__/renderQuestion"; - -import type {InputNumberWidget, PerseusRenderer} from "../../perseus-types"; -import type {UserEvent} from "@testing-library/user-event"; - -const question: PerseusRenderer = { - content: - "A sequence is defined recursively as follows:\n\n\n$\\qquad\\displaystyle{{a}_{n}}=-\\frac{1}{a_{n-1}-1} \n~~~~~~\\text{ with}\\qquad\\displaystyle{{a}_{0}}=\\frac{1}{2}\\,$\n\n\nFind the term $a_3$ in the sequence.\n\n[[\u2603 input-number 1]]", - images: {}, - widgets: { - "input-number 1": { - graded: true, - version: { - major: 0, - minor: 0, - }, - static: false, - type: "input-number", - options: { - maxError: 0.1, - inexact: false, - value: 0.5, - simplify: "required", - answerType: "number", - size: "normal", - }, - alignment: "default", - } as InputNumberWidget, - }, -}; - -describe("input-number widget", () => { - let userEvent: UserEvent; - beforeEach(() => { - userEvent = userEventLib.setup({ - advanceTimers: jest.advanceTimersByTime, - }); - }); - - it("should get prompt json which matches the state of the UI", async () => { - // Arrange - const {renderer} = renderQuestion(question); - - // Act - const input = "40"; - const textbox = screen.getByRole("textbox"); - await userEvent.click(textbox); - await userEvent.type(textbox, input); - const json = renderer.getPromptJSON(); - - // Assert - expect(json).toEqual({ - content: - "A sequence is defined recursively as follows:\n\n\n$\\qquad\\displaystyle{{a}_{n}}=-\\frac{1}{a_{n-1}-1} \n~~~~~~\\text{ with}\\qquad\\displaystyle{{a}_{0}}=\\frac{1}{2}\\,$\n\n\nFind the term $a_3$ in the sequence.\n\n[[\u2603 input-number 1]]", - widgets: { - "input-number 1": { - type: "input-number", - options: { - simplify: "required", - answerType: "number", - }, - userInput: { - value: "40", - }, - }, - }, - }); - }); -}); diff --git a/packages/perseus/src/widget-ai-utils/input-number/prompt-utils.test.ts b/packages/perseus/src/widget-ai-utils/input-number/prompt-utils.test.ts deleted file mode 100644 index f6c9ff60e3..0000000000 --- a/packages/perseus/src/widget-ai-utils/input-number/prompt-utils.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import {getPromptJSON} from "./prompt-utils"; - -import type {PerseusInputNumberUserInput} from "../../validation.types"; - -describe("InputNumber getPromptJSON", () => { - it("it returns JSON with the expected format and fields", () => { - const renderProps: any = { - simplify: "optional", - answerType: "integer", - }; - - const userInput: PerseusInputNumberUserInput = { - currentValue: "123", - }; - - const resultJSON = getPromptJSON(renderProps, userInput); - - expect(resultJSON).toEqual({ - type: "input-number", - options: { - simplify: "optional", - answerType: "integer", - }, - userInput: { - value: "123", - }, - }); - }); -}); diff --git a/packages/perseus/src/widgets.ts b/packages/perseus/src/widgets.ts index 11991cdd37..3f271cc5d2 100644 --- a/packages/perseus/src/widgets.ts +++ b/packages/perseus/src/widgets.ts @@ -74,6 +74,9 @@ export const replaceDeprecatedWidgets = () => { replaceWidget("sequence", "deprecated-standin"); replaceWidget("simulator", "deprecated-standin"); replaceWidget("unit-input", "deprecated-standin"); + + // Input-Number is a special case as it is being replaced by Numeric-Input + replaceWidget("input-number", "numeric-input"); }; export const registerEditors = (editorsToRegister: ReadonlyArray) => { @@ -115,6 +118,11 @@ export const replaceDeprecatedEditors = () => { replaceEditor("sequence", "deprecated-standin"); replaceEditor("simulator", "deprecated-standin"); replaceEditor("unit-input", "deprecated-standin"); + + // We're replacing the input-number editor with the numeric-input editor here, + // but we're also modifying the JSON content to convert input-number into the + // numeric-input format over in editor-page.tsx on line 92. + replaceEditor("input-number", "numeric-input"); }; export const getWidget = ( @@ -174,9 +182,8 @@ export const getPublicWidgets = (): ReadonlyArray => { // @ts-expect-error - TS2740 - Type 'Pick<{ [key: string]: Readonly<{ name: string; displayName: string; getWidget?: (() => ComponentType) | undefined; accessible?: boolean | ((props: any) => boolean) | undefined; hidden?: boolean | undefined; ... 10 more ...; widget: ComponentType<...>; }>; }, string>' is missing the following properties from type 'readonly Readonly<{ name: string; displayName: string; getWidget?: (() => ComponentType) | undefined; accessible?: boolean | ((props: any) => boolean) | undefined; hidden?: boolean | undefined; ... 10 more ...; widget: ComponentType<...>; }>[]': length, concat, join, slice, and 18 more. return _.pick( widgets, - // @ts-expect-error - TS2345 - Argument of type '(name: string) => boolean | undefined' is not assignable to parameter of type 'Iteratee'. _.reject(_.keys(widgets), function (name) { - return widgets[name].hidden; + return widgets[name].hidden || name === "input-number"; }), ); }; diff --git a/packages/perseus/src/widgets/graded-group-set/__snapshots__/graded-group-set-jipt.test.ts.snap b/packages/perseus/src/widgets/graded-group-set/__snapshots__/graded-group-set-jipt.test.ts.snap index f1453a2201..b4a5c219a6 100644 --- a/packages/perseus/src/widgets/graded-group-set/__snapshots__/graded-group-set-jipt.test.ts.snap +++ b/packages/perseus/src/widgets/graded-group-set/__snapshots__/graded-group-set-jipt.test.ts.snap @@ -59,175 +59,173 @@ exports[`graded-group-set should render all graded groups 1`] = `
    -
    - -
    - -
    + +
    + +
    +
  • +
    -
    - + + @@ -288,175 +286,173 @@ exports[`graded-group-set should render all graded groups 1`] = `
    -
    - -
    - -
    + +
    + +
    +
    @@ -517,175 +513,173 @@ exports[`graded-group-set should render all graded groups 1`] = `
    -
    - -
    - -
    + +
    + +
    +
    diff --git a/packages/perseus/src/widgets/graded-group-set/__snapshots__/graded-group-set.test.ts.snap b/packages/perseus/src/widgets/graded-group-set/__snapshots__/graded-group-set.test.ts.snap index ce69ebd6c0..8db4a96dae 100644 --- a/packages/perseus/src/widgets/graded-group-set/__snapshots__/graded-group-set.test.ts.snap +++ b/packages/perseus/src/widgets/graded-group-set/__snapshots__/graded-group-set.test.ts.snap @@ -125,175 +125,173 @@ exports[`graded group widget should snapshot 1`] = `
    -
    - -
    - -
    + +
    + +
    +
    diff --git a/packages/perseus/src/widgets/group/__snapshots__/group.test.tsx.snap b/packages/perseus/src/widgets/group/__snapshots__/group.test.tsx.snap index 828a6ab9b6..32395581ae 100644 --- a/packages/perseus/src/widgets/group/__snapshots__/group.test.tsx.snap +++ b/packages/perseus/src/widgets/group/__snapshots__/group.test.tsx.snap @@ -752,175 +752,173 @@ exports[`group widget should snapshot: initial render 1`] = `
    -
    - -
    - -
    + +
    + +
    +
    @@ -959,175 +957,173 @@ exports[`group widget should snapshot: initial render 1`] = `
    -
    - -
    - -
    + +
    + +
    +
    diff --git a/packages/perseus/src/widgets/input-number/__snapshots__/input-number.test.ts.snap b/packages/perseus/src/widgets/input-number/__snapshots__/input-number.test.ts.snap deleted file mode 100644 index d9d3651a3a..0000000000 --- a/packages/perseus/src/widgets/input-number/__snapshots__/input-number.test.ts.snap +++ /dev/null @@ -1,495 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`rendering supports mobile rendering: mobile render 1`] = ` -
    -
    -
    -
    - Akshat works in a hospital lab. -
    -
    -
    -
    - To project blood quantities, he wants to know the probability that more than - - - - 1 - - - - of the next - - - - 7 - - - - donors will have type-A blood. From his previous work, Sorin knows that - - - - \\dfrac14 - - - - of donors have type-A blood. -
    -
    -
    -
    - Akshat uses a computer to produce many samples that simulate the next - - - - 7 - - - - donors. The first - - - - 8 - - - - samples are shown in the table below where " - - - - \\text{\\red{A}} - - - - " represents a donor - - with - - type-A blood, and " - - - - \\text{\\blue{Z}} - - - - " represents a donor - - without - - type-A blood. -
    -
    -
    -
    - - Based on the samples below, estimate the probability that more than - - - - 1 - - - - of the next - - - - 7 - - - - donors will have type-A blood. - - If necessary, round your answer to the nearest hundredth. -
    -
    -
    -
    - - - -
    -
    -
    -
    -
    -
    -
    -
    - - Note: This a small sample to practice with. A larger sample could give a much better estimate. - -
    -
    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    - - Sample - -
    - - - - 1 - - - - - - - - \\text{\\blue{Z}, \\blue{Z}, \\blue{Z}, \\blue{Z}, \\red{A}, \\blue{Z}, \\blue{Z}} - - - -
    - - - - 2 - - - - - - - - \\text{\\blue{Z}, \\blue{Z}, \\blue{Z}, \\blue{Z}, \\blue{Z}, \\blue{Z}, \\blue{Z}} - - - -
    - - - - 3 - - - - - - - - \\text{\\blue{Z}, \\blue{Z}, \\red{A}, \\blue{Z}, \\blue{Z}, \\blue{Z}, \\blue{Z}} - - - -
    - - - - 4 - - - - - - - - \\text{\\red{A}, \\red{A}, \\blue{Z}, \\blue{Z}, \\blue{Z}, \\blue{Z}, \\blue{Z}} - - - -
    - - - - 5 - - - - - - - - \\text{\\blue{Z}, \\blue{Z}, \\blue{Z}, \\blue{Z}, \\blue{Z}, \\red{A}, \\red{A}} - - - -
    - - - - 6 - - - - - - - - \\text{\\blue{Z}, \\red{A}, \\red{A}, \\blue{Z}, \\blue{Z}, \\blue{Z}, \\blue{Z}} - - - -
    - - - - 7 - - - - - - - - \\text{\\blue{Z}, \\red{A}, \\blue{Z}, \\blue{Z}, \\blue{Z}, \\red{A}, \\blue{Z}} - - - -
    - - - - 8 - - - - - - - - \\text{\\blue{Z}, \\blue{Z}, \\blue{Z}, \\blue{Z}, \\red{A}, \\blue{Z}, \\blue{Z}} - - - -
    -
    -
    -
    -`; diff --git a/packages/perseus/src/widgets/input-number/input-number.test.ts b/packages/perseus/src/widgets/input-number/input-number.test.ts deleted file mode 100644 index 4c99d33508..0000000000 --- a/packages/perseus/src/widgets/input-number/input-number.test.ts +++ /dev/null @@ -1,363 +0,0 @@ -/** - * Disclaimer: Definitely not thorough enough - */ -import {describe, beforeEach, it} from "@jest/globals"; -import {act, screen} from "@testing-library/react"; -import {userEvent as userEventLib} from "@testing-library/user-event"; -import _ from "underscore"; - -import {testDependencies} from "../../../../../testing/test-dependencies"; -import * as Dependencies from "../../dependencies"; -import {mockStrings} from "../../strings"; -import {renderQuestion} from "../__testutils__/renderQuestion"; - -import InputNumber from "./input-number"; -import {question3 as question} from "./input-number.testdata"; -import scoreInputNumber from "./score-input-number"; - -import type { - PerseusInputNumberWidgetOptions, - PerseusRenderer, -} from "../../perseus-types"; -import type {UserEvent} from "@testing-library/user-event"; - -const {transform} = InputNumber; - -const options: PerseusInputNumberWidgetOptions = { - value: "2^{-2}-3", - size: "normal", - simplify: "optional", -}; - -describe("input-number", function () { - let userEvent: UserEvent; - beforeEach(() => { - userEvent = userEventLib.setup({ - advanceTimers: jest.advanceTimersByTime, - }); - - jest.spyOn(Dependencies, "getDependencies").mockReturnValue( - testDependencies, - ); - }); - - describe("full render", function () { - it("Should accept the right answer", async () => { - // Arrange - const {renderer} = renderQuestion(question); - - // Act - const textbox = screen.getByRole("textbox"); - await userEvent.click(textbox); - await userEvent.type(textbox, "1/2"); - - // Assert - expect(renderer).toHaveBeenAnsweredCorrectly(); - }); - - it("should reject an incorrect answer", async () => { - // Arrange - const {renderer} = renderQuestion(question); - - // Act - const textbox = screen.getByRole("textbox"); - await userEvent.click(textbox); - await userEvent.type(textbox, "0.7"); - - // Assert - expect(renderer).toHaveBeenAnsweredIncorrectly(); - }); - - it("should refuse to score an incoherent answer", async () => { - // Arrange - const {renderer} = renderQuestion(question); - - // Act - const textbox = screen.getByRole("textbox"); - await userEvent.click(textbox); - await userEvent.type(textbox, "0..7"); - - // Assert - expect(renderer).toHaveInvalidInput(); - }); - }); - - describe.each([ - [ - { - content: - "Denis baked a peach pie and cut it into $3$ equal-sized pieces. Denis's dad eats $1$ section of the pie. \n\n**What fraction of the pie did Denis's dad eat?** \n![](https://ka-perseus-graphie.s3.amazonaws.com/74a2b7583a2c26ebfb3ad714e29867541253fc97.png) \n[[\u2603 input-number 1]] \n\n\n\n", - images: { - "https://ka-perseus-graphie.s3.amazonaws.com/74a2b7583a2c26ebfb3ad714e29867541253fc97.png": - { - width: 200, - height: 200, - }, - }, - widgets: { - "input-number 1": { - version: { - major: 0, - minor: 0, - }, - type: "input-number", - graded: true, - alignment: "default", - options: { - maxError: 0.1, - inexact: false, - value: 0.3333333333333333, - simplify: "optional", - answerType: "rational", - size: "normal", - }, - }, - }, - } as PerseusRenderer, - "1/3", - "0.4", - ], - [ - { - content: - "Denis baked a peach pie and cut it into $3$ equal-sized pieces. Denis's dad eats $1$ section of the pie. \n\n**What fraction of the pie did Denis's dad eat?** \n![](https://ka-perseus-graphie.s3.amazonaws.com/74a2b7583a2c26ebfb3ad714e29867541253fc97.png) \n[[\u2603 input-number 1]] \n\n\n\n", - images: { - "https://ka-perseus-graphie.s3.amazonaws.com/74a2b7583a2c26ebfb3ad714e29867541253fc97.png": - { - width: 200, - height: 200, - }, - }, - widgets: { - "input-number 1": { - version: { - major: 0, - minor: 0, - }, - type: "input-number", - graded: true, - alignment: "default", - options: { - maxError: 0.1, - inexact: false, - value: 0.3333333333333333, - simplify: "required", - answerType: "rational", - size: "normal", - }, - }, - }, - } as PerseusRenderer, - "1/3", - "0.4", - ], - [ - { - content: - "A washing machine is being redesigned to handle a greater volume of water. One part is a pipe with a radius of $3 \\,\\text{cm}$ and a length of $11\\,\\text{cm}$. It gets replaced with a pipe of radius $4\\,\\text{cm}$, and the same length. \n\n**How many more cubic centimeters of water can the new pipe hold?**\n\n [[\u2603 input-number 1]] $\\text{cm}^3$", - images: {}, - widgets: { - "input-number 1": { - type: "input-number", - graded: true, - options: { - maxError: 0.1, - inexact: false, - value: 241.90263432641407, - simplify: "required", - answerType: "pi", - size: "normal", - }, - }, - }, - } as PerseusRenderer, - "77 pi", - "76 pi", - ], - [ - { - content: - 'Akshat works in a hospital lab.\n\nTo project blood quantities, he wants to know the probability that more than $1$ of the next $7$ donors will have type-A blood. From his previous work, Sorin knows that $\\dfrac14$ of donors have type-A blood.\n\nAkshat uses a computer to produce many samples that simulate the next $7$ donors. The first $8$ samples are shown in the table below where "$\\text{\\red{A}}$" represents a donor *with* type-A blood, and "$\\text{\\blue{Z}}$" represents a donor *without* type-A blood.\n\n**Based on the samples below, estimate the probability that more than $1$ of the next $7$ donors will have type-A blood.** If necessary, round your answer to the nearest hundredth. [[\u2603 input-number 1]]\n\n*Note: This a small sample to practice with. A larger sample could give a much better estimate.*\n\n | Sample |\n:-: | :-: | \n$1$ | $\\text{\\blue{Z}, \\blue{Z}, \\blue{Z}, \\blue{Z}, \\red{A}, \\blue{Z}, \\blue{Z}}$\n$2$ | $\\text{\\blue{Z}, \\blue{Z}, \\blue{Z}, \\blue{Z}, \\blue{Z}, \\blue{Z}, \\blue{Z}}$\n$3$ | $\\text{\\blue{Z}, \\blue{Z}, \\red{A}, \\blue{Z}, \\blue{Z}, \\blue{Z}, \\blue{Z}}$\n$4$ | $\\text{\\red{A}, \\red{A}, \\blue{Z}, \\blue{Z}, \\blue{Z}, \\blue{Z}, \\blue{Z}}$\n$5$ | $\\text{\\blue{Z}, \\blue{Z}, \\blue{Z}, \\blue{Z}, \\blue{Z}, \\red{A}, \\red{A}}$\n$6$ | $\\text{\\blue{Z}, \\red{A}, \\red{A}, \\blue{Z}, \\blue{Z}, \\blue{Z}, \\blue{Z}}$\n$7$ | $\\text{\\blue{Z}, \\red{A}, \\blue{Z}, \\blue{Z}, \\blue{Z}, \\red{A}, \\blue{Z}}$\n$8$ | $\\text{\\blue{Z}, \\blue{Z}, \\blue{Z}, \\blue{Z}, \\red{A}, \\blue{Z}, \\blue{Z}}$\n\n', - images: {}, - widgets: { - "input-number 1": { - type: "input-number", - graded: true, - options: { - maxError: 0.1, - inexact: false, - value: 0.5, - simplify: "optional", - answerType: "percent", - size: "small", - }, - }, - }, - } as PerseusRenderer, - "50%", - "0.56", - ], - ])("answer type", (question, correct, incorrect) => { - it("Should accept the right answer", async () => { - // Arrange - const {renderer} = renderQuestion(question); - - // Act - const textbox = screen.getByRole("textbox"); - await userEvent.click(textbox); - await userEvent.type(textbox, correct); - - // Assert - expect(renderer).toHaveBeenAnsweredCorrectly(); - }); - - it("should reject an incorrect answer", async () => { - // Arrange - const {renderer} = renderQuestion(question); - - // Act - const textbox = screen.getByRole("textbox"); - await userEvent.click(textbox); - await userEvent.type(textbox, incorrect); - - // Assert - expect(renderer).toHaveBeenAnsweredIncorrectly(); - }); - }); - - it("transform should remove the `value` field", function () { - const editorProps = { - value: 5, - simplify: "required", - size: "normal", - inexact: false, - maxError: 0.1, - answerType: "number", - } as const; - if (!transform) { - throw new Error("transform not defined"); - } - const widgetProps = transform(editorProps); - expect(_.has(widgetProps, "value")).toBe(false); - }); -}); - -describe("invalid", function () { - beforeEach(() => { - jest.spyOn(Dependencies, "getDependencies").mockReturnValue( - testDependencies, - ); - }); - - it("should handle invalid answers with no error callback", function () { - const err = scoreInputNumber( - {currentValue: "x+1"}, - options, - mockStrings, - ); - expect(err).toMatchInlineSnapshot(` - { - "message": "We could not understand your answer. Please check your answer for extra text or symbols.", - "type": "invalid", - } - `); - }); -}); - -describe("getOneCorrectAnswerFromRubric", () => { - beforeEach(() => { - jest.spyOn(Dependencies, "getDependencies").mockReturnValue( - testDependencies, - ); - }); - - it("should return undefined if rubric.value is null/undefined", () => { - // Arrange - const rubric: Record = {}; - - // Act - const result = InputNumber.getOneCorrectAnswerFromRubric?.(rubric); - - // Assert - expect(result).toBeUndefined(); - }); - - it("should return rubric.value if inexact is false", () => { - // Arrange - const rubric = { - value: 0, - maxError: 0.1, - inexact: false, - } as const; - - // Act - const result = InputNumber.getOneCorrectAnswerFromRubric?.(rubric); - - // Assert - expect(result).toEqual("0"); - }); - - it("should return rubric.value with an error band if inexact is true", () => { - // Arrange - const rubric = { - value: 0, - maxError: 0.1, - inexact: true, - } as const; - - // Act - const result = InputNumber.getOneCorrectAnswerFromRubric?.(rubric); - - // Assert - expect(result).toEqual("0 ± 0.1"); - }); -}); - -describe("rendering", () => { - beforeEach(() => { - jest.spyOn(Dependencies, "getDependencies").mockReturnValue( - testDependencies, - ); - }); - - it("supports mobile rendering", () => { - const {container} = renderQuestion(question, { - // Setting this triggers mobile rendering - // it would be nice if this was more clear in the code - customKeypad: true, - }); - - expect(container).toMatchSnapshot("mobile render"); - }); -}); - -describe("focus state", () => { - beforeEach(() => { - jest.spyOn(Dependencies, "getDependencies").mockReturnValue( - testDependencies, - ); - }); - - it("supports focusing", async () => { - // Arrange - const {renderer} = renderQuestion(question); - - // Act - const gotFocus = await act(() => renderer.focus()); - - // Assert - expect(gotFocus).toBe(true); - }); - - it("supports blurring", async () => { - // Arrange - const {renderer} = renderQuestion(question); - - // Act - const gotFocus = await act(() => renderer.focus()); - act(() => renderer.blur()); - - // Assert - expect(gotFocus).toBe(true); - }); -}); diff --git a/packages/perseus/src/widgets/input-number/input-number.testdata.ts b/packages/perseus/src/widgets/input-number/input-number.testdata.ts index a31796fcb6..b0fa7cc847 100644 --- a/packages/perseus/src/widgets/input-number/input-number.testdata.ts +++ b/packages/perseus/src/widgets/input-number/input-number.testdata.ts @@ -26,6 +26,7 @@ export const question1: PerseusRenderer = { simplify: "optional", answerType: "rational", size: "normal", + rightAlign: true, }, } as InputNumberWidget, }, diff --git a/packages/perseus/src/widgets/numeric-input/__snapshots__/numeric-input.test.ts.snap b/packages/perseus/src/widgets/numeric-input/__snapshots__/numeric-input.test.ts.snap index 53e9f2f3e5..eddb44a2bd 100644 --- a/packages/perseus/src/widgets/numeric-input/__snapshots__/numeric-input.test.ts.snap +++ b/packages/perseus/src/widgets/numeric-input/__snapshots__/numeric-input.test.ts.snap @@ -90,175 +90,173 @@ exports[`numeric-input widget Should render predictably: after interaction 1`] =
    -
    - -
    - -
    + +
    + +
    +
    @@ -294,175 +292,173 @@ exports[`numeric-input widget Should render predictably: first render 1`] = `
    -
    - -
    - -
    + +
    + +
    +
    diff --git a/packages/perseus/src/widgets/numeric-input/numeric-input.tsx b/packages/perseus/src/widgets/numeric-input/numeric-input.tsx index fc4fbb0e70..a162b2e564 100644 --- a/packages/perseus/src/widgets/numeric-input/numeric-input.tsx +++ b/packages/perseus/src/widgets/numeric-input/numeric-input.tsx @@ -249,21 +249,19 @@ export class NumericInput }); return ( -
    - (this.inputRef = ref)} - value={this.props.currentValue} - onChange={this.handleChange} - labelText={labelText} - examples={this.examples()} - shouldShowExamples={this.shouldShowExamples()} - onFocus={this._handleFocus} - onBlur={this._handleBlur} - id={this.props.widgetId} - disabled={this.props.apiOptions.readOnly} - style={styles.input} - /> -
    + (this.inputRef = ref)} + value={this.props.currentValue} + onChange={this.handleChange} + labelText={labelText} + examples={this.examples()} + shouldShowExamples={this.shouldShowExamples()} + onFocus={this._handleFocus} + onBlur={this._handleBlur} + id={this.props.widgetId} + disabled={this.props.apiOptions.readOnly} + style={styles.input} + /> ); } } @@ -342,13 +340,63 @@ const propsTransform = function ( return rendererProps; }; +// This function is being used to replace the input-number widget +// with the numeric-input widget +const propUpgrades = { + /* c8 ignore next */ + "1": (initialProps: any): PerseusNumericInputWidgetOptions => { + // If the initialProps has a value, it means we're upgrading from + // input-number to numeric-input. In this case, we need to upgrade + // the widget options accordingly. + if (initialProps.value) { + const provideAnswerForm = initialProps.answerType !== "number"; + + // We need to determine the mathFormat for the numeric-input widget + const mathFormat = + initialProps.answerType === "rational" + ? "proper" // input-number uses "rational" for proper fractions + : initialProps.answerType; // Otherwise, we can use the answerType directly + + // If adjusting this logic, also adjust the logic in the convertInputNumberWidgetOptions + // function in input-number.ts in the Perseus Editor package's util folder + const answers = [ + { + value: initialProps.value, + simplify: initialProps.simplify, + answerForms: provideAnswerForm ? [mathFormat] : undefined, + strict: initialProps.inexact, + // We only want to set maxError if the inexact prop is true + maxError: initialProps.inexact ? initialProps.maxError : 0, + status: "correct", // Input-number only allows correct answers + message: "", + }, + ]; + + return { + answers, + size: initialProps.size, + coefficient: false, // input-number doesn't have a coefficient prop + labelText: "", // input-number doesn't have a labelText prop + static: false, // static is always false for numeric-input + rightAlign: initialProps.rightAlign || false, + }; + } else { + // Otherwise simply return the initialProps as there's no differences + // between v0 and v1 for numeric-input + return initialProps; + } + }, +} as const; + export default { name: "numeric-input", displayName: "Numeric input", defaultAlignment: "inline-block", accessible: true, widget: NumericInput, + version: {major: 1, minor: 0}, transform: propsTransform, + propUpgrades: propUpgrades, isLintable: true, scorer: scoreNumericInput,