Skip to content

Commit 1b5f514

Browse files
authored
Numeric Input: Treat unrecognized simplify values as "required" (#2308)
Context: I'm seeing errors in Sentry because the perseus JSON parser doesn't understand some of the values of `simplify` that exist in production data. This PR ensures we accept and handle those values. ## Summary: - I added tests to characterize the behavior of numeric input scoring for all the values of `simplify` that are in production. - I updated the perseus JSON parser to convert nonstandard `simplify` values to equivalent ones. Issue: none ## Test plan: `yarn test` Author: benchristel Reviewers: benchristel, SonicScrewdriver, handeyeco Required Reviewers: Approved By: SonicScrewdriver Checks: ✅ 8 checks were successful Pull Request URL: #2308
1 parent 4c0b317 commit 1b5f514

File tree

7 files changed

+604
-39
lines changed

7 files changed

+604
-39
lines changed

.changeset/fair-ravens-guess.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@khanacademy/perseus-core": patch
3+
---
4+
5+
Fix a bug in numeric input scoring where `simplify` value of `false` led to incorrect scoring logic being applied

packages/perseus-core/src/data-schema.ts

+11-7
Original file line numberDiff line numberDiff line change
@@ -1206,15 +1206,19 @@ export type MathFormat =
12061206
| "pi";
12071207

12081208
export type PerseusNumericInputAnswerForm = {
1209-
simplify: PerseusNumericInputSimplify | null | undefined;
1209+
simplify: PerseusNumericInputSimplify;
12101210
name: MathFormat;
12111211
};
12121212

1213-
export type PerseusNumericInputSimplify =
1214-
| "required"
1215-
| "correct"
1216-
| "enforced"
1217-
| "optional";
1213+
/**
1214+
* Determines how unsimplified fractions are scored.
1215+
*
1216+
* - "required" means unsimplified fractions are considered invalid input, and
1217+
* the learner can try again.
1218+
* - "enforced" means unsimplified fractions are marked incorrect.
1219+
* - "optional" means unsimplified fractions are accepted.
1220+
*/
1221+
export type PerseusNumericInputSimplify = "required" | "enforced" | "optional";
12181222

12191223
export type PerseusNumericInputWidgetOptions = {
12201224
// A list of all the possible correct and incorrect answers
@@ -1249,7 +1253,7 @@ export type PerseusNumericInputAnswer = {
12491253
// NOTE: perseus_data.go says this is non-nullable even though we handle null values.
12501254
maxError: number | null | undefined;
12511255
// Unsimplified answers are Ungraded, Accepted, or Wrong. Options: "required", "correct", or "enforced"
1252-
simplify: PerseusNumericInputSimplify | null | undefined;
1256+
simplify: PerseusNumericInputSimplify;
12531257
};
12541258

12551259
export type PerseusNumberLineWidgetOptions = {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import {anyFailure} from "../general-purpose-parsers/test-helpers";
2+
import {parse} from "../parse";
3+
import {success} from "../result";
4+
5+
import {parseSimplify} from "./numeric-input-widget";
6+
7+
describe("parseSimplify", () => {
8+
it(`preserves "required"`, () => {
9+
expect(parse("required", parseSimplify)).toEqual(success("required"));
10+
});
11+
12+
it(`preserves "enforced"`, () => {
13+
expect(parse("enforced", parseSimplify)).toEqual(success("enforced"));
14+
});
15+
16+
it(`preserves "optional"`, () => {
17+
expect(parse("optional", parseSimplify)).toEqual(success("optional"));
18+
});
19+
20+
it(`converts null to "required"`, () => {
21+
expect(parse(null, parseSimplify)).toEqual(success("required"));
22+
});
23+
24+
it(`converts undefined to "required"`, () => {
25+
expect(parse(undefined, parseSimplify)).toEqual(success("required"));
26+
});
27+
28+
it(`converts true to "required"`, () => {
29+
expect(parse(true, parseSimplify)).toEqual(success("required"));
30+
});
31+
32+
it(`converts false to "required"`, () => {
33+
expect(parse(false, parseSimplify)).toEqual(success("required"));
34+
});
35+
36+
it(`converts "accepted" to "required"`, () => {
37+
expect(parse("accepted", parseSimplify)).toEqual(success("required"));
38+
});
39+
40+
it(`converts "correct" to "required"`, () => {
41+
expect(parse("correct", parseSimplify)).toEqual(success("required"));
42+
});
43+
44+
it(`rejects an arbitrary string`, () => {
45+
expect(parse("foobar", parseSimplify)).toEqual(anyFailure);
46+
});
47+
});

packages/perseus-core/src/parse-perseus-json/perseus-parsers/numeric-input-widget.ts

+41-31
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ import {defaulted} from "../general-purpose-parsers/defaulted";
1616

1717
import {parseWidget} from "./widget";
1818

19-
import type {NumericInputWidget} from "../../data-schema";
19+
import type {
20+
NumericInputWidget,
21+
PerseusNumericInputSimplify,
22+
} from "../../data-schema";
2023
import type {Parser} from "../parser-types";
2124

2225
const parseMathFormat = enumeration(
@@ -29,12 +32,41 @@ const parseMathFormat = enumeration(
2932
"pi",
3033
);
3134

32-
const parseSimplify = enumeration(
33-
"required",
34-
"correct",
35-
"enforced",
36-
"optional",
37-
);
35+
export const parseSimplify = pipeParsers(
36+
union(constant(null))
37+
.or(constant(undefined))
38+
.or(boolean)
39+
.or(constant("required"))
40+
.or(constant("correct"))
41+
.or(constant("enforced"))
42+
.or(constant("optional"))
43+
.or(constant("accepted")).parser,
44+
).then(convert(deprecatedSimplifyValuesToRequired)).parser;
45+
46+
function deprecatedSimplifyValuesToRequired(
47+
simplify:
48+
| "required"
49+
| "correct"
50+
| "enforced"
51+
| "optional"
52+
| "accepted"
53+
| null
54+
| undefined
55+
| boolean,
56+
): PerseusNumericInputSimplify {
57+
switch (simplify) {
58+
case "enforced":
59+
case "required":
60+
case "optional":
61+
return simplify;
62+
// NOTE(benchristel): "accepted", "correct", true, false, undefined, and
63+
// null are all treated the same as "required" during scoring, so we
64+
// convert them to "required" here to preserve behavior. See the tests
65+
// in score-numeric-input.test.ts
66+
default:
67+
return "required";
68+
}
69+
}
3870

3971
export const parseNumericInputWidget: Parser<NumericInputWidget> = parseWidget(
4072
constant("numeric-input"),
@@ -53,20 +85,7 @@ export const parseNumericInputWidget: Parser<NumericInputWidget> = parseWidget(
5385
// TODO(benchristel): simplify should never be a boolean, but we
5486
// have some content where it is anyway. If we ever backfill
5587
// the data, we should simplify `simplify`.
56-
simplify: optional(
57-
nullable(
58-
union(parseSimplify).or(
59-
pipeParsers(boolean).then(
60-
convert((value) => {
61-
if (typeof value === "boolean") {
62-
return value ? "required" : "optional";
63-
}
64-
return value;
65-
}),
66-
).parser,
67-
).parser,
68-
),
69-
),
88+
simplify: parseSimplify,
7089
}),
7190
),
7291
labelText: optional(string),
@@ -78,16 +97,7 @@ export const parseNumericInputWidget: Parser<NumericInputWidget> = parseWidget(
7897
array(
7998
object({
8099
name: parseMathFormat,
81-
simplify: optional(
82-
nullable(
83-
enumeration(
84-
"required",
85-
"correct",
86-
"enforced",
87-
"optional",
88-
),
89-
),
90-
),
100+
simplify: parseSimplify,
91101
}),
92102
),
93103
),

0 commit comments

Comments
 (0)