@khanacademy/perseus 1.4.1 → 2.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/es/index.css +9 -245
  3. package/dist/es/index.js +138 -584
  4. package/dist/es/index.js.map +1 -1
  5. package/dist/index.css +9 -245
  6. package/dist/index.js +135 -581
  7. package/dist/index.js.map +1 -1
  8. package/package.json +1 -1
  9. package/src/__stories__/hints-renderer.stories.jsx +9 -1
  10. package/src/__testdata__/graphie_testdata.js +53 -0
  11. package/src/__tests__/__snapshots__/perseus-markdown_test.jsx.snap +0 -9
  12. package/src/__tests__/__snapshots__/renderer_test.jsx.snap +0 -9
  13. package/src/components/__stories__/graphie.stories.jsx +5 -1
  14. package/src/hint-renderer.jsx +11 -14
  15. package/src/perseus-api.jsx +0 -23
  16. package/src/perseus-markdown.jsx +0 -25
  17. package/src/styles/constants.js +0 -21
  18. package/src/styles/global-constants.js +0 -6
  19. package/src/styles/khan-exercise.css +9 -1
  20. package/src/styles/perseus-renderer.less +0 -259
  21. package/src/styles/variables.less +0 -11
  22. package/src/styles/variables.shared.less +0 -10
  23. package/src/types.js +0 -16
  24. package/src/widgets/__stories__/input-number.stories.jsx +0 -14
  25. package/src/widgets/__stories__/radio.stories.jsx +0 -3
  26. package/src/widgets/__tests__/__snapshots__/definition_test.js.snap +18 -28
  27. package/src/widgets/__tests__/__snapshots__/group_test.jsx.snap +0 -9
  28. package/src/widgets/__tests__/__snapshots__/image_test.jsx.snap +0 -18
  29. package/src/widgets/__tests__/__snapshots__/numeric-input_test.jsx.snap +2 -440
  30. package/src/widgets/__tests__/__snapshots__/radio_test.jsx.snap +66 -3726
  31. package/src/widgets/__tests__/numeric-input_test.jsx +54 -118
  32. package/src/widgets/__tests__/radio/choice-icon_test.jsx +25 -75
  33. package/src/widgets/__tests__/radio_test.jsx +362 -394
  34. package/src/widgets/definition.jsx +27 -14
  35. package/src/widgets/input-number.jsx +2 -60
  36. package/src/widgets/numeric-input.jsx +17 -89
  37. package/src/widgets/radio/__stories__/base-radio.stories.jsx +1 -3
  38. package/src/widgets/radio/__stories__/choice-icon.stories.jsx +23 -105
  39. package/src/widgets/radio/__stories__/option-status.stories.jsx +0 -8
  40. package/src/widgets/radio/base-radio.jsx +13 -96
  41. package/src/widgets/radio/choice-icon/choice-icon.jsx +177 -50
  42. package/src/widgets/radio/choice-icon/cross-out-line.jsx +9 -13
  43. package/src/widgets/radio/choice-icon/shared-styles.js +2 -3
  44. package/src/widgets/radio/choice.jsx +5 -59
  45. package/src/widgets/radio/option-status.jsx +2 -14
  46. package/src/components/__stories__/possible-answers.stories.jsx +0 -22
  47. package/src/components/possible-answers.jsx +0 -36
  48. package/src/styles/widgets/definition.less +0 -86
  49. package/src/widgets/radio/__stories__/sat-styles.stories.jsx +0 -191
  50. package/src/widgets/radio/choice-icon/library-choice-icon.jsx +0 -225
  51. package/src/widgets/radio/choice-icon/sat-choice-icon.jsx +0 -120
@@ -45,488 +45,458 @@ describe("single-choice question", () => {
45
45
  });
46
46
 
47
47
  const [question, correct, incorrect] = questionAndAnswer;
48
+ const apiOptions = Object.freeze({});
48
49
 
49
- describe.each([[true], [false]])("satStyling: %s", (satStyling) => {
50
- const apiOptions: APIOptions = {
51
- satStyling,
52
- };
50
+ describe.each([[true], [false]])(
51
+ "reviewMode: %s",
52
+ (reviewMode: boolean) => {
53
+ it("should snapshot the same", () => {
54
+ // Arrange & Act
55
+ const {container} = renderQuestion(question, apiOptions, {
56
+ reviewMode,
57
+ });
53
58
 
54
- describe.each([[true], [false]])(
55
- "reviewMode: %s",
56
- (reviewMode: boolean) => {
57
- it("should snapshot the same", () => {
58
- // Arrange & Act
59
- const {container} = renderQuestion(question, apiOptions, {
60
- reviewMode,
61
- });
59
+ // Assert
60
+ expect(container).toMatchSnapshot("first render");
61
+ });
62
62
 
63
- // Assert
64
- expect(container).toMatchSnapshot("first render");
63
+ it("should snapshot the same with correct answer", () => {
64
+ // Arrange
65
+ const {container} = renderQuestion(question, apiOptions);
66
+
67
+ // Act
68
+ selectOption(correct);
69
+
70
+ // Assert
71
+ expect(container).toMatchSnapshot("correct answer");
72
+ });
73
+
74
+ it("should snapshot the same with incorrect answer", () => {
75
+ // Arrange
76
+ const {container} = renderQuestion(question, apiOptions);
77
+
78
+ // Act
79
+ selectOption(incorrect[0]);
80
+
81
+ // Assert
82
+ expect(container).toMatchSnapshot("incorrect answer");
83
+ });
84
+
85
+ it("should accept the right answer (mouse)", () => {
86
+ // Arrange
87
+ const {renderer} = renderQuestion(question, apiOptions, {
88
+ reviewMode,
65
89
  });
66
90
 
67
- it("should snapshot the same with correct answer", () => {
91
+ // Act
92
+ selectOption(correct);
93
+ // Assert
94
+ expect(renderer).toHaveBeenAnsweredCorrectly();
95
+ });
96
+
97
+ it("should accept the right answer (touch)", () => {
98
+ // Arrange
99
+ const {renderer} = renderQuestion(question, apiOptions);
100
+ const correctRadio = screen.getAllByRole("radio")[correct];
101
+
102
+ // Act
103
+ fireEvent.touchStart(correctRadio);
104
+ fireEvent.touchEnd(correctRadio);
105
+ // We're using fireEvent.click() because mobile browsers synthesize
106
+ // click events from matching touchStart/touchEnd events. user-event
107
+ // does not support touch events so we have to do this ourselves.
108
+ // eslint-disable-next-line testing-library/prefer-user-event
109
+ fireEvent.click(correctRadio);
110
+
111
+ // Assert
112
+ expect(renderer).toHaveBeenAnsweredCorrectly();
113
+ });
114
+
115
+ it.each(incorrect)(
116
+ "should reject incorrect answer - choice %d",
117
+ (incorrect: number) => {
68
118
  // Arrange
69
- const {container} = renderQuestion(question, apiOptions);
119
+ const {renderer} = renderQuestion(question, apiOptions);
70
120
 
71
121
  // Act
72
- selectOption(correct);
122
+ selectOption(incorrect);
73
123
 
74
124
  // Assert
75
- expect(container).toMatchSnapshot("correct answer");
125
+ expect(renderer).toHaveBeenAnsweredIncorrectly();
126
+ },
127
+ );
128
+
129
+ it("calling .focus() on the renderer should succeed", () => {
130
+ // Arrange
131
+ const {renderer} = renderQuestion(question, apiOptions, {
132
+ reviewMode,
76
133
  });
77
134
 
78
- it("should snapshot the same with incorrect answer", () => {
79
- // Arrange
80
- const {container} = renderQuestion(question, apiOptions);
135
+ // Act
136
+ const gotFocus = renderer.focus();
81
137
 
82
- // Act
83
- selectOption(incorrect[0]);
138
+ // Assert
139
+ expect(gotFocus).toBeTrue();
140
+ });
84
141
 
85
- // Assert
86
- expect(container).toMatchSnapshot("incorrect answer");
142
+ it("should deselect incorrect selected choices", () => {
143
+ // Arrange
144
+ const {renderer} = renderQuestion(question, apiOptions, {
145
+ reviewMode,
87
146
  });
88
147
 
89
- it("should accept the right answer (mouse)", () => {
90
- // Arrange
91
- const {renderer} = renderQuestion(question, apiOptions, {
92
- reviewMode,
93
- });
148
+ // Act
149
+ // Since this is a single-select setup, just select the first
150
+ // incorrect choice.
151
+ selectOption(incorrect[0]);
152
+ renderer.deselectIncorrectSelectedChoices();
94
153
 
95
- // Act
96
- selectOption(correct);
97
- // Assert
98
- expect(renderer).toHaveBeenAnsweredCorrectly();
154
+ // Assert
155
+ screen.getAllByRole("radio").forEach((r) => {
156
+ expect(r).not.toBeChecked();
99
157
  });
158
+ });
100
159
 
101
- it("should accept the right answer (touch)", () => {
102
- // Arrange
103
- const {renderer} = renderQuestion(question, apiOptions);
104
- const correctRadio = screen.getAllByRole("radio")[correct];
160
+ it("should disable all radio inputs when static is true", () => {
161
+ // Arrange
162
+ const staticQuestion = {
163
+ ...question,
164
+ widgets: {
165
+ ...question.widgets,
166
+ "radio 1": {
167
+ ...question.widgets["radio 1"],
168
+ static: true,
169
+ },
170
+ },
171
+ };
172
+ renderQuestion(staticQuestion, apiOptions, {reviewMode});
105
173
 
106
- // Act
107
- fireEvent.touchStart(correctRadio);
108
- fireEvent.touchEnd(correctRadio);
109
- // We're using fireEvent.click() because mobile browsers synthesize
110
- // click events from matching touchStart/touchEnd events. user-event
111
- // does not support touch events so we have to do this ourselves.
112
- // eslint-disable-next-line testing-library/prefer-user-event
113
- fireEvent.click(correctRadio);
174
+ // Act
175
+ selectOption(correct);
114
176
 
115
- // Assert
116
- expect(renderer).toHaveBeenAnsweredCorrectly();
177
+ // Assert
178
+ // Everything's read-only so no selections made
179
+ // Note(TB): The visual buttons are hidden from screen
180
+ // readers, so they need to be identified as hidden;
181
+ // the visual button has the aria attributes
182
+ screen.getAllByRole("button", {hidden: true}).forEach((r) => {
183
+ expect(r).toHaveAttribute("aria-disabled", "true");
184
+ });
185
+ // Note(TB): Confirms the screen reader only
186
+ // radio options are also disabled
187
+ screen.getAllByRole("radio").forEach((r) => {
188
+ expect(r).toHaveAttribute("disabled");
117
189
  });
190
+ });
191
+ },
192
+ );
118
193
 
119
- it.each(incorrect)(
120
- "should reject incorrect answer - choice %d",
121
- (incorrect: number) => {
122
- // Arrange
123
- const {renderer} = renderQuestion(question, apiOptions);
194
+ it("should be able to navigate down by keyboard", () => {
195
+ // Arrange
196
+ renderQuestion(question, apiOptions);
124
197
 
125
- // Act
126
- selectOption(incorrect);
198
+ // Act
199
+ userEvent.tab();
200
+ userEvent.tab();
127
201
 
128
- // Assert
129
- expect(renderer).toHaveBeenAnsweredIncorrectly();
130
- },
131
- );
202
+ // Assert
203
+ // Note(TB): The visual buttons are hidden from screen readers
204
+ // so they need to be identified as hidden;
205
+ // cannot access screen reader buttons via tabbing
206
+ expect(screen.getAllByRole("button", {hidden: true})[1]).toHaveFocus();
207
+ });
132
208
 
133
- it("calling .focus() on the renderer should succeed", () => {
134
- // Arrange
135
- const {renderer} = renderQuestion(question, apiOptions, {
136
- reviewMode,
137
- });
209
+ it("should be able to navigate up by keyboard", () => {
210
+ // Arrange
211
+ renderQuestion(question, apiOptions);
138
212
 
139
- // Act
140
- const gotFocus = renderer.focus();
213
+ // Act
214
+ userEvent.tab();
215
+ // Note(TB): The visual buttons are hidden from screen readers
216
+ // so they need to be identified as hidden
217
+ expect(screen.getAllByRole("button", {hidden: true})[0]).toHaveFocus();
141
218
 
142
- // Assert
143
- expect(gotFocus).toBeTrue();
144
- });
219
+ userEvent.tab();
220
+ expect(screen.getAllByRole("button", {hidden: true})[1]).toHaveFocus();
145
221
 
146
- it("should deselect incorrect selected choices", () => {
147
- // Arrange
148
- const {renderer} = renderQuestion(question, apiOptions, {
149
- reviewMode,
150
- });
222
+ userEvent.tab({shift: true});
151
223
 
152
- // Act
153
- // Since this is a single-select setup, just select the first
154
- // incorrect choice.
155
- selectOption(incorrect[0]);
156
- renderer.deselectIncorrectSelectedChoices();
224
+ // Assert
225
+ expect(screen.getAllByRole("button", {hidden: true})[0]).toHaveFocus();
226
+ });
157
227
 
158
- // Assert
159
- screen.getAllByRole("radio").forEach((r) => {
160
- expect(r).not.toBeChecked();
161
- });
162
- });
228
+ it("should be able to select an option by keyboard", () => {
229
+ // Arrange
230
+ renderQuestion(question, apiOptions);
163
231
 
164
- it("should disable all radio inputs when static is true", () => {
165
- // Arrange
166
- const staticQuestion = {
167
- ...question,
168
- widgets: {
169
- ...question.widgets,
170
- "radio 1": {
171
- ...question.widgets["radio 1"],
172
- static: true,
173
- },
174
- },
175
- };
176
- renderQuestion(staticQuestion, apiOptions, {reviewMode});
232
+ // Act
233
+ userEvent.tab();
234
+ userEvent.keyboard("{space}");
177
235
 
178
- // Act
179
- selectOption(correct);
236
+ // Assert
237
+ expect(screen.getAllByRole("radio")[0]).toBeChecked();
238
+ });
180
239
 
181
- // Assert
182
- // Everything's read-only so no selections made
183
- // Note(TB): The visual buttons are hidden from screen
184
- // readers, so they need to be identified as hidden;
185
- // the visual button has the aria attributes
186
- screen
187
- .getAllByRole("button", {hidden: true})
188
- .forEach((r) => {
189
- expect(r).toHaveAttribute("aria-disabled", "true");
190
- });
191
- // Note(TB): Confirms the screen reader only
192
- // radio options are also disabled
193
- screen.getAllByRole("radio").forEach((r) => {
194
- expect(r).toHaveAttribute("disabled");
195
- });
196
- });
197
- },
240
+ it("should be able to navigate to 'None of the above' choice by keyboard", () => {
241
+ // Arrange
242
+ const q = clone(question);
243
+ ((q.widgets["radio 1"]
244
+ .options: any): PerseusRadioWidgetOptions).choices[3].isNoneOfTheAbove = true;
245
+ renderQuestion(q, apiOptions);
246
+
247
+ // Act
248
+ userEvent.tab();
249
+ userEvent.tab();
250
+ userEvent.tab();
251
+
252
+ // Assert
253
+ // Note(TB): The visual buttons are hidden from screen readers
254
+ // so they need to be identified as hidden
255
+ expect(screen.getAllByRole("button", {hidden: true})[2]).toHaveFocus();
256
+ });
257
+
258
+ it.each([
259
+ ["No", "Yes"],
260
+ ["False", "True"],
261
+ ])("should enforce ordering for common answers: %j", (...answers) => {
262
+ // Arrange
263
+ const q = clone(question);
264
+ ((q.widgets["radio 1"]
265
+ .options: any): PerseusRadioWidgetOptions).choices = answers.map(
266
+ (answer, idx) => ({
267
+ content: answer,
268
+ correct: idx === 1, // Correct answer is the "truthy" item
269
+ }),
198
270
  );
199
271
 
200
- it("should be able to navigate down by keyboard", () => {
201
- // Arrange
202
- renderQuestion(question, apiOptions);
272
+ // Act
273
+ const {renderer} = renderQuestion(q, apiOptions);
274
+ // We click on the first item, which was the second (index == 1)
275
+ // item in the original choices. But because of enforced ordering,
276
+ // it is now at the top of the list (and thus our correct answer).
277
+ userEvent.click(screen.getAllByRole("radio")[0]);
203
278
 
204
- // Act
205
- userEvent.tab();
206
- userEvent.tab();
279
+ // Assert
280
+ const items = screen.getAllByRole("listitem");
281
+ expect(items[0]).toHaveTextContent(answers[1]);
282
+ expect(items[1]).toHaveTextContent(answers[0]);
283
+ expect(renderer).toHaveBeenAnsweredCorrectly();
284
+ });
207
285
 
208
- // Assert
209
- // Note(TB): The visual buttons are hidden from screen readers
210
- // so they need to be identified as hidden;
211
- // cannot access screen reader buttons via tabbing
212
- expect(
213
- screen.getAllByRole("button", {hidden: true})[1],
214
- ).toHaveFocus();
215
- });
286
+ it("should not change ordering of non-common answers", () => {
287
+ // Arrange
288
+ const answers = ["Last", "First"];
289
+ const q = clone(question);
290
+ ((q.widgets["radio 1"]
291
+ .options: any): PerseusRadioWidgetOptions).choices = answers.map(
292
+ (answer, idx) => ({
293
+ content: answer,
294
+ correct: idx === 1,
295
+ }),
296
+ );
216
297
 
217
- it("should be able to navigate up by keyboard", () => {
218
- // Arrange
219
- renderQuestion(question, apiOptions);
298
+ // Act
299
+ renderQuestion(q, apiOptions);
220
300
 
221
- // Act
222
- userEvent.tab();
223
- // Note(TB): The visual buttons are hidden from screen readers
224
- // so they need to be identified as hidden
225
- expect(
226
- screen.getAllByRole("button", {hidden: true})[0],
227
- ).toHaveFocus();
301
+ // Assert
302
+ const items = screen.getAllByRole("listitem");
303
+ expect(items[0]).toHaveTextContent(answers[0]);
304
+ expect(items[1]).toHaveTextContent(answers[1]);
305
+ });
228
306
 
229
- userEvent.tab();
230
- expect(
231
- screen.getAllByRole("button", {hidden: true})[1],
232
- ).toHaveFocus();
307
+ it("should transform inline passage-refs to references to passage widgets", () => {
308
+ // Arrange
309
+
310
+ // Add a passage widget to the question content and then reference it
311
+ // in an answer.
312
+ const questionWithPassage = clone(question);
313
+ questionWithPassage.content = `[[\u2603 passage 1]]\n\nNow what's the answer?\n\n[[\u2603 radio 1]]`;
314
+ questionWithPassage.widgets["passage 1"] = passageWidget;
315
+ const radioOptions = questionWithPassage.widgets["radio 1"];
316
+ // HACK(jeremy): Flow doesn't know what type these options are because
317
+ // we've extracted them out of a generic `PerseusJson` blob and all the
318
+ // `widget` items are just type `Widget`.
319
+ (radioOptions.options: any).choices[0].content =
320
+ '{{passage-ref 1 1 "Reference 1 here"}}';
321
+
322
+ // Act
323
+ renderQuestion(questionWithPassage, apiOptions);
324
+ // Passage refs use `_.defer()` to update their reference ranges.
325
+ jest.runOnlyPendingTimers();
326
+
327
+ // Assert
328
+ // By using a `passage-ref` in a choice, we should now have the
329
+ // reference on the radio we used it on.
330
+ // NOTE: We get by listitem role here because the 'radio' role
331
+ // element does not contain the HTML that provides the label
332
+ // for it.
333
+ const passageRefRadio = screen.getAllByRole("listitem")[0];
334
+ expect(passageRefRadio).toHaveTextContent("lines NaN–NaN");
335
+ });
336
+
337
+ it("should render rationales for selected choices", () => {
338
+ // Arrange
339
+ const {renderer} = renderQuestion(question, apiOptions);
340
+
341
+ // Act
342
+ selectOption(incorrect[0]);
343
+ renderer.showRationalesForCurrentlySelectedChoices();
233
344
 
234
- userEvent.tab({shift: true});
345
+ // Assert
346
+ expect(
347
+ screen.queryAllByTestId(
348
+ `perseus-radio-rationale-content-${incorrect[0]}`,
349
+ ),
350
+ ).toHaveLength(1);
351
+ });
352
+
353
+ describe("cross-out is enabled", () => {
354
+ const crossOutApiOptions = {
355
+ ...apiOptions,
356
+ crossOutEnabled: true,
357
+ };
358
+
359
+ it("should render cross-out menu button", () => {
360
+ // Arrange & Act
361
+ renderQuestion(question, crossOutApiOptions);
235
362
 
236
363
  // Assert
237
364
  expect(
238
- screen.getAllByRole("button", {hidden: true})[0],
239
- ).toHaveFocus();
365
+ screen.getByRole("button", {
366
+ name: /Open menu for Choice B/,
367
+ }),
368
+ ).toBeVisible();
240
369
  });
241
370
 
242
- it("should be able to select an option by keyboard", () => {
371
+ it("should open the cross-out menu when button clicked", async () => {
243
372
  // Arrange
244
- renderQuestion(question, apiOptions);
373
+ renderQuestion(question, crossOutApiOptions);
245
374
 
246
375
  // Act
247
- userEvent.tab();
248
- userEvent.keyboard("{space}");
376
+ userEvent.click(
377
+ screen.getByRole("button", {
378
+ name: /Open menu for Choice B/,
379
+ }),
380
+ );
381
+ await act(async () => {
382
+ await jest.runAllTimers();
383
+ });
249
384
 
250
385
  // Assert
251
- expect(screen.getAllByRole("radio")[0]).toBeChecked();
386
+ expect(
387
+ screen.getByRole("button", {
388
+ name: /Cross out Choice B/,
389
+ }),
390
+ ).toBeVisible();
252
391
  });
253
392
 
254
- it("should be able to navigate to 'None of the above' choice by keyboard", () => {
393
+ it("should open the cross-out menu when focused and spacebar pressed", async () => {
255
394
  // Arrange
256
- const q = clone(question);
257
- ((q.widgets["radio 1"]
258
- .options: any): PerseusRadioWidgetOptions).choices[3].isNoneOfTheAbove = true;
259
- renderQuestion(q, apiOptions);
395
+ renderQuestion(question, crossOutApiOptions);
396
+ userEvent.tab(); // Choice icon
397
+ userEvent.tab(); // Cross-out menu ellipsis
260
398
 
261
399
  // Act
262
- userEvent.tab();
263
- userEvent.tab();
264
- userEvent.tab();
400
+ userEvent.keyboard("{space}");
401
+ await act(async () => {
402
+ await jest.runAllTimers();
403
+ });
265
404
 
266
405
  // Assert
267
- // Note(TB): The visual buttons are hidden from screen readers
268
- // so they need to be identified as hidden
269
406
  expect(
270
- screen.getAllByRole("button", {hidden: true})[2],
271
- ).toHaveFocus();
407
+ screen.getByRole("button", {
408
+ name: /Cross out Choice A/,
409
+ }),
410
+ ).toBeVisible();
272
411
  });
273
412
 
274
- it.each([
275
- ["No", "Yes"],
276
- ["False", "True"],
277
- ])("should enforce ordering for common answers: %j", (...answers) => {
413
+ it("should cross-out selection and dismiss button when clicked", () => {
278
414
  // Arrange
279
- const q = clone(question);
280
- ((q.widgets["radio 1"]
281
- .options: any): PerseusRadioWidgetOptions).choices = answers.map(
282
- (answer, idx) => ({
283
- content: answer,
284
- correct: idx === 1, // Correct answer is the "truthy" item
415
+ renderQuestion(question, crossOutApiOptions);
416
+ userEvent.click(
417
+ screen.getByRole("button", {
418
+ name: /Open menu for Choice B/,
285
419
  }),
286
420
  );
287
421
 
288
422
  // Act
289
- const {renderer} = renderQuestion(q, apiOptions);
290
- // We click on the first item, which was the second (index == 1)
291
- // item in the original choices. But because of enforced ordering,
292
- // it is now at the top of the list (and thus our correct answer).
293
- userEvent.click(screen.getAllByRole("radio")[0]);
423
+ userEvent.click(
424
+ screen.getByRole("button", {
425
+ name: /Cross out Choice B/,
426
+ }),
427
+ );
294
428
 
295
429
  // Assert
296
- const items = screen.getAllByRole("listitem");
297
- expect(items[0]).toHaveTextContent(answers[1]);
298
- expect(items[1]).toHaveTextContent(answers[0]);
299
- expect(renderer).toHaveBeenAnsweredCorrectly();
430
+ expect(
431
+ screen.queryAllByRole("button", {
432
+ name: /Cross out Choice B/,
433
+ }),
434
+ ).toHaveLength(0);
435
+
436
+ expect(
437
+ screen.getByTestId("choice-icon__cross-out-line"),
438
+ ).toBeVisible();
300
439
  });
301
440
 
302
- it("should not change ordering of non-common answers", () => {
441
+ it("should remove cross-out line on selection", async () => {
303
442
  // Arrange
304
- const answers = ["Last", "First"];
305
- const q = clone(question);
306
- ((q.widgets["radio 1"]
307
- .options: any): PerseusRadioWidgetOptions).choices = answers.map(
308
- (answer, idx) => ({
309
- content: answer,
310
- correct: idx === 1,
443
+ renderQuestion(question, crossOutApiOptions);
444
+ userEvent.click(
445
+ screen.getByRole("button", {
446
+ name: /Open menu for Choice B/,
311
447
  }),
312
448
  );
313
449
 
314
450
  // Act
315
- renderQuestion(q, apiOptions);
316
-
317
- // Assert
318
- const items = screen.getAllByRole("listitem");
319
- expect(items[0]).toHaveTextContent(answers[0]);
320
- expect(items[1]).toHaveTextContent(answers[1]);
321
- });
322
-
323
- it("should transform inline passage-refs to references to passage widgets", () => {
324
- // Arrange
325
-
326
- // Add a passage widget to the question content and then reference it
327
- // in an answer.
328
- const questionWithPassage = clone(question);
329
- questionWithPassage.content = `[[\u2603 passage 1]]\n\nNow what's the answer?\n\n[[\u2603 radio 1]]`;
330
- questionWithPassage.widgets["passage 1"] = passageWidget;
331
- const radioOptions = questionWithPassage.widgets["radio 1"];
332
- // HACK(jeremy): Flow doesn't know what type these options are because
333
- // we've extracted them out of a generic `PerseusJson` blob and all the
334
- // `widget` items are just type `Widget`.
335
- (radioOptions.options: any).choices[0].content =
336
- '{{passage-ref 1 1 "Reference 1 here"}}';
451
+ userEvent.click(
452
+ screen.getByRole("button", {
453
+ name: /Cross out Choice B/,
454
+ }),
455
+ );
456
+ jest.runAllTimers();
337
457
 
338
- // Act
339
- renderQuestion(questionWithPassage, apiOptions);
340
- // Passage refs use `_.defer()` to update their reference ranges.
341
- jest.runOnlyPendingTimers();
458
+ userEvent.click(
459
+ screen.getByRole("radio", {
460
+ name: "(Choice B, Crossed out) -8",
461
+ }),
462
+ );
342
463
 
343
464
  // Assert
344
- // By using a `passage-ref` in a choice, we should now have the
345
- // reference on the radio we used it on.
346
- // NOTE: We get by listitem role here because the 'radio' role
347
- // element does not contain the HTML that provides the label
348
- // for it.
349
- const passageRefRadio = screen.getAllByRole("listitem")[0];
350
- expect(passageRefRadio).toHaveTextContent("lines NaN–NaN");
465
+ expect(
466
+ screen.queryByTestId("choice-icon__cross-out-line"),
467
+ ).not.toBeInTheDocument();
351
468
  });
352
469
 
353
- it("should render rationales for selected choices", () => {
470
+ it("should dismiss cross-out button with {tab} key", async () => {
354
471
  // Arrange
355
- const {renderer} = renderQuestion(question, apiOptions);
472
+ renderQuestion(question, crossOutApiOptions);
473
+ userEvent.tab(); // Choice icon
474
+ userEvent.tab(); // Cross-out menu ellipsis
356
475
 
357
476
  // Act
358
- selectOption(incorrect[0]);
359
- renderer.showRationalesForCurrentlySelectedChoices();
360
-
361
- // Assert
362
- expect(
363
- screen.queryAllByTestId(
364
- `perseus-radio-rationale-content-${incorrect[0]}`,
365
- ),
366
- ).toHaveLength(1);
367
- });
368
-
369
- describe("cross-out is enabled", () => {
370
- const crossOutApiOptions = {
371
- ...apiOptions,
372
- crossOutEnabled: true,
373
- };
374
-
375
- it("should render cross-out menu button", () => {
376
- // Arrange & Act
377
- renderQuestion(question, crossOutApiOptions);
378
-
379
- // Assert
380
- expect(
381
- screen.getByRole("button", {
382
- name: /Open menu for Choice B/,
383
- }),
384
- ).toBeVisible();
385
- });
386
-
387
- it("should open the cross-out menu when button clicked", async () => {
388
- // Arrange
389
- renderQuestion(question, crossOutApiOptions);
390
-
391
- // Act
392
- userEvent.click(
393
- screen.getByRole("button", {
394
- name: /Open menu for Choice B/,
395
- }),
396
- );
397
- await act(async () => {
398
- await jest.runAllTimers();
399
- });
400
-
401
- // Assert
402
- expect(
403
- screen.getByRole("button", {
404
- name: /Cross out Choice B/,
405
- }),
406
- ).toBeVisible();
407
- });
408
-
409
- it("should open the cross-out menu when focused and spacebar pressed", async () => {
410
- // Arrange
411
- renderQuestion(question, crossOutApiOptions);
412
- userEvent.tab(); // Choice icon
413
- userEvent.tab(); // Cross-out menu ellipsis
414
-
415
- // Act
416
- userEvent.keyboard("{space}");
417
- await act(async () => {
418
- await jest.runAllTimers();
419
- });
420
-
421
- // Assert
422
- expect(
423
- screen.getByRole("button", {
424
- name: /Cross out Choice A/,
425
- }),
426
- ).toBeVisible();
427
- });
428
-
429
- it("should cross-out selection and dismiss button when clicked", () => {
430
- // Arrange
431
- renderQuestion(question, crossOutApiOptions);
432
- userEvent.click(
433
- screen.getByRole("button", {
434
- name: /Open menu for Choice B/,
435
- }),
436
- );
437
-
438
- // Act
439
- userEvent.click(
440
- screen.getByRole("button", {
441
- name: /Cross out Choice B/,
442
- }),
443
- );
444
-
445
- // Assert
446
- expect(
447
- screen.queryAllByRole("button", {
448
- name: /Cross out Choice B/,
449
- }),
450
- ).toHaveLength(0);
451
-
452
- expect(
453
- screen.getByTestId("choice-icon__cross-out-line"),
454
- ).toBeVisible();
455
- });
456
-
457
- it("should remove cross-out line on selection", async () => {
458
- // Arrange
459
- renderQuestion(question, crossOutApiOptions);
460
- userEvent.click(
461
- screen.getByRole("button", {
462
- name: /Open menu for Choice B/,
463
- }),
464
- );
465
-
466
- // Act
467
- userEvent.click(
468
- screen.getByRole("button", {
469
- name: /Cross out Choice B/,
470
- }),
471
- );
472
- jest.runAllTimers();
473
-
474
- userEvent.click(
475
- screen.getByRole("radio", {
476
- name: "(Choice B, Crossed out) -8",
477
- }),
478
- );
479
-
480
- // Assert
481
- expect(
482
- screen.queryByTestId("choice-icon__cross-out-line"),
483
- ).not.toBeInTheDocument();
477
+ userEvent.keyboard("{space}");
478
+ await act(async () => {
479
+ await jest.runAllTimers();
484
480
  });
485
481
 
486
- it("should dismiss cross-out button with {tab} key", async () => {
487
- // Arrange
488
- renderQuestion(question, crossOutApiOptions);
489
- userEvent.tab(); // Choice icon
490
- userEvent.tab(); // Cross-out menu ellipsis
491
-
492
- // Act
493
- userEvent.keyboard("{space}");
494
- await act(async () => {
495
- await jest.runAllTimers();
496
- });
497
-
498
- expect(
499
- screen.getByRole("button", {
500
- name: /Cross out Choice A/,
501
- }),
502
- ).toBeVisible();
482
+ expect(
483
+ screen.getByRole("button", {
484
+ name: /Cross out Choice A/,
485
+ }),
486
+ ).toBeVisible();
503
487
 
504
- userEvent.keyboard("{space}");
505
- jest.runAllTimers();
488
+ userEvent.keyboard("{space}");
489
+ jest.runAllTimers();
506
490
 
507
- // Assert
508
- expect(
509
- screen.queryAllByRole("button", {
510
- name: /Cross out Choice A/,
511
- }),
512
- ).toHaveLength(0);
513
- });
491
+ // Assert
492
+ expect(
493
+ screen.queryAllByRole("button", {
494
+ name: /Cross out Choice A/,
495
+ }),
496
+ ).toHaveLength(0);
514
497
  });
515
498
  });
516
499
 
517
- it("should not display instructions when satStyling true", () => {
518
- // Arrange
519
- const apiOptions: APIOptions = {
520
- satStyling: true,
521
- };
522
-
523
- // Act
524
- renderQuestion(question, apiOptions);
525
-
526
- // Assert
527
- expect(screen.queryByText("Choose 1 answer:")).not.toBeInTheDocument();
528
- });
529
-
530
500
  it("should be invalid when first rendered", () => {
531
501
  // Arrange
532
502
  const apiOptions: APIOptions = {
@@ -627,9 +597,7 @@ describe("multi-choice question", () => {
627
597
  const [question, correct, incorrect, invalid] =
628
598
  multiChoiceQuestionAndAnswer;
629
599
 
630
- const apiOptions: APIOptions = {
631
- satStyling: false,
632
- };
600
+ const apiOptions: APIOptions = Object.freeze({});
633
601
 
634
602
  beforeEach(() => {
635
603
  jest.spyOn(Dependencies, "getDependencies").mockReturnValue(