@remit/ui 0.0.57 → 0.0.59

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,725 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { createElement } from "react";
4
+ import { renderToString } from "react-dom/server";
5
+ import type { MatchCount, StepId, WizardDraft } from "../lib/wizard-steps.js";
6
+ import { stepsFor } from "../lib/wizard-steps.js";
7
+ import type { RuleClause } from "./filter-rule.js";
8
+ import {
9
+ FolderStepBody,
10
+ MatchStepBody,
11
+ NameStepBody,
12
+ PropertiesStepBody,
13
+ ReviewStepBody,
14
+ RuleStepBody,
15
+ RunFooter,
16
+ RunStepBody,
17
+ SelectionSample,
18
+ SelectionWizard,
19
+ type SelectionWizardProps,
20
+ type WizardMessage,
21
+ type WizardScreenProps,
22
+ } from "./selection-wizard.js";
23
+
24
+ const noop = () => {};
25
+
26
+ /** The rendered markup with the server renderer's text-node separators taken out. */
27
+ const text = (html: string): string => html.replaceAll("<!-- -->", "");
28
+
29
+ const messages: WizardMessage[] = [
30
+ {
31
+ id: "m1",
32
+ sender: "Booking.com",
33
+ subject: "Booking confirmation: Hotel Bairro Alto",
34
+ date: "Jul 2",
35
+ },
36
+ {
37
+ id: "m2",
38
+ sender: "Airbnb",
39
+ subject: "Your reservation is confirmed",
40
+ date: "Jun 28",
41
+ },
42
+ ];
43
+
44
+ const clauses: RuleClause[] = [
45
+ { id: "c1", field: "From", value: "noreply@booking.com", derived: true },
46
+ { id: "c2", field: "Subject", value: "confirmation" },
47
+ ];
48
+
49
+ const counted = (count: number): MatchCount => ({ status: "ready", count });
50
+ const UNCOUNTED: MatchCount = { status: "uncounted" };
51
+
52
+ const sample = { messages, count: counted(2), label: "Your selection" };
53
+
54
+ const matchProps = {
55
+ selectedCount: 2,
56
+ mode: "selected" as const,
57
+ onModeChange: noop,
58
+ onSemanticFallback: noop,
59
+ sample,
60
+ };
61
+
62
+ const propertiesProps = {
63
+ clauses,
64
+ matchOperator: "all" as const,
65
+ onMatchOperatorChange: noop,
66
+ onStartAddClause: noop,
67
+ onStartEditClause: noop,
68
+ onRemoveClause: noop,
69
+ onChangeDraft: noop,
70
+ onSubmitClause: noop,
71
+ onCancelClause: noop,
72
+ sample: { messages, count: UNCOUNTED, label: "What this matches" },
73
+ };
74
+
75
+ const folderProps = {
76
+ mailboxes: [
77
+ { id: "mbx-archive", label: "Archive" },
78
+ { id: "mbx-travel", label: "Travel" },
79
+ { id: "mbx-inbox", label: "Inbox", isCurrent: true },
80
+ ],
81
+ mailboxId: "mbx-travel",
82
+ onSelect: noop,
83
+ onCreateFolder: () => Promise.resolve({ id: "mbx-new", label: "Hotels" }),
84
+ };
85
+
86
+ const draft = (over: Partial<WizardDraft> = {}): WizardDraft => ({
87
+ clauses,
88
+ matchOperator: "all",
89
+ ...over,
90
+ });
91
+
92
+ const ruleProps = {
93
+ draft: draft(),
94
+ onScopeChange: noop,
95
+ onUntilChange: noop,
96
+ };
97
+
98
+ const nameProps = { name: "Travel confirmations", onNameChange: noop };
99
+
100
+ const reviewProps = {
101
+ verb: "move" as const,
102
+ mode: "selected" as const,
103
+ selectedCount: 2,
104
+ clauses,
105
+ matchOperator: "all" as const,
106
+ folder: "Travel",
107
+ sample,
108
+ };
109
+
110
+ const runProps = {
111
+ state: "backApplyComplete" as const,
112
+ verb: "move" as const,
113
+ matched: 2,
114
+ applied: 2,
115
+ failures: [],
116
+ onRetry: noop,
117
+ onDismiss: noop,
118
+ };
119
+
120
+ const wizardProps = (overrides: Partial<SelectionWizardProps> = {}) => ({
121
+ verb: "move" as const,
122
+ steps: stepsFor({ verb: "move", mode: "selected" }),
123
+ step: "match" as StepId,
124
+ onBack: noop,
125
+ onExit: noop,
126
+ onContinue: noop,
127
+ onCommit: noop,
128
+ match: matchProps,
129
+ properties: propertiesProps,
130
+ folder: folderProps,
131
+ rule: ruleProps,
132
+ name: nameProps,
133
+ review: reviewProps,
134
+ run: runProps,
135
+ ...overrides,
136
+ });
137
+
138
+ describe("SelectionSample", () => {
139
+ it("says nothing matches rather than rendering a bare empty state", () => {
140
+ const html = renderToString(
141
+ createElement(SelectionSample, {
142
+ messages: [],
143
+ count: counted(0),
144
+ label: "What this matches",
145
+ emptyReason: "noMatch",
146
+ }),
147
+ );
148
+ assert.match(html, /Nothing matches this yet/);
149
+ });
150
+
151
+ it("says the mail is not indexed yet, the other reason for no rows", () => {
152
+ const html = renderToString(
153
+ createElement(SelectionSample, {
154
+ messages: [],
155
+ count: counted(0),
156
+ label: "What this matches",
157
+ emptyReason: "notIndexed",
158
+ }),
159
+ );
160
+ assert.match(html, /isn&#x27;t indexed yet/);
161
+ assert.doesNotMatch(html, /Nothing matches this yet/);
162
+ });
163
+
164
+ it("still explains itself when no reason was given", () => {
165
+ const html = renderToString(
166
+ createElement(SelectionSample, {
167
+ messages: [],
168
+ count: counted(0),
169
+ label: "Sample",
170
+ }),
171
+ );
172
+ assert.match(html, /Nothing matches this yet/);
173
+ });
174
+
175
+ it("says the total is unknown when the match has not run", () => {
176
+ const html = renderToString(
177
+ createElement(SelectionSample, {
178
+ messages,
179
+ count: UNCOUNTED,
180
+ label: "First matches",
181
+ }),
182
+ );
183
+ assert.match(html, /not known until the run finishes/);
184
+ });
185
+
186
+ it("counts the rest once the server's count has settled", () => {
187
+ const html = renderToString(
188
+ createElement(SelectionSample, {
189
+ messages,
190
+ count: counted(9),
191
+ label: "What this covers",
192
+ }),
193
+ );
194
+ assert.match(text(html), /and 7 more/);
195
+ assert.doesNotMatch(html, /not known until the run finishes/);
196
+ });
197
+
198
+ it("says a count that is still moving is still moving", () => {
199
+ const loading = renderToString(
200
+ createElement(SelectionSample, {
201
+ messages,
202
+ count: { status: "loading" },
203
+ label: "What this covers",
204
+ }),
205
+ );
206
+ assert.match(loading, /Counting matches/);
207
+
208
+ const stale = renderToString(
209
+ createElement(SelectionSample, {
210
+ messages,
211
+ count: { status: "ready", count: 9, stale: true },
212
+ label: "What this covers",
213
+ }),
214
+ );
215
+ assert.match(text(stale), /recounting/);
216
+ assert.doesNotMatch(text(stale), /and 7 more/);
217
+ });
218
+ });
219
+
220
+ describe("MatchStepBody", () => {
221
+ it("offers the three doors with the ticked count on them", () => {
222
+ const html = renderToString(createElement(MatchStepBody, matchProps));
223
+ assert.match(html, /These 2 messages/);
224
+ assert.match(html, /Similar to these 2/);
225
+ assert.match(html, /Its properties/);
226
+ });
227
+
228
+ it("leaves the similar door pressable and dimmed when it cannot run", () => {
229
+ const html = renderToString(
230
+ createElement(MatchStepBody, {
231
+ ...matchProps,
232
+ semanticUnavailable: true,
233
+ semanticFallbackTaken: true,
234
+ }),
235
+ );
236
+ assert.match(html, /opacity-55/);
237
+ assert.doesNotMatch(html, /disabled=""/);
238
+ assert.match(html, /matching on the\s+senders instead/);
239
+ });
240
+ });
241
+
242
+ describe("PropertiesStepBody", () => {
243
+ it("renders the rule as the shipped chips, with the join between them", () => {
244
+ const html = renderToString(
245
+ createElement(PropertiesStepBody, propertiesProps),
246
+ );
247
+ assert.match(html, /noreply@booking\.com/);
248
+ assert.match(html, /Remove From clause/);
249
+ assert.match(html, /from sender/);
250
+ assert.match(html, /Add clause/);
251
+ assert.match(html, /Match operator/);
252
+ });
253
+
254
+ it("opens the shipped clause editor on the clause being amended", () => {
255
+ const html = renderToString(
256
+ createElement(PropertiesStepBody, {
257
+ ...propertiesProps,
258
+ clauseEdit: {
259
+ mode: "edit",
260
+ clauseId: "c1",
261
+ draft: { field: "From", value: "noreply@booking.com" },
262
+ },
263
+ clauseSuggestions: [{ value: "noreply@ryanair.com" }],
264
+ }),
265
+ );
266
+ assert.match(html, /Clause field/);
267
+ assert.match(html, /Clause value/);
268
+ assert.doesNotMatch(html, /Add clause/);
269
+ });
270
+
271
+ it("carries the conversion notice when a search opened it", () => {
272
+ const html = renderToString(
273
+ createElement(PropertiesStepBody, {
274
+ ...propertiesProps,
275
+ conversionNotice: {
276
+ scopedOutFolder: "Archive",
277
+ droppedFacets: ["Unread"],
278
+ droppedSemantic: true,
279
+ },
280
+ semanticFallbackTaken: true,
281
+ }),
282
+ );
283
+ assert.match(html, /From your search/);
284
+ assert.match(html, /limited to Archive/);
285
+ assert.match(html, /These are the senders/);
286
+ });
287
+
288
+ it("offers no combine control for a single clause", () => {
289
+ const html = renderToString(
290
+ createElement(PropertiesStepBody, {
291
+ ...propertiesProps,
292
+ clauses: [clauses[0]],
293
+ }),
294
+ );
295
+ assert.doesNotMatch(html, /Match operator/);
296
+ });
297
+ });
298
+
299
+ describe("FolderStepBody", () => {
300
+ it("offers the destinations the app ordered, and names the one chosen", () => {
301
+ const html = renderToString(createElement(FolderStepBody, folderProps));
302
+ assert.ok(html.indexOf(">Archive<") < html.indexOf(">Travel<"));
303
+ assert.match(text(html), /Moving to Travel\./);
304
+ assert.match(html, /Move to Archive/);
305
+ });
306
+
307
+ it("marks the folder the mail is already in rather than offering it", () => {
308
+ const html = renderToString(createElement(FolderStepBody, folderProps));
309
+ assert.match(html, /Inbox \(current folder\)/);
310
+ });
311
+
312
+ it("says how to make a folder when none is chosen yet", () => {
313
+ const html = renderToString(
314
+ createElement(FolderStepBody, { ...folderProps, mailboxId: undefined }),
315
+ );
316
+ assert.match(text(html), /type a name that doesn&#x27;t exist yet/);
317
+ });
318
+ });
319
+
320
+ describe("RuleStepBody", () => {
321
+ const bodyTextClause: RuleClause[] = [
322
+ { id: "c1", field: "HasWords", value: "boarding pass" },
323
+ ];
324
+
325
+ it("offers the three scopes", () => {
326
+ const html = renderToString(
327
+ createElement(RuleStepBody, {
328
+ ...ruleProps,
329
+ draft: draft({ scope: "once" }),
330
+ }),
331
+ );
332
+ assert.match(html, /Just once/);
333
+ assert.match(html, /Keep doing this/);
334
+ assert.match(html, /Until a date/);
335
+ });
336
+
337
+ it("says a one-time apply cannot read message bodies, where the scope is chosen", () => {
338
+ const html = renderToString(
339
+ createElement(RuleStepBody, {
340
+ ...ruleProps,
341
+ draft: draft({ clauses: bodyTextClause }),
342
+ }),
343
+ );
344
+ assert.match(html, /can&#x27;t read message bodies/);
345
+ });
346
+
347
+ it("says nothing about message bodies when the widen is the matcher", () => {
348
+ const html = renderToString(
349
+ createElement(RuleStepBody, {
350
+ ...ruleProps,
351
+ draft: draft({ clauses: bodyTextClause, widen: { anchorCount: 3 } }),
352
+ }),
353
+ );
354
+ assert.doesNotMatch(html, /can&#x27;t read message bodies/);
355
+ });
356
+
357
+ it("warns again once the widen the rule carried cannot be evaluated", () => {
358
+ const html = renderToString(
359
+ createElement(RuleStepBody, {
360
+ ...ruleProps,
361
+ draft: draft({
362
+ clauses: bodyTextClause,
363
+ widen: { anchorCount: 3, inactive: true },
364
+ }),
365
+ }),
366
+ );
367
+ assert.match(html, /can&#x27;t read message bodies/);
368
+ });
369
+
370
+ it("asks for the stop date on the step that offered the scope", () => {
371
+ const html = renderToString(
372
+ createElement(RuleStepBody, {
373
+ ...ruleProps,
374
+ draft: draft({ scope: "until", until: "2026-09-01" }),
375
+ }),
376
+ );
377
+ assert.match(html, /Stops on/);
378
+ assert.match(html, /2026-09-01/);
379
+ });
380
+ });
381
+
382
+ describe("NameStepBody", () => {
383
+ it("prefills the suggested name and offers to clear it", () => {
384
+ const html = renderToString(createElement(NameStepBody, nameProps));
385
+ assert.match(html, /Travel confirmations/);
386
+ assert.match(html, /Clear name/);
387
+ });
388
+
389
+ it("offers no clear control when the field is empty", () => {
390
+ const html = renderToString(
391
+ createElement(NameStepBody, { ...nameProps, name: "" }),
392
+ );
393
+ assert.doesNotMatch(html, /Clear name/);
394
+ });
395
+ });
396
+
397
+ describe("ReviewStepBody", () => {
398
+ it("states the whole thing as one sentence, then as a list, then the sample", () => {
399
+ const html = renderToString(createElement(ReviewStepBody, reviewProps));
400
+ assert.match(html, /2 messages/);
401
+ assert.match(html, /to Travel/);
402
+ assert.match(html, /Destination/);
403
+ assert.match(html, /Booking confirmation/);
404
+ });
405
+
406
+ it("warns that a widened match covers messages not shown in the list", () => {
407
+ const html = renderToString(
408
+ createElement(ReviewStepBody, {
409
+ ...reviewProps,
410
+ mode: "similar",
411
+ sample: {
412
+ messages,
413
+ count: UNCOUNTED,
414
+ label: "A sample of what matches",
415
+ },
416
+ }),
417
+ );
418
+ assert.match(html, /covers messages not shown in the list/);
419
+ assert.match(html, /not known until the run finishes/);
420
+ });
421
+
422
+ it("names the rule and its stop date once the scope persists", () => {
423
+ const html = renderToString(
424
+ createElement(ReviewStepBody, {
425
+ ...reviewProps,
426
+ verb: "organize",
427
+ scope: "until",
428
+ until: "2026-09-01",
429
+ ruleName: "Travel confirmations",
430
+ }),
431
+ );
432
+ assert.match(html, /save a rule/);
433
+ assert.match(html, /until 2026-09-01/);
434
+ assert.match(html, /Travel confirmations/);
435
+ });
436
+ });
437
+
438
+ describe("RunStepBody", () => {
439
+ it("shows the pass over existing mail running, with progress", () => {
440
+ const html = renderToString(
441
+ createElement(RunStepBody, {
442
+ ...runProps,
443
+ state: "backApplyRunning",
444
+ scope: "standing",
445
+ applied: 0,
446
+ }),
447
+ );
448
+ assert.match(html, /Moving the mail already in your mailbox/);
449
+ assert.match(html, /role="progressbar"/);
450
+ });
451
+
452
+ it("names every message the mail server rejected, and keeps the rule", () => {
453
+ const html = renderToString(
454
+ createElement(RunStepBody, {
455
+ ...runProps,
456
+ state: "backApplyFailed",
457
+ scope: "standing",
458
+ matched: 4,
459
+ applied: 2,
460
+ failures: messages,
461
+ }),
462
+ );
463
+ assert.match(html, /Not moved/);
464
+ assert.match(html, /Booking\.com/);
465
+ assert.match(html, /Server rejected/);
466
+ assert.match(html, /The rule itself is saved/);
467
+ });
468
+
469
+ it("ends a one-off run on its own count", () => {
470
+ const html = renderToString(
471
+ createElement(RunStepBody, { ...runProps, scope: "once" }),
472
+ );
473
+ assert.match(html, /Moved 2/);
474
+ });
475
+
476
+ it("says a create that failed changed nothing", () => {
477
+ const html = renderToString(
478
+ createElement(RunStepBody, {
479
+ ...runProps,
480
+ state: "commitFailed",
481
+ scope: "standing",
482
+ }),
483
+ );
484
+ assert.match(html, /Couldn&#x27;t save the rule/);
485
+ assert.match(html, /Nothing has changed\./);
486
+ });
487
+
488
+ it("says a filter saved with nothing to back-apply is live", () => {
489
+ const html = renderToString(
490
+ createElement(RunStepBody, {
491
+ ...runProps,
492
+ state: "filterSaved",
493
+ scope: "standing",
494
+ }),
495
+ );
496
+ assert.match(html, /Filter saved/);
497
+ assert.doesNotMatch(html, /role="progressbar"/);
498
+ });
499
+ });
500
+
501
+ describe("RunFooter", () => {
502
+ it("offers a retry for the messages that failed", () => {
503
+ const html = renderToString(
504
+ createElement(RunFooter, {
505
+ ...runProps,
506
+ state: "backApplyFailed",
507
+ failures: messages,
508
+ }),
509
+ );
510
+ assert.match(html, /Retry 2/);
511
+ assert.match(html, /Close/);
512
+ });
513
+
514
+ it("offers the pass again when it never started", () => {
515
+ const html = renderToString(
516
+ createElement(RunFooter, {
517
+ ...runProps,
518
+ state: "backApplyStartFailed",
519
+ scope: "standing",
520
+ }),
521
+ );
522
+ assert.match(html, /Run it over existing mail/);
523
+ assert.match(html, /Not now/);
524
+ });
525
+
526
+ it("offers only a way out once there is nothing outstanding", () => {
527
+ const html = renderToString(createElement(RunFooter, runProps));
528
+ assert.match(html, /Done/);
529
+ assert.doesNotMatch(html, /Retry/);
530
+ });
531
+
532
+ it("lets the user leave while the job is still in flight", () => {
533
+ const html = renderToString(
534
+ createElement(RunFooter, { ...runProps, state: "saving" }),
535
+ );
536
+ assert.match(html, /Close/);
537
+ });
538
+ });
539
+
540
+ describe("SelectionWizard", () => {
541
+ it("renders the header, the rail and the footer around every step", () => {
542
+ for (const step of stepsFor({
543
+ verb: "organize",
544
+ mode: "properties",
545
+ scope: "standing",
546
+ })) {
547
+ const html = renderToString(
548
+ createElement(
549
+ SelectionWizard,
550
+ wizardProps({
551
+ verb: "organize",
552
+ steps: stepsFor({
553
+ verb: "organize",
554
+ mode: "properties",
555
+ scope: "standing",
556
+ }),
557
+ step,
558
+ review: { ...reviewProps, verb: "organize", scope: "standing" },
559
+ }),
560
+ ),
561
+ );
562
+ assert.match(html, /aria-label="Back"/, step);
563
+ assert.match(html, /aria-label="Cancel"/, step);
564
+ assert.match(html, /aria-label="Progress"/, step);
565
+ }
566
+ });
567
+
568
+ it("counts the step out of the list the rail is rendering", () => {
569
+ const steps = stepsFor({ verb: "move", mode: "properties" });
570
+ const html = renderToString(
571
+ createElement(SelectionWizard, wizardProps({ steps, step: "folder" })),
572
+ );
573
+ assert.match(text(html), /Step 3 of 5 · Folder/);
574
+ });
575
+
576
+ it("renders the header, the rail and the body from one resolved step", () => {
577
+ const steps = stepsFor({ verb: "organize", mode: "selected" });
578
+ const html = renderToString(
579
+ createElement(
580
+ SelectionWizard,
581
+ wizardProps({ verb: "organize", steps, step: "properties" }),
582
+ ),
583
+ );
584
+ assert.match(text(html), /Step 1 of 4 · Apply to/);
585
+ assert.match(html, /What should this apply to\?/);
586
+ assert.match(html, /These 2 messages/);
587
+ assert.doesNotMatch(html, /Match properties/);
588
+ assert.doesNotMatch(html, /Add clause/);
589
+ });
590
+
591
+ it("leaves the wizard on Back from the opening step and from the run", () => {
592
+ const steps = stepsFor({ verb: "move", mode: "selected" });
593
+ let backs = 0;
594
+ let exits = 0;
595
+ const handlers = {
596
+ onBack: () => {
597
+ backs += 1;
598
+ },
599
+ onExit: () => {
600
+ exits += 1;
601
+ },
602
+ };
603
+ const pressBack = (step: StepId) => {
604
+ const screen = SelectionWizard(wizardProps({ steps, step, ...handlers }));
605
+ (screen.props as WizardScreenProps).onBack();
606
+ };
607
+
608
+ pressBack("match");
609
+ pressBack("run");
610
+ assert.deepEqual({ backs, exits }, { backs: 0, exits: 2 });
611
+
612
+ pressBack("folder");
613
+ assert.deepEqual({ backs, exits }, { backs: 1, exits: 2 });
614
+ });
615
+
616
+ it("flips the header from the verb to Done once the job has ended", () => {
617
+ const steps = stepsFor({ verb: "move", mode: "selected" });
618
+ const running = renderToString(
619
+ createElement(
620
+ SelectionWizard,
621
+ wizardProps({
622
+ steps,
623
+ step: "run",
624
+ run: { ...runProps, state: "backApplyRunning" },
625
+ }),
626
+ ),
627
+ );
628
+ assert.match(running, /<h1[^>]*>Move<\/h1>/);
629
+
630
+ const ended = renderToString(
631
+ createElement(SelectionWizard, wizardProps({ steps, step: "run" })),
632
+ );
633
+ assert.match(ended, /<h1[^>]*>Done<\/h1>/);
634
+
635
+ const failed = renderToString(
636
+ createElement(
637
+ SelectionWizard,
638
+ wizardProps({
639
+ steps,
640
+ step: "run",
641
+ run: { ...runProps, state: "commitFailed" },
642
+ }),
643
+ ),
644
+ );
645
+ assert.match(failed, /<h1[^>]*>Move<\/h1>/);
646
+ assert.doesNotMatch(failed, /<h1[^>]*>Done<\/h1>/);
647
+ });
648
+
649
+ it("keeps a blocked Continue pressable and dimmed, and says what is missing", () => {
650
+ const html = renderToString(
651
+ createElement(
652
+ SelectionWizard,
653
+ wizardProps({
654
+ step: "properties",
655
+ blockedReason: "Add a property to match on.",
656
+ nudged: true,
657
+ }),
658
+ ),
659
+ );
660
+ assert.match(html, /Add a property to match on\./);
661
+ assert.match(html, /aria-disabled="true"/);
662
+ assert.doesNotMatch(html, /<button[^>]*disabled=""/);
663
+ });
664
+
665
+ it("holds the blocked reason back until Continue is pressed", () => {
666
+ const html = renderToString(
667
+ createElement(
668
+ SelectionWizard,
669
+ wizardProps({
670
+ step: "properties",
671
+ blockedReason: "Add a property to match on.",
672
+ }),
673
+ ),
674
+ );
675
+ assert.doesNotMatch(html, /Add a property to match on\./);
676
+ assert.match(html, /aria-disabled="true"/);
677
+ });
678
+
679
+ it("commits under the scope's own label, in the verb's own tone", () => {
680
+ const html = renderToString(
681
+ createElement(
682
+ SelectionWizard,
683
+ wizardProps({
684
+ verb: "delete",
685
+ step: "review",
686
+ review: { ...reviewProps, verb: "delete", scope: undefined },
687
+ }),
688
+ ),
689
+ );
690
+ assert.match(html, /Delete/);
691
+ assert.match(html, /bg-danger/);
692
+ });
693
+
694
+ it("uses the scope's commit label once a rule is being saved", () => {
695
+ const html = renderToString(
696
+ createElement(
697
+ SelectionWizard,
698
+ wizardProps({
699
+ verb: "organize",
700
+ steps: stepsFor({
701
+ verb: "organize",
702
+ mode: "selected",
703
+ scope: "standing",
704
+ }),
705
+ step: "review",
706
+ review: { ...reviewProps, verb: "organize", scope: "standing" },
707
+ }),
708
+ ),
709
+ );
710
+ assert.match(html, /Save rule/);
711
+ });
712
+
713
+ it("fails loudly rather than rendering a step with no answers", () => {
714
+ assert.throws(
715
+ () =>
716
+ renderToString(
717
+ createElement(
718
+ SelectionWizard,
719
+ wizardProps({ step: "folder", folder: undefined }),
720
+ ),
721
+ ),
722
+ /Folder step was rendered without its answers/,
723
+ );
724
+ });
725
+ });