@remit/ui 0.0.41 → 0.0.43

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,604 @@
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 {
6
+ AddChipButton,
7
+ ClauseChip,
8
+ ClauseEditor,
9
+ WidenChip,
10
+ } from "./filter-clause-chip.js";
11
+ import { FilterPreviewCount } from "./filter-preview-count.js";
12
+ import {
13
+ type ClauseField,
14
+ clauseFieldHint,
15
+ clauseFieldLabel,
16
+ clauseFieldOrder,
17
+ commitBlockedReason,
18
+ commitLabel,
19
+ demoRule,
20
+ demoSenderFallbackRule,
21
+ demoVocabularyRule,
22
+ type FilterRule,
23
+ type FolderOption,
24
+ matchJoinWord,
25
+ matchOperatorLabel,
26
+ type PreviewCount,
27
+ previewCountSummary,
28
+ type RuleScope,
29
+ scopeLabel,
30
+ widenChipLabel,
31
+ } from "./filter-rule.js";
32
+ import {
33
+ FilterRuleDialog,
34
+ FilterRuleEditor,
35
+ type FilterRuleEditorProps,
36
+ FilterRuleSheet,
37
+ } from "./filter-rule-editor.js";
38
+
39
+ /** SSR splits interpolations with comment markers; sentences read across them. */
40
+ const render = (element: Parameters<typeof renderToString>[0]) =>
41
+ renderToString(element).replaceAll("<!-- -->", "");
42
+
43
+ const FOLDERS: FolderOption[] = [
44
+ { id: "mbx-inbox", label: "Inbox" },
45
+ { id: "mbx-archive", label: "Archive" },
46
+ ];
47
+
48
+ const READY: PreviewCount = { status: "ready", count: 47 };
49
+
50
+ const editor = (overrides: Partial<FilterRuleEditorProps> = {}) =>
51
+ render(
52
+ createElement(FilterRuleEditor, {
53
+ rule: demoRule,
54
+ folders: FOLDERS,
55
+ preview: READY,
56
+ semanticAvailable: true,
57
+ ...overrides,
58
+ }),
59
+ );
60
+
61
+ describe("filter-rule model", () => {
62
+ it("labels every clause field", () => {
63
+ const labels = clauseFieldOrder.map(clauseFieldLabel);
64
+ assert.deepEqual(labels, [
65
+ "From",
66
+ "Subject",
67
+ "Has the words",
68
+ "List",
69
+ "Domain",
70
+ ]);
71
+ });
72
+
73
+ it("joins clauses with and/or by operator", () => {
74
+ assert.equal(matchJoinWord("all"), "and");
75
+ assert.equal(matchJoinWord("any"), "or");
76
+ });
77
+
78
+ it("names the operators and scopes for the toggles", () => {
79
+ assert.equal(matchOperatorLabel("all"), "Match all");
80
+ assert.equal(matchOperatorLabel("any"), "Match any");
81
+ const scopes: RuleScope[] = ["once", "standing", "until"];
82
+ assert.deepEqual(scopes.map(scopeLabel), [
83
+ "Just once",
84
+ "Keep doing this",
85
+ "Until a date",
86
+ ]);
87
+ });
88
+
89
+ it("labels the widen chip with its anchor count", () => {
90
+ assert.equal(widenChipLabel({ anchorCount: 2 }), "Similar to these 2");
91
+ });
92
+
93
+ it("labels the commit by scope", () => {
94
+ assert.equal(commitLabel("once"), "Apply now");
95
+ assert.equal(commitLabel("standing"), "Save rule");
96
+ assert.equal(commitLabel("until"), "Save until then");
97
+ });
98
+
99
+ it("summarises every preview state", () => {
100
+ assert.match(previewCountSummary({ status: "loading" }), /Counting/);
101
+ assert.equal(
102
+ previewCountSummary({ status: "ready", count: 0 }),
103
+ "No mail matches yet",
104
+ );
105
+ assert.equal(
106
+ previewCountSummary({ status: "ready", count: 1 }),
107
+ "1 message match",
108
+ );
109
+ assert.equal(
110
+ previewCountSummary({ status: "ready", count: 9 }),
111
+ "9 messages match",
112
+ );
113
+ assert.match(
114
+ previewCountSummary({ status: "ready", count: 9, stale: true }),
115
+ /recounting/,
116
+ );
117
+ assert.equal(
118
+ previewCountSummary({ status: "error", reason: "server said no" }),
119
+ "server said no",
120
+ );
121
+ });
122
+ });
123
+
124
+ describe("commitBlockedReason", () => {
125
+ const base: FilterRule = {
126
+ clauses: [{ id: "c", field: "From", value: "a@b.com" }],
127
+ matchOperator: "all",
128
+ moveMailboxId: "mbx-archive",
129
+ scope: "once",
130
+ };
131
+ const fresh: PreviewCount = { status: "ready", count: 5 };
132
+
133
+ it("clears when a one-time rule has a matcher, a folder, and a settled count", () => {
134
+ assert.equal(commitBlockedReason(base, fresh), undefined);
135
+ });
136
+
137
+ it("an active widen alone is a matcher", () => {
138
+ assert.equal(
139
+ commitBlockedReason(
140
+ { ...base, clauses: [], widen: { anchorCount: 2 } },
141
+ fresh,
142
+ ),
143
+ undefined,
144
+ );
145
+ });
146
+
147
+ it("an inactive widen alone is not a matcher", () => {
148
+ assert.match(
149
+ commitBlockedReason(
150
+ { ...base, clauses: [], widen: { anchorCount: 2, inactive: true } },
151
+ fresh,
152
+ ) ?? "",
153
+ /Add a clause/,
154
+ );
155
+ });
156
+
157
+ it("asks for a matcher when there is none", () => {
158
+ assert.match(
159
+ commitBlockedReason({ ...base, clauses: [] }, fresh) ?? "",
160
+ /Add a clause/,
161
+ );
162
+ });
163
+
164
+ it("asks for a folder when none is chosen", () => {
165
+ assert.match(
166
+ commitBlockedReason({ ...base, moveMailboxId: undefined }, fresh) ?? "",
167
+ /Pick a folder/,
168
+ );
169
+ });
170
+
171
+ it("asks a standing rule for a name", () => {
172
+ assert.match(
173
+ commitBlockedReason({ ...base, scope: "standing", name: " " }, fresh) ??
174
+ "",
175
+ /Name this rule/,
176
+ );
177
+ });
178
+
179
+ it("asks a timed rule for a date", () => {
180
+ assert.match(
181
+ commitBlockedReason({ ...base, scope: "until", name: "x" }, fresh) ?? "",
182
+ /date this rule should stop/,
183
+ );
184
+ });
185
+
186
+ it("blocks a well-formed rule while the count is still loading", () => {
187
+ assert.match(
188
+ commitBlockedReason(base, { status: "loading" }) ?? "",
189
+ /count settles/,
190
+ );
191
+ });
192
+
193
+ it("blocks a well-formed rule while the count is stale", () => {
194
+ assert.match(
195
+ commitBlockedReason(base, { status: "ready", count: 5, stale: true }) ??
196
+ "",
197
+ /count settles/,
198
+ );
199
+ });
200
+
201
+ it("does not gate on a preview error — the count region raises that", () => {
202
+ assert.equal(
203
+ commitBlockedReason(base, { status: "error", reason: "boom" }),
204
+ undefined,
205
+ );
206
+ });
207
+ });
208
+
209
+ describe("ClauseChip", () => {
210
+ it("shows the field and value and both affordances", () => {
211
+ const html = render(
212
+ createElement(ClauseChip, {
213
+ clause: { id: "c", field: "Subject", value: "invoice" },
214
+ onEdit: () => {},
215
+ onRemove: () => {},
216
+ }),
217
+ );
218
+ assert.match(html, /Subject/);
219
+ assert.match(html, /invoice/);
220
+ assert.match(html, /aria-label="Edit Subject clause"/);
221
+ assert.match(html, /aria-label="Remove Subject clause"/);
222
+ });
223
+
224
+ it("marks a sender-derived From clause as visible and editable", () => {
225
+ const html = render(
226
+ createElement(ClauseChip, {
227
+ clause: {
228
+ id: "c",
229
+ field: "From",
230
+ value: "receipts@stripe.com",
231
+ derived: true,
232
+ },
233
+ onEdit: () => {},
234
+ onRemove: () => {},
235
+ }),
236
+ );
237
+ assert.match(html, /from sender/);
238
+ assert.match(html, /receipts@stripe\.com/);
239
+ assert.match(html, /border-dashed/);
240
+ });
241
+
242
+ it("renders the ListId and FromDomain variants for ticket B", () => {
243
+ const fields: ClauseField[] = ["ListId", "FromDomain"];
244
+ for (const field of fields) {
245
+ const html = render(
246
+ createElement(ClauseChip, {
247
+ clause: { id: "c", field, value: "x" },
248
+ }),
249
+ );
250
+ assert.match(html, new RegExp(clauseFieldLabel(field)));
251
+ }
252
+ });
253
+ });
254
+
255
+ describe("WidenChip", () => {
256
+ it("offers a removable similar-mail chip with the anchor count", () => {
257
+ const html = render(
258
+ createElement(WidenChip, {
259
+ widen: { anchorCount: 3 },
260
+ onRemove: () => {},
261
+ }),
262
+ );
263
+ assert.match(html, /and anything similar/);
264
+ assert.match(html, /Similar to these 3/);
265
+ assert.match(html, /aria-label="Remove the similar-mail widen"/);
266
+ });
267
+
268
+ it("marks a degraded widen inactive but keeps it removable", () => {
269
+ const html = render(
270
+ createElement(WidenChip, {
271
+ widen: { anchorCount: 2, inactive: true },
272
+ onRemove: () => {},
273
+ }),
274
+ );
275
+ assert.match(html, /not available here/);
276
+ assert.match(html, /line-through/);
277
+ assert.match(html, /aria-label="Remove the similar-mail widen"/);
278
+ });
279
+ });
280
+
281
+ describe("AddChipButton", () => {
282
+ it("renders its label", () => {
283
+ const html = render(createElement(AddChipButton, { label: "Add clause" }));
284
+ assert.match(html, /Add clause/);
285
+ });
286
+ });
287
+
288
+ describe("ClauseEditor", () => {
289
+ it("offers the field picker and a value input when adding", () => {
290
+ const html = render(
291
+ createElement(ClauseEditor, {
292
+ draft: { field: "From", value: "" },
293
+ mode: "add",
294
+ }),
295
+ );
296
+ assert.match(html, /aria-label="Clause field"/);
297
+ assert.match(html, /aria-label="Clause value"/);
298
+ assert.match(html, />Add</);
299
+ });
300
+
301
+ it("labels the submit Save when editing", () => {
302
+ const html = render(
303
+ createElement(ClauseEditor, {
304
+ draft: { field: "Subject", value: "invoice" },
305
+ mode: "edit",
306
+ }),
307
+ );
308
+ assert.match(html, />Save</);
309
+ });
310
+
311
+ it("offers only the fields a consumer allows, so it never proposes a clause the backend can't match", () => {
312
+ const html = render(
313
+ createElement(ClauseEditor, {
314
+ draft: { field: "From", value: "" },
315
+ mode: "add",
316
+ fields: ["From", "Subject", "HasWords"],
317
+ }),
318
+ );
319
+ assert.match(html, />From</);
320
+ assert.match(html, />Subject</);
321
+ assert.doesNotMatch(html, /value="ListId"/);
322
+ assert.doesNotMatch(html, /value="FromDomain"/);
323
+ });
324
+
325
+ it("shows the ListId hint — what it matches and the forward-only caveat", () => {
326
+ const html = render(
327
+ createElement(ClauseEditor, {
328
+ draft: { field: "ListId", value: "" },
329
+ mode: "add",
330
+ }),
331
+ );
332
+ assert.match(html, /List-Id/);
333
+ assert.match(html, /matched as it arrives/i);
334
+ });
335
+
336
+ it("shows the FromDomain hint — the registrable domain, look-alikes excluded", () => {
337
+ const html = render(
338
+ createElement(ClauseEditor, {
339
+ draft: { field: "FromDomain", value: "" },
340
+ mode: "add",
341
+ }),
342
+ );
343
+ assert.match(html, /registrable domain/i);
344
+ });
345
+
346
+ it("carries no hint for the self-evident fields", () => {
347
+ const html = render(
348
+ createElement(ClauseEditor, {
349
+ draft: { field: "Subject", value: "" },
350
+ mode: "add",
351
+ }),
352
+ );
353
+ assert.doesNotMatch(html, /registrable domain/i);
354
+ assert.doesNotMatch(html, /List-Id/);
355
+ });
356
+ });
357
+
358
+ describe("clauseFieldHint", () => {
359
+ it("explains ListId and FromDomain, and leaves the plain fields unexplained", () => {
360
+ assert.match(clauseFieldHint("ListId") ?? "", /List-Id/);
361
+ assert.match(clauseFieldHint("FromDomain") ?? "", /registrable domain/i);
362
+ assert.equal(clauseFieldHint("From"), undefined);
363
+ assert.equal(clauseFieldHint("Subject"), undefined);
364
+ assert.equal(clauseFieldHint("HasWords"), undefined);
365
+ });
366
+ });
367
+
368
+ describe("FilterPreviewCount", () => {
369
+ it("shows a live loading state", () => {
370
+ const html = render(
371
+ createElement(FilterPreviewCount, { preview: { status: "loading" } }),
372
+ );
373
+ assert.match(html, /Counting matches/);
374
+ assert.match(html, /aria-live/);
375
+ });
376
+
377
+ it("states zero without reading as an error", () => {
378
+ const html = render(
379
+ createElement(FilterPreviewCount, {
380
+ preview: { status: "ready", count: 0 },
381
+ }),
382
+ );
383
+ assert.match(html, /No mail matches yet/);
384
+ assert.doesNotMatch(html, /role="alert"/);
385
+ });
386
+
387
+ it("counts many matches", () => {
388
+ const html = render(
389
+ createElement(FilterPreviewCount, {
390
+ preview: { status: "ready", count: 412 },
391
+ }),
392
+ );
393
+ assert.match(html, /412 messages match/);
394
+ });
395
+
396
+ it("marks a changed set as recounting rather than blank", () => {
397
+ const html = render(
398
+ createElement(FilterPreviewCount, {
399
+ preview: { status: "ready", count: 47, stale: true },
400
+ }),
401
+ );
402
+ assert.match(html, /recounting/);
403
+ });
404
+
405
+ it("raises an alert on a preview error", () => {
406
+ const html = render(
407
+ createElement(FilterPreviewCount, {
408
+ preview: { status: "error", reason: "preview failed" },
409
+ }),
410
+ );
411
+ assert.match(html, /role="alert"/);
412
+ assert.match(html, /preview failed/);
413
+ });
414
+ });
415
+
416
+ describe("FilterRuleEditor", () => {
417
+ it("renders clause chips joined by the operator word", () => {
418
+ const html = editor();
419
+ assert.match(html, /notifications@github\.com/);
420
+ assert.match(html, /pull request/);
421
+ assert.match(html, /and/);
422
+ });
423
+
424
+ it("joins with or when matching any", () => {
425
+ const html = editor({ rule: demoVocabularyRule });
426
+ assert.match(html, /python-dev\.python\.org/);
427
+ assert.match(html, /python\.org/);
428
+ assert.match(html, /nightly build/);
429
+ assert.match(html, /or/);
430
+ });
431
+
432
+ it("shows the match-operator toggle only with two or more matchers", () => {
433
+ assert.match(editor(), /aria-label="Match operator"/);
434
+ const single = editor({
435
+ rule: {
436
+ clauses: [{ id: "c", field: "From", value: "a@b.com" }],
437
+ matchOperator: "all",
438
+ moveMailboxId: "mbx-archive",
439
+ scope: "once",
440
+ },
441
+ });
442
+ assert.doesNotMatch(single, /aria-label="Match operator"/);
443
+ });
444
+
445
+ it("offers the widen add only when the deployment can serve it", () => {
446
+ const noWiden: FilterRule = { ...demoRule, widen: undefined };
447
+ assert.match(
448
+ editor({ rule: noWiden, semanticAvailable: true }),
449
+ /…and similar/,
450
+ );
451
+ assert.doesNotMatch(
452
+ editor({ rule: noWiden, semanticAvailable: false }),
453
+ /…and similar/,
454
+ );
455
+ });
456
+
457
+ it("renders a present widen even where the capability is absent", () => {
458
+ const html = editor({
459
+ rule: {
460
+ ...demoRule,
461
+ widen: { anchorCount: 2, inactive: true },
462
+ },
463
+ semanticAvailable: false,
464
+ });
465
+ assert.match(html, /not available here/);
466
+ });
467
+
468
+ it("drops the join word before an inactive widen chip", () => {
469
+ const joins = (html: string) =>
470
+ html.match(/uppercase text-fg-subtle">and</g)?.length ?? 0;
471
+ assert.equal(joins(editor()), 2);
472
+ assert.equal(
473
+ joins(
474
+ editor({
475
+ rule: { ...demoRule, widen: { anchorCount: 2, inactive: true } },
476
+ semanticAvailable: false,
477
+ }),
478
+ ),
479
+ 1,
480
+ );
481
+ });
482
+
483
+ it("keeps a degraded widen removable in the editor", () => {
484
+ const html = editor({
485
+ rule: { ...demoRule, widen: { anchorCount: 2, inactive: true } },
486
+ semanticAvailable: false,
487
+ onRemoveWiden: () => {},
488
+ });
489
+ assert.match(html, /aria-label="Remove the similar-mail widen"/);
490
+ });
491
+
492
+ it("blocks the commit while the previewed set is recounting", () => {
493
+ const html = editor({
494
+ preview: { status: "ready", count: 47, stale: true },
495
+ });
496
+ assert.match(html, /role="status"/);
497
+ assert.match(html, /count settles/);
498
+ assert.match(html, /disabled/);
499
+ });
500
+
501
+ it("renders the sender-fallback From chips as ordinary editable chips", () => {
502
+ const html = editor({ rule: demoSenderFallbackRule });
503
+ assert.match(html, /receipts@stripe\.com/);
504
+ assert.match(html, /receipts@lyft\.com/);
505
+ assert.match(html, /from sender/);
506
+ });
507
+
508
+ it("reserves a disabled label slot next to the move action", () => {
509
+ const html = editor();
510
+ assert.match(html, /label them…/);
511
+ assert.match(html, /Labeling isn.+available yet/);
512
+ });
513
+
514
+ it("shows the scope toggle and names a standing rule", () => {
515
+ const html = editor();
516
+ assert.match(html, /aria-label="Rule scope"/);
517
+ assert.match(html, /aria-label="Rule name"/);
518
+ });
519
+
520
+ it("asks for a date only on the until scope", () => {
521
+ const timed = editor({
522
+ rule: { ...demoRule, scope: "until", until: "2026-08-01" },
523
+ });
524
+ assert.match(timed, /aria-label="Expiry date"/);
525
+ assert.doesNotMatch(editor(), /aria-label="Expiry date"/);
526
+ });
527
+
528
+ it("hides the name field for a one-time rule", () => {
529
+ const once = editor({ rule: { ...demoRule, scope: "once" } });
530
+ assert.doesNotMatch(once, /aria-label="Rule name"/);
531
+ });
532
+
533
+ it("drives the commit label from the scope", () => {
534
+ assert.match(editor(), /Save rule/);
535
+ assert.match(editor({ rule: { ...demoRule, scope: "once" } }), /Apply now/);
536
+ });
537
+
538
+ it("states why the commit is blocked instead of only disabling it", () => {
539
+ const html = editor({
540
+ rule: { ...demoRule, moveMailboxId: undefined },
541
+ });
542
+ assert.match(html, /role="status"/);
543
+ assert.match(html, /Pick a folder/);
544
+ });
545
+
546
+ it("renders the inline clause editor when adding", () => {
547
+ const html = editor({
548
+ clauseEdit: { mode: "add", draft: { field: "From", value: "" } },
549
+ });
550
+ assert.match(html, /aria-label="Clause value"/);
551
+ assert.doesNotMatch(html, /Add clause/);
552
+ });
553
+
554
+ it("shows the live preview region", () => {
555
+ assert.match(editor(), /47 messages match/);
556
+ });
557
+ });
558
+
559
+ describe("FilterRuleDialog", () => {
560
+ it("renders the editor when open", () => {
561
+ const html = render(
562
+ createElement(FilterRuleDialog, {
563
+ open: true,
564
+ onClose: () => {},
565
+ rule: demoRule,
566
+ folders: FOLDERS,
567
+ preview: READY,
568
+ }),
569
+ );
570
+ assert.match(html, /role="dialog"/);
571
+ assert.match(html, /Filter rule/);
572
+ });
573
+
574
+ it("renders nothing when closed", () => {
575
+ assert.equal(
576
+ renderToString(
577
+ createElement(FilterRuleDialog, {
578
+ open: false,
579
+ onClose: () => {},
580
+ rule: demoRule,
581
+ folders: FOLDERS,
582
+ preview: READY,
583
+ }),
584
+ ),
585
+ "",
586
+ );
587
+ });
588
+ });
589
+
590
+ describe("FilterRuleSheet", () => {
591
+ it("renders the editor inside a dismissible sheet", () => {
592
+ const html = render(
593
+ createElement(FilterRuleSheet, {
594
+ open: true,
595
+ onClose: () => {},
596
+ rule: demoRule,
597
+ folders: FOLDERS,
598
+ preview: READY,
599
+ }),
600
+ );
601
+ assert.match(html, /Dismiss filter rule/);
602
+ assert.match(html, /Filter rule/);
603
+ });
604
+ });