@remit/web-client 0.0.200 → 0.0.201

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,230 @@
1
+ /**
2
+ * What the run screen's controls decide (#521, #526).
3
+ *
4
+ * The wizard host wires routing, history and several data hooks together, so
5
+ * the decision is taken out of it and asserted by its result — the same move
6
+ * `runEndingBanner` made for the completion banner. What the run screen then
7
+ * shows for each state is `@remit/ui`'s `selection-wizard.render.test.ts`, and
8
+ * the poll a refresh re-issues is `../../hooks/useOrganizeJob.render.test.ts`.
9
+ */
10
+
11
+ import assert from "node:assert/strict";
12
+ import { describe, it } from "node:test";
13
+ import type { BulkActionTarget, BulkRunOutcome } from "@/lib/bulk-actions";
14
+ import {
15
+ type RetryContext,
16
+ retryIntent,
17
+ runIsInFlight,
18
+ stopRunner,
19
+ wizardExit,
20
+ } from "./selection-wizard-run-controls";
21
+
22
+ const target = (id: string): BulkActionTarget => ({ id, accountId: "acc-1" });
23
+
24
+ const context = (over: Partial<RetryContext> = {}): RetryContext => ({
25
+ runState: "backApplyComplete",
26
+ isEscalated: false,
27
+ committedScope: "just-these",
28
+ createFilterFailed: false,
29
+ backApplyPending: false,
30
+ widenRunsAsJob: false,
31
+ failedIds: [],
32
+ sent: [target("m1"), target("m2")],
33
+ ...over,
34
+ });
35
+
36
+ describe("retrying a run whose status could not be read", () => {
37
+ it("looks at the job it already has rather than queuing a second pass", () => {
38
+ assert.deepEqual(retryIntent(context({ runState: "statusUnknown" })), {
39
+ kind: "refreshStatus",
40
+ });
41
+ });
42
+
43
+ // The back-apply is still running on the server, so every other branch —
44
+ // each of which starts something — has to lose to this one.
45
+ for (const over of [
46
+ { isEscalated: true },
47
+ { committedScope: "standing" as const, backApplyPending: true },
48
+ { committedScope: "temporary" as const, createFilterFailed: true },
49
+ { committedScope: "all-like-these" as const, widenRunsAsJob: true },
50
+ { committedScope: "just-these" as const },
51
+ ]) {
52
+ it(`wins over ${JSON.stringify(over)}`, () => {
53
+ assert.deepEqual(
54
+ retryIntent(context({ ...over, runState: "statusUnknown" })).kind,
55
+ "refreshStatus",
56
+ );
57
+ });
58
+ }
59
+ });
60
+
61
+ describe("retrying an escalated predicate", () => {
62
+ it("re-resolves the predicate rather than resuming a page list", () => {
63
+ assert.deepEqual(retryIntent(context({ isEscalated: true })), {
64
+ kind: "rerunEscalated",
65
+ });
66
+ });
67
+ });
68
+
69
+ describe("retrying a filter that was being saved", () => {
70
+ it("clears the failed create before sending the same commit again", () => {
71
+ assert.deepEqual(
72
+ retryIntent(
73
+ context({ committedScope: "standing", createFilterFailed: true }),
74
+ ),
75
+ { kind: "resetAndResend" },
76
+ );
77
+ });
78
+
79
+ it("starts the pass over existing mail when the create landed", () => {
80
+ assert.deepEqual(
81
+ retryIntent(
82
+ context({ committedScope: "temporary", backApplyPending: true }),
83
+ ),
84
+ { kind: "startBackApply" },
85
+ );
86
+ });
87
+
88
+ it("starts nothing for a saved filter with no pass behind it", () => {
89
+ assert.deepEqual(retryIntent(context({ committedScope: "standing" })), {
90
+ kind: "waitOnJob",
91
+ });
92
+ });
93
+ });
94
+
95
+ describe("retrying a widened match", () => {
96
+ it("re-sends the commit when the widen runs as a job", () => {
97
+ assert.deepEqual(
98
+ retryIntent(
99
+ context({ committedScope: "all-like-these", widenRunsAsJob: true }),
100
+ ),
101
+ { kind: "resend" },
102
+ );
103
+ });
104
+
105
+ it("re-sends the resolved ids when it does not", () => {
106
+ assert.deepEqual(
107
+ retryIntent(context({ committedScope: "all-like-these" })),
108
+ { kind: "rerunBulk", targets: [target("m1"), target("m2")] },
109
+ );
110
+ });
111
+ });
112
+
113
+ describe("retrying a bounded run", () => {
114
+ it("re-sends only what the run never reached", () => {
115
+ assert.deepEqual(
116
+ retryIntent(
117
+ context({ sent: [target("m1"), target("m2")], failedIds: ["m2"] }),
118
+ ),
119
+ { kind: "rerunBulk", targets: [target("m2")] },
120
+ );
121
+ });
122
+
123
+ // A run that threw before it handed anything back reports no failed ids, and
124
+ // re-sending nothing is a retry that silently does nothing at all.
125
+ it("re-sends everything when the run named no id it missed", () => {
126
+ assert.deepEqual(retryIntent(context()), {
127
+ kind: "rerunBulk",
128
+ targets: [target("m1"), target("m2")],
129
+ });
130
+ });
131
+
132
+ it("keeps each id's own account, so a cross-account retry still batches", () => {
133
+ const sent = [
134
+ { id: "m1", accountId: "acc-work" },
135
+ { id: "m2", accountId: "acc-personal" },
136
+ ];
137
+ assert.deepEqual(retryIntent(context({ sent, failedIds: ["m2"] })), {
138
+ kind: "rerunBulk",
139
+ targets: [{ id: "m2", accountId: "acc-personal" }],
140
+ });
141
+ });
142
+ });
143
+
144
+ describe("whether the run screen offers a way to end the run", () => {
145
+ const outcome: BulkRunOutcome = {
146
+ done: 2,
147
+ failedIds: [],
148
+ cancelled: false,
149
+ };
150
+
151
+ it("offers nothing before a commit has been sent", () => {
152
+ assert.equal(runIsInFlight(undefined), false);
153
+ });
154
+
155
+ it("offers it while the runner is still paging", () => {
156
+ assert.equal(runIsInFlight({}), true);
157
+ });
158
+
159
+ it("withdraws it once the runner has handed back an outcome", () => {
160
+ assert.equal(runIsInFlight({ outcome }), false);
161
+ });
162
+
163
+ // Nothing was sent, so there is nothing paging to end — the screen carries
164
+ // why instead, and a control claiming to stop a run would stop nothing.
165
+ it("withdraws it for a commit that could not start", () => {
166
+ assert.equal(runIsInFlight({ failureReason: "no Junk folder" }), false);
167
+ });
168
+ });
169
+
170
+ describe("which runner the stop reaches", () => {
171
+ const spies = () => {
172
+ const called: string[] = [];
173
+ return {
174
+ called,
175
+ escalated: { stop: () => called.push("escalated") },
176
+ stopBulk: () => called.push("bulk"),
177
+ };
178
+ };
179
+
180
+ it("stops the list's runner when the selection is a predicate", () => {
181
+ const { called, escalated, stopBulk } = spies();
182
+ stopRunner(escalated, stopBulk);
183
+ assert.deepEqual(called, ["escalated"]);
184
+ });
185
+
186
+ // A bounded select-all runs on the wizard's own runner and reaches the same
187
+ // control, so a stop that only knows about the predicate leaves that button
188
+ // on screen doing nothing at all.
189
+ it("stops the wizard's own runner for a bounded selection", () => {
190
+ const { called, stopBulk } = spies();
191
+ stopRunner(undefined, stopBulk);
192
+ assert.deepEqual(called, ["bulk"]);
193
+ });
194
+ });
195
+
196
+ describe("which movement the wizard's exit is", () => {
197
+ const movements = () => {
198
+ const called: string[] = [];
199
+ return {
200
+ called,
201
+ handlers: {
202
+ dismiss: () => called.push("dismiss"),
203
+ cancel: () => called.push("cancel"),
204
+ },
205
+ };
206
+ };
207
+
208
+ it("walks away from the run on the run screen, leaving it going", () => {
209
+ const { called, handlers } = movements();
210
+ wizardExit("run", handlers)();
211
+ assert.deepEqual(called, ["dismiss"]);
212
+ });
213
+
214
+ // Rewinding the entries the wizard owns is only safe before anything is
215
+ // sent; taken off the run screen it offers the commit a second time.
216
+ for (const step of [
217
+ "match",
218
+ "properties",
219
+ "folder",
220
+ "rule",
221
+ "name",
222
+ "review",
223
+ ] as const) {
224
+ it(`rewinds the walk from ${step}`, () => {
225
+ const { called, handlers } = movements();
226
+ wizardExit(step, handlers)();
227
+ assert.deepEqual(called, ["cancel"]);
228
+ });
229
+ }
230
+ });
@@ -0,0 +1,101 @@
1
+ import type { RunState, StepId } from "@remit/ui";
2
+ import type { BulkActionTarget, BulkRunOutcome } from "@/lib/bulk-actions";
3
+ import type { OrganizeScope } from "@/lib/organize/organize-model";
4
+
5
+ export interface RetryContext {
6
+ runState: RunState;
7
+ isEscalated: boolean;
8
+ committedScope: OrganizeScope | undefined;
9
+ createFilterFailed: boolean;
10
+ backApplyPending: boolean;
11
+ widenRunsAsJob: boolean;
12
+ failedIds: readonly string[];
13
+ sent: readonly BulkActionTarget[];
14
+ }
15
+
16
+ export type RetryIntent =
17
+ | { kind: "refreshStatus" }
18
+ | { kind: "rerunEscalated" }
19
+ | { kind: "resetAndResend" }
20
+ | { kind: "resend" }
21
+ | { kind: "startBackApply" }
22
+ | { kind: "waitOnJob" }
23
+ | { kind: "rerunBulk"; targets: readonly BulkActionTarget[] };
24
+
25
+ /**
26
+ * What pressing the run screen's retry asks for, given where the commit got to.
27
+ *
28
+ * A back-apply whose status could not be read is still running on the server
29
+ * (#526), so that one is decided before anything that could queue a second pass
30
+ * over the same mail.
31
+ */
32
+ export const retryIntent = (context: RetryContext): RetryIntent => {
33
+ if (context.runState === "statusUnknown") return { kind: "refreshStatus" };
34
+ // The predicate is re-resolved, not resumed: every verb it carries is
35
+ // idempotent, so the messages the first pass already reached are a no-op.
36
+ if (context.isEscalated) return { kind: "rerunEscalated" };
37
+ if (
38
+ context.committedScope === "standing" ||
39
+ context.committedScope === "temporary"
40
+ ) {
41
+ if (context.createFilterFailed) return { kind: "resetAndResend" };
42
+ return context.backApplyPending
43
+ ? { kind: "startBackApply" }
44
+ : { kind: "waitOnJob" };
45
+ }
46
+ if (context.committedScope === "all-like-these" && context.widenRunsAsJob) {
47
+ return { kind: "resend" };
48
+ }
49
+ // A run hands back ids, and each one's account came from the batch it was
50
+ // sent in — so a retry re-sends the targets the run was given, filtered to
51
+ // what it never reached.
52
+ const outstanding = new Set(context.failedIds);
53
+ return {
54
+ kind: "rerunBulk",
55
+ targets:
56
+ outstanding.size > 0
57
+ ? context.sent.filter((target) => outstanding.has(target.id))
58
+ : context.sent,
59
+ };
60
+ };
61
+
62
+ /**
63
+ * What a commit has left paging. A commit that never started, one whose runner
64
+ * has handed back its outcome, and one that could not start at all all have
65
+ * nothing to end — so the run screen offers no control that would claim to end
66
+ * them (#521).
67
+ */
68
+ export const runIsInFlight = (
69
+ bulkRun: { outcome?: BulkRunOutcome; failureReason?: string } | undefined,
70
+ ): boolean =>
71
+ bulkRun !== undefined &&
72
+ bulkRun.outcome === undefined &&
73
+ bulkRun.failureReason === undefined;
74
+
75
+ /**
76
+ * Ends the run rather than leaving it. An escalated predicate is paged by the
77
+ * list's own runner and a bounded selection by the wizard's, so the stop has to
78
+ * follow whichever one is paging: a stop that reaches only one of them leaves
79
+ * the other's control on screen doing nothing.
80
+ */
81
+ export const stopRunner = (
82
+ escalated: { stop: () => void } | undefined,
83
+ stopBulk: () => void,
84
+ ): void => {
85
+ if (escalated) {
86
+ escalated.stop();
87
+ return;
88
+ }
89
+ stopBulk();
90
+ };
91
+
92
+ /**
93
+ * Which of the two movements the wizard's exit is, on the step it is taken
94
+ * from. Everywhere but the run screen it rewinds the entries the wizard owns
95
+ * and leaves the selection where it was; on the run screen the action has
96
+ * already been sent, so leaving walks away from a run that keeps going.
97
+ */
98
+ export const wizardExit = (
99
+ step: StepId,
100
+ movements: { dismiss: () => void; cancel: () => void },
101
+ ): (() => void) => (step === "run" ? movements.dismiss : movements.cancel);
@@ -1,51 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { readFileSync } from "node:fs";
3
- import { dirname, resolve } from "node:path";
4
- import { describe, it } from "node:test";
5
- import { fileURLToPath } from "node:url";
6
-
7
- /**
8
- * The phone brief must mount the same selection surface the multi-pane list
9
- * does (#203). `BriefPane` wires the bulk verbs and the selection bar into
10
- * `DailyBrief` from the `BriefList` slot; the phone view used to render
11
- * `DailyBrief` directly, without `onDeleteMessages`, so a selection made in the
12
- * brief on a phone raised no action bar — selectable, but nothing to act with.
13
- *
14
- * As with `MessageList.selection.test.ts`, `BriefPane` weaves routing and
15
- * several data hooks together, so the rule is enforced by reading the source:
16
- * the phone view reuses the list slot rather than a bare `DailyBrief`.
17
- */
18
-
19
- const here = dirname(fileURLToPath(import.meta.url));
20
- const source = readFileSync(resolve(here, "BriefPane.tsx"), "utf8");
21
-
22
- const briefPhoneBody = (): string => {
23
- const start = source.indexOf("function BriefPhone(");
24
- assert.notEqual(start, -1, "BriefPhone is defined");
25
- const next = source.indexOf("\nfunction ", start + 1);
26
- return source.slice(start, next === -1 ? undefined : next);
27
- };
28
-
29
- describe("BriefPane phone selection surface (#203)", () => {
30
- it("the phone brief reuses the list slot, not a bare DailyBrief", () => {
31
- const body = briefPhoneBody();
32
- assert.match(
33
- body,
34
- /<BriefList \/>/,
35
- "phone brief renders the list slot that wires the bulk verbs",
36
- );
37
- assert.doesNotMatch(
38
- body,
39
- /<DailyBrief\b/,
40
- "phone brief must not render DailyBrief directly — that drops onDeleteMessages and the selection bar with it",
41
- );
42
- });
43
-
44
- it("the list slot hands DailyBrief the delete verb the selection bar gates on", () => {
45
- const start = source.indexOf("function BriefList(");
46
- assert.notEqual(start, -1, "BriefList is defined");
47
- const next = source.indexOf("\nfunction ", start + 1);
48
- const body = source.slice(start, next === -1 ? undefined : next);
49
- assert.match(body, /onDeleteMessages=\{onDeleteMessages\}/);
50
- });
51
- });
@@ -1,75 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { readFileSync } from "node:fs";
3
- import { dirname, resolve } from "node:path";
4
- import { describe, it } from "node:test";
5
- import { fileURLToPath } from "node:url";
6
-
7
- /**
8
- * The brief raises one selection surface at every width, and select-all is on
9
- * it — wired into the touch sheet alone, the same list could be selected whole
10
- * on a phone and not on a desktop (#453).
11
- *
12
- * As with `MessageList.selection.test.ts`, `DailyBrief` weaves routing, the
13
- * cursor and several data hooks together, so the rule is enforced by reading
14
- * the source. `resolveBriefSelectionScope` carries the decisions that can be
15
- * unit-tested on their own.
16
- */
17
-
18
- const here = dirname(fileURLToPath(import.meta.url));
19
- const source = readFileSync(resolve(here, "DailyBrief.tsx"), "utf8");
20
-
21
- const chromeBody = (): string => {
22
- const start = source.indexOf("function BriefSelectionChrome(");
23
- assert.notEqual(start, -1, "BriefSelectionChrome is defined");
24
- const next = source.indexOf("\nfunction ", start + 1);
25
- return source.slice(start, next === -1 ? undefined : next);
26
- };
27
-
28
- describe("the brief's select-all", () => {
29
- it("reaches the one selection bar from one derivation", () => {
30
- const body = chromeBody();
31
- const derivations = body.match(/const selectAll = useMemo\(/g) ?? [];
32
- assert.equal(derivations.length, 1, "one select-all");
33
- const bars = body.match(/<SelectionTopBar\b/g) ?? [];
34
- assert.equal(bars.length, 1, "one surface, at every width (#480)");
35
- const wired = body.match(/selectAll=\{selectAll\}/g) ?? [];
36
- assert.equal(wired.length, 1, "the bar takes it");
37
- });
38
-
39
- it("is offered only when there are rows to select", () => {
40
- assert.match(chromeBody(), /orderedIds\.length > 0/);
41
- });
42
-
43
- it("mounts the bar unconditionally — it is the list header (#480)", () => {
44
- assert.match(
45
- chromeBody(),
46
- /const selectionBar = \(chrome: ListHeaderChrome\) => \(\s*<SelectionTopBar/,
47
- );
48
- });
49
-
50
- it("carries every verb the bar can offer (#477 1.4)", () => {
51
- // That each of them opens the wizard is `selection-verbs.test.ts`, over
52
- // every surface at once. What this pins is that none of them was dropped
53
- // from the bar the brief raises.
54
- const body = chromeBody();
55
- for (const prop of [
56
- "onDelete",
57
- "onMove",
58
- "onOrganize",
59
- "onJunk",
60
- "onMarkRead",
61
- ]) {
62
- assert.match(body, new RegExp(`${prop}=`));
63
- }
64
- assert.doesNotMatch(body, /onSomethingElse/);
65
- });
66
-
67
- it("drives the wizard and the keyboard layer from one control", () => {
68
- // Two copies of "which verb is the wizard on" is how the brief came to
69
- // offer a verb the mailbox list did not, and how its keyboard came to act
70
- // behind a wizard the bar had opened.
71
- assert.match(source, /const wizard = useSelectionWizard\(\);/);
72
- assert.match(source, /onSelectionVerb={wizard.start}/);
73
- assert.match(source, /wizardOpen={wizard.isOpen}/);
74
- });
75
- });
@@ -1,195 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { readFileSync } from "node:fs";
3
- import { dirname, resolve } from "node:path";
4
- import { describe, it } from "node:test";
5
- import { fileURLToPath } from "node:url";
6
-
7
- /**
8
- * Selection mode has one source of truth and one exit (#115). `MessageList`
9
- * wires the DOM, the virtualizer, routing and several data hooks together, so
10
- * — as with this package's other component-level rules (see
11
- * `../../index.css.test.ts`) — the rule is enforced by reading the source
12
- * rather than rendering the tree. The decisions themselves are unit-tested in
13
- * `../../lib/selection-mode.test.ts`.
14
- */
15
-
16
- const here = dirname(fileURLToPath(import.meta.url));
17
- const source = readFileSync(resolve(here, "MessageList.tsx"), "utf8");
18
-
19
- describe("MessageList selection mode", () => {
20
- it("keeps no selection-mode flag of its own", () => {
21
- assert.doesNotMatch(source, /useState[^\n]*[iI]sMultiSelectMode/);
22
- assert.doesNotMatch(source, /setIsMultiSelectMode/);
23
- });
24
-
25
- it("derives the mode from the selection count", () => {
26
- assert.match(
27
- source,
28
- /const isMultiSelectMode = deriveIsMultiSelectMode\(\s*selectedCount,/,
29
- );
30
- });
31
-
32
- it("calls the selection hook's clearSelection from exactly one place", () => {
33
- const calls = source.match(/\bclearSelection\(\)/g) ?? [];
34
- assert.equal(calls.length, 1);
35
- assert.match(
36
- source,
37
- /const exitSelection = useCallback\(\(\) => \{[^}]*clearSelection\(\);/,
38
- );
39
- });
40
-
41
- it("exits selection when the mailbox changes", () => {
42
- assert.match(source, /previousMailboxIdRef\.current === mailboxId/);
43
- });
44
-
45
- it("turns the back gesture into an exit, and only while selecting", () => {
46
- assert.match(
47
- source,
48
- /shouldExitSelectionOnNavigate\(action, hasSelection, wizardStep\)/,
49
- );
50
- assert.match(source, /disabled: !hasSelection/);
51
- });
52
-
53
- it("leaves the back gesture to the wizard while the wizard is open", () => {
54
- assert.match(
55
- source,
56
- /disabled: !hasSelection \|\| wizardStep !== undefined/,
57
- );
58
- });
59
- });
60
-
61
- /**
62
- * An escalated selection is a predicate, and it walks the same wizard every
63
- * other selection walks (#508). The list hands the wizard the predicate — the
64
- * words it names it with, the count it was escalated to, and the chunked runner
65
- * that re-resolves it — and keeps no second confirmation of its own.
66
- */
67
- describe("MessageList escalated actions", () => {
68
- it("hands the escalated predicate to the wizard rather than running it here", () => {
69
- assert.match(
70
- source,
71
- /const escalatedSelection: EscalatedSelection \| undefined =\s*escalation\.phase\.kind === "escalated"/,
72
- );
73
- assert.match(
74
- source,
75
- /scope: describeSearchScope\(searchPredicate \?\? \{\}\)/,
76
- );
77
- assert.match(source, /total: escalation\.phase\.total/);
78
- assert.match(
79
- source,
80
- /run: \(action: EscalatedAction\) => escalation\.runAction\(action\)/,
81
- );
82
- const host = source.match(/<SelectionWizardHost[\s\S]*?\/>/)?.[0] ?? "";
83
- assert.match(host, /escalated=\{escalatedSelection\}/);
84
- });
85
-
86
- it("keeps no predicate confirmation of its own", () => {
87
- // The review screen names what the predicate covers; a second dialog
88
- // asking the same question is the drift the wizard exists to end.
89
- assert.doesNotMatch(source, /source: "predicate"/);
90
- assert.doesNotMatch(source, /requestEscalatedDelete/);
91
- assert.doesNotMatch(source, /pendingDeleteIsEstimate/);
92
- });
93
-
94
- it("words the bar's progress per action instead of per delete", () => {
95
- assert.match(
96
- source,
97
- /bulkActionProgressLabel\(\s*escalation\.runningAction\.kind,/,
98
- );
99
- });
100
- });
101
-
102
- /**
103
- * Advanced selection is no longer mobile-only (#212): the escalation engine is
104
- * opened to every width, and the one selection bar renders the
105
- * offer → counting → escalated → progress → notice states.
106
- */
107
- describe("MessageList escalation reaches desktop (#212)", () => {
108
- it("no longer gates the escalation engine on the mobile viewport", () => {
109
- assert.match(
110
- source,
111
- /const escalationEnabled = isSearching && !!searchPredicate;/,
112
- );
113
- assert.doesNotMatch(
114
- source,
115
- /!isDesktop && isSearching && !!searchPredicate/,
116
- );
117
- });
118
-
119
- it("feeds the bar the shared escalation state", () => {
120
- // Up to the bar's own closing tag, at its own indent — a nested slot
121
- // element closes first and would cut the match short.
122
- const bar = source.match(/<SelectionTopBar[\s\S]*?\n\t\t\/>/)?.[0] ?? "";
123
- assert.match(bar, /statusLabel=\{selectionStatusLabel\}/);
124
- assert.match(bar, /notice=\{selectionNotice\}/);
125
- assert.match(bar, /progress=\{selectionProgress\}/);
126
- assert.match(bar, /selectAll=\{selectionSelectAll\}/);
127
- assert.match(bar, /isCounting=\{escalation\.phase\.kind === "counting"\}/);
128
- });
129
-
130
- it("raises one selection surface, at every width (#480)", () => {
131
- const bars = source.match(/<SelectionTopBar\b/g) ?? [];
132
- assert.equal(bars.length, 1, "one bar, so no two surfaces can drift");
133
- assert.doesNotMatch(source, /<SelectionSheet\b/);
134
- });
135
-
136
- it("mounts that surface unconditionally — it is the list header", () => {
137
- // Gated on a selection, the bar's zero-ticked state (the mailbox name,
138
- // and select-all inline from 768px up) reaches nothing.
139
- assert.match(source, /const activeSelectionBar = \(\s*<SelectionTopBar/);
140
- assert.match(source, /title=\{listHeaderChrome\.title \|\| listTitle\}/);
141
- assert.match(source, /navSlot=\{listHeaderChrome\.navSlot\}/);
142
- });
143
-
144
- it("carries every verb the bar can offer (#477 1.4)", () => {
145
- // That each of them opens the wizard is `selection-verbs.test.ts`, over
146
- // every surface at once. What this pins is that none of them was dropped
147
- // from the one bar the mailbox raises.
148
- const bar = source.match(/<SelectionTopBar[\s\S]*?\n\t\t\/>/)?.[0] ?? "";
149
- for (const prop of [
150
- "onDelete",
151
- "onMove",
152
- "onOrganize",
153
- "onJunk",
154
- "onMarkRead",
155
- ]) {
156
- assert.match(bar, new RegExp(`${prop}=`));
157
- }
158
- // The one selection surface has no second organize entry beside them.
159
- assert.doesNotMatch(source, /onSomethingElse/);
160
- });
161
-
162
- it("keeps Organize reachable over a predicate rather than dropping it", () => {
163
- // A verb that vanishes when a selection escalates leaves the user no route
164
- // at all: the make-filter affordance it defers to is hidden while rows are
165
- // ticked (#477 1.7).
166
- assert.match(source, /if \(escalatedSelection\) \{\s*startFromSearch\(\);/);
167
- });
168
-
169
- it("offers the label picker only when the account has labels", () => {
170
- assert.match(source, /labels\.length > 0 \? \(/);
171
- });
172
- });
173
-
174
- /**
175
- * A confirmed delete opens the surviving neighbour. On desktop that fills the
176
- * reading pane beside the list; on a single-pane mobile layout the same
177
- * navigation replaced the list with a full-screen message, so the delete read as
178
- * "jumped into a random message" rather than "the row is gone" (#202). Mobile
179
- * now stays on the list and raises a completion banner instead.
180
- */
181
- describe("MessageList confirmed delete stays on the list on mobile (#202)", () => {
182
- it("only the desktop two-pane opens the surviving neighbour after a delete", () => {
183
- assert.match(
184
- source,
185
- /if \(isDesktop\) \{\s*openRow\(nextFocus, \{ replace: true \}\);\s*\}/,
186
- );
187
- });
188
-
189
- it("raises a completion banner on mobile so the delete is not silent", () => {
190
- assert.match(
191
- source,
192
- /if \(!isDesktop\) \{\s*setCompletionBanner\(\s*bulkActionCompletionText\("delete", ids\.length, deleteOutcome\),?\s*\);\s*\}/,
193
- );
194
- });
195
- });