@khanacademy/perseus 15.0.0 → 16.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/dist/article-renderer.d.ts +2 -1
- package/dist/es/index.js +456 -10
- package/dist/es/index.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +457 -8
- package/dist/index.js.map +1 -1
- package/dist/item-renderer.d.ts +125 -0
- package/dist/mixins/provide-keypad.d.ts +39 -0
- package/dist/server-item-renderer.d.ts +15 -2
- package/package.json +1 -1
- package/src/__stories__/item-renderer.stories.tsx +30 -0
- package/src/__stories__/server-item-renderer.stories.tsx +1 -1
- package/src/__testdata__/item-renderer.testdata.ts +299 -0
- package/src/__tests__/__snapshots__/item-renderer.test.tsx.snap +224 -0
- package/src/__tests__/item-renderer.test.tsx +930 -0
- package/src/__tests__/server-item-renderer.test.tsx +1 -1
- package/src/article-renderer.tsx +15 -13
- package/src/index.ts +1 -0
- package/src/item-renderer.tsx +529 -0
- package/src/mixins/provide-keypad.tsx +137 -0
- package/src/server-item-renderer.tsx +13 -11
- package/tsconfig-build.tsbuildinfo +1 -1
- package/src/__testdata__/server-item-renderer.testdata.ts +0 -160
|
@@ -0,0 +1,930 @@
|
|
|
1
|
+
import {StatefulKeypadContextProvider} from "@khanacademy/math-input";
|
|
2
|
+
import {RenderStateRoot} from "@khanacademy/wonder-blocks-core";
|
|
3
|
+
import {fireEvent, render, screen} from "@testing-library/react";
|
|
4
|
+
import userEvent from "@testing-library/user-event";
|
|
5
|
+
import * as React from "react";
|
|
6
|
+
import "@testing-library/jest-dom"; // Imports custom matchers
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
testDependencies,
|
|
10
|
+
testDependenciesV2,
|
|
11
|
+
} from "../../../../testing/test-dependencies";
|
|
12
|
+
import {
|
|
13
|
+
itemWithInput,
|
|
14
|
+
labelImageItem,
|
|
15
|
+
mockedItem,
|
|
16
|
+
} from "../__testdata__/item-renderer.testdata";
|
|
17
|
+
import * as Dependencies from "../dependencies";
|
|
18
|
+
import ItemRenderer from "../item-renderer";
|
|
19
|
+
import {registerWidget} from "../widgets";
|
|
20
|
+
import {multiChoiceQuestion} from "../widgets/__testdata__/radio.testdata";
|
|
21
|
+
import DefinitionExport from "../widgets/definition";
|
|
22
|
+
import InputNumberExport from "../widgets/input-number";
|
|
23
|
+
import LabelImageExport from "../widgets/label-image";
|
|
24
|
+
import RadioWidgetExport from "../widgets/radio";
|
|
25
|
+
|
|
26
|
+
import MockWidgetExport from "./mock-widget";
|
|
27
|
+
|
|
28
|
+
import type {PerseusItem, PerseusRenderer} from "../perseus-types";
|
|
29
|
+
import type {Widget as WidgetInstance} from "../renderer";
|
|
30
|
+
import type {APIOptions, PerseusScore} from "../types";
|
|
31
|
+
|
|
32
|
+
const Peripherals = () => (
|
|
33
|
+
<div className="test_peripherals">
|
|
34
|
+
<div
|
|
35
|
+
id="calculator"
|
|
36
|
+
data-test-id="calculator"
|
|
37
|
+
style={{display: "none"}}
|
|
38
|
+
/>
|
|
39
|
+
<div
|
|
40
|
+
className="periodic-table-info-box"
|
|
41
|
+
data-test-id="periodic-table-info-box"
|
|
42
|
+
style={{display: "none"}}
|
|
43
|
+
/>
|
|
44
|
+
<div
|
|
45
|
+
className="z-table-info-box"
|
|
46
|
+
data-test-id="z-table-info-box"
|
|
47
|
+
style={{display: "none"}}
|
|
48
|
+
/>
|
|
49
|
+
<div
|
|
50
|
+
className="t-table-info-box"
|
|
51
|
+
data-test-id="t-table-info-box"
|
|
52
|
+
style={{display: "none"}}
|
|
53
|
+
/>
|
|
54
|
+
<div
|
|
55
|
+
className="chi2-table-info-box"
|
|
56
|
+
data-test-id="chi2-table-info-box"
|
|
57
|
+
style={{display: "none"}}
|
|
58
|
+
/>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
// This looks alot like `widgets/__tests__/renderQuestion.jsx', except we use
|
|
63
|
+
// the ItemRenderer instead of Renderer
|
|
64
|
+
export const renderQuestion = (
|
|
65
|
+
question: PerseusItem,
|
|
66
|
+
apiOptions: APIOptions = Object.freeze({}),
|
|
67
|
+
optionalProps: Partial<ItemRenderer["props"]> = Object.freeze({}),
|
|
68
|
+
): {
|
|
69
|
+
container: HTMLElement;
|
|
70
|
+
renderer: ItemRenderer;
|
|
71
|
+
} => {
|
|
72
|
+
let renderer: ItemRenderer | null = null;
|
|
73
|
+
const {container} = render(
|
|
74
|
+
<RenderStateRoot>
|
|
75
|
+
<StatefulKeypadContextProvider>
|
|
76
|
+
<ItemRenderer
|
|
77
|
+
ref={(node) => (renderer = node)}
|
|
78
|
+
apiOptions={apiOptions}
|
|
79
|
+
item={question}
|
|
80
|
+
problemNum={0}
|
|
81
|
+
reviewMode={false}
|
|
82
|
+
savedState=""
|
|
83
|
+
controlPeripherals={false}
|
|
84
|
+
dependencies={testDependenciesV2}
|
|
85
|
+
{...optionalProps}
|
|
86
|
+
/>
|
|
87
|
+
{/* The ItemRenderer _requires_ two divs: a work area and hints
|
|
88
|
+
area. Without both of these, it fails to render anything! */}
|
|
89
|
+
<div id="workarea" />
|
|
90
|
+
<div id="hintsarea" />
|
|
91
|
+
<Peripherals />
|
|
92
|
+
</StatefulKeypadContextProvider>
|
|
93
|
+
</RenderStateRoot>,
|
|
94
|
+
);
|
|
95
|
+
if (!renderer) {
|
|
96
|
+
throw new Error(`Failed to render!`);
|
|
97
|
+
}
|
|
98
|
+
return {container, renderer};
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// Finds a widget in the given renderer by ID. The return type is non-optional
|
|
102
|
+
// (ie. it assumes the widget will exist) to make TypeScript happier in the tests
|
|
103
|
+
// that use this function.
|
|
104
|
+
const getWidgetById = (
|
|
105
|
+
renderer: ItemRenderer,
|
|
106
|
+
widgetId: string,
|
|
107
|
+
): WidgetInstance => {
|
|
108
|
+
const mockWidget = renderer.questionRenderer.getWidgetInstance(widgetId);
|
|
109
|
+
expect(mockWidget).not.toBeUndefined();
|
|
110
|
+
if (mockWidget == null) {
|
|
111
|
+
throw new Error("Oops");
|
|
112
|
+
}
|
|
113
|
+
return mockWidget;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const makeHint = (content: string): PerseusRenderer => ({
|
|
117
|
+
content:
|
|
118
|
+
"Maybe if you try using a calculator? See: [[\u2603 mock-hint-widget 1]]",
|
|
119
|
+
widgets: {
|
|
120
|
+
"mock-hint-widget 1": {
|
|
121
|
+
graded: true,
|
|
122
|
+
version: {major: 0, minor: 0},
|
|
123
|
+
static: false,
|
|
124
|
+
// @ts-expect-error - TS2322 - Type '"mock-widget"' is not assignable to type '"video" | "image" | "iframe" | "table" | "radio" | "definition" | "group" | "matrix" | "categorizer" | "cs-program" | "dropdown" | "example-graphie-widget" | "example-widget" | ... 26 more ... | "unit-input"'.
|
|
125
|
+
type: "mock-widget",
|
|
126
|
+
options: {static: false},
|
|
127
|
+
alignment: "default",
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
images: {},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("item renderer", () => {
|
|
134
|
+
beforeAll(() => {
|
|
135
|
+
registerWidget("definition", DefinitionExport);
|
|
136
|
+
registerWidget("input-number", InputNumberExport);
|
|
137
|
+
registerWidget("mock-widget", MockWidgetExport);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
beforeEach(() => {
|
|
141
|
+
// Mock ResizeObserver used by the mobile keypad
|
|
142
|
+
window.ResizeObserver = jest.fn().mockImplementation(() => ({
|
|
143
|
+
observe: jest.fn(),
|
|
144
|
+
unobserve: jest.fn(),
|
|
145
|
+
disconnect: jest.fn(),
|
|
146
|
+
}));
|
|
147
|
+
|
|
148
|
+
jest.spyOn(Dependencies, "getDependencies").mockReturnValue(
|
|
149
|
+
testDependencies,
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("should snapshot", () => {
|
|
154
|
+
// Arrange and Act
|
|
155
|
+
const {container} = renderQuestion(itemWithInput);
|
|
156
|
+
|
|
157
|
+
// Assert
|
|
158
|
+
expect(container).toMatchSnapshot("initial render");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("should render the content", () => {
|
|
162
|
+
// Arrange and Act
|
|
163
|
+
renderQuestion(itemWithInput);
|
|
164
|
+
|
|
165
|
+
// Assert
|
|
166
|
+
expect(screen.getByRole("textbox")).toBeVisible();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("API options callbacks", () => {
|
|
170
|
+
it("should call the answerable callback when mounted", () => {
|
|
171
|
+
// Arrange
|
|
172
|
+
const answerableCallback = jest.fn();
|
|
173
|
+
|
|
174
|
+
// Act
|
|
175
|
+
renderQuestion(itemWithInput, {
|
|
176
|
+
answerableCallback,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// Assert
|
|
180
|
+
expect(answerableCallback).toHaveBeenCalledWith(
|
|
181
|
+
false /* Answer is not answerable yet */,
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("should call interactionCallback when widget interation happens", () => {
|
|
186
|
+
// Arrange
|
|
187
|
+
const interactionCallback = jest.fn();
|
|
188
|
+
const {renderer} = renderQuestion(mockedItem, {
|
|
189
|
+
interactionCallback,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Act
|
|
193
|
+
renderer.handleInteractWithWidget("mock-widget 3");
|
|
194
|
+
|
|
195
|
+
// Assert
|
|
196
|
+
expect(interactionCallback).toHaveBeenCalled();
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe("required DOM elements", () => {
|
|
201
|
+
it("should render empty div if no work area found", () => {
|
|
202
|
+
// Arrange and Act
|
|
203
|
+
renderQuestion(itemWithInput, undefined, {
|
|
204
|
+
workAreaSelector: "non_existant_id",
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// Assert
|
|
208
|
+
expect(
|
|
209
|
+
screen.queryByText(/Enter the number/),
|
|
210
|
+
).not.toBeInTheDocument();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("should render empty div if no hints area found", () => {
|
|
214
|
+
// Arrange and Act
|
|
215
|
+
renderQuestion(itemWithInput, undefined, {
|
|
216
|
+
hintsAreaSelector: "non_existant_id",
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// Assert
|
|
220
|
+
expect(
|
|
221
|
+
screen.queryByText(/Enter the number/),
|
|
222
|
+
).not.toBeInTheDocument();
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe("focus handling", () => {
|
|
227
|
+
it("should focus the first widget who's focus() returns true", () => {
|
|
228
|
+
// Arrange
|
|
229
|
+
const {renderer} = renderQuestion(mockedItem);
|
|
230
|
+
getWidgetById(renderer, "mock-widget 3").focus = jest
|
|
231
|
+
.fn()
|
|
232
|
+
.mockReturnValue(true);
|
|
233
|
+
|
|
234
|
+
// Act
|
|
235
|
+
const didFocus = renderer.focus();
|
|
236
|
+
|
|
237
|
+
// Assert
|
|
238
|
+
expect(didFocus).toBeTrue();
|
|
239
|
+
expect(renderer._currentFocus).toStrictEqual(["mock-widget 3"]);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("should blur the currently focused widget when blur() called", () => {
|
|
243
|
+
// Arrange
|
|
244
|
+
const {renderer} = renderQuestion(itemWithInput);
|
|
245
|
+
|
|
246
|
+
// Set focus to the widget and make sure it worked
|
|
247
|
+
screen.getByRole("textbox").focus();
|
|
248
|
+
expect(renderer._currentFocus).toStrictEqual(["input-number 1"]);
|
|
249
|
+
|
|
250
|
+
// Act
|
|
251
|
+
renderer.blur();
|
|
252
|
+
// There's two async calls (in a series) on the blur path:
|
|
253
|
+
// * _.defer() in renderer.jsx (in _onWidgetBlur())
|
|
254
|
+
// * setTimeout() in item-renderer.jsx (in _onRendererBlur())
|
|
255
|
+
jest.runOnlyPendingTimers();
|
|
256
|
+
jest.runOnlyPendingTimers();
|
|
257
|
+
|
|
258
|
+
// Assert
|
|
259
|
+
expect(renderer._currentFocus).toStrictEqual(null);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("should call the onFocusChange callback when focus set", () => {
|
|
263
|
+
// Arrange
|
|
264
|
+
const onFocusChange = jest.fn();
|
|
265
|
+
|
|
266
|
+
const {renderer} = renderQuestion(mockedItem, {
|
|
267
|
+
onFocusChange,
|
|
268
|
+
});
|
|
269
|
+
getWidgetById(renderer, "mock-widget 2").focus = jest
|
|
270
|
+
.fn()
|
|
271
|
+
.mockReturnValue(true);
|
|
272
|
+
|
|
273
|
+
// Act
|
|
274
|
+
renderer.focus();
|
|
275
|
+
|
|
276
|
+
// Assert
|
|
277
|
+
expect(onFocusChange).toHaveBeenCalledWith(
|
|
278
|
+
["mock-widget 2"], // widget that received focus
|
|
279
|
+
null, // previously focused widget
|
|
280
|
+
false, // did focus the input (our mock widget doesn't have input)
|
|
281
|
+
);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("should provide previous focus widgetId to onFocusChange", () => {
|
|
285
|
+
// Arrange
|
|
286
|
+
const onFocusChange = jest.fn();
|
|
287
|
+
|
|
288
|
+
const {renderer} = renderQuestion(mockedItem, {
|
|
289
|
+
onFocusChange,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
getWidgetById(renderer, "mock-widget 4").focus = jest
|
|
293
|
+
.fn()
|
|
294
|
+
.mockReturnValue(true);
|
|
295
|
+
renderer.focus();
|
|
296
|
+
|
|
297
|
+
getWidgetById(renderer, "mock-widget 2").focus = jest
|
|
298
|
+
.fn()
|
|
299
|
+
.mockReturnValue(true);
|
|
300
|
+
|
|
301
|
+
// Act
|
|
302
|
+
const gotFocus = renderer.focus();
|
|
303
|
+
|
|
304
|
+
// Assert
|
|
305
|
+
expect(gotFocus).toBeTrue();
|
|
306
|
+
expect(renderer._currentFocus).toStrictEqual(["mock-widget 2"]);
|
|
307
|
+
expect(onFocusChange).toHaveBeenCalledWith(
|
|
308
|
+
["mock-widget 2"], // widget that received focus
|
|
309
|
+
["mock-widget 4"], // previously focused widget
|
|
310
|
+
false, // did focus the input (our mock widget doesn't have input)
|
|
311
|
+
);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("should provide current and previous focus paths on focus change to, and away from, a single widget", () => {
|
|
315
|
+
// Arrange
|
|
316
|
+
const onFocusChange = jest.fn();
|
|
317
|
+
renderQuestion(itemWithInput, {
|
|
318
|
+
onFocusChange,
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
userEvent.click(screen.getByRole("textbox"));
|
|
322
|
+
|
|
323
|
+
// Act
|
|
324
|
+
userEvent.tab();
|
|
325
|
+
// There's two async calls (in a series) on the blur path:
|
|
326
|
+
// * _.defer() in renderer.jsx (in _onWidgetBlur())
|
|
327
|
+
// * setTimeout() in item-renderer.jsx (in _onRendererBlur())
|
|
328
|
+
jest.runOnlyPendingTimers();
|
|
329
|
+
jest.runOnlyPendingTimers();
|
|
330
|
+
|
|
331
|
+
// Assert
|
|
332
|
+
expect(onFocusChange).toHaveBeenCalledTimes(2);
|
|
333
|
+
expect(onFocusChange).toHaveBeenNthCalledWith(
|
|
334
|
+
1,
|
|
335
|
+
["input-number 1"], // current focus
|
|
336
|
+
null, // previous focus
|
|
337
|
+
null, // no keypad element?
|
|
338
|
+
);
|
|
339
|
+
expect(onFocusChange).toHaveBeenNthCalledWith(
|
|
340
|
+
2,
|
|
341
|
+
null, // current focus
|
|
342
|
+
["input-number 1"], // previous focus
|
|
343
|
+
null, // no keypad element?
|
|
344
|
+
);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("should provide current and previous focus paths on focus change from one widget to another", () => {
|
|
348
|
+
// Arrange
|
|
349
|
+
const onFocusChange = jest.fn();
|
|
350
|
+
const inputNumber =
|
|
351
|
+
itemWithInput.question.widgets["input-number 1"];
|
|
352
|
+
|
|
353
|
+
renderQuestion(
|
|
354
|
+
{
|
|
355
|
+
...itemWithInput,
|
|
356
|
+
question: {
|
|
357
|
+
...itemWithInput.question,
|
|
358
|
+
content:
|
|
359
|
+
"Enter the number $$-42$$ in both boxes: \n\n[[\u2603 input-number 1]]\n\n[[\u2603 input-number 2]]",
|
|
360
|
+
widgets: {
|
|
361
|
+
"input-number 1": inputNumber,
|
|
362
|
+
"input-number 2": inputNumber,
|
|
363
|
+
},
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
{onFocusChange},
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
// Focus second input to start
|
|
370
|
+
userEvent.click(screen.queryAllByRole("textbox")[1]);
|
|
371
|
+
|
|
372
|
+
// Act
|
|
373
|
+
userEvent.click(screen.queryAllByRole("textbox")[0]);
|
|
374
|
+
// There's two async calls (in a series) on the blur path:
|
|
375
|
+
// * _.defer() in renderer.jsx (in _onWidgetBlur())
|
|
376
|
+
// * setTimeout() in item-renderer.jsx (in _onRendererBlur())
|
|
377
|
+
jest.runOnlyPendingTimers();
|
|
378
|
+
jest.runOnlyPendingTimers();
|
|
379
|
+
|
|
380
|
+
// Assert
|
|
381
|
+
expect(onFocusChange).toHaveBeenCalledTimes(2);
|
|
382
|
+
expect(onFocusChange).toHaveBeenNthCalledWith(
|
|
383
|
+
1,
|
|
384
|
+
["input-number 2"], // current focus
|
|
385
|
+
null, // previous focus
|
|
386
|
+
null, // no keypad element?
|
|
387
|
+
);
|
|
388
|
+
expect(onFocusChange).toHaveBeenNthCalledWith(
|
|
389
|
+
2,
|
|
390
|
+
["input-number 1"], // current focus
|
|
391
|
+
["input-number 2"], // previous focus
|
|
392
|
+
null, // no keypad element?
|
|
393
|
+
);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("should set given value to widget at focus path", () => {
|
|
397
|
+
// Arrange
|
|
398
|
+
const {renderer} = renderQuestion(itemWithInput);
|
|
399
|
+
const cb = jest.fn();
|
|
400
|
+
|
|
401
|
+
// Act
|
|
402
|
+
renderer.setInputValue(["input-number 1"], "answer_42", cb);
|
|
403
|
+
jest.runOnlyPendingTimers(); // due to setState()
|
|
404
|
+
|
|
405
|
+
// Assert
|
|
406
|
+
expect(cb).toHaveBeenCalled();
|
|
407
|
+
expect(screen.getByDisplayValue("answer_42")).toBeVisible();
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("should focus the widget found at the given path", () => {
|
|
411
|
+
// Arrange
|
|
412
|
+
const {renderer} = renderQuestion(itemWithInput);
|
|
413
|
+
|
|
414
|
+
// Act
|
|
415
|
+
renderer.focusPath(["input-number 1"]);
|
|
416
|
+
|
|
417
|
+
// Assert
|
|
418
|
+
expect(screen.getByRole("textbox")).toHaveFocus();
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it("should blur the widget found at the given path", () => {
|
|
422
|
+
// Arrange
|
|
423
|
+
const {renderer} = renderQuestion(itemWithInput);
|
|
424
|
+
screen.getByRole("textbox").focus();
|
|
425
|
+
|
|
426
|
+
// Act
|
|
427
|
+
renderer.blurPath(["input-number 1"]);
|
|
428
|
+
// There's two async calls (in a series) on the blur path:
|
|
429
|
+
// * _.defer() in renderer.jsx (in _onWidgetBlur())
|
|
430
|
+
// * setTimeout() in item-renderer.jsx (in _onRendererBlur())
|
|
431
|
+
jest.runOnlyPendingTimers();
|
|
432
|
+
jest.runOnlyPendingTimers();
|
|
433
|
+
|
|
434
|
+
// Assert
|
|
435
|
+
expect(screen.getByRole("textbox")).not.toHaveFocus();
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
// TODO(jeremy): I couldn't get this test to work, but I'd like to eventually
|
|
439
|
+
it.skip("should dismiss the keypad if no input element focused (mobile)", () => {
|
|
440
|
+
// Arrange
|
|
441
|
+
const onFocusChange = jest.fn();
|
|
442
|
+
renderQuestion(itemWithInput, {
|
|
443
|
+
onFocusChange,
|
|
444
|
+
isMobile: true,
|
|
445
|
+
customKeypad: true,
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
const mathInput = screen.getByRole("textbox");
|
|
449
|
+
fireEvent.touchStart(mathInput);
|
|
450
|
+
fireEvent.touchEnd(mathInput);
|
|
451
|
+
jest.runOnlyPendingTimers();
|
|
452
|
+
// Make sure the keypad appeared
|
|
453
|
+
expect(screen.getByLabelText("Up arrow")).toBeVisible();
|
|
454
|
+
|
|
455
|
+
// Act
|
|
456
|
+
const instructions = screen.getByText(/Enter the number/);
|
|
457
|
+
fireEvent.touchStart(instructions);
|
|
458
|
+
fireEvent.touchEnd(instructions);
|
|
459
|
+
// There's two defer()'s on the blur path:
|
|
460
|
+
// * one in renderer.jsx
|
|
461
|
+
// * one in item-renderer.jsx
|
|
462
|
+
jest.runOnlyPendingTimers();
|
|
463
|
+
jest.runOnlyPendingTimers();
|
|
464
|
+
|
|
465
|
+
// Assert
|
|
466
|
+
// TODO!!
|
|
467
|
+
expect(screen.getByLabelText("Up arrow")).not.toBeVisible();
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
describe("scoring", () => {
|
|
472
|
+
it("should highlight empty widgets when scoring", () => {
|
|
473
|
+
// Arrange
|
|
474
|
+
const {renderer} = renderQuestion(itemWithInput);
|
|
475
|
+
|
|
476
|
+
// Act
|
|
477
|
+
const score = renderer.scoreInput();
|
|
478
|
+
jest.runOnlyPendingTimers(); // due to setState()
|
|
479
|
+
|
|
480
|
+
// Assert
|
|
481
|
+
expect(score.empty).toBeTrue();
|
|
482
|
+
expect(screen.getByRole("textbox")).toBeHighlighted();
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it("should remove highlight after marking widget as having had interaction", () => {
|
|
486
|
+
// Arrange
|
|
487
|
+
const {renderer} = renderQuestion(itemWithInput);
|
|
488
|
+
renderer.scoreInput(); // Triggers highlighting
|
|
489
|
+
expect(screen.getByRole("textbox")).toBeHighlighted();
|
|
490
|
+
|
|
491
|
+
// Act
|
|
492
|
+
screen.getByRole("textbox").focus();
|
|
493
|
+
userEvent.keyboard("23");
|
|
494
|
+
jest.runOnlyPendingTimers(); // due to setState()
|
|
495
|
+
|
|
496
|
+
// Assert
|
|
497
|
+
expect(screen.getByRole("textbox")).not.toBeHighlighted();
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it("should return a score for each widget when scoreWidgets() called", () => {
|
|
501
|
+
// Arrange
|
|
502
|
+
const correct: PerseusScore = {earned: 1, total: 1, type: "points"};
|
|
503
|
+
const wrong: PerseusScore = {
|
|
504
|
+
type: "invalid",
|
|
505
|
+
message: "please enter a value",
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
let isCorrect = true;
|
|
509
|
+
const ticktock = (): PerseusScore => {
|
|
510
|
+
const wasCorrect = isCorrect;
|
|
511
|
+
isCorrect = !isCorrect;
|
|
512
|
+
if (wasCorrect) {
|
|
513
|
+
return correct;
|
|
514
|
+
}
|
|
515
|
+
return wrong;
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
const {renderer} = renderQuestion(mockedItem);
|
|
519
|
+
for (const [id] of Object.entries(mockedItem.question.widgets)) {
|
|
520
|
+
const widget = getWidgetById(renderer, id);
|
|
521
|
+
widget.simpleValidate = jest.fn().mockReturnValue(ticktock());
|
|
522
|
+
widget.getUserInput = jest
|
|
523
|
+
.fn()
|
|
524
|
+
.mockReturnValue(`user input for ${id}`);
|
|
525
|
+
widget.getSerializedState = jest
|
|
526
|
+
.fn()
|
|
527
|
+
.mockReturnValue({id, a: 1, b: "state"});
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Act
|
|
531
|
+
const scores = renderer.scoreWidgets();
|
|
532
|
+
|
|
533
|
+
// Assert
|
|
534
|
+
expect(scores).toMatchInlineSnapshot(`
|
|
535
|
+
{
|
|
536
|
+
"mock-widget 1": {
|
|
537
|
+
"correct": true,
|
|
538
|
+
"empty": false,
|
|
539
|
+
"guess": "user input for mock-widget 1",
|
|
540
|
+
"message": undefined,
|
|
541
|
+
"state": {
|
|
542
|
+
"a": 1,
|
|
543
|
+
"b": "state",
|
|
544
|
+
"id": "mock-widget 1",
|
|
545
|
+
},
|
|
546
|
+
},
|
|
547
|
+
"mock-widget 2": {
|
|
548
|
+
"correct": false,
|
|
549
|
+
"empty": true,
|
|
550
|
+
"guess": "user input for mock-widget 2",
|
|
551
|
+
"message": "please enter a value",
|
|
552
|
+
"state": {
|
|
553
|
+
"a": 1,
|
|
554
|
+
"b": "state",
|
|
555
|
+
"id": "mock-widget 2",
|
|
556
|
+
},
|
|
557
|
+
"suppressAlmostThere": undefined,
|
|
558
|
+
},
|
|
559
|
+
"mock-widget 3": {
|
|
560
|
+
"correct": true,
|
|
561
|
+
"empty": false,
|
|
562
|
+
"guess": "user input for mock-widget 3",
|
|
563
|
+
"message": undefined,
|
|
564
|
+
"state": {
|
|
565
|
+
"a": 1,
|
|
566
|
+
"b": "state",
|
|
567
|
+
"id": "mock-widget 3",
|
|
568
|
+
},
|
|
569
|
+
},
|
|
570
|
+
"mock-widget 4": {
|
|
571
|
+
"correct": false,
|
|
572
|
+
"empty": true,
|
|
573
|
+
"guess": "user input for mock-widget 4",
|
|
574
|
+
"message": "please enter a value",
|
|
575
|
+
"state": {
|
|
576
|
+
"a": 1,
|
|
577
|
+
"b": "state",
|
|
578
|
+
"id": "mock-widget 4",
|
|
579
|
+
},
|
|
580
|
+
"suppressAlmostThere": undefined,
|
|
581
|
+
},
|
|
582
|
+
}
|
|
583
|
+
`);
|
|
584
|
+
});
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
describe("rationales", () => {
|
|
588
|
+
it("should show rationales for widgets that support it", () => {
|
|
589
|
+
// Arrange
|
|
590
|
+
registerWidget("label-image", LabelImageExport);
|
|
591
|
+
|
|
592
|
+
const {renderer} = renderQuestion(labelImageItem);
|
|
593
|
+
|
|
594
|
+
// Act
|
|
595
|
+
renderer.showRationalesForCurrentlySelectedChoices();
|
|
596
|
+
|
|
597
|
+
// Assert
|
|
598
|
+
expect(
|
|
599
|
+
screen.getByText(
|
|
600
|
+
/Click each dot on the image to select an answer/,
|
|
601
|
+
),
|
|
602
|
+
).toBeVisible();
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it("should show not throw if none of the rendered widgets support supporationales", () => {
|
|
606
|
+
// Arrange
|
|
607
|
+
const {renderer} = renderQuestion(itemWithInput);
|
|
608
|
+
|
|
609
|
+
// Act and Assert
|
|
610
|
+
expect(() => {
|
|
611
|
+
renderer.showRationalesForCurrentlySelectedChoices();
|
|
612
|
+
}).not.toThrow();
|
|
613
|
+
});
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
describe("support functions", () => {
|
|
617
|
+
it("should return all widget IDs when getWidgetIds() called", () => {
|
|
618
|
+
// Arrange
|
|
619
|
+
const {renderer} = renderQuestion(mockedItem);
|
|
620
|
+
|
|
621
|
+
// Act
|
|
622
|
+
const ids = renderer.getWidgetIds();
|
|
623
|
+
|
|
624
|
+
// Assert
|
|
625
|
+
expect(ids).toStrictEqual([
|
|
626
|
+
"mock-widget 1",
|
|
627
|
+
"mock-widget 2",
|
|
628
|
+
"mock-widget 3",
|
|
629
|
+
"mock-widget 4",
|
|
630
|
+
]);
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
it("should return the DOM element for the widget at the given path", () => {
|
|
634
|
+
// Arrange
|
|
635
|
+
const {renderer} = renderQuestion(itemWithInput);
|
|
636
|
+
|
|
637
|
+
// Act
|
|
638
|
+
const node = renderer.getDOMNodeForPath(["input-number 1"]);
|
|
639
|
+
|
|
640
|
+
// Assert
|
|
641
|
+
expect(node).toBeInTheDocument();
|
|
642
|
+
const input = screen.getByRole("textbox");
|
|
643
|
+
expect(node?.contains(input)).toBeTrue();
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
it("should return null when widget not found for the given path", () => {
|
|
647
|
+
// Arrange
|
|
648
|
+
const {renderer} = renderQuestion(itemWithInput);
|
|
649
|
+
|
|
650
|
+
// Act
|
|
651
|
+
const node = renderer.getDOMNodeForPath([
|
|
652
|
+
"this-is-not-a-valid-widget-id",
|
|
653
|
+
]);
|
|
654
|
+
|
|
655
|
+
// Assert
|
|
656
|
+
expect(node).toBeNull();
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
it("should do grammar type for the widget at the given path", () => {
|
|
660
|
+
// Arrange
|
|
661
|
+
const {renderer} = renderQuestion(mockedItem);
|
|
662
|
+
getWidgetById(renderer, "mock-widget 2").getGrammarTypeForPath =
|
|
663
|
+
jest.fn().mockReturnValue("yabadabadoo!");
|
|
664
|
+
|
|
665
|
+
// Act
|
|
666
|
+
const grammarType = renderer.getGrammarTypeForPath([
|
|
667
|
+
"mock-widget 2",
|
|
668
|
+
]);
|
|
669
|
+
|
|
670
|
+
// Assert
|
|
671
|
+
expect(grammarType).toBe("yabadabadoo!");
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
it("should return input paths for widgets supporting input", () => {
|
|
675
|
+
// Arrange
|
|
676
|
+
const {renderer} = renderQuestion(mockedItem);
|
|
677
|
+
getWidgetById(renderer, "mock-widget 1").getInputPaths = jest
|
|
678
|
+
.fn()
|
|
679
|
+
.mockReturnValue([["a singular path"]]);
|
|
680
|
+
getWidgetById(renderer, "mock-widget 4").getInputPaths = jest
|
|
681
|
+
.fn()
|
|
682
|
+
.mockReturnValue([["some", "path"]]);
|
|
683
|
+
|
|
684
|
+
// Act
|
|
685
|
+
const inputPaths = renderer.getInputPaths();
|
|
686
|
+
|
|
687
|
+
// Assert
|
|
688
|
+
expect(inputPaths).toStrictEqual([
|
|
689
|
+
["mock-widget 1", "a singular path"],
|
|
690
|
+
["mock-widget 4", "some", "path"],
|
|
691
|
+
]);
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
it("should forward deselectIncorrectSelectedChoices calls to questionRenderer", () => {
|
|
695
|
+
// Arrange
|
|
696
|
+
registerWidget("radio", RadioWidgetExport);
|
|
697
|
+
const {renderer} = renderQuestion({
|
|
698
|
+
...itemWithInput,
|
|
699
|
+
question: multiChoiceQuestion,
|
|
700
|
+
});
|
|
701
|
+
screen.getAllByRole("checkbox").map((r) => userEvent.click(r));
|
|
702
|
+
jest.runOnlyPendingTimers(); // due to setState()
|
|
703
|
+
|
|
704
|
+
// Act
|
|
705
|
+
renderer.deselectIncorrectSelectedChoices();
|
|
706
|
+
|
|
707
|
+
// Assert
|
|
708
|
+
const checkboxes = screen.getAllByRole("checkbox");
|
|
709
|
+
expect(checkboxes[0]).not.toBeChecked();
|
|
710
|
+
expect(checkboxes[1]).not.toBeChecked();
|
|
711
|
+
expect(checkboxes[2]).not.toBeChecked();
|
|
712
|
+
expect(checkboxes[3]).toBeChecked();
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
it("should return serialized state for each widget and hints", () => {
|
|
716
|
+
// Arrange
|
|
717
|
+
const {renderer} = renderQuestion({
|
|
718
|
+
...mockedItem,
|
|
719
|
+
hints: [
|
|
720
|
+
makeHint("Maybe if you try using a calculator?"),
|
|
721
|
+
makeHint("I think you need a TI-80 to get this done!"),
|
|
722
|
+
],
|
|
723
|
+
});
|
|
724
|
+
// We get the result of the widget's getSerializedState() if it
|
|
725
|
+
// implements it, otherwise we just get it's props.
|
|
726
|
+
getWidgetById(renderer, "mock-widget 1").getSerializedState = jest
|
|
727
|
+
.fn()
|
|
728
|
+
.mockReturnValue({id: "mock-widget 1", a: 1, b: "state 1"});
|
|
729
|
+
getWidgetById(renderer, "mock-widget 4").getSerializedState = jest
|
|
730
|
+
.fn()
|
|
731
|
+
.mockReturnValue({id: "mock-widget 4", a: 4, b: "state 4"});
|
|
732
|
+
|
|
733
|
+
renderer.showHint(); // should be one left
|
|
734
|
+
|
|
735
|
+
// Act
|
|
736
|
+
const state = renderer.getSerializedState();
|
|
737
|
+
|
|
738
|
+
// Assert
|
|
739
|
+
expect(state).toStrictEqual({
|
|
740
|
+
hints: [{"mock-hint-widget 1": {static: false}}],
|
|
741
|
+
question: {
|
|
742
|
+
"mock-widget 1": {id: "mock-widget 1", a: 1, b: "state 1"},
|
|
743
|
+
"mock-widget 2": {smiling: false, static: false},
|
|
744
|
+
"mock-widget 3": {smiling: true, static: false},
|
|
745
|
+
"mock-widget 4": {id: "mock-widget 4", a: 4, b: "state 4"},
|
|
746
|
+
},
|
|
747
|
+
});
|
|
748
|
+
});
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
describe("controlPeripherals is disabled", () => {
|
|
752
|
+
const extraProps = {controlPeripherals: false} as const;
|
|
753
|
+
|
|
754
|
+
it("should hide peripherals if answerArea flag(s) are false", () => {
|
|
755
|
+
// Arrange
|
|
756
|
+
const item = {...itemWithInput, answerArea: null} as const;
|
|
757
|
+
|
|
758
|
+
// Act
|
|
759
|
+
renderQuestion(item, undefined, extraProps);
|
|
760
|
+
|
|
761
|
+
// Assert
|
|
762
|
+
expect(screen.getByTestId("calculator")).not.toBeVisible();
|
|
763
|
+
expect(
|
|
764
|
+
screen.getByTestId("periodic-table-info-box"),
|
|
765
|
+
).not.toBeVisible();
|
|
766
|
+
expect(screen.getByTestId("z-table-info-box")).not.toBeVisible();
|
|
767
|
+
expect(screen.getByTestId("t-table-info-box")).not.toBeVisible();
|
|
768
|
+
expect(screen.getByTestId("chi2-table-info-box")).not.toBeVisible();
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
it("should call onShowXyz peripheral callbacks", () => {
|
|
772
|
+
// Arrange
|
|
773
|
+
const item = {
|
|
774
|
+
...itemWithInput,
|
|
775
|
+
answerArea: {
|
|
776
|
+
zTable: true,
|
|
777
|
+
chi2Table: true,
|
|
778
|
+
tTable: true,
|
|
779
|
+
calculator: true,
|
|
780
|
+
periodicTable: true,
|
|
781
|
+
},
|
|
782
|
+
} as const;
|
|
783
|
+
|
|
784
|
+
const onShowCalculator = jest.fn();
|
|
785
|
+
const onShowPeriodicTable = jest.fn();
|
|
786
|
+
const onShowZTable = jest.fn();
|
|
787
|
+
const onShowTTable = jest.fn();
|
|
788
|
+
const onShowChi2Table = jest.fn();
|
|
789
|
+
const localExtraProps = {
|
|
790
|
+
...extraProps,
|
|
791
|
+
onShowCalculator,
|
|
792
|
+
onShowPeriodicTable,
|
|
793
|
+
onShowZTable,
|
|
794
|
+
onShowTTable,
|
|
795
|
+
onShowChi2Table,
|
|
796
|
+
} as const;
|
|
797
|
+
|
|
798
|
+
// Act
|
|
799
|
+
renderQuestion(item, undefined, localExtraProps);
|
|
800
|
+
|
|
801
|
+
// Assert
|
|
802
|
+
expect(onShowCalculator).toHaveBeenCalled();
|
|
803
|
+
expect(onShowPeriodicTable).toHaveBeenCalled();
|
|
804
|
+
expect(onShowZTable).toHaveBeenCalled();
|
|
805
|
+
expect(onShowTTable).toHaveBeenCalled();
|
|
806
|
+
expect(onShowChi2Table).toHaveBeenCalled();
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
it("should call onShowPeriodicTable with showKey option if selected", () => {
|
|
810
|
+
// Arrange
|
|
811
|
+
const item = {
|
|
812
|
+
...itemWithInput,
|
|
813
|
+
answerArea: {
|
|
814
|
+
zTable: false,
|
|
815
|
+
chi2Table: false,
|
|
816
|
+
tTable: false,
|
|
817
|
+
calculator: false,
|
|
818
|
+
periodicTable: true,
|
|
819
|
+
periodicTableWithKey: true,
|
|
820
|
+
},
|
|
821
|
+
} as const;
|
|
822
|
+
|
|
823
|
+
const onShowCalculator = jest.fn();
|
|
824
|
+
const onShowPeriodicTable = jest.fn();
|
|
825
|
+
const onShowZTable = jest.fn();
|
|
826
|
+
const onShowTTable = jest.fn();
|
|
827
|
+
const onShowChi2Table = jest.fn();
|
|
828
|
+
const localExtraProps = {
|
|
829
|
+
...extraProps,
|
|
830
|
+
onShowCalculator,
|
|
831
|
+
onShowPeriodicTable,
|
|
832
|
+
onShowZTable,
|
|
833
|
+
onShowTTable,
|
|
834
|
+
onShowChi2Table,
|
|
835
|
+
} as const;
|
|
836
|
+
|
|
837
|
+
// Act
|
|
838
|
+
renderQuestion(item, undefined, localExtraProps);
|
|
839
|
+
|
|
840
|
+
// Assert
|
|
841
|
+
expect(onShowCalculator).not.toHaveBeenCalled();
|
|
842
|
+
expect(onShowZTable).not.toHaveBeenCalled();
|
|
843
|
+
expect(onShowTTable).not.toHaveBeenCalled();
|
|
844
|
+
expect(onShowChi2Table).not.toHaveBeenCalled();
|
|
845
|
+
expect(onShowPeriodicTable).toHaveBeenCalledWith(
|
|
846
|
+
expect.objectContaining({showKey: true}),
|
|
847
|
+
);
|
|
848
|
+
});
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
describe("controlPeripherals is enabled", () => {
|
|
852
|
+
const extraProps: Partial<ItemRenderer["props"]> = {
|
|
853
|
+
controlPeripherals: true,
|
|
854
|
+
};
|
|
855
|
+
|
|
856
|
+
it("should show peripherals if answerArea flag(s) are true", () => {
|
|
857
|
+
// Arrange
|
|
858
|
+
const item = {
|
|
859
|
+
...itemWithInput,
|
|
860
|
+
answerArea: {
|
|
861
|
+
zTable: true,
|
|
862
|
+
chi2Table: true,
|
|
863
|
+
tTable: true,
|
|
864
|
+
calculator: true,
|
|
865
|
+
periodicTable: true,
|
|
866
|
+
},
|
|
867
|
+
} as const;
|
|
868
|
+
|
|
869
|
+
// Act
|
|
870
|
+
renderQuestion(item, undefined, extraProps);
|
|
871
|
+
|
|
872
|
+
// Assert
|
|
873
|
+
expect(screen.getByTestId("calculator")).toBeVisible();
|
|
874
|
+
expect(screen.getByTestId("periodic-table-info-box")).toBeVisible();
|
|
875
|
+
expect(screen.getByTestId("z-table-info-box")).toBeVisible();
|
|
876
|
+
expect(screen.getByTestId("t-table-info-box")).toBeVisible();
|
|
877
|
+
expect(screen.getByTestId("chi2-table-info-box")).toBeVisible();
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
it("should hide peripherals if answerArea flag(s) are false", () => {
|
|
881
|
+
// Arrange
|
|
882
|
+
const item = {
|
|
883
|
+
...itemWithInput,
|
|
884
|
+
answerArea: {
|
|
885
|
+
zTable: false,
|
|
886
|
+
chi2Table: false,
|
|
887
|
+
tTable: false,
|
|
888
|
+
calculator: false,
|
|
889
|
+
periodicTable: false,
|
|
890
|
+
},
|
|
891
|
+
} as const;
|
|
892
|
+
|
|
893
|
+
// Act
|
|
894
|
+
renderQuestion(item, undefined, extraProps);
|
|
895
|
+
|
|
896
|
+
// Assert
|
|
897
|
+
expect(screen.getByTestId("calculator")).not.toBeVisible();
|
|
898
|
+
expect(
|
|
899
|
+
screen.getByTestId("periodic-table-info-box"),
|
|
900
|
+
).not.toBeVisible();
|
|
901
|
+
expect(screen.getByTestId("z-table-info-box")).not.toBeVisible();
|
|
902
|
+
expect(screen.getByTestId("t-table-info-box")).not.toBeVisible();
|
|
903
|
+
expect(screen.getByTestId("chi2-table-info-box")).not.toBeVisible();
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
it("should call the setDrawingAreaAvailable callback when mounted", () => {
|
|
907
|
+
// Arrange
|
|
908
|
+
const item = {
|
|
909
|
+
...itemWithInput,
|
|
910
|
+
answerArea: {
|
|
911
|
+
zTable: false,
|
|
912
|
+
chi2Table: false,
|
|
913
|
+
tTable: false,
|
|
914
|
+
calculator: false,
|
|
915
|
+
periodicTable: false,
|
|
916
|
+
},
|
|
917
|
+
} as const;
|
|
918
|
+
const onDrawingAreaAvailable = jest.fn();
|
|
919
|
+
const apiOptions = {
|
|
920
|
+
setDrawingAreaAvailable: onDrawingAreaAvailable,
|
|
921
|
+
} as const;
|
|
922
|
+
|
|
923
|
+
// Act
|
|
924
|
+
renderQuestion(item, apiOptions, extraProps);
|
|
925
|
+
|
|
926
|
+
// Assert
|
|
927
|
+
expect(onDrawingAreaAvailable).toHaveBeenCalledWith(true);
|
|
928
|
+
});
|
|
929
|
+
});
|
|
930
|
+
});
|