Skip to content

Commit cec57bb

Browse files
authored
Add recursive typesafe parser for Group's user input type (#2545)
## Summary: Add a user input type parser and typetest for the Group widget. Following the model of `widget-map.ts`, in the course of adding the above parser, this adds functions for parsing a UserInputMap and its entries. Has a number of TODOs to finish once the remaining widget user input parsers are added. Issue: LEMS-3140 ## Test plan: - Confirm all checks pass - Confirm widget still acts as expected via Storybook Author: Myranae Reviewers: benchristel, Myranae, handeyeco Required Reviewers: Approved By: benchristel Checks: ✅ 8 checks were successful Pull Request URL: #2545
1 parent 0eba51c commit cec57bb

File tree

8 files changed

+715
-24
lines changed

8 files changed

+715
-24
lines changed

.changeset/warm-penguins-tickle.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@khanacademy/perseus-core": patch
3+
---
4+
5+
Add recursive typesafe parser for Group's user input type with supporting utilities and widget ID parsing refactor
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import {parseUserInputMap} from "./user-input-map";
2+
3+
import type {PerseusGroupUserInput} from "../../validation.types";
4+
import type {Parser} from "../parser-types";
5+
6+
// NOTE(TB): Function wrapper is necessary to avoid circular import issues with user-input-map.ts
7+
export const parseGroupUserInput: Parser<PerseusGroupUserInput> = (
8+
rawVal,
9+
ctx,
10+
) => parseUserInputMap(rawVal, ctx);
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import {summon} from "../general-purpose-parsers/test-helpers";
2+
3+
import type {parseGroupUserInput} from "./group-user-input";
4+
import type {PerseusGroupUserInput} from "../../validation.types";
5+
import type {RecursiveRequired} from "../general-purpose-parsers/test-helpers";
6+
import type {ParsedValue} from "../parser-types";
7+
8+
type Parsed = ParsedValue<typeof parseGroupUserInput>;
9+
10+
summon<Parsed>() satisfies PerseusGroupUserInput;
11+
summon<PerseusGroupUserInput>() satisfies Parsed;
12+
13+
// The `RecursiveRequired` test ensures that any new optional properties added
14+
// to the types in data-schema.ts are also added to the parser.
15+
summon<
16+
RecursiveRequired<Parsed>
17+
>() satisfies RecursiveRequired<PerseusGroupUserInput>;
Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
import {
2+
anyFailure,
3+
ctx,
4+
parseFailureWith,
5+
} from "../general-purpose-parsers/test-helpers";
6+
import {success} from "../result";
7+
8+
import {parseUserInputMap} from "./user-input-map";
9+
10+
describe("parseUserInputMap", () => {
11+
describe("basic structure validation", () => {
12+
it("accepts an empty user input map", () => {
13+
const result = parseUserInputMap({}, ctx());
14+
expect(result).toEqual(success({}));
15+
});
16+
17+
it("rejects a non-object", () => {
18+
const result = parseUserInputMap("not an object", ctx());
19+
expect(result).toEqual(anyFailure);
20+
});
21+
22+
it("rejects null", () => {
23+
const result = parseUserInputMap(null, ctx());
24+
expect(result).toEqual(anyFailure);
25+
});
26+
27+
it("rejects an array", () => {
28+
const result = parseUserInputMap([], ctx());
29+
expect(result).toEqual(anyFailure);
30+
});
31+
32+
it("provides appropriate error message for non-object", () => {
33+
const result = parseUserInputMap("not an object", ctx());
34+
expect(result).toEqual(
35+
parseFailureWith({
36+
expected: ["UserInputMap"],
37+
badValue: "not an object",
38+
}),
39+
);
40+
});
41+
});
42+
43+
describe("invalid widget ID keys", () => {
44+
it("rejects an invalid widget ID", () => {
45+
const userInputMap = {
46+
"radio -1": {choicesSelected: [false]},
47+
};
48+
const result = parseUserInputMap(userInputMap, ctx());
49+
expect(result).toEqual(
50+
parseFailureWith({
51+
path: ["radio -1", "(widget ID)", 1],
52+
expected: ["a string representing a non-negative integer"],
53+
}),
54+
);
55+
});
56+
});
57+
58+
describe("valid user input for known widget types", () => {
59+
it("accepts valid dropdown user input", () => {
60+
const userInputMap = {
61+
"dropdown 1": {value: 0},
62+
};
63+
const result = parseUserInputMap(userInputMap, ctx());
64+
expect(result).toEqual(
65+
success({
66+
"dropdown 1": {value: 0},
67+
}),
68+
);
69+
});
70+
});
71+
72+
describe("invalid user input for known widget types", () => {
73+
it("rejects invalid dropdown user input", () => {
74+
const userInputMap = {
75+
"dropdown 1": {value: "not a number"},
76+
};
77+
const result = parseUserInputMap(userInputMap, ctx());
78+
expect(result).toEqual(anyFailure);
79+
});
80+
81+
it("rejects invalid radio user input", () => {
82+
const userInputMap = {
83+
"radio 1": {choicesSelected: "not an array"},
84+
};
85+
const result = parseUserInputMap(userInputMap, ctx());
86+
expect(result).toEqual(anyFailure);
87+
});
88+
89+
it("rejects invalid numeric-input user input", () => {
90+
const userInputMap = {
91+
"numeric-input 1": {currentValue: 123}, // should be string
92+
};
93+
const result = parseUserInputMap(userInputMap, ctx());
94+
expect(result).toEqual(anyFailure);
95+
});
96+
97+
it("rejects invalid free-response user input", () => {
98+
const userInputMap = {
99+
"free-response 1": {currentValue: 123}, // should be string
100+
};
101+
const result = parseUserInputMap(userInputMap, ctx());
102+
expect(result).toEqual(anyFailure);
103+
});
104+
105+
it("rejects invalid categorizer user input structure", () => {
106+
const userInputMap = {
107+
"categorizer 1": {values: ["string", "not", "numbers"]},
108+
};
109+
const result = parseUserInputMap(userInputMap, ctx());
110+
expect(result).toEqual(anyFailure);
111+
});
112+
113+
it("rejects invalid matrix user input", () => {
114+
const userInputMap = {
115+
"matrix 1": {answers: "not an array"},
116+
};
117+
const result = parseUserInputMap(userInputMap, ctx());
118+
expect(result).toEqual(anyFailure);
119+
});
120+
121+
it("rejects invalid table user input", () => {
122+
const userInputMap = {
123+
"table 1": "not an array",
124+
};
125+
const result = parseUserInputMap(userInputMap, ctx());
126+
expect(result).toEqual(anyFailure);
127+
});
128+
129+
it("rejects invalid plotter user input", () => {
130+
const userInputMap = {
131+
"plotter 1": ["not", "numbers"],
132+
};
133+
const result = parseUserInputMap(userInputMap, ctx());
134+
expect(result).toEqual(anyFailure);
135+
});
136+
137+
it("rejects invalid number-line user input", () => {
138+
const userInputMap = {
139+
"number-line 1": {numLinePosition: "not a number"},
140+
};
141+
const result = parseUserInputMap(userInputMap, ctx());
142+
expect(result).toEqual(anyFailure);
143+
});
144+
145+
it("rejects invalid expression user input", () => {
146+
const userInputMap = {
147+
"expression 1": 123, // should be string
148+
};
149+
const result = parseUserInputMap(userInputMap, ctx());
150+
expect(result).toEqual(anyFailure);
151+
});
152+
153+
it("rejects invalid input-number user input", () => {
154+
const userInputMap = {
155+
"input-number 1": {currentValue: 123}, // should be string
156+
};
157+
const result = parseUserInputMap(userInputMap, ctx());
158+
expect(result).toEqual(anyFailure);
159+
});
160+
161+
it("rejects invalid interactive-graph user input", () => {
162+
const userInputMap = {
163+
"interactive-graph 1": "not a valid graph object",
164+
};
165+
const result = parseUserInputMap(userInputMap, ctx());
166+
expect(result).toEqual(anyFailure);
167+
});
168+
169+
it("rejects invalid grapher user input", () => {
170+
const userInputMap = {
171+
"grapher 1": "not a valid grapher object",
172+
};
173+
const result = parseUserInputMap(userInputMap, ctx());
174+
expect(result).toEqual(anyFailure);
175+
});
176+
177+
it("rejects invalid orderer user input", () => {
178+
const userInputMap = {
179+
"orderer 1": {current: "not an array"},
180+
};
181+
const result = parseUserInputMap(userInputMap, ctx());
182+
expect(result).toEqual(anyFailure);
183+
});
184+
185+
it("rejects invalid matcher user input", () => {
186+
const userInputMap = {
187+
"matcher 1": {left: "not an array"},
188+
};
189+
const result = parseUserInputMap(userInputMap, ctx());
190+
expect(result).toEqual(anyFailure);
191+
});
192+
193+
it("rejects invalid sorter user input", () => {
194+
const userInputMap = {
195+
"sorter 1": {options: "not an array"},
196+
};
197+
const result = parseUserInputMap(userInputMap, ctx());
198+
expect(result).toEqual(anyFailure);
199+
});
200+
201+
it("rejects invalid label-image user input", () => {
202+
const userInputMap = {
203+
"label-image 1": {markers: "not an array"},
204+
};
205+
const result = parseUserInputMap(userInputMap, ctx());
206+
expect(result).toEqual(anyFailure);
207+
});
208+
209+
it("rejects invalid cs-program user input", () => {
210+
const userInputMap = {
211+
"cs-program 1": {code: 123}, // should be "correct", "incorrect", or "incomplete"
212+
};
213+
const result = parseUserInputMap(userInputMap, ctx());
214+
expect(result).toEqual(anyFailure);
215+
});
216+
217+
it("rejects invalid iframe user input", () => {
218+
const userInputMap = {
219+
"iframe 1": {status: 123}, // should be "correct", "incorrect", or "incomplete"
220+
};
221+
const result = parseUserInputMap(userInputMap, ctx());
222+
expect(result).toEqual(anyFailure);
223+
});
224+
225+
it("stops at first invalid widget and provides appropriate error", () => {
226+
const userInputMap = {
227+
"dropdown 1": {value: "invalid"},
228+
"radio 2": {choicesSelected: [true]}, // this one is valid
229+
};
230+
const result = parseUserInputMap(userInputMap, ctx());
231+
232+
expect(result).toEqual(
233+
parseFailureWith({
234+
path: ["dropdown 1", "value"],
235+
}),
236+
);
237+
});
238+
});
239+
240+
describe("unrecognized widget types", () => {
241+
it("accepts user input for unrecognized widget types", () => {
242+
const userInputMap = {
243+
"unknown-widget 1": {anyField: "anyValue", someNumber: 42},
244+
};
245+
const result = parseUserInputMap(userInputMap, ctx());
246+
expect(result).toEqual(
247+
success({
248+
"unknown-widget 1": {anyField: "anyValue", someNumber: 42},
249+
}),
250+
);
251+
});
252+
253+
it("validates widget ID format even for unknown widget types", () => {
254+
const userInputMap = {
255+
"unknown-widget -1": {someField: "value"},
256+
};
257+
const result = parseUserInputMap(userInputMap, ctx());
258+
expect(result).toEqual(anyFailure);
259+
});
260+
});
261+
262+
describe("recursive group widget handling", () => {
263+
it("accepts nested group widgets", () => {
264+
const userInputMap = {
265+
"group 1": {
266+
"dropdown 1": {value: 0},
267+
"group 2": {
268+
"radio 1": {choicesSelected: [true, false]},
269+
},
270+
},
271+
};
272+
const result = parseUserInputMap(userInputMap, ctx());
273+
expect(result).toEqual(
274+
success({
275+
"group 1": {
276+
"dropdown 1": {value: 0},
277+
"group 2": {
278+
"radio 1": {choicesSelected: [true, false]},
279+
},
280+
},
281+
}),
282+
);
283+
});
284+
285+
it("validates nested widgets in group widgets", () => {
286+
const userInputMap = {
287+
"group 1": {
288+
"dropdown 1": {value: "invalid"}, // should be number
289+
},
290+
};
291+
const result = parseUserInputMap(userInputMap, ctx());
292+
expect(result).toEqual(anyFailure);
293+
});
294+
295+
it("provides correct error path for nested validation failures", () => {
296+
const userInputMap = {
297+
"group 1": {
298+
"dropdown 1": {value: "invalid"},
299+
},
300+
};
301+
const result = parseUserInputMap(userInputMap, ctx());
302+
303+
expect(result).toEqual(
304+
parseFailureWith({
305+
path: ["group 1", "dropdown 1", "value"],
306+
expected: ["number"],
307+
}),
308+
);
309+
});
310+
});
311+
});

0 commit comments

Comments
 (0)