@remit/ui 0.0.120 → 0.0.122

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,253 @@
1
+ /**
2
+ * The worker against real dictionaries. What runs here is the code that ships
3
+ * inside the worker, over the Hunspell build in `build/hunspell/` and the
4
+ * upstream `.aff`/`.dic` pairs the image stages — so a language that passes
5
+ * here is a language a writer is actually checked in.
6
+ *
7
+ * The engine and the dictionaries are fetched at runtime, so the fetch is where
8
+ * this stands in: file URLs into `build/` and `node_modules/`, byte for byte
9
+ * what the server would hand a browser.
10
+ */
11
+ import assert from "node:assert/strict";
12
+ import { readFile } from "node:fs/promises";
13
+ import { createRequire } from "node:module";
14
+ import { dirname, join } from "node:path";
15
+ import { before, describe, it } from "node:test";
16
+ import { fileURLToPath, pathToFileURL } from "node:url";
17
+ import type {
18
+ SpellWorkerRequest,
19
+ SpellWorkerResponse,
20
+ } from "./rich-text-spellcheck.js";
21
+
22
+ const require = createRequire(import.meta.url);
23
+ const engineDir = fileURLToPath(
24
+ new URL("../../../../build/hunspell/", import.meta.url),
25
+ );
26
+ const BASE = pathToFileURL(engineDir).href;
27
+
28
+ const dictionaryDir = (tag: string): string =>
29
+ dirname(require.resolve(`dictionary-${tag.toLowerCase()}`));
30
+
31
+ /** The one path that answers 503, so the download failure has a producer. */
32
+ let refuse: string | null = null;
33
+
34
+ /**
35
+ * The image serves these files brotli-compressed, so `content-length` is the
36
+ * compressed length while what arrives is the decompressed file. A quarter
37
+ * stands in for that ratio: anything reading the header instead of the build's
38
+ * own figure opens the notice at a size it then walks straight past.
39
+ */
40
+ const asOnTheWire = (bytes: Buffer): string =>
41
+ String(Math.max(1, Math.round(bytes.byteLength / 4)));
42
+
43
+ const served = async (url: string): Promise<Response> => {
44
+ if (url === refuse) return new Response("no", { status: 503 });
45
+ const dictionary = /dictionaries\/([^/]+)\/(index\.(?:aff|dic))$/.exec(url);
46
+ const path = dictionary
47
+ ? join(dictionaryDir(dictionary[1]), dictionary[2])
48
+ : fileURLToPath(url);
49
+ const bytes = await readFile(path);
50
+ return new Response(bytes, {
51
+ headers: { "content-length": asOnTheWire(bytes) },
52
+ });
53
+ };
54
+
55
+ /** What the build weighs a language at: the engine and its two files. */
56
+ const weightOf = async (tag: string): Promise<number> => {
57
+ const directory = dictionaryDir(tag);
58
+ const files = await Promise.all([
59
+ readFile(join(engineDir, "hunspell.wasm")),
60
+ readFile(join(directory, "index.aff")),
61
+ readFile(join(directory, "index.dic")),
62
+ ]);
63
+ return files.reduce((total, bytes) => total + bytes.byteLength, 0);
64
+ };
65
+
66
+ let post: (message: SpellWorkerRequest) => void;
67
+ const heard: SpellWorkerResponse[] = [];
68
+ const waiting = new Map<string, (message: SpellWorkerResponse) => void>();
69
+
70
+ const nextMessage = <T extends SpellWorkerResponse["type"]>(
71
+ type: T,
72
+ ): Promise<Extract<SpellWorkerResponse, { type: T }>> =>
73
+ new Promise((resolve) => {
74
+ waiting.set(type, (message) =>
75
+ resolve(message as Extract<SpellWorkerResponse, { type: T }>),
76
+ );
77
+ });
78
+
79
+ const openOn = async (
80
+ tag: string,
81
+ ): Promise<Extract<SpellWorkerResponse, { type: "ready" | "failed" }>> => {
82
+ const ready = nextMessage("ready");
83
+ const failed = nextMessage("failed");
84
+ post({
85
+ type: "open",
86
+ language: tag,
87
+ base: BASE,
88
+ bytesExpected: await weightOf(tag),
89
+ });
90
+ return Promise.race([ready, failed]);
91
+ };
92
+
93
+ const checked = async (
94
+ tag: string,
95
+ text: string,
96
+ ): Promise<readonly string[]> => {
97
+ const answer = nextMessage("checked");
98
+ post({
99
+ type: "check",
100
+ requestId: "1",
101
+ language: tag,
102
+ revision: 1,
103
+ spans: [{ spanId: "a", text }],
104
+ });
105
+ const { findings } = await answer;
106
+ return findings.map((finding) => text.slice(finding.start, finding.end));
107
+ };
108
+
109
+ const suggested = async (tag: string, word: string): Promise<string[]> => {
110
+ const answer = nextMessage("suggested");
111
+ post({ type: "suggest", requestId: "1", language: tag, word });
112
+ return [...(await answer).suggestions];
113
+ };
114
+
115
+ before(async () => {
116
+ Object.defineProperty(globalThis, "fetch", {
117
+ value: (input: RequestInfo | URL) => served(String(input)),
118
+ configurable: true,
119
+ });
120
+ Object.defineProperty(globalThis, "postMessage", {
121
+ value: (message: SpellWorkerResponse) => {
122
+ heard.push(message);
123
+ const settle = waiting.get(message.type);
124
+ if (!settle) return;
125
+ waiting.delete(message.type);
126
+ settle(message);
127
+ },
128
+ configurable: true,
129
+ });
130
+ Object.defineProperty(globalThis, "addEventListener", {
131
+ value: (
132
+ _type: string,
133
+ listener: (event: { data: SpellWorkerRequest }) => void,
134
+ ) => {
135
+ post = (message) => listener({ data: message });
136
+ },
137
+ configurable: true,
138
+ });
139
+ await import("./rich-text-spellcheck-worker.js");
140
+ });
141
+
142
+ describe("English, against SCOWL", () => {
143
+ it("marks what is misspelt and leaves the rest alone", async () => {
144
+ assert.equal((await openOn("en")).type, "ready");
145
+ assert.deepEqual(await checked("en", "Ths report is redy today"), [
146
+ "Ths",
147
+ "redy",
148
+ ]);
149
+ assert.deepEqual(
150
+ await checked("en", "The meeting notes are attached."),
151
+ [],
152
+ "a sentence of real words carries no marks",
153
+ );
154
+ });
155
+
156
+ it("offers the correction a writer meant", async () => {
157
+ assert.ok(
158
+ (await suggested("en", "recieve")).includes("receive"),
159
+ "the engine suggests, and it knows the word",
160
+ );
161
+ assert.ok((await suggested("en", "meetign")).includes("meeting"));
162
+ });
163
+
164
+ it("reports the bytes as they arrive", () => {
165
+ const progress = heard.filter((message) => message.type === "opening");
166
+ assert.ok(progress.length > 0, "a download that says nothing looks broken");
167
+ const last = progress.at(-1);
168
+ assert.ok(last && last.bytesLoaded > 0 && last.bytesTotal > 0);
169
+ });
170
+ });
171
+
172
+ describe("British English, against the same source", () => {
173
+ it("takes the spellings the other English does not", async () => {
174
+ assert.equal((await openOn("en-GB")).type, "ready");
175
+ assert.deepEqual(
176
+ await checked("en-GB", "I will organise the colour of the centre."),
177
+ [],
178
+ );
179
+ assert.deepEqual(
180
+ await checked("en-GB", "The kolour of the sentre."),
181
+ ["kolour", "sentre"],
182
+ "a real British misspelling is still a misspelling",
183
+ );
184
+ });
185
+ });
186
+
187
+ describe("Dutch, against OpenTaal", () => {
188
+ it("knows the words the stub never did", async () => {
189
+ assert.equal((await openOn("nl")).type, "ready");
190
+ assert.deepEqual(
191
+ await checked(
192
+ "nl",
193
+ "De vergadering van vanmiddag gaat over de begroting.",
194
+ ),
195
+ [],
196
+ );
197
+ });
198
+
199
+ it("marks a Dutch misspelling and corrects it in Dutch", async () => {
200
+ assert.deepEqual(
201
+ await checked("nl", "De vergaderingg gaat over de begrooting."),
202
+ ["vergaderingg", "begrooting"],
203
+ );
204
+ assert.ok((await suggested("nl", "vergaderingg")).includes("vergadering"));
205
+ });
206
+ });
207
+
208
+ describe("what the composer is told the download costs", () => {
209
+ it("names the size the build weighed, not the length on the wire", async () => {
210
+ const from = heard.length;
211
+ assert.equal((await openOn("nl")).type, "ready");
212
+ const progress = heard
213
+ .slice(from)
214
+ .filter((message) => message.type === "opening");
215
+ const weight = await weightOf("nl");
216
+
217
+ assert.ok(progress.length > 1, "a download reported once is not reported");
218
+ assert.deepEqual(
219
+ [...new Set(progress.map((message) => message.bytesTotal))],
220
+ [weight],
221
+ "a figure that is rewritten mid-download was never the size of anything",
222
+ );
223
+ assert.equal(
224
+ progress.at(-1)?.bytesLoaded,
225
+ weight,
226
+ "and what arrives ends on it rather than past it",
227
+ );
228
+ });
229
+ });
230
+
231
+ describe("when the dictionary does not arrive", () => {
232
+ it("says which file and what it answered", async () => {
233
+ refuse = `${BASE}dictionaries/nl/index.dic`;
234
+ const answer = await openOn("nl");
235
+ refuse = null;
236
+
237
+ assert.equal(answer.type, "failed");
238
+ assert.equal(answer.type === "failed" && answer.reason, "download");
239
+ assert.match(
240
+ answer.type === "failed" ? answer.detail : "",
241
+ /index\.dic answered 503/,
242
+ "the status is what a report link has to carry",
243
+ );
244
+ });
245
+
246
+ it("marks nothing rather than everything once it has failed", async () => {
247
+ assert.deepEqual(
248
+ await checked("nl", "De vergaderingg gaat over de begrooting."),
249
+ [],
250
+ "the browser's own checker is the one on screen now",
251
+ );
252
+ });
253
+ });
@@ -1,8 +1,12 @@
1
1
  /**
2
2
  * The checking side, off the main thread. It answers with the revision it was
3
3
  * asked at and never touches the document, which is what lets the editor throw
4
- * a late answer away. The word list it consults is a stub; the engine that
5
- * replaces it changes nothing above this file.
4
+ * a late answer away.
5
+ *
6
+ * One worker holds one language's engine. Everything before `ready` is a
7
+ * download, reported as it goes, and every way it can end badly is a message
8
+ * the composer can put words to — never silence, because the browser's own
9
+ * checker is still on and the writer must not be left with neither.
6
10
  */
7
11
 
8
12
  import type {
@@ -11,10 +15,11 @@ import type {
11
15
  SpellWorkerRequest,
12
16
  SpellWorkerResponse,
13
17
  } from "./rich-text-spellcheck.js";
18
+ import type { SpellEngine } from "./rich-text-spellcheck-engine.js";
19
+ import { openEngine } from "./rich-text-spellcheck-engine.js";
14
20
  import {
15
- dictionaryFor,
16
21
  findMisspellings,
17
- suggestionsFor,
22
+ SUGGESTION_LIMIT,
18
23
  } from "./rich-text-spellcheck-words.js";
19
24
 
20
25
  interface WorkerScope {
@@ -25,11 +30,13 @@ interface WorkerScope {
25
30
  ): void;
26
31
  }
27
32
 
28
- const spell = (request: CheckRequest): Finding[] => {
29
- const words = dictionaryFor(request.language);
30
- if (!words) return [];
31
- return request.spans.flatMap((span) =>
32
- findMisspellings(span.text, words).map((range) => ({
33
+ const scope = globalThis as unknown as WorkerScope;
34
+
35
+ let engine: SpellEngine | null = null;
36
+
37
+ const spell = (request: CheckRequest, checker: SpellEngine): Finding[] =>
38
+ request.spans.flatMap((span) =>
39
+ findMisspellings(span.text, (word) => checker.spell(word)).map((range) => ({
33
40
  spanId: span.spanId,
34
41
  start: range.start,
35
42
  end: range.end,
@@ -37,22 +44,63 @@ const spell = (request: CheckRequest): Finding[] => {
37
44
  suggestions: [],
38
45
  })),
39
46
  );
40
- };
41
47
 
42
- const scope = globalThis as unknown as WorkerScope;
48
+ const open = async (
49
+ language: string,
50
+ base: string,
51
+ bytesExpected: number,
52
+ ): Promise<void> => {
53
+ // Whatever was answering before is not answering for this language, and a
54
+ // failed open must not leave the previous dictionary marking the new text.
55
+ engine?.close();
56
+ engine = null;
57
+ const result = await openEngine({
58
+ base,
59
+ tag: language,
60
+ bytesExpected,
61
+ onProgress: ({ bytesLoaded, bytesTotal }) =>
62
+ scope.postMessage({
63
+ type: "opening",
64
+ language,
65
+ bytesLoaded,
66
+ bytesTotal,
67
+ }),
68
+ });
69
+ if (!result.ok) {
70
+ scope.postMessage({
71
+ type: "failed",
72
+ language,
73
+ reason: result.reason,
74
+ detail: result.detail,
75
+ });
76
+ return;
77
+ }
78
+ engine = result.engine;
79
+ scope.postMessage({ type: "ready", language });
80
+ };
43
81
 
44
82
  scope.addEventListener("message", ({ data }) => {
45
83
  if (data.type === "open") {
46
- scope.postMessage({ type: "ready", language: data.language });
84
+ open(data.language, data.base, data.bytesExpected).catch(
85
+ (error: unknown) => {
86
+ scope.postMessage({
87
+ type: "failed",
88
+ language: data.language,
89
+ reason: "engine",
90
+ detail: error instanceof Error ? error.message : String(error),
91
+ });
92
+ },
93
+ );
47
94
  return;
48
95
  }
49
96
  if (data.type === "suggest") {
50
- const words = dictionaryFor(data.language);
51
97
  scope.postMessage({
52
98
  type: "suggested",
53
99
  requestId: data.requestId,
54
100
  word: data.word,
55
- suggestions: words ? suggestionsFor(data.word, words) : [],
101
+ suggestions: engine
102
+ ? engine.suggest(data.word).slice(0, SUGGESTION_LIMIT)
103
+ : [],
56
104
  });
57
105
  return;
58
106
  }
@@ -60,6 +108,6 @@ scope.addEventListener("message", ({ data }) => {
60
108
  type: "checked",
61
109
  requestId: data.requestId,
62
110
  revision: data.revision,
63
- findings: spell(data),
111
+ findings: engine ? spell(data, engine) : [],
64
112
  });
65
113
  });
@@ -22,12 +22,19 @@ import type {
22
22
  ProviderStatus,
23
23
  SpellcheckOptions,
24
24
  SpellProvider,
25
+ SpellWorkerRequest,
26
+ SpellWorkerResponse,
25
27
  } from "./rich-text-spellcheck.js";
26
28
  import {
27
- dictionaryFor,
28
- findMisspellings,
29
- suggestionsFor,
30
- } from "./rich-text-spellcheck-words.js";
29
+ stubKnows,
30
+ stubSuggestionsFor,
31
+ } from "./rich-text-spellcheck-double.js";
32
+ import {
33
+ CHECK_DEADLINE_MS,
34
+ openSpellProvider,
35
+ type SpellWorkerPort,
36
+ } from "./rich-text-spellcheck-provider.js";
37
+ import { findMisspellings } from "./rich-text-spellcheck-words.js";
31
38
 
32
39
  const SENTENCE = "Ths report is redy today";
33
40
  const IDLE_MS = 400;
@@ -77,19 +84,20 @@ const stubSpellcheck = (
77
84
  tune: {
78
85
  revisionOf?: (request: CheckRequest, nth: number) => number;
79
86
  hold?: boolean;
87
+ /** Opens on a dictionary still arriving, the way a real one does. */
88
+ downloading?: boolean;
80
89
  } = {},
81
90
  ): Stub => {
82
91
  const asked: CheckRequest[] = [];
83
92
  const held: (() => void)[] = [];
84
93
  const listeners = new Set<(status: ProviderStatus) => void>();
85
94
  let closed = 0;
86
- const words = dictionaryFor("en") ?? new Set<string>();
87
95
 
88
96
  const answer = (request: CheckRequest, nth: number): CheckResponse => ({
89
97
  requestId: request.requestId,
90
98
  revision: tune.revisionOf?.(request, nth) ?? request.revision,
91
99
  findings: request.spans.flatMap((span) =>
92
- findMisspellings(span.text, words).map(
100
+ findMisspellings(span.text, stubKnows).map(
93
101
  (range): Finding => ({
94
102
  spanId: span.spanId,
95
103
  start: range.start,
@@ -105,7 +113,16 @@ const stubSpellcheck = (
105
113
  language: "en",
106
114
  onStatus: (listener) => {
107
115
  listeners.add(listener);
108
- listener({ state: "ready", language: "en" });
116
+ listener(
117
+ tune.downloading
118
+ ? {
119
+ state: "opening",
120
+ language: "en",
121
+ bytesLoaded: 0,
122
+ bytesTotal: 167_936,
123
+ }
124
+ : { state: "ready", language: "en" },
125
+ );
109
126
  return () => {
110
127
  listeners.delete(listener);
111
128
  };
@@ -122,7 +139,7 @@ const stubSpellcheck = (
122
139
  Promise.resolve({
123
140
  requestId: request.requestId,
124
141
  word: request.word,
125
- suggestions: suggestionsFor(request.word, words),
142
+ suggestions: stubSuggestionsFor(request.word),
126
143
  }),
127
144
  close: () => {
128
145
  closed += 1;
@@ -532,6 +549,100 @@ describe("spellcheck marks", () => {
532
549
  assert.equal(seen.at(-1)?.state, "failed", "the caller is told why");
533
550
  });
534
551
 
552
+ it("checks the document the download was holding up", async () => {
553
+ const spellcheck = stubSpellcheck({ downloading: true });
554
+ await mount({
555
+ initialHtml: `<p>${SENTENCE}</p>`,
556
+ lang: "en",
557
+ spellcheck,
558
+ });
559
+ await settle();
560
+ assert.equal(
561
+ marks(),
562
+ undefined,
563
+ "nothing of ours is on screen while the dictionary is on its way",
564
+ );
565
+ assert.equal(editable().getAttribute("spellcheck"), "true");
566
+
567
+ // A real dictionary arrives in twenty of these before it is ready.
568
+ await act(async () => {
569
+ for (const bytesLoaded of [65_536, 131_072, 167_936]) {
570
+ spellcheck.push({
571
+ state: "opening",
572
+ language: "en",
573
+ bytesLoaded,
574
+ bytesTotal: 167_936,
575
+ });
576
+ }
577
+ });
578
+ await act(async () => {
579
+ spellcheck.push({ state: "ready", language: "en" });
580
+ });
581
+ await settle();
582
+
583
+ assert.deepEqual(
584
+ offsets(),
585
+ [
586
+ [0, 3],
587
+ [14, 18],
588
+ ],
589
+ "the leaves that were waiting on the dictionary are the first pass",
590
+ );
591
+ });
592
+
593
+ it("hands checking back when the engine takes a pass and freezes", async () => {
594
+ const seen: ProviderStatus[] = [];
595
+ const posted: SpellWorkerRequest[] = [];
596
+ let deliver: ((message: SpellWorkerResponse) => void) | undefined;
597
+ // A worker that opens, says it is ready, and then answers nothing at all —
598
+ // a wedged WebAssembly instance, which no `error` event ever announces.
599
+ const dead: SpellWorkerPort = {
600
+ post: (message) => posted.push(message),
601
+ listen: (listener) => {
602
+ deliver = listener;
603
+ },
604
+ fail: () => {},
605
+ terminate: () => {},
606
+ };
607
+
608
+ await mount({
609
+ initialHtml: `<p>${SENTENCE}</p>`,
610
+ lang: "en",
611
+ spellcheck: {
612
+ provider: async (language: string) =>
613
+ openSpellProvider(language, "/spellcheck/", dead),
614
+ onStatus: (status: ProviderStatus) => seen.push(status),
615
+ },
616
+ });
617
+ await act(async () => {
618
+ deliver?.({ type: "ready", language: "en" });
619
+ });
620
+ await settle();
621
+ assert.ok(
622
+ posted.some((message) => message.type === "check"),
623
+ "the pass went out",
624
+ );
625
+ assert.equal(
626
+ editable().getAttribute("spellcheck"),
627
+ "false",
628
+ "ours is the checker on screen while it is answering",
629
+ );
630
+
631
+ await act(async () => {
632
+ await new Promise((resolve) =>
633
+ setTimeout(resolve, CHECK_DEADLINE_MS + IDLE_MS),
634
+ );
635
+ });
636
+
637
+ assert.equal(seen.at(-1)?.state, "failed", "silence is named, not endured");
638
+ assert.equal(
639
+ editable().getAttribute("spellcheck"),
640
+ "true",
641
+ "the browser checks again rather than nobody checking",
642
+ );
643
+ assert.equal(marks(), undefined);
644
+ });
645
+
535
646
  it("says when a language has no dictionary", async () => {
536
647
  const seen: ProviderStatus[] = [];
537
648
  await mount({
@@ -544,7 +655,10 @@ describe("spellcheck marks", () => {
544
655
  });
545
656
  await settle();
546
657
 
547
- assert.deepEqual(seen, [{ state: "unavailable", language: "de" }]);
658
+ assert.deepEqual(seen, [
659
+ { state: "opening", language: "de", bytesLoaded: 0, bytesTotal: 0 },
660
+ { state: "unavailable", language: "de" },
661
+ ]);
548
662
  assert.equal(editable().getAttribute("spellcheck"), "true");
549
663
  });
550
664
 
@@ -52,7 +52,19 @@ export interface SuggestResponse {
52
52
  }
53
53
 
54
54
  export type ProviderStatus =
55
- | { readonly state: "opening"; readonly language: LanguageTag }
55
+ /**
56
+ * Carries the download rather than a verdict about it: a composer that has to
57
+ * say "still fetching Dutch (2.4 MB)" needs the two numbers, and where the
58
+ * line between quick and slow falls belongs to whatever renders it, not here.
59
+ * The total is what the build weighed the files at, so it is right from the
60
+ * first report and never moves under the writer.
61
+ */
62
+ | {
63
+ readonly state: "opening";
64
+ readonly language: LanguageTag;
65
+ readonly bytesLoaded: number;
66
+ readonly bytesTotal: number;
67
+ }
56
68
  | { readonly state: "ready"; readonly language: LanguageTag }
57
69
  /** Nothing here checks this language, and the browser is welcome to. */
58
70
  | { readonly state: "unavailable"; readonly language: LanguageTag }
@@ -85,11 +97,28 @@ export interface SpellcheckOptions {
85
97
  }
86
98
 
87
99
  export type SpellWorkerRequest =
88
- | { readonly type: "open"; readonly language: LanguageTag }
100
+ /**
101
+ * `base` is where the engine and the dictionaries are served from, decided by
102
+ * the build and passed in rather than read inside the worker — so what the
103
+ * worker fetches is visible at the seam and can be pointed elsewhere.
104
+ */
105
+ | {
106
+ readonly type: "open";
107
+ readonly language: LanguageTag;
108
+ readonly base: string;
109
+ /** What the build weighed these files at, for the download notice. */
110
+ readonly bytesExpected: number;
111
+ }
89
112
  | ({ readonly type: "check" } & CheckRequest)
90
113
  | ({ readonly type: "suggest" } & SuggestRequest);
91
114
 
92
115
  export type SpellWorkerResponse =
116
+ | {
117
+ readonly type: "opening";
118
+ readonly language: LanguageTag;
119
+ readonly bytesLoaded: number;
120
+ readonly bytesTotal: number;
121
+ }
93
122
  | { readonly type: "ready"; readonly language: LanguageTag }
94
123
  | {
95
124
  readonly type: "failed";
@@ -3,7 +3,11 @@ import { describe, it } from "node:test";
3
3
  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
- import { stepsFor } from "../lib/wizard-steps.js";
6
+ import {
7
+ matchDoorsFor,
8
+ stepBlockedReason,
9
+ stepsFor,
10
+ } from "../lib/wizard-steps.js";
7
11
  import {
8
12
  type RuleClause,
9
13
  UNCOUNTABLE_PREDICATE_REASON,
@@ -57,6 +61,7 @@ const sample = { messages, count: counted(2), label: "Your selection" };
57
61
  const matchProps = {
58
62
  selectedCount: 2,
59
63
  mode: "selected" as const,
64
+ accountId: "acc-personal",
60
65
  onModeChange: noop,
61
66
  onSemanticFallback: noop,
62
67
  sample,
@@ -291,6 +296,42 @@ describe("MatchStepBody", () => {
291
296
  });
292
297
  });
293
298
 
299
+ /**
300
+ * Starred, and any other surface whose rows span accounts (#523). Both widened
301
+ * doors are counted through a preview one account answers, so offering them
302
+ * walks the user to a review waiting on a count nobody can take, with Back the
303
+ * only way on. They are withheld, and the step says why.
304
+ */
305
+ describe("MatchStepBody with no single account", () => {
306
+ const spanning = { ...matchProps, accountId: undefined };
307
+
308
+ it("offers the ticked rows alone, and states the restriction", () => {
309
+ const html = text(renderToString(createElement(MatchStepBody, spanning)));
310
+ assert.match(html, /These 2 messages/);
311
+ assert.doesNotMatch(html, /Similar to these/);
312
+ assert.doesNotMatch(html, /Its properties/);
313
+ assert.match(html, /only works within one account/);
314
+ });
315
+
316
+ it("leaves a review that commits rather than one pinned on the count", () => {
317
+ assert.deepEqual([...matchDoorsFor(undefined)], ["selected"]);
318
+ // The only door left is its own count, so nothing about the match is still
319
+ // settling by the time the review is reached.
320
+ const blockedReason = stepBlockedReason("review", draft(), counted(2));
321
+ assert.equal(blockedReason, undefined);
322
+ const html = text(
323
+ renderToString(
324
+ createElement(
325
+ SelectionWizard,
326
+ wizardProps({ step: "review", match: spanning, blockedReason }),
327
+ ),
328
+ ),
329
+ );
330
+ assert.doesNotMatch(html, /Counting matches/);
331
+ assert.match(html, /2 messages/);
332
+ });
333
+ });
334
+
294
335
  describe("PropertiesStepBody", () => {
295
336
  it("renders the rule as the shipped chips, with the join between them", () => {
296
337
  const html = renderToString(