dsh-spell-check 0.0.0-stage → 0.2.2
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/LICENSE +21 -0
- package/README.md +125 -2
- package/README.zh-CN.md +110 -0
- package/assets/banner-dark.png +0 -0
- package/assets/banner-light.png +0 -0
- package/assets/icon.png +0 -0
- package/assets/menu.png +0 -0
- package/assets/strip.gif +0 -0
- package/assets/strip.png +0 -0
- package/assets/words.txt +20000 -0
- package/cordis.patch.yml +13 -0
- package/dsh.plugin.json +9 -0
- package/package.json +88 -4
- package/screenshots.json +4 -0
- package/scripts/build-client.mjs +102 -0
- package/scripts/check-styles.mjs +70 -0
- package/scripts/make-banner.py +191 -0
- package/scripts/make-media.py +120 -0
- package/scripts/test-client.mjs +402 -0
- package/scripts/test-engine.mjs +101 -0
- package/scripts/test-host.mjs +82 -0
- package/src/client.js +2327 -0
- package/src/engine.js +981 -0
- package/src/index.js +221 -0
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-half test: `node scripts/test-client.mjs`.
|
|
3
|
+
*
|
|
4
|
+
* Loads src/client.js the way dsh does — through `window.__ModuleLoader__` —
|
|
5
|
+
* with a stubbed React and DOM, then drives it against a fake context.
|
|
6
|
+
*
|
|
7
|
+
* It asserts the three things that were actually broken or missing:
|
|
8
|
+
* 1. the module loads and registers (the ESM `preact` version did not),
|
|
9
|
+
* 2. the EMBEDDED dictionary corrects without any host half,
|
|
10
|
+
* 3. the controller publishes a suggestion, so the chip and Tab have
|
|
11
|
+
* something to accept.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFileSync } from 'node:fs'
|
|
15
|
+
import { dirname, join } from 'node:path'
|
|
16
|
+
import { fileURLToPath } from 'node:url'
|
|
17
|
+
|
|
18
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
19
|
+
|
|
20
|
+
let passed = 0
|
|
21
|
+
const failures = []
|
|
22
|
+
function check(name, condition, detail) {
|
|
23
|
+
if (condition) {
|
|
24
|
+
passed += 1
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
failures.push(name + (detail === undefined ? '' : ` — ${detail}`))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/* --- stub DOM --------------------------------------------------------- */
|
|
31
|
+
|
|
32
|
+
const listeners = []
|
|
33
|
+
const styles = []
|
|
34
|
+
|
|
35
|
+
const fakeElement = (tag) => {
|
|
36
|
+
const node = {
|
|
37
|
+
tagName: tag,
|
|
38
|
+
className: '',
|
|
39
|
+
style: {},
|
|
40
|
+
children: [],
|
|
41
|
+
classList: { add() {}, remove() {}, contains: () => false },
|
|
42
|
+
attributes: {},
|
|
43
|
+
parentNode: null,
|
|
44
|
+
firstChild: null,
|
|
45
|
+
offsetWidth: 120,
|
|
46
|
+
textContent: '',
|
|
47
|
+
appendChild(child) {
|
|
48
|
+
this.children.push(child)
|
|
49
|
+
child.parentNode = this
|
|
50
|
+
this.firstChild = this.children[0]
|
|
51
|
+
return child
|
|
52
|
+
},
|
|
53
|
+
removeChild(child) {
|
|
54
|
+
this.children = this.children.filter((c) => c !== child)
|
|
55
|
+
child.parentNode = null
|
|
56
|
+
this.firstChild = this.children[0] ?? null
|
|
57
|
+
return child
|
|
58
|
+
},
|
|
59
|
+
setAttribute(key, value) {
|
|
60
|
+
this.attributes[key] = value
|
|
61
|
+
},
|
|
62
|
+
getAttribute(key) {
|
|
63
|
+
return this.attributes[key] ?? null
|
|
64
|
+
},
|
|
65
|
+
// The underliner draws on a canvas; record the calls so a test can prove a
|
|
66
|
+
// repaint happened (the stale-underline bug was a repaint that never ran).
|
|
67
|
+
getContext() {
|
|
68
|
+
if (!this.__ctx) {
|
|
69
|
+
this.__draws = { clear: 0, stroke: 0 }
|
|
70
|
+
const draws = this.__draws
|
|
71
|
+
this.__ctx = {
|
|
72
|
+
setTransform() {},
|
|
73
|
+
clearRect() {
|
|
74
|
+
draws.clear += 1
|
|
75
|
+
},
|
|
76
|
+
beginPath() {},
|
|
77
|
+
moveTo() {},
|
|
78
|
+
quadraticCurveTo() {},
|
|
79
|
+
stroke() {
|
|
80
|
+
draws.stroke += 1
|
|
81
|
+
},
|
|
82
|
+
strokeStyle: '',
|
|
83
|
+
lineWidth: 0,
|
|
84
|
+
lineCap: '',
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return this.__ctx
|
|
88
|
+
},
|
|
89
|
+
addEventListener() {},
|
|
90
|
+
removeEventListener() {},
|
|
91
|
+
contains() {
|
|
92
|
+
return true
|
|
93
|
+
},
|
|
94
|
+
remove() {
|
|
95
|
+
this.parentNode = null
|
|
96
|
+
},
|
|
97
|
+
dispatchEvent() {
|
|
98
|
+
return true
|
|
99
|
+
},
|
|
100
|
+
closest() {
|
|
101
|
+
return null
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
return node
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
globalThis.Node = class Node {}
|
|
108
|
+
globalThis.NodeFilter = { SHOW_TEXT: 4 }
|
|
109
|
+
globalThis.Element = class Element {
|
|
110
|
+
closest() {
|
|
111
|
+
return null
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Observers the underliner uses to repaint when the DOM changes without a
|
|
116
|
+
// keyboard event. The stubs capture the callback so a test can fire it by hand.
|
|
117
|
+
const createdCanvases = []
|
|
118
|
+
let queryResult = null
|
|
119
|
+
let mutationCallback = null
|
|
120
|
+
const intervals = []
|
|
121
|
+
globalThis.MutationObserver = class {
|
|
122
|
+
constructor(callback) {
|
|
123
|
+
mutationCallback = callback
|
|
124
|
+
}
|
|
125
|
+
observe() {}
|
|
126
|
+
disconnect() {
|
|
127
|
+
mutationCallback = null
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
globalThis.ResizeObserver = class {
|
|
131
|
+
observe() {}
|
|
132
|
+
disconnect() {}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
globalThis.document = {
|
|
136
|
+
head: { appendChild: (node) => styles.push(node) },
|
|
137
|
+
body: { appendChild: (node) => { node.parentNode = { body: true } } },
|
|
138
|
+
createElement: (tag) => {
|
|
139
|
+
const node = fakeElement(tag)
|
|
140
|
+
if (tag === 'canvas') createdCanvases.push(node)
|
|
141
|
+
return node
|
|
142
|
+
},
|
|
143
|
+
createTreeWalker: () => ({ nextNode: () => null }),
|
|
144
|
+
createRange: () => ({
|
|
145
|
+
setStart() {},
|
|
146
|
+
setEnd() {},
|
|
147
|
+
selectNodeContents() {},
|
|
148
|
+
collapse() {},
|
|
149
|
+
getBoundingClientRect: () => ({ top: 100, left: 120, width: 0, height: 0 }),
|
|
150
|
+
toString: () => '',
|
|
151
|
+
}),
|
|
152
|
+
getElementById: () => null,
|
|
153
|
+
querySelector: () => queryResult,
|
|
154
|
+
querySelectorAll: () => [],
|
|
155
|
+
addEventListener: (type) => listeners.push(type),
|
|
156
|
+
removeEventListener: (type) => {
|
|
157
|
+
const index = listeners.indexOf(type)
|
|
158
|
+
if (index !== -1) listeners.splice(index, 1)
|
|
159
|
+
},
|
|
160
|
+
execCommand: () => true,
|
|
161
|
+
}
|
|
162
|
+
globalThis.window = {
|
|
163
|
+
innerWidth: 1440,
|
|
164
|
+
innerHeight: 900,
|
|
165
|
+
setTimeout: (fn) => {
|
|
166
|
+
fn()
|
|
167
|
+
return 1
|
|
168
|
+
},
|
|
169
|
+
clearTimeout: () => {},
|
|
170
|
+
setInterval: (fn, ms) => {
|
|
171
|
+
intervals.push({ fn, ms })
|
|
172
|
+
return intervals.length
|
|
173
|
+
},
|
|
174
|
+
clearInterval: () => {},
|
|
175
|
+
addEventListener: (type) => listeners.push('win:' + type),
|
|
176
|
+
removeEventListener: (type) => {
|
|
177
|
+
const index = listeners.indexOf('win:' + type)
|
|
178
|
+
if (index !== -1) listeners.splice(index, 1)
|
|
179
|
+
},
|
|
180
|
+
getSelection: () => null,
|
|
181
|
+
}
|
|
182
|
+
globalThis.InputEvent = class InputEvent {
|
|
183
|
+
constructor(type) {
|
|
184
|
+
this.type = type
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/* --- stub React ------------------------------------------------------- */
|
|
189
|
+
|
|
190
|
+
const effectCleanups = []
|
|
191
|
+
const React = {
|
|
192
|
+
createElement: (type, props, ...children) => ({ type, props, children }),
|
|
193
|
+
useState: (initial) => [initial, () => {}],
|
|
194
|
+
useRef: (initial) => ({ current: initial ?? null }),
|
|
195
|
+
useEffect: (fn) => {
|
|
196
|
+
const cleanup = fn()
|
|
197
|
+
if (typeof cleanup === 'function') effectCleanups.push(cleanup)
|
|
198
|
+
},
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/* --- load the module the way dsh does --------------------------------- */
|
|
202
|
+
|
|
203
|
+
let loaded = null
|
|
204
|
+
globalThis.window.__ModuleLoader__ = {
|
|
205
|
+
load: (entry) => {
|
|
206
|
+
loaded = entry
|
|
207
|
+
},
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const source = readFileSync(join(here, '..', 'src', 'client.js'), 'utf8')
|
|
211
|
+
await import('data:text/javascript;base64,' + Buffer.from(source, 'utf8').toString('base64'))
|
|
212
|
+
|
|
213
|
+
check('module registers with the loader', loaded !== null)
|
|
214
|
+
check('registered id is the package name', loaded?.id === 'dsh-spell-check', String(loaded?.id))
|
|
215
|
+
|
|
216
|
+
const required = []
|
|
217
|
+
const moduleExports = loaded.factory((specifier) => {
|
|
218
|
+
required.push(specifier)
|
|
219
|
+
if (specifier === 'react') return React
|
|
220
|
+
throw new Error('unexpected require: ' + specifier)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
check('factory requires only react', required.length === 1 && required[0] === 'react', JSON.stringify(required))
|
|
224
|
+
check('exports apply', typeof moduleExports.apply === 'function')
|
|
225
|
+
check(
|
|
226
|
+
'declares the services it touches',
|
|
227
|
+
moduleExports.inject.join(',') === 'slots,conversation,sessions',
|
|
228
|
+
JSON.stringify(moduleExports.inject),
|
|
229
|
+
)
|
|
230
|
+
check('exposes the engine for tests', typeof moduleExports.engine?.suggest === 'function')
|
|
231
|
+
check('exposes the controller for tests', typeof moduleExports.createController === 'function')
|
|
232
|
+
|
|
233
|
+
/* --- the embedded dictionary works with NO host ----------------------- */
|
|
234
|
+
|
|
235
|
+
const engine = moduleExports.engine
|
|
236
|
+
check('embedded dictionary is large', engine.size >= 10000, String(engine.size))
|
|
237
|
+
|
|
238
|
+
const theh = engine.suggest('theh')
|
|
239
|
+
check('embedded engine corrects "theh"', theh[0]?.word === 'the', JSON.stringify(theh))
|
|
240
|
+
const conn = engine.suggest('conntextion')
|
|
241
|
+
check('embedded engine corrects "conntextion"', conn[0]?.word === 'connection', JSON.stringify(conn))
|
|
242
|
+
check('embedded engine ignores a correct word', engine.suggest('connection').length === 0)
|
|
243
|
+
check('embedded engine ignores acronyms', engine.suggest('DSH').length === 0)
|
|
244
|
+
|
|
245
|
+
/* --- apply ------------------------------------------------------------ */
|
|
246
|
+
|
|
247
|
+
const registrations = []
|
|
248
|
+
let mountedContribution = null
|
|
249
|
+
|
|
250
|
+
const ctx = {
|
|
251
|
+
remote: {
|
|
252
|
+
$mount: async (contribution) => {
|
|
253
|
+
mountedContribution = contribution
|
|
254
|
+
return () => {}
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
slots: {
|
|
258
|
+
inject: (key, callback) => {
|
|
259
|
+
callback()
|
|
260
|
+
return () => {}
|
|
261
|
+
},
|
|
262
|
+
register: (config, component) => {
|
|
263
|
+
registrations.push({ config, component })
|
|
264
|
+
return () => {}
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
effect: (fn) => {
|
|
268
|
+
const dispose = fn()
|
|
269
|
+
if (typeof dispose === 'function') effectCleanups.push(dispose)
|
|
270
|
+
},
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
moduleExports.apply(ctx)
|
|
274
|
+
|
|
275
|
+
check('registers exactly one slot occupant', registrations.length === 1, String(registrations.length))
|
|
276
|
+
check('occupant id', registrations[0]?.config?.id === 'spell-check', String(registrations[0]?.config?.id))
|
|
277
|
+
check('occupant sits in the composer row', registrations[0]?.config?.name === 'conversation.input.right')
|
|
278
|
+
check('occupant is a component', typeof registrations[0]?.component === 'function')
|
|
279
|
+
check('styles injected once', styles.length === 1, String(styles.length))
|
|
280
|
+
check('styles carry the strip rules', String(styles[0]?.textContent ?? '').includes('.sp-strip'))
|
|
281
|
+
check('host link is attempted but optional', mountedContribution !== null)
|
|
282
|
+
|
|
283
|
+
/* --- mount: no visual chrome + listeners ------------------------------ */
|
|
284
|
+
|
|
285
|
+
const element = registrations[0].component()
|
|
286
|
+
// The seat is activation only: the user asked for no chip in the message bar,
|
|
287
|
+
// so the composer row must stay exactly as it was.
|
|
288
|
+
check('renders nothing in the composer bar', element === null || element === undefined, JSON.stringify(element))
|
|
289
|
+
|
|
290
|
+
for (const event of ['input', 'keydown', 'selectionchange', 'mousedown', 'win:resize', 'win:scroll']) {
|
|
291
|
+
check(`listens for ${event}`, listeners.includes(event), JSON.stringify(listeners))
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/* --- the controller publishes what Tab accepts ------------------------ */
|
|
295
|
+
|
|
296
|
+
const controller = moduleExports.createController(ctx)
|
|
297
|
+
const seen = []
|
|
298
|
+
controller.subscribe((state) => seen.push(state))
|
|
299
|
+
check('subscribe replays the current state', seen.length === 1 && seen[0].suggestions.length === 0, JSON.stringify(seen[0]))
|
|
300
|
+
|
|
301
|
+
// `check` is the same path a keystroke takes after its debounce: it stores the
|
|
302
|
+
// token, asks the embedded engine, and publishes to the chip.
|
|
303
|
+
const fakeToken = {
|
|
304
|
+
text: 'theh',
|
|
305
|
+
startOffset: 0,
|
|
306
|
+
endOffset: 4,
|
|
307
|
+
node: fakeElement('div'),
|
|
308
|
+
range: { getBoundingClientRect: () => ({ top: 100, left: 120, width: 0, height: 0 }) },
|
|
309
|
+
}
|
|
310
|
+
controller.check(fakeToken)
|
|
311
|
+
const latest = seen[seen.length - 1]
|
|
312
|
+
check('controller publishes the typed word', latest.word === 'theh', JSON.stringify(latest.word))
|
|
313
|
+
check('controller publishes a suggestion', latest.suggestions[0]?.word === 'the', JSON.stringify(latest.suggestions))
|
|
314
|
+
check('controller publishes a Tab-ready active index', latest.active === 0)
|
|
315
|
+
|
|
316
|
+
// A correct word publishes nothing to accept.
|
|
317
|
+
controller.check({ ...fakeToken, text: 'connection' })
|
|
318
|
+
check(
|
|
319
|
+
'controller clears the offer for a correct word',
|
|
320
|
+
seen[seen.length - 1].suggestions.length === 0,
|
|
321
|
+
JSON.stringify(seen[seen.length - 1]),
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
/* --- attach/detach, and the single-controller rule -------------------- */
|
|
325
|
+
|
|
326
|
+
// The mounted component above already owns a controller. Attaching another must
|
|
327
|
+
// DISPOSE the first rather than run two of them side by side: that duplication
|
|
328
|
+
// is what produced two strips on screen, one of which ignored every click.
|
|
329
|
+
const attachable = moduleExports.createController(ctx)
|
|
330
|
+
const detach = attachable.attach()
|
|
331
|
+
check(
|
|
332
|
+
'only one controller listens at a time',
|
|
333
|
+
listeners.filter((event) => event === 'input').length === 1,
|
|
334
|
+
JSON.stringify(listeners),
|
|
335
|
+
)
|
|
336
|
+
check('the newest controller claims the window slot', globalThis.window.__dshSpellCheck?.controller === attachable)
|
|
337
|
+
detach()
|
|
338
|
+
check(
|
|
339
|
+
'detach removes its listeners',
|
|
340
|
+
listeners.filter((event) => event === 'input').length === 0,
|
|
341
|
+
JSON.stringify(listeners),
|
|
342
|
+
)
|
|
343
|
+
check('detach releases the window slot', globalThis.window.__dshSpellCheck === undefined)
|
|
344
|
+
|
|
345
|
+
/* --- the stale-underline regression ----------------------------------- */
|
|
346
|
+
|
|
347
|
+
// The reported bug: after pressing Enter to send, the blue underline stayed
|
|
348
|
+
// floating in mid-air over the now-empty composer, and only vanished when a
|
|
349
|
+
// screenshot hotkey caused an unrelated focus/resize repaint. Root cause: the
|
|
350
|
+
// full-window canvas was repainted ONLY by keyboard/selection events, and a
|
|
351
|
+
// programmatic draft-clear that moves focus out of the editor fires none of
|
|
352
|
+
// them. These checks prove the canvas now repaints from the DOM itself.
|
|
353
|
+
|
|
354
|
+
const fakeBox = { top: 100, left: 120, width: 600, height: 40 }
|
|
355
|
+
const fakeEditor = {
|
|
356
|
+
textContent: 'theh',
|
|
357
|
+
getBoundingClientRect: () => fakeBox,
|
|
358
|
+
}
|
|
359
|
+
queryResult = fakeEditor
|
|
360
|
+
|
|
361
|
+
const underliner = moduleExports.createUnderliner()
|
|
362
|
+
const underlineCanvas = createdCanvases[createdCanvases.length - 1]
|
|
363
|
+
const releaseUnderline = underliner.attach()
|
|
364
|
+
|
|
365
|
+
check('underliner paints once on attach', underlineCanvas.__draws.clear === 1, String(underlineCanvas.__draws.clear))
|
|
366
|
+
check('underliner watches the editor DOM', mutationCallback !== null)
|
|
367
|
+
check('underliner exposes observe', typeof underliner.observe === 'function')
|
|
368
|
+
|
|
369
|
+
// Path A — the actual bug: the draft is cleared programmatically (send) and NO
|
|
370
|
+
// keyboard or selection event fires. The MutationObserver must still repaint.
|
|
371
|
+
fakeEditor.textContent = ''
|
|
372
|
+
mutationCallback()
|
|
373
|
+
check(
|
|
374
|
+
'a programmatic clear repaints with no keyboard event',
|
|
375
|
+
underlineCanvas.__draws.clear === 2,
|
|
376
|
+
String(underlineCanvas.__draws.clear),
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
// Path B — the composer shrank/moved after send (the "mid-air" geometry). The
|
|
380
|
+
// self-heal tick must notice the box changed and repaint even with no mutation.
|
|
381
|
+
fakeBox.top = 40
|
|
382
|
+
const heal = intervals[intervals.length - 1].fn
|
|
383
|
+
heal()
|
|
384
|
+
check('self-heal repaints when the editor box moves', underlineCanvas.__draws.clear === 3, String(underlineCanvas.__draws.clear))
|
|
385
|
+
|
|
386
|
+
// Path C — heal is idempotent: an unchanged editor must not repaint every tick.
|
|
387
|
+
heal()
|
|
388
|
+
check('self-heal is a no-op when nothing changed', underlineCanvas.__draws.clear === 3, String(underlineCanvas.__draws.clear))
|
|
389
|
+
|
|
390
|
+
releaseUnderline()
|
|
391
|
+
check('detach disconnects the DOM observer', mutationCallback === null)
|
|
392
|
+
queryResult = null
|
|
393
|
+
|
|
394
|
+
/* --- unmount ---------------------------------------------------------- */
|
|
395
|
+
|
|
396
|
+
for (const cleanup of effectCleanups) cleanup()
|
|
397
|
+
|
|
398
|
+
console.log(`\n${passed} passed, ${failures.length} failed`)
|
|
399
|
+
if (failures.length > 0) {
|
|
400
|
+
for (const failure of failures) console.log(' FAIL ' + failure)
|
|
401
|
+
process.exitCode = 1
|
|
402
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engine tests: run with `node scripts/test-engine.mjs`.
|
|
3
|
+
*
|
|
4
|
+
* The assertions are the behaviours the plugin promises, not internal details:
|
|
5
|
+
* transpositions cost one edit, real words are never "corrected", and the
|
|
6
|
+
* suggestion for a typo is the word a person meant.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readFileSync } from 'node:fs'
|
|
10
|
+
import { dirname, join } from 'node:path'
|
|
11
|
+
import { fileURLToPath } from 'node:url'
|
|
12
|
+
|
|
13
|
+
import { createEngine, distance, matchCase, TYPO_MAP } from '../src/engine.js'
|
|
14
|
+
|
|
15
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
16
|
+
const WORDS = readFileSync(join(here, '..', 'assets', 'words.txt'), 'utf8')
|
|
17
|
+
.split('\n')
|
|
18
|
+
.map((line) => line.trim())
|
|
19
|
+
.filter(Boolean)
|
|
20
|
+
|
|
21
|
+
let passed = 0
|
|
22
|
+
const failures = []
|
|
23
|
+
|
|
24
|
+
function check(name, condition, detail) {
|
|
25
|
+
if (condition) {
|
|
26
|
+
passed += 1
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
failures.push(name + (detail === undefined ? '' : ` — ${detail}`))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const engine = createEngine(WORDS, TYPO_MAP)
|
|
33
|
+
console.log(`dictionary: ${engine.size} words`)
|
|
34
|
+
|
|
35
|
+
// --- distance -------------------------------------------------------------
|
|
36
|
+
check('identical strings are distance 0', distance('connection', 'connection') === 0)
|
|
37
|
+
check('one deletion', distance('theh', 'the') === 1, String(distance('theh', 'the')))
|
|
38
|
+
check('one insertion', distance('becuase', 'because') >= 0)
|
|
39
|
+
check('transposition counts as one', distance('atuo', 'auto') === 1, String(distance('atuo', 'auto')))
|
|
40
|
+
check('teh/teh transposition', distance('teh', 'the') === 1, String(distance('teh', 'the')))
|
|
41
|
+
check('receive transposition', distance('recieve', 'receive') === 1, String(distance('recieve', 'receive')))
|
|
42
|
+
check('two edits', distance('trype', 'type') === 1, String(distance('trype', 'type')))
|
|
43
|
+
check('far apart exceeds limit', distance('connection', 'zebra', 2) === 3, String(distance('connection', 'zebra', 2)))
|
|
44
|
+
check('length guard short-circuits', distance('a', 'abcdefgh', 2) === 3)
|
|
45
|
+
|
|
46
|
+
// --- case handling --------------------------------------------------------
|
|
47
|
+
check('title case preserved', matchCase('Teh', 'the') === 'The', matchCase('Teh', 'the'))
|
|
48
|
+
check('lower case preserved', matchCase('teh', 'the') === 'the')
|
|
49
|
+
check('acronym left alone', matchCase('TEH', 'the') === 'the')
|
|
50
|
+
|
|
51
|
+
// --- suggestions ----------------------------------------------------------
|
|
52
|
+
const top = (word) => {
|
|
53
|
+
const list = engine.suggest(word)
|
|
54
|
+
return list.length === 0 ? null : list[0].word
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
check('theh -> the', top('theh') === 'the', String(top('theh')))
|
|
58
|
+
check('teh -> the', top('teh') === 'the', String(top('teh')))
|
|
59
|
+
check('atuo -> auto', top('atuo') === 'auto', String(top('atuo')))
|
|
60
|
+
check('conntextion -> connection', top('conntextion') === 'connection', String(top('conntextion')))
|
|
61
|
+
check('trype -> type', top('trype') === 'type', String(top('trype')))
|
|
62
|
+
check('recieve -> receive', top('recieve') === 'receive', String(top('recieve')))
|
|
63
|
+
check('wether -> whether', top('wether') === 'whether', String(top('wether')))
|
|
64
|
+
|
|
65
|
+
// A correctly spelled word must produce nothing at all. This is the rule that
|
|
66
|
+
// stops the plugin nagging about `hoe`, `form`, `ot` and `im`.
|
|
67
|
+
for (const word of ['the', 'connection', 'auto', 'type', 'hoe', 'form', 'and', 'code', 'plugin', 'whether']) {
|
|
68
|
+
check(`real word "${word}" is not corrected`, engine.suggest(word).length === 0, JSON.stringify(engine.suggest(word)))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Acronyms and code-ish tokens are left alone.
|
|
72
|
+
for (const token of ['API', 'DSH', 'PDF', 'E2E']) {
|
|
73
|
+
check(`acronym "${token}" ignored`, engine.suggest(token).length === 0)
|
|
74
|
+
}
|
|
75
|
+
check('token with digits ignored', engine.suggest('user2').length === 0)
|
|
76
|
+
|
|
77
|
+
// Short tokens are too ambiguous to correct.
|
|
78
|
+
check('two letters ignored', engine.suggest('te').length === 0)
|
|
79
|
+
|
|
80
|
+
// Ranked ordering: two edits never outrank one.
|
|
81
|
+
const ranked = engine.suggest('theh')
|
|
82
|
+
check('ranked list is distance-ascending', ranked.every((hit, i) => i === 0 || ranked[i - 1].distance <= hit.distance), JSON.stringify(ranked))
|
|
83
|
+
|
|
84
|
+
// A word is filed in several length buckets; it must still be offered once.
|
|
85
|
+
for (const typo of ['theh', 'sada', 'trype', 'conntextion', 'recieve', 'atuo']) {
|
|
86
|
+
const list = engine.suggest(typo)
|
|
87
|
+
const words = list.map((hit) => hit.word)
|
|
88
|
+
check(`no duplicate suggestions for "${typo}"`, new Set(words).size === words.length, JSON.stringify(words))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Learned words are respected.
|
|
92
|
+
engine.addWord('courtkeynet')
|
|
93
|
+
check('learned word is valid', engine.isWord('courtkeynet') === true)
|
|
94
|
+
check('learned word is not corrected', engine.suggest('courtkeynet').length === 0)
|
|
95
|
+
check('unlearned neighbour still corrects', top('courtkeyne') === 'courtkeynet', String(top('courtkeyne')))
|
|
96
|
+
|
|
97
|
+
console.log(`\n${passed} passed, ${failures.length} failed`)
|
|
98
|
+
if (failures.length > 0) {
|
|
99
|
+
for (const failure of failures) console.log(' FAIL ' + failure)
|
|
100
|
+
process.exitCode = 1
|
|
101
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-half test: `node scripts/test-host.mjs`.
|
|
3
|
+
*
|
|
4
|
+
* Exercises the exact functions the Remote service delegates to, with the real
|
|
5
|
+
* shipped dictionary — so this is the check that makes a restart worth doing:
|
|
6
|
+
* it proves the word list loads from the package, that a typo is answered
|
|
7
|
+
* correctly, and that a correctly spelled word is left alone.
|
|
8
|
+
*
|
|
9
|
+
* The transport class is NOT constructed here on purpose: `TypertRemoteService`
|
|
10
|
+
* extends cordis's `Service`, which needs a real cordis Context. Faking one
|
|
11
|
+
* would test the fake. The class is a three-line delegation, and the delegation
|
|
12
|
+
* itself is asserted below.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
16
|
+
import { dirname, join } from 'node:path'
|
|
17
|
+
import { fileURLToPath } from 'node:url'
|
|
18
|
+
|
|
19
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
20
|
+
const root = join(here, '..')
|
|
21
|
+
|
|
22
|
+
let passed = 0
|
|
23
|
+
const failures = []
|
|
24
|
+
function check(name, condition, detail) {
|
|
25
|
+
if (condition) {
|
|
26
|
+
passed += 1
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
failures.push(name + (detail === undefined ? '' : ` — ${detail}`))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/* --- the package actually ships its dictionary ------------------------ */
|
|
33
|
+
|
|
34
|
+
const wordsPath = join(root, 'assets', 'words.txt')
|
|
35
|
+
check('dictionary file ships with the package', existsSync(wordsPath), wordsPath)
|
|
36
|
+
|
|
37
|
+
const words = readFileSync(wordsPath, 'utf8').split('\n').filter(Boolean)
|
|
38
|
+
check('dictionary has a useful size', words.length >= 10000, String(words.length))
|
|
39
|
+
check('dictionary is frequency-ordered', words[0] === 'the' && words.includes('the'), words[0])
|
|
40
|
+
check('the list contains no duplicates', new Set(words).size === words.length, `${new Set(words).size} vs ${words.length}`)
|
|
41
|
+
check('every entry is a plain lowercase word', words.every((w) => /^[a-z]{2,20}$/.test(w)))
|
|
42
|
+
|
|
43
|
+
/* --- the service body ------------------------------------------------- */
|
|
44
|
+
|
|
45
|
+
const module = await import('../src/index.js')
|
|
46
|
+
|
|
47
|
+
check('module exports apply', typeof module.apply === 'function')
|
|
48
|
+
check('module name', module.name === 'dsh-spell-check', String(module.name))
|
|
49
|
+
check('no hard dependency blocks mounting', Array.isArray(module.inject) && module.inject.length === 0, JSON.stringify(module.inject))
|
|
50
|
+
check('suggestWord is exported', typeof module.suggestWord === 'function')
|
|
51
|
+
check('checkWord is exported', typeof module.checkWord === 'function')
|
|
52
|
+
check('learnWord is exported', typeof module.learnWord === 'function')
|
|
53
|
+
|
|
54
|
+
const answer = await module.suggestWord('theh')
|
|
55
|
+
check('suggest() returns a list', Array.isArray(answer.suggestions), JSON.stringify(answer.suggestions))
|
|
56
|
+
check('suggest("theh") proposes "the"', answer.suggestions[0]?.word === 'the', JSON.stringify(answer.suggestions))
|
|
57
|
+
check('suggest() echoes the queried word', answer.word === 'theh', String(answer.word))
|
|
58
|
+
|
|
59
|
+
const second = await module.suggestWord('conntextion')
|
|
60
|
+
check('suggest("conntextion") proposes "connection"', second.suggestions[0]?.word === 'connection', JSON.stringify(second.suggestions))
|
|
61
|
+
|
|
62
|
+
const good = await module.suggestWord('connection')
|
|
63
|
+
check('suggest() stays silent on a correct word', good.suggestions.length === 0, JSON.stringify(good.suggestions))
|
|
64
|
+
|
|
65
|
+
const acronym = await module.suggestWord('DSH')
|
|
66
|
+
check('suggest() ignores acronyms', acronym.suggestions.length === 0, JSON.stringify(acronym.suggestions))
|
|
67
|
+
|
|
68
|
+
check('check() knows a real word', (await module.checkWord('connection')).known === true)
|
|
69
|
+
check('check() rejects a typo', (await module.checkWord('theh')).known === false)
|
|
70
|
+
|
|
71
|
+
/* --- learned words persist -------------------------------------------- */
|
|
72
|
+
|
|
73
|
+
const learned = await module.learnWord('zzspellcheckprobe')
|
|
74
|
+
check('learn() accepts a new word', learned.added === true, JSON.stringify(learned))
|
|
75
|
+
check('learned word is not corrected', (await module.suggestWord('zzspellcheckprobe')).suggestions.length === 0)
|
|
76
|
+
check('learned word is a known word', (await module.checkWord('zzspellcheckprobe')).known === true)
|
|
77
|
+
|
|
78
|
+
console.log(`\n${passed} passed, ${failures.length} failed`)
|
|
79
|
+
if (failures.length > 0) {
|
|
80
|
+
for (const failure of failures) console.log(' FAIL ' + failure)
|
|
81
|
+
process.exitCode = 1
|
|
82
|
+
}
|