@remit/ui 0.0.61 → 0.0.63

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.
@@ -4,7 +4,10 @@ import { createElement } from "react";
4
4
  import { renderToString } from "react-dom/server";
5
5
  import type { MatchCount, StepId, WizardDraft } from "../lib/wizard-steps.js";
6
6
  import { stepsFor } from "../lib/wizard-steps.js";
7
- import type { RuleClause } from "./filter-rule.js";
7
+ import {
8
+ type RuleClause,
9
+ UNCOUNTABLE_PREDICATE_REASON,
10
+ } from "./filter-rule.js";
8
11
  import {
9
12
  FolderStepBody,
10
13
  MatchStepBody,
@@ -172,6 +175,23 @@ describe("SelectionSample", () => {
172
175
  assert.match(html, /Nothing matches this yet/);
173
176
  });
174
177
 
178
+ // A count that could not be taken is not a count of zero. A body-text clause
179
+ // is the case that makes the difference load-bearing: it matches, once a
180
+ // saved rule is what runs it, so "nothing matches this yet" is wrong rather
181
+ // than merely unexplained (#477 3.5).
182
+ it("says a count could not be taken instead of that nothing matched", () => {
183
+ const html = renderToString(
184
+ createElement(SelectionSample, {
185
+ messages: [],
186
+ count: { status: "error", reason: UNCOUNTABLE_PREDICATE_REASON },
187
+ label: "What this matches",
188
+ emptyReason: "noMatch",
189
+ }),
190
+ );
191
+ assert.match(text(html), /only a saved rule does/);
192
+ assert.doesNotMatch(html, /Nothing matches this yet/);
193
+ });
194
+
175
195
  it("says the total is unknown when the match has not run", () => {
176
196
  const html = renderToString(
177
197
  createElement(SelectionSample, {
@@ -237,6 +257,36 @@ describe("MatchStepBody", () => {
237
257
  assert.doesNotMatch(html, /disabled=""/);
238
258
  assert.match(html, /matching on the\s+senders instead/);
239
259
  });
260
+
261
+ it("names what an escalated predicate covers instead of offering doors", () => {
262
+ const html = renderToString(
263
+ createElement(MatchStepBody, {
264
+ ...matchProps,
265
+ mode: "escalated",
266
+ escalatedScope: 'matching "npm"',
267
+ sample: {
268
+ messages,
269
+ count: counted(1284),
270
+ label: "A sample of what matches",
271
+ },
272
+ }),
273
+ );
274
+ assert.match(html, /Every message matching "npm"/);
275
+ assert.match(html, /nothing to widen/);
276
+ // The three doors are gone: a predicate is the match already.
277
+ assert.doesNotMatch(html, /These 2 messages/);
278
+ assert.doesNotMatch(html, /Similar to these/);
279
+ assert.doesNotMatch(html, /Its properties/);
280
+ // The members of the match still close the screen (#477 2.3).
281
+ assert.match(html, /Booking confirmation/);
282
+ });
283
+
284
+ it("falls back to naming the list rather than naming nothing", () => {
285
+ const html = renderToString(
286
+ createElement(MatchStepBody, { ...matchProps, mode: "escalated" }),
287
+ );
288
+ assert.match(html, /Every message the list is showing/);
289
+ });
240
290
  });
241
291
 
242
292
  describe("PropertiesStepBody", () => {
@@ -419,6 +469,29 @@ describe("ReviewStepBody", () => {
419
469
  assert.match(html, /not known until the run finishes/);
420
470
  });
421
471
 
472
+ it("states an escalated predicate and the count the server gave it", () => {
473
+ const html = renderToString(
474
+ createElement(ReviewStepBody, {
475
+ ...reviewProps,
476
+ verb: "delete",
477
+ mode: "escalated",
478
+ escalatedScope: 'matching "npm"',
479
+ folder: undefined,
480
+ sample: {
481
+ messages,
482
+ count: counted(1284),
483
+ label: "A sample of what matches",
484
+ },
485
+ }),
486
+ );
487
+ assert.match(
488
+ text(html),
489
+ /Delete<\/span> all 1,284 messages matching &quot;npm&quot;/,
490
+ );
491
+ // The count is one reading of a live predicate, and the run takes another.
492
+ assert.match(html, /anything else matching by the time it runs/);
493
+ });
494
+
422
495
  it("names the rule and its stop date once the scope persists", () => {
423
496
  const html = renderToString(
424
497
  createElement(ReviewStepBody, {
@@ -466,6 +539,23 @@ describe("RunStepBody", () => {
466
539
  assert.match(html, /The rule itself is saved/);
467
540
  });
468
541
 
542
+ it("says a stopped run was never sent rather than rejected", () => {
543
+ const html = renderToString(
544
+ createElement(RunStepBody, {
545
+ ...runProps,
546
+ state: "runStopped",
547
+ scope: "once",
548
+ matched: 4,
549
+ applied: 2,
550
+ failures: messages,
551
+ }),
552
+ );
553
+ assert.match(html, /Stopped after 2/);
554
+ assert.match(html, /nothing was sent for them/);
555
+ assert.match(html, /Never sent/);
556
+ assert.doesNotMatch(html, /Server rejected/);
557
+ });
558
+
469
559
  it("ends a one-off run on its own count", () => {
470
560
  const html = renderToString(
471
561
  createElement(RunStepBody, { ...runProps, scope: "once" }),
@@ -665,33 +755,35 @@ describe("SelectionWizard", () => {
665
755
  assert.doesNotMatch(html, /<button[^>]*disabled=""/);
666
756
  });
667
757
 
668
- it("carries the blocked reason on the control before it is pressed, and shows it after", () => {
758
+ it("carries the blocked reason on the control before it is pressed, and announces it after", () => {
759
+ const reason = "Add a property to match on.";
669
760
  const quiet = renderToString(
670
761
  createElement(
671
762
  SelectionWizard,
672
- wizardProps({
673
- step: "properties",
674
- blockedReason: "Add a property to match on.",
675
- }),
763
+ wizardProps({ step: "properties", blockedReason: reason }),
676
764
  ),
677
765
  );
678
- // Described, so it is heard on the control; not on screen until pressed.
766
+ // Described, so it is heard on the control; not on screen and nothing
767
+ // announced until the control is pressed.
679
768
  assert.match(quiet, /aria-describedby="/);
680
- assert.match(quiet, /sr-only/);
681
- assert.doesNotMatch(quiet, /role="status"/);
769
+ assert.match(quiet, /<p id="[^"]*"[^>]*sr-only/);
770
+ assert.match(quiet, /role="status"[^>]*><\/span>/);
682
771
 
683
772
  const nudged = renderToString(
684
773
  createElement(
685
774
  SelectionWizard,
686
775
  wizardProps({
687
776
  step: "properties",
688
- blockedReason: "Add a property to match on.",
777
+ blockedReason: reason,
689
778
  nudged: true,
690
779
  }),
691
780
  ),
692
781
  );
693
- assert.match(nudged, /role="status"/);
694
- assert.doesNotMatch(nudged, /sr-only/);
782
+ // The reason is written into the live region, which is what a screen
783
+ // reader announces — marking the description live would announce nothing.
784
+ assert.doesNotMatch(nudged, /role="status"[^>]*><\/span>/);
785
+ assert.match(nudged, new RegExp(`role="status"[^>]*>${reason}`));
786
+ assert.doesNotMatch(nudged, /<p id="[^"]*"[^>]*sr-only/);
695
787
  });
696
788
 
697
789
  it("commits under the scope's own label, in the verb's own tone", () => {
@@ -21,7 +21,12 @@ import { Fragment, type ReactNode, useEffect, useId, useRef } from "react";
21
21
  import { cn } from "../lib/cn.js";
22
22
  import {
23
23
  backExits,
24
+ ESCALATED_MATCH_HINT,
25
+ ESCALATED_REVIEW_WARNING,
26
+ ESCALATED_SCOPE_FALLBACK,
27
+ escalatedMatchLabel,
24
28
  type MatchCount,
29
+ type MatchDoor,
25
30
  type MatchMode,
26
31
  matchDoorHint,
27
32
  matchDoorLabel,
@@ -42,6 +47,7 @@ import {
42
47
  type WizardDraft,
43
48
  } from "../lib/wizard-steps.js";
44
49
  import { Badge } from "./badge.js";
50
+ import { BlockedReason } from "./blocked-reason.js";
45
51
  import { Button } from "./button.js";
46
52
  import { FieldLabel } from "./field-label.js";
47
53
  import {
@@ -279,6 +285,25 @@ const sampleFooter = (count: MatchCount, shown: number): string => {
279
285
  return previewCountSummary(count);
280
286
  };
281
287
 
288
+ /**
289
+ * What an empty sample says. A count that could not be taken says so; only a
290
+ * count that came back empty may say nothing matches (#477 3.5).
291
+ *
292
+ * A body-text clause is the case that makes the difference load-bearing: the
293
+ * vector-free matcher refuses to evaluate it, so there is no count and no rows,
294
+ * and "nothing matches this yet" is not merely unexplained but wrong — the rule
295
+ * matches, once a saved rule is what runs it.
296
+ */
297
+ const sampleEmptyLine = (
298
+ count: MatchCount,
299
+ emptyReason: SampleEmptyReason | undefined,
300
+ loading: boolean | undefined,
301
+ ): string => {
302
+ if (loading) return "Fetching the messages this covers…";
303
+ if (count.status === "error") return count.reason;
304
+ return sampleEmptyCopy(emptyReason ?? "noMatch");
305
+ };
306
+
282
307
  /**
283
308
  * The members of the match, closing every screen that names one. A named match
284
309
  * with no members shown is an unseen bulk action, so the rows scroll in their own
@@ -298,9 +323,7 @@ export function SelectionSample({
298
323
  </h2>
299
324
  {messages.length === 0 ? (
300
325
  <p className="px-3 py-4 text-xs text-fg-muted">
301
- {loading
302
- ? "Fetching the messages this covers…"
303
- : sampleEmptyCopy(emptyReason ?? "noMatch")}
326
+ {sampleEmptyLine(count, emptyReason, loading)}
304
327
  </p>
305
328
  ) : (
306
329
  <>
@@ -345,9 +368,8 @@ export interface FooterNavProps {
345
368
  /**
346
369
  * Nothing disables (#477 1.7). A Continue with an answer still missing is dimmed
347
370
  * and stays pressable — and stays pressable to assistive technology too, which
348
- * `aria-disabled` would have taken away along with the reason. The reason is on
349
- * the control the whole time it applies, through `aria-describedby`; pressing it
350
- * is what brings the reason on screen.
371
+ * `aria-disabled` would have taken away along with the reason. `BlockedReason`
372
+ * carries the two ways that reason reaches the user.
351
373
  */
352
374
  export function FooterNav({
353
375
  backLabel = "Back",
@@ -362,13 +384,12 @@ export function FooterNav({
362
384
  return (
363
385
  <div className="space-y-2">
364
386
  {blockedReason && (
365
- <p
387
+ <BlockedReason
366
388
  id={reasonId}
367
- role={nudged ? "status" : undefined}
368
- className={cn("px-1 text-2xs text-warning", !nudged && "sr-only")}
369
- >
370
- {blockedReason}
371
- </p>
389
+ reason={blockedReason}
390
+ nudged={nudged}
391
+ className="px-1 text-2xs text-warning"
392
+ />
372
393
  )}
373
394
  <div className="flex items-center gap-3">
374
395
  <Button
@@ -398,7 +419,12 @@ export function FooterNav({
398
419
  export interface MatchStepProps {
399
420
  selectedCount: number;
400
421
  mode: MatchMode;
401
- onModeChange: (mode: MatchMode) => void;
422
+ /**
423
+ * Answers the door. Typed to the three doors rather than to every mode, so
424
+ * no driver can set `escalated` from a screen — the list escalates a
425
+ * selection, the wizard never does.
426
+ */
427
+ onModeChange: (mode: MatchDoor) => void;
402
428
  /**
403
429
  * Similar-mail matching cannot run right now. A runtime state, not a property
404
430
  * of the deployment: the door stays pressable and dimmed.
@@ -413,6 +439,12 @@ export interface MatchStepProps {
413
439
  /** The dimmed door was pressed and the senders were filled in instead. */
414
440
  semanticFallbackTaken?: boolean;
415
441
  onSemanticFallback: () => void;
442
+ /**
443
+ * What the escalated predicate covers, in the words the list escalated it
444
+ * with. Read only while the mode is `escalated`, where it replaces the three
445
+ * doors: the predicate is the match already.
446
+ */
447
+ escalatedScope?: string;
416
448
  sample: SelectionSampleProps;
417
449
  }
418
450
 
@@ -424,8 +456,24 @@ export function MatchStepBody({
424
456
  semanticErrorDetail,
425
457
  semanticFallbackTaken,
426
458
  onSemanticFallback,
459
+ escalatedScope,
427
460
  sample,
428
461
  }: MatchStepProps) {
462
+ if (mode === "escalated") {
463
+ return (
464
+ <>
465
+ <div className="rounded-lg border border-accent bg-accent-soft p-3">
466
+ <p className="text-sm font-medium text-fg">
467
+ {escalatedMatchLabel(escalatedScope ?? ESCALATED_SCOPE_FALLBACK)}
468
+ </p>
469
+ <p className="mt-1 text-xs text-fg-muted">{ESCALATED_MATCH_HINT}</p>
470
+ </div>
471
+ <div className="mt-4">
472
+ <SelectionSample {...sample} />
473
+ </div>
474
+ </>
475
+ );
476
+ }
429
477
  return (
430
478
  <>
431
479
  <div className="space-y-2">
@@ -805,6 +853,8 @@ export interface ReviewStepProps {
805
853
  until?: string;
806
854
  /** Present when the flow reached the naming step. */
807
855
  ruleName?: string;
856
+ /** What the escalated predicate covers, in the list's own words. */
857
+ escalatedScope?: string;
808
858
  sample: SelectionSampleProps;
809
859
  }
810
860
 
@@ -818,10 +868,21 @@ export function ReviewStepBody({
818
868
  scope,
819
869
  until,
820
870
  ruleName,
871
+ escalatedScope,
821
872
  sample,
822
873
  }: ReviewStepProps) {
823
874
  const { label } = verbCopy(verb);
824
- const description = { mode, selectedCount, clauses, matchOperator };
875
+ const description = {
876
+ mode,
877
+ selectedCount,
878
+ clauses,
879
+ matchOperator,
880
+ escalatedScope,
881
+ // A count still moving is not a number to commit against, and the footer
882
+ // under the review's Continue already says so.
883
+ escalatedCount:
884
+ sample.count.status === "ready" ? sample.count.count : undefined,
885
+ };
825
886
  const widened = mode !== "selected";
826
887
  const persists = scope === "standing" || scope === "until";
827
888
 
@@ -844,7 +905,9 @@ export function ReviewStepBody({
844
905
  {widened && (
845
906
  <p className="mt-2 flex items-start gap-1.5 text-xs text-warning">
846
907
  <AlertTriangle className="mt-px size-3.5 shrink-0" />
847
- This covers messages not shown in the list.
908
+ {mode === "escalated"
909
+ ? ESCALATED_REVIEW_WARNING
910
+ : "This covers messages not shown in the list."}
848
911
  </p>
849
912
  )}
850
913
  </div>
@@ -934,6 +997,9 @@ export function RunStepBody(props: RunStepProps) {
934
997
  const { matched, applied, failures } = props;
935
998
  const outcome = runOutcomeOf(props);
936
999
  const copy = runCopy(outcome);
1000
+ // A stopped run never sent these, so nothing rejected them.
1001
+ const failureBadge =
1002
+ props.state === "runStopped" ? "Never sent" : "Server rejected";
937
1003
 
938
1004
  return (
939
1005
  <div className="space-y-4 pt-2">
@@ -964,7 +1030,7 @@ export function RunStepBody(props: RunStepProps) {
964
1030
  {message.subject}
965
1031
  </p>
966
1032
  <Badge className="mt-1" tone="warning">
967
- Server rejected
1033
+ {failureBadge}
968
1034
  </Badge>
969
1035
  </li>
970
1036
  ))}
@@ -1132,10 +1198,17 @@ export function SelectionWizard(props: SelectionWizardProps) {
1132
1198
  return runCopy(runOutcomeOf(stepProps(props.run, step))).screenTitle;
1133
1199
  };
1134
1200
 
1201
+ // An escalated predicate is offered no doors, so the screen states what it
1202
+ // covers rather than asking a question with one answer.
1203
+ const subtitle =
1204
+ step === "match" && props.match?.mode === "escalated"
1205
+ ? "What this applies to"
1206
+ : screen.subtitle;
1207
+
1135
1208
  return (
1136
1209
  <WizardScreen
1137
1210
  title={title()}
1138
- subtitle={screen.subtitle}
1211
+ subtitle={subtitle}
1139
1212
  steps={steps}
1140
1213
  step={step}
1141
1214
  onBack={onBack}
package/src/index.ts CHANGED
@@ -67,6 +67,10 @@ export {
67
67
  type BannerTone,
68
68
  type BannerVariant,
69
69
  } from "./components/banner.js";
70
+ export {
71
+ BlockedReason,
72
+ type BlockedReasonProps,
73
+ } from "./components/blocked-reason.js";
70
74
  export {
71
75
  BottomSheet,
72
76
  type BottomSheetProps,
@@ -174,6 +178,7 @@ export {
174
178
  type RuleWiden,
175
179
  ruleBlockedCopy,
176
180
  scopeLabel,
181
+ UNCOUNTABLE_PREDICATE_REASON,
177
182
  unreadableBodyClauses,
178
183
  widenChipLabel,
179
184
  } from "./components/filter-rule.js";
@@ -628,11 +633,11 @@ export {
628
633
  } from "./lib/roving-focus.js";
629
634
  export { type RuleNameParts, suggestRuleName } from "./lib/rule-name.js";
630
635
  export {
631
- buildSearchRule,
632
636
  type DroppedFacet,
633
637
  type DroppedFacetType,
634
638
  isConvertible,
635
639
  type SearchConversion,
640
+ searchConversionNotice,
636
641
  } from "./lib/search-rule.js";
637
642
  export {
638
643
  collapsibleDomain,
@@ -664,8 +669,13 @@ export {
664
669
  clauseWords,
665
670
  crossAccountDestinationReason,
666
671
  crossAccountRuleReason,
672
+ ESCALATED_MATCH_HINT,
673
+ ESCALATED_REVIEW_WARNING,
674
+ ESCALATED_SCOPE_FALLBACK,
675
+ escalatedMatchLabel,
667
676
  type MatchCount,
668
677
  type MatchDescription,
678
+ type MatchDoor,
669
679
  type MatchMode,
670
680
  matchDoorHint,
671
681
  matchDoorLabel,
@@ -1,9 +1,9 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
3
  import {
4
- buildSearchRule,
5
4
  isConvertible,
6
5
  type SearchConversion,
6
+ searchConversionNotice,
7
7
  } from "./search-rule.js";
8
8
 
9
9
  const conversion = (
@@ -43,31 +43,32 @@ describe("isConvertible", () => {
43
43
  });
44
44
  });
45
45
 
46
- describe("buildSearchRule", () => {
47
- it("builds a standing rule with stable clause ids, no widen, empty name", () => {
48
- const rule = buildSearchRule(
46
+ describe("searchConversionNotice", () => {
47
+ it("names the folder the search was scoped to and every facet dropped", () => {
48
+ const notice = searchConversionNotice(
49
49
  conversion({
50
- clauses: [
51
- { field: "From", value: "a@b.com" },
52
- { field: "HasWords", value: "nightly" },
53
- ],
50
+ clauses: [{ field: "HasWords", value: "npm" }],
51
+ keptTerms: true,
52
+ scopedOut: { mailboxId: "mbx-archive", label: "Archive" },
53
+ droppedFacets: [{ type: "isUnread", label: "Unread" }],
54
+ droppedSemantic: true,
54
55
  }),
55
56
  );
56
- assert.equal(rule.scope, "standing");
57
- assert.equal(rule.widen, undefined);
58
- assert.equal(rule.name, "");
59
- assert.deepEqual(
60
- rule.clauses.map((clause) => clause.id),
61
- ["search-0", "search-1"],
62
- );
57
+ assert.deepEqual(notice, {
58
+ scopedOutFolder: "Archive",
59
+ droppedFacets: ["Unread"],
60
+ droppedSemantic: true,
61
+ });
63
62
  });
64
63
 
65
- it("honors an explicit scope and move target", () => {
66
- const rule = buildSearchRule(
67
- conversion({ clauses: [{ field: "HasWords", value: "nightly" }] }),
68
- { scope: "once", moveMailboxId: "mbx-archive" },
64
+ it("states nothing for a conversion that carried everything", () => {
65
+ const notice = searchConversionNotice(
66
+ conversion({ clauses: [{ field: "From", value: "a@b.com" }] }),
69
67
  );
70
- assert.equal(rule.scope, "once");
71
- assert.equal(rule.moveMailboxId, "mbx-archive");
68
+ assert.deepEqual(notice, {
69
+ scopedOutFolder: undefined,
70
+ droppedFacets: [],
71
+ droppedSemantic: false,
72
+ });
72
73
  });
73
74
  });
@@ -1,16 +1,12 @@
1
1
  /**
2
- * What a search converts to, and the rule built from it (RFC 038 D5). The shape
3
- * carries the clauses alongside everything the search held that a filter cannot,
4
- * so a conversion can never drop a facet without saying so; `search-conversion.ts`
5
- * beside it owns the copy that states it.
2
+ * What a search converts to (RFC 038 D5). The shape carries the clauses
3
+ * alongside everything the search held that a filter cannot, so a conversion can
4
+ * never drop a facet without saying so; `search-conversion.ts` beside it owns
5
+ * the copy that states it.
6
6
  */
7
7
 
8
- import type {
9
- ClauseField,
10
- FilterRule,
11
- MatchOperator,
12
- RuleScope,
13
- } from "../components/filter-rule.js";
8
+ import type { ClauseField, MatchOperator } from "../components/filter-rule.js";
9
+ import type { SearchConversionNotice } from "../components/search-conversion.js";
14
10
 
15
11
  /**
16
12
  * A search facet a filter has no clause for. Attachment, read state, starred,
@@ -68,29 +64,15 @@ export interface SearchConversion {
68
64
  export const isConvertible = (conversion: SearchConversion): boolean =>
69
65
  conversion.clauses.length > 0;
70
66
 
71
- interface BuildRuleOptions {
72
- scope?: RuleScope;
73
- moveMailboxId?: string;
74
- }
75
-
76
67
  /**
77
- * The rule the editor opens on, from a conversion. A search-derived rule defaults
78
- * to a standing filter "make this a filter" is a request to keep applying it —
79
- * and the editor lets the user drop it back to a one-time apply. It carries no
80
- * widen: a free-text query has no message anchor, so the semantic chip is not
81
- * offered on this surface (its loss is stated in the conversion notice instead).
68
+ * The conversion as the notice reads it. One mapping, beside both shapes, so
69
+ * every surface that opens on a converted search states the same losses in the
70
+ * same words.
82
71
  */
83
- export const buildSearchRule = (
72
+ export const searchConversionNotice = (
84
73
  conversion: SearchConversion,
85
- { scope = "standing", moveMailboxId }: BuildRuleOptions = {},
86
- ): FilterRule => ({
87
- clauses: conversion.clauses.map((clause, index) => ({
88
- id: `search-${index}`,
89
- field: clause.field,
90
- value: clause.value,
91
- })),
92
- matchOperator: conversion.matchOperator,
93
- moveMailboxId,
94
- scope,
95
- name: "",
74
+ ): SearchConversionNotice => ({
75
+ scopedOutFolder: conversion.scopedOut?.label,
76
+ droppedFacets: conversion.droppedFacets.map((facet) => facet.label),
77
+ droppedSemantic: conversion.droppedSemantic,
96
78
  });
@@ -9,6 +9,9 @@ import {
9
9
  backExits,
10
10
  clauseSentence,
11
11
  clauseWords,
12
+ ESCALATED_MATCH_HINT,
13
+ ESCALATED_REVIEW_WARNING,
14
+ escalatedMatchLabel,
12
15
  type MatchCount,
13
16
  type MatchMode,
14
17
  matchDoorHint,
@@ -30,7 +33,7 @@ import {
30
33
  } from "./wizard-steps.js";
31
34
 
32
35
  const VERBS: Verb[] = ["delete", "move", "junk", "markRead", "organize"];
33
- const MODES: MatchMode[] = ["selected", "similar", "properties"];
36
+ const MODES: MatchMode[] = ["selected", "similar", "properties", "escalated"];
34
37
  const SCOPES: (RuleScope | undefined)[] = [
35
38
  undefined,
36
39
  "once",
@@ -475,6 +478,7 @@ describe("runCopy", () => {
475
478
  "backApplyFailed",
476
479
  "backApplyStartFailed",
477
480
  "filterSaved",
481
+ "runStopped",
478
482
  "commitFailed",
479
483
  ];
480
484
  for (const state of states) {
@@ -499,6 +503,23 @@ describe("runCopy", () => {
499
503
  assert.doesNotMatch(once.detail, /rule/);
500
504
  });
501
505
 
506
+ it("separates a run that stopped from a pass the server rejected", () => {
507
+ // The bulk endpoints accept every id in a call that returns, so the only
508
+ // failure the chunked runner can observe is a call that threw — and
509
+ // everything after it was never sent. Saying the mail server rejected
510
+ // those states a cause that did not happen.
511
+ const stopped = outcome("runStopped", "once");
512
+ assert.equal(stopped.title, "Stopped after 10");
513
+ assert.match(stopped.detail, /nothing was sent for them/);
514
+ assert.doesNotMatch(stopped.detail, /rejected/);
515
+ assert.equal(stopped.retryLabel, "Retry 2");
516
+ assert.equal(stopped.showProgress, true);
517
+
518
+ // The back-apply pass is run by the server, which reports what it could
519
+ // not apply — a rejection, and worded as one.
520
+ assert.match(outcome("backApplyFailed", "once").detail, /rejected/);
521
+ });
522
+
502
523
  it("ends a one-off run on its own count and a saved rule on the rule", () => {
503
524
  assert.equal(outcome("backApplyComplete", "once").title, "Moved 10");
504
525
  assert.equal(
@@ -585,6 +606,15 @@ describe("sample and door vocabulary", () => {
585
606
  assert.match(sampleEmptyCopy("notIndexed"), /isn't indexed yet/);
586
607
  });
587
608
 
609
+ it("states what an escalated predicate covers instead of a door", () => {
610
+ assert.equal(
611
+ escalatedMatchLabel('matching "npm"'),
612
+ 'Every message matching "npm"',
613
+ );
614
+ assert.match(ESCALATED_MATCH_HINT, /nothing to widen/);
615
+ assert.match(ESCALATED_REVIEW_WARNING, /by the time it runs/);
616
+ });
617
+
588
618
  it("names each door and what it does", () => {
589
619
  assert.equal(matchDoorLabel("selected", 3), "These 3 messages");
590
620
  assert.equal(matchDoorLabel("similar", 3), "Similar to these 3");
@@ -661,6 +691,55 @@ describe("the match as words", () => {
661
691
  );
662
692
  });
663
693
 
694
+ it("summarises an escalated predicate by what it covers", () => {
695
+ assert.equal(
696
+ matchSummary({
697
+ mode: "escalated",
698
+ selectedCount: 4,
699
+ clauses,
700
+ matchOperator: "all",
701
+ escalatedScope: 'matching "npm"',
702
+ }),
703
+ 'Every message matching "npm"',
704
+ );
705
+ assert.equal(
706
+ matchSummary({
707
+ mode: "escalated",
708
+ selectedCount: 4,
709
+ clauses,
710
+ matchOperator: "all",
711
+ }),
712
+ "Every message the list is showing",
713
+ );
714
+ });
715
+
716
+ it("phrases an escalated predicate with the count the server gave it", () => {
717
+ assert.equal(
718
+ matchPhrase({
719
+ mode: "escalated",
720
+ selectedCount: 4,
721
+ clauses,
722
+ matchOperator: "all",
723
+ escalatedScope: 'matching "npm"',
724
+ escalatedCount: 3412,
725
+ }),
726
+ 'all 3,412 messages matching "npm"',
727
+ );
728
+ });
729
+
730
+ it("claims no number while the count is still being taken", () => {
731
+ assert.equal(
732
+ matchPhrase({
733
+ mode: "escalated",
734
+ selectedCount: 4,
735
+ clauses,
736
+ matchOperator: "all",
737
+ escalatedScope: 'matching "npm"',
738
+ }),
739
+ 'every message matching "npm"',
740
+ );
741
+ });
742
+
664
743
  it("phrases each door for the review sentence", () => {
665
744
  assert.equal(
666
745
  matchPhrase({