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,