diff --git a/.changeset/eight-carrots-flow.md b/.changeset/eight-carrots-flow.md new file mode 100644 index 00000000000..708ffb5c28d --- /dev/null +++ b/.changeset/eight-carrots-flow.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus-score": minor +--- + +Removes the experimental function `scorePerseusItemWithInputNumberAsNumericInput`. diff --git a/.changeset/late-ads-rescue.md b/.changeset/late-ads-rescue.md new file mode 100644 index 00000000000..27f39b8d52c --- /dev/null +++ b/.changeset/late-ads-rescue.md @@ -0,0 +1,7 @@ +--- +"@khanacademy/perseus": major +"@khanacademy/perseus-core": major +"@khanacademy/perseus-editor": major +--- + +The InputNumber widget code has been removed; widgets with type "input-number" will now render as NumericInput widgets. This involves a breaking change to the InputNumber widget types in data-schema. Callers should, as always, use the parser to migrate Perseus JSON to the latest schema version before using it, and avoid depending directly on the schema types. diff --git a/packages/perseus-core/src/data-schema.ts b/packages/perseus-core/src/data-schema.ts index bb6b3113014..2e0594850d4 100644 --- a/packages/perseus-core/src/data-schema.ts +++ b/packages/perseus-core/src/data-schema.ts @@ -1649,10 +1649,10 @@ export type PerseusNumericInputWidgetOptions = { export type PerseusNumericInputAnswer = { /** * Translatable Display; A description for why this answer is correct, - * wrong, or ungraded + * wrong, or ungraded. Always the empty string in answerless data. */ message: string; - /** The expected answer */ + /** The expected answer. Null in answerless data. */ value?: number | null; /** Whether this answer is "correct", "wrong", or "ungraded" */ status: string; @@ -1666,9 +1666,12 @@ export type PerseusNumericInputAnswer = { * (strict = false). */ strict: boolean; - /** A range of error +/- the value */ + /** + * The maximum difference between the answer key and a correct user + * input. + */ maxError?: number | null; - /** Unsimplified answers are Ungraded, Accepted, or Wrong. */ + /** How unsimplified responses should be handled. */ simplify: PerseusNumericInputSimplify; }; @@ -2227,26 +2230,9 @@ export type PerseusVideoWidgetOptions = { static?: boolean; }; -export type PerseusInputNumberAnswerType = - | "number" - | "decimal" - | "integer" - | "rational" - | "improper" - | "mixed" - | "percent" - | "pi"; +export type PerseusInputNumberAnswer = PerseusNumericInputAnswer; -/** Options for the input-number widget (deprecated; prefer numeric-input). */ -export type PerseusInputNumberWidgetOptions = { - answerType?: PerseusInputNumberAnswerType; - inexact?: boolean; - maxError?: number | string; - rightAlign?: boolean; - simplify: "required" | "optional" | "enforced"; - size: "normal" | "small"; - value: string | number; -}; +export type PerseusInputNumberWidgetOptions = PerseusNumericInputWidgetOptions; /** Options for the molecule-renderer widget. Renders a molecule via SMILES. */ export type PerseusMoleculeRendererWidgetOptions = { diff --git a/packages/perseus-core/src/data-schema.typetest.ts b/packages/perseus-core/src/data-schema.typetest.ts new file mode 100644 index 00000000000..b2f2646df88 --- /dev/null +++ b/packages/perseus-core/src/data-schema.typetest.ts @@ -0,0 +1,17 @@ +import {describe, expect, it} from "tstyche"; + +import type { + PerseusInputNumberWidgetOptions, + PerseusNumericInputWidgetOptions, +} from "./data-schema"; + +describe("PerseusInputNumberWidgetOptions", () => { + it("is identical to PerseusNumericInputWidgetOptions", () => { + // This test is needed because the PerseusInputNumberWidgetOptions now + // get passed to the NumericInput component. We are currently (May + // 2026) removing the deprecated InputNumber code in favor of using + // NumericInput everywhere, but we need to keep separate types for + // InputNumber to support legacy content. + expect().type.toBe(); + }); +}); diff --git a/packages/perseus-core/src/index.ts b/packages/perseus-core/src/index.ts index 571932f24d4..5d0ecb264a6 100644 --- a/packages/perseus-core/src/index.ts +++ b/packages/perseus-core/src/index.ts @@ -206,9 +206,6 @@ export {default as videoLogic} from "./widgets/video"; /** @hidden */ export type {VideoDefaultWidgetOptions} from "./widgets/video"; -/** @hidden */ -export {convertInputNumberOptionsToNumericInput} from "./widgets/input-number/to-numeric-input"; - /** @hidden */ export { applyDefaultsToWidgets, @@ -371,6 +368,7 @@ export { export { generateInputNumberOptions, generateInputNumberWidget, + generateInputNumberAnswer, } from "./utils/generators/input-number-widget-generator"; /** @hidden */ export { diff --git a/packages/perseus-core/src/parse-perseus-json/perseus-parsers/input-number-widget.test.ts b/packages/perseus-core/src/parse-perseus-json/perseus-parsers/input-number-widget.test.ts new file mode 100644 index 00000000000..503cb73c4f2 --- /dev/null +++ b/packages/perseus-core/src/parse-perseus-json/perseus-parsers/input-number-widget.test.ts @@ -0,0 +1,308 @@ +import {describe, expect, it} from "@jest/globals"; + +import {anySuccess, ctx} from "../general-purpose-parsers/test-helpers"; +import {assertSuccess, success} from "../result"; + +import {parseInputNumberWidget} from "./input-number-widget"; + +import type {PerseusInputNumberWidgetOptionsV0} from "./input-number-widget"; + +const baseOptionsV0: PerseusInputNumberWidgetOptionsV0 = { + simplify: "required", + size: "normal", + value: "0", +}; + +describe("parseInputNumberWidget", () => { + it("parses v0 -> v1, converting maxError to a number", () => { + const raw = { + type: "input-number", + options: { + ...baseOptionsV0, + inexact: true, + maxError: "0.042", + }, + }; + + const result = parseInputNumberWidget(raw, ctx()); + + expect(result).toEqual(anySuccess); + assertSuccess(result); + expect(result.value.options.answers[0].maxError).toBe(0.042); + }); + + it("parses v0 -> v1, converting maxError from exponent notation", () => { + const raw = { + type: "input-number", + options: { + ...baseOptionsV0, + inexact: true, + maxError: "42e-3", + }, + }; + + const result = parseInputNumberWidget(raw, ctx()); + + expect(result).toEqual(anySuccess); + assertSuccess(result); + expect(result.value.options.answers[0].maxError).toBe(0.042); + }); + + it("parses v0 -> v1, leaving maxError undefined when undefined", () => { + const raw = { + type: "input-number", + options: { + ...baseOptionsV0, + maxError: undefined, + inexact: true, + }, + }; + + const result = parseInputNumberWidget(raw, ctx()); + + expect(result).toEqual(anySuccess); + assertSuccess(result); + expect(result.value.options.answers[0].maxError).toBe(undefined); + }); + + it("parses v0 -> v1, setting maxError = 0 when inexact is false", () => { + const raw = { + type: "input-number", + options: { + ...baseOptionsV0, + maxError: 0.99, + inexact: false, + }, + }; + + const result = parseInputNumberWidget(raw, ctx()); + + expect(result).toEqual(anySuccess); + assertSuccess(result); + expect(result.value.options.answers[0].maxError).toBe(0); + }); + + it("parses v0 -> v1, setting maxError = 0 when inexact is undefined", () => { + const raw = { + type: "input-number", + options: { + ...baseOptionsV0, + maxError: 0.99, + inexact: undefined, + }, + }; + + const result = parseInputNumberWidget(raw, ctx()); + + expect(result).toEqual(anySuccess); + assertSuccess(result); + expect(result.value.options.answers[0].maxError).toBe(0); + }); + + it("parses v0 -> v1, converting the 'number' answerType to empty answerForms", () => { + const raw = { + type: "input-number", + options: { + ...baseOptionsV0, + answerType: "number", + }, + }; + + const result = parseInputNumberWidget(raw, ctx()); + + expect(result).toEqual(anySuccess); + assertSuccess(result); + expect(result.value.options.answers[0].answerForms).toEqual([]); + }); + + it("parses v0 -> v1, converting the 'rational' answerType to [integer, proper, improper, mixed]", () => { + const raw = { + type: "input-number", + options: { + ...baseOptionsV0, + answerType: "rational", + }, + }; + + const result = parseInputNumberWidget(raw, ctx()); + + expect(result).toEqual(anySuccess); + assertSuccess(result); + expect(result.value.options.answers[0].answerForms).toEqual([ + "integer", + "proper", + "improper", + "mixed", + ]); + }); + + it("parses v0 -> v1, defaulting answerForms to empty when answerType is undefined", () => { + const raw = { + type: "input-number", + options: { + ...baseOptionsV0, + answerType: undefined, + }, + }; + + const result = parseInputNumberWidget(raw, ctx()); + + expect(result).toEqual(anySuccess); + assertSuccess(result); + expect(result.value.options.answers[0].answerForms).toEqual([]); + }); + + it("parses v0 -> v1, converting `value` to a number", () => { + const raw = { + type: "input-number", + options: { + ...baseOptionsV0, + value: "1.5", + }, + }; + + const result = parseInputNumberWidget(raw, ctx()); + + expect(result).toEqual(anySuccess); + assertSuccess(result); + expect(result.value.options.answers[0].value).toEqual(1.5); + }); + + it("parses v0 -> v1, excluding `decimal` and `integer` from `answerForms` when answerType is number, inexact is false, and `value` has more than 10 decimal places", () => { + // The intention here is that if the answer is a very long decimal, + // and an exact answer is required, the learner must use a fraction + // instead of typing all the decimal places. + const raw = { + type: "input-number", + options: { + ...baseOptionsV0, + answerType: "number", + inexact: false, + value: "1.12345678901", + }, + }; + + const result = parseInputNumberWidget(raw, ctx()); + + expect(result).toEqual(anySuccess); + assertSuccess(result); + expect(result.value.options.answers[0].answerForms).toEqual([ + "proper", + "improper", + "mixed", + ]); + }); + + it("parses v0 -> v1, excluding `decimal` and `integer` from `answerForms` when answerType is undefined, inexact is false, and `value` has more than 10 decimal places", () => { + // The intention here is that if the answer is a very long decimal, + // and an exact answer is required, the learner must use a fraction + // instead of typing all the decimal places. + const raw = { + type: "input-number", + options: { + ...baseOptionsV0, + answerType: undefined, + inexact: false, + value: "1.12345678901", + }, + }; + + const result = parseInputNumberWidget(raw, ctx()); + + expect(result).toEqual(anySuccess); + assertSuccess(result); + expect(result.value.options.answers[0].answerForms).toEqual([ + "proper", + "improper", + "mixed", + ]); + }); + + it("parses v0 -> v1, excluding `decimal` and `integer` from `answerForms` when inexact is undefined and `value` has more than 10 decimal places", () => { + // The intention here is that if the answer is a very long decimal, + // and an exact answer is required, the learner must use a fraction + // instead of typing all the decimal places. + const raw = { + type: "input-number", + options: { + ...baseOptionsV0, + answerType: undefined, + inexact: undefined, + value: "1.12345678901", + }, + }; + + const result = parseInputNumberWidget(raw, ctx()); + + expect(result).toEqual(anySuccess); + assertSuccess(result); + expect(result.value.options.answers[0].answerForms).toEqual([ + "proper", + "improper", + "mixed", + ]); + }); + + it("parses v0 -> v1, returning an empty array for answerForms when inexact is true and value has more than 10 decimal places", () => { + const raw = { + type: "input-number", + options: { + ...baseOptionsV0, + answerType: undefined, + inexact: true, + value: "1.12345678901", + }, + }; + + const result = parseInputNumberWidget(raw, ctx()); + + expect(result).toEqual(anySuccess); + assertSuccess(result); + expect(result.value.options.answers[0].answerForms).toEqual([]); + }); + + it("allows decimal answers when the answer has exactly 10 decimal places", () => { + const raw = { + type: "input-number", + options: { + ...baseOptionsV0, + answerType: undefined, + inexact: undefined, + value: "0.1231231234", + }, + }; + + const result = parseInputNumberWidget(raw, ctx()); + + expect(result).toEqual(anySuccess); + assertSuccess(result); + // Empty answerForms means the default answer forms are accepted. + expect(result.value.options.answers[0].answerForms).toEqual([]); + }); + + it("parses v1 options", () => { + const raw = { + type: "input-number", + version: {major: 1, minor: 0}, + options: { + size: "normal", + coefficient: false, + answers: [ + { + status: "correct", + value: 42, + maxError: 0.7, + simplify: "required", + answerForms: [], + message: "", + strict: true, + }, + ], + }, + }; + + const result = parseInputNumberWidget(raw, ctx()); + + expect(result).toEqual(success(raw)); + }); +}); diff --git a/packages/perseus-core/src/parse-perseus-json/perseus-parsers/input-number-widget.ts b/packages/perseus-core/src/parse-perseus-json/perseus-parsers/input-number-widget.ts index d6ef55d77f8..4eada23b930 100644 --- a/packages/perseus-core/src/parse-perseus-json/perseus-parsers/input-number-widget.ts +++ b/packages/perseus-core/src/parse-perseus-json/perseus-parsers/input-number-widget.ts @@ -7,21 +7,74 @@ import { optional, string, union, + array, + nullable, } from "../general-purpose-parsers"; import {defaulted} from "../general-purpose-parsers/defaulted"; -import {parseWidget} from "./widget"; +import {versionedWidgetOptions} from "./versioned-widget-options"; +import {parseWidgetWithVersion} from "./widget"; -import type {Parser} from "../parser-types"; +import type {ParsedValue, Parser} from "../parser-types"; -const booleanToString: Parser = (rawValue, ctx) => { +const booleanToZero: Parser = (rawValue, ctx) => { if (typeof rawValue === "boolean") { - return ctx.success(String(rawValue)); + return ctx.success(0); } return ctx.failure("boolean", rawValue); }; -export const parseInputNumberWidget = parseWidget( +const parseMathFormat = enumeration( + "integer", + "mixed", + "improper", + "proper", + "decimal", + "percent", + "pi", +); + +const parseInputNumberWidgetV1 = parseWidgetWithVersion( + object({major: constant(1), minor: number}), + constant("input-number"), + object({ + size: string, + coefficient: boolean, + labelText: optional(string), + rightAlign: optional(boolean), + answers: array( + object({ + value: optional(nullable(number)), + status: string, + message: string, + answerForms: optional(array(parseMathFormat)), + // FIXME: confirm that we need the default on `strict`. + // cribbed from numeric-input-widget.ts. + strict: defaulted(boolean, () => false), + maxError: optional(nullable(number)), + simplify: enumeration("required", "enforced", "optional"), + }), + ), + }), +); + +function migrateV0ToV1( + v0: ParsedValue, +): ParsedValue { + const v1Options = convertInputNumberOptionsToNumericInput(v0.options); + return { + ...v0, + version: {major: 1, minor: 0}, + options: v1Options, + }; +} + +export type PerseusInputNumberWidgetOptionsV0 = ParsedValue< + typeof parseInputNumberWidgetV0 +>["options"]; + +const parseInputNumberWidgetV0 = parseWidgetWithVersion( + optional(object({major: constant(0), minor: number})), constant("input-number"), object({ answerType: optional( @@ -46,8 +99,100 @@ export const parseInputNumberWidget = parseWidget( // those content items are actually published anywhere, and consider // updating them. value: defaulted( - union(number).or(string).or(booleanToString).parser, + union(number).or(string).or(booleanToZero).parser, () => 0, ), }), ); + +function convertInputNumberOptionsToNumericInput( + inputNumberOptions: ParsedValue["options"], +): ParsedValue["options"] { + return { + coefficient: false, + rightAlign: inputNumberOptions.rightAlign, + size: inputNumberOptions.size, + answers: [ + { + status: "correct", + value: Number(inputNumberOptions.value), + simplify: inputNumberOptions.simplify, + message: "", + maxError: getMaxError(inputNumberOptions), + strict: true, + answerForms: getAnswerForms(inputNumberOptions), + }, + ], + }; +} + +function getMaxError( + inputNumberOptions: PerseusInputNumberWidgetOptionsV0, +): number | undefined { + if (!inputNumberOptions.inexact) { + return 0; + } + + if (inputNumberOptions.maxError == null) { + return undefined; + } + + return Number(inputNumberOptions.maxError); +} + +type AnswerType = + | "number" + | "decimal" + | "integer" + | "rational" + | "improper" + | "mixed" + | "percent" + | "pi"; +type AnswerForm = + | "integer" + | "decimal" + | "proper" + | "improper" + | "mixed" + | "percent" + | "pi"; + +const mathFormatsForAnswerType: Record = { + number: [], + decimal: ["decimal"], + integer: ["integer"], + rational: ["integer", "proper", "improper", "mixed"], + improper: ["integer", "proper", "improper"], + mixed: ["integer", "proper", "mixed"], + percent: ["integer", "decimal", "proper", "improper", "mixed", "percent"], + pi: ["pi"], +}; + +function getAnswerForms( + options: PerseusInputNumberWidgetOptionsV0, +): AnswerForm[] { + const value = Number(options.value); + const {inexact} = options; + const precision = 1e10; + const rounded = Math.round(value * precision) / precision; + + const answerType = options.answerType ?? "number"; + if (answerType === "number" && !inexact && !equalFloats(rounded, value)) { + // Disallow decimal answers when the correct answer has more than 10 + // decimal places. This is for compatibility with legacy input-number + // behavior. + return ["proper", "improper", "mixed"]; + } + + return mathFormatsForAnswerType[answerType]; +} + +function equalFloats(a: number, b: number): boolean { + return Math.abs(a - b) < Math.pow(2, -42); +} + +export const parseInputNumberWidget = versionedWidgetOptions( + 1, + parseInputNumberWidgetV1, +).withMigrationFrom(0, parseInputNumberWidgetV0, migrateV0ToV1).parser; diff --git a/packages/perseus-core/src/parse-perseus-json/perseus-parsers/widgets-map.test.ts b/packages/perseus-core/src/parse-perseus-json/perseus-parsers/widgets-map.test.ts index 34b7d0ed8cf..34244797349 100644 --- a/packages/perseus-core/src/parse-perseus-json/perseus-parsers/widgets-map.test.ts +++ b/packages/perseus-core/src/parse-perseus-json/perseus-parsers/widgets-map.test.ts @@ -390,11 +390,21 @@ describe("parseWidgetsMap", () => { const widgetsMap: unknown = { "input-number 1": { type: "input-number", - version: {major: 0, minor: 0}, + version: {major: 1, minor: 0}, options: { - simplify: "required", size: "normal", - value: "", + coefficient: false, + answers: [ + { + status: "correct", + value: 0, + maxError: 0, + simplify: "required", + answerForms: [], + message: "", + strict: true, + }, + ], }, }, }; diff --git a/packages/perseus-core/src/parse-perseus-json/regression-tests/__snapshots__/parse-perseus-json-regression.test.ts.snap b/packages/perseus-core/src/parse-perseus-json/regression-tests/__snapshots__/parse-perseus-json-regression.test.ts.snap index 704019364d6..ea84dc04f61 100644 --- a/packages/perseus-core/src/parse-perseus-json/regression-tests/__snapshots__/parse-perseus-json-regression.test.ts.snap +++ b/packages/perseus-core/src/parse-perseus-json/regression-tests/__snapshots__/parse-perseus-json-regression.test.ts.snap @@ -864,11 +864,12 @@ exports[`parseAndMigratePerseusItem given cs-program-missing-static.ts returns t "options": { "answers": [ { - "answerForms": undefined, + "maxError": 0.05, "message": "", "simplify": "required", "status": "correct", "strict": false, + "value": null, }, ], "coefficient": false, @@ -1465,11 +1466,12 @@ $r = $ [[☃ numeric-input 1]] ", "options": { "answers": [ { - "answerForms": undefined, + "maxError": null, "message": "", "simplify": "required", "status": "correct", "strict": false, + "value": null, }, ], "coefficient": false, @@ -4684,32 +4686,48 @@ exports[`parseAndMigratePerseusItem given input-number-with-boolean-value.ts ret "input-number 1": { "graded": true, "options": { - "answerType": "number", - "inexact": false, - "maxError": 0.1, - "simplify": "required", + "answers": [ + { + "answerForms": [], + "maxError": 0, + "message": "", + "simplify": "required", + "status": "correct", + "strict": true, + "value": 0, + }, + ], + "coefficient": false, + "rightAlign": undefined, "size": "small", - "value": 0, }, "type": "input-number", "version": { - "major": 0, + "major": 1, "minor": 0, }, }, "input-number 2": { "graded": true, "options": { - "answerType": "number", - "inexact": false, - "maxError": 0.1, - "simplify": "required", + "answers": [ + { + "answerForms": [], + "maxError": 0, + "message": "", + "simplify": "required", + "status": "correct", + "strict": true, + "value": 0, + }, + ], + "coefficient": false, + "rightAlign": undefined, "size": "small", - "value": "true", }, "type": "input-number", "version": { - "major": 0, + "major": 1, "minor": 0, }, }, @@ -4812,18 +4830,25 @@ exports[`parseAndMigratePerseusItem given input-number-with-boolean-value.ts ret "alignment": "default", "graded": true, "options": { - "answerType": "number", - "inexact": false, - "maxError": 0.1, + "answers": [ + { + "answerForms": [], + "maxError": 0, + "message": "", + "simplify": "required", + "status": "correct", + "strict": true, + "value": null, + }, + ], + "coefficient": false, "rightAlign": false, - "simplify": "required", "size": "small", - "value": 0, }, "static": false, "type": "input-number", "version": { - "major": 0, + "major": 1, "minor": 0, }, }, @@ -4831,18 +4856,25 @@ exports[`parseAndMigratePerseusItem given input-number-with-boolean-value.ts ret "alignment": "default", "graded": true, "options": { - "answerType": "number", - "inexact": false, - "maxError": 0.1, + "answers": [ + { + "answerForms": [], + "maxError": 0, + "message": "", + "simplify": "required", + "status": "correct", + "strict": true, + "value": null, + }, + ], + "coefficient": false, "rightAlign": false, - "simplify": "required", "size": "small", - "value": 0, }, "static": false, "type": "input-number", "version": { - "major": 0, + "major": 1, "minor": 0, }, }, @@ -11777,11 +11809,12 @@ $9 \\times 5 =$ [[☃ numeric-input 1]]", "options": { "answers": [ { - "answerForms": undefined, + "maxError": null, "message": "", "simplify": "required", "status": "correct", "strict": false, + "value": null, }, ], "coefficient": false, @@ -14117,11 +14150,12 @@ exports[`parseAndMigratePerseusItem given number-line-with-null-correctX.ts retu "options": { "answers": [ { - "answerForms": undefined, + "maxError": null, "message": "", "simplify": "required", "status": "correct", "strict": false, + "value": null, }, ], "coefficient": false, @@ -14210,10 +14244,12 @@ exports[`parseAndMigratePerseusItem given numeric-input-answer-with-null-value.t "answers": [ { "answerForms": [], + "maxError": null, "message": "", "simplify": "required", "status": "correct", "strict": false, + "value": null, }, ], "coefficient": false, @@ -14326,11 +14362,12 @@ exports[`parseAndMigratePerseusItem given numeric-input-answer-with-simplify-tru "options": { "answers": [ { - "answerForms": undefined, + "maxError": 0, "message": "", "simplify": "required", "status": "correct", "strict": false, + "value": null, }, ], "coefficient": false, @@ -14518,10 +14555,12 @@ exports[`parseAndMigratePerseusItem given numeric-input-answer-without-value.ts "proper", "improper", ], + "maxError": null, "message": "", "simplify": "required", "status": "correct", "strict": false, + "value": null, }, ], "coefficient": false, @@ -14618,10 +14657,12 @@ What is the radius of the mattress when it is rolled up into a cylinder. "answers": [ { "answerForms": [], + "maxError": null, "message": "", "simplify": "required", "status": "correct", "strict": false, + "value": null, }, ], "coefficient": false, @@ -14692,16 +14733,24 @@ exports[`parseAndMigratePerseusItem given numeric-input-missing-labelText.ts ret "input-number 1": { "graded": true, "options": { - "answerType": "number", - "inexact": false, - "maxError": 0.1, - "simplify": "required", + "answers": [ + { + "answerForms": [], + "maxError": 0, + "message": "", + "simplify": "required", + "status": "correct", + "strict": true, + "value": 0, + }, + ], + "coefficient": false, + "rightAlign": undefined, "size": "normal", - "value": 0, }, "type": "input-number", "version": { - "major": 0, + "major": 1, "minor": 0, }, }, @@ -15016,11 +15065,12 @@ Heart rate is described as the number of heart beats per minute. The normal hum "options": { "answers": [ { - "answerForms": undefined, + "maxError": null, "message": "", "simplify": "required", "status": "correct", "strict": false, + "value": null, }, ], "coefficient": false, @@ -15534,11 +15584,12 @@ exports[`parseAndMigratePerseusItem given numeric-input-with-null-answerForms.ts "options": { "answers": [ { - "answerForms": undefined, + "maxError": null, "message": "", "simplify": "required", "status": "correct", "strict": false, + "value": null, }, ], "coefficient": false, @@ -15560,11 +15611,12 @@ exports[`parseAndMigratePerseusItem given numeric-input-with-null-answerForms.ts "options": { "answers": [ { - "answerForms": undefined, + "maxError": null, "message": "", "simplify": "required", "status": "correct", "strict": false, + "value": null, }, ], "coefficient": false, @@ -15586,11 +15638,12 @@ exports[`parseAndMigratePerseusItem given numeric-input-with-null-answerForms.ts "options": { "answers": [ { - "answerForms": undefined, + "maxError": null, "message": "", "simplify": "required", "status": "correct", "strict": false, + "value": null, }, ], "coefficient": false, @@ -15941,32 +15994,36 @@ exports[`parseAndMigratePerseusItem given numeric-input-with-simplify-accepted.t "options": { "answers": [ { - "answerForms": undefined, + "maxError": 0.01, "message": "", "simplify": "required", "status": "correct", "strict": false, + "value": null, }, { - "answerForms": undefined, + "maxError": 0.2, "message": "", "simplify": "required", "status": "ungraded", "strict": false, + "value": null, }, { - "answerForms": undefined, + "maxError": 0.5, "message": "", "simplify": "required", "status": "ungraded", "strict": false, + "value": null, }, { - "answerForms": undefined, + "maxError": 0.1, "message": "", "simplify": "required", "status": "wrong", "strict": false, + "value": null, }, ], "coefficient": false, @@ -16073,11 +16130,12 @@ exports[`parseAndMigratePerseusItem given numeric-input-with-simplify-false.ts r "options": { "answers": [ { - "answerForms": undefined, + "maxError": 0, "message": "", "simplify": "required", "status": "correct", "strict": false, + "value": null, }, ], "coefficient": false, @@ -16325,11 +16383,12 @@ exports[`parseAndMigratePerseusItem given numeric-input-with-simplify-true-strin "options": { "answers": [ { - "answerForms": undefined, + "maxError": 0, "message": "", "simplify": "required", "status": "correct", "strict": false, + "value": null, }, ], "coefficient": false, @@ -17973,16 +18032,24 @@ $\\green5 - \\red3= \\purple{2}$", "input-number 1": { "graded": true, "options": { - "answerType": "number", - "inexact": false, - "maxError": 0.1, - "simplify": "required", + "answers": [ + { + "answerForms": [], + "maxError": 0, + "message": "", + "simplify": "required", + "status": "correct", + "strict": true, + "value": 2, + }, + ], + "coefficient": false, + "rightAlign": undefined, "size": "normal", - "value": 2, }, "type": "input-number", "version": { - "major": 0, + "major": 1, "minor": 0, }, }, @@ -18075,18 +18142,25 @@ exports[`parseAndMigratePerseusItem given plotter-missing-scaleY-and-snapsPerLin "alignment": "default", "graded": true, "options": { - "answerType": "number", - "inexact": false, - "maxError": 0.1, + "answers": [ + { + "answerForms": [], + "maxError": 0, + "message": "", + "simplify": "required", + "status": "correct", + "strict": true, + "value": null, + }, + ], + "coefficient": false, "rightAlign": false, - "simplify": "required", "size": "normal", - "value": 0, }, "static": false, "type": "input-number", "version": { - "major": 0, + "major": 1, "minor": 0, }, }, diff --git a/packages/perseus-core/src/utils/extract-perseus-ai-data.test.ts b/packages/perseus-core/src/utils/extract-perseus-ai-data.test.ts index 9dc17530b55..dde20c09624 100644 --- a/packages/perseus-core/src/utils/extract-perseus-ai-data.test.ts +++ b/packages/perseus-core/src/utils/extract-perseus-ai-data.test.ts @@ -17,10 +17,16 @@ import { generateExplanationOptions, generateExplanationWidget, } from "./generators/explanation-widget-generator"; +import {generateExpressionWidget} from "./generators/expression-widget-generator"; import { generateGradedGroupOptions, generateGradedGroupWidget, } from "./generators/graded-group-widget-generator"; +import { + generateInputNumberAnswer, + generateInputNumberOptions, + generateInputNumberWidget, +} from "./generators/input-number-widget-generator"; import { generateIGPolygonGraph, generateInteractiveGraphOptions, @@ -58,6 +64,7 @@ import type { NumericInputWidget, ExpressionWidget, CategorizerWidget, + InputNumberWidget, } from "../data-schema"; const stub: jest.MockedFunction = jest.fn(); @@ -809,49 +816,9 @@ describe("injectWidgets", () => { it("should inject ? placeholder string for input widgets", () => { const widgets: PerseusWidgetsMap = { - "numeric-input 1": { - type: "numeric-input", - options: { - answers: [ - { - message: "rationale", - value: 42, - status: "correct", - strict: false, - maxError: 0, - simplify: "required", - }, - ], - labelText: "Enter a number", - size: "normal", - coefficient: false, - }, - }, - "input-number 1": { - type: "input-number", - options: { - value: 42, - simplify: "required", - size: "normal", - }, - }, - "expression 1": { - type: "expression", - options: { - answerForms: [ - { - value: "27\\pi", - form: false, - simplify: false, - considered: "correct", - key: "0", - }, - ], - buttonSets: ["basic", "prealgebra"], - functions: ["f", "g", "h"], - times: false, - }, - }, + "numeric-input 1": generateNumericInputWidget(), + "input-number 1": generateInputNumberWidget(), + "expression 1": generateExpressionWidget(), }; const content = injectWidgets( "Enter your numeric-input [[☃ numeric-input 1]], Enter your input-number [[☃ input-number 1]], Enter your expression [[☃ expression 1]]", @@ -953,14 +920,11 @@ describe("getAnswersFromWidgets", () => { }); it("should get the answer from a input-number widget", () => { - const widget = { - type: "input-number", - options: { - value: 42, - simplify: "required", - size: "normal", - }, - } as const; + const widget: InputNumberWidget = generateInputNumberWidget({ + options: generateInputNumberOptions({ + answers: [generateInputNumberAnswer({value: 42})], + }), + }); const answer = getAnswersFromWidgets({"input-number 1": widget}); expect(answer).toEqual(["42"]); }); @@ -1047,14 +1011,11 @@ describe("getAnswersFromWidgets", () => { ], }, }, - "input-number 1": { - type: "input-number", - options: { - value: 42, - simplify: "required", - size: "normal", - }, - }, + "input-number 1": generateInputNumberWidget({ + options: generateInputNumberOptions({ + answers: [generateInputNumberAnswer({value: 42})], + }), + }), }, }, }; diff --git a/packages/perseus-core/src/utils/extract-perseus-ai-data.ts b/packages/perseus-core/src/utils/extract-perseus-ai-data.ts index 619312fdbaa..a3c0211de89 100644 --- a/packages/perseus-core/src/utils/extract-perseus-ai-data.ts +++ b/packages/perseus-core/src/utils/extract-perseus-ai-data.ts @@ -103,6 +103,7 @@ export function getAnswersFromWidgets( } } break; + case "input-number": case "numeric-input": // Answer is the numeric value cast to a string const numericInput = widget; @@ -115,14 +116,6 @@ export function getAnswersFromWidgets( } } break; - case "input-number": - // Answer is the correct value - const inputNumber = widget; - // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions - if (inputNumber.options?.value) { - answers.push(inputNumber.options.value.toString()); - } - break; case "expression": // Answer is a list of potential correct expressions, // since there can be multiple correct forms of the expression diff --git a/packages/perseus-core/src/utils/generators/input-number-widget-generator.test.ts b/packages/perseus-core/src/utils/generators/input-number-widget-generator.test.ts new file mode 100644 index 00000000000..b78dd9c657a --- /dev/null +++ b/packages/perseus-core/src/utils/generators/input-number-widget-generator.test.ts @@ -0,0 +1,16 @@ +import {describe, expect, it} from "@jest/globals"; + +import {parse} from "../../parse-perseus-json/parse"; +import {parseInputNumberWidget} from "../../parse-perseus-json/perseus-parsers/input-number-widget"; + +import {generateInputNumberWidget} from "./input-number-widget-generator"; + +describe("generateInputNumberWidget", () => { + it("produces a value that parses successfully", () => { + const widget = generateInputNumberWidget(); + + const parsed = parse(widget, parseInputNumberWidget); + + expect(parsed).toEqual(expect.objectContaining({type: "success"})); + }); +}); diff --git a/packages/perseus-core/src/utils/generators/input-number-widget-generator.ts b/packages/perseus-core/src/utils/generators/input-number-widget-generator.ts index 2c94adf3e45..6866fe79733 100644 --- a/packages/perseus-core/src/utils/generators/input-number-widget-generator.ts +++ b/packages/perseus-core/src/utils/generators/input-number-widget-generator.ts @@ -3,17 +3,19 @@ import inputNumberWidgetLogic from "../../widgets/input-number"; import type { InputNumberWidget, PerseusInputNumberWidgetOptions, + PerseusInputNumberAnswer, } from "../../data-schema"; -// TODO(LEMS-4085): Delete this file. - export function generateInputNumberWidget( - inputNumberWidgetProperties?: Partial>, + inputNumberWidgetProperties?: Partial< + Omit + >, ): InputNumberWidget { return { type: "input-number", graded: true, static: false, + version: {major: 1, minor: 0}, options: generateInputNumberOptions(), ...inputNumberWidgetProperties, }; @@ -27,3 +29,12 @@ export function generateInputNumberOptions( ...options, }; } + +export function generateInputNumberAnswer( + params?: Partial, +): PerseusInputNumberAnswer { + return { + ...inputNumberWidgetLogic.defaultWidgetOptions.answers[0], + ...params, + }; +} diff --git a/packages/perseus-core/src/utils/split-perseus-item.test.ts b/packages/perseus-core/src/utils/split-perseus-item.test.ts index 9e5bce2a354..fd4bf81c46f 100644 --- a/packages/perseus-core/src/utils/split-perseus-item.test.ts +++ b/packages/perseus-core/src/utils/split-perseus-item.test.ts @@ -246,9 +246,13 @@ describe("splitPerseusItem", () => { rightAlign: false, answers: [ { + value: null, simplify: "required", status: "correct", + maxError: null, + strict: false, answerForms: ["pi"], + message: "", }, ], }, diff --git a/packages/perseus-core/src/utils/split-perseus-renderer.test.ts b/packages/perseus-core/src/utils/split-perseus-renderer.test.ts index 750d9c111e2..317a22706d1 100644 --- a/packages/perseus-core/src/utils/split-perseus-renderer.test.ts +++ b/packages/perseus-core/src/utils/split-perseus-renderer.test.ts @@ -141,7 +141,11 @@ describe("splitPerseusRenderer", () => { { simplify: "required", status: "correct", + value: null, + maxError: null, answerForms: ["pi"], + message: "", + strict: false, }, ], }, diff --git a/packages/perseus-core/src/validation.types.ts b/packages/perseus-core/src/validation.types.ts index 2f0603163f7..56526883f46 100644 --- a/packages/perseus-core/src/validation.types.ts +++ b/packages/perseus-core/src/validation.types.ts @@ -45,7 +45,6 @@ import type { MakeWidgetMap, PerseusFreeResponseWidgetScoringCriterion, PerseusRenderer, - PerseusInputNumberWidgetOptions, } from "./data-schema"; import type {ErrorCode} from "./error-codes"; import type {Relationship} from "./types"; @@ -556,8 +555,7 @@ export interface RubricRegistry { "graded-group": PerseusGradedGroupRubric; grapher: PerseusGrapherRubric; group: PerseusGroupRubric; - // TODO(LEMS-4085): change to PerseusNumericInputRubric; - "input-number": PerseusInputNumberWidgetOptions; + "input-number": PerseusNumericInputRubric; "interactive-graph": PerseusInteractiveGraphRubric; "label-image": PerseusLabelImageRubric; matcher: PerseusMatcherRubric; diff --git a/packages/perseus-core/src/widgets/input-number/index.ts b/packages/perseus-core/src/widgets/input-number/index.ts index e36637e381c..2025c4dcfc6 100644 --- a/packages/perseus-core/src/widgets/input-number/index.ts +++ b/packages/perseus-core/src/widgets/input-number/index.ts @@ -4,25 +4,23 @@ import type {InputNumberPublicWidgetOptions} from "./input-number-util"; import type {PerseusInputNumberWidgetOptions} from "../../data-schema"; import type {WidgetLogic} from "../logic-export.types"; -export type InputNumberDefaultWidgetOptions = Pick< - PerseusInputNumberWidgetOptions, - | "value" - | "simplify" - | "size" - | "inexact" - | "maxError" - | "answerType" - | "rightAlign" ->; +export type InputNumberDefaultWidgetOptions = PerseusInputNumberWidgetOptions; const defaultWidgetOptions: InputNumberDefaultWidgetOptions = { - value: 0, - simplify: "required", - size: "normal", - inexact: false, - maxError: 0.1, - answerType: "number", rightAlign: false, + coefficient: false, + size: "normal", + answers: [ + { + status: "correct", + value: 0, + simplify: "required", + maxError: 0, + answerForms: [], + message: "", + strict: true, + }, + ], }; const inputNumberWidgetLogic: WidgetLogic< diff --git a/packages/perseus-core/src/widgets/input-number/input-number-util.test.ts b/packages/perseus-core/src/widgets/input-number/input-number-util.test.ts index 2e067eaf0e0..be035020709 100644 --- a/packages/perseus-core/src/widgets/input-number/input-number-util.test.ts +++ b/packages/perseus-core/src/widgets/input-number/input-number-util.test.ts @@ -3,16 +3,35 @@ import {getInputNumberPublicWidgetOptions} from "./input-number-util"; import type {PerseusInputNumberWidgetOptions} from "../../data-schema"; describe("getInputNumberPublicWidgetOptions", () => { - it("removes value", () => { + it("removes the answer `value` and `message`", () => { const original: PerseusInputNumberWidgetOptions = { - simplify: "optional", size: "normal", - value: 42, + coefficient: false, + answers: [ + { + status: "correct", + simplify: "optional", + value: 42, + answerForms: ["pi"], + message: "this should be removed", + strict: true, + }, + ], }; expect(getInputNumberPublicWidgetOptions(original)).toEqual({ - simplify: "optional", size: "normal", + coefficient: false, + answers: [ + { + status: "correct", + simplify: "optional", + value: null, + answerForms: ["pi"], + message: "", + strict: true, + }, + ], }); }); }); diff --git a/packages/perseus-core/src/widgets/input-number/input-number-util.ts b/packages/perseus-core/src/widgets/input-number/input-number-util.ts index 30b8f21fb4c..b210e42200f 100644 --- a/packages/perseus-core/src/widgets/input-number/input-number-util.ts +++ b/packages/perseus-core/src/widgets/input-number/input-number-util.ts @@ -1,21 +1,16 @@ -import type {PerseusInputNumberWidgetOptions} from "../../data-schema"; +import {getNumericInputPublicWidgetOptions} from "../numeric-input/numeric-input-util"; + +import type {NumericInputPublicWidgetOptions} from "../numeric-input/numeric-input-util"; /** * For details on the individual options, see the * PerseusInputNumberWidgetOptions type */ -export type InputNumberPublicWidgetOptions = Pick< - PerseusInputNumberWidgetOptions, - "answerType" | "inexact" | "maxError" | "rightAlign" | "simplify" | "size" ->; +export type InputNumberPublicWidgetOptions = NumericInputPublicWidgetOptions; /** * Given a PerseusInputNumberWidgetOptions object, return a new object with only * the public options that should be exposed to the client. */ -export function getInputNumberPublicWidgetOptions( - options: PerseusInputNumberWidgetOptions, -): InputNumberPublicWidgetOptions { - const {value: _, ...publicWidgetOptions} = options; - return publicWidgetOptions; -} +export const getInputNumberPublicWidgetOptions = + getNumericInputPublicWidgetOptions; diff --git a/packages/perseus-core/src/widgets/input-number/input-number-util.typetest.ts b/packages/perseus-core/src/widgets/input-number/input-number-util.typetest.ts new file mode 100644 index 00000000000..4d6b91afc73 --- /dev/null +++ b/packages/perseus-core/src/widgets/input-number/input-number-util.typetest.ts @@ -0,0 +1,19 @@ +import {describe, expect, it} from "tstyche"; + +import {getInputNumberPublicWidgetOptions} from "./input-number-util"; + +import type {PerseusInputNumberWidgetOptions} from "../../data-schema"; + +describe("getInputNumberPublicWidgetOptions", () => { + it("returns a type assignable to the data-schema types", () => { + const answerful = summon(); + expect( + getInputNumberPublicWidgetOptions(answerful), + ).type.toBeAssignableTo(); + }); +}); + +function summon(): T { + // eslint-disable-next-line no-restricted-syntax + return null as T; +} diff --git a/packages/perseus-core/src/widgets/input-number/to-numeric-input.test.ts b/packages/perseus-core/src/widgets/input-number/to-numeric-input.test.ts deleted file mode 100644 index cd87b16a635..00000000000 --- a/packages/perseus-core/src/widgets/input-number/to-numeric-input.test.ts +++ /dev/null @@ -1,206 +0,0 @@ -import {describe, it, expect} from "@jest/globals"; - -import {convertInputNumberOptionsToNumericInput} from "./to-numeric-input"; - -import type {PerseusInputNumberWidgetOptions} from "../../data-schema"; - -const baseOptions: PerseusInputNumberWidgetOptions = { - simplify: "required", - size: "normal", - value: "0", -}; - -describe("convertInputNumberOptionsToNumericInput", () => { - it("converts maxError to a number when present", () => { - const options: PerseusInputNumberWidgetOptions = { - ...baseOptions, - inexact: true, - maxError: "0.042", - }; - - const result = convertInputNumberOptionsToNumericInput(options); - - expect(result.answers).toHaveLength(1); - expect(result.answers[0].maxError).toBe(0.042); - }); - - it("converts maxError values in exponent notation", () => { - const options: PerseusInputNumberWidgetOptions = { - ...baseOptions, - maxError: "42e-3", - inexact: true, - }; - - const result = convertInputNumberOptionsToNumericInput(options); - - expect(result.answers).toHaveLength(1); - expect(result.answers[0].maxError).toBe(0.042); - }); - - it("leaves maxError undefined when undefined", () => { - const options: PerseusInputNumberWidgetOptions = { - ...baseOptions, - maxError: undefined, - inexact: true, - }; - - const result = convertInputNumberOptionsToNumericInput(options); - - expect(result.answers).toHaveLength(1); - expect(result.answers[0].maxError).toBe(undefined); - }); - - it("sets maxError to 0 when inexact is false", () => { - const options: PerseusInputNumberWidgetOptions = { - ...baseOptions, - maxError: 0.99, - inexact: false, - }; - - const result = convertInputNumberOptionsToNumericInput(options); - - expect(result.answers[0].maxError).toBe(0); - }); - - it("sets maxError to 0 when inexact is undefined", () => { - const options: PerseusInputNumberWidgetOptions = { - ...baseOptions, - maxError: 0.99, - inexact: undefined, - }; - - const result = convertInputNumberOptionsToNumericInput(options); - - expect(result.answers[0].maxError).toBe(0); - }); - - it(`converts the "number" answer type to an empty array`, () => { - const options: PerseusInputNumberWidgetOptions = { - ...baseOptions, - answerType: "number", - }; - - const result = convertInputNumberOptionsToNumericInput(options); - - expect(result.answers).toHaveLength(1); - expect(result.answers[0].answerForms).toEqual([]); - }); - - it(`converts the "rational" answer type to [integer, proper, improper, mixed]`, () => { - const options: PerseusInputNumberWidgetOptions = { - ...baseOptions, - answerType: "rational", - }; - - const result = convertInputNumberOptionsToNumericInput(options); - - expect(result.answers).toHaveLength(1); - expect(result.answers[0].answerForms).toEqual([ - "integer", - "proper", - "improper", - "mixed", - ]); - }); - - it("defaults answerForms to an empty array when answerType is undefined", () => { - const options: PerseusInputNumberWidgetOptions = { - ...baseOptions, - answerType: undefined, - }; - - const result = convertInputNumberOptionsToNumericInput(options); - - expect(result.answers).toHaveLength(1); - expect(result.answers[0].answerForms).toEqual([]); - }); - - it("converts the `value` to a number", () => { - const options: PerseusInputNumberWidgetOptions = { - ...baseOptions, - value: "1.5", - }; - - const result = convertInputNumberOptionsToNumericInput(options); - - expect(result.answers).toHaveLength(1); - expect(result.answers[0].value).toEqual(1.5); - }); - - it("excludes `decimal` and `integer` from `answerForms` when answerType is number, inexact is false, and `value` has more than 10 decimal places", () => { - const options: PerseusInputNumberWidgetOptions = { - ...baseOptions, - answerType: "number", - inexact: false, - value: "1.12345678901", - }; - - const result = convertInputNumberOptionsToNumericInput(options); - expect(result.answers).toHaveLength(1); - expect(result.answers[0].answerForms).toEqual([ - "proper", - "improper", - "mixed", - ]); - }); - - it("excludes `decimal` and `integer` from `answerForms` when answerType is undefined, inexact is false, and `value` has more than 10 decimal places", () => { - const options: PerseusInputNumberWidgetOptions = { - ...baseOptions, - answerType: undefined, - inexact: false, - value: "1.12345678901", - }; - - const result = convertInputNumberOptionsToNumericInput(options); - expect(result.answers).toHaveLength(1); - expect(result.answers[0].answerForms).toEqual([ - "proper", - "improper", - "mixed", - ]); - }); - - it("excludes `decimal` and `integer` from `answerForms` when inexact is undefined and `value` has more than 10 decimal places", () => { - const options: PerseusInputNumberWidgetOptions = { - ...baseOptions, - answerType: undefined, - inexact: undefined, - value: "1.12345678901", - }; - - const result = convertInputNumberOptionsToNumericInput(options); - expect(result.answers).toHaveLength(1); - expect(result.answers[0].answerForms).toEqual([ - "proper", - "improper", - "mixed", - ]); - }); - - it("returns an empty array for answerForms when inexact is true and value has more than 10 decimal places", () => { - const options: PerseusInputNumberWidgetOptions = { - ...baseOptions, - answerType: undefined, - inexact: true, - value: "1.12345678901", - }; - - const result = convertInputNumberOptionsToNumericInput(options); - expect(result.answers).toHaveLength(1); - expect(result.answers[0].answerForms).toEqual([]); - }); - - it("returns an empty array for answerForms when the answer has exactly 10 decimal places", () => { - const options: PerseusInputNumberWidgetOptions = { - ...baseOptions, - answerType: undefined, - inexact: undefined, - value: "0.1231231234", - }; - - const result = convertInputNumberOptionsToNumericInput(options); - expect(result.answers).toHaveLength(1); - expect(result.answers[0].answerForms).toEqual([]); - }); -}); diff --git a/packages/perseus-core/src/widgets/input-number/to-numeric-input.ts b/packages/perseus-core/src/widgets/input-number/to-numeric-input.ts deleted file mode 100644 index 9594640f727..00000000000 --- a/packages/perseus-core/src/widgets/input-number/to-numeric-input.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { - MathFormat, - PerseusInputNumberAnswerType, - PerseusInputNumberWidgetOptions, - PerseusNumericInputWidgetOptions, -} from "../../data-schema"; - -// TODO(LEMS-4085): move this function to the input-number widget parser; it -// should only be used there by the end of this project. -export function convertInputNumberOptionsToNumericInput( - inputNumberOptions: PerseusInputNumberWidgetOptions, -): PerseusNumericInputWidgetOptions { - return { - coefficient: false, - rightAlign: inputNumberOptions.rightAlign, - size: inputNumberOptions.size, - answers: [ - { - status: "correct", - value: Number(inputNumberOptions.value), - simplify: inputNumberOptions.simplify, - message: "", - maxError: getMaxError(inputNumberOptions), - strict: true, - answerForms: getAnswerForms(inputNumberOptions), - }, - ], - }; -} - -function getMaxError( - inputNumberOptions: PerseusInputNumberWidgetOptions, -): number | undefined { - if (!inputNumberOptions.inexact) { - return 0; - } - - if (inputNumberOptions.maxError == null) { - return undefined; - } - - return Number(inputNumberOptions.maxError); -} - -// NOTE: the information in mathFormatsForAnswerType is duplicated in -// score-input-number.ts. I think this is okay because inputNumber is -// deprecated and the scoring logic will be removed as part of this project. -const mathFormatsForAnswerType: Record< - PerseusInputNumberAnswerType, - MathFormat[] -> = { - number: [], - decimal: ["decimal"], - integer: ["integer"], - rational: ["integer", "proper", "improper", "mixed"], - improper: ["integer", "proper", "improper"], - mixed: ["integer", "proper", "mixed"], - percent: ["integer", "decimal", "proper", "improper", "mixed", "percent"], - pi: ["pi"], -}; - -function getAnswerForms( - options: PerseusInputNumberWidgetOptions, -): MathFormat[] { - const value = Number(options.value); - const {inexact} = options; - const precision = 1e10; - const rounded = Math.round(value * precision) / precision; - - const answerType = options.answerType ?? "number"; - if (answerType === "number" && !inexact && !equalFloats(rounded, value)) { - // Disallow decimal answers when the correct answer has more than 10 - // decimal places. This is for compatibility with legacy input-number - // behavior. - return ["proper", "improper", "mixed"]; - } - - return mathFormatsForAnswerType[answerType]; -} - -function equalFloats(a: number, b: number): boolean { - return Math.abs(a - b) < Math.pow(2, -42); -} diff --git a/packages/perseus-core/src/widgets/numeric-input/numeric-input-util.test.ts b/packages/perseus-core/src/widgets/numeric-input/numeric-input-util.test.ts index 47cfed3eab9..a66eeb79e6c 100644 --- a/packages/perseus-core/src/widgets/numeric-input/numeric-input-util.test.ts +++ b/packages/perseus-core/src/widgets/numeric-input/numeric-input-util.test.ts @@ -9,12 +9,12 @@ describe("getNumericInputPublicWidgetOptions", () => { answers: [ { status: "correct", - maxError: null, - strict: false, + maxError: 0.07, + strict: true, value: 1252, answerForms: ["pi"], simplify: "required", - message: "", + message: "the answer is 1252", }, ], labelText: "labelText", @@ -35,8 +35,12 @@ describe("getNumericInputPublicWidgetOptions", () => { answers: [ { status: "correct", + value: null, answerForms: ["pi"], simplify: "required", + maxError: 0.07, + strict: true, + message: "", }, ], }); diff --git a/packages/perseus-core/src/widgets/numeric-input/numeric-input-util.ts b/packages/perseus-core/src/widgets/numeric-input/numeric-input-util.ts index 6e7450306f6..b9633346ea2 100644 --- a/packages/perseus-core/src/widgets/numeric-input/numeric-input-util.ts +++ b/packages/perseus-core/src/widgets/numeric-input/numeric-input-util.ts @@ -3,22 +3,11 @@ import type { PerseusNumericInputWidgetOptions, } from "../../data-schema"; -type NumericInputAnswerPublicData = Pick< - PerseusNumericInputAnswer, - "answerForms" | "simplify" | "status" ->; - /** * For details on the individual options, see the * PerseusNumericInputWidgetOptions type */ -export type NumericInputPublicWidgetOptions = { - labelText?: PerseusNumericInputWidgetOptions["labelText"]; - size: PerseusNumericInputWidgetOptions["size"]; - coefficient: PerseusNumericInputWidgetOptions["coefficient"]; - rightAlign?: PerseusNumericInputWidgetOptions["rightAlign"]; - answers: ReadonlyArray; -}; +export type NumericInputPublicWidgetOptions = PerseusNumericInputWidgetOptions; /** * This data from `answers` is used pre-scoring to give hints @@ -26,12 +15,11 @@ export type NumericInputPublicWidgetOptions = { */ function getNumericInputAnswerPublicData( answer: PerseusNumericInputAnswer, -): NumericInputAnswerPublicData { - const {answerForms, simplify, status} = answer; +): PerseusNumericInputAnswer { return { - answerForms, - simplify, - status, + ...answer, + value: null, + message: "", }; } diff --git a/packages/perseus-editor/package.json b/packages/perseus-editor/package.json index ab76181538c..b856547e09b 100644 --- a/packages/perseus-editor/package.json +++ b/packages/perseus-editor/package.json @@ -41,7 +41,6 @@ "@khanacademy/perseus": "workspace:*", "@khanacademy/perseus-core": "workspace:*", "@khanacademy/perseus-linter": "workspace:*", - "@khanacademy/perseus-score": "workspace:*", "@khanacademy/perseus-utils": "workspace:*", "axe-core": "^4.11.0", "katex": "0.11.1", diff --git a/packages/perseus-editor/src/__testdata__/all-widgets.testdata.ts b/packages/perseus-editor/src/__testdata__/all-widgets.testdata.ts index 3186d1d2b4b..c01a6efdcbe 100644 --- a/packages/perseus-editor/src/__testdata__/all-widgets.testdata.ts +++ b/packages/perseus-editor/src/__testdata__/all-widgets.testdata.ts @@ -239,12 +239,19 @@ export const comprehensiveQuestion: PerseusRenderer = { static: false, type: "input-number", options: { - value: 0.5, - simplify: "optional", size: "normal", - inexact: false, - maxError: 0.1, - answerType: "rational", + coefficient: false, + answers: [ + { + status: "correct", + value: 0.5, + simplify: "optional", + maxError: 0, + answerForms: ["integer", "proper", "improper", "mixed"], + message: "", + strict: true, + }, + ], }, }, "free-response 1": generateFreeResponseWidget({ diff --git a/packages/perseus-editor/src/widgets/__tests__/input-number-editor.test.tsx b/packages/perseus-editor/src/widgets/__tests__/input-number-editor.test.tsx deleted file mode 100644 index 91966eeabf7..00000000000 --- a/packages/perseus-editor/src/widgets/__tests__/input-number-editor.test.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import {Dependencies} from "@khanacademy/perseus"; -import {render, screen} from "@testing-library/react"; -import {userEvent as userEventLib} from "@testing-library/user-event"; -import * as React from "react"; - -import {testDependencies} from "../../testing/test-dependencies"; -import InputNumberEditor from "../input-number-editor"; - -import type {UserEvent} from "@testing-library/user-event"; - -describe("input-number-editor", () => { - let userEvent: UserEvent; - beforeEach(() => { - userEvent = userEventLib.setup({ - advanceTimers: jest.advanceTimersByTime, - }); - - jest.spyOn(Dependencies, "getDependencies").mockReturnValue( - testDependencies, - ); - }); - - it("should render", async () => { - render( undefined} />); - - expect(await screen.findByText("Correct answer:")).toBeInTheDocument(); - }); - - it("should be possible to change the correct answer", async () => { - const onChangeMock = jest.fn(); - - render(); - - const input = screen.getByRole("textbox", {name: "Correct answer:"}); - await userEvent.type(input, "1"); - input.blur(); - - expect(onChangeMock).toHaveBeenCalledWith( - expect.objectContaining({value: 1}), - ); - }); - - it("should be possible to change allow inexact answers", async () => { - const onChangeMock = jest.fn(); - - render(); - - await userEvent.click( - screen.getByRole("checkbox", {name: "Allow inexact answers"}), - ); - - expect(onChangeMock).toHaveBeenCalledWith( - expect.objectContaining({inexact: true}), - ); - }); - - it("should be possible to change right alignment", async () => { - const onChangeMock = jest.fn(); - - render(); - - await userEvent.click( - screen.getByRole("checkbox", {name: "Right alignment"}), - ); - - expect(onChangeMock).toHaveBeenCalledWith( - expect.objectContaining({rightAlign: true}), - ); - }); - - const unsimplifiedOptions = ["required", "optional", "enforced"]; - unsimplifiedOptions.forEach((opt) => { - it(`should be possible to set unsimplified answers to: ${opt}`, async () => { - const onChangeMock = jest.fn(); - - render(); - - const select = screen.getByRole("combobox", { - name: "Unsimplified answers", - }); - await userEvent.selectOptions(select, opt); - - expect(onChangeMock).toHaveBeenCalledWith( - expect.objectContaining({simplify: opt}), - ); - }); - }); - - const answerTypeOptions = [ - "number", - "decimal", - "integer", - "rational", - "improper", - "mixed", - "percent", - "pi", - ]; - answerTypeOptions.forEach((opt) => { - it(`should be possible to set answer type to: ${opt}`, async () => { - const onChangeMock = jest.fn(); - - render(); - - const select = screen.getByRole("combobox", { - name: "Answer type", - }); - await userEvent.selectOptions(select, opt); - - expect(onChangeMock).toHaveBeenCalledWith( - expect.objectContaining({answerType: opt}), - ); - }); - }); - - const sizeOptions = ["normal", "small"]; - sizeOptions.forEach((opt) => { - it(`should be possible to set unsimplified answers to: ${opt}`, async () => { - const onChangeMock = jest.fn(); - - render(); - - const select = screen.getByRole("combobox", { - name: "Width", - }); - await userEvent.selectOptions(select, opt); - - expect(onChangeMock).toHaveBeenCalledWith( - expect.objectContaining({size: opt}), - ); - }); - }); -}); diff --git a/packages/perseus-editor/src/widgets/input-number-editor.tsx b/packages/perseus-editor/src/widgets/input-number-editor.tsx index fd6376bbabb..2fb1912d90f 100644 --- a/packages/perseus-editor/src/widgets/input-number-editor.tsx +++ b/packages/perseus-editor/src/widgets/input-number-editor.tsx @@ -1,241 +1,11 @@ -import {components, Util} from "@khanacademy/perseus"; -import {inputNumberLogic} from "@khanacademy/perseus-core"; -import {inputNumberAnswerTypes} from "@khanacademy/perseus-score"; -import * as React from "react"; -import _ from "underscore"; - -import BlurInput from "../components/blur-input"; - -import type {ParsedValue} from "@khanacademy/perseus"; -import type { - PerseusInputNumberWidgetOptions, - InputNumberDefaultWidgetOptions, -} from "@khanacademy/perseus-core"; - -const {InfoTip} = components; - -type Props = { - value: number; - simplify: PerseusInputNumberWidgetOptions["simplify"]; - size: PerseusInputNumberWidgetOptions["size"]; - inexact: PerseusInputNumberWidgetOptions["inexact"]; - maxError: PerseusInputNumberWidgetOptions["maxError"]; - answerType: PerseusInputNumberWidgetOptions["answerType"]; - rightAlign: PerseusInputNumberWidgetOptions["rightAlign"]; - onChange: (arg1: { - value?: ParsedValue | 0; - simplify?: Props["simplify"]; - size?: Props["size"]; - inexact?: Props["inexact"]; - maxError?: Props["maxError"]; - answerType?: Props["answerType"]; - rightAlign?: Props["rightAlign"]; - }) => void; -}; +import NumericInputEditor from "./numeric-input-editor"; // JSDoc will be shown in Storybook widget editor description /** * An editor for adding an input number widget that allows users to enter numerical values. */ -class InputNumberEditor extends React.Component { +class InputNumberEditor extends NumericInputEditor { static widgetName = "input-number" as const; - - static defaultProps: InputNumberDefaultWidgetOptions = - inputNumberLogic.defaultWidgetOptions; - - input = React.createRef(); - - handleAnswerChange: (arg1: string) => void = (str) => { - const value = Util.firstNumericalParse(str) || 0; - this.props.onChange({value: value}); - }; - - focus: () => boolean = () => { - this.input.current?.focus(); - return true; - }; - - serialize: () => { - value: Props["value"]; - simplify: Props["simplify"]; - size: Props["size"]; - inexact: Props["inexact"]; - maxError: Props["maxError"]; - answerType: Props["answerType"]; - rightAlign: Props["rightAlign"]; - } = () => ({ - value: this.props.value, - simplify: this.props.simplify, - size: this.props.size, - inexact: this.props.inexact, - maxError: this.props.maxError, - answerType: this.props.answerType, - rightAlign: this.props.rightAlign, - }); - - render(): React.ReactNode { - const answerTypeOptions = _.map( - inputNumberAnswerTypes, - function (v, k) { - return ( - - ); - }, - this, - ); - - return ( -
-
- -
- -
- - -

- Normally select "will not be graded". This - will give the user a message saying the answer is - correct but not simplified. The user will then have - to simplify it and re-enter, but will not be - penalized. (5th grade and anything after) -

-

- Select "will be accepted" only if the user - is not expected to know how to simplify fractions - yet. (Anything prior to 5th grade) -

-

- Select "will be marked wrong" only if we - are specifically assessing the ability to simplify. -

-
-
- -
- - - -
- -
- Answer type:{" "} - - -

- Use the default "Numbers" unless the - answer must be in a specific form (e.g., question is - about converting decimals to fractions). -

-
-
- -
- - -

- Use size "Normal" for all text boxes, - unless there are multiple text boxes in one line and - the answer area is too narrow to fit them. -

-
-
- -
- -
-
- ); - } } export default InputNumberEditor; diff --git a/packages/perseus-score/src/index.ts b/packages/perseus-score/src/index.ts index e21897c8151..3a2aeda249c 100644 --- a/packages/perseus-score/src/index.ts +++ b/packages/perseus-score/src/index.ts @@ -32,16 +32,9 @@ export {default as scoreSorter} from "./widgets/sorter/score-sorter"; export {default as validateSorter} from "./widgets/sorter/validate-sorter"; export {default as scoreTable} from "./widgets/table/score-table"; export {default as validateTable} from "./widgets/table/validate-table"; -export { - default as scoreInputNumber, - inputNumberAnswerTypes, -} from "./widgets/input-number/score-input-number"; +export {default as scoreInputNumber} from "./widgets/input-number/score-input-number"; -export { - scorePerseusItem, - scorePerseusItemWithInputNumberAsNumericInput, - scoreWidgetsFunctional, -} from "./score"; +export {scorePerseusItem, scoreWidgetsFunctional} from "./score"; export {default as flattenScores} from "./util/flatten-scores"; export {validateUserInput, emptyWidgetsFunctional} from "./validate"; export {default as hasEmptyDINERWidgets} from "./has-empty-diner-widgets"; diff --git a/packages/perseus-score/src/input-number-to-numeric-input-regression.test.ts b/packages/perseus-score/src/input-number-to-numeric-input-regression.test.ts deleted file mode 100644 index 59f7241b46a..00000000000 --- a/packages/perseus-score/src/input-number-to-numeric-input-regression.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -import {describe, expect, test} from "@jest/globals"; - -import type {PerseusWidgetsMap, UserInputMap} from "@khanacademy/perseus-core"; - -import { - scorePerseusItem, - scorePerseusItemWithInputNumberAsNumericInput, -} from "./index"; - -interface TestCase { - title: string; - userInput: UserInputMap; - widgets: PerseusWidgetsMap; -} - -// NOTE: To add a test case to this list: -// - Go to https://console.cloud.google.com/logs -// - Search for "side-by-side" in the Perseus service -// - select "Copy > Copy as JSON" -// - `pbpaste | jq '.jsonPayload.metadata | {userInput: .inputNumberUserInputs, widgets: .inputNumberWidgets}'` -// TODO(LEMS-4085): Delete this test file. -const cases: TestCase[] = [ - { - title: "when inexact = false", - userInput: { - "input-number 1": { - currentValue: "6.336", - }, - "input-number 2": { - currentValue: "6.308", - }, - }, - widgets: { - "input-number 1": { - options: { - value: 6.338, - answerType: "number", - rightAlign: false, - maxError: 0.1, - size: "normal", - simplify: "required", - inexact: false, - }, - graded: true, - static: false, - version: { - minor: 0, - major: 0, - }, - type: "input-number", - alignment: "default", - }, - "input-number 2": { - type: "input-number", - version: { - major: 0, - minor: 0, - }, - static: false, - alignment: "default", - options: { - rightAlign: false, - maxError: 0.1, - size: "normal", - inexact: false, - simplify: "required", - value: 6.34, - answerType: "number", - }, - graded: true, - }, - }, - }, - { - title: "when inexact = false and the correct answer has more than 9 decimal places", - userInput: { - "input-number 1": { - currentValue: "1.123456789", - }, - }, - widgets: { - "input-number 1": { - type: "input-number", - version: { - major: 0, - minor: 0, - }, - static: false, - alignment: "default", - options: { - rightAlign: false, - maxError: 0.1, - size: "normal", - inexact: false, - simplify: "required", - // rounds to 1.123456789 - value: 1.1234567885, - answerType: "number", - }, - graded: true, - }, - }, - }, - { - title: "when the user inputs a long decimal", - userInput: { - "input-number 1": { - currentValue: "0.7692307692307", - }, - }, - widgets: { - "input-number 1": { - options: { - maxError: 0.031, - simplify: "optional", - answerType: "number", - inexact: false, - size: "normal", - value: 0.7692307692307693, - }, - graded: true, - type: "input-number", - }, - }, - }, -]; - -describe("scoring with input-number converted to numeric-input", () => { - test.each(cases)("$title", ({widgets, userInput}) => { - const content = Object.keys(widgets) - .map((id) => `[[\u2603 ${id}]]`) - .join(""); - - const scoringArgs: Parameters = [ - {content, widgets, images: {}}, - userInput, - "en", - ]; - - const officialScore = scorePerseusItem(...scoringArgs); - const inniScore = scorePerseusItemWithInputNumberAsNumericInput( - ...scoringArgs, - ); - - expect(inniScore).toEqual(officialScore); - }); -}); diff --git a/packages/perseus-score/src/score.test.ts b/packages/perseus-score/src/score.test.ts index e4fb254e0c3..d1365287ad4 100644 --- a/packages/perseus-score/src/score.test.ts +++ b/packages/perseus-score/src/score.test.ts @@ -1,8 +1,6 @@ import { generateDropdownOptions, generateDropdownWidget, - generateInputNumberWidget, - generateInputNumberOptions, } from "@khanacademy/perseus-core"; import invariant from "tiny-invariant"; @@ -10,7 +8,6 @@ import { combineScoreWithWidgetScores, onlyInvalidScores, scorePerseusItem, - scorePerseusItemWithInputNumberAsNumericInput, scoreWidgetsFunctional, } from "./score"; import {getExpressionWidget, getTestDropdownWidget} from "./util/test-helpers"; @@ -559,218 +556,6 @@ describe("scorePerseusItem", () => { }); }); -describe("scorePerseusItemWithInputNumberAsNumericInput", () => { - it("scores a correctly-answered input-number widget", () => { - const renderer = { - content: "[[☃ input-number 1]]", - widgets: { - "input-number 1": generateInputNumberWidget({ - options: generateInputNumberOptions({value: "42"}), - }), - }, - images: {}, - }; - - const userInputMap = { - "input-number 1": { - currentValue: "42", - }, - }; - - const score = scorePerseusItemWithInputNumberAsNumericInput( - renderer, - userInputMap, - "en", - ); - - expect(score).toHaveBeenAnsweredCorrectly(); - expect(score.widgetScores).toStrictEqual({ - "input-number 1": { - type: "points", - total: 1, - earned: 1, - message: null, - }, - }); - }); - - it("scores an incorrectly-answered input-number widget", () => { - const renderer = { - content: "[[☃ input-number 1]]", - widgets: { - "input-number 1": generateInputNumberWidget({ - options: generateInputNumberOptions({value: "42"}), - }), - }, - images: {}, - }; - - const userInputMap = { - "input-number 1": { - currentValue: "99", - }, - }; - - const score = scorePerseusItemWithInputNumberAsNumericInput( - renderer, - userInputMap, - "en", - ); - - expect(score).toHaveBeenAnsweredIncorrectly(); - expect(score.widgetScores).toStrictEqual({ - "input-number 1": { - type: "points", - total: 1, - earned: 0, - message: null, - }, - }); - }); - - it("scores an empty input-number widget", () => { - const renderer = { - content: "[[☃ input-number 1]]", - widgets: { - "input-number 1": generateInputNumberWidget({ - options: generateInputNumberOptions({value: "42"}), - }), - }, - images: {}, - }; - - const userInputMap = { - "input-number 1": { - currentValue: "", - }, - }; - - const score = scorePerseusItemWithInputNumberAsNumericInput( - renderer, - userInputMap, - "en", - ); - - expect(score).toHaveInvalidInput(); - expect(score.widgetScores).toStrictEqual({ - "input-number 1": {type: "invalid", message: null}, - }); - }); - - it("ignores an input-number widget that does not have a placeholder in the content string", () => { - const renderer = { - content: "", - widgets: { - "input-number 1": generateInputNumberWidget({ - options: generateInputNumberOptions({value: "42"}), - }), - }, - images: {}, - }; - - const userInputMap = { - "input-number 1": { - currentValue: "", - }, - }; - - const score = scorePerseusItemWithInputNumberAsNumericInput( - renderer, - userInputMap, - "en", - ); - - expect(score).toHaveBeenAnsweredCorrectly({shouldHavePoints: false}); - expect(score.widgetScores).toStrictEqual({}); - }); - - it("ignores an input-number widget that is not graded", () => { - const renderer = { - content: "[[☃ input-number 1]]", - widgets: { - "input-number 1": generateInputNumberWidget({ - graded: false, - options: generateInputNumberOptions({value: "42"}), - }), - }, - images: {}, - }; - - const userInputMap = { - "input-number 1": { - currentValue: "", - }, - }; - - const score = scorePerseusItemWithInputNumberAsNumericInput( - renderer, - userInputMap, - "en", - ); - - expect(score).toHaveBeenAnsweredCorrectly({shouldHavePoints: false}); - }); - - it("scores correctly-answered widgets other than input-number", () => { - const renderer = { - content: "[[☃ dropdown 1]]", - widgets: { - "dropdown 1": generateDropdownWidget({ - options: generateDropdownOptions({ - choices: [{content: "A", correct: true}], - }), - }), - }, - images: {}, - }; - - const userInputMap: UserInputMap = { - "dropdown 1": { - value: 1, - }, - }; - - const score = scorePerseusItemWithInputNumberAsNumericInput( - renderer, - userInputMap, - "en", - ); - - expect(score).toHaveBeenAnsweredCorrectly(); - }); - - it("scores incorrectly-answered widgets other than input-number", () => { - const renderer = { - content: "[[☃ dropdown 1]]", - widgets: { - "dropdown 1": generateDropdownWidget({ - options: generateDropdownOptions({ - choices: [ - {content: "A", correct: true}, - {content: "B", correct: false}, - ], - }), - }), - }, - images: {}, - }; - - const userInputMap: UserInputMap = { - "dropdown 1": { - value: 2, - }, - }; - - const score = scorePerseusItemWithInputNumberAsNumericInput( - renderer, - userInputMap, - "en", - ); - - expect(score).toHaveBeenAnsweredIncorrectly(); - }); -}); - describe("onlyInvalidScores", () => { it("returns an empty object when given an empty object", () => { expect(onlyInvalidScores({})).toStrictEqual({}); diff --git a/packages/perseus-score/src/score.ts b/packages/perseus-score/src/score.ts index 0d34b25b737..909bf9928e3 100644 --- a/packages/perseus-score/src/score.ts +++ b/packages/perseus-score/src/score.ts @@ -1,5 +1,3 @@ -import {convertInputNumberOptionsToNumericInput} from "@khanacademy/perseus-core"; - import flattenScores from "./util/flatten-scores"; import getScoreableWidgets from "./util/get-scoreable-widgets"; import isWidgetScoreable from "./util/is-widget-scoreable"; @@ -93,27 +91,6 @@ export function scorePerseusItem( ); } -/** - * @experimental - this is a temporary function for use by the Input Number to - * Numeric Input project. It will be removed in a future minor version. - */ -// TODO(LEMS-4085): remove this function once the Input Number to Numeric -// Input project is complete. -export function scorePerseusItemWithInputNumberAsNumericInput( - perseusRenderData: PerseusRenderer, - userInputMap: UserInputMap, - locale: string, -): PerseusScoreWithWidgetScores { - const scoreableWidgetIds = getScoreableWidgets(perseusRenderData); - const scores = scoreWidgetsFunctionalWithInputNumberAsNumericInput( - perseusRenderData.widgets, - scoreableWidgetIds, - userInputMap, - locale, - ); - return combineScoreWithWidgetScores(flattenScores(scores), scores); -} - export function scoreWidgetsFunctional( widgets: PerseusWidgetsMap, // This is a port of old code, I'm not sure why @@ -150,54 +127,3 @@ export function scoreWidgetsFunctional( return widgetScores; } - -// TODO(LEMS-4085): remove this function once the Input Number to Numeric -// Input project is complete. -function scoreWidgetsFunctionalWithInputNumberAsNumericInput( - widgets: PerseusWidgetsMap, - // This is a port of old code, I'm not sure why - // we need widgetIds vs the keys of the widgets object - widgetIds: ReadonlyArray, - userInputMap: UserInputMap, - locale: string, -): WidgetScores { - const gradedWidgetIds = widgetIds.filter((id) => - isWidgetScoreable(widgets[id]), - ); - - const widgetScores: Record = {}; - gradedWidgetIds.forEach((id) => { - const widget = widgets[id]!; - - // TODO(benchristel): Without the explicit type annotation, the type of - // userInput would be inferred as `any`. This is because the keys of - // userInputMap are strings with a specific format, but `id` is any old - // string. Find a way to make this more typesafe. - const userInput: UserInput | undefined = userInputMap[id]; - let widgetType; - let widgetOptions; - if (widget.type === "input-number") { - widgetType = "numeric-input"; - widgetOptions = convertInputNumberOptionsToNumericInput( - widget.options, - ); - } else { - widgetType = widget.type; - widgetOptions = widget.options; - } - - const validator = getWidgetValidator(widgetType); - const scorer = getWidgetScorer(widgetType); - - // We do validation (empty checks) first and then scoring. If - // validation fails, it's result is itself a PerseusScore. - const score = - validator?.(userInput, widgetOptions, locale) ?? - scorer?.(userInput, widgetOptions, locale); - if (score != null) { - widgetScores[id] = score; - } - }); - - return widgetScores; -} diff --git a/packages/perseus-score/src/widgets/input-number/score-input-number.test.ts b/packages/perseus-score/src/widgets/input-number/score-input-number.test.ts index f65efde0a1e..39f36e74ce7 100644 --- a/packages/perseus-score/src/widgets/input-number/score-input-number.test.ts +++ b/packages/perseus-score/src/widgets/input-number/score-input-number.test.ts @@ -1,21 +1,21 @@ +import { + generateInputNumberAnswer, + generateInputNumberOptions, +} from "@khanacademy/perseus-core"; + import scoreInputNumber from "./score-input-number"; import type { - PerseusInputNumberWidgetOptions, PerseusInputNumberUserInput, + PerseusInputNumberWidgetOptions, } from "@khanacademy/perseus-core"; // TODO(LEMS-4085): Delete these tests; scoreInputNumber will be replaced by scoreNumericInput. describe("scoreInputNumber", () => { it("scores undefined user input as invalid", () => { - const rubric: PerseusInputNumberWidgetOptions = { - maxError: 0.1, - inexact: false, - value: 1, - simplify: "optional", - answerType: "percent", - size: "normal", - }; + const rubric = generateInputNumberOptions({ + answers: [generateInputNumberAnswer({value: 1})], + }); const userInput = undefined; @@ -25,75 +25,68 @@ describe("scoreInputNumber", () => { }); it("scores correct answer correctly", () => { - const rubric: PerseusInputNumberWidgetOptions = { - maxError: 0.1, - inexact: false, - value: 1, - simplify: "optional", - answerType: "percent", - size: "normal", - }; + const rubric = generateInputNumberOptions({ + answers: [generateInputNumberAnswer({value: 1})], + }); - const useInput: PerseusInputNumberUserInput = { + const userInput: PerseusInputNumberUserInput = { currentValue: "1", }; - const score = scoreInputNumber(useInput, rubric); + const score = scoreInputNumber(userInput, rubric); expect(score).toHaveBeenAnsweredCorrectly(); }); it("scores incorrect answer correctly", () => { - const rubric: PerseusInputNumberWidgetOptions = { - maxError: 0.1, - inexact: false, - value: 1, - simplify: "optional", - answerType: "percent", - size: "normal", - }; + const rubric = generateInputNumberOptions({ + answers: [generateInputNumberAnswer({value: 1})], + }); - const useInput: PerseusInputNumberUserInput = { + const userInput: PerseusInputNumberUserInput = { currentValue: "2", }; - const score = scoreInputNumber(useInput, rubric); + const score = scoreInputNumber(userInput, rubric); expect(score).toHaveBeenAnsweredIncorrectly(); }); it("shows as invalid with a nonsense answer", () => { - const rubric: PerseusInputNumberWidgetOptions = { - maxError: 0.1, - inexact: false, - value: 1, - simplify: "optional", - answerType: "percent", - size: "normal", - }; + const rubric = generateInputNumberOptions({ + answers: [generateInputNumberAnswer({value: 1})], + }); - const useInput: PerseusInputNumberUserInput = { + const userInput: PerseusInputNumberUserInput = { currentValue: "sadasdfas", }; - const score = scoreInputNumber(useInput, rubric); + const score = scoreInputNumber(userInput, rubric); expect(score).toHaveInvalidInput("EXTRA_SYMBOLS_ERROR"); }); // Don't default to validating the answer as a pi answer - // if answerType isn't set on the answer. + // if answerForms isn't set on the answer. // The answer value and - // the omission of answerType in the answer are + // the omission of answerForms in the answer are // important to the test. // https://khanacademy.atlassian.net/browse/LC-691 it("doesn't default to validating pi", () => { const rubric: PerseusInputNumberWidgetOptions = { - maxError: 0.1, - inexact: false, - value: 241.90263432641407, - simplify: "required", size: "normal", + coefficient: false, + answers: [ + { + status: "correct", + value: 241.90263432641407, + maxError: 0, + simplify: "required", + answerForms: [], + message: "", + strict: true, + }, + ], }; const userInput: PerseusInputNumberUserInput = { @@ -117,12 +110,19 @@ describe("scoreInputNumber", () => { it("validates against pi if provided in answerType", () => { const rubric: PerseusInputNumberWidgetOptions = { - maxError: 0.1, - inexact: false, - value: 241.90263432641407, - simplify: "required", - answerType: "pi", size: "normal", + coefficient: false, + answers: [ + { + status: "correct", + value: 241.90263432641407, + maxError: 0, + simplify: "required", + answerForms: ["pi"], + message: "", + strict: true, + }, + ], }; const userInput: PerseusInputNumberUserInput = { @@ -136,9 +136,19 @@ describe("scoreInputNumber", () => { it("should handle invalid answers with no error callback", function () { const rubric: PerseusInputNumberWidgetOptions = { - value: "2^{-2}-3", - simplify: "optional", size: "normal", + coefficient: false, + answers: [ + { + status: "correct", + value: 0, + maxError: 0, + simplify: "optional", + answerForms: [], + message: "", + strict: true, + }, + ], }; const userInput: PerseusInputNumberUserInput = {currentValue: "x+1"}; @@ -153,12 +163,19 @@ describe("scoreInputNumber", () => { it("should not consider commas as a decimal separator in the EN locale", () => { const rubric: PerseusInputNumberWidgetOptions = { - maxError: 0.1, - inexact: false, - value: 16, - simplify: "optional", - answerType: "number", size: "normal", + coefficient: false, + answers: [ + { + status: "correct", + value: 16, + maxError: 0, + simplify: "optional", + answerForms: [], + message: "", + strict: true, + }, + ], }; const userInput: PerseusInputNumberUserInput = { @@ -172,12 +189,19 @@ describe("scoreInputNumber", () => { it("should reject European decimal format in EN locale", () => { const rubric: PerseusInputNumberWidgetOptions = { - maxError: 0.1, - inexact: false, - value: 16.5, - simplify: "optional", - answerType: "decimal", size: "normal", + coefficient: false, + answers: [ + { + status: "correct", + value: 16.5, + maxError: 0, + simplify: "optional", + answerForms: ["decimal"], + message: "", + strict: true, + }, + ], }; const userInput: PerseusInputNumberUserInput = { @@ -193,12 +217,19 @@ describe("scoreInputNumber", () => { it("should consider commas as the decimal separator in the FR locale", () => { const rubric: PerseusInputNumberWidgetOptions = { - maxError: 0.1, - inexact: false, - value: 16.5, - simplify: "optional", - answerType: "decimal", size: "normal", + coefficient: false, + answers: [ + { + status: "correct", + value: 16.5, + maxError: 0, + simplify: "optional", + answerForms: ["decimal"], + message: "", + strict: true, + }, + ], }; const userInput: PerseusInputNumberUserInput = { @@ -212,13 +243,22 @@ describe("scoreInputNumber", () => { }); it("should consider decimals as the thousands separator in FR locale", () => { + // TODO(benchristel): This test seems wrong. The correct answer is 16.5, but + // 16.500,00 is accepted. const rubric: PerseusInputNumberWidgetOptions = { - maxError: 0.1, - inexact: false, - value: 16.5, - simplify: "optional", - answerType: "decimal", size: "normal", + coefficient: false, + answers: [ + { + status: "correct", + value: 16.5, + maxError: 0, + simplify: "optional", + answerForms: ["decimal"], + message: "", + strict: true, + }, + ], }; const userInput: PerseusInputNumberUserInput = { diff --git a/packages/perseus-score/src/widgets/input-number/score-input-number.ts b/packages/perseus-score/src/widgets/input-number/score-input-number.ts index d474805267c..080df756045 100644 --- a/packages/perseus-score/src/widgets/input-number/score-input-number.ts +++ b/packages/perseus-score/src/widgets/input-number/score-input-number.ts @@ -1,64 +1,3 @@ -import {convertInputNumberOptionsToNumericInput} from "@khanacademy/perseus-core"; - import scoreNumericInput from "../numeric-input/score-numeric-input"; -import type { - PerseusInputNumberUserInput, - PerseusScore, - PerseusInputNumberWidgetOptions, -} from "@khanacademy/perseus-core"; - -// TODO(LEMS-4085): Delete inputNumberAnswerTypes. -export const inputNumberAnswerTypes = { - number: { - name: "Numbers", - forms: "integer, decimal, proper, improper, mixed", - }, - decimal: { - name: "Decimals", - forms: "decimal", - }, - integer: { - name: "Integers", - forms: "integer", - }, - rational: { - name: "Fractions and mixed numbers", - forms: "integer, proper, improper, mixed", - }, - improper: { - name: "Improper numbers (no mixed)", - forms: "integer, proper, improper", - }, - mixed: { - name: "Mixed numbers (no improper)", - forms: "integer, proper, mixed", - }, - percent: { - name: "Numbers or percents", - forms: "integer, decimal, proper, improper, mixed, percent", - }, - pi: { - name: "Numbers with pi", - forms: "pi", - }, -} as const; - -// TODO(LEMS-4085): Delete; scoreInputNumber will be replaced by -// scoreNumericInput in the registry once the parser migrates input-number -// widget options to a numeric-input compatible form. -function scoreInputNumber( - // NOTE(benchristel): userInput can be undefined if the widget has never - // been interacted with. - userInput: PerseusInputNumberUserInput | undefined, - rubric: PerseusInputNumberWidgetOptions, - locale?: string, -): PerseusScore { - return scoreNumericInput( - userInput, - convertInputNumberOptionsToNumericInput(rubric), - locale, - ); -} - -export default scoreInputNumber; +export default scoreNumericInput; diff --git a/packages/perseus/src/__testdata__/extract-perseus-data.testdata.ts b/packages/perseus/src/__testdata__/extract-perseus-data.testdata.ts index bd3ceac62de..754cb2cf63c 100644 --- a/packages/perseus/src/__testdata__/extract-perseus-data.testdata.ts +++ b/packages/perseus/src/__testdata__/extract-perseus-data.testdata.ts @@ -85,15 +85,22 @@ export const PerseusItemWithInputNumber = generateTestPerseusItem({ static: false, graded: true, options: { - value: 66, - simplify: "required", size: "normal", - inexact: false, - maxError: 0.1, - answerType: "number", + coefficient: false, + answers: [ + { + status: "correct", + value: 66, + maxError: 0, + simplify: "required", + answerForms: [], + message: "", + strict: true, + }, + ], }, version: { - major: 0, + major: 1, minor: 0, }, }, 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 18d6e15d30d..00000000000 --- a/packages/perseus/src/__tests__/test-items/input-number-2-item.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type {PerseusRenderer} from "@khanacademy/perseus-core"; - -export default { - // eslint-disable-next-line no-restricted-syntax - 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, - }, - // eslint-disable-next-line no-restricted-syntax - hints: [] as ReadonlyArray, -}; diff --git a/packages/perseus/src/types.ts b/packages/perseus/src/types.ts index 8fb0514e46d..448d9269c7f 100644 --- a/packages/perseus/src/types.ts +++ b/packages/perseus/src/types.ts @@ -134,8 +134,6 @@ export type ImageUploader = ( callback: (url: string) => unknown, ) => unknown; -export type Path = ReadonlyArray; - type TrackInteractionArgs = { // The widget type that this interaction originates from type: string; diff --git a/packages/perseus/src/widget-ai-utils/input-number/input-number-ai-utils.test.ts b/packages/perseus/src/widget-ai-utils/input-number/input-number-ai-utils.test.ts index f6b899e0e11..bac56d18dd7 100644 --- a/packages/perseus/src/widget-ai-utils/input-number/input-number-ai-utils.test.ts +++ b/packages/perseus/src/widget-ai-utils/input-number/input-number-ai-utils.test.ts @@ -21,18 +21,25 @@ const question: PerseusRenderer = { "input-number 1": { graded: true, version: { - major: 0, + major: 1, minor: 0, }, static: false, type: "input-number", options: { - maxError: 0.1, - inexact: false, - value: 0.5, - simplify: "required", - answerType: "number", size: "normal", + coefficient: false, + answers: [ + { + status: "correct", + value: 0.5, + maxError: 0, + simplify: "required", + answerForms: [], + message: "", + strict: true, + }, + ], }, alignment: "default", } as InputNumberWidget, @@ -62,10 +69,7 @@ describe("InputNumber AI utils", () => { expect(resultJSON).toEqual({ type: "input-number", - options: { - simplify: "optional", - answerType: "integer", - }, + label: undefined, userInput: { value: "123", }, @@ -89,11 +93,8 @@ describe("InputNumber AI utils", () => { "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", - }, + type: "numeric-input", + label: "", userInput: { value: "40", }, diff --git a/packages/perseus/src/widget-ai-utils/input-number/input-number-ai-utils.ts b/packages/perseus/src/widget-ai-utils/input-number/input-number-ai-utils.ts index 91bc7437d6b..9fd4b1711ad 100644 --- a/packages/perseus/src/widget-ai-utils/input-number/input-number-ai-utils.ts +++ b/packages/perseus/src/widget-ai-utils/input-number/input-number-ai-utils.ts @@ -11,27 +11,21 @@ import type React from "react"; export type InputNumberPromptJSON = { type: "input-number"; - /** The configuration of the widget, set by the content creator. */ - options: { - /** - * Indicates how answers in unsimplified form are scored. - * - * - "optional" means the answer can be unsimplified. - * - "required" means an unsimplified answer is considered invalid, - * and the learner can try again without penalty. - * - "enforced" means unsimplified answers are counted as incorrect. - */ - simplify: string; - /** The expected numeric form, e.g. "rational", "decimal" */ - answerType: string; - }; + /** + * Accessible label for the input field, set by the content creator. + * Shown to learners using screen readers to describe what value should + * be entered. + */ + label: string; /** * The current state of the widget user interface. Usually represents a * learner's attempt to answer a question. */ userInput: { - /** The text input by the user */ + /** + * The text currently entered in the input field by the learner. + */ value: string; }; }; @@ -41,10 +35,7 @@ export const getPromptJSON = ( ): InputNumberPromptJSON => { return { type: "input-number", - options: { - simplify: widgetData.simplify, - answerType: widgetData.answerType, - }, + label: widgetData.labelText, userInput: { value: widgetData.userInput.currentValue, }, 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 index 38f34d8f64f..7fc44e30b7f 100644 --- 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 @@ -165,31 +165,33 @@ exports[`rendering supports mobile rendering: mobile render 1`] = `
-
+
- + + + -
diff --git a/packages/perseus/src/widgets/input-number/input-number.stories.tsx b/packages/perseus/src/widgets/input-number/input-number.stories.tsx index ebda0592064..2bcc111a23c 100644 --- a/packages/perseus/src/widgets/input-number/input-number.stories.tsx +++ b/packages/perseus/src/widgets/input-number/input-number.stories.tsx @@ -129,9 +129,18 @@ Percent.args = question3.widgets["input-number 1"].options; export const Answerful = (): React.ReactElement => { const item = getAnswerfulItem("input-number", { - simplify: "optional", size: "normal", - value: 42, + coefficient: false, + answers: [ + { + status: "correct", + simplify: "optional", + value: 42, + answerForms: [], + message: "", + strict: true, + }, + ], }); // TODO(LEMS-3083): Remove eslint suppression // eslint-disable-next-line @@ -141,9 +150,18 @@ export const Answerful = (): React.ReactElement => { export const Answerless = (): React.ReactElement => { const item = getAnswerlessItem("input-number", { - simplify: "optional", size: "normal", - value: 42, + coefficient: false, + answers: [ + { + status: "correct", + simplify: "optional", + value: 42, + answerForms: [], + message: "", + strict: true, + }, + ], }); // TODO(LEMS-3083): Remove eslint suppression // eslint-disable-next-line diff --git a/packages/perseus/src/widgets/input-number/input-number.test.ts b/packages/perseus/src/widgets/input-number/input-number.test.ts index 5fb8e820723..ff897ee7eaa 100644 --- a/packages/perseus/src/widgets/input-number/input-number.test.ts +++ b/packages/perseus/src/widgets/input-number/input-number.test.ts @@ -2,9 +2,12 @@ * Disclaimer: Definitely not thorough enough */ import {describe, beforeEach, it} from "@jest/globals"; +import { + generateInputNumberAnswer, + generateInputNumberOptions, +} from "@khanacademy/perseus-core"; import {act, screen, waitFor} from "@testing-library/react"; import {userEvent as userEventLib} from "@testing-library/user-event"; -import _ from "underscore"; import * as Dependencies from "../../dependencies"; import {getFeatureFlags} from "../../testing/feature-flags-util"; @@ -23,7 +26,7 @@ import InputNumber from "./input-number"; import {question3 as question} from "./input-number.testdata"; import type {PerseusDependenciesV2} from "../../types"; -import type {PerseusRenderer} from "@khanacademy/perseus-core"; +import type {MathFormat, PerseusRenderer} from "@khanacademy/perseus-core"; import type {UserEvent} from "@testing-library/user-event"; describe("input-number", function () { @@ -107,7 +110,7 @@ describe("input-number", function () { type: "perseus:widget:rendered:ti", payload: { widgetSubType: "null", - widgetType: "input-number", + widgetType: "numeric-input", widgetId: "input-number 1", }, }); @@ -137,21 +140,32 @@ describe("input-number", function () { graded: true, alignment: "default", options: { - maxError: 0.1, - inexact: false, - value: 0.3333333333333333, - simplify: "optional", - answerType: "rational", size: "normal", + coefficient: false, + answers: [ + { + status: "correct", + value: 0.3333333333333333, + maxError: 0, + simplify: "optional", + answerForms: [ + "integer", + "proper", + "improper", + "mixed", + ], + message: "", + strict: true, + }, + ], }, }, }, - } as PerseusRenderer, + } satisfies PerseusRenderer, "1/3", "0.4", ], [ - // eslint-disable-next-line no-restricted-syntax { 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", @@ -164,29 +178,37 @@ describe("input-number", function () { }, widgets: { "input-number 1": { - version: { - major: 0, - minor: 0, - }, type: "input-number", + version: {major: 1, minor: 0}, graded: true, alignment: "default", options: { - maxError: 0.1, - inexact: false, - value: 0.3333333333333333, - simplify: "required", - answerType: "rational", size: "normal", + coefficient: false, + answers: [ + { + status: "correct", + value: 0.3333333333333333, + maxError: 0, + simplify: "required", + answerForms: [ + "integer", + "proper", + "improper", + "mixed", + ], + message: "", + strict: true, + }, + ], }, }, }, - } as PerseusRenderer, + } satisfies PerseusRenderer, "1/3", "0.4", ], [ - // eslint-disable-next-line no-restricted-syntax { 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$", @@ -194,23 +216,30 @@ describe("input-number", function () { widgets: { "input-number 1": { type: "input-number", + version: {major: 1, minor: 0}, graded: true, options: { - maxError: 0.1, - inexact: false, - value: 241.90263432641407, - simplify: "required", - answerType: "pi", size: "normal", + coefficient: false, + answers: [ + { + status: "correct", + value: 241.90263432641407, + simplify: "required", + maxError: 0, + answerForms: ["pi"], + message: "", + strict: true, + }, + ], }, }, }, - } as PerseusRenderer, + } satisfies PerseusRenderer, "77 pi", "76 pi", ], [ - // eslint-disable-next-line no-restricted-syntax { 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', @@ -218,18 +247,33 @@ describe("input-number", function () { widgets: { "input-number 1": { type: "input-number", + version: {major: 1, minor: 0}, graded: true, options: { - maxError: 0.1, - inexact: false, - value: 0.5, - simplify: "optional", - answerType: "percent", size: "small", + coefficient: false, + answers: [ + { + status: "correct", + value: 0.5, + simplify: "optional", + maxError: 0, + answerForms: [ + "integer", + "decimal", + "proper", + "improper", + "mixed", + "percent", + ], + message: "", + strict: true, + }, + ], }, }, }, - } as PerseusRenderer, + } satisfies PerseusRenderer, "50%", "0.56", ], @@ -331,12 +375,12 @@ describe("input-number with input-number-to-numeric-input flag on", () => { }); it.each([ - ["rational", 0.3333333333333333, "1/3", "normal" as const], - ["percent", 0.5, "50%", "small" as const], - ["pi", 241.90263432641407, "77 pi", "normal" as const], - ] as const)( - "accepts a correct answer when answerType is %s", - async (answerType, value, correctAnswer, size) => { + [["proper"], 0.3333333333333333, "1/3", "normal" as const], + [["decimal", "percent"], 0.5, "50%", "small" as const], + [["pi"], 241.90263432641407, "77 pi", "normal" as const], + ] satisfies Array<[MathFormat[], number, string, "small" | "normal"]>)( + "accepts a correct answer when answerForms is %s", + async (answerForms, value, correctAnswer, size) => { // Arrange const item: PerseusRenderer = { content: "[[☃ input-number 1]]", @@ -346,12 +390,19 @@ describe("input-number with input-number-to-numeric-input flag on", () => { type: "input-number", graded: true, options: { - maxError: 0.1, - inexact: false, - value, - simplify: "optional", - answerType, size, + coefficient: false, + answers: [ + { + status: "correct", + value, + simplify: "optional", + maxError: 0, + answerForms: answerForms, + message: "", + strict: true, + }, + ], }, }, }, @@ -406,39 +457,69 @@ describe("getOneCorrectAnswerFromRubric", () => { ); }); - it("should return undefined if rubric.value is null/undefined", () => { + it("should return empty if the answer value is null", () => { + // Arrange + const rubric = generateInputNumberOptions({ + answers: [ + generateInputNumberAnswer({ + value: null, + }), + ], + }); + + // Act + const result = InputNumber.getOneCorrectAnswerFromRubric?.(rubric); + + // Assert + expect(result).toBe(""); + }); + + it("should return the answer value if maxError is 0", () => { // Arrange - const rubric: Record = {}; + const rubric = generateInputNumberOptions({ + answers: [ + generateInputNumberAnswer({ + value: 42, + maxError: 0, + }), + ], + }); // Act const result = InputNumber.getOneCorrectAnswerFromRubric?.(rubric); // Assert - expect(result).toBeUndefined(); + expect(result).toEqual("42"); }); - it("should return rubric.value if inexact is false", () => { + it("should return the answer value if maxError is undefined", () => { // Arrange - const rubric = { - value: 0, - maxError: 0.1, - inexact: false, - } as const; + const rubric = generateInputNumberOptions({ + answers: [ + generateInputNumberAnswer({ + value: 42, + maxError: undefined, + }), + ], + }); // Act const result = InputNumber.getOneCorrectAnswerFromRubric?.(rubric); // Assert - expect(result).toEqual("0"); + expect(result).toEqual("42"); }); - it("should return rubric.value with an error band if inexact is true", () => { + it("should return the answer value with an error band if maxError is greater than zero", () => { // Arrange - const rubric = { - value: 0, - maxError: 0.1, - inexact: true, - } as const; + const rubric = generateInputNumberOptions({ + answers: [ + generateInputNumberAnswer({ + value: 0, + maxError: 0.1, + }), + ], + }); // Act const result = InputNumber.getOneCorrectAnswerFromRubric?.(rubric); @@ -503,29 +584,47 @@ describe("focus state", () => { function getAnswerlessInputNumber() { return getAnswerlessItem("input-number", { - simplify: "optional", size: "normal", - value: 42, + coefficient: false, + answers: [ + { + status: "correct", + value: 42, + simplify: "optional", + message: "", + strict: true, + answerForms: [], + }, + ], }); } function getAnswerfulInputNumber() { return getAnswerfulItem("input-number", { - simplify: "optional", size: "normal", - value: 42, + coefficient: false, + answers: [ + { + status: "correct", + value: 42, + simplify: "optional", + message: "", + strict: true, + answerForms: [], + }, + ], }); } it("removes answers from item data", () => { expect( getAnswerfulInputNumber().question.widgets["input-number 1"].options - .value, + .answers[0].value, ).toBe(42); expect( getAnswerlessInputNumber().question.widgets["input-number 1"].options - .value, - ).toBeUndefined(); + .answers[0].value, + ).toBe(null); }); describe.each([ 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 870e4430772..581c07ef82b 100644 --- a/packages/perseus/src/widgets/input-number/input-number.testdata.ts +++ b/packages/perseus/src/widgets/input-number/input-number.testdata.ts @@ -1,7 +1,4 @@ -import type { - PerseusRenderer, - InputNumberWidget, -} from "@khanacademy/perseus-core"; +import type {PerseusRenderer} from "@khanacademy/perseus-core"; export const question1: PerseusRenderer = { content: @@ -16,22 +13,26 @@ export const question1: PerseusRenderer = { widgets: { // eslint-disable-next-line no-restricted-syntax "input-number 1": { - version: { - major: 0, - minor: 0, - }, type: "input-number", + version: {major: 1, minor: 0}, graded: true, alignment: "default", options: { - maxError: 0.1, - inexact: false, - value: 0.3333333333333333, - simplify: "optional", - answerType: "rational", size: "normal", + coefficient: false, + answers: [ + { + status: "correct", + value: 0.3333333333333333, + simplify: "optional", + maxError: 0, + answerForms: ["integer", "proper", "improper", "mixed"], + message: "", + strict: true, + }, + ], }, - } as InputNumberWidget, + }, }, }; @@ -40,19 +41,26 @@ export const question2: PerseusRenderer = { "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: Object.freeze({}), widgets: { - // eslint-disable-next-line no-restricted-syntax "input-number 1": { type: "input-number", + version: {major: 1, minor: 0}, graded: true, options: { - maxError: 0.1, - inexact: false, - value: 241.90263432641407, - simplify: "required", - answerType: "pi", size: "normal", + coefficient: false, + answers: [ + { + status: "correct", + value: 241.90263432641407, + simplify: "required", + maxError: 0, + answerForms: ["pi"], + message: "", + strict: true, + }, + ], }, - } as InputNumberWidget, + }, }, }; @@ -61,18 +69,32 @@ export const question3: PerseusRenderer = { '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: Object.freeze({}), widgets: { - // eslint-disable-next-line no-restricted-syntax "input-number 1": { type: "input-number", + version: {major: 1, minor: 0}, graded: true, options: { - maxError: 0.1, - inexact: false, - value: 0.5, - simplify: "optional", - answerType: "percent", size: "small", + coefficient: false, + answers: [ + { + status: "correct", + value: 0.5, + simplify: "optional", + maxError: 0, + answerForms: [ + "integer", + "decimal", + "proper", + "improper", + "mixed", + "percent", + ], + message: "", + strict: true, + }, + ], }, - } as InputNumberWidget, + }, }, }; diff --git a/packages/perseus/src/widgets/input-number/input-number.tsx b/packages/perseus/src/widgets/input-number/input-number.tsx index 4c0a89cad7e..328740ee502 100644 --- a/packages/perseus/src/widgets/input-number/input-number.tsx +++ b/packages/perseus/src/widgets/input-number/input-number.tsx @@ -1,300 +1,7 @@ -import { - convertInputNumberOptionsToNumericInput, - isFeatureOn, -} from "@khanacademy/perseus-core"; -import {linterContextDefault} from "@khanacademy/perseus-linter"; -import {inputNumberAnswerTypes} from "@khanacademy/perseus-score"; -import {spacing} from "@khanacademy/wonder-blocks-tokens"; -import {StyleSheet} from "aphrodite"; -import * as React from "react"; +import numericInput from "../numeric-input/numeric-input.class"; -import {PerseusI18nContext} from "../../components/i18n-context"; -import SimpleKeypadInput from "../../components/simple-keypad-input"; -import {withDependencies} from "../../components/with-dependencies"; -import {ApiOptions} from "../../perseus-api"; -import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/input-number/input-number-ai-utils"; -import InputWithExamples from "../numeric-input/input-with-examples"; -import {NumericInput} from "../numeric-input/numeric-input.class"; - -import type {PerseusStrings} from "../../strings"; -import type { - Focusable, - Path, - PerseusDependenciesV2, - Widget, - WidgetExports, - WidgetProps, -} from "../../types"; -import type {InputNumberPromptJSON} from "../../widget-ai-utils/input-number/input-number-ai-utils"; -import type { - PerseusInputNumberWidgetOptions, - PerseusInputNumberUserInput, -} from "@khanacademy/perseus-core"; - -type FormExampleFunction = (options: Props, strings: PerseusStrings) => string; - -const formExamples: Record = { - integer: function (_, strings) { - return strings.integerExample; - }, - proper: function (options, strings) { - if (options.simplify === "optional") { - return strings.properExample; - } - return strings.simplifiedProperExample; - }, - improper: function (options, strings) { - if (options.simplify === "optional") { - return strings.improperExample; - } - return strings.simplifiedImproperExample; - }, - mixed: function (_, strings) { - return strings.mixedExample; - }, - decimal: function (_, strings) { - return strings.decimalExample; - }, - percent: function (_, strings) { - return strings.percentExample; - }, - pi: function (_, strings) { - return strings.piExample; - }, -} as const; - -type ExternalProps = WidgetProps< - PerseusInputNumberWidgetOptions, - PerseusInputNumberUserInput ->; -type Props = ExternalProps & { - apiOptions: NonNullable; - linterContext: NonNullable; - rightAlign: NonNullable; - size: NonNullable; - // NOTE(kevinb): This was the only default prop that is listed as - // not-required in PerseusInputNumberWidgetOptions. - answerType: NonNullable; - dependencies: PerseusDependenciesV2; -}; - -type DefaultProps = Pick< - Props, - | "answerType" - | "apiOptions" - | "linterContext" - | "rightAlign" - | "size" - | "userInput" ->; - -class InputNumber extends React.Component implements Widget { - static contextType = PerseusI18nContext; - declare context: React.ContextType; - - // TODO(LEMS-4085): Single ref shared by both the legacy and NumericInput render paths. - // Both targets satisfy `Focusable`, so focus/blur delegation is uniform. - inputRef: {current: Focusable | null} = {current: null}; - - static defaultProps: DefaultProps = { - size: "normal", - answerType: "number", - rightAlign: false, - // NOTE(kevinb): renderer.jsx should be provide this so we probably don't - // need to include it in defaultProps. - apiOptions: ApiOptions.defaults, - linterContext: linterContextDefault, - userInput: {currentValue: ""}, - }; - - componentDidMount(): void { - this.props.dependencies.analytics.onAnalyticsEvent({ - type: "perseus:widget:rendered:ti", - payload: { - widgetSubType: "null", - widgetType: "input-number", - widgetId: this.props.widgetId, - }, - }); - } - - private handleInputRef = (instance: Focusable | null): void => { - this.inputRef.current = instance; - }; - - shouldShowExamples: () => boolean = () => { - return this.props.answerType !== "number"; - }; - - handleChange: (arg1: string, arg2: () => void) => void = (newValue, cb) => { - this.props.handleUserInput({currentValue: newValue}, cb); - }; - - _handleFocus: () => void = () => { - this.props.onFocus([]); - }; - - _handleBlur: () => void = () => { - this.props.onBlur([]); - }; - - focus: () => boolean = () => { - this.inputRef.current?.focus(); - return true; - }; - - focusInputPath: () => void = () => { - this.inputRef.current?.focus(); - }; - - blurInputPath: () => void = () => { - this.inputRef.current?.blur(); - }; - - getInputPaths: () => ReadonlyArray = () => { - // The widget itself is an input, so we return a single empty list to - // indicate this. - /* c8 ignore next */ - return [[]]; - }; - - getPromptJSON(): InputNumberPromptJSON { - return _getPromptJSON(this.props); - } - - examples(): ReadonlyArray { - const {strings} = this.context; - const type = this.props.answerType; - const forms = inputNumberAnswerTypes[type].forms.split(/\s*,\s*/); - - const examples = forms.map((form) => - formExamples[form](this.props, strings), - ); - - return [strings.yourAnswer].concat(examples); - } - - /** - * @deprecated and likely very broken API - * [LEMS-3185] do not trust serializedState - */ - getSerializedState(): any { - return { - alignment: this.props.alignment, - static: this.props.static, - simplify: this.props.simplify, - size: this.props.size, - answerType: this.props.answerType, - rightAlign: this.props.rightAlign, - currentValue: this.props.userInput.currentValue, - }; - } - - render(): React.ReactNode { - // TODO(LEMS-4085): This logic renders the Numeric Input widget instead of the - // Input Number widget when the appropriate flag is on. We will want to - // remove the flag check when we're ready to fully replace Input Number. - if (isFeatureOn(this.props, "input-number-to-numeric-input")) { - const numericInputOptions = convertInputNumberOptionsToNumericInput( - this.props, - ); - - return ( - - ); - } - - if (this.props.apiOptions.customKeypad) { - const input = ( - - ); - - if (this.props.rightAlign) { - return
{input}
; - } - - return input; - } - - // Note: This is _very_ similar to what `numeric-input.jsx` does. If - // you modify this, double-check if you also need to modify that - // component. - const inputStyles = [ - styles.default, - this.props.size === "small" ? styles.small : null, - this.props.rightAlign ? styles.rightAlign : styles.leftAlign, - ]; - // Unanswered - if (this.props.reviewMode && !this.props.userInput.currentValue) { - inputStyles.push(styles.answerStateUnanswered); - } - - return ( - - ); - } -} - -const styles = StyleSheet.create({ - default: { - width: 80, - height: "auto", - // Even in RTL languages, math is LTR. - // So we force this component to always render LTR - direction: "ltr", - }, - small: { - width: 40, - }, - leftAlign: { - paddingLeft: spacing.xxxSmall_4, - paddingRight: 0, - }, - rightAlign: { - textAlign: "right", - paddingLeft: 0, - paddingRight: spacing.xxxSmall_4, - }, - answerStateUnanswered: { - backgroundColor: "#eee", - border: "solid 1px #999", - }, -}); - -function getOneCorrectAnswerFromRubric(rubric: any): string | undefined { - if (rubric.value == null) { - return; - } - let answerString = String(rubric.value); - if (rubric.inexact && rubric.maxError) { - answerString += " \u00B1 " + rubric.maxError; - } - return answerString; -} +import type {WidgetExports} from "../../types"; +import type {PerseusInputNumberUserInput} from "@khanacademy/perseus-core"; /** * @deprecated and likely a very broken API @@ -308,26 +15,17 @@ function getUserInputFromSerializedState( }; } -function getStartUserInput(): PerseusInputNumberUserInput { - return {currentValue: ""}; -} - -function getCorrectUserInput( - options: PerseusInputNumberWidgetOptions, -): PerseusInputNumberUserInput { - return {currentValue: options.value.toString()}; -} - -const WrappedInputNumber = withDependencies(InputNumber); - export default { name: "input-number", + version: {major: 1, minor: 0}, displayName: "Input number (deprecated - use numeric input instead)", hidden: true, - widget: WrappedInputNumber, + // NOTE(benchristel): We replaced the InputNumber component with + // NumericInput in 2026. + widget: numericInput.widget, isLintable: true, - getOneCorrectAnswerFromRubric, - getStartUserInput, - getCorrectUserInput, + getOneCorrectAnswerFromRubric: numericInput.getOneCorrectAnswerFromRubric, + getStartUserInput: numericInput.getStartUserInput, + getCorrectUserInput: numericInput.getCorrectUserInput, getUserInputFromSerializedState, -} satisfies WidgetExports; +} satisfies WidgetExports; diff --git a/packages/perseus/src/widgets/input-number/serialize-input-number.test.ts b/packages/perseus/src/widgets/input-number/serialize-input-number.test.ts deleted file mode 100644 index d8875781b83..00000000000 --- a/packages/perseus/src/widgets/input-number/serialize-input-number.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { - generateTestPerseusItem, - generateTestPerseusRenderer, -} from "@khanacademy/perseus-core"; -import {screen, act} from "@testing-library/react"; -import {userEvent as userEventLib} from "@testing-library/user-event"; - -import {renderQuestion} from "../../__tests__/test-utils"; -import * as Dependencies from "../../dependencies"; -import {testDependencies} from "../../testing/test-dependencies"; -import {registerAllWidgetsForTesting} from "../../util/register-all-widgets-for-testing"; - -import type {PerseusItem} from "@khanacademy/perseus-core"; -import type {UserEvent} from "@testing-library/user-event"; - -/** - * [LEMS-3185] These are tests for the legacy Serialization API. - * - * This API is not built in a way that supports migrating data - * between versions of Perseus JSON. In fact serialization - * doesn't use WidgetOptions, but manipulated widget props; it's leveraging - * what is considered an internal implementation detail to support - * rehydrating previous state. - * - * The API is very fragile and likely broken. We have a ticket to remove it. - * However we don't have the bandwidth to implement an alternative right now, - * so I'm adding tests to make sure we're roughly still able to support - * what little we've been supporting so far. - * - * This API needs to be removed and these tests need to be removed with it. - */ -describe("InputNumber serialization", () => { - function generateBasicInputNumber(): PerseusItem { - const question = generateTestPerseusRenderer({ - content: "[[☃ input-number 1]]", - widgets: { - "input-number 1": { - type: "input-number", - options: { - value: 42, - simplify: "optional", - size: "normal", - }, - }, - }, - }); - const item = generateTestPerseusItem({question}); - return item; - } - - beforeAll(() => { - registerAllWidgetsForTesting(); - }); - - let userEvent: UserEvent; - beforeEach(() => { - userEvent = userEventLib.setup({ - advanceTimers: jest.advanceTimersByTime, - }); - - jest.spyOn(Dependencies, "getDependencies").mockReturnValue( - testDependencies, - ); - }); - - afterEach(() => { - // The Renderer uses a timer to wait for widgets to complete rendering. - // If we don't spin the timers here, then the timer fires in the test - // _after_ and breaks it because we do setState() in the callback, - // and by that point the component has been unmounted. - act(() => jest.runOnlyPendingTimers()); - }); - - it("should serialize the current state", async () => { - // Arrange - const {renderer} = renderQuestion(generateBasicInputNumber()); - - // Act - await userEvent.type(screen.getByRole("textbox"), "42"); - const state = renderer.getSerializedState(); - - // Assert - expect(state).toEqual({ - question: { - "input-number 1": { - alignment: "default", - static: false, - simplify: "optional", - size: "normal", - answerType: "number", - rightAlign: false, - // this is the stashed user input - currentValue: "42", - }, - }, - hints: [], - }); - }); -}); diff --git a/packages/perseus/src/widgets/numeric-input/numeric-input.class.tsx b/packages/perseus/src/widgets/numeric-input/numeric-input.class.tsx index 2d46d17fc4f..e398c990f58 100644 --- a/packages/perseus/src/widgets/numeric-input/numeric-input.class.tsx +++ b/packages/perseus/src/widgets/numeric-input/numeric-input.class.tsx @@ -29,6 +29,10 @@ export type NumericInputProps = ExternalProps & { rightAlign: NonNullable; apiOptions: NonNullable; coefficient: NonNullable; + // TODO(benchristel): answerForms is not actually passed to NumericInput. + // It seems to be here because this props type is reused by + // NumericInputComponent, which does take answerForms. + // Use separate prop types that reflect the actual props of each component. answerForms: ReadonlyArray; labelText: string; linterContext: NonNullable; @@ -90,9 +94,6 @@ export class NumericInput return true; }; - // TODO(LEMS-4085): While we cannot find any callers of this method, - // adding it is the simplest way to resolve temporary type issues - // regarding rendering the Input Number widget as a Numeric Input blur: () => void = () => { this.inputRef.current?.blur(); }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b1a5304d64..c3bf396d0c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -773,9 +773,6 @@ importers: '@khanacademy/perseus-linter': specifier: workspace:* version: link:../perseus-linter - '@khanacademy/perseus-score': - specifier: workspace:* - version: link:../perseus-score '@khanacademy/perseus-utils': specifier: workspace:* version: link:../perseus-utils