@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.
@@ -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
+ });