@remit/web-client 0.0.200 → 0.0.202

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.
@@ -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
- });
@@ -1,50 +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
- * A back-apply whose status could not be read is still running on the server
9
- * (#526), so the run screen's affordance looks at that job again rather than
10
- * queuing a second pass over the same mail.
11
- *
12
- * The host wires routing, history and several data hooks together, so — as with
13
- * this package's other component-level rules (see `SelectionWizardHost.run-exit.test.ts`)
14
- * — the wiring is read off the source. What it decides is proven where it can be
15
- * run: `./organize-run-state.test.ts` for the ending the screen shows, and
16
- * `../../hooks/useOrganizeJob.render.test.ts` for the poll it re-issues.
17
- */
18
-
19
- const here = dirname(fileURLToPath(import.meta.url));
20
- const source = readFileSync(resolve(here, "SelectionWizardHost.tsx"), "utf8");
21
-
22
- const retryBody = (): string => {
23
- const body = source.match(
24
- /const retry = \(\): void => \{([\s\S]*?)\n\t\};/,
25
- )?.[1];
26
- assert.ok(body, "the run screen has no retry");
27
- return body;
28
- };
29
-
30
- describe("retrying from an unknown status", () => {
31
- it("looks at the job it already has", () => {
32
- const body = retryBody();
33
- const unknown = body.indexOf('run.state === "statusUnknown"');
34
- assert.ok(unknown >= 0, "retry does not recognise an unknown status");
35
- assert.match(body.slice(unknown), /organizeJob\.refreshStatus\(\)/);
36
- });
37
-
38
- it("decides that before it can reach anything that queues a second job", () => {
39
- const body = retryBody();
40
- assert.ok(
41
- body.indexOf('run.state === "statusUnknown"') < body.indexOf("startJob("),
42
- "a second back-apply can be queued over a job that is still running",
43
- );
44
- });
45
-
46
- it("reads what the job is doing rather than that something failed", () => {
47
- assert.doesNotMatch(source, /organizeJob\.isError/);
48
- assert.match(source, /organizeRunState\(\{/);
49
- });
50
- });
@@ -1,83 +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
- * Every way off the run screen is the same movement, and none of them ends the
9
- * run the screen promised would keep going (#521). Ending it is a control of
10
- * its own, and whichever way the user leaves, the run reports where it got to.
11
- *
12
- * The host wires routing, history and several data hooks together, so — as with
13
- * this package's other component-level rules (see `MessageList.selection.test.ts`)
14
- * — the wiring is read off the source. What the exits then do is proven where it
15
- * can be run: `../../hooks/escalated-run-lifetime.render.test.ts` for the run's
16
- * lifetime, and `@remit/ui`'s `selection-wizard.render.test.ts` for the controls.
17
- */
18
-
19
- const here = dirname(fileURLToPath(import.meta.url));
20
- const source = readFileSync(resolve(here, "SelectionWizardHost.tsx"), "utf8");
21
-
22
- describe("leaving the run screen", () => {
23
- it("routes the header's X and back arrow through the same dismiss the footer uses", () => {
24
- assert.match(source, /onExit=\{current === "run" \? dismiss : cancel\}/);
25
- });
26
-
27
- it("routes hardware back through that dismiss too", () => {
28
- assert.match(
29
- source,
30
- /if \(action !== "BACK"\) return false;\s*\n\s*dismiss\(\);/,
31
- );
32
- });
33
-
34
- it("leaves the run alone on the way out", () => {
35
- const dismissBody = source.match(
36
- /const dismiss = useCallback\(\(\) => \{([\s\S]*?)\}, \[/,
37
- )?.[1];
38
- assert.ok(dismissBody, "the run screen has no dismiss");
39
- assert.doesNotMatch(dismissBody, /stop/i);
40
- });
41
- });
42
-
43
- describe("ending the run", () => {
44
- it("is a control of its own, offered only while a run is in flight", () => {
45
- assert.match(source, /onCancelRun: runInFlight \? stopRun : undefined/);
46
- assert.match(
47
- source,
48
- /const runInFlight =\s*bulkRun !== undefined &&\s*bulkRun\.outcome === undefined &&\s*bulkRun\.failureReason === undefined;/,
49
- );
50
- });
51
-
52
- it("stops whichever runner is paging — the list's or the wizard's own", () => {
53
- const stopBody = source.match(
54
- /const stopRun = useCallback\(\(\) => \{([\s\S]*?)\}, \[/,
55
- )?.[1];
56
- assert.ok(stopBody, "there is no way to stop a run");
57
- assert.match(stopBody, /escalated\.stop\(\)/);
58
- assert.match(stopBody, /stopBulk\(\)/);
59
- });
60
- });
61
-
62
- /**
63
- * Both runners report, not just the predicate one: a ticked select-all runs on
64
- * the wizard's own runner, and the same screen makes the same promise over it.
65
- */
66
- describe("stating how a run ended once the screen is gone", () => {
67
- it("marks the walk-away where it happens rather than reading it back off the URL", () => {
68
- assert.match(source, /walkedAway\.current = true;\s*\n\s*closeWizard\(/);
69
- });
70
-
71
- for (const runner of ["runBulk", "runEscalated"]) {
72
- it(`reports the run ${runner} drove`, () => {
73
- const body =
74
- source.match(
75
- new RegExp(
76
- `const ${runner} = useCallback\\(([\\s\\S]*?)\\n\\t\\}, \\[`,
77
- ),
78
- )?.[1] ?? "";
79
- assert.match(body, /if \(walkedAway\.current\)/);
80
- assert.match(body, /onRunEnded\?\.\(action\.kind, /);
81
- });
82
- }
83
- });
@@ -1,63 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { readdirSync, 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
- * "Make this a filter" is the wizard's second entry (#477 clause 1.8), and the
9
- * step it pushes has to be answered on the view that offered it.
10
- *
11
- * The affordance is built once by `MailListHeader` and handed down as
12
- * `makeFilterSlot`, so any surface can put it on screen — but the wizard is
13
- * mounted per surface, and a surface that renders the row without one leaves
14
- * the press on a URL nothing answers. That is the dead button clause 1.7 exists
15
- * to prevent, and it is invisible in review: the row and the host are two files
16
- * apart, and the affordance keeps working everywhere else.
17
- *
18
- * So the pairing is the assertion. A surface that renders the row names where
19
- * its step is answered, and that file is checked to mount a host. A fourth
20
- * surface fails here until it does the same.
21
- */
22
-
23
- const here = dirname(fileURLToPath(import.meta.url));
24
-
25
- /** How the chrome's slot is read — `MailListHeader` writes it, everyone else reads it. */
26
- const RENDERS_SLOT = /\.makeFilterSlot/;
27
- const MOUNTS_HOST = /<SelectionWizardHost/;
28
-
29
- /** Surface that renders the affordance → the file whose composition answers the step. */
30
- const ANSWERED_BY: Record<string, string> = {
31
- "DailyBrief.tsx": "DailyBrief.tsx",
32
- "MessageList.tsx": "MessageList.tsx",
33
- // The starred list's bar is shared with the brief's, so its host is mounted
34
- // by the view rather than beside the bar.
35
- "ThreadListInteraction.tsx": "FlaggedList.tsx",
36
- };
37
-
38
- const read = (file: string): string =>
39
- readFileSync(resolve(here, file), "utf8");
40
-
41
- const surfacesRenderingTheAffordance = (): string[] =>
42
- readdirSync(here)
43
- .filter((file) => file.endsWith(".tsx"))
44
- .filter((file) => RENDERS_SLOT.test(read(file)));
45
-
46
- describe("the make-filter entry", () => {
47
- it("is rendered only by surfaces that say where its step is answered", () => {
48
- assert.deepEqual(
49
- surfacesRenderingTheAffordance().sort(),
50
- Object.keys(ANSWERED_BY).sort(),
51
- );
52
- });
53
-
54
- it("is answered by a mounted wizard on every one of them", () => {
55
- for (const [surface, host] of Object.entries(ANSWERED_BY)) {
56
- assert.match(
57
- read(host),
58
- MOUNTS_HOST,
59
- `${surface} is answered by ${host}`,
60
- );
61
- }
62
- });
63
- });
@@ -1,285 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { readdirSync, 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
- * One invariant, over every surface: a verb aimed at a selection reaches the
9
- * mail server only through the wizard, whose review screen names what it covers
10
- * (#477 1.4 and its FAQ, #508).
11
- *
12
- * There are two ways to ask for one — the selection bar and the keyboard — and
13
- * three surfaces that raise a bar. Checked here rather than by a spec per
14
- * surface, because what has repeatedly gone wrong is not a broken route but a
15
- * missing one: a fourth surface, or a sixth verb, quietly keeping a direct
16
- * handler while every other route was moved. That is invisible in review and
17
- * indistinguishable from correct until someone selects 3,412 messages and
18
- * presses a key.
19
- *
20
- * These read the source rather than rendering, for the reason
21
- * `MessageList.selection.test.ts` gives: these components wire the DOM, the
22
- * router and several data hooks together, and the rule is about which function
23
- * a prop names.
24
- */
25
-
26
- const here = dirname(fileURLToPath(import.meta.url));
27
- const read = (file: string): string =>
28
- readFileSync(resolve(here, file), "utf8");
29
-
30
- /** Every verb the bar can carry — the props `SelectionTopBar` accepts. */
31
- const BAR_VERB_PROPS = [
32
- "onDelete",
33
- "onMove",
34
- "onOrganize",
35
- "onJunk",
36
- "onMarkRead",
37
- ] as const;
38
-
39
- /**
40
- * How a surface is allowed to answer a verb prop. `startWizard`/`wizard.start`/
41
- * `startSelectionVerb` all open the wizard on the verb; `organizeSelection` and
42
- * `startFromSearch` open it on the search entry, which is the same wizard
43
- * reached by its other door (#477 1.8). Anything else is a route around the
44
- * review screen.
45
- */
46
- const OPENS_THE_WIZARD =
47
- /^(startWizard|wizard\.start|startSelectionVerb|organizeSelection|startFromSearch)\b/;
48
-
49
- const surfaceFiles = (): string[] =>
50
- readdirSync(here)
51
- .filter((file) => file.endsWith(".tsx"))
52
- .filter((file) => /<SelectionTopBar/.test(read(file)));
53
-
54
- /**
55
- * The bar element as written, to its own closing tag. Found by matching tags
56
- * rather than by stopping at the first `/>` on a line: a nested self-closing
57
- * element matches that too, so the scan would end early and silently skip
58
- * whatever a surface happened to write after it.
59
- */
60
- const barMarkup = (source: string): string => {
61
- const start = source.indexOf("<SelectionTopBar");
62
- if (start === -1) return "";
63
- let depth = 0;
64
- for (let i = start; i < source.length; i++) {
65
- if (source[i] === "<" && /[A-Za-z]/.test(source[i + 1] ?? "")) depth++;
66
- else if (source.startsWith("/>", i)) {
67
- depth--;
68
- if (depth === 0) return source.slice(start, i + 2);
69
- i++;
70
- } else if (source.startsWith("</", i)) depth--;
71
- }
72
- return "";
73
- };
74
-
75
- /** The body of `const <name> = useCallback(…)`, or of an inline arrow value. */
76
- const bracedFrom = (source: string, open: number): string => {
77
- let depth = 0;
78
- for (let i = open; i < source.length; i++) {
79
- if (source[i] === "{") depth++;
80
- else if (source[i] === "}") {
81
- depth--;
82
- if (depth === 0) return source.slice(open, i + 1);
83
- }
84
- }
85
- return source.slice(open);
86
- };
87
-
88
- const namedCallbackBody = (source: string, name: string): string => {
89
- const at = source.indexOf(`const ${name} = useCallback(`);
90
- if (at === -1) return "";
91
- const open = source.indexOf("{", at);
92
- return open === -1 ? "" : bracedFrom(source, open);
93
- };
94
-
95
- /** The triage handlers a pane wires into the keyboard layer, by name. */
96
- const declaredHandlers = (source: string): string[] => {
97
- const at = source.indexOf("handlers: {");
98
- if (at === -1) return [];
99
- return Array.from(
100
- bracedFrom(source, source.indexOf("{", at)).matchAll(/\n\t{3}(\w+): /g),
101
- (match) => match[1],
102
- );
103
- };
104
-
105
- /**
106
- * What a pane does for one keyboard verb: the handler it wired into the triage
107
- * layer, plus any callback that handler defers to for its target. A handler
108
- * that offers the list the press and then falls through to a helper picking the
109
- * selection is exactly as wrong as one picking it inline, so both are read.
110
- */
111
- const paneVerbBody = (source: string, handler: string): string => {
112
- const at = source.search(new RegExp(`\\n\\t{3}${handler}: `));
113
- if (at === -1) return "";
114
- const value =
115
- source.slice(at).match(new RegExp(`${handler}: (.*)`))?.[1] ?? "";
116
- const body = value.startsWith("(")
117
- ? bracedFrom(source, source.indexOf("{", at))
118
- : namedCallbackBody(source, value.replace(/[,\s].*$/, ""));
119
- const helpers = Array.from(body.matchAll(/\b(\w+MessageIds)\(\)/g), (match) =>
120
- namedCallbackBody(source, match[1]),
121
- );
122
- return [body, ...helpers].join("\n");
123
- };
124
-
125
- /**
126
- * What a verb prop is wired to, with the whitespace, the guard a verb is
127
- * offered behind, and the arrow taken off — what is left is the call itself.
128
- */
129
- const verbHandler = (bar: string, prop: string): string | undefined => {
130
- const assigned = bar.match(new RegExp(`${prop}=\\{([\\s\\S]*?)\\}\\n`))?.[1];
131
- if (assigned === undefined) return undefined;
132
- return assigned
133
- .replace(/\s+/g, " ")
134
- .replace(/^.*? \? /, "")
135
- .replace(/ : undefined$/, "")
136
- .replace(/^\(\)\s*=>\s*/, "")
137
- .trim();
138
- };
139
-
140
- describe("every verb on a selection bar opens the wizard", () => {
141
- it("covers the surfaces that raise one", () => {
142
- assert.deepEqual(surfaceFiles().sort(), [
143
- "DailyBrief.tsx",
144
- "MessageList.tsx",
145
- "ThreadListInteraction.tsx",
146
- ]);
147
- });
148
-
149
- for (const file of surfaceFiles()) {
150
- it(`${file} routes each verb it offers through the wizard`, () => {
151
- const bar = barMarkup(read(file));
152
- assert.notEqual(bar, "", `${file} renders a SelectionTopBar`);
153
- const offered = BAR_VERB_PROPS.filter((prop) => bar.includes(`${prop}=`));
154
- assert.ok(offered.length > 0, `${file} offers at least one verb`);
155
- for (const prop of offered) {
156
- const handler = verbHandler(bar, prop);
157
- assert.ok(handler, `${file}'s ${prop} is wired to something`);
158
- assert.match(
159
- handler,
160
- OPENS_THE_WIZARD,
161
- `${file}'s ${prop} must open the wizard, not run the verb`,
162
- );
163
- }
164
- });
165
- }
166
- });
167
-
168
- /**
169
- * The keyboard's half. A list offers the pane one seam — `requestVerb` — and
170
- * claims the press whenever it has a selection, so a pane's own handler only
171
- * ever sees a verb aimed at the bare cursor. A pane that runs a verb over
172
- * `selectedIds` without offering the list the press first is the hole this
173
- * binds shut.
174
- */
175
- describe("every keyboard verb over a selection goes through the list", () => {
176
- const LISTS = ["MessageList.tsx", "ThreadListInteraction.tsx"];
177
- const PANES = ["MailboxPane.tsx", "BriefPane.tsx", "FlaggedPane.tsx"];
178
- /**
179
- * The triage handlers that can be aimed at a selection, each with the wizard
180
- * verb it has to route to. Star is a knowing carve-out: it is not one of the
181
- * five verbs the wizard walks, it is not on `SelectionTopBar`, and it sets a
182
- * flag on mail already in front of the user that the same key unsets — so
183
- * there is nothing for a review screen to name. `LabelApplyTrigger` applies
184
- * labels to the selection directly for the same reason, and because labels
185
- * are out of scope for the epic that put the wizard in front of the rest
186
- * (#477 §6). Mute and block are per-sender and reach no selection at all.
187
- */
188
- const PANE_VERB_ROUTE: Record<string, string> = {
189
- delete: "delete",
190
- toggleRead: "markRead",
191
- markJunk: "junk",
192
- };
193
-
194
- /**
195
- * How a list may answer a verb aimed at the bare cursor: with the shared
196
- * decision, whose rules are in `../../lib/list-verb-request.test.ts`, or
197
- * inline. Either way only Delete is the list's.
198
- */
199
- const CURSOR_DELETE_ONLY = /listVerbRequest\(\{|if \(verb !== "delete"/;
200
-
201
- for (const file of LISTS) {
202
- it(`${file} publishes the one seam and claims a selection`, () => {
203
- const source = read(file);
204
- assert.match(source, /requestVerb,/);
205
- assert.match(
206
- source,
207
- CURSOR_DELETE_ONLY,
208
- "only delete is the list's over a bare cursor",
209
- );
210
- assert.doesNotMatch(
211
- source,
212
- /requestDelete:/,
213
- "one seam, so no verb can have a second one",
214
- );
215
- });
216
-
217
- it(`${file} stands its keyboard down while the wizard is up`, () => {
218
- assert.match(
219
- read(file),
220
- /blocksKeyboard: confirmOpen \|\| wizard/,
221
- "a shortcut must not act behind the screen already asking",
222
- );
223
- });
224
- }
225
-
226
- // Only the handlers a pane really declares get a case, so a pane that never
227
- // wired one cannot pass by skipping — the roll-call below is what notices a
228
- // handler nobody routed.
229
- for (const file of PANES) {
230
- for (const handler of declaredHandlers(read(file))) {
231
- const verb = PANE_VERB_ROUTE[handler];
232
- if (verb === undefined) continue;
233
-
234
- it(`${file}'s ${handler} offers the list ${verb} first`, () => {
235
- const body = paneVerbBody(read(file), handler);
236
- assert.notEqual(body, "", `${handler}'s body was found`);
237
- assert.match(
238
- body,
239
- new RegExp(`requestVerb\\("${verb}"\\)`),
240
- `${handler} must hand ${verb} to the list before running it`,
241
- );
242
- });
243
-
244
- it(`${file}'s ${handler} aims at the cursor once the list declines`, () => {
245
- const body = paneVerbBody(read(file), handler);
246
- assert.notEqual(body, "", `${handler}'s body was found`);
247
- // The list declines only when its commands are gone, which an
248
- // escalated selection outlives — so a fallback that could still see
249
- // a selection would run the verb over a stale snapshot of rows that
250
- // are no longer loaded, with no wizard in front of it.
251
- assert.doesNotMatch(
252
- body,
253
- /selectedIds/i,
254
- `${handler} falls back to the focused row, never to a selection`,
255
- );
256
- });
257
- }
258
- }
259
-
260
- it("checks the routed handler on every pane that declares one", () => {
261
- // The roll-call. Each pair above is generated from what a pane declares, so
262
- // this is what says the generation reached everything it should have — a
263
- // handler that stops being declared, or a body the reader stops finding,
264
- // shows up here as a missing pair rather than as a case that passed by
265
- // doing nothing.
266
- const checked = PANES.flatMap((file) =>
267
- declaredHandlers(read(file))
268
- .filter((handler) => handler in PANE_VERB_ROUTE)
269
- .map((handler) => `${file}:${handler}`),
270
- );
271
- assert.deepEqual(checked.sort(), [
272
- "BriefPane.tsx:delete",
273
- "BriefPane.tsx:toggleRead",
274
- "FlaggedPane.tsx:delete",
275
- "FlaggedPane.tsx:toggleRead",
276
- "MailboxPane.tsx:delete",
277
- "MailboxPane.tsx:markJunk",
278
- "MailboxPane.tsx:toggleRead",
279
- ]);
280
- for (const entry of checked) {
281
- const [file, handler] = entry.split(":");
282
- assert.notEqual(paneVerbBody(read(file), handler), "", entry);
283
- }
284
- });
285
- });