diff --git a/packages/perseus-core/src/data-schema.ts b/packages/perseus-core/src/data-schema.ts index 7536a870c0..df091e71bd 100644 --- a/packages/perseus-core/src/data-schema.ts +++ b/packages/perseus-core/src/data-schema.ts @@ -1206,15 +1206,19 @@ export type MathFormat = | "pi"; export type PerseusNumericInputAnswerForm = { - simplify: PerseusNumericInputSimplify | null | undefined; + simplify: PerseusNumericInputSimplify; name: MathFormat; }; -export type PerseusNumericInputSimplify = - | "required" - | "correct" - | "enforced" - | "optional"; +/** + * Determines how unsimplified fractions are scored. + * + * - "required" means unsimplified fractions are considered invalid input, and + * the learner can try again. + * - "enforced" means unsimplified fractions are marked incorrect. + * - "optional" means unsimplified fractions are accepted. + */ +export type PerseusNumericInputSimplify = "required" | "enforced" | "optional"; export type PerseusNumericInputWidgetOptions = { // A list of all the possible correct and incorrect answers @@ -1249,7 +1253,7 @@ export type PerseusNumericInputAnswer = { // NOTE: perseus_data.go says this is non-nullable even though we handle null values. maxError: number | null | undefined; // Unsimplified answers are Ungraded, Accepted, or Wrong. Options: "required", "correct", or "enforced" - simplify: PerseusNumericInputSimplify | null | undefined; + simplify: PerseusNumericInputSimplify; }; export type PerseusNumberLineWidgetOptions = { diff --git a/packages/perseus-core/src/parse-perseus-json/perseus-parsers/numeric-input-widget.test.ts b/packages/perseus-core/src/parse-perseus-json/perseus-parsers/numeric-input-widget.test.ts new file mode 100644 index 0000000000..03651a0a16 --- /dev/null +++ b/packages/perseus-core/src/parse-perseus-json/perseus-parsers/numeric-input-widget.test.ts @@ -0,0 +1,47 @@ +import {anyFailure} from "../general-purpose-parsers/test-helpers"; +import {parse} from "../parse"; +import {success} from "../result"; + +import {parseSimplify} from "./numeric-input-widget"; + +describe("parseSimplify", () => { + it(`preserves "required"`, () => { + expect(parse("required", parseSimplify)).toEqual(success("required")); + }); + + it(`preserves "enforced"`, () => { + expect(parse("enforced", parseSimplify)).toEqual(success("enforced")); + }); + + it(`preserves "optional"`, () => { + expect(parse("optional", parseSimplify)).toEqual(success("optional")); + }); + + it(`converts null to "required"`, () => { + expect(parse(null, parseSimplify)).toEqual(success("required")); + }); + + it(`converts undefined to "required"`, () => { + expect(parse(undefined, parseSimplify)).toEqual(success("required")); + }); + + it(`converts true to "required"`, () => { + expect(parse(true, parseSimplify)).toEqual(success("required")); + }); + + it(`converts false to "required"`, () => { + expect(parse(false, parseSimplify)).toEqual(success("required")); + }); + + it(`converts "accepted" to "required"`, () => { + expect(parse("accepted", parseSimplify)).toEqual(success("required")); + }); + + it(`converts "correct" to "required"`, () => { + expect(parse("correct", parseSimplify)).toEqual(success("required")); + }); + + it(`rejects an arbitrary string`, () => { + expect(parse("foobar", parseSimplify)).toEqual(anyFailure); + }); +}); diff --git a/packages/perseus-core/src/parse-perseus-json/perseus-parsers/numeric-input-widget.ts b/packages/perseus-core/src/parse-perseus-json/perseus-parsers/numeric-input-widget.ts index 3bbde30509..2665695daa 100644 --- a/packages/perseus-core/src/parse-perseus-json/perseus-parsers/numeric-input-widget.ts +++ b/packages/perseus-core/src/parse-perseus-json/perseus-parsers/numeric-input-widget.ts @@ -16,7 +16,10 @@ import {defaulted} from "../general-purpose-parsers/defaulted"; import {parseWidget} from "./widget"; -import type {NumericInputWidget} from "../../data-schema"; +import type { + NumericInputWidget, + PerseusNumericInputSimplify, +} from "../../data-schema"; import type {Parser} from "../parser-types"; const parseMathFormat = enumeration( @@ -29,12 +32,41 @@ const parseMathFormat = enumeration( "pi", ); -const parseSimplify = enumeration( - "required", - "correct", - "enforced", - "optional", -); +export const parseSimplify = pipeParsers( + union(constant(null)) + .or(constant(undefined)) + .or(boolean) + .or(constant("required")) + .or(constant("correct")) + .or(constant("enforced")) + .or(constant("optional")) + .or(constant("accepted")).parser, +).then(convert(deprecatedSimplifyValuesToRequired)).parser; + +function deprecatedSimplifyValuesToRequired( + simplify: + | "required" + | "correct" + | "enforced" + | "optional" + | "accepted" + | null + | undefined + | boolean, +): PerseusNumericInputSimplify { + switch (simplify) { + case "enforced": + case "required": + case "optional": + return simplify; + // NOTE(benchristel): "accepted", "correct", true, false, undefined, and + // null are all treated the same as "required" during scoring, so we + // convert them to "required" here to preserve behavior. See the tests + // in score-numeric-input.test.ts + default: + return "required"; + } +} export const parseNumericInputWidget: Parser = parseWidget( constant("numeric-input"), @@ -53,20 +85,7 @@ export const parseNumericInputWidget: Parser = parseWidget( // TODO(benchristel): simplify should never be a boolean, but we // have some content where it is anyway. If we ever backfill // the data, we should simplify `simplify`. - simplify: optional( - nullable( - union(parseSimplify).or( - pipeParsers(boolean).then( - convert((value) => { - if (typeof value === "boolean") { - return value ? "required" : "optional"; - } - return value; - }), - ).parser, - ).parser, - ), - ), + simplify: parseSimplify, }), ), labelText: optional(string), @@ -78,16 +97,7 @@ export const parseNumericInputWidget: Parser = parseWidget( array( object({ name: parseMathFormat, - simplify: optional( - nullable( - enumeration( - "required", - "correct", - "enforced", - "optional", - ), - ), - ), + simplify: parseSimplify, }), ), ), diff --git a/packages/perseus-score/src/widgets/numeric-input/score-numeric-input.test.ts b/packages/perseus-score/src/widgets/numeric-input/score-numeric-input.test.ts index 37ef3e7c5d..83cb193860 100644 --- a/packages/perseus-score/src/widgets/numeric-input/score-numeric-input.test.ts +++ b/packages/perseus-score/src/widgets/numeric-input/score-numeric-input.test.ts @@ -528,6 +528,120 @@ describe("scoreNumericInput", () => { expect(score).toHaveBeenAnsweredIncorrectly(); }); + + // Tests for the "simplify" widget option: + // + // - simplify: "enforced" means unsimplified fractions are marked incorrect. + // - simplify: "required" means unsimplified fractions are returned as + // invalid, and the learner can try again. + // - simplify: "optional" means unsimplified fractions are accepted as + // correct. + // + // NOTE(benchristel): "accepted", "correct", booleans, undefined, and null + // are treated the same as "required". They are not valid values for + // `simplify` according to our types, but they appear in production data. + // The tests for these values characterize the current behavior as of + // 2025-03-18. Note that "accepted", "correct", booleans, undefined, and + // null are treated the same as "required". + describe.each` + simplify | unsimplifiedFractionScore + ${"enforced"} | ${"incorrect"} + ${"optional"} | ${"correct"} + ${"required"} | ${"invalid"} + ${"accepted"} | ${"invalid"} + ${"correct"} | ${"invalid"} + ${undefined} | ${"invalid"} + ${null} | ${"invalid"} + ${false} | ${"invalid"} + ${true} | ${"invalid"} + `("with simplify: $simplify", ({simplify, unsimplifiedFractionScore}) => { + it(`marks unsimplified fractions ${unsimplifiedFractionScore}`, () => { + const answerMatchers = { + incorrect: expect.objectContaining({ + type: "points", + earned: 0, + }), + + correct: expect.objectContaining({ + type: "points", + earned: 1, + }), + + invalid: expect.objectContaining({ + type: "invalid", + }), + }; + + const expected = answerMatchers[unsimplifiedFractionScore]; + + // Arrange + const rubric: PerseusNumericInputRubric = { + coefficient: false, + answers: [ + { + value: 0.5, + status: "correct", + maxError: 0, + simplify, + strict: false, + message: "", + }, + ], + }; + + // Act + const score = scoreNumericInput({currentValue: "2/4"}, rubric); + + // Assert + expect(score).toEqual(expected); + }); + + it("marks a simplified fraction correct", () => { + // Arrange + const rubric: PerseusNumericInputRubric = { + coefficient: false, + answers: [ + { + value: 0.5, + status: "correct", + maxError: 0, + simplify, + strict: false, + message: "", + }, + ], + }; + + // Act + const score = scoreNumericInput({currentValue: "1/2"}, rubric); + + // Assert + expect(score).toHaveBeenAnsweredCorrectly(); + }); + + it("marks an incorrect fraction incorrect", () => { + // Arrange + const rubric: PerseusNumericInputRubric = { + coefficient: false, + answers: [ + { + value: 0.5, + status: "correct", + maxError: 0, + simplify, + strict: false, + message: "", + }, + ], + }; + + // Act + const score = scoreNumericInput({currentValue: "2/3"}, rubric); + + // Assert + expect(score).toHaveBeenAnsweredIncorrectly(); + }); + }); }); describe("maybeParsePercentInput utility function", () => {