@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.
- package/package.json +1 -1
- package/src/components/mail/SelectionWizardHost.search-entry.test.ts +71 -30
- package/src/components/mail/SelectionWizardHost.tsx +68 -61
- package/src/components/mail/selection-surface-rollcall.test.ts +270 -0
- package/src/components/mail/selection-wizard-run-controls.test.ts +230 -0
- package/src/components/mail/selection-wizard-run-controls.ts +101 -0
- package/src/components/mail/BriefPane.selection.test.ts +0 -51
- package/src/components/mail/DailyBrief.selection.test.ts +0 -75
- package/src/components/mail/MessageList.selection.test.ts +0 -195
- package/src/components/mail/SelectionWizardHost.job-status.test.ts +0 -50
- package/src/components/mail/SelectionWizardHost.run-exit.test.ts +0 -83
- package/src/components/mail/make-filter-entry.test.ts +0 -63
- package/src/components/mail/selection-verbs.test.ts +0 -285
|
@@ -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
|
-
});
|