@remit/ui 0.0.120 → 0.0.121

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,246 +1,312 @@
1
1
  /**
2
- * The provider and the worker against each other. The worker module is loaded
3
- * with a stand-in for the worker global, so what runs here is the code that
4
- * ships inside the worker, over the same messages a real one exchanges.
2
+ * The provider against the messages a worker sends, without a worker: what is
3
+ * under test is the protocol which status a message publishes, which request
4
+ * an answer settles, and what a menu is told when nothing comes back. The
5
+ * engine itself is proved against real dictionaries in
6
+ * `rich-text-spellcheck-worker.test.ts`.
5
7
  */
6
8
  import assert from "node:assert/strict";
7
- import { before, describe, it, mock } from "node:test";
9
+ import { describe, it, mock } from "node:test";
8
10
  import type {
9
11
  ProviderStatus,
10
12
  SpellWorkerRequest,
11
13
  SpellWorkerResponse,
12
14
  } from "./rich-text-spellcheck.js";
13
15
  import {
16
+ dictionaryTagFor,
17
+ spellcheckBase,
18
+ spellcheckBytes,
19
+ spellcheckLanguages,
20
+ } from "./rich-text-spellcheck-languages.js";
21
+ import {
22
+ CHECK_DEADLINE_MS,
14
23
  openSpellProvider,
15
24
  type SpellWorkerPort,
16
25
  SUGGEST_DEADLINE_MS,
17
26
  } from "./rich-text-spellcheck-provider.js";
18
27
  import {
19
- dictionaryFor,
20
28
  findMisspellings,
21
- suggestionsFor,
29
+ normaliseWord,
30
+ wordsIn,
22
31
  } from "./rich-text-spellcheck-words.js";
23
32
 
24
- interface WorkerScope {
25
- postMessage(message: SpellWorkerResponse): void;
26
- addEventListener(
27
- type: "message",
28
- listener: (event: { data: SpellWorkerRequest }) => void,
29
- ): void;
33
+ interface Wire {
34
+ readonly port: SpellWorkerPort;
35
+ readonly posted: SpellWorkerRequest[];
36
+ answer(message: SpellWorkerResponse): void;
37
+ fall(detail: string): void;
38
+ terminated(): number;
30
39
  }
31
40
 
32
- let receive: (event: { data: SpellWorkerRequest }) => void;
33
- let deliver: ((message: SpellWorkerResponse) => void) | undefined;
34
- let terminated = 0;
35
-
36
- /** Wires the loaded worker module to a provider, the way a real port does. */
37
- const port: SpellWorkerPort = {
38
- post: (message) => receive({ data: message }),
39
- listen: (listener) => {
40
- deliver = listener;
41
- },
42
- fail: () => {},
43
- terminate: () => {
44
- terminated += 1;
45
- },
46
- };
47
-
48
- before(async () => {
49
- const scope: WorkerScope = {
50
- postMessage: (message) => deliver?.(message),
51
- addEventListener: (_type, listener) => {
52
- receive = listener;
41
+ const wire = (): Wire => {
42
+ const posted: SpellWorkerRequest[] = [];
43
+ let deliver: ((message: SpellWorkerResponse) => void) | undefined;
44
+ let raise: ((detail: string) => void) | undefined;
45
+ let terminated = 0;
46
+ return {
47
+ posted,
48
+ port: {
49
+ post: (message) => posted.push(message),
50
+ listen: (listener) => {
51
+ deliver = listener;
52
+ },
53
+ fail: (listener) => {
54
+ raise = listener;
55
+ },
56
+ terminate: () => {
57
+ terminated += 1;
58
+ },
53
59
  },
60
+ answer: (message) => deliver?.(message),
61
+ fall: (detail) => raise?.(detail),
62
+ terminated: () => terminated,
54
63
  };
55
- Object.defineProperty(globalThis, "postMessage", {
56
- value: scope.postMessage,
57
- configurable: true,
58
- });
59
- Object.defineProperty(globalThis, "addEventListener", {
60
- value: scope.addEventListener,
61
- configurable: true,
62
- });
63
- await import("./rich-text-spellcheck-worker.js");
64
- });
64
+ };
65
65
 
66
- describe("the stub dictionary", () => {
67
- it("takes a region tag and answers for the language", () => {
68
- assert.ok(dictionaryFor("en-GB"), "a region variant reads the same words");
69
- assert.equal(
70
- dictionaryFor("de"),
71
- null,
72
- "no dictionary is not an empty one",
66
+ const ready = (wired: Wire, language: string): void =>
67
+ wired.answer({ type: "ready", language });
68
+
69
+ describe("the tokeniser", () => {
70
+ it("takes words and leaves single letters alone", () => {
71
+ assert.deepEqual(
72
+ wordsIn("A report is redy").map((range) => range.start),
73
+ [2, 9, 12],
74
+ "an initial is not a word to check",
73
75
  );
74
76
  });
75
77
 
76
- it("ranges the words it does not hold", () => {
77
- const words = dictionaryFor("en");
78
- assert.ok(words);
79
- assert.deepEqual(findMisspellings("Ths report is redy today", words), [
78
+ it("ranges only what the checker does not know", () => {
79
+ const known = (word: string) => word.toLowerCase() !== "ths";
80
+ assert.deepEqual(findMisspellings("Ths report", known), [
80
81
  { start: 0, end: 3 },
81
- { start: 14, end: 18 },
82
82
  ]);
83
- assert.deepEqual(
84
- findMisspellings("A report", words),
85
- [],
86
- "a single letter is not a word to check",
87
- );
88
83
  });
89
84
 
90
- it("offers the words a keystroke or two away, nearest first", () => {
91
- const words = dictionaryFor("en");
92
- assert.ok(words);
93
- assert.deepEqual(suggestionsFor("redy", words), ["ready", "read", "very"]);
94
- assert.deepEqual(
95
- suggestionsFor("recieve", words),
96
- ["receive", "received"],
97
- "a swapped pair of letters is one keystroke, not two",
85
+ it("reads a curly apostrophe as the straight one", () => {
86
+ assert.equal(normaliseWord("Don’t"), "don't");
87
+ });
88
+ });
89
+
90
+ /**
91
+ * The build writes these in; a test process has neither, so each one is put
92
+ * where the compiled define would have been and taken away again.
93
+ */
94
+ const staged = (
95
+ values: { base?: string; bytes?: Record<string, number>; baseURI?: string },
96
+ use: () => void,
97
+ ): void => {
98
+ const names = ["__REMIT_SPELLCHECK_BASE__", "__REMIT_SPELLCHECK_BYTES__"];
99
+ if (values.base !== undefined)
100
+ Object.defineProperty(globalThis, names[0], {
101
+ value: values.base,
102
+ configurable: true,
103
+ });
104
+ if (values.bytes !== undefined)
105
+ Object.defineProperty(globalThis, names[1], {
106
+ value: values.bytes,
107
+ configurable: true,
108
+ });
109
+ if (values.baseURI !== undefined)
110
+ Object.defineProperty(globalThis, "document", {
111
+ value: { baseURI: values.baseURI },
112
+ configurable: true,
113
+ });
114
+ try {
115
+ use();
116
+ } finally {
117
+ for (const name of [...names, "document"])
118
+ Reflect.deleteProperty(globalThis, name);
119
+ }
120
+ };
121
+
122
+ describe("what this build carries", () => {
123
+ it("has nothing where no build staged anything", () => {
124
+ assert.deepEqual(spellcheckLanguages(), []);
125
+ assert.equal(spellcheckBase(), "/spellcheck/");
126
+ });
127
+
128
+ // Storybook builds relative and is published under /reader/pr/<n>/<sha>/,
129
+ // which no absolute path baked at build time can name. Resolving against the
130
+ // document is what makes the spellcheck stories work anywhere they land.
131
+ it("resolves a relative base against the page it is on", () => {
132
+ staged(
133
+ {
134
+ base: "spellcheck/0123456789abcdef/",
135
+ baseURI:
136
+ "https://remit-mail.github.io/reader/pr/755/abc123/iframe.html",
137
+ },
138
+ () =>
139
+ assert.equal(
140
+ spellcheckBase(),
141
+ "https://remit-mail.github.io/reader/pr/755/abc123/spellcheck/0123456789abcdef/",
142
+ ),
98
143
  );
99
- assert.deepEqual(
100
- suggestionsFor("Attachd", words),
101
- ["Attached"],
102
- "a suggestion arrives dressed the way the word was written",
144
+ });
145
+
146
+ it("leaves an app's own absolute base where it is", () => {
147
+ staged(
148
+ {
149
+ base: "/spellcheck/0123456789abcdef/",
150
+ baseURI: "https://mail.example.com/mail/inbox/42",
151
+ },
152
+ () =>
153
+ assert.equal(
154
+ spellcheckBase(),
155
+ "https://mail.example.com/spellcheck/0123456789abcdef/",
156
+ ),
103
157
  );
158
+ });
159
+
160
+ it("knows what opening a language weighs", () => {
161
+ staged({ bytes: { nl: 2_465_792 } }, () => {
162
+ assert.equal(spellcheckBytes("nl"), 2_465_792);
163
+ assert.equal(spellcheckBytes("de"), 0, "a language nothing staged");
164
+ });
165
+ });
166
+
167
+ it("takes the region dictionary where the build has it", () => {
168
+ const built = ["en", "en-GB", "nl"];
169
+ assert.equal(dictionaryTagFor("en-GB", built), "en-GB");
170
+ assert.equal(dictionaryTagFor("EN-gb", built), "en-GB");
171
+ });
172
+
173
+ it("falls back to the language where the region is not staged", () => {
174
+ assert.equal(dictionaryTagFor("nl-BE", ["en", "nl"]), "nl");
104
175
  assert.equal(
105
- suggestionsFor("Ths", words).length,
106
- 5,
107
- "a short word has many neighbours, and the menu takes five",
108
- );
109
- assert.deepEqual(
110
- suggestionsFor("qwertyuiop", words),
111
- [],
112
- "nothing near it is not the same as anything at all",
113
- );
114
- assert.deepEqual(
115
- suggestionsFor("report", words),
116
- [],
117
- "a word it holds needs no correcting",
176
+ dictionaryTagFor("de", ["en", "nl"]),
177
+ null,
178
+ "no dictionary is not an empty one",
118
179
  );
119
180
  });
120
181
  });
121
182
 
122
183
  describe("a provider over a worker", () => {
123
- it("opens, checks and closes", async () => {
124
- const seen: ProviderStatus[] = [];
125
- const provider = openSpellProvider("en", port);
126
- const unsubscribe = provider.onStatus((status) => seen.push(status));
127
-
128
- assert.deepEqual(
129
- seen.at(-1),
130
- { state: "ready", language: "en" },
131
- "a listener that subscribes late still learns the worker is up",
184
+ it("opens on the language, the place the build serves it from, and its weight", () => {
185
+ const wired = wire();
186
+ const provider = openSpellProvider(
187
+ "nl",
188
+ "/spellcheck/",
189
+ wired.port,
190
+ 2_465_792,
132
191
  );
133
-
134
- const response = await provider.check({
135
- requestId: "7",
136
- language: "en",
137
- revision: 3,
138
- spans: [{ spanId: "a", text: "Ths report" }],
139
- });
140
-
141
- assert.equal(response.requestId, "7");
142
- assert.equal(response.revision, 3, "the answer carries the revision asked");
143
- assert.deepEqual(response.findings, [
192
+ assert.deepEqual(wired.posted, [
144
193
  {
145
- spanId: "a",
146
- start: 0,
147
- end: 3,
148
- kind: "spelling",
149
- suggestions: [],
194
+ type: "open",
195
+ language: "nl",
196
+ base: "/spellcheck/",
197
+ bytesExpected: 2_465_792,
150
198
  },
151
199
  ]);
152
-
153
- unsubscribe();
154
200
  provider.close();
155
- assert.equal(terminated, 1, "closing the provider takes the worker down");
156
201
  });
157
202
 
158
- it("answers each request with its own findings", async () => {
159
- const provider = openSpellProvider("en", port);
160
- const [first, second] = await Promise.all([
161
- provider.check({
162
- requestId: "1",
163
- language: "en",
164
- revision: 1,
165
- spans: [{ spanId: "a", text: "redy" }],
166
- }),
167
- provider.check({
168
- requestId: "2",
169
- language: "en",
170
- revision: 2,
171
- spans: [{ spanId: "b", text: "the report" }],
172
- }),
173
- ]);
203
+ it("carries the download rather than a verdict about it", () => {
204
+ const wired = wire();
205
+ const seen: ProviderStatus[] = [];
206
+ const provider = openSpellProvider("nl", "/spellcheck/", wired.port);
207
+ provider.onStatus((status) => seen.push(status));
208
+
209
+ assert.deepEqual(seen.at(-1), {
210
+ state: "opening",
211
+ language: "nl",
212
+ bytesLoaded: 0,
213
+ bytesTotal: 0,
214
+ });
215
+
216
+ wired.answer({
217
+ type: "opening",
218
+ language: "nl",
219
+ bytesLoaded: 65_536,
220
+ bytesTotal: 702_464,
221
+ });
174
222
 
175
- assert.equal(first?.findings.length, 1);
176
- assert.equal(first?.findings[0]?.spanId, "a");
177
- assert.deepEqual(second?.findings, []);
223
+ assert.deepEqual(
224
+ seen.at(-1),
225
+ {
226
+ state: "opening",
227
+ language: "nl",
228
+ bytesLoaded: 65_536,
229
+ bytesTotal: 702_464,
230
+ },
231
+ "the two numbers reach whatever decides five seconds is long",
232
+ );
178
233
  provider.close();
179
234
  });
180
235
 
181
- it("answers a correction menu one word at a time", async () => {
182
- const provider = openSpellProvider("en", port);
183
- const response = await provider.suggest({
184
- requestId: "9",
236
+ it("settles each request with its own answer", async () => {
237
+ const wired = wire();
238
+ const provider = openSpellProvider("en", "/spellcheck/", wired.port);
239
+ ready(wired, "en");
240
+
241
+ const first = provider.check({
242
+ requestId: "1",
185
243
  language: "en",
186
- word: "redy",
244
+ revision: 1,
245
+ spans: [{ spanId: "a", text: "Ths report" }],
246
+ });
247
+ const second = provider.check({
248
+ requestId: "2",
249
+ language: "en",
250
+ revision: 2,
251
+ spans: [{ spanId: "b", text: "the report" }],
187
252
  });
188
253
 
189
- assert.equal(response.requestId, "9");
190
- assert.equal(response.word, "redy");
191
- assert.deepEqual(response.suggestions, ["ready", "read", "very"]);
254
+ wired.answer({
255
+ type: "checked",
256
+ requestId: "2",
257
+ revision: 2,
258
+ findings: [],
259
+ });
260
+ wired.answer({
261
+ type: "checked",
262
+ requestId: "1",
263
+ revision: 1,
264
+ findings: [
265
+ { spanId: "a", start: 0, end: 3, kind: "spelling", suggestions: [] },
266
+ ],
267
+ });
268
+
269
+ assert.equal((await second).findings.length, 0);
270
+ assert.equal(
271
+ (await first).revision,
272
+ 1,
273
+ "the answer carries what was asked",
274
+ );
192
275
  provider.close();
276
+ assert.equal(wired.terminated(), 1, "closing takes the worker down");
193
277
  });
194
278
 
195
279
  it("tells a waiting menu when the worker stops answering", async () => {
196
- let raise: ((detail: string) => void) | undefined;
197
- const provider = openSpellProvider("en", {
198
- ...port,
199
- post: () => {},
200
- listen: () => {},
201
- fail: (listener) => {
202
- raise = listener;
203
- },
204
- });
280
+ const wired = wire();
281
+ const provider = openSpellProvider("en", "/spellcheck/", wired.port);
205
282
  const asking = provider.suggest({
206
283
  requestId: "1",
207
284
  language: "en",
208
285
  word: "redy",
209
286
  });
210
- raise?.("worker exited");
287
+ wired.fall("worker exited");
211
288
 
212
289
  await assert.rejects(asking, /worker exited/);
213
290
  provider.close();
214
291
  });
215
292
 
216
293
  it("tells a waiting menu when the checker closes under it", async () => {
217
- const provider = openSpellProvider("en", {
218
- ...port,
219
- post: () => {},
220
- listen: () => {},
221
- });
294
+ const wired = wire();
295
+ const provider = openSpellProvider("en", "/spellcheck/", wired.port);
222
296
  const asking = provider.suggest({
223
297
  requestId: "1",
224
298
  language: "en",
225
299
  word: "redy",
226
300
  });
227
-
228
301
  provider.close();
229
302
 
230
- await assert.rejects(
231
- asking,
232
- /the checker closed/,
233
- "a menu waiting on a provider that went away hears about it",
234
- );
303
+ await assert.rejects(asking, /the checker closed/);
235
304
  });
236
305
 
237
306
  it("gives up on a worker that took the word and went quiet", async () => {
238
307
  mock.timers.enable({ apis: ["setTimeout"] });
239
- const provider = openSpellProvider("en", {
240
- ...port,
241
- post: () => {},
242
- listen: () => {},
243
- });
308
+ const wired = wire();
309
+ const provider = openSpellProvider("en", "/spellcheck/", wired.port);
244
310
  const asking = provider.suggest({
245
311
  requestId: "1",
246
312
  language: "en",
@@ -258,66 +324,99 @@ describe("a provider over a worker", () => {
258
324
  provider.close();
259
325
  });
260
326
 
261
- it("says which language it has no words for", async () => {
262
- const provider = openSpellProvider("de", port);
263
- const response = await provider.check({
327
+ it("calls a checker that took the pass and went quiet stopped", async () => {
328
+ mock.timers.enable({ apis: ["setTimeout"] });
329
+ const wired = wire();
330
+ const seen: ProviderStatus[] = [];
331
+ const provider = openSpellProvider("nl", "/spellcheck/", wired.port);
332
+ provider.onStatus((status) => seen.push(status));
333
+ ready(wired, "nl");
334
+
335
+ const pass = provider.check({
264
336
  requestId: "1",
265
- language: "de",
266
- revision: 1,
267
- spans: [{ spanId: "a", text: "Vielen Dank" }],
337
+ language: "nl",
338
+ revision: 7,
339
+ spans: [{ spanId: "a", text: "De vergaderingg" }],
268
340
  });
269
341
 
342
+ mock.timers.tick(CHECK_DEADLINE_MS);
343
+ mock.timers.reset();
344
+
270
345
  assert.deepEqual(
271
- response.findings,
272
- [],
273
- "a language with no dictionary marks nothing rather than everything",
346
+ await pass,
347
+ { requestId: "1", revision: 7, findings: [] },
348
+ "a pass nobody answers ends, rather than hanging on a promise",
349
+ );
350
+ const last = seen.at(-1);
351
+ assert.ok(last?.state === "failed", "a frozen engine is a failure");
352
+ assert.equal(last.reason, "worker");
353
+ assert.match(
354
+ last.detail,
355
+ /nl checker did not answer/,
356
+ "a frozen engine is indistinguishable from clean text unless it says so",
357
+ );
358
+ provider.close();
359
+ });
360
+
361
+ it("says a checker stopped once, however many passes were waiting", async () => {
362
+ mock.timers.enable({ apis: ["setTimeout"] });
363
+ const wired = wire();
364
+ const seen: ProviderStatus[] = [];
365
+ const provider = openSpellProvider("en", "/spellcheck/", wired.port);
366
+ provider.onStatus((status) => seen.push(status));
367
+ ready(wired, "en");
368
+
369
+ const passes = [1, 2].map((nth) =>
370
+ provider.check({
371
+ requestId: `${nth}`,
372
+ language: "en",
373
+ revision: nth,
374
+ spans: [{ spanId: "a", text: "Ths report" }],
375
+ }),
376
+ );
377
+
378
+ mock.timers.tick(CHECK_DEADLINE_MS);
379
+ mock.timers.reset();
380
+
381
+ await Promise.all(passes);
382
+ assert.equal(
383
+ seen.filter((status) => status.state === "failed").length,
384
+ 1,
385
+ "one stopped checker is one banner",
274
386
  );
275
387
  provider.close();
276
388
  });
277
389
 
278
390
  it("passes on a failure the worker names itself", () => {
391
+ const wired = wire();
279
392
  const seen: ProviderStatus[] = [];
280
- let answer: ((message: SpellWorkerResponse) => void) | undefined;
281
- const provider = openSpellProvider("nl", {
282
- ...port,
283
- post: () => {},
284
- listen: (listener) => {
285
- answer = listener;
286
- },
287
- });
393
+ const provider = openSpellProvider("nl", "/spellcheck/", wired.port);
288
394
  provider.onStatus((status) => seen.push(status));
289
- answer?.({
395
+ wired.answer({
290
396
  type: "failed",
291
397
  language: "nl",
292
398
  reason: "download",
293
- detail: "503 fetching the dictionary",
399
+ detail: "/spellcheck/dictionaries/nl/index.dic answered 503",
294
400
  });
295
401
 
296
402
  assert.deepEqual(seen.at(-1), {
297
403
  state: "failed",
298
404
  language: "nl",
299
405
  reason: "download",
300
- detail: "503 fetching the dictionary",
406
+ detail: "/spellcheck/dictionaries/nl/index.dic answered 503",
301
407
  });
302
408
  provider.close();
303
409
  });
304
410
 
305
411
  it("reports a worker that fell over", () => {
412
+ const wired = wire();
306
413
  const seen: ProviderStatus[] = [];
307
- let raise: ((detail: string) => void) | undefined;
308
- const provider = openSpellProvider("en", {
309
- ...port,
310
- post: () => {},
311
- listen: () => {},
312
- fail: (listener) => {
313
- raise = listener;
314
- },
315
- });
414
+ const provider = openSpellProvider("en", "/spellcheck/", wired.port);
316
415
  provider.onStatus((status) => seen.push(status));
317
- raise?.("worker exited");
416
+ wired.fall("worker exited");
318
417
 
319
418
  assert.deepEqual(seen, [
320
- { state: "opening", language: "en" },
419
+ { state: "opening", language: "en", bytesLoaded: 0, bytesTotal: 0 },
321
420
  {
322
421
  state: "failed",
323
422
  language: "en",