dsh-wisp 0.6.0 → 1.25.1

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.
Files changed (68) hide show
  1. package/README.md +869 -10
  2. package/assets/canon/attn.webp +0 -0
  3. package/assets/canon/happy.webp +0 -0
  4. package/assets/canon/idle.webp +0 -0
  5. package/assets/canon/poked.webp +0 -0
  6. package/assets/canon/proud.webp +0 -0
  7. package/assets/canon/sleepy.webp +0 -0
  8. package/assets/canon/work.webp +0 -0
  9. package/assets/classic/attn.webp +0 -0
  10. package/assets/classic/happy.webp +0 -0
  11. package/assets/classic/idle.webp +0 -0
  12. package/assets/classic/poked.webp +0 -0
  13. package/assets/classic/proud.webp +0 -0
  14. package/assets/classic/sleepy.webp +0 -0
  15. package/assets/classic/work.webp +0 -0
  16. package/assets/deepsea/attn.webp +0 -0
  17. package/assets/deepsea/happy.webp +0 -0
  18. package/assets/deepsea/idle.webp +0 -0
  19. package/assets/deepsea/poked.webp +0 -0
  20. package/assets/deepsea/proud.webp +0 -0
  21. package/assets/deepsea/sleepy.webp +0 -0
  22. package/assets/deepsea/work.webp +0 -0
  23. package/assets/night/attn.webp +0 -0
  24. package/assets/night/happy.webp +0 -0
  25. package/assets/night/idle.webp +0 -0
  26. package/assets/night/poked.webp +0 -0
  27. package/assets/night/proud.webp +0 -0
  28. package/assets/night/sleepy.webp +0 -0
  29. package/assets/night/work.webp +0 -0
  30. package/assets/pajama/attn.webp +0 -0
  31. package/assets/pajama/happy.webp +0 -0
  32. package/assets/pajama/idle.webp +0 -0
  33. package/assets/pajama/poked.webp +0 -0
  34. package/assets/pajama/proud.webp +0 -0
  35. package/assets/pajama/sleepy.webp +0 -0
  36. package/assets/pajama/work.webp +0 -0
  37. package/audit/generation-audit.jsonl +35 -0
  38. package/build.mjs +93 -30
  39. package/cordis.patch.yml +2 -0
  40. package/lib/client.js +1293 -41
  41. package/lib/client.template.js +1248 -37
  42. package/lib/index.js +115 -12
  43. package/package.json +8 -1
  44. package/tools/CHARACTER-PROMPT.md +221 -3
  45. package/tools/assets.mjs +39 -16
  46. package/tools/audit-log.mjs +245 -0
  47. package/tools/bundle-cost.mjs +105 -0
  48. package/tools/pack-check.mjs +78 -0
  49. package/tools/perf.mjs +186 -0
  50. package/tools/smoke.mjs +577 -0
  51. package/tools/tiers.mjs +104 -0
  52. package/verify-wisp.mjs +2681 -872
  53. package/assets/happy.webp +0 -0
  54. package/assets/happy_hi.webp +0 -0
  55. package/assets/happy_md.webp +0 -0
  56. package/assets/happy_sm.webp +0 -0
  57. package/assets/idle.webp +0 -0
  58. package/assets/idle_hi.webp +0 -0
  59. package/assets/idle_md.webp +0 -0
  60. package/assets/idle_sm.webp +0 -0
  61. package/assets/sleepy.webp +0 -0
  62. package/assets/sleepy_hi.webp +0 -0
  63. package/assets/sleepy_md.webp +0 -0
  64. package/assets/sleepy_sm.webp +0 -0
  65. package/assets/work.webp +0 -0
  66. package/assets/work_hi.webp +0 -0
  67. package/assets/work_md.webp +0 -0
  68. package/assets/work_sm.webp +0 -0
package/verify-wisp.mjs CHANGED
@@ -1,872 +1,2681 @@
1
- /* ============================================================================
2
- verify-wisp.mjs — pre-flight checks for the dsh-wisp bundle.
3
-
4
- TWO FILES, TWO DIFFERENT CONTRACTS. Getting this wrong caused every load
5
- failure this plugin had, so the checks are organised around it.
6
-
7
- lib/index.js the HOST half — a plain ES module the host imports.
8
- The loader entry in cordis.patch.yml points at the PACKAGE
9
- (`dsh-wisp`), which resolves here.
10
-
11
- lib/client.js the BROWSER half — a CLASSIC script that registers with the
12
- web shell: window.__ModuleLoader__.load({ id, factory })
13
- Reached through exports["./client"], served by /plugins.
14
-
15
- The browser half is not just syntax-checked: it is EXECUTED against a
16
- harness that reproduces the surfaces it really meets —
17
-
18
- * all six globals the dynamic runner traps (setTimeout, setInterval,
19
- clearTimeout, clearInterval, fetch, require) are shadowed by THROWING
20
- functions, so touching one fails the run instead of the user's session;
21
- * a virtual clock drives BOTH scheduling paths (the Client `timer` service
22
- and the requestAnimationFrame fallback), so timer behaviour is asserted
23
- rather than assumed;
24
- * a fake DOM, Blob/URL pipeline, localStorage (including one that throws,
25
- like an opaque origin), and a scriptable busy/composer state.
26
-
27
- RUN
28
- node verify-wisp.mjs
29
- EXIT CODE 0 = both halves match their contract.
30
- ========================================================================== */
31
-
32
- import { readFileSync, statSync, existsSync } from 'node:fs'
33
- import { fileURLToPath, pathToFileURL } from 'node:url'
34
- import { dirname, join, resolve } from 'node:path'
35
-
36
- const here = dirname(fileURLToPath(import.meta.url))
37
- const pkgPath = join(here, 'package.json')
38
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
39
-
40
- const hostPath = resolve(here, pkg.main ?? 'lib/index.js')
41
- const clientRel = pkg.exports?.['./client']?.default
42
- const clientPath = clientRel ? resolve(here, clientRel) : null
43
-
44
- let failures = 0
45
- const ok = (l, d = '') => console.log(` PASS ${l}${d ? ' — ' + d : ''}`)
46
- const bad = (l, d = '') => { failures++; console.log(` FAIL ${l}${d ? ' — ' + d : ''}`) }
47
- const head = (t) => console.log(`\n=== ${t} ===`)
48
- const check = (cond, l, d = '') => (cond ? ok(l, d) : bad(l, d))
49
-
50
- /* ============================================================ 0. the traps == */
51
-
52
- /**
53
- * The six globals a dynamic client half must never touch. The runner shadows
54
- * them with throwing parameters; this suite reproduces that exactly, so a
55
- * regression here fails the build instead of crashing a live page.
56
- */
57
- const TRAPPED = ['setTimeout', 'setInterval', 'clearTimeout', 'clearInterval', 'fetch', 'require']
58
-
59
- /** The client half's reaction poll period; kept in step with lib/client.template.js. */
60
- const POLL_MS = 1200
61
-
62
- /* ==================================================== the browser harness === */
63
-
64
- let active = null
65
- let urlSeq = 0
66
-
67
- class FakeBlob {
68
- constructor(parts, opts) {
69
- this.parts = parts
70
- this.type = (opts && opts.type) || ''
71
- this.size = parts[0] ? parts[0].length : 0
72
- if (active) active.blobs.push(this)
73
- }
74
- }
75
- const FakeURL = {
76
- createObjectURL() { const url = `blob:dsh-app/verify-${++urlSeq}`; if (active) active.urLs.push(url); return url },
77
- revokeObjectURL(url) { if (active) active.revoked.push(url) },
78
- }
79
- /* The constructor writes the backing field directly: routing its own
80
- initialisation through the instrumented setter would record a phantom empty
81
- src for every `new Image()`. */
82
- const FakeImage = function FakeImage() { this._src = '' }
83
- Object.defineProperty(FakeImage.prototype, 'src', {
84
- get() { return this._src ?? '' },
85
- set(value) {
86
- this._src = value
87
- if (active) active.srcs.push(value)
88
- // The plugin builds its alpha hit-mask from an Image load, so the harness
89
- // fires onload synchronously rather than leaving the mask path untested.
90
- if (typeof this.onload === 'function') this.onload()
91
- },
92
- })
93
- const fakeAtob = (s) => Buffer.from(s, 'base64').toString('binary')
94
-
95
- /**
96
- * Build one isolated page.
97
- * @param {object} [options] - timer (service present), busy, composerText,
98
- * storage ('memory' | 'throwing' | 'absent'), theme, viewport.
99
- * @returns the harness: element factory, dispatch helpers, clock driver, ctx.
100
- */
101
- function createHarness(options = {}) {
102
- const state = {
103
- now: 0,
104
- busy: !!options.busy,
105
- composerText: options.composerText === undefined ? null : options.composerText,
106
- }
107
-
108
- const created = []
109
- const frames = []
110
- const rafSlots = new Map()
111
- let rafSeq = 0
112
- const timers = []
113
- const store = new Map()
114
-
115
- const makeEl = (tag) => {
116
- const el = {
117
- tagName: String(tag).toUpperCase(),
118
- children: [], parentNode: null, dataset: {}, attrs: {},
119
- style: { setProperty(k, v) { this[k] = v }, getPropertyValue(k) { return this[k] ?? '' } },
120
- className: '', textContent: '', id: '', value: '',
121
- listeners: Object.create(null),
122
- _src: '',
123
- get firstChild() { return this.children[0] ?? null },
124
- get src() { return this._src },
125
- set src(v) { this._src = v; if (this.tagName === 'IMG') harness.srcs.push(v) },
126
- setAttribute(k, v) { this.attrs[k] = String(v) },
127
- getAttribute(k) { return Object.hasOwn(this.attrs, k) ? this.attrs[k] : null },
128
- appendChild(c) { if (c.parentNode) c.parentNode.removeChild(c); c.parentNode = this; this.children.push(c); return c },
129
- append(...cs) { for (const c of cs) this.appendChild(c) },
130
- insertBefore(c, before) {
131
- if (c.parentNode) c.parentNode.removeChild(c)
132
- c.parentNode = this
133
- const at = before ? this.children.indexOf(before) : -1
134
- if (at < 0) this.children.unshift(c)
135
- else this.children.splice(at, 0, c)
136
- return c
137
- },
138
- removeChild(c) { this.children = this.children.filter((x) => x !== c); c.parentNode = null; return c },
139
- remove() { if (this.parentNode) this.parentNode.removeChild(this) },
140
- descendants() { return this.children.flatMap((c) => [c, ...c.descendants()]) },
141
- querySelector(sel) {
142
- const cls = typeof sel === 'string' && sel.startsWith('.') ? sel.slice(1) : null
143
- return cls === null ? null : this.descendants().find((d) => d.className === cls) ?? null
144
- },
145
- querySelectorAll(sel) {
146
- const cls = typeof sel === 'string' && sel.startsWith('.') ? sel.slice(1) : null
147
- return cls === null ? [] : this.descendants().filter((d) => d.className === cls)
148
- },
149
- addEventListener(type, fn) { (this.listeners[type] ??= []).push(fn) },
150
- removeEventListener(type, fn) {
151
- const list = this.listeners[type]
152
- if (!list) return
153
- const at = list.indexOf(fn)
154
- if (at >= 0) list.splice(at, 1)
155
- },
156
- dispatch(type, event) { for (const fn of [...(this.listeners[type] ?? [])]) fn(event ?? {}) },
157
- listenerCount(type) { return (this.listeners[type] ?? []).length },
158
- }
159
- if (String(tag).toLowerCase() === 'canvas') {
160
- // A deterministic stand-in for the sprite's alpha channel: opaque across
161
- // the middle half of the width, transparent outside it. That is what the
162
- // shipped sprites look like (55-87% of the width is figure, the rest is
163
- // margin) and it makes the hit test assertable.
164
- el.getContext = () => ({
165
- drawImage() {},
166
- getImageData(x, y, w, h) {
167
- const data = new Uint8ClampedArray(w * h * 4)
168
- for (let py = 0; py < h; py++) {
169
- for (let px = 0; px < w; px++) {
170
- const opaque = px >= w * 0.25 && px < w * 0.75
171
- data[(py * w + px) * 4 + 3] = opaque ? 255 : 0
172
- }
173
- }
174
- return { data }
175
- },
176
- })
177
- }
178
- created.push(el)
179
- return el
180
- }
181
-
182
- const bodyEl = makeEl('body')
183
- const headEl = makeEl('head')
184
- /* The composer is a live node whose text follows `state.composerText`, and it
185
- is absent (null) while that is null — so the plugin's caching AND its
186
- re-resolution are both exercised for real. */
187
- const composerNode = {
188
- tagName: 'DIV',
189
- isConnected: true,
190
- get textContent() { return state.composerText ?? '' },
191
- }
192
- const queries = { stop: 0, composer: 0, all: 0 }
193
- const COMPOSER_HOOKS = ['[data-composer-input]', 'textarea', '[contenteditable="true"]']
194
- const documentShim = {
195
- body: bodyEl,
196
- head: headEl,
197
- createElement: (t) => makeEl(t),
198
- getElementById: (id) => created.find((e) => e.id === id) ?? null,
199
- querySelector: (sel) => {
200
- if (typeof sel !== 'string') return null
201
- if (sel.startsWith('button[')) { queries.stop++; return state.busy ? {} : null }
202
- if (COMPOSER_HOOKS.includes(sel)) {
203
- queries.composer++
204
- return state.composerText === null ? null : composerNode
205
- }
206
- return null
207
- },
208
- querySelectorAll: (sel) => { queries.all++; return [] },
209
- }
210
-
211
- const winListeners = Object.create(null)
212
- const localStorageShim = options.storage === 'absent' ? undefined : {
213
- getItem: (k) => {
214
- if (options.storage === 'throwing') throw new Error('storage disabled (opaque origin)')
215
- return store.has(k) ? store.get(k) : null
216
- },
217
- setItem: (k, v) => {
218
- if (options.storage === 'throwing') throw new Error('storage disabled (opaque origin)')
219
- store.set(k, String(v))
220
- },
221
- removeItem: (k) => {
222
- if (options.storage === 'throwing') throw new Error('storage disabled (opaque origin)')
223
- store.delete(k)
224
- },
225
- }
226
-
227
- const win = {
228
- innerWidth: options.width ?? 1920,
229
- innerHeight: options.height ?? 1200,
230
- performance: { now: () => state.now },
231
- requestAnimationFrame: (fn) => { const id = ++rafSeq; rafSlots.set(id, fn); frames.push({ id, fn }); return id },
232
- cancelAnimationFrame: (id) => {
233
- // A no-op here would let a disposed frame survive teardown and make the
234
- // "the loop stops" assertions meaningless.
235
- if (!rafSlots.has(id)) return
236
- rafSlots.delete(id)
237
- const at = frames.findIndex((rec) => rec.id === id)
238
- if (at >= 0) frames.splice(at, 1)
239
- },
240
- localStorage: localStorageShim,
241
- addEventListener(type, fn) { (winListeners[type] ??= []).push(fn) },
242
- removeEventListener(type, fn) {
243
- const list = winListeners[type]
244
- if (!list) return
245
- const at = list.indexOf(fn)
246
- if (at >= 0) list.splice(at, 1)
247
- },
248
- dispatch(type, event) { for (const fn of [...(winListeners[type] ?? [])]) fn(event ?? {}) },
249
- listenerCount: (type) => (winListeners[type] ?? []).length,
250
- __ModuleLoader__: { load(def) { harness.loaded = def } },
251
- }
252
- win.window = win
253
-
254
- /* The Client `timer` service: registrations plus their disposers. */
255
- const timerService = options.timer === false ? undefined : {
256
- timeout(cb, ms) { const t = { cb, due: state.now + ms, every: 0, cancelled: false }; timers.push(t); return () => { t.cancelled = true } },
257
- interval(cb, ms) { const t = { cb, due: state.now + ms, every: ms, cancelled: false }; timers.push(t); return () => { t.cancelled = true } },
258
- }
259
-
260
- const themeListeners = []
261
- const themeSnapshot = { preference: options.themeDark ? 'dark' : 'light', active: { id: 'x', colorScheme: options.themeDark ? 'dark' : 'light', tokens: {} } }
262
- let teardown = null
263
- const styleInserts = []
264
-
265
- const ctx = {
266
- get: (name) => {
267
- if (name === 'timer') return timerService
268
- if (name === 'styles') return { insert: (css) => { styleInserts.push(css); return () => { styleInserts.pop() } } }
269
- if (name === 'theme') return { getTheme: () => themeSnapshot }
270
- return undefined
271
- },
272
- on: (name, fn) => {
273
- if (name !== 'theme/change') return () => {}
274
- themeListeners.push(fn)
275
- return () => { const at = themeListeners.indexOf(fn); if (at >= 0) themeListeners.splice(at, 1) }
276
- },
277
- effect: (fn) => { teardown = fn; return () => {} },
278
- }
279
-
280
- const harness = {
281
- state, ctx, win, document: documentShim, bodyEl, created, frames, timers, queries,
282
- srcs: [], urLs: [], revoked: [], blobs: [], styleInserts,
283
- loaded: null,
284
- timerService,
285
- get teardown() { return teardown },
286
- emitTheme(snapshot) { for (const fn of [...themeListeners]) fn(snapshot ?? themeSnapshot) },
287
- themeListenerCount: () => themeListeners.length,
288
- fireTimers() {
289
- for (let guard = 0; guard < 2000; guard++) {
290
- const due = timers.filter((t) => !t.cancelled && t.due <= state.now).sort((a, b) => a.due - b.due)
291
- if (due.length === 0) return
292
- const t = due[0]
293
- if (t.every > 0) t.due = state.now + t.every
294
- else { t.cancelled = true; timers.splice(timers.indexOf(t), 1) }
295
- t.cb()
296
- }
297
- },
298
- /** Advance virtual time, pumping both scheduling paths. */
299
- advance(ms, stepMs = 25) {
300
- let left = ms
301
- while (left > 0) {
302
- const delta = Math.min(stepMs, left)
303
- state.now += delta
304
- left -= delta
305
- harness.fireTimers()
306
- // Two frames per step is enough to run everything already due; a frame
307
- // loop that re-arms itself does not need to be drained to infinity.
308
- for (let i = 0; i < 2 && frames.length > 0; i++) {
309
- const rec = frames.shift()
310
- rafSlots.delete(rec.id)
311
- rec.fn()
312
- }
313
- }
314
- },
315
- pendingTimers: () => timers.filter((t) => !t.cancelled),
316
- find: (className) => created.find((e) => e.className === className) ?? null,
317
- all: (className) => created.filter((e) => e.className === className),
318
- /** Evaluate lib/client.js the way the shell does, with every trap armed. */
319
- evaluate(src) {
320
- const traps = {}
321
- for (const name of TRAPPED) traps[name] = () => { throw new Error(`${name} is not available in a dynamic client half`) }
322
- // eslint-disable-next-line no-new-func
323
- new Function('window', 'document', ...TRAPPED, src)(
324
- win, documentShim, ...TRAPPED.map((name) => traps[name]),
325
- )
326
- },
327
- module() {
328
- if (!harness.loaded) throw new Error('load() was never called')
329
- return harness.loaded.factory(() => ({}))
330
- },
331
- }
332
- return harness
333
- }
334
-
335
- /* ======================================================= 1. the HOST half == */
336
-
337
- head('1. host half (lib/index.js) must be importable ES module')
338
-
339
- if (!existsSync(hostPath)) {
340
- bad('host half exists', pkg.main)
341
- } else {
342
- const src = readFileSync(hostPath, 'utf8')
343
- if (/^\s*(import|export)\s/m.test(src)) ok('uses ES module syntax')
344
- else bad('uses ES module syntax', 'the host imports this file; a classic script cannot be imported')
345
-
346
- try {
347
- const mod = await import(pathToFileURL(hostPath).href + '?probe=host')
348
- ok('host module imports cleanly')
349
- if (typeof mod.apply === 'function') ok('exports apply()')
350
- else bad('exports apply()')
351
- } catch (error) {
352
- bad('host module imports cleanly', `${error.constructor.name}: ${error.message}`)
353
- }
354
- }
355
-
356
- /* ===================================================== 2. the BROWSER half == */
357
-
358
- head('2. browser half (lib/client.js) must register with the module loader')
359
-
360
- const clientSrc = clientPath && existsSync(clientPath) ? readFileSync(clientPath, 'utf8') : null
361
-
362
- if (clientSrc === null) {
363
- bad('exports["./client"] resolves', String(clientRel))
364
- } else {
365
- if (/^\s*(import|export)\s/m.test(clientSrc)) {
366
- bad('no import/export statements', 'this is a classic script, not a module')
367
- } else ok('no import/export statements')
368
-
369
- if (/window\.__ModuleLoader__\.load\(\s*\{/.test(clientSrc)) ok('calls window.__ModuleLoader__.load({…})')
370
- else bad('calls window.__ModuleLoader__.load({…})', 'this is how a browser half registers')
371
-
372
- if (/id:\s*['"]dsh-wisp\/client['"]/.test(clientSrc)) ok("registers under id 'dsh-wisp/client'")
373
- else bad("registers under id 'dsh-wisp/client'")
374
-
375
- if (/factory:\s*\(require\)\s*=>/.test(clientSrc)) ok('factory takes `require`')
376
- else bad('factory takes `require`')
377
-
378
- if (/return\s+module\.exports/.test(clientSrc)) ok('factory returns module.exports')
379
- else bad('factory returns module.exports')
380
-
381
- if (/<\/script/i.test(clientSrc)) bad('no literal </script>', 'would break inline embedding')
382
- else ok('no literal </script>')
383
-
384
- // Static floor under the trap test below: a call to a trapped global is a
385
- // guaranteed runtime throw in a client half, and no bundler flags it.
386
- const trappedCall = clientSrc.match(/(setTimeout|setInterval|clearTimeout|clearInterval|fetch|require)\s*\(/)
387
- if (trappedCall) bad('never calls a trapped global', `found ${trappedCall[1]}(`)
388
- else ok('never calls a trapped global', 'checked statically; execution below proves it')
389
- }
390
-
391
- /* ============================ 3. the browser half, executed and driven ===== */
392
-
393
- head('3. browser half runs against the real contract (traps armed)')
394
-
395
- const installFakes = () => {
396
- globalThis.URL = FakeURL
397
- globalThis.Blob = FakeBlob
398
- globalThis.atob = fakeAtob
399
- globalThis.Image = FakeImage
400
- }
401
-
402
- if (clientSrc !== null) {
403
- const savedGlobals = { URL: globalThis.URL, Blob: globalThis.Blob, atob: globalThis.atob, Image: globalThis.Image }
404
- installFakes()
405
-
406
- try {
407
- /* ---------------------------------------------------------- 3a. mount -- */
408
- head('3a. Client timer service path')
409
-
410
- const h = createHarness({ timer: true })
411
- active = h
412
- h.evaluate(clientSrc)
413
- ok('registers without throwing', `id=${h.loaded.id}`)
414
-
415
- const exportsObj = h.module()
416
- const plugin = exportsObj?.default
417
- check(typeof plugin?.apply === 'function', 'exports a mountable plugin', `name=${plugin?.name}`)
418
-
419
- plugin.apply(h.ctx, { happyMs: 6000 })
420
- const api = h.win.__wisp
421
- check(typeof api?.destroy === 'function', 'public handle exposed', Object.keys(api ?? {}).join(','))
422
- check(api?.clock === 'timer-service', 'schedules on the Client timer service', String(api?.clock))
423
-
424
- h.advance(50)
425
- const layer = h.find('wisp-layer')
426
- const root = h.find('wisp-root')
427
- check(layer !== null && layer.parentNode === h.bodyEl, 'mounted into document.body')
428
- check(root !== null, 'creature element exists', `mood=${root?.dataset.mood}`)
429
- check(h.styleInserts.length === 1, 'stylesheet pushed through the styles builtin')
430
- check(h.frames.length === 0, 'spends zero animation frames while scheduling', 'the rAF loop is not even armed')
431
- check(root.style.width === '560px' && root.style.height === '840px',
432
- 'drawn at the enlarged default (140x210 base x 4)', `${root.style.width} x ${root.style.height}`)
433
-
434
- /* ------------------------------------------------- 3b. sprite pipeline -- */
435
- head('3b. sprite pipeline (the broken-image regression)')
436
-
437
- if (h.srcs.length === 0) {
438
- bad('sprite assigned to an <img>', 'no src was ever set')
439
- } else if (h.srcs.every((s) => typeof s === 'string' && s.startsWith('blob:'))) {
440
- ok('sprites use blob: object URLs', h.srcs[0])
441
- } else if (h.srcs.some((s) => typeof s === 'string' && s.startsWith('data:'))) {
442
- bad('sprites must not use data: URIs', 'this shell renders them as a broken image')
443
- } else {
444
- bad('sprite URL scheme', JSON.stringify(h.srcs).slice(0, 200))
445
- }
446
- check(h.blobs.length > 0, 'base64 decoded into Blob objects', `${h.blobs.length} blob(s)`)
447
- check(h.blobs.every((b) => b.type.startsWith('image/')), 'blob MIME types are images', h.blobs[0]?.type)
448
-
449
- /* ------------------------------------------------ 3c. moods and speech -- */
450
- head('3c. moods, speech and idle/sleep')
451
-
452
- h.advance(900)
453
- const layerEl = api.element.parentNode
454
- check(layerEl.querySelectorAll('.wisp-say').length === 1, 'says hello after mount')
455
- api.say('测试')
456
- check(layerEl.querySelectorAll('.wisp-say').length === 1, 'say() replaces the bubble rather than stacking',
457
- `${layerEl.querySelectorAll('.wisp-say').length} attached`)
458
- check(api.element.querySelectorAll('.wisp-say').length === 0,
459
- 'the bubble is not inside the mirrored root', 'a child of the root would render its text backwards')
460
- api.mood('happy')
461
- check(root.dataset.mood === 'happy', 'mood() control works')
462
- check(h.srcs[h.srcs.length - 1] !== h.srcs[0], 'sprite switches with mood', String(h.srcs[h.srcs.length - 1]))
463
-
464
- // The swap must be a cross-fade: two layers for the length of the fade,
465
- // then the outgoing one is dropped. A hard `src` swap reads as a glitch.
466
- const bodyNode = root.querySelector('.wisp-body')
467
- const imgLayers = () => bodyNode.querySelectorAll('.wisp-img').length
468
- check(imgLayers() === 2, 'a mood change cross-fades instead of swapping the src', `${imgLayers()} layers`)
469
- h.advance(400)
470
- check(imgLayers() === 1, 'the outgoing sprite layer is dropped after the fade', `${imgLayers()} layers`)
471
-
472
- api.mood('idle')
473
- h.advance(91000, 500)
474
- check(root.dataset.mood === 'sleep', 'sleeps after the idle window', String(root.dataset.mood))
475
- const zzz = root.querySelectorAll('.wisp-zzz').length
476
- check(zzz === 2, 'shows two z glyphs while asleep', `${zzz}`)
477
- h.win.dispatch('pointermove', {})
478
- h.advance(10)
479
- check(root.dataset.mood === 'idle', 'any input wakes her')
480
- check(root.querySelectorAll('.wisp-zzz').length === 0, 'the z glyphs go when she wakes')
481
-
482
- /* --------------------------------------------------- 3d. reactions ----- */
483
- head('3d. reactions: busy, composer, completion')
484
-
485
- // Each phase advances just past one poll so the assertion lands inside the
486
- // state it is testing instead of one poll later.
487
- h.state.busy = true
488
- h.advance(1300, 100)
489
- check(root.dataset.mood === 'alert', 'agent busy -> alert', String(root.dataset.mood))
490
- h.advance(4000, 500)
491
- h.state.busy = false
492
- h.advance(1300, 100)
493
- check(root.dataset.mood === 'happy', 'a finished run is celebrated', String(root.dataset.mood))
494
- const said = layerEl.querySelector('.wisp-say')
495
- check(said !== null && ['跑完了。', '收了。', '这一轮结束。', '去看看?'].includes(said.textContent),
496
- 'the celebration says something', said?.textContent)
497
- h.advance(7000, 500)
498
- check(root.dataset.mood === 'idle', 'the celebration ends on its own', String(root.dataset.mood))
499
-
500
- h.state.composerText = 'hello'
501
- h.advance(1300, 100)
502
- check(root.dataset.mood === 'alert', 'composer text -> alert', String(root.dataset.mood))
503
- h.state.composerText = null
504
- h.advance(1300, 100)
505
- check(root.dataset.mood === 'idle', 'back to idle when nothing is happening', String(root.dataset.mood))
506
-
507
- /* ------------------------------------------- 3d-bis. what a tick costs -- */
508
- head('3d-bis. one tick does not re-walk the document')
509
-
510
- // A composer that is present and unchanged must be resolved once and then
511
- // read directly; a tick that re-queries it is the difference between
512
- // ~0.3 ms and ~0 ms per tick on a long transcript.
513
- const beforeQueries = { ...h.queries }
514
- h.state.composerText = 'still typing'
515
- h.advance(POLL_MS * 8, 100)
516
- const composerSpend = h.queries.composer - beforeQueries.composer
517
- check(composerSpend === 0, 'a resolved composer is not re-queried per tick', `${composerSpend} query/queries over 8 ticks`)
518
- check(h.queries.all === beforeQueries.all, 'the tick never collects a NodeList', `${h.queries.all - beforeQueries.all}`)
519
- h.state.composerText = null
520
- h.advance(1300, 100)
521
-
522
- // With no composer open at all (the blank-session Hero), the precise hook is
523
- // probed — cheaply, and so a session that opens is noticed at once — while
524
- // the ambiguous legacy shapes are only probed on the retry cadence.
525
- const hm = createHarness({ timer: true, composerText: null })
526
- active = hm
527
- hm.evaluate(clientSrc)
528
- hm.module().apply(hm.ctx, {})
529
- const TICKS = 9
530
- hm.advance(POLL_MS * TICKS, 100)
531
- const probes = hm.queries.composer
532
- check(probes <= TICKS + Math.ceil(TICKS / 4) * 2,
533
- 'an absent composer costs one probe per tick, not three',
534
- `${probes} probes over ${TICKS} ticks (the old shape paid ${TICKS * 3})`)
535
- hm.win.__wisp.destroy()
536
- active = h // hand the Blob/URL recorder back to the main harness
537
-
538
- /* ------------------------------------------------------ 3e. theme ------ */
539
- head('3e. theme follows the shell')
540
-
541
- check(h.themeListenerCount() === 1, 'subscribed to theme/change')
542
- h.emitTheme({ active: { colorScheme: 'dark' } })
543
- check(root.dataset.wispTheme === 'dark', 'dark theme is reflected on the element', String(root.dataset.wispTheme))
544
- h.emitTheme({ active: { colorScheme: 'light' } })
545
- check(root.dataset.wispTheme === 'light', 'light theme is reflected on the element', String(root.dataset.wispTheme))
546
-
547
- /* ------------------------------------------------- 3f. drag + persistence */
548
- head('3f. drag, and the position it remembers')
549
-
550
- const bodyWisp = h.find('wisp-body')
551
- const SCALE = 4
552
- const BOX_W = 140 * SCALE
553
- const BOX_H = 210 * SCALE
554
- const centre = () => ({ x: api.position.x + BOX_W / 2, y: api.position.y + BOX_H / 2 })
555
- const press = (x, y) => {
556
- const ev = { button: 0, clientX: x, clientY: y, defaultPrevented: false, preventDefault() { this.defaultPrevented = true }, stopPropagation() {} }
557
- bodyWisp.dispatch('pointerdown', ev)
558
- return ev
559
- }
560
-
561
- const before = api.position
562
- const start = centre()
563
- check(press(start.x, start.y).defaultPrevented, 'a press on her body takes the pointer', `${start.x},${start.y}`)
564
- h.win.dispatch('pointermove', { clientX: start.x - 300, clientY: start.y - 60 })
565
- h.win.dispatch('pointerup', {})
566
- const after = api.position
567
- check(after.x !== before.x || after.y !== before.y, 'dragging moves her', `${before.x},${before.y} -> ${after.x},${after.y}`)
568
- check(after.x === before.x - 300 && after.y === before.y - 60, 'she follows the pointer exactly', `${after.x},${after.y}`)
569
-
570
- /* The transform must place the box's TOP-LEFT at the reported position. An
571
- earlier build translated by `x + boxW/2` "for the centre origin", which a
572
- CSS translation ignores — the box rendered half a box-width to the right,
573
- off the viewport edge, while the hit mask kept sampling from `coords.x`.
574
- That made her impossible to grab. Assert the rendered box, not the state. */
575
- const boxAt = (t) => {
576
- const m = /translate3d\(([-\d.]+)px,([-\d.]+)px,0\) scaleX\((-?\d)\)/.exec(String(t))
577
- return m === null ? null : { x: Number(m[1]), y: Number(m[2]), facing: Number(m[3]) }
578
- }
579
- const placed = boxAt(root.style.transform)
580
- check(placed !== null && Math.abs(placed.x - after.x) <= 1 && Math.abs(placed.y - after.y) <= 1,
581
- 'the rendered box starts exactly where the position says',
582
- `transform at ${placed?.x},${placed?.y} vs position ${after.x},${after.y}`)
583
-
584
- const storedRaw = h.win.localStorage.getItem('dsh-wisp:position:v1')
585
- check(typeof storedRaw === 'string' && JSON.parse(storedRaw).x === after.x, 'drag end persists the position', storedRaw)
586
-
587
- // A plain click must not be mistaken for a drag.
588
- const held = api.position
589
- const c1 = centre()
590
- press(c1.x, c1.y)
591
- h.win.dispatch('pointerup', {})
592
- check(api.position.x === held.x && api.position.y === held.y, 'a click does not move her')
593
-
594
- /* ---- the transparent margin must not swallow the app's clicks ------ */
595
- const edge = press(api.position.x + 4, api.position.y + BOX_H / 2)
596
- check(edge.defaultPrevented === false,
597
- 'a press on her transparent margin passes through to the app', `defaultPrevented=${edge.defaultPrevented}`)
598
- const stillHeld = api.position
599
- h.win.dispatch('pointermove', { clientX: 0, clientY: 0 })
600
- h.win.dispatch('pointerup', {})
601
- check(api.position.x === stillHeld.x && api.position.y === stillHeld.y,
602
- 'a margin press does not start a drag', `${api.position.x},${api.position.y}`)
603
-
604
- /* ---- she is grabbable wherever she is drawn ------------------------ */
605
- // Deliberate product decision: she does not yield to a control underneath,
606
- // because the user can simply move her. Pin it so a future "safety" tweak
607
- // cannot quietly make parts of her undraggable again.
608
- const c3 = centre()
609
- const heldBy = api.position
610
- const regrab = press(c3.x, c3.y)
611
- check(regrab.defaultPrevented, 'a press on her body always grabs her, control or not')
612
- h.win.dispatch('pointermove', { clientX: c3.x - 40, clientY: c3.y })
613
- h.win.dispatch('pointerup', {})
614
- check(api.position.x === heldBy.x - 40, 'and it drags her', `${api.position.x} vs ${heldBy.x - 40}`)
615
-
616
- /* ---- geometry must follow the render scale, and the mirror must pivot -- */
617
- const c2 = centre()
618
- press(c2.x, c2.y)
619
- h.win.dispatch('pointermove', { clientX: 9999, clientY: 9999 })
620
- h.win.dispatch('pointerup', {})
621
- const corner = api.position
622
- check(corner.x === h.win.innerWidth - BOX_W && corner.y === h.win.innerHeight - BOX_H,
623
- 'the clamp uses the SCALED box, so she cannot hang off the edge',
624
- `x=${corner.x} (max ${h.win.innerWidth - BOX_W}), y=${corner.y} (max ${h.win.innerHeight - BOX_H})`)
625
-
626
- api.move(0, 300)
627
- const tr = boxAt(root.style.transform)
628
- check(tr !== null, 'the transform is a plain translate plus a mirror', String(root.style.transform))
629
- if (tr !== null) {
630
- check(tr.facing === -1, 'she turns to face left on the left half', `scaleX(${tr.facing})`)
631
- check(tr.x === 0 && tr.y === 300, 'turning around does not shift her box',
632
- `translate ${tr.x},${tr.y} for position 0,300`)
633
- }
634
- api.move(after.x, after.y) // put her back for the teardown/persistence checks
635
-
636
- /* ------------------------------------------------------ 3g. teardown --- */
637
- head('3g. teardown')
638
-
639
- const liveBefore = h.pendingTimers().length
640
- const undestroyed = h.teardown
641
- check(typeof undestroyed === 'function', 'ctx.effect teardown registered')
642
- const disposal = undestroyed()
643
- check(typeof disposal === 'function', 'ctx.effect returns a disposer')
644
- check(liveBefore > 0, 'timers were live before teardown', `${liveBefore}`)
645
- disposal()
646
-
647
- check(h.pendingTimers().length === 0, 'every timer is disposed', `left ${h.pendingTimers().length}`)
648
- check(h.win.listenerCount('pointermove') === 0, 'window listeners are removed')
649
- check(h.win.listenerCount('keydown') === 0, 'keydown listener is removed')
650
- check(layer.parentNode === null, 'the layer leaves the DOM')
651
- check(h.srcs.length > 0 && h.revoked.length > 0, 'sprite blob URLs are revoked', `${h.revoked.length} revoked`)
652
- check(h.win.__wisp === undefined, 'the public handle is withdrawn')
653
- check(h.themeListenerCount() === 0, 'the theme subscription is released', `${h.themeListenerCount()} left`)
654
-
655
- // destroy() twice must be a no-op, not a second teardown.
656
- let doubleThrow = null
657
- try { api.destroy() } catch (error) { doubleThrow = error }
658
- check(doubleThrow === null, 'destroy() is idempotent')
659
-
660
- /* ------------------------------- 3h. re-apply: singleton + fresh blobs -- */
661
- head('3h. re-apply keeps exactly one companion (HMR / duplicate row)')
662
-
663
- // Same module instance: the blob cache is module-scoped, so a stale cache
664
- // here is exactly how a re-mount served a revoked URL and drew a broken
665
- // image. The new instance must get URLs that are not in the revoked set.
666
- plugin.apply(h.ctx, {})
667
- const layers = h.all('wisp-layer')
668
- check(layers.length === 2 && layers.filter((l) => l.parentNode === h.bodyEl).length === 1,
669
- 'exactly one companion is mounted', `${layers.filter((l) => l.parentNode === h.bodyEl).length} attached`)
670
- const second = h.win.__wisp
671
- check(second !== api && typeof second?.destroy === 'function', 'the handle points at the new instance')
672
- const freshSrcs = h.srcs.slice(-1)
673
- check(freshSrcs[0]?.startsWith('blob:') && !h.revoked.includes(freshSrcs[0]),
674
- 'the re-mount is not handed a revoked blob URL', String(freshSrcs[0]))
675
- const restored = second.position
676
- check(restored.x === after.x && restored.y === after.y, 'the remembered position is restored on the next mount',
677
- `${restored.x},${restored.y}`)
678
-
679
- /* ------------------------------------------------- 3i. reconfigure ------ */
680
- head('3i. configure() merges instead of resetting')
681
-
682
- plugin.apply(h.ctx, { reactions: false, persist: false })
683
- const third = h.win.__wisp
684
- // Read geometry off the LIVE handle: `h.find` returns the first element with
685
- // that class, which belongs to an instance that was already replaced.
686
- const liveRoot = third.element
687
- const timersBefore = h.pendingTimers().length
688
- third.configure({ size: 2 })
689
- check(liveRoot.style.width === '280px' && liveRoot.style.height === '420px', 'configure() resizes her box',
690
- `${liveRoot.style.width} x ${liveRoot.style.height}`)
691
- check(h.pendingTimers().length <= timersBefore + 1, 'configure() did not resurrect the reaction poll',
692
- `${timersBefore} -> ${h.pendingTimers().length}`)
693
- third.configure({ size: 'huge', sleepAfterMs: -1 })
694
- check(!String(liveRoot.style.width).includes('NaN') && !String(liveRoot.style.transform).includes('NaN'),
695
- 'a nonsense config cannot produce NaN geometry', `${liveRoot.style.width} / ${liveRoot.style.transform}`)
696
- third.destroy()
697
-
698
- /* ------------------------------------------- 3i-bis. 自己踱步 + 右键归位 */
699
- head('3i-bis. idle wander and the right-click trip home')
700
-
701
- plugin.apply(h.ctx, { reactions: false, wander: true, wanderMs: 5000, wanderRange: 200 })
702
- const fifth = h.win.__wisp
703
- const body5 = fifth.element.querySelector('.wisp-body')
704
- const BOX_W5 = 140 * 4
705
- const BOX_H5 = 210 * 4
706
- const centreOf = () => ({ x: fifth.position.x + BOX_W5 / 2, y: fifth.position.y + BOX_H5 / 2 })
707
-
708
- fifth.move(200, 200) // away from the corners, so any bearing can move
709
- h.advance(6000, 200)
710
- const strolled = fifth.position
711
- check(strolled.x !== 200 || strolled.y !== 200, 'she strolls on her own while idle',
712
- `200,200 -> ${strolled.x},${strolled.y}`)
713
- check(Math.abs(strolled.x - 200) <= 200 && Math.abs(strolled.y - 200) <= 200,
714
- 'a stroll stays inside the configured range', `${strolled.x},${strolled.y}`)
715
- check(fifth.element.dataset.gliding === 'true', 'the stroll is a glide, not a teleport')
716
- h.advance(2000, 200)
717
- check(fifth.element.dataset.gliding === undefined, 'the glide class is cleared afterwards')
718
- check(JSON.parse(h.win.localStorage.getItem('dsh-wisp:position:v1') || '{}').x !== strolled.x,
719
- 'a stroll does not overwrite the position the user chose', h.win.localStorage.getItem('dsh-wisp:position:v1'))
720
-
721
- // 右键:任何位置都要能一下子叫回右下角
722
- const clickPoint = centreOf()
723
- const home = { clientX: clickPoint.x, clientY: clickPoint.y, defaultPrevented: false, preventDefault() { this.defaultPrevented = true }, stopPropagation() {} }
724
- body5.dispatch('contextmenu', home)
725
- check(home.defaultPrevented, 'a right-click on her body is handled')
726
- const homed = fifth.position
727
- check(homed.x === h.win.innerWidth - 26 - BOX_W5 && homed.y === h.win.innerHeight - 46 - BOX_H5,
728
- 'the right-click puts her back in her corner', `${homed.x},${homed.y}`)
729
- check(JSON.parse(h.win.localStorage.getItem('dsh-wisp:position:v1')).x === homed.x,
730
- 'the remembered position follows her home')
731
-
732
- // 但踱步不能在她被拖拽 / 睡着 / 忙碌时乱动
733
- fifth.move(300, 300)
734
- fifth.mood('sleep')
735
- h.advance(6000, 200)
736
- check(fifth.position.x === 300 && fifth.position.y === 300, 'no strolling while she is asleep',
737
- `${fifth.position.x},${fifth.position.y}`)
738
- fifth.destroy()
739
-
740
- /* ------------------------------------------- 3i-ter. 关掉踱步就不动 ---- */
741
- plugin.apply(h.ctx, { reactions: false, wander: false })
742
- const sixth = h.win.__wisp
743
- sixth.move(300, 300)
744
- h.advance(3000, 200)
745
- check(sixth.position.x === 300 && sixth.position.y === 300, 'wander: false keeps her still',
746
- `${sixth.position.x},${sixth.position.y}`)
747
- sixth.destroy()
748
-
749
- /* ------------------------------ 3j. storage that throws (opaque origin) - */
750
- head('3j. a page without usable storage still works')
751
-
752
- for (const mode of ['throwing', 'absent']) {
753
- const hs = createHarness({ timer: true, storage: mode })
754
- active = hs
755
- let error = null
756
- try {
757
- hs.evaluate(clientSrc)
758
- const mod = hs.module()
759
- mod.apply(hs.ctx, {})
760
- hs.advance(100)
761
- const body = hs.find('wisp-body')
762
- body?.dispatch('pointerdown', { button: 0, clientX: 900, clientY: 700, preventDefault() {}, stopPropagation() {} })
763
- hs.win.dispatch('pointermove', { clientX: 400, clientY: 300 })
764
- hs.win.dispatch('pointerup', {})
765
- const handle = hs.win.__wisp
766
- handle.destroy()
767
- } catch (e) { error = e }
768
- check(error === null, `mounts and tears down with storage ${mode}`, error ? String(error.message) : '')
769
- }
770
-
771
- /* --------------------------------- 3k. the rAF fallback (no timer svc) -- */
772
- head('3k. requestAnimationFrame fallback when the timer service is absent')
773
-
774
- const hf = createHarness({ timer: false, composerText: null })
775
- active = hf
776
- hf.evaluate(clientSrc)
777
- const modF = hf.module()
778
- modF.apply(hf.ctx, { reactions: false, chatterMs: 0, sleepAfterMs: 5000, wander: false })
779
- const apiF = hf.win.__wisp
780
- const rootF = apiF.element
781
- check(apiF?.clock === 'animation-frame', 'falls back to the frame clock', String(apiF?.clock))
782
- check(hf.frames.length > 0, 'the fallback arms a frame while work is pending')
783
- hf.advance(6000, 50)
784
- check(rootF.dataset.mood === 'sleep', 'the fallback still sleeps on schedule', String(rootF.dataset.mood))
785
- // Long enough that the bubble job the sleep line may have armed is gone too.
786
- hf.advance(8000, 50)
787
- check(hf.frames.length === 0, 'the fallback stops the frame loop once nothing is pending',
788
- `${hf.frames.length} frame(s) armed`)
789
- apiF.destroy()
790
- check(hf.frames.length === 0, 'teardown leaves no armed frame', `${hf.frames.length} frame(s) armed`)
791
- } catch (error) {
792
- bad('browser half executes', `${error.constructor.name}: ${error.message}`)
793
- console.log(error.stack)
794
- } finally {
795
- active = null
796
- for (const [key, value] of Object.entries(savedGlobals)) globalThis[key] = value
797
- }
798
- }
799
-
800
- /* ======================================================= 4. packaging ====== */
801
-
802
- head('4. packaging consistency')
803
-
804
- const patchPath = pkg.dsh?.bundle?.patch ? resolve(here, pkg.dsh.bundle.patch) : null
805
- if (patchPath && existsSync(patchPath)) {
806
- ok('dsh.bundle.patch exists', pkg.dsh.bundle.patch)
807
- const yml = readFileSync(patchPath, 'utf8')
808
- const nameMatch = yml.match(/^\s*name:\s*["']?([^"'\s]+)["']?\s*$/m)
809
- const entry = nameMatch ? nameMatch[1] : null
810
- if (entry === pkg.name) ok('patch entry points at the host half', entry)
811
- else bad('patch entry points at the host half', `patch says '${entry}', should be '${pkg.name}' (never '<pkg>/client')`)
812
- } else bad('dsh.bundle.patch exists')
813
-
814
- if (!pkg.dsh?.client?.inject && !pkg.dsh?.client?.shared) ok('no inject/shared declarations')
815
- else bad('inject/shared declared', 'a local-path install cannot satisfy them')
816
-
817
- /* ---- 5. shipping readiness: sprites inlined, attribution present ---- */
818
-
819
- head('5. redistribution readiness')
820
-
821
- if (clientSrc !== null) {
822
- const uris = (clientSrc.match(/data:image\/(?:webp|png);base64,/g) || []).length
823
- if (uris === 4) ok('all four sprites inlined as data URIs')
824
- else bad('all four sprites inlined as data URIs', `found ${uris}, expected 4`)
825
-
826
- if (clientSrc.includes('__SPRITES__')) bad('no leftover build placeholder', 'run `node build.mjs`')
827
- else ok('no leftover build placeholder')
828
-
829
- // The bundle must be self-contained: a share carries no asset paths.
830
- const refsAssetFile = /['"][^'"]*\.(?:webp|png)['"]/.test(clientSrc)
831
- if (!refsAssetFile) ok('bundle references no external image files')
832
- else bad('bundle references no external image files', 'a share would break on missing assets')
833
-
834
- const stamped = clientSrc.match(/const VERSION = '([^']+)'/)
835
- if (stamped && stamped[1] === pkg.version) ok('the bundle stamps the package version', stamped[1])
836
- else bad('the bundle stamps the package version', `bundle ${stamped?.[1] ?? 'none'} vs package ${pkg.version}`)
837
- }
838
-
839
- if (existsSync(join(here, 'NOTICE.md'))) ok('NOTICE.md present', 'character provenance + image licence')
840
- else bad('NOTICE.md present', 'required when redistributing the character images')
841
-
842
- const notice = existsSync(join(here, 'NOTICE.md')) ? readFileSync(join(here, 'NOTICE.md'), 'utf8') : ''
843
- if (/非官方|unofficial|not affiliated/i.test(notice)) ok('NOTICE states it is unofficial')
844
- else bad('NOTICE states it is unofficial')
845
-
846
- if (/CC BY-NC-SA/i.test(notice)) ok('NOTICE separates the image licence from MIT')
847
- else bad('NOTICE separates the image licence from MIT')
848
-
849
- if (pkg.private === true) bad('package is publishable', 'remove "private": true to share it')
850
- else ok('package is publishable')
851
-
852
- if (pkg.dsh?.client?.platform) ok('dsh.client.platform declared', pkg.dsh.client.platform)
853
- else bad('dsh.client.platform declared')
854
-
855
- const deps = Object.keys(pkg.dependencies ?? {}).length + Object.keys(pkg.peerDependencies ?? {}).length
856
- if (deps === 0) ok('no dependencies at all')
857
- else bad(`${deps} dependency declaration(s)`)
858
-
859
- // Every path the manifest promises must exist, or an install ships a hole.
860
- const declaredFiles = Array.isArray(pkg.files) ? pkg.files : []
861
- const missingFiles = declaredFiles.filter((rel) => !existsSync(join(here, rel)))
862
- if (declaredFiles.length === 0) bad('package.json files[] declares the shipped set')
863
- else if (missingFiles.length === 0) ok('every path in files[] exists', `${declaredFiles.length} entries`)
864
- else bad('every path in files[] exists', `missing: ${missingFiles.join(', ')}`)
865
-
866
- /* ============================================================= done ====== */
867
-
868
- head(failures === 0 ? 'ALL CHECKS PASSED' : `${failures} CHECK(S) FAILED`)
869
- console.log(failures === 0
870
- ? ' Both halves match their contracts. A remaining failure would be outside\n this package: restart the app to rebuild its loader graph.\n'
871
- : ' Fix the FAIL lines above before reloading the app.\n')
872
- process.exit(failures === 0 ? 0 : 1)
1
+ /* ============================================================================
2
+ verify-wisp.mjs — pre-flight checks for the dsh-wisp bundle.
3
+
4
+ TWO FILES, TWO DIFFERENT CONTRACTS. Getting this wrong caused every load
5
+ failure this plugin had, so the checks are organised around it.
6
+
7
+ lib/index.js the HOST half — a plain ES module the host imports.
8
+ The loader entry in cordis.patch.yml points at the PACKAGE
9
+ (`dsh-wisp`), which resolves here.
10
+
11
+ lib/client.js the BROWSER half — a CLASSIC script that registers with the
12
+ web shell: window.__ModuleLoader__.load({ id, factory })
13
+ Reached through exports["./client"], served by /plugins.
14
+
15
+ The browser half is not just syntax-checked: it is EXECUTED against a
16
+ harness that reproduces the surfaces it really meets —
17
+
18
+ * all six globals the dynamic runner traps (setTimeout, setInterval,
19
+ clearTimeout, clearInterval, fetch, require) are shadowed by THROWING
20
+ functions, so touching one fails the run instead of the user's session;
21
+ * a virtual clock drives BOTH scheduling paths (the Client `timer` service
22
+ and the requestAnimationFrame fallback), so timer behaviour is asserted
23
+ rather than assumed;
24
+ * a fake DOM, Blob/URL pipeline, localStorage (including one that throws,
25
+ like an opaque origin), and a scriptable busy/composer state.
26
+
27
+ RUN
28
+ node verify-wisp.mjs
29
+ EXIT CODE 0 = both halves match their contract.
30
+ ========================================================================== */
31
+
32
+ import { readFileSync, statSync, existsSync } from 'node:fs'
33
+ import { fileURLToPath, pathToFileURL } from 'node:url'
34
+ import { dirname, join, resolve } from 'node:path'
35
+ import { verifyAudit } from './tools/audit-log.mjs'
36
+
37
+ const here = dirname(fileURLToPath(import.meta.url))
38
+ const pkgPath = join(here, 'package.json')
39
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
40
+
41
+ const hostPath = resolve(here, pkg.main ?? 'lib/index.js')
42
+ const clientRel = pkg.exports?.['./client']?.default
43
+ const clientPath = clientRel ? resolve(here, clientRel) : null
44
+
45
+ let failures = 0
46
+ const ok = (l, d = '') => console.log(` PASS ${l}${d ? ' — ' + d : ''}`)
47
+ const bad = (l, d = '') => { failures++; console.log(` FAIL ${l}${d ? ' — ' + d : ''}`) }
48
+ const head = (t) => console.log(`\n=== ${t} ===`)
49
+ const check = (cond, l, d = '') => (cond ? ok(l, d) : bad(l, d))
50
+
51
+ /* ============================================================ 0. the traps == */
52
+
53
+ /**
54
+ * The six globals a dynamic client half must never touch. The runner shadows
55
+ * them with throwing parameters; this suite reproduces that exactly, so a
56
+ * regression here fails the build instead of crashing a live page.
57
+ */
58
+ const TRAPPED = ['setTimeout', 'setInterval', 'clearTimeout', 'clearInterval', 'fetch', 'require']
59
+
60
+ /** The client half's reaction poll period; kept in step with lib/client.template.js. */
61
+ const POLL_MS = 1200
62
+
63
+ /* ==================================================== the browser harness === */
64
+
65
+ let active = null
66
+ let urlSeq = 0
67
+
68
+ /* 插件依次尝试的输入框钩子数量(与 client.template.js 的 COMPOSER_HOOKS 对应)。
69
+ 写成常量是为了让"它到底试了几条"成为一个可断言的数字,而不是一句描述。 */
70
+ const COMPOSER_HOOK_COUNT = 3
71
+
72
+ /* 台词池从**产物**里读出来,而不是在测试里抄一份。
73
+ 抄一份的话,每次改文案都要同步改测试 —— 而且改漏了才发现,比如"庆祝说了别的池子的话"
74
+ 这种真 bug 会被硬编码的列表掩盖。
75
+ 惰性求值:`clientSrc` 在文件后面才声明,写成立即求值会踩 TDZ。 */
76
+ let linesCache
77
+ const linesInBundle = () => {
78
+ if (linesCache !== undefined) return linesCache
79
+ linesCache = null
80
+ if (typeof clientSrc === 'string') {
81
+ const match = clientSrc.match(/const LINES = (\{[\s\S]*?\n {4}\})/)
82
+ if (match) {
83
+ try { linesCache = new Function(`return ${match[1]}`)() } catch (e) { linesCache = null }
84
+ }
85
+ }
86
+ return linesCache
87
+ }
88
+
89
+ /* 假的主题令牌表:doctor() 会把它们读出来,所以替身必须真的给值 ——
90
+ 全空的替身会让"主题令牌读不到"这条告警永远成立,测不出真实行为。 */
91
+ const styleTokens = {
92
+ '--dsw-alias-label-primary': '#1b1b1f',
93
+ '--dsw-alias-bg-overlay': 'rgba(255,255,255,.93)',
94
+ '--dsw-alias-border-l1': 'rgba(15,30,40,.10)',
95
+ '--dsw-alias-bg-layer-2': 'rgba(120,170,255,.22)',
96
+ '--dsw-alias-brand-primary': '#4c8dff',
97
+ }
98
+
99
+ class FakeBlob {
100
+ constructor(parts, opts) {
101
+ this.parts = parts
102
+ this.type = (opts && opts.type) || ''
103
+ this.size = parts[0] ? parts[0].length : 0
104
+ if (active) active.blobs.push(this)
105
+ }
106
+ }
107
+ const FakeURL = {
108
+ createObjectURL() { const url = `blob:dsh-app/verify-${++urlSeq}`; if (active) active.urLs.push(url); return url },
109
+ revokeObjectURL(url) { if (active) active.revoked.push(url) },
110
+ }
111
+ /* The constructor writes the backing field directly: routing its own
112
+ initialisation through the instrumented setter would record a phantom empty
113
+ src for every `new Image()`. */
114
+ const FakeImage = function FakeImage() { this._src = '' }
115
+ Object.defineProperty(FakeImage.prototype, 'src', {
116
+ get() { return this._src ?? '' },
117
+ set(value) {
118
+ this._src = value
119
+ if (active) active.srcs.push(value)
120
+ // The plugin builds its alpha hit-mask from an Image load, so the harness
121
+ // fires onload synchronously rather than leaving the mask path untested.
122
+ if (typeof this.onload === 'function') this.onload()
123
+ },
124
+ })
125
+ const fakeAtob = (s) => Buffer.from(s, 'base64').toString('binary')
126
+
127
+ /**
128
+ * Build one isolated page.
129
+ * @param {object} [options] - timer (service present), busy, composerText,
130
+ * storage ('memory' | 'throwing' | 'absent'), theme, viewport.
131
+ * @returns the harness: element factory, dispatch helpers, clock driver, ctx.
132
+ */
133
+ function createHarness(options = {}) {
134
+ const state = {
135
+ now: 0,
136
+ busy: !!options.busy,
137
+ composerText: options.composerText === undefined ? null : options.composerText,
138
+ pending: options.pending === undefined ? null : options.pending,
139
+ errors: options.errors === undefined ? 0 : options.errors,
140
+ ariaLabels: options.ariaLabels === undefined ? [] : options.ariaLabels,
141
+ overlayStack: options.overlayStack === undefined ? [] : options.overlayStack,
142
+ }
143
+
144
+ const created = []
145
+ const frames = []
146
+ const rafSlots = new Map()
147
+ let rafSeq = 0
148
+ const timers = []
149
+ /* 可预置的存储:跨天归零这类行为没法用虚拟时钟测(它读真实日期),
150
+ 但可以用"存着昨天的数据"来测。 */
151
+ const clipboardWrites = []
152
+ const store = new Map()
153
+ if (options.storageSeed && typeof options.storageSeed === 'object') {
154
+ for (const [k, v] of Object.entries(options.storageSeed)) store.set(k, v)
155
+ }
156
+ /* 最小焦点模型:真实 DOM 里 focus() 会更新 document.activeElement,键盘导航依赖这一点。
157
+ 没有它,"焦点在哪一项"就无法断言 —— 替身缺能力会被误读成产品缺陷。 */
158
+ let activeEl = null
159
+
160
+ const makeEl = (tag) => {
161
+ const el = {
162
+ tagName: String(tag).toUpperCase(),
163
+ children: [], parentNode: null, dataset: {}, attrs: {},
164
+ style: { setProperty(k, v) { this[k] = v }, getPropertyValue(k) { return this[k] ?? '' } },
165
+ className: '', textContent: '', id: '', value: '',
166
+ listeners: Object.create(null),
167
+ _src: '',
168
+ focus() { activeEl = el },
169
+ blur() { if (activeEl === el) activeEl = null },
170
+ get firstChild() { return this.children[0] ?? null },
171
+ get src() { return this._src },
172
+ set src(v) { this._src = v; if (this.tagName === 'IMG') harness.srcs.push(v) },
173
+ setAttribute(k, v) { this.attrs[k] = String(v) },
174
+ getAttribute(k) { return Object.hasOwn(this.attrs, k) ? this.attrs[k] : null },
175
+ hasAttribute(k) { return Object.hasOwn(this.attrs, k) },
176
+ appendChild(c) { if (c.parentNode) c.parentNode.removeChild(c); c.parentNode = this; this.children.push(c); return c },
177
+ append(...cs) { for (const c of cs) this.appendChild(c) },
178
+ insertBefore(c, before) {
179
+ if (c.parentNode) c.parentNode.removeChild(c)
180
+ c.parentNode = this
181
+ const at = before ? this.children.indexOf(before) : -1
182
+ if (at < 0) this.children.unshift(c)
183
+ else this.children.splice(at, 0, c)
184
+ return c
185
+ },
186
+ removeChild(c) {
187
+ this.children = this.children.filter((x) => x !== c)
188
+ c.parentNode = null
189
+ /* 移除必须**递归**标记:真实 DOM 的 querySelectorAll 只返回还连在文档上的元素。
190
+ 只标记被直接摘掉的那个,会让"页面上还剩几个她"把早已移除的旧子树也数进来。 */
191
+ const markRemoved = (node) => {
192
+ node.removed = true
193
+ for (const child of node.children || []) markRemoved(child)
194
+ }
195
+ markRemoved(c)
196
+ return c
197
+ },
198
+ remove() { if (this.parentNode) this.parentNode.removeChild(this) },
199
+ descendants() { return this.children.flatMap((c) => [c, ...c.descendants()]) },
200
+ querySelector(sel) {
201
+ const found = this.querySelectorAll(sel)
202
+ return found.length > 0 ? found[0] : null
203
+ },
204
+ querySelectorAll(sel) {
205
+ // 类选择器与标签选择器都要支持:只支持前者时,一个用 'img' 查询的断言会
206
+ // 静默返回空集 —— 测试替身的局限会伪装成产品缺陷。
207
+ if (typeof sel !== 'string' || sel === '') return []
208
+ if (sel.startsWith('.')) {
209
+ const cls = sel.slice(1)
210
+ return this.descendants().filter((d) => d.className === cls)
211
+ }
212
+ const tag = sel.toUpperCase()
213
+ return this.descendants().filter((d) => d.tagName === tag)
214
+ },
215
+ addEventListener(type, fn) { (this.listeners[type] ??= []).push(fn) },
216
+ removeEventListener(type, fn) {
217
+ const list = this.listeners[type]
218
+ if (!list) return
219
+ const at = list.indexOf(fn)
220
+ if (at >= 0) list.splice(at, 1)
221
+ },
222
+ dispatch(type, event) { for (const fn of [...(this.listeners[type] ?? [])]) fn(event ?? {}) },
223
+ listenerCount(type) { return (this.listeners[type] ?? []).length },
224
+ }
225
+ if (String(tag).toLowerCase() === 'canvas') {
226
+ // A deterministic stand-in for the sprite's alpha channel: opaque across
227
+ // the middle half of the width, transparent outside it. That is what the
228
+ // shipped sprites look like (55-87% of the width is figure, the rest is
229
+ // margin) and it makes the hit test assertable.
230
+ el.getContext = () => ({
231
+ drawImage() {},
232
+ getImageData(x, y, w, h) {
233
+ const data = new Uint8ClampedArray(w * h * 4)
234
+ for (let py = 0; py < h; py++) {
235
+ for (let px = 0; px < w; px++) {
236
+ const opaque = px >= w * 0.25 && px < w * 0.75
237
+ data[(py * w + px) * 4 + 3] = opaque ? 255 : 0
238
+ }
239
+ }
240
+ return { data }
241
+ },
242
+ })
243
+ }
244
+ created.push(el)
245
+ return el
246
+ }
247
+
248
+ const bodyEl = makeEl('body')
249
+ const headEl = makeEl('head')
250
+ /* The composer is a live node whose text follows `state.composerText`, and it
251
+ is absent (null) while that is null — so the plugin's caching AND its
252
+ re-resolution are both exercised for real. */
253
+ const composerNode = {
254
+ tagName: 'DIV',
255
+ isConnected: true,
256
+ get textContent() { return state.composerText ?? '' },
257
+ }
258
+ const queries = { stop: 0, composer: 0, all: 0, pending: 0, errors: 0 }
259
+ const COMPOSER_HOOKS = ['[data-composer-input]', 'textarea', '[contenteditable="true"]']
260
+
261
+ /* 匹配集合的唯一真源。querySelector 与 querySelectorAll 在真实 DOM 里匹配的是同一个集合,
262
+ 所以替身也必须只有一处逻辑 —— 之前两处各写一半,结果是 doctor()(用 querySelectorAll)
263
+ 在替身里永远看到 0,而产品代码在真实浏览器里是对的:**替身说谎**。 */
264
+ const matchAll = (sel) => {
265
+ if (typeof sel !== 'string') return []
266
+ /* 类选择器:真实 DOM 里按 class 查询是最常见的一种,替身必须支持 ——
267
+ 否则"页面上有几个她"那条兜底逻辑在替身里永远看不到任何东西。 */
268
+ if (sel.startsWith('.')) {
269
+ const want = sel.slice(1)
270
+ return created.filter((e) => !e.removed && String(e.className || '').split(/\s+/).includes(want))
271
+ }
272
+ // 带 aria-label 的按钮:doctor() 在忙碌钩子 miss 时会采样它们。
273
+ // 必须放在忙碌分支【之前】:'button[aria-label]' 也以 'button[' 开头,而真实 DOM 是精确
274
+ // 匹配选择器的 —— 顺序写错会让这条断言空过(替身说谎的又一例)。
275
+ if (sel === 'button[aria-label]') {
276
+ return state.ariaLabels.map((label) => ({ tagName: 'BUTTON', getAttribute: (k) => (k === 'aria-label' ? label : null) }))
277
+ }
278
+ if (sel.startsWith('button[')) return state.busy ? [pendingNode] : []
279
+ if (sel.startsWith('[data-question-key]')) {
280
+ return state.pending === null
281
+ ? []
282
+ : [{ getAttribute: (n) => (n === 'data-question-key' ? state.pending : null) }]
283
+ }
284
+ if (COMPOSER_HOOKS.includes(sel)) { // 真实页面里只会存在**一种**形态的输入框钩子,所以替身也只让被选中的那一种命中 ——
285
+ // 让三个同时命中会掩盖回退链:第二、三条到底能不能用,只有分开才测得到。
286
+ const shape = options.composerShape ?? 0
287
+ return COMPOSER_HOOKS[shape] === sel && state.composerText !== null ? [composerNode] : []
288
+ }
289
+ // 出错行:DSH 给失败的工具行/命令卡标 data-error="true",按 state.errors 造出同等数量
290
+ if (sel === '[data-error="true"]') {
291
+ return Array.from({ length: state.errors }, (_, i) => ({ tagName: 'SPAN', className: 'err', key: i }))
292
+ }
293
+ // 带 aria-label 的按钮:doctor() 在忙碌钩子 miss 时会采样它们
294
+ if (sel === 'button[aria-label]') {
295
+ return state.ariaLabels.map((label) => ({ tagName: 'BUTTON', getAttribute: (k) => (k === 'aria-label' ? label : null) }))
296
+ }
297
+ return []
298
+ }
299
+ // 计数仍按原来的语义各自统计,这样性能类断言("空闲时 0 次全量查询")不受影响
300
+ const countQuery = (sel) => {
301
+ if (typeof sel === 'string' && sel.startsWith('button[')) queries.stop++
302
+ else if (typeof sel === 'string' && sel.startsWith('[data-question-key]')) queries.pending++
303
+ else if (COMPOSER_HOOKS.includes(sel)) queries.composer++
304
+ }
305
+ const pendingNode = {}
306
+
307
+ const documentShim = {
308
+ body: bodyEl,
309
+ head: headEl,
310
+ get activeElement() { return activeEl },
311
+ createElement: (t) => makeEl(t),
312
+ getElementById: (id) => created.find((e) => e.id === id && !e.removed) ?? null,
313
+ /* 采样点上的元素栈:按"最上面优先"返回。窗口背景那一段要靠它判断
314
+ "谁在铺满视口的地方画了不透明底色",所以替身必须真的按尺寸过滤。 */
315
+ elementsFromPoint: () => (state.overlayStack || []).filter((el) => {
316
+ const r = el.getBoundingClientRect()
317
+ return r.width >= (options.width ?? 1920) * 0.8 && r.height >= (options.height ?? 1200) * 0.3
318
+ }),
319
+ querySelector: (sel) => {
320
+ countQuery(sel)
321
+ return matchAll(sel)[0] ?? null
322
+ },
323
+ querySelectorAll: (sel) => {
324
+ queries.all++
325
+ if (sel === '[data-error="true"]') queries.errors++
326
+ return matchAll(sel)
327
+ },
328
+ }
329
+
330
+ const winListeners = Object.create(null)
331
+ const localStorageShim = options.storage === 'absent' ? undefined : {
332
+ getItem: (k) => {
333
+ if (options.storage === 'throwing') throw new Error('storage disabled (opaque origin)')
334
+ return store.has(k) ? store.get(k) : null
335
+ },
336
+ setItem: (k, v) => {
337
+ if (options.storage === 'throwing') throw new Error('storage disabled (opaque origin)')
338
+ store.set(k, String(v))
339
+ },
340
+ removeItem: (k) => {
341
+ if (options.storage === 'throwing') throw new Error('storage disabled (opaque origin)')
342
+ store.delete(k)
343
+ },
344
+ }
345
+
346
+ const win = {
347
+ innerWidth: options.width ?? 1920,
348
+ innerHeight: options.height ?? 1200,
349
+ performance: { now: () => state.now },
350
+ requestAnimationFrame: (fn) => { const id = ++rafSeq; rafSlots.set(id, fn); frames.push({ id, fn }); return id },
351
+ cancelAnimationFrame: (id) => {
352
+ // A no-op here would let a disposed frame survive teardown and make the
353
+ // "the loop stops" assertions meaningless.
354
+ if (!rafSlots.has(id)) return
355
+ rafSlots.delete(id)
356
+ const at = frames.findIndex((rec) => rec.id === id)
357
+ if (at >= 0) frames.splice(at, 1)
358
+ },
359
+ localStorage: localStorageShim,
360
+ /* 剪贴板默认**不存在** —— 这样「没有权限时静默降级」那条才测得到;
361
+ 要测复制成功用 createHarness({ clipboard: true })。 */
362
+ navigator: options.clipboard === true
363
+ ? { clipboard: { writeText: (text) => { clipboardWrites.push(text); return Promise.resolve() } } }
364
+ : {},
365
+ /* 真实浏览器里 getComputedStyle 是 window 上的方法。替身必须给出它 ——
366
+ 缺这一项会让"用了裸全局"这类可移植性问题无法被发现(doctor() 第一次就撞上了)。 */
367
+ getComputedStyle: (el) => ({
368
+ getPropertyValue: (name) => (options.tokens === 'empty' ? '' : (styleTokens[name] ?? '')),
369
+ /* 背景色按"元素的第一个类名"查表 —— 替身没有真实级联,这样足够表达
370
+ "这个元素画不画底色"这一件事,而它正是窗口背景那段逻辑唯一关心的。 */
371
+ get backgroundColor() {
372
+ const cls = String(el?.className || '').split(/\s+/).filter(Boolean)[0]
373
+ return (options.backgrounds && cls && options.backgrounds[cls]) || 'rgba(0, 0, 0, 0)'
374
+ },
375
+ }),
376
+ addEventListener(type, fn) { (winListeners[type] ??= []).push(fn) },
377
+ removeEventListener(type, fn) {
378
+ const list = winListeners[type]
379
+ if (!list) return
380
+ const at = list.indexOf(fn)
381
+ if (at >= 0) list.splice(at, 1)
382
+ },
383
+ dispatch(type, event) { for (const fn of [...(winListeners[type] ?? [])]) fn(event ?? {}) },
384
+ listenerCount: (type) => (winListeners[type] ?? []).length,
385
+ __ModuleLoader__: { load(def) { harness.loaded = def } },
386
+ }
387
+ win.window = win
388
+
389
+ /* The Client `timer` service: registrations plus their disposers. */
390
+ const timerService = options.timer === false ? undefined : {
391
+ timeout(cb, ms) { const t = { cb, due: state.now + ms, every: 0, cancelled: false }; timers.push(t); return () => { t.cancelled = true } },
392
+ interval(cb, ms) { const t = { cb, due: state.now + ms, every: ms, cancelled: false }; timers.push(t); return () => { t.cancelled = true } },
393
+ }
394
+
395
+ const themeListeners = []
396
+ const themeSnapshot = { preference: options.themeDark ? 'dark' : 'light', active: { id: 'x', colorScheme: options.themeDark ? 'dark' : 'light', tokens: {} } }
397
+ /* 真实的 ctx.effect(fn) **立即调用 fn**,把返回值当 disposer 记下来,卸载时按反序调用。
398
+ 只"存起来等卸载"的替身会让注册类 effect(设置页就是)永远不执行 —— 那是替身不准。 */
399
+ const effects = []
400
+ const effectDisposers = []
401
+ const runEffects = () => {
402
+ const out = effectDisposers.splice(0, effectDisposers.length)
403
+ out.reverse().forEach((dispose) => { try { dispose() } catch (e) { /* ignore */ } })
404
+ return out
405
+ }
406
+ const registerEffect = (fn, label) => {
407
+ effects.push({ fn, label })
408
+ const result = typeof fn === 'function' ? fn() : undefined
409
+ if (typeof result === 'function') effectDisposers.push(result)
410
+ return () => {}
411
+ }
412
+
413
+ const styleInserts = []
414
+
415
+ const ctx = {
416
+ get: (name) => {
417
+ if (name === 'timer') return timerService
418
+ if (name === 'styles') return { insert: (css) => { styleInserts.push(css); return () => { styleInserts.pop() } } }
419
+ if (name === 'theme') return { getTheme: () => themeSnapshot }
420
+ return undefined
421
+ },
422
+ on: (name, fn) => {
423
+ if (name !== 'theme/change') return () => {}
424
+ themeListeners.push(fn)
425
+ return () => { const at = themeListeners.indexOf(fn); if (at >= 0) themeListeners.splice(at, 1) }
426
+ },
427
+ effect: registerEffect,
428
+ }
429
+
430
+ const harness = {
431
+ state, ctx, win, document: documentShim, bodyEl, created, frames, timers, queries,
432
+ srcs: [], urLs: [], revoked: [], blobs: [], styleInserts,
433
+ loaded: null,
434
+ timerService,
435
+ get teardown() { return runEffects },
436
+ get effectLabels() { return effects.map((e) => e.label) },
437
+ emitTheme(snapshot) { for (const fn of [...themeListeners]) fn(snapshot ?? themeSnapshot) },
438
+ themeListenerCount: () => themeListeners.length,
439
+ fireTimers() {
440
+ for (let guard = 0; guard < 2000; guard++) {
441
+ const due = timers.filter((t) => !t.cancelled && t.due <= state.now).sort((a, b) => a.due - b.due)
442
+ if (due.length === 0) return
443
+ const t = due[0]
444
+ if (t.every > 0) t.due = state.now + t.every
445
+ else { t.cancelled = true; timers.splice(timers.indexOf(t), 1) }
446
+ t.cb()
447
+ }
448
+ },
449
+ /** Advance virtual time, pumping both scheduling paths. */
450
+ advance(ms, stepMs = 25) {
451
+ let left = ms
452
+ while (left > 0) {
453
+ const delta = Math.min(stepMs, left)
454
+ state.now += delta
455
+ left -= delta
456
+ harness.fireTimers()
457
+ // Two frames per step is enough to run everything already due; a frame
458
+ // loop that re-arms itself does not need to be drained to infinity.
459
+ for (let i = 0; i < 2 && frames.length > 0; i++) {
460
+ const rec = frames.shift()
461
+ rafSlots.delete(rec.id)
462
+ rec.fn()
463
+ }
464
+ }
465
+ },
466
+ pendingTimers: () => timers.filter((t) => !t.cancelled),
467
+ /** 假 document:有些断言要直接查插件往页面里放了什么(例如窗口背景那条规则)。 */
468
+ document: documentShim,
469
+ find: (className) => created.find((e) => e.className === className) ?? null,
470
+ all: (className) => created.filter((e) => e.className === className),
471
+ /** Evaluate lib/client.js the way the shell does, with every trap armed. */
472
+ evaluate(src) {
473
+ const traps = {}
474
+ for (const name of TRAPPED) traps[name] = () => { throw new Error(`${name} is not available in a dynamic client half`) }
475
+ // `host` 是 shell 注入给客户端半包的座位(是**能力**,不是 fetch 那种陷阱)。
476
+ // 默认不给 —— 这样"没有 host 时降级"才测得到;要测检查更新就传 hostCall。
477
+ const hostSeat = typeof options.hostCall === 'function' ? { call: options.hostCall } : undefined
478
+ // eslint-disable-next-line no-new-func
479
+ new Function('window', 'document', 'host', ...TRAPPED, src)(
480
+ win, documentShim, hostSeat, ...TRAPPED.map((name) => traps[name]),
481
+ )
482
+ },
483
+ module() {
484
+ if (!harness.loaded) throw new Error('load() was never called')
485
+ return harness.loaded.factory(() => ({}))
486
+ },
487
+ /* 剪贴板收件箱(只有 clipboard: true 时会收到东西)—— 测试要能从外面看它,
488
+ 所以挂到替身上,而不是留在 createHarness 的词法作用域里。 */
489
+ clipboardWrites,
490
+ }
491
+ return harness
492
+ }
493
+
494
+ /* ======================================================= 1. the HOST half == */
495
+
496
+ head('1. host half (lib/index.js) must be importable ES module')
497
+
498
+ if (!existsSync(hostPath)) {
499
+ bad('host half exists', pkg.main)
500
+ } else {
501
+ const src = readFileSync(hostPath, 'utf8')
502
+ if (/^\s*(import|export)\s/m.test(src)) ok('uses ES module syntax')
503
+ else bad('uses ES module syntax', 'the host imports this file; a classic script cannot be imported')
504
+
505
+ try {
506
+ const mod = await import(pathToFileURL(hostPath).href + '?probe=host')
507
+ ok('host module imports cleanly')
508
+ if (typeof mod.apply === 'function') ok('exports apply()')
509
+ else bad('exports apply()')
510
+ } catch (error) {
511
+ bad('host module imports cleanly', `${error.constructor.name}: ${error.message}`)
512
+ }
513
+ }
514
+
515
+ /* ================================================= 1b. the update handler == */
516
+
517
+ head('1b. the host half answers checkUpdate through the platform web service')
518
+
519
+ /* 检查更新**必须**走平台给的通道:浏览器半包的 fetch 是陷阱,宿主半包在 vm 沙箱里
520
+ 没有 Node API,而 `ctx.web.fetch` 是 DSH 自己的 web_fetch 工具用的那一条服务。
521
+ 这一节把宿主编译出来的逻辑当纯函数测 —— 第一次真正测到宿主半包的行为。 */
522
+ {
523
+ const mod = await import(pathToFileURL(hostPath).href + '?probe=update')
524
+ const { readPublishedVersion, compareVersions, registerHandlers, inject } = mod
525
+
526
+ /* web 服务必须声明为**可选**:硬依赖一个没加载的服务会让 fiber 永远 waiting,
527
+ 她会直接从界面上消失。这条是安全属性,不是风格偏好。 */
528
+ const declaresWeb = inject && typeof inject === 'object'
529
+ check(Boolean(declaresWeb) && Array.isArray(inject.optional) && inject.optional.includes('web'),
530
+ 'web is declared as an OPTIONAL inject', JSON.stringify(inject))
531
+ check(Boolean(declaresWeb) && Array.isArray(inject.required) && !inject.required.includes('web'),
532
+ 'and not as a required one — a missing service must not hide her', JSON.stringify(inject))
533
+
534
+ const fakeWeb = (outcome) => ({
535
+ fetch: async (request) => {
536
+ if (outcome.throw) throw new Error(outcome.throw)
537
+ return {
538
+ url: request.url,
539
+ statusCode: outcome.status ?? 200,
540
+ body: { kind: 'text', content: outcome.body ?? '' },
541
+ truncated: false,
542
+ }
543
+ },
544
+ })
545
+
546
+ const good = await readPublishedVersion(fakeWeb({ body: '{"name":"dsh-wisp","version":"9.9.9"}' }), 'https://x/y')
547
+ check(good.ok === true && good.latest === '9.9.9', 'a 200 with a version field is read correctly', JSON.stringify(good))
548
+
549
+ const notFound = await readPublishedVersion(fakeWeb({ status: 404, body: 'nope' }), 'https://x/y')
550
+ check(notFound.ok === false && notFound.reason === 'http-404',
551
+ 'a non-2xx is a result, not a throw — and is reported as such', JSON.stringify(notFound))
552
+
553
+ const noWeb = await readPublishedVersion(undefined, 'https://x/y')
554
+ check(noWeb.ok === false && noWeb.reason === 'no-web-service',
555
+ 'without the web service it says so instead of throwing', JSON.stringify(noWeb))
556
+
557
+ const blewUp = await readPublishedVersion(fakeWeb({ throw: 'socket closed' }), 'https://x/y')
558
+ check(blewUp.ok === false && blewUp.reason === 'fetch-failed' && blewUp.detail === 'socket closed',
559
+ 'a transport failure is caught and reported', JSON.stringify(blewUp))
560
+
561
+ const notJson = await readPublishedVersion(fakeWeb({ body: '<html>404</html>' }), 'https://x/y')
562
+ check(notJson.ok === false && notJson.reason === 'not-json', 'a non-JSON body is reported', JSON.stringify(notJson))
563
+
564
+ const noField = await readPublishedVersion(fakeWeb({ body: '{"name":"dsh-wisp"}' }), 'https://x/y')
565
+ check(noField.ok === false && noField.reason === 'no-version-field',
566
+ 'a manifest without a version is reported', JSON.stringify(noField))
567
+
568
+ check(compareVersions('1.2.3', '1.2.4') === -1 && compareVersions('1.2.3', '1.2.3') === 0
569
+ && compareVersions('1.3.0', '1.2.9') === 1, 'version comparison is numeric, not lexicographic',
570
+ `${compareVersions('1.10.0', '1.9.0')} for 1.10.0 vs 1.9.0`)
571
+
572
+ /* 注册:harness 是沙箱全局,测试里临时摆一个座位 */
573
+ const previousSeat = globalThis.harness
574
+ const handlers = new Map()
575
+ globalThis.harness = { handle: (method, fn) => { handlers.set(method, fn); return () => handlers.delete(method) } }
576
+ const registered = registerHandlers({ web: fakeWeb({ body: '{"version":"9.9.9"}' }) }, {})
577
+ check(registered.registered === true && handlers.has('checkUpdate'),
578
+ 'registerHandlers puts checkUpdate on the harness seat', JSON.stringify({ registered: registered.registered }))
579
+ const answer = await handlers.get('checkUpdate')({ current: '1.0.0' })
580
+ check(answer.ok === true && answer.latest === '9.9.9' && answer.current === '1.0.0'
581
+ && typeof answer.checkedAt === 'string',
582
+ 'the handler returns a plain, serializable verdict', JSON.stringify(answer))
583
+ const injectedUrl = handlers.size > 0 ? registered.url : null
584
+ check(typeof injectedUrl === 'string' && injectedUrl.startsWith('https://'),
585
+ 'and it reads from a real https manifest URL', String(injectedUrl))
586
+
587
+ /* 座位不在时不许抛:宿主半包在别的运行时里也要能 import */
588
+ delete globalThis.harness
589
+ let noSeatThrew = false
590
+ let noSeat = null
591
+ try { noSeat = registerHandlers({}, {}) } catch (error) { noSeatThrew = true }
592
+ check(noSeatThrew === false && noSeat && noSeat.registered === false,
593
+ 'without a harness seat it reports that instead of throwing', JSON.stringify(noSeat))
594
+
595
+ globalThis.harness = { handle: (method, fn) => { handlers.set(method, fn); return () => handlers.delete(method) } }
596
+ const effects = []
597
+ let appliedThrew = false
598
+ try {
599
+ mod.apply({ web: fakeWeb({ body: '{"version":"9.9.9"}' }), effect: (fn, label) => { effects.push(label); return () => {} } }, {})
600
+ } catch (error) { appliedThrew = true }
601
+ check(appliedThrew === false && handlers.has('checkUpdate') && effects.length === 1,
602
+ 'apply() registers the handler and keeps its disposer on the fiber', effects.join(', '))
603
+
604
+ if (previousSeat === undefined) delete globalThis.harness
605
+ else globalThis.harness = previousSeat
606
+ }
607
+
608
+ /* ===================================================== 2. the BROWSER half == */
609
+
610
+ head('2. browser half (lib/client.js) must register with the module loader')
611
+
612
+ const clientSrc = clientPath && existsSync(clientPath) ? readFileSync(clientPath, 'utf8') : null
613
+
614
+ if (clientSrc === null) {
615
+ bad('exports["./client"] resolves', String(clientRel))
616
+ } else {
617
+ if (/^\s*(import|export)\s/m.test(clientSrc)) {
618
+ bad('no import/export statements', 'this is a classic script, not a module')
619
+ } else ok('no import/export statements')
620
+
621
+ if (/window\.__ModuleLoader__\.load\(\s*\{/.test(clientSrc)) ok('calls window.__ModuleLoader__.load({…})')
622
+ else bad('calls window.__ModuleLoader__.load({…})', 'this is how a browser half registers')
623
+
624
+ if (/id:\s*['"]dsh-wisp\/client['"]/.test(clientSrc)) ok("registers under id 'dsh-wisp/client'")
625
+ else bad("registers under id 'dsh-wisp/client'")
626
+
627
+ if (/factory:\s*\(require\)\s*=>/.test(clientSrc)) ok('factory takes `require`')
628
+ else bad('factory takes `require`')
629
+
630
+ if (/return\s+module\.exports/.test(clientSrc)) ok('factory returns module.exports')
631
+ else bad('factory returns module.exports')
632
+
633
+ if (/<\/script/i.test(clientSrc)) bad('no literal </script>', 'would break inline embedding')
634
+ else ok('no literal </script>')
635
+
636
+ // Static floor under the trap test below: a call to a trapped global is a
637
+ // guaranteed runtime throw in a client half, and no bundler flags it.
638
+ const trappedCall = clientSrc.match(/(setTimeout|setInterval|clearTimeout|clearInterval|fetch|require)\s*\(/)
639
+ if (trappedCall) bad('never calls a trapped global', `found ${trappedCall[1]}(`)
640
+ else ok('never calls a trapped global', 'checked statically; execution below proves it')
641
+ }
642
+
643
+ /* ============================ 3. the browser half, executed and driven ===== */
644
+
645
+ head('3. browser half runs against the real contract (traps armed)')
646
+
647
+ const installFakes = () => {
648
+ globalThis.URL = FakeURL
649
+ globalThis.Blob = FakeBlob
650
+ globalThis.atob = fakeAtob
651
+ globalThis.Image = FakeImage
652
+ }
653
+
654
+ if (clientSrc !== null) {
655
+ const savedGlobals = { URL: globalThis.URL, Blob: globalThis.Blob, atob: globalThis.atob, Image: globalThis.Image }
656
+ installFakes()
657
+
658
+ try {
659
+ /* ---------------------------------------------------------- 3a. mount -- */
660
+ head('3a. Client timer service path')
661
+
662
+ const h = createHarness({ timer: true })
663
+ active = h
664
+ h.evaluate(clientSrc)
665
+ ok('registers without throwing', `id=${h.loaded.id}`)
666
+
667
+ const exportsObj = h.module()
668
+ const plugin = exportsObj?.default
669
+ check(typeof plugin?.apply === 'function', 'exports a mountable plugin', `name=${plugin?.name}`)
670
+
671
+ plugin.apply(h.ctx, { happyMs: 6000 })
672
+ const api = h.win.__wisp
673
+ check(typeof api?.destroy === 'function', 'public handle exposed', Object.keys(api ?? {}).join(','))
674
+ check(api?.clock === 'timer-service', 'schedules on the Client timer service', String(api?.clock))
675
+
676
+ h.advance(50)
677
+ const layer = h.find('wisp-layer')
678
+ const root = h.find('wisp-root')
679
+ check(layer !== null && layer.parentNode === h.bodyEl, 'mounted into document.body')
680
+ check(root !== null, 'creature element exists', `mood=${root?.dataset.mood}`)
681
+ check(h.styleInserts.length === 1, 'stylesheet pushed through the styles builtin')
682
+ check(h.frames.length === 0, 'spends zero animation frames while scheduling', 'the rAF loop is not even armed')
683
+ check(root.style.width === '560px' && root.style.height === '840px',
684
+ 'drawn at the enlarged default (140x210 base x 4)', `${root.style.width} x ${root.style.height}`)
685
+
686
+ /* ------------------------------------------------- 3b. sprite pipeline -- */
687
+ head('3b. sprite pipeline (the broken-image regression)')
688
+
689
+ if (h.srcs.length === 0) {
690
+ bad('sprite assigned to an <img>', 'no src was ever set')
691
+ } else if (h.srcs.every((s) => typeof s === 'string' && s.startsWith('blob:'))) {
692
+ ok('sprites use blob: object URLs', h.srcs[0])
693
+ } else if (h.srcs.some((s) => typeof s === 'string' && s.startsWith('data:'))) {
694
+ bad('sprites must not use data: URIs', 'this shell renders them as a broken image')
695
+ } else {
696
+ bad('sprite URL scheme', JSON.stringify(h.srcs).slice(0, 200))
697
+ }
698
+ check(h.blobs.length > 0, 'base64 decoded into Blob objects', `${h.blobs.length} blob(s)`)
699
+ check(h.blobs.every((b) => b.type.startsWith('image/')), 'blob MIME types are images', h.blobs[0]?.type)
700
+
701
+ /* ------------------------------------------------ 3c. moods and speech -- */
702
+ head('3c. moods, speech and idle/sleep')
703
+
704
+ h.advance(900)
705
+ const layerEl = api.element.parentNode
706
+ check(layerEl.querySelectorAll('.wisp-say').length === 1, 'says hello after mount')
707
+ api.say('测试')
708
+ check(layerEl.querySelectorAll('.wisp-say').length === 1, 'say() replaces the bubble rather than stacking',
709
+ `${layerEl.querySelectorAll('.wisp-say').length} attached`)
710
+ check(api.element.querySelectorAll('.wisp-say').length === 0,
711
+ 'the bubble is not inside the mirrored root', 'a child of the root would render its text backwards')
712
+ api.mood('happy')
713
+ check(root.dataset.mood === 'happy', 'mood() control works')
714
+ check(h.srcs[h.srcs.length - 1] !== h.srcs[0], 'sprite switches with mood', String(h.srcs[h.srcs.length - 1]))
715
+
716
+ // The swap must be a cross-fade: two layers for the length of the fade,
717
+ // then the outgoing one is dropped. A hard `src` swap reads as a glitch.
718
+ // attn 以前借用 work 那张图(两个状态长得一样,只能靠光晕区分);现在有自己的一张,
719
+ // 而且某套皮肤缺 attn 时会退回 work —— 这条断言把"用的是哪一张"钉住。
720
+ const bodyNode = root.querySelector('.wisp-body')
721
+ const imgLayers = () => bodyNode.querySelectorAll('.wisp-img').length
722
+ check(imgLayers() === 2, 'a mood change cross-fades instead of swapping the src', `${imgLayers()} layers`)
723
+ h.advance(400)
724
+ check(imgLayers() === 1, 'the outgoing sprite layer is dropped after the fade', `${imgLayers()} layers`)
725
+
726
+ api.mood('idle')
727
+ h.advance(91000, 500)
728
+ check(root.dataset.mood === 'sleep', 'sleeps after the idle window', String(root.dataset.mood))
729
+ const zzz = root.querySelectorAll('.wisp-zzz').length
730
+ check(zzz === 2, 'shows two z glyphs while asleep', `${zzz}`)
731
+ h.win.dispatch('pointermove', {})
732
+ h.advance(10)
733
+ check(root.dataset.mood === 'idle', 'any input wakes her')
734
+ check(root.querySelectorAll('.wisp-zzz').length === 0, 'the z glyphs go when she wakes')
735
+
736
+ /* --------------------------------------------------- 3d. reactions ----- */
737
+ head('3d. reactions: busy, composer, completion')
738
+
739
+ // Each phase advances just past one poll so the assertion lands inside the
740
+ // state it is testing instead of one poll later.
741
+ h.state.busy = true
742
+ h.advance(1300, 100)
743
+ check(root.dataset.mood === 'alert', 'agent busy -> alert', String(root.dataset.mood))
744
+ h.advance(4000, 500)
745
+ h.state.busy = false
746
+ h.advance(1300, 100)
747
+ check(root.dataset.mood === 'happy', 'a finished run is celebrated', String(root.dataset.mood))
748
+ const said = layerEl.querySelector('.wisp-say')
749
+ check(said !== null && Array.isArray(linesInBundle()?.done) && linesInBundle().done.includes(said.textContent),
750
+ 'the celebration line comes from the done pool',
751
+ said?.textContent + '(池子里有 ' + (linesInBundle()?.done?.length ?? '?') + ' 句)')
752
+ h.advance(7000, 500)
753
+ check(root.dataset.mood === 'idle', 'the celebration ends on its own', String(root.dataset.mood))
754
+
755
+ h.state.composerText = 'hello'
756
+ h.advance(1300, 100)
757
+ check(root.dataset.mood === 'alert', 'composer text -> alert', String(root.dataset.mood))
758
+ h.state.composerText = null
759
+ h.advance(1300, 100)
760
+ check(root.dataset.mood === 'idle', 'back to idle when nothing is happening', String(root.dataset.mood))
761
+
762
+ /* ------------------------------------------- 3d-bis. what a tick costs -- */
763
+ head('3d-bis. one tick does not re-walk the document')
764
+
765
+ // A composer that is present and unchanged must be resolved once and then
766
+ // read directly; a tick that re-queries it is the difference between
767
+ // ~0.3 ms and ~0 ms per tick on a long transcript.
768
+ const beforeQueries = { ...h.queries }
769
+ h.state.composerText = 'still typing'
770
+ h.advance(POLL_MS * 8, 100)
771
+ const composerSpend = h.queries.composer - beforeQueries.composer
772
+ check(composerSpend === 0, 'a resolved composer is not re-queried per tick', `${composerSpend} query/queries over 8 ticks`)
773
+ check(h.queries.all === beforeQueries.all, 'the tick never collects a NodeList', `${h.queries.all - beforeQueries.all}`)
774
+ h.state.composerText = null
775
+ h.advance(1300, 100)
776
+
777
+ // With no composer open at all (the blank-session Hero), the precise hook is
778
+ // probed — cheaply, and so a session that opens is noticed at once — while
779
+ // the ambiguous legacy shapes are only probed on the retry cadence.
780
+ const hm = createHarness({ timer: true, composerText: null })
781
+ active = hm
782
+ hm.evaluate(clientSrc)
783
+ hm.module().apply(hm.ctx, {})
784
+ const TICKS = 9
785
+ hm.advance(POLL_MS * TICKS, 100)
786
+ const probes = hm.queries.composer
787
+ check(probes <= TICKS + Math.ceil(TICKS / 4) * 2,
788
+ 'an absent composer costs one probe per tick, not three',
789
+ `${probes} probes over ${TICKS} ticks (the old shape paid ${TICKS * 3})`)
790
+ hm.win.__wisp.destroy()
791
+ active = h // hand the Blob/URL recorder back to the main harness
792
+
793
+ /* ------------------------------------- 3d-ter. 有东西在等你处理 ------- */
794
+ head('3d-ter. attention when the harness is waiting on you')
795
+
796
+ const bubbles = () => h.all('wisp-say').length
797
+ const beforeAttn = bubbles()
798
+ h.state.pending = 'q-1'
799
+ h.advance(1300, 100)
800
+ check(root.dataset.mood === 'attn', 'a pending question switches her to attention', String(root.dataset.mood))
801
+ check(h.srcs[h.srcs.length - 1] !== h.srcs[0],
802
+ 'the attention state has its own sprite rather than borrowing idle',
803
+ String(h.srcs[h.srcs.length - 1]).slice(0, 22))
804
+ check(bubbles() > beforeAttn, 'she says something about it',
805
+ layerEl.querySelector('.wisp-say')?.textContent)
806
+
807
+ // 同一件事不重复念;换了一件才再说
808
+ const afterFirst = bubbles()
809
+ h.advance(6000, 300)
810
+ check(bubbles() === afterFirst, 'she does not repeat herself for the same item', `${bubbles()} vs ${afterFirst}`)
811
+ check(root.dataset.mood === 'attn', 'and she stays on attention while it is unanswered')
812
+ h.state.pending = 'q-2'
813
+ h.advance(1300, 100)
814
+ check(bubbles() > afterFirst, 'a different item is announced again', `${bubbles()}`)
815
+
816
+ // 睡着了也要被叫起来 —— 这是唯一"不理它就卡住"的状态
817
+ h.state.pending = null
818
+ h.advance(1300, 100)
819
+ check(root.dataset.mood === 'idle', 'she relaxes once it is answered', String(root.dataset.mood))
820
+ api.mood('sleep')
821
+ h.state.pending = 'q-3'
822
+ h.advance(1300, 100)
823
+ check(root.dataset.mood === 'attn', 'a question wakes her up', String(root.dataset.mood))
824
+ h.state.pending = null
825
+ h.advance(1300, 100)
826
+ h.state.composerText = null
827
+ check(root.dataset.mood === 'idle', 'back to normal afterwards', String(root.dataset.mood))
828
+
829
+ /* ------------------------------------- 3d-quater. 出错时的反应 --------- */
830
+ head('3d-quater. she notices when a run fails')
831
+
832
+ // 先制造一轮 busy -> idle,并且这一轮里有 2 个出错行
833
+ const errorsBefore = h.queries.errors
834
+ h.state.errors = 2
835
+ h.state.busy = true
836
+ h.advance(3000, 200)
837
+ check(root.dataset.mood !== 'worried', 'no verdict while the run is still going', String(root.dataset.mood))
838
+ const bubblesBeforeError = h.all('wisp-say').length
839
+ h.state.busy = false
840
+ h.advance(1300, 100)
841
+ check(root.dataset.mood === 'worried', 'a failed run puts her in the worried state', String(root.dataset.mood))
842
+ check(h.all('wisp-say').length > bubblesBeforeError, 'and she says something about it',
843
+ layerEl.querySelector('.wisp-say')?.textContent)
844
+ check(h.queries.errors - errorsBefore === 1,
845
+ 'the document is scanned for errors once per finished run, not once per poll',
846
+ `${h.queries.errors - errorsBefore} scan(s) for ${Math.ceil(3000 / POLL_MS) + 1} polls`)
847
+
848
+ // 同一批错误不该每轮都重念一遍
849
+ const afterFirstError = h.all('wisp-say').length
850
+ h.state.busy = true
851
+ h.advance(3000, 200)
852
+ h.state.busy = false
853
+ h.advance(1300, 100)
854
+ check(h.all('wisp-say').length === afterFirstError,
855
+ 'the same errors are not announced again on the next run', `${h.all('wisp-say').length} vs ${afterFirstError}`)
856
+ check(h.queries.errors - errorsBefore === 2, 'still one scan per run', `${h.queries.errors - errorsBefore}`)
857
+
858
+ // 错误清掉之后再出现新的,还要再说一次
859
+ h.state.errors = 0
860
+ h.advance(1300, 100)
861
+ h.state.errors = 3
862
+ h.state.busy = true
863
+ h.advance(3000, 200)
864
+ h.state.busy = false
865
+ h.advance(1300, 100)
866
+ check(h.all('wisp-say').length > afterFirstError, 'fresh errors are announced again',
867
+ `${h.all('wisp-say').length}`)
868
+
869
+ // 她不会一直挂着这个表情
870
+ h.advance(4000, 300)
871
+ check(root.dataset.mood === 'idle', 'the worried look is temporary', String(root.dataset.mood))
872
+
873
+ // 干净的一轮不该有任何反应
874
+ h.state.errors = 0
875
+ const cleanBubbles = h.all('wisp-say').length
876
+ h.state.busy = true
877
+ h.advance(3000, 200)
878
+ h.state.busy = false
879
+ h.advance(1300, 100)
880
+ check(root.dataset.mood !== 'worried', 'a clean run leaves her alone', String(root.dataset.mood))
881
+
882
+ // reactions: false 时连扫都不扫
883
+ h.state.busy = false
884
+ h.state.errors = 1
885
+ api.configure({ reactions: false })
886
+ const scansOff = h.queries.errors
887
+ h.state.busy = true
888
+ h.advance(1500, 150)
889
+ h.state.busy = false
890
+ h.advance(1500, 150)
891
+ check(h.queries.errors === scansOff, 'reactions: false skips the error scan entirely', `${h.queries.errors - scansOff} scans`)
892
+ api.configure({ reactions: true })
893
+ h.state.errors = 0
894
+ h.advance(1300, 100)
895
+
896
+ /* ---------------- 3d-senies. doctor():钩子自检 ----------------------- */
897
+ head('3d-senies. doctor() reports whether the selectors actually resolve')
898
+
899
+ // 这些选择器是读 DSH 源码推出来的,错了不会有任何报错 —— 对应功能只是永远不触发。
900
+ // doctor() 就是为此存在的:让页面自己报告哪条链路是活的。
901
+ h.state.composerText = ''
902
+ h.state.busy = true
903
+ const snapshotBefore = { mood: root.dataset.mood, position: api.position, hidden: api.hidden }
904
+ const report = api.doctor()
905
+ check(typeof report === 'object' && report !== null, 'doctor() returns a report')
906
+ check(report.version === '1.4.0' || /^\d+\.\d+\.\d+$/.test(String(report.version)),
907
+ 'the report carries the running version', String(report.version))
908
+ check(Array.isArray(report.hooks?.composer) && report.hooks.composer.length === COMPOSER_HOOK_COUNT,
909
+ 'it lists every composer hook it tries', `${report.hooks?.composer?.length} hooks`)
910
+ check(report.hooks.composer.filter((x) => x.matches > 0).length === 1,
911
+ 'exactly one composer hook resolves while the composer is present',
912
+ report.hooks.composer.map((x) => `${x.selector}=${x.matches}`).join(' '))
913
+ check(report.hooks.composer[0].matches === 1 && report.hooks.composer[1].matches === 0,
914
+ 'and it is the most specific one that wins', 'the fallbacks stay cold when the best hook is there')
915
+ check(report.hooks.busy.matches === 1, 'the busy hook resolves while a run is going',
916
+ `${report.hooks.busy.selector} = ${report.hooks.busy.matches}`)
917
+ check(report.hooks.busy.ariaLabelsOnPage === undefined,
918
+ 'and it does not attach the label sample when the hook works',
919
+ 'the sample only appears on a miss')
920
+
921
+ /* 忙碌钩子是唯一无法静态确认的(aria-label 来自语言表),所以它 miss 时必须自解释:
922
+ 把页面上真实的按钮标签带出来,一次 doctor() 就能看出该匹配什么。 */
923
+ const idle = createHarness({ timer: true, busy: false, composerText: '', ariaLabels: ['停止', '搜索', '停止'] })
924
+ const keepIdle = active
925
+ active = idle
926
+ idle.evaluate(clientSrc)
927
+ idle.module().default.apply(idle.ctx, { reactions: false, wander: false })
928
+ const idleReport = idle.win.__wisp.doctor()
929
+ check(idleReport.hooks.busy.matches === 0, 'with no run going the busy hook is a miss',
930
+ String(idleReport.hooks.busy.matches))
931
+ check(JSON.stringify(idleReport.hooks.busy.ariaLabelsOnPage) === JSON.stringify(['停止', '搜索']),
932
+ 'and the miss carries the page labels it should have matched, deduplicated',
933
+ JSON.stringify(idleReport.hooks.busy.ariaLabelsOnPage))
934
+ idle.win.__wisp.destroy()
935
+ active = keepIdle
936
+ check(Array.isArray(report.hooks.pending) && report.hooks.pending.length === 3,
937
+ 'it reports all three pending-panel hooks', `${report.hooks.pending?.length}`)
938
+ check(typeof report.hooks.error === 'object' && report.hooks.error.matches === 0,
939
+ 'it reports the error hook even when nothing is failing', String(report.hooks.error?.matches))
940
+ check(report.tokens.length === 5 && report.tokens.every((t) => typeof t.token === 'string'),
941
+ 'it reads the theme tokens the stylesheet depends on', `${report.tokens.length} tokens`)
942
+ check(report.tokens.every((t) => t.value !== ''),
943
+ 'and the tokens resolve to real values in a healthy page',
944
+ report.tokens.map((t) => `${t.token.replace('--dsw-alias-', '')}=${t.value || '(空)'}`).join(' '))
945
+ check(report.problems.length === 0, 'a healthy page reports no problems', report.problems.join(' / '))
946
+ check(Array.isArray(report.problems), 'and returns a problems list', `${report.problems.length} problem(s)`)
947
+ check(typeof report.sessionsService === 'boolean',
948
+ 'it records whether the sessions service is reachable', String(report.sessionsService))
949
+
950
+ // 只读:自检不能改变任何状态
951
+ const snapshotAfter = { mood: root.dataset.mood, position: api.position, hidden: api.hidden }
952
+ check(snapshotBefore.mood === snapshotAfter.mood && snapshotBefore.hidden === snapshotAfter.hidden
953
+ && snapshotBefore.position.x === snapshotAfter.position.x
954
+ && snapshotBefore.position.y === snapshotAfter.position.y,
955
+ 'doctor() has no side effects', `${snapshotBefore.mood} -> ${snapshotAfter.mood}`)
956
+
957
+ // 关键:钩子真的坏掉时它必须**报出来**,而不是永远说"一切正常"
958
+ h.state.composerText = null
959
+ const degraded = api.doctor()
960
+ check(degraded.hooks.composer.every((x) => x.matches === 0),
961
+ 'with the composer gone, no composer hook resolves', 'the harness models a composer that comes and goes')
962
+ check(degraded.problems.some((p) => p.includes('输入框')) ,
963
+ 'and doctor() names the feature that stops working, not just a number',
964
+ degraded.problems.join(' / '))
965
+ h.state.composerText = ''
966
+ h.state.busy = false
967
+ h.advance(1300, 100)
968
+
969
+ /* 回退链:真实页面可能换一种输入框形态。第二、三条钩子必须真的能用 ——
970
+ 之前替身让三个同时命中,回退链等于没测。
971
+ 注意这里必须 reactions: true —— 轮询由它控制,关掉它等于把被测功能本身关掉了。 */
972
+ for (const shape of [1, 2]) {
973
+ const fallback = createHarness({ timer: true, composerText: '', composerShape: shape })
974
+ const keepActive = active
975
+ active = fallback
976
+ fallback.evaluate(clientSrc)
977
+ fallback.module().default.apply(fallback.ctx, { reactions: true, wander: false, celebrate: false })
978
+ fallback.state.composerText = '正在打字'
979
+ fallback.advance(1300, 100)
980
+ const early = fallback.find('wisp-root').dataset.mood
981
+ fallback.advance(8000, 200)
982
+ const mood = fallback.find('wisp-root').dataset.mood
983
+ const doctorOfFallback = fallback.win.__wisp.doctor()
984
+ check(mood === 'alert' && doctorOfFallback.hooks.composer[shape].matches === 1,
985
+ `the composer fallback #${shape + 1} (${['[data-composer-input]', 'textarea', '[contenteditable="true"]'][shape]}) really works`,
986
+ `mood=${mood}(1 个周期后是 ${early}), matches=${doctorOfFallback.hooks.composer.map((x) => x.matches).join('/')}`)
987
+ fallback.win.__wisp.destroy()
988
+ active = keepActive
989
+ }
990
+ h.state.composerText = ''
991
+
992
+ /* ---------------- 3d-septies. 台词池与时段招呼 ------------------------ */
993
+ head('3d-septies. line pools and the time-of-day greeting')
994
+
995
+ const pools = linesInBundle()
996
+ check(pools !== null, 'the line pools can be read back out of the bundle', 'data-driven, not copied into the test')
997
+ if (pools !== null) {
998
+ /* 阈值按**见到的频率**分级,而不是一刀切:
999
+ · hint 一辈子只说一次,3 句绰绰有余;
1000
+ · 招呼语一次挂载说一句,5 句够;
1001
+ · 其余场景一天会被看到几十次,池子窄了会在一周内被看穿。 */
1002
+ const WIDE = ['click', 'chatter', 'done', 'error', 'attn', 'move', 'home', 'happy', 'sleep', 'wake']
1003
+ const NARROW = ['hint', 'hello', 'morning', 'afternoon', 'evening', 'night']
1004
+ const tooThin = WIDE.filter((k) => !Array.isArray(pools[k]) || pools[k].length < 8)
1005
+ .map((k) => `${k}=${pools[k]?.length ?? 0} (<8)`)
1006
+ const thinNarrow = NARROW.filter((k) => !Array.isArray(pools[k]) || pools[k].length < 3)
1007
+ .map((k) => `${k}=${pools[k]?.length ?? 0} (<3)`)
1008
+ check(tooThin.length === 0 && thinNarrow.length === 0,
1009
+ 'the pools you see repeatedly are wide, the one-off ones are merely non-empty',
1010
+ [...tooThin, ...thinNarrow].length
1011
+ ? [...tooThin, ...thinNarrow].join(' ')
1012
+ : `常用池 ≥8(${WIDE.map((k) => pools[k].length).join('/')}), 罕见池 ≥3(${NARROW.map((k) => pools[k].length).join('/')})`)
1013
+ const dupes = []
1014
+ for (const [name, pool] of Object.entries(pools)) {
1015
+ const seen = new Set()
1016
+ for (const line of pool) { if (seen.has(line)) dupes.push(`${name}:${line}`); seen.add(line) }
1017
+ }
1018
+ check(dupes.length === 0, 'no pool repeats a line', dupes.slice(0, 3).join(' | '))
1019
+ check(['morning', 'afternoon', 'evening', 'night'].every((k) => Array.isArray(pools[k]) && pools[k].length > 0),
1020
+ 'all four time-of-day pools exist and are non-empty')
1021
+ }
1022
+
1023
+ /* 招呼语必须来自**当前时段**的池子。时段在测试这边按同样的规则算一遍 ——
1024
+ 挂载前后各算一次,跨过整点也不会误判。 */
1025
+ const poolNameOf = (h24) => (h24 < 5 || h24 >= 22 ? 'night' : h24 < 11 ? 'morning' : h24 < 18 ? 'afternoon' : 'evening')
1026
+ const hourBeforeMount = new Date().getHours()
1027
+ const greetHarness = createHarness({ timer: true, composerText: null })
1028
+ const keepGreet = active
1029
+ active = greetHarness
1030
+ greetHarness.evaluate(clientSrc)
1031
+ greetHarness.module().default.apply(greetHarness.ctx, { reactions: false, wander: false })
1032
+ const hourAfterMount = new Date().getHours()
1033
+ greetHarness.advance(1200, 100)
1034
+ const greetReport = greetHarness.win.__wisp.doctor()
1035
+ const greetLine = greetHarness.find('wisp-say')?.textContent
1036
+ const allowed = new Set([poolNameOf(hourBeforeMount), poolNameOf(hourAfterMount)])
1037
+ check(allowed.has(greetReport.greeting.pool),
1038
+ 'the greeting pool matches the hour the mount happened in',
1039
+ `${greetReport.greeting.hour} 点 -> ${greetReport.greeting.pool}(允许 ${[...allowed].join('/')})`)
1040
+ check(pools !== null && pools[greetReport.greeting.pool]?.includes(greetLine),
1041
+ 'and the line she said really comes from that pool', `${greetLine}(${greetReport.greeting.pool})`)
1042
+ check(greetReport.greeting.poolSize === pools?.[greetReport.greeting.pool]?.length,
1043
+ 'the report exposes the pool size for debugging', `${greetReport.greeting.poolSize} 句`)
1044
+ greetHarness.win.__wisp.destroy()
1045
+ active = keepGreet
1046
+
1047
+ /* ---------------- 3d-octies. 窗口背景(Mica)能否露出来 ----------------- */
1048
+ head('3d-octies. the option that lets the window backdrop through')
1049
+
1050
+ /* "壁纸可见"有两条路:整窗 alpha(会把她一起乘掉)与 DWM 背景材质(内容保持不透明)。
1051
+ 后者要求页面在背景处不画不透明像素 —— 这个功能就是清掉挡路的那一层。
1052
+ 默认必须是关的:那是影响整个应用的视觉改动,不该由她替别人做主。 */
1053
+ const backdropOff = createHarness({ timer: true, composerText: '' })
1054
+ const keepBackdrop = active
1055
+ active = backdropOff
1056
+ backdropOff.evaluate(clientSrc)
1057
+ backdropOff.module().default.apply(backdropOff.ctx, { reactions: false, wander: false })
1058
+ check(backdropOff.win.__wisp.config.backdrop === false, 'the backdrop option is off by default')
1059
+ check(backdropOff.document.getElementById('wisp-backdrop') === null,
1060
+ 'and nothing is injected while it is off', 'no style element')
1061
+ backdropOff.win.__wisp.destroy()
1062
+ active = keepBackdrop
1063
+
1064
+ // 打开它:铺满视口的底色应当被清掉,内容卡片不能被碰
1065
+ const fullScreen = {
1066
+ tagName: 'DIV', className: 'appBackdrop',
1067
+ getBoundingClientRect() { return { width: 1920, height: 1200 } },
1068
+ }
1069
+ const card = {
1070
+ tagName: 'DIV', className: 'messageCard',
1071
+ getBoundingClientRect() { return { width: 600, height: 200 } },
1072
+ }
1073
+ const backdrop = createHarness({
1074
+ timer: true, composerText: '', overlayStack: [fullScreen, card],
1075
+ backgrounds: { appBackdrop: 'rgb(12, 14, 18)', messageCard: 'rgb(30, 32, 40)' },
1076
+ })
1077
+ const keepBackdrop2 = active
1078
+ active = backdrop
1079
+ backdrop.evaluate(clientSrc)
1080
+ backdrop.module().default.apply(backdrop.ctx, { reactions: false, wander: false, backdrop: true })
1081
+ const tag = backdrop.document.getElementById('wisp-backdrop')
1082
+ check(backdrop.win.__wisp.config.backdrop === true,
1083
+ 'the mount config turns the option on', String(backdrop.win.__wisp.config.backdrop))
1084
+ check(tag !== null, 'with the option on and a full-viewport backdrop present, a rule is injected')
1085
+ check(tag !== null && tag.textContent.includes('.appBackdrop'),
1086
+ 'and it targets the full-viewport element', tag?.textContent)
1087
+ check(tag !== null && !tag.textContent.includes('messageCard'),
1088
+ 'but never a content card, even though it also paints a background',
1089
+ 'the size floor keeps cards out')
1090
+ check(tag !== null && tag.textContent.includes('background-color:transparent !important'),
1091
+ 'the rule neutralises the colour', tag?.textContent)
1092
+
1093
+ const bdReport = backdrop.win.__wisp.doctor()
1094
+ check(bdReport.backdrop.enabled === true && bdReport.backdrop.cleared.length === 1,
1095
+ 'doctor() reports what it cleared', JSON.stringify(bdReport.backdrop.cleared))
1096
+ check(bdReport.backdrop.blockers.length === 1 && bdReport.backdrop.blockers[0].background === 'rgb(12, 14, 18)',
1097
+ 'and what it found blocking, with the colour', JSON.stringify(bdReport.backdrop.blockers))
1098
+
1099
+ backdrop.win.__wisp.configure({ backdrop: false })
1100
+ check(backdrop.document.getElementById('wisp-backdrop') === null,
1101
+ 'turning it off leaves no injected rule behind')
1102
+ backdrop.win.__wisp.destroy()
1103
+ active = keepBackdrop2
1104
+ check(backdrop.document.getElementById('wisp-backdrop') === null,
1105
+ 'and teardown leaves none either', '她自己的东西自己清干净')
1106
+
1107
+ // 类名以数字开头(哈希类名很常见)时不能写进选择器 —— 无效规则会毁掉整张样式表
1108
+ const oddClass = {
1109
+ tagName: 'DIV', className: '8xY_thing',
1110
+ getBoundingClientRect() { return { width: 1920, height: 1200 } },
1111
+ }
1112
+ const odd = createHarness({
1113
+ timer: true, composerText: '', overlayStack: [oddClass],
1114
+ backgrounds: { '8xY_thing': 'rgb(1, 2, 3)' },
1115
+ })
1116
+ const keepOdd = active
1117
+ active = odd
1118
+ odd.evaluate(clientSrc)
1119
+ odd.module().default.apply(odd.ctx, { reactions: false, wander: false, backdrop: true })
1120
+ const oddTag = odd.document.getElementById('wisp-backdrop')
1121
+ const oddCss = oddTag === null ? '' : oddTag.textContent
1122
+ check(!oddCss.includes('8xY_thing'), 'a class unsafe in a selector is skipped, not pasted in',
1123
+ oddCss === '' ? '没有注入规则' : oddCss)
1124
+ check(odd.win.__wisp.doctor().backdrop.skipped.length === 1,
1125
+ 'and doctor() says so, so a silent miss stays visible',
1126
+ JSON.stringify(odd.win.__wisp.doctor().backdrop.skipped))
1127
+ odd.win.__wisp.destroy()
1128
+ active = keepOdd
1129
+
1130
+ /* ---------------- 3d-nonies. 页面上只能有一个她 ------------------------ */
1131
+ head('3d-nonies. exactly one of her, no matter how many times it mounts')
1132
+
1133
+ /* "出现了两个她"是用户先发现的,不该是这样。原因可能是"只靠 window.__wisp 那一个句柄
1134
+ 不足以认定页面上没有旧层"(句柄属于另一次加载/另一个上下文时就会漏),
1135
+ 所以挂载前按类名清场,而不是只信那个引用。 */
1136
+ const twice = createHarness({ timer: true, composerText: '' })
1137
+ const keepTwice = active
1138
+ active = twice
1139
+ twice.evaluate(clientSrc)
1140
+ const twicePlugin = twice.module().default
1141
+ twicePlugin.apply(twice.ctx, { reactions: false, wander: false, size: 2 })
1142
+ twice.advance(300, 100)
1143
+ const layersAfterFirst = twice.document.querySelectorAll('.wisp-layer').length
1144
+ /* 把句柄藏起来,模拟"旧层还在、但 window.__wisp 指向别处/已被清掉"这种漏网情形 */
1145
+ const firstApi = twice.win.__wisp
1146
+ delete twice.win.__wisp
1147
+ twicePlugin.apply(twice.ctx, { reactions: false, wander: false, size: 2 })
1148
+ twice.advance(300, 100)
1149
+ const layersAfterSecond = twice.document.querySelectorAll('.wisp-layer').length
1150
+ check(layersAfterFirst === 1, 'one mount leaves exactly one layer', String(layersAfterFirst))
1151
+ check(layersAfterSecond === 1,
1152
+ 'and a second mount still leaves exactly one — the stray is removed by class, not by a remembered handle',
1153
+ `${layersAfterFirst} -> ${layersAfterSecond}`)
1154
+ const twiceInstances = twice.win.__wisp.doctor().instances
1155
+ check(twiceInstances.layers === 1 && twiceInstances.roots === 1,
1156
+ 'doctor() reports the count so a duplicate can never hide', JSON.stringify(twiceInstances))
1157
+ check(firstApi !== twice.win.__wisp, 'the surviving instance is the newest one', '老实例被清掉')
1158
+ twice.win.__wisp.destroy()
1159
+ active = keepTwice
1160
+
1161
+ /* ---------------- 3d-decies. 连戳 -> 抗议 -------------------------------- */
1162
+ head('3d-decies. poking her repeatedly turns into a protest')
1163
+
1164
+ /* 这个行为需要一个**可控的时钟**:连戳窗口是 3 秒,如果它读真实时间,测试里根本推不动它。
1165
+ 实现里已经改用 nowMs()(插件自己的时钟),所以这里能用 advance 精确推进。 */
1166
+ const pk = createHarness({ timer: true, composerText: '' })
1167
+ const keepPk = active
1168
+ active = pk
1169
+ pk.evaluate(clientSrc)
1170
+ pk.module().default.apply(pk.ctx, { reactions: true, wander: false, celebrate: false })
1171
+ pk.advance(1200, 100)
1172
+ const pkApi = pk.win.__wisp
1173
+ const pkBody = pk.find('wisp-body')
1174
+ const pkMood = () => pk.find('wisp-root').dataset.mood
1175
+ const pokeOnce = () => {
1176
+ const x = pkApi.position.x + 280
1177
+ const y = pkApi.position.y + 420
1178
+ pkBody.dispatch('pointerdown', {
1179
+ button: 0, clientX: x, clientY: y, preventDefault() {}, stopPropagation() {},
1180
+ })
1181
+ pk.win.dispatch('pointerup', {})
1182
+ pk.advance(200, 50)
1183
+ }
1184
+
1185
+ pokeOnce()
1186
+ check(pkMood() === 'happy', 'the first poke is the happy reaction', pkMood())
1187
+ pokeOnce()
1188
+ check(pkMood() === 'happy', 'so is the second', pkMood())
1189
+ pokeOnce()
1190
+ check(pkMood() === 'poked', 'the third poke in a row is a protest', pkMood())
1191
+ /* 气泡是每次新建一个、旧的移除:find() 拿到的是最早那条,必须取最新的。 */
1192
+ const protestLine = pk.all('wisp-say').at(-1)?.textContent
1193
+ check(Array.isArray(linesInBundle()?.poked) && linesInBundle().poked.includes(protestLine),
1194
+ 'and it comes from the poked pool, not from the click pool',
1195
+ protestLine + '(池子 ' + (linesInBundle()?.poked?.length ?? '?') + ' 句)')
1196
+
1197
+ // 瞬时状态必须活过同一时刻的反应轮询 —— worried 当初就是被它盖掉的
1198
+ pk.advance(1300, 100)
1199
+ check(pkMood() === 'poked', 'the reaction poll does not overwrite the protest', pkMood())
1200
+ pk.advance(2200, 100)
1201
+ check(pkMood() === 'idle', 'and it ends on its own', pkMood())
1202
+
1203
+ // 窗口过期后重新计数:隔一会儿再戳一下,不该被记仇
1204
+ pokeOnce()
1205
+ pokeOnce()
1206
+ check(pkMood() === 'happy', 'two more pokes inside the window are still happy', pkMood())
1207
+ pk.advance(3500, 100)
1208
+ pokeOnce()
1209
+ check(pkMood() === 'happy',
1210
+ 'after the window expires the streak resets — one poke later is not a protest', pkMood())
1211
+ pokeOnce()
1212
+ pokeOnce()
1213
+ check(pkMood() === 'poked', 'and it takes three fresh pokes to protest again', pkMood())
1214
+
1215
+ /* api.sprites 是**当前皮肤的情绪名数组**(Object.keys(SPRITES[activeSkin])),不是 URL 表;
1216
+ "每套皮肤都带全部情绪"由解析产物 SPRITES 表那条检查覆盖,而且更强。 */
1217
+ check(Array.isArray(pkApi.sprites) && pkApi.sprites.includes('poked'),
1218
+ 'the active skin actually serves a poked sprite', JSON.stringify(pkApi.sprites))
1219
+ pk.win.__wisp.destroy()
1220
+ active = keepPk
1221
+
1222
+ /* ---------------- 3d-undecies. 久坐提醒 ---------------------------------- */
1223
+ head('3d-undecies. she notices when you have been sitting too long')
1224
+
1225
+ /* 这个行为的难点不在"说话",而在**判据**:什么时候算还在坐着、什么时候算离开、
1226
+ 以及多久才该再说一次。这些用虚拟时钟都能精确推到边界的另一侧。
1227
+ 注意:care 是个**瞬时状态**(4.2 秒),而 advance 是分步推进的 ——
1228
+ 用"推进中观察到过 care"来断言,而不是在某一个瞬间采样(那样会正好错过它)。 */
1229
+ const care = createHarness({ timer: true, composerText: '在打字' })
1230
+ const keepCare = active
1231
+ active = care
1232
+ care.evaluate(clientSrc)
1233
+ // sleepAfterMs 拉满:这一段测的是久坐,不该被"她打盹"这件事干扰(打盹另有一条断言)
1234
+ care.module().default.apply(care.ctx, {
1235
+ reactions: true, wander: false, celebrate: false, careAfterMs: 600000, sleepAfterMs: 3600000,
1236
+ })
1237
+ care.advance(1300, 100)
1238
+ const careApi = care.win.__wisp
1239
+ const careMood = () => care.find('wisp-root').dataset.mood
1240
+ /* 推进并**观察**每一种出现过的情绪 */
1241
+ const careObserve = (ms, chunk = 1000) => {
1242
+ const seen = new Set()
1243
+ for (let left = ms; left > 0; left -= chunk) {
1244
+ care.advance(Math.min(chunk, left), 200)
1245
+ seen.add(careMood())
1246
+ }
1247
+ return seen
1248
+ }
1249
+ const bubbleIsCare = () => {
1250
+ const text = care.all('wisp-say').at(-1)?.textContent
1251
+ return Array.isArray(linesInBundle()?.care) && linesInBundle().care.includes(text)
1252
+ }
1253
+
1254
+ check(careMood() === 'alert', 'while you are typing she is on alert, not reminding', careMood())
1255
+
1256
+ // 推到 9 分钟:还没到 10 分钟的线,不该提醒
1257
+ const careBefore = careObserve(9 * 60000)
1258
+ check(!careBefore.has('care') && !bubbleIsCare(),
1259
+ 'nine minutes in she has not reminded you yet', `观察到的情绪: ${[...careBefore].join(',')}`)
1260
+
1261
+ // 越过 10 分钟。推进到「看到 care 为止」就停下 —— 它只活 4.2 秒,
1262
+ // 一口气推完再采样必然错过(这正是这个测试前两次失败的原因)。
1263
+ const advanceUntilCare = (limitMs, chunk = 1000) => {
1264
+ for (let left = limitMs; left > 0; left -= chunk) {
1265
+ care.advance(Math.min(chunk, left), 200)
1266
+ if (careMood() === 'care') return true
1267
+ }
1268
+ return false
1269
+ }
1270
+ check(advanceUntilCare(200000), 'past the threshold she speaks up')
1271
+ check(bubbleIsCare(), 'and the line comes from the care pool',
1272
+ (care.all('wisp-say').at(-1)?.textContent ?? '(无)') + '(池子 ' + (linesInBundle()?.care?.length ?? '?') + ' 句)')
1273
+ check(careMood() === 'care' && careApi.currentMood === 'care',
1274
+ 'and both the DOM and the reported mood say care', `${careMood()} / ${careApi.currentMood}`)
1275
+
1276
+ // 稳态轮询不能把它抹掉:趁它还活着时再走一个轮询周期
1277
+ care.advance(1000, 100)
1278
+ check(careMood() === 'care', 'the reaction poll does not overwrite the reminder', careMood())
1279
+ const careSettled = careObserve(6000)
1280
+ void careSettled
1281
+ /* 只断言"结束时回到 alert":观察窗口的前几秒它本来就还在 care 里(CARE_MS = 4.2 秒),
1282
+ 断言"这段时间里没出现过 care"是错的 —— 那是把"还没结束"当成了"没结束过"。 */
1283
+ check(careMood() === 'alert', 'and it ends on its own, back to alert', careMood())
1284
+
1285
+ // 不该连着说:下一个完整间隔之前不再提
1286
+ const careNagging = careObserve(60000)
1287
+ check(!careNagging.has('care'), 'it does not nag again a minute later', `观察到的情绪: ${[...careNagging].join(',')}`)
1288
+ check(advanceUntilCare(600000), 'but it does come back after another full interval')
1289
+ care.advance(6000, 500)
1290
+
1291
+ // 他离开了一阵:计时该从头开始,而不是接着攒
1292
+ care.win.__wisp.configure({ careAfterMs: 300000 })
1293
+ care.state.composerText = null // 输入框消失 = 没有活动
1294
+ careObserve(9 * 60000)
1295
+ care.state.composerText = '回来了'
1296
+ care.advance(1300, 100)
1297
+ const careRestarted = careObserve(120000)
1298
+ check(!careRestarted.has('care'), 'after you walk away the clock starts from zero', `观察到的情绪: ${[...careRestarted].join(',')}`)
1299
+ check(advanceUntilCare(300000), 'only a fresh full stretch brings it back')
1300
+ care.advance(6000, 500)
1301
+
1302
+ // 关掉这个行为
1303
+ care.win.__wisp.configure({ careAfterMs: 0 })
1304
+ const careOff = careObserve(20 * 60000, 5000)
1305
+ check(!careOff.has('care'), 'careAfterMs: 0 turns the whole thing careOff', `观察到的情绪: ${[...careOff].join(',')}`)
1306
+ check(care.win.__wisp.config.careAfterMs === 0, 'and the config says so', String(care.win.__wisp.config.careAfterMs))
1307
+ care.win.__wisp.destroy()
1308
+ active = keepCare
1309
+
1310
+ /* 睡着了也得被叫醒:这条单独测,因为它需要 sleepAfterMs 很短 */
1311
+ const sleepCare = createHarness({ timer: true, composerText: '在打字' })
1312
+ const keepSleepCare = active
1313
+ active = sleepCare
1314
+ sleepCare.evaluate(clientSrc)
1315
+ sleepCare.module().default.apply(sleepCare.ctx, {
1316
+ reactions: true, wander: false, celebrate: false, careAfterMs: 60000, sleepAfterMs: 5000,
1317
+ })
1318
+ sleepCare.advance(8000, 500)
1319
+ check(sleepCare.find('wisp-root').dataset.mood === 'sleep', 'with a short sleep timer she dozes careOff first',
1320
+ sleepCare.find('wisp-root').dataset.mood)
1321
+ const careWoke = (() => {
1322
+ const seen = new Set()
1323
+ for (let left = 90000; left > 0; left -= 1000) {
1324
+ sleepCare.advance(Math.min(1000, left), 200)
1325
+ seen.add(sleepCare.find('wisp-root').dataset.mood)
1326
+ }
1327
+ return seen
1328
+ })()
1329
+ check(careWoke.has('care'), 'and the reminder wakes her up rather than mumbling from sleep',
1330
+ `观察到的情绪: ${[...careWoke].join(',')}`)
1331
+ sleepCare.win.__wisp.destroy()
1332
+ active = keepSleepCare
1333
+
1334
+ /* ---------------- 3d-duodecies. 今日轮次里程碑 --------------------------- */
1335
+ head('3d-duodecies. she counts the day and speaks up at milestones')
1336
+
1337
+ const ms = createHarness({ timer: true, composerText: '' })
1338
+ const keepMs = active
1339
+ active = ms
1340
+ ms.evaluate(clientSrc)
1341
+ ms.module().default.apply(ms.ctx, { reactions: true, wander: false, celebrate: true, celebrateAfterMs: 0 })
1342
+ ms.advance(1300, 100)
1343
+ const msApi = ms.win.__wisp
1344
+ const msMood = () => ms.find('wisp-root').dataset.mood
1345
+ /* 驱动一轮「跑完」:busy true → false 就是那个边沿 */
1346
+ const finishRun = () => {
1347
+ ms.state.busy = true
1348
+ ms.advance(1500, 100)
1349
+ ms.state.busy = false
1350
+ ms.advance(1500, 100)
1351
+ }
1352
+ const milestoneHit = () => msMood() === 'proud'
1353
+
1354
+ finishRun()
1355
+ check(msApi.doctor().runsToday.count === 1, 'a finished run is counted', JSON.stringify(msApi.doctor().runsToday))
1356
+ check(!milestoneHit(), 'and a single run is celebrated, not flattered', msMood())
1357
+
1358
+ // 数到第 10 轮
1359
+ let milestoneSeen = false
1360
+ for (let i = 0; i < 9; i++) {
1361
+ finishRun()
1362
+ if (milestoneHit()) milestoneSeen = true
1363
+ }
1364
+ const day10 = msApi.doctor().runsToday
1365
+ check(day10.count === 10, 'the count reaches ten', JSON.stringify(day10))
1366
+ check(milestoneSeen, 'and the tenth run gets the proud reaction')
1367
+ const proudLine = ms.all('wisp-say').at(-1)?.textContent
1368
+ check(linesInBundle()?.milestone?.some((l) => l.split('{n}').join('10') === proudLine),
1369
+ 'with the number actually substituted into the line', String(proudLine))
1370
+ check(day10.said.includes(10), 'and the milestone is remembered as said', JSON.stringify(day10.said))
1371
+
1372
+ // 第 11 轮不该再说一遍
1373
+ finishRun()
1374
+ check(!milestoneHit(), 'the eleventh run does not repeat it', msMood())
1375
+ check(msApi.doctor().runsToday.count === 11, 'but it is still counted', String(msApi.doctor().runsToday.count))
1376
+
1377
+ // 刷新页面:数字延续,已经说过的不再说
1378
+ msApi.destroy()
1379
+ ms.module().default.apply(ms.ctx, { reactions: true, wander: false, celebrate: true, celebrateAfterMs: 0 })
1380
+ ms.advance(1300, 100)
1381
+ const afterReload = ms.win.__wisp.doctor().runsToday
1382
+ check(afterReload.count === 11 && afterReload.said.includes(10),
1383
+ 'a reload keeps the count and remembers what was already said', JSON.stringify(afterReload))
1384
+ finishRun()
1385
+ check(!milestoneHit(), 'so a reload does not make her repeat herself', msMood())
1386
+ ms.win.__wisp.destroy()
1387
+ active = keepMs
1388
+
1389
+ // 换了一天:计数归零,昨天的里程碑不再算数
1390
+ const nextDay = createHarness({
1391
+ timer: true, composerText: '',
1392
+ storageSeed: { 'dsh-wisp:runs:v1': JSON.stringify({ day: '2000-1-1', count: 42, said: [10, 25] }) },
1393
+ })
1394
+ const keepNextDay = active
1395
+ active = nextDay
1396
+ nextDay.evaluate(clientSrc)
1397
+ nextDay.module().default.apply(nextDay.ctx, { reactions: true, wander: false, celebrate: true, celebrateAfterMs: 0 })
1398
+ nextDay.advance(1300, 100)
1399
+ check(nextDay.win.__wisp.doctor().runsToday.count === 0,
1400
+ 'a stored count from another day starts at zero', JSON.stringify(nextDay.win.__wisp.doctor().runsToday))
1401
+ nextDay.state.busy = true
1402
+ nextDay.advance(1500, 100)
1403
+ nextDay.state.busy = false
1404
+ nextDay.advance(1500, 100)
1405
+ check(nextDay.win.__wisp.doctor().runsToday.count === 1,
1406
+ 'and today starts counting from one', JSON.stringify(nextDay.win.__wisp.doctor().runsToday))
1407
+ nextDay.win.__wisp.destroy()
1408
+ active = keepNextDay
1409
+
1410
+ /* ---------------- 3d-terdecies. 她记着你 -------------------------------- */
1411
+ head('3d-terdecies. she remembers a few things about you')
1412
+
1413
+ /* 这个功能的难点全在**防噪**:同一条记忆一辈子只说一次、一天最多说一句。
1414
+ 没有这两条,台词会在每次刷新时重复,很快从"她记得"变成"她唠叨"。 */
1415
+ const memKey = 'dsh-wisp:memory:v1'
1416
+ /* 断言"她说过"而不是"最后一条是她说的":同一次挂载里可能还有别的台词(例如
1417
+ 首次的右键提示),只看最后一条会把别的当成本条的结果。 */
1418
+ const saidAnywhere = (harness, pool, fill) =>
1419
+ harness.all('wisp-say').some((el) => pool.some((l) => l.split('{n}').join(fill).split('{c}').join(fill).split('{s}').join(fill) === el.textContent))
1420
+ const sixDaysAgo = (n) => {
1421
+ const out = []
1422
+ for (let i = n; i >= 1; i--) {
1423
+ const d = new Date(Date.now() - i * 86400000)
1424
+ out.push(`${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`)
1425
+ }
1426
+ return out
1427
+ }
1428
+
1429
+ // 第一次见面:只记下今天,不该有什么"我们 1 天了"这种话
1430
+ const m1 = createHarness({ timer: true, composerText: '' })
1431
+ const keepM1 = active
1432
+ active = m1
1433
+ m1.evaluate(clientSrc)
1434
+ m1.module().default.apply(m1.ctx, { reactions: true, wander: false, celebrate: false })
1435
+ m1.advance(4500, 200)
1436
+ const firstDay = m1.win.__wisp.doctor().memory
1437
+ check(firstDay.days === 1 && firstDay.said.length === 0,
1438
+ 'the first day is recorded and nothing is claimed', JSON.stringify(firstDay))
1439
+ check(!saidAnywhere(m1, linesInBundle().days, '1'),
1440
+ 'and she does not announce day one', '没有任何「第 1 天」的台词')
1441
+ m1.win.__wisp.destroy()
1442
+ active = keepM1
1443
+
1444
+ // 一起第 7 天:存着前 6 天,今天挂载就是第 7 天
1445
+ const m7 = createHarness({
1446
+ timer: true, composerText: '',
1447
+ storageSeed: { [memKey]: JSON.stringify({ days: sixDaysAgo(6), corners: {}, skins: {}, said: [], remarkDay: '' }) },
1448
+ })
1449
+ const keepM7 = active
1450
+ active = m7
1451
+ m7.evaluate(clientSrc)
1452
+ m7.module().default.apply(m7.ctx, { reactions: true, wander: false, celebrate: false })
1453
+ m7.advance(9000, 200)
1454
+ const day7 = m7.win.__wisp.doctor().memory
1455
+ check(day7.days === 7, 'seven days together is counted', JSON.stringify({ days: day7.days }))
1456
+ check(saidAnywhere(m7, linesInBundle().days, '7'),
1457
+ 'and she says so with the number substituted',
1458
+ m7.all('wisp-say').map((el) => el.textContent).join(' | '))
1459
+ check(day7.said.includes('days:7'), 'and marks it claimed', JSON.stringify(day7.said))
1460
+
1461
+ // 同一天里再刷新:不该再说一遍
1462
+ m7.win.__wisp.destroy()
1463
+ m7.module().default.apply(m7.ctx, { reactions: true, wander: false, celebrate: false })
1464
+ const saidBefore = m7.all('wisp-say').length
1465
+ m7.advance(9000, 200)
1466
+ const newBubbles = m7.all('wisp-say').slice(saidBefore)
1467
+ check(!newBubbles.some((el) => linesInBundle().days.some((l) => l.split('{n}').join('7') === el.textContent)),
1468
+ 'a reload on the same day does not say it again',
1469
+ newBubbles.map((el) => el.textContent).join(' | ') || '刷新后没有新台词')
1470
+ m7.win.__wisp.destroy()
1471
+ active = keepM7
1472
+
1473
+ // 角落习惯:右下角用了 12 次、占绝大多数
1474
+ const cornerMem = createHarness({
1475
+ timer: true, composerText: '',
1476
+ storageSeed: {
1477
+ [memKey]: JSON.stringify({
1478
+ // 天数刻意避开里程碑:否则「一起 N 天」会先命中,角落那条就没机会开口(那是规则正确,不是 bug)
1479
+ days: sixDaysAgo(3), corners: { 右下角: 12, 左上角: 2 }, skins: {}, said: [], remarkDay: '',
1480
+ }),
1481
+ },
1482
+ })
1483
+ const keepCorner = active
1484
+ active = cornerMem
1485
+ cornerMem.evaluate(clientSrc)
1486
+ cornerMem.module().default.apply(cornerMem.ctx, { reactions: true, wander: false, celebrate: false })
1487
+ cornerMem.advance(9000, 200)
1488
+ check(saidAnywhere(cornerMem, linesInBundle().habitCorner, '右下角'),
1489
+ 'a corner habit gets mentioned once it is established',
1490
+ cornerMem.all('wisp-say').map((el) => el.textContent).join(' | '))
1491
+ check(cornerMem.find('wisp-root').dataset.mood === 'proud', 'and it is said with the proud look',
1492
+ cornerMem.find('wisp-root').dataset.mood)
1493
+ check(cornerMem.win.__wisp.doctor().memory.said.includes('corner:右下角'),
1494
+ 'and it will not be said twice', JSON.stringify(cornerMem.win.__wisp.doctor().memory.said))
1495
+ cornerMem.win.__wisp.destroy()
1496
+ active = keepCorner
1497
+
1498
+ // 证据不够就不说:只用了 3 次,或者刚过半数
1499
+ const weakCorner = createHarness({
1500
+ timer: true, composerText: '',
1501
+ storageSeed: {
1502
+ [memKey]: JSON.stringify({
1503
+ days: sixDaysAgo(3), corners: { 右下角: 3, 左上角: 3 }, skins: {}, said: [], remarkDay: '',
1504
+ }),
1505
+ },
1506
+ })
1507
+ const keepWeak = active
1508
+ active = weakCorner
1509
+ weakCorner.evaluate(clientSrc)
1510
+ weakCorner.module().default.apply(weakCorner.ctx, { reactions: true, wander: false, celebrate: false })
1511
+ weakCorner.advance(9000, 200)
1512
+ const weakSaid = weakCorner.all('wisp-say').map((el) => el.textContent)
1513
+ check(!weakSaid.some((t) => /你总把我放在|你习惯那儿了|都快成我的位置了/.test(String(t))),
1514
+ 'a couple of uses is not a habit', weakSaid.join(' | '))
1515
+ weakCorner.win.__wisp.destroy()
1516
+ active = keepWeak
1517
+
1518
+ // 皮肤习惯:常穿的那套不是当前这套
1519
+ const skinMem = createHarness({
1520
+ timer: true, composerText: '',
1521
+ storageSeed: {
1522
+ [memKey]: JSON.stringify({ days: sixDaysAgo(3), corners: {}, skins: { pajama: 7 }, said: [], remarkDay: '' }),
1523
+ },
1524
+ })
1525
+ const keepSkinMem = active
1526
+ active = skinMem
1527
+ skinMem.evaluate(clientSrc)
1528
+ skinMem.module().default.apply(skinMem.ctx, { reactions: true, wander: false, celebrate: false })
1529
+ skinMem.advance(9000, 200)
1530
+ const skinSaid = skinMem.all('wisp-say').map((el) => el.textContent).join(' | ')
1531
+ check(/睡衣/.test(skinSaid), 'a skin habit is mentioned by its label', skinSaid)
1532
+ skinMem.win.__wisp.destroy()
1533
+ active = keepSkinMem
1534
+
1535
+ // 一天最多一句:同时满足"第 7 天"和"角落习惯",也只能说一句
1536
+ const bothMem = createHarness({
1537
+ timer: true, composerText: '',
1538
+ storageSeed: {
1539
+ [memKey]: JSON.stringify({
1540
+ days: sixDaysAgo(6), corners: { 右下角: 12 }, skins: {}, said: [], remarkDay: '',
1541
+ }),
1542
+ },
1543
+ })
1544
+ const keepBoth = active
1545
+ active = bothMem
1546
+ bothMem.evaluate(clientSrc)
1547
+ bothMem.module().default.apply(bothMem.ctx, { reactions: true, wander: false, celebrate: false })
1548
+ bothMem.advance(9000, 200)
1549
+ const bothSaid = bothMem.win.__wisp.doctor().memory.said
1550
+ check(bothSaid.length === 1, 'only one memory line per day, even when several apply', JSON.stringify(bothSaid))
1551
+ bothMem.win.__wisp.destroy()
1552
+ active = keepBoth
1553
+
1554
+ // 记录不能无限长
1555
+ const longMem = createHarness({
1556
+ timer: true, composerText: '',
1557
+ storageSeed: {
1558
+ [memKey]: JSON.stringify({
1559
+ days: Array.from({ length: 300 }, (_, i) => `2000-1-${(i % 28) + 1}`), corners: {}, skins: {}, said: [], remarkDay: '',
1560
+ }),
1561
+ },
1562
+ })
1563
+ const keepLong = active
1564
+ active = longMem
1565
+ longMem.evaluate(clientSrc)
1566
+ longMem.module().default.apply(longMem.ctx, { reactions: true, wander: false, celebrate: false })
1567
+ longMem.advance(200, 100)
1568
+ check(longMem.win.__wisp.doctor().memory.days <= 90,
1569
+ 'the day list stays bounded', String(longMem.win.__wisp.doctor().memory.days))
1570
+ longMem.win.__wisp.destroy()
1571
+ active = keepLong
1572
+
1573
+ // memory: false 关掉
1574
+ const noMem = createHarness({
1575
+ timer: true, composerText: '',
1576
+ storageSeed: {
1577
+ [memKey]: JSON.stringify({ days: sixDaysAgo(6), corners: {}, skins: {}, said: [], remarkDay: '' }),
1578
+ },
1579
+ })
1580
+ const keepNoMem = active
1581
+ active = noMem
1582
+ noMem.evaluate(clientSrc)
1583
+ noMem.module().default.apply(noMem.ctx, { reactions: true, wander: false, celebrate: false, memory: false })
1584
+ noMem.advance(9000, 200)
1585
+ check(noMem.win.__wisp.doctor().memory.said.length === 0, 'memory: false turns the remarks off',
1586
+ JSON.stringify(noMem.win.__wisp.doctor().memory.said))
1587
+ noMem.win.__wisp.destroy()
1588
+ active = keepNoMem
1589
+
1590
+ /* ---------------- 3d-quattuordecies. 交互三件事 --------------------------- */
1591
+ head('3d-quattuordecies. resize by wheel, move by keyboard, reach her when hidden')
1592
+
1593
+ /* 这三条都是「只能靠鼠标」的缺口:调大小只能开菜单、定位只能拖、躲起来之后够不着任何设置。 */
1594
+ const ix = createHarness({ timer: true, composerText: '' })
1595
+ const keepIx = active
1596
+ active = ix
1597
+ ix.evaluate(clientSrc)
1598
+ ix.module().default.apply(ix.ctx, { reactions: true, wander: false, celebrate: false })
1599
+ ix.advance(1300, 100)
1600
+ const ixApi = ix.win.__wisp
1601
+ const ixBody = ix.find('wisp-body')
1602
+ const wheel = (deltaY, mods) => {
1603
+ const ev = {
1604
+ deltaY, ctrlKey: false, metaKey: false, defaultPrevented: false,
1605
+ preventDefault() { this.defaultPrevented = true }, stopPropagation() {}, ...(mods || {}),
1606
+ }
1607
+ ixBody.dispatch('wheel', ev)
1608
+ return ev
1609
+ }
1610
+ const arrow = (key, shift) => {
1611
+ const ev = {
1612
+ key, shiftKey: shift === true, defaultPrevented: false,
1613
+ preventDefault() { this.defaultPrevented = true }, stopPropagation() {},
1614
+ }
1615
+ ixBody.dispatch('keydown', ev)
1616
+ return ev
1617
+ }
1618
+
1619
+ /* ---- Ctrl/⌘ + 滚轮:调大小 ---- */
1620
+ const size0 = ixApi.config.size
1621
+ wheel(-100, { ctrlKey: true })
1622
+ const sizeUp = ixApi.config.size
1623
+ check(Math.abs(sizeUp - (size0 + 0.25)) < 1e-9, 'ctrl+wheel up makes her a step bigger',
1624
+ String(size0) + ' -> ' + String(sizeUp))
1625
+ wheel(100, { ctrlKey: true })
1626
+ check(Math.abs(ixApi.config.size - size0) < 1e-9, 'and ctrl+wheel down takes it back',
1627
+ String(ixApi.config.size))
1628
+ wheel(-100, { metaKey: true })
1629
+ check(Math.abs(ixApi.config.size - (size0 + 0.25)) < 1e-9, '⌘+wheel does the same on macOS',
1630
+ String(ixApi.config.size))
1631
+ wheel(100, { metaKey: true })
1632
+
1633
+ /* 上下限由 CONFIG_SPEC 统一夹紧 —— 滚到天边也不该越界 */
1634
+ for (let i = 0; i < 60; i++) wheel(100, { ctrlKey: true })
1635
+ check(ixApi.config.size === 0.4, 'wheeling down stops at the lower bound', String(ixApi.config.size))
1636
+ for (let i = 0; i < 80; i++) wheel(-100, { ctrlKey: true })
1637
+ check(ixApi.config.size === 8, 'and up stops at the upper bound', String(ixApi.config.size))
1638
+ ixApi.configure({ size: size0 })
1639
+
1640
+ /* ---- 普通滚轮:一个字都不许动 ---- */
1641
+ const plain = wheel(-100)
1642
+ check(ixApi.config.size === size0, 'a plain wheel never resizes her', String(ixApi.config.size))
1643
+ check(plain.defaultPrevented === false,
1644
+ 'and a plain wheel is left alone so the page still scrolls over her', 'defaultPrevented=false')
1645
+
1646
+ /* ---- 方向键:微调位置 ---- */
1647
+ const pos0 = ixApi.position
1648
+ const right = arrow('ArrowRight')
1649
+ check(ixApi.position.x === pos0.x + 16 && ixApi.position.y === pos0.y,
1650
+ 'the right arrow nudges her 16px',
1651
+ pos0.x + ',' + pos0.y + ' -> ' + ixApi.position.x + ',' + ixApi.position.y)
1652
+ check(right.defaultPrevented, 'and the arrow is swallowed so the page does not scroll with it')
1653
+ check(JSON.parse(ix.win.localStorage.getItem('dsh-wisp:position:v1') || 'null')?.x === ixApi.position.x,
1654
+ 'a nudge is remembered like a drag', ix.win.localStorage.getItem('dsh-wisp:position:v1'))
1655
+ arrow('ArrowDown')
1656
+ check(ixApi.position.y === pos0.y + 16, 'the down arrow moves her down', String(ixApi.position.y))
1657
+ arrow('ArrowRight', true)
1658
+ check(ixApi.position.x === pos0.x + 20, 'shift+arrow is the fine step (4px)', String(ixApi.position.x))
1659
+
1660
+ /* 边界:一直按左键不该把她推出去 */
1661
+ ixApi.move(0, 100)
1662
+ for (let i = 0; i < 5; i++) arrow('ArrowLeft')
1663
+ check(ixApi.position.x === 0 && ixApi.position.y === 100,
1664
+ 'nudging into the edge clamps instead of pushing her out',
1665
+ ixApi.position.x + ',' + ixApi.position.y)
1666
+ for (let i = 0; i < 10; i++) arrow('ArrowUp')
1667
+ check(ixApi.position.y === 0, 'same at the top', String(ixApi.position.y))
1668
+
1669
+ /* ---- 躲起来之后:迷你按钮右键也能开菜单 ---- */
1670
+ ixApi.hide()
1671
+ ix.advance(200, 100)
1672
+ const ixTab = ix.find('wisp-tab')
1673
+ check(ixTab !== null, 'the mini tab is there while she is hidden')
1674
+ const tabEv = {
1675
+ clientX: 900, clientY: 500, defaultPrevented: false,
1676
+ preventDefault() { this.defaultPrevented = true }, stopPropagation() {},
1677
+ }
1678
+ ixTab.dispatch('contextmenu', tabEv)
1679
+ check(ix.find('wisp-menu') !== null, 'right-clicking the mini tab opens the menu')
1680
+ check(tabEv.defaultPrevented, 'and the browser context menu is suppressed')
1681
+ /* all() 会把已移除的旧菜单项一起返回(关掉的菜单、重开的菜单)—— 只认还在文档里的。 */
1682
+ const liveItems = () => ix.all('wisp-menu-item').filter((el) => el.removed !== true)
1683
+ const toggleHidden = liveItems().map((el) => el.textContent)
1684
+ .find((t) => t.includes('回来吧') || t.includes('先躲起来'))
1685
+ check(toggleHidden !== undefined && toggleHidden.includes('回来吧'),
1686
+ 'while hidden the hide entry turns into a way back', String(toggleHidden))
1687
+ const backEl = liveItems().find((el) => el.textContent.includes('回来吧'))
1688
+ backEl.dispatch('click', { preventDefault() {}, stopPropagation() {} })
1689
+ check(ixApi.hidden === false, 'clicking it brings her back', String(ixApi.hidden))
1690
+ check(ixApi.element.dataset.hidden === 'false', 'and the root is marked visible again')
1691
+
1692
+ /* 可见时它是单向的「先躲起来」。
1693
+ 这里用键盘 Enter 开菜单:rightClickOnHer() 定义在本节之后,直接调用会踩 TDZ
1694
+ (Enter 走的是同一条 openMenu,测的是同一件事)。 */
1695
+ arrow('Enter')
1696
+ const toggleVisible = liveItems().map((el) => el.textContent)
1697
+ .find((t) => t.includes('回来吧') || t.includes('先躲起来'))
1698
+ check(toggleVisible !== undefined && toggleVisible.includes('先躲起来'),
1699
+ 'while visible the same entry says hide', String(toggleVisible))
1700
+
1701
+ ix.win.__wisp.destroy()
1702
+ active = keepIx
1703
+
1704
+ /* ---------------- 3d-quindecies. 版本与更新 ------------------------------- */
1705
+ head('3d-quindecies. she knows her version — and is honest about what she cannot do')
1706
+
1707
+ /* 平台事实(源码级查过):宿主半包跑在 vm 沙箱里,harness 只给
1708
+ { defineTool, registerTool, handle },沙箱里 nodeApiTraps() 挡住 Node API ——
1709
+ 插件**既不能联网、也不能写文件**,DSH 的安装器也没有可注入的服务。
1710
+ 所以"自己检查并安装更新"做不到;这一节测的是能真正做到的那几件事。 */
1711
+ const uv = createHarness({ timer: true, composerText: '', clipboard: true })
1712
+ const keepUv = active
1713
+ active = uv
1714
+ uv.evaluate(clientSrc)
1715
+ uv.module().default.apply(uv.ctx, { reactions: true, wander: false, celebrate: false })
1716
+ uv.advance(1300, 100)
1717
+ const uvApi = uv.win.__wisp
1718
+ const pkgVersion = JSON.parse(readFileSync(join(here, 'package.json'), 'utf8')).version
1719
+
1720
+ check(uvApi.version === pkgVersion, 'the running bundle reports the packaged version',
1721
+ `${uvApi.version} vs ${pkgVersion}`)
1722
+ check(uvApi.doctor().version === pkgVersion, 'and doctor() agrees', String(uvApi.doctor().version))
1723
+
1724
+ /* 版本说明表必须写当前版本 —— 忘了更新就红,不会静默过期 */
1725
+ const update = uvApi.doctor().update
1726
+ check(typeof update?.whatsNew === 'string' && update.whatsNew.length > 0,
1727
+ 'the what-is-new note covers the CURRENT version', String(update?.whatsNew))
1728
+
1729
+ /* 能力边界要如实报告,不能让人以为她能自己更新 */
1730
+ check(update.canSelfUpdate === false, 'she does not pretend she can update herself',
1731
+ String(update.canSelfUpdate))
1732
+ check(typeof update.hint === 'string' && update.hint.includes('dsh-wisp'),
1733
+ 'but she does say which package to install', String(update.hint))
1734
+ check(/沙箱|sandbox/.test(String(update.why)), 'and why — in plain words', String(update.why))
1735
+
1736
+ /* 菜单里那一项:说出当前版本,并把包名复制好 */
1737
+ const beforeWrites = uv.clipboardWrites.length
1738
+ const updateResult = uvApi.showUpdate()
1739
+ check(updateResult.version === pkgVersion && updateResult.copied === true,
1740
+ 'the update action reports the version and copies the package name', JSON.stringify(updateResult))
1741
+ const lastBubble = uv.all('wisp-say').at(-1)?.textContent
1742
+ check(String(lastBubble).includes(pkgVersion), 'and she says the version out loud', String(lastBubble))
1743
+ check(uv.clipboardWrites.length === beforeWrites + 1 && uv.clipboardWrites[uv.clipboardWrites.length - 1] === 'dsh-wisp',
1744
+ 'the clipboard actually received the package name',
1745
+ JSON.stringify(uv.clipboardWrites.slice(-1)))
1746
+ uv.win.__wisp.destroy()
1747
+ active = keepUv
1748
+
1749
+ /* 没有剪贴板(没有权限 / 不是安全上下文):**静默降级**,不许抛错 */
1750
+ const noClip = createHarness({ timer: true, composerText: '' })
1751
+ const keepNoClip = active
1752
+ active = noClip
1753
+ noClip.evaluate(clientSrc)
1754
+ noClip.module().default.apply(noClip.ctx, { reactions: true, wander: false, celebrate: false })
1755
+ noClip.advance(1300, 100)
1756
+ let threw = false
1757
+ let res = null
1758
+ try { res = noClip.win.__wisp.showUpdate() } catch (e) { threw = true }
1759
+ check(threw === false && res !== null && res.copied === false,
1760
+ 'without a clipboard she still speaks and reports the copy as not done', JSON.stringify(res))
1761
+ noClip.win.__wisp.destroy()
1762
+ active = keepNoClip
1763
+
1764
+ /* ---- 真的去查:走 host.call → 宿主半包的 checkUpdate → ctx.web.fetch ---- */
1765
+ const mkChecker = (reply) => createHarness({
1766
+ timer: true, composerText: '',
1767
+ hostCall: async (method, args) => {
1768
+ if (typeof reply === 'function') return reply(method, args)
1769
+ throw reply
1770
+ },
1771
+ })
1772
+ const checkCases = [
1773
+ ['available', async () => ({ ok: true, latest: '99.0.0', current: pkgVersion })],
1774
+ ['current', async () => ({ ok: true, latest: pkgVersion, current: pkgVersion })],
1775
+ ['failed', async () => ({ ok: false, reason: 'no-web-service' })],
1776
+ ]
1777
+ for (const [expected, reply] of checkCases) {
1778
+ const hc = mkChecker(reply)
1779
+ const keepHc = active
1780
+ active = hc
1781
+ hc.evaluate(clientSrc)
1782
+ hc.module().default.apply(hc.ctx, { reactions: true, wander: false, celebrate: false })
1783
+ hc.advance(1300, 100)
1784
+ const res = await hc.win.__wisp.checkForUpdate()
1785
+ check(res.state === expected, `a ${expected} answer from the host lands in that state`, JSON.stringify(res))
1786
+ check(hc.win.__wisp.doctor().update.lastCheck.state === expected,
1787
+ 'and doctor() reports the last check', JSON.stringify(hc.win.__wisp.doctor().update.lastCheck))
1788
+ if (expected === 'available') {
1789
+ hc.win.__wisp.showUpdate()
1790
+ /* hostCall 是 async 的,链上有好几跳微任务;两个 await 不够,
1791
+ 换成 setImmediate 一次冲干净(这里的 setTimeout 是测试进程的,不是被陷阱的那个)。 */
1792
+ await new Promise((resolve) => setImmediate(resolve))
1793
+ const line = hc.all('wisp-say').at(-1)?.textContent
1794
+ check(String(line).includes('99.0.0'), 'and she says the new version out loud', String(line))
1795
+ }
1796
+ hc.win.__wisp.destroy()
1797
+ active = keepHc
1798
+ }
1799
+
1800
+ /* 宿主调用抛错:不能变成未处理的 rejection */
1801
+ const thrower = mkChecker('boom')
1802
+ const keepThrower = active
1803
+ active = thrower
1804
+ thrower.evaluate(clientSrc)
1805
+ thrower.module().default.apply(thrower.ctx, { reactions: true, wander: false, celebrate: false })
1806
+ thrower.advance(1300, 100)
1807
+ const threwRes = await thrower.win.__wisp.checkForUpdate()
1808
+ check(threwRes.state === 'failed' && threwRes.reason === 'call-failed' && threwRes.detail === 'boom',
1809
+ 'a throwing host call is caught and reported', JSON.stringify(threwRes))
1810
+ thrower.win.__wisp.destroy()
1811
+ active = keepThrower
1812
+
1813
+ /* 没有 host 座位(默认替身):如实说"查不了",不抛 */
1814
+ const noHost = createHarness({ timer: true, composerText: '' })
1815
+ const keepNoHost = active
1816
+ active = noHost
1817
+ noHost.evaluate(clientSrc)
1818
+ noHost.module().default.apply(noHost.ctx, { reactions: true, wander: false, celebrate: false })
1819
+ noHost.advance(1300, 100)
1820
+ const unsupported = await noHost.win.__wisp.checkForUpdate()
1821
+ check(unsupported.state === 'unsupported',
1822
+ 'with no host seat she reports that checking is not available', JSON.stringify(unsupported))
1823
+ const stillWorks = noHost.win.__wisp.showUpdate()
1824
+ check(stillWorks.version === pkgVersion && stillWorks.checking === true,
1825
+ 'and the update entry still works without it', JSON.stringify(stillWorks))
1826
+ noHost.win.__wisp.destroy()
1827
+ active = keepNoHost
1828
+
1829
+ /* 更新过了就说一句;第一次安装不吭声 */
1830
+ const upgraded = createHarness({
1831
+ timer: true, composerText: '',
1832
+ storageSeed: { 'dsh-wisp:seen-version:v1': JSON.stringify('0.0.1-old') },
1833
+ })
1834
+ const keepUp = active
1835
+ active = upgraded
1836
+ upgraded.evaluate(clientSrc)
1837
+ upgraded.module().default.apply(upgraded.ctx, { reactions: true, wander: false, celebrate: false })
1838
+ upgraded.advance(3000, 200)
1839
+ const upgradeBubbles = upgraded.all('wisp-say').map((el) => el.textContent)
1840
+ check(upgradeBubbles.some((t) => String(t).includes(pkgVersion)),
1841
+ 'a version change since last time makes her say she was updated',
1842
+ upgradeBubbles.join(' | '))
1843
+ check(JSON.parse(upgraded.win.localStorage.getItem('dsh-wisp:seen-version:v1')) === pkgVersion,
1844
+ 'and the new version is remembered',
1845
+ upgraded.win.localStorage.getItem('dsh-wisp:seen-version:v1'))
1846
+ upgraded.win.__wisp.destroy()
1847
+ active = keepUp
1848
+
1849
+ const sameVer = createHarness({
1850
+ timer: true, composerText: '',
1851
+ storageSeed: { 'dsh-wisp:seen-version:v1': JSON.stringify(pkgVersion) },
1852
+ })
1853
+ const keepSame = active
1854
+ active = sameVer
1855
+ sameVer.evaluate(clientSrc)
1856
+ sameVer.module().default.apply(sameVer.ctx, { reactions: true, wander: false, celebrate: false })
1857
+ sameVer.advance(3000, 200)
1858
+ check(!sameVer.all('wisp-say').some((el) => String(el.textContent).includes('更新到')),
1859
+ 'but the same version says nothing about updating',
1860
+ sameVer.all('wisp-say').map((el) => el.textContent).join(' | '))
1861
+ sameVer.win.__wisp.destroy()
1862
+ active = keepSame
1863
+
1864
+ /* ------------------------------------------------------ 3e. theme ------ */
1865
+ head('3e. theme follows the shell')
1866
+
1867
+ check(h.themeListenerCount() === 1, 'subscribed to theme/change')
1868
+ h.emitTheme({ active: { colorScheme: 'dark' } })
1869
+ check(root.dataset.wispTheme === 'dark', 'dark theme is reflected on the element', String(root.dataset.wispTheme))
1870
+ h.emitTheme({ active: { colorScheme: 'light' } })
1871
+ check(root.dataset.wispTheme === 'light', 'light theme is reflected on the element', String(root.dataset.wispTheme))
1872
+
1873
+ /* ------------------------------------------------- 3f. drag + persistence */
1874
+ head('3f. drag, and the position it remembers')
1875
+
1876
+ const bodyWisp = h.find('wisp-body')
1877
+ const SCALE = 4
1878
+ const BOX_W = 140 * SCALE
1879
+ const BOX_H = 210 * SCALE
1880
+ const centre = () => ({ x: api.position.x + BOX_W / 2, y: api.position.y + BOX_H / 2 })
1881
+ const press = (x, y) => {
1882
+ const ev = { button: 0, clientX: x, clientY: y, defaultPrevented: false, preventDefault() { this.defaultPrevented = true }, stopPropagation() {} }
1883
+ bodyWisp.dispatch('pointerdown', ev)
1884
+ return ev
1885
+ }
1886
+
1887
+ const before = api.position
1888
+ const start = centre()
1889
+ check(press(start.x, start.y).defaultPrevented, 'a press on her body takes the pointer', `${start.x},${start.y}`)
1890
+ h.win.dispatch('pointermove', { clientX: start.x - 300, clientY: start.y - 60 })
1891
+ h.win.dispatch('pointerup', {})
1892
+ const after = api.position
1893
+ check(after.x !== before.x || after.y !== before.y, 'dragging moves her', `${before.x},${before.y} -> ${after.x},${after.y}`)
1894
+ check(after.x === before.x - 300 && after.y === before.y - 60, 'she follows the pointer exactly', `${after.x},${after.y}`)
1895
+
1896
+ /* The transform must place the box's TOP-LEFT at the reported position. An
1897
+ earlier build translated by `x + boxW/2` "for the centre origin", which a
1898
+ CSS translation ignores — the box rendered half a box-width to the right,
1899
+ off the viewport edge, while the hit mask kept sampling from `coords.x`.
1900
+ That made her impossible to grab. Assert the rendered box, not the state. */
1901
+ const boxAt = (t) => {
1902
+ const m = /translate3d\(([-\d.]+)px,([-\d.]+)px,0\) scaleX\((-?\d)\)/.exec(String(t))
1903
+ return m === null ? null : { x: Number(m[1]), y: Number(m[2]), facing: Number(m[3]) }
1904
+ }
1905
+ const placed = boxAt(root.style.transform)
1906
+ check(placed !== null && Math.abs(placed.x - after.x) <= 1 && Math.abs(placed.y - after.y) <= 1,
1907
+ 'the rendered box starts exactly where the position says',
1908
+ `transform at ${placed?.x},${placed?.y} vs position ${after.x},${after.y}`)
1909
+
1910
+ const storedRaw = h.win.localStorage.getItem('dsh-wisp:position:v1')
1911
+ check(typeof storedRaw === 'string' && JSON.parse(storedRaw).x === after.x, 'drag end persists the position', storedRaw)
1912
+
1913
+ // A plain click must not be mistaken for a drag.
1914
+ const held = api.position
1915
+ const c1 = centre()
1916
+ press(c1.x, c1.y)
1917
+ h.win.dispatch('pointerup', {})
1918
+ check(api.position.x === held.x && api.position.y === held.y, 'a click does not move her')
1919
+
1920
+ /* ---- the transparent margin must not swallow the app's clicks ------ */
1921
+ const edge = press(api.position.x + 4, api.position.y + BOX_H / 2)
1922
+ check(edge.defaultPrevented === false,
1923
+ 'a press on her transparent margin passes through to the app', `defaultPrevented=${edge.defaultPrevented}`)
1924
+ const stillHeld = api.position
1925
+ h.win.dispatch('pointermove', { clientX: 0, clientY: 0 })
1926
+ h.win.dispatch('pointerup', {})
1927
+ check(api.position.x === stillHeld.x && api.position.y === stillHeld.y,
1928
+ 'a margin press does not start a drag', `${api.position.x},${api.position.y}`)
1929
+
1930
+ /* ---- she is grabbable wherever she is drawn ------------------------ */
1931
+ // Deliberate product decision: she does not yield to a control underneath,
1932
+ // because the user can simply move her. Pin it so a future "safety" tweak
1933
+ // cannot quietly make parts of her undraggable again.
1934
+ const c3 = centre()
1935
+ const heldBy = api.position
1936
+ const regrab = press(c3.x, c3.y)
1937
+ check(regrab.defaultPrevented, 'a press on her body always grabs her, control or not')
1938
+ h.win.dispatch('pointermove', { clientX: c3.x - 40, clientY: c3.y })
1939
+ h.win.dispatch('pointerup', {})
1940
+ check(api.position.x === heldBy.x - 40, 'and it drags her', `${api.position.x} vs ${heldBy.x - 40}`)
1941
+
1942
+ /* ---- geometry must follow the render scale, and the mirror must pivot -- */
1943
+ const c2 = centre()
1944
+ press(c2.x, c2.y)
1945
+ h.win.dispatch('pointermove', { clientX: 9999, clientY: 9999 })
1946
+ h.win.dispatch('pointerup', {})
1947
+ const corner = api.position
1948
+ check(corner.x === h.win.innerWidth - BOX_W && corner.y === h.win.innerHeight - BOX_H,
1949
+ 'the clamp uses the SCALED box, so she cannot hang off the edge',
1950
+ `x=${corner.x} (max ${h.win.innerWidth - BOX_W}), y=${corner.y} (max ${h.win.innerHeight - BOX_H})`)
1951
+
1952
+ api.move(0, 300)
1953
+ const tr = boxAt(root.style.transform)
1954
+ check(tr !== null, 'the transform is a plain translate plus a mirror', String(root.style.transform))
1955
+ if (tr !== null) {
1956
+ check(tr.facing === -1, 'she turns to face left on the left half', `scaleX(${tr.facing})`)
1957
+ check(tr.x === 0 && tr.y === 300, 'turning around does not shift her box',
1958
+ `translate ${tr.x},${tr.y} for position 0,300`)
1959
+ }
1960
+ api.move(after.x, after.y) // put her back for the teardown/persistence checks
1961
+
1962
+ /* ------------------------------------------------------ 3g. teardown --- */
1963
+ head('3g. teardown')
1964
+
1965
+ const liveBefore = h.pendingTimers().length
1966
+ check(typeof h.teardown === 'function', 'ctx.effect teardown registered')
1967
+ check(h.effectLabels.includes('dsh-wisp: teardown'),
1968
+ 'the mount is registered on the caller fiber for teardown', h.effectLabels.join(' / '))
1969
+ const disposals = h.teardown()
1970
+ check(disposals.length >= 1 && disposals.every((d) => typeof d === 'function'),
1971
+ 'every ctx.effect callback returns a real disposer', `${disposals.length} disposer(s)`)
1972
+ check(liveBefore > 0, 'timers were live before teardown', `${liveBefore}`)
1973
+ for (const dispose of disposals) dispose()
1974
+
1975
+ check(h.pendingTimers().length === 0, 'every timer is disposed', `left ${h.pendingTimers().length}`)
1976
+ check(h.win.listenerCount('pointermove') === 0, 'window listeners are removed')
1977
+ check(h.win.listenerCount('keydown') === 0, 'keydown listener is removed')
1978
+ check(layer.parentNode === null, 'the layer leaves the DOM')
1979
+ check(h.srcs.length > 0 && h.revoked.length > 0, 'sprite blob URLs are revoked', `${h.revoked.length} revoked`)
1980
+ check(h.win.__wisp === undefined, 'the public handle is withdrawn')
1981
+ check(h.themeListenerCount() === 0, 'the theme subscription is released', `${h.themeListenerCount()} left`)
1982
+
1983
+ // destroy() twice must be a no-op, not a second teardown.
1984
+ let doubleThrow = null
1985
+ try { api.destroy() } catch (error) { doubleThrow = error }
1986
+ check(doubleThrow === null, 'destroy() is idempotent')
1987
+
1988
+ /* ------------------------------- 3h. re-apply: singleton + fresh blobs -- */
1989
+ head('3h. re-apply keeps exactly one companion (HMR / duplicate row)')
1990
+
1991
+ // Same module instance: the blob cache is module-scoped, so a stale cache
1992
+ // here is exactly how a re-mount served a revoked URL and drew a broken
1993
+ // image. The new instance must get URLs that are not in the revoked set.
1994
+ plugin.apply(h.ctx, {})
1995
+ const layers = h.all('wisp-layer')
1996
+ check(layers.length === 2 && layers.filter((l) => l.parentNode === h.bodyEl).length === 1,
1997
+ 'exactly one companion is mounted', `${layers.filter((l) => l.parentNode === h.bodyEl).length} attached`)
1998
+ const second = h.win.__wisp
1999
+ check(second !== api && typeof second?.destroy === 'function', 'the handle points at the new instance')
2000
+ const freshSrcs = h.srcs.slice(-1)
2001
+ check(freshSrcs[0]?.startsWith('blob:') && !h.revoked.includes(freshSrcs[0]),
2002
+ 'the re-mount is not handed a revoked blob URL', String(freshSrcs[0]))
2003
+ const restored = second.position
2004
+ check(restored.x === after.x && restored.y === after.y, 'the remembered position is restored on the next mount',
2005
+ `${restored.x},${restored.y}`)
2006
+
2007
+ /* ------------------------------------------------- 3i. reconfigure ------ */
2008
+ head('3i. configure() merges instead of resetting')
2009
+
2010
+ plugin.apply(h.ctx, { reactions: false, persist: false })
2011
+ const third = h.win.__wisp
2012
+ // Read geometry off the LIVE handle: `h.find` returns the first element with
2013
+ // that class, which belongs to an instance that was already replaced.
2014
+ const liveRoot = third.element
2015
+ const timersBefore = h.pendingTimers().length
2016
+ third.configure({ size: 2 })
2017
+ check(liveRoot.style.width === '280px' && liveRoot.style.height === '420px', 'configure() resizes her box',
2018
+ `${liveRoot.style.width} x ${liveRoot.style.height}`)
2019
+ check(h.pendingTimers().length <= timersBefore + 1, 'configure() did not resurrect the reaction poll',
2020
+ `${timersBefore} -> ${h.pendingTimers().length}`)
2021
+ third.configure({ size: 'huge', sleepAfterMs: -1 })
2022
+ check(!String(liveRoot.style.width).includes('NaN') && !String(liveRoot.style.transform).includes('NaN'),
2023
+ 'a nonsense config cannot produce NaN geometry', `${liveRoot.style.width} / ${liveRoot.style.transform}`)
2024
+ third.destroy()
2025
+
2026
+ /* ------------------------------------------- 3i-bis. 自己踱步 + 右键归位 */
2027
+ head('3i-bis. idle wander and the right-click trip home')
2028
+
2029
+ plugin.apply(h.ctx, { reactions: false, wander: true, wanderMs: 5000, wanderRange: 200 })
2030
+ const fifth = h.win.__wisp
2031
+ const body5 = fifth.element.querySelector('.wisp-body')
2032
+ const BOX_W5 = 140 * 4
2033
+ const BOX_H5 = 210 * 4
2034
+ const centreOf = () => ({ x: fifth.position.x + BOX_W5 / 2, y: fifth.position.y + BOX_H5 / 2 })
2035
+
2036
+ fifth.move(200, 200) // away from the corners, so any bearing can move
2037
+ h.advance(6000, 200)
2038
+ const strolled = fifth.position
2039
+ check(strolled.x !== 200 || strolled.y !== 200, 'she strolls on her own while idle',
2040
+ `200,200 -> ${strolled.x},${strolled.y}`)
2041
+ check(Math.abs(strolled.x - 200) <= 200 && Math.abs(strolled.y - 200) <= 200,
2042
+ 'a stroll stays inside the configured range', `${strolled.x},${strolled.y}`)
2043
+ check(fifth.element.dataset.gliding === 'true', 'the stroll is a glide, not a teleport')
2044
+ h.advance(2000, 200)
2045
+ check(fifth.element.dataset.gliding === undefined, 'the glide class is cleared afterwards')
2046
+ check(JSON.parse(h.win.localStorage.getItem('dsh-wisp:position:v1') || '{}').x !== strolled.x,
2047
+ 'a stroll does not overwrite the position the user chose', h.win.localStorage.getItem('dsh-wisp:position:v1'))
2048
+
2049
+ // 右键:弹出菜单(归位只是其中一项)
2050
+ const ctxEvent = (x, y) => ({
2051
+ clientX: x, clientY: y, defaultPrevented: false,
2052
+ preventDefault() { this.defaultPrevented = true }, stopPropagation() {},
2053
+ })
2054
+ const itemEvent = () => ({ defaultPrevented: false, preventDefault() { this.defaultPrevented = true }, stopPropagation() {} })
2055
+ // 菜单挂在 layer 上(与气泡同一层,屏幕坐标系),不是挂在会被镜像/缩放的 root 上
2056
+ const menuHost = () => fifth.element.parentNode
2057
+ const menuOf = () => menuHost().querySelector('.wisp-menu')
2058
+ const itemsOf = () => menuHost().querySelectorAll('.wisp-menu-item')
2059
+ // 尺寸会变(菜单里就能放大缩小),所以每次都要按当前实时状态算,不能用写死常量
2060
+ const boxNow = () => ({
2061
+ w: parseInt(fifth.element.style.width, 10),
2062
+ h: parseInt(fifth.element.style.height, 10),
2063
+ })
2064
+ const rightClickOnHer = () => {
2065
+ const b = boxNow()
2066
+ const p = fifth.position
2067
+ const ev = ctxEvent(p.x + b.w / 2, p.y + b.h / 2)
2068
+ // 现在归位用的兜底尺寸也要跟着当前尺寸走
2069
+ return { ev, homeX: h.win.innerWidth - 26 - b.w, homeY: h.win.innerHeight - 46 - b.h }
2070
+ }
2071
+
2072
+ const firstClick = rightClickOnHer()
2073
+ body5.dispatch('contextmenu', firstClick.ev)
2074
+ check(firstClick.ev.defaultPrevented, 'a right-click on her body is handled')
2075
+ check(menuOf() !== null, 'the right-click opens a context menu')
2076
+ check(menuOf()?.getAttribute('role') === 'menu', 'the menu carries menu semantics')
2077
+ /* 项数从皮肤数**推导**,不写死:加一套皮肤不该让测试变红(那是数据的错,不是行为的错)。
2078
+ 固定部分 = 6 个动作 + 3 个开关 + 4 个角落 = 13;其余是每套皮肤一项。 */
2079
+ /* api.skins 是数组(SKIN_IDS 的拷贝),不是对象 —— 用 Object.keys 只是碰巧数对了。 */
2080
+ const skinCount = Array.isArray(api.skins) ? api.skins.length : Object.keys(api.skins ?? {}).length
2081
+ check(itemsOf().length === 13 + skinCount,
2082
+ 'the menu lists fixed actions and corners plus exactly one entry per skin',
2083
+ `${itemsOf().length} items = 13 + ${skinCount} skin(s)`)
2084
+ const byText = (t) => itemsOf().filter((i) => i.textContent.includes(t))
2085
+ const heads = menuOf().querySelectorAll('.wisp-menu-head')
2086
+ check(heads.length === 3, 'the behaviour, position and skin sections each get a header', `${heads.length} headers`)
2087
+ check(heads[0].textContent === '行为' && heads[1].textContent === '位置' && heads[2].textContent === '皮肤',
2088
+ 'the headers name their sections', heads.map((x) => x.textContent).join(' / '))
2089
+ /* 从 API 派生,**不写死皮肤名**。写死过「初版女仆」,改名成「素绘女仆」之后
2090
+ 三条断言当场全炸,其中一条 undefined.dispatch 还把整个套件截断了。 */
2091
+ /* 既有 API 的形状是**有序数组** [{id,label}],不是 id→label 的对象。 */
2092
+ const skinNames = api.skinLabels.map((s) => s.label)
2093
+ const plainSkinLabel = (el) => el.textContent.replace(/^[✓ ]\s*/, '')
2094
+ const skinItems = itemsOf().filter((i) => skinNames.includes(plainSkinLabel(i)))
2095
+ check(skinItems.length === skinCount,
2096
+ 'every shipped skin is offered',
2097
+ `${skinItems.length} 项 / ${skinCount} 套;名字表=${JSON.stringify(skinNames)};菜单=${itemsOf().map((i) => i.textContent).join('|')}`)
2098
+ // 勾现在有三处(三个行为开关都默认开)—— 皮肤那一段必须**恰有一个**勾
2099
+ check(skinItems.filter((i) => i.textContent.startsWith('✓')).length === 1,
2100
+ 'exactly one skin is ticked as current',
2101
+ skinItems.map((i) => i.textContent).join(' / '))
2102
+ check(body5.querySelectorAll('.wisp-menu').length === 0,
2103
+ 'the menu lives beside her body, not inside it', 'inside it would be mirrored and scale with her')
2104
+
2105
+ /* ---- 三个行为开关(原本在设置页里,那页撤掉后落到菜单) -------------- */
2106
+ const toggleItem = (label) => itemsOf().find((i) => i.textContent.replace(/^[✓ ]\s*/, '') === label)
2107
+ const togglesPresent = ['自己溜达', '跟随状态', '跑完撒花'].every((l) => toggleItem(l) !== undefined)
2108
+ check(togglesPresent, 'all three behaviour toggles are in the menu',
2109
+ itemsOf().filter((i) => /溜达|跟随状态|撒花/.test(i.textContent)).map((i) => i.textContent).join(' / '))
2110
+ check(toggleItem('自己溜达').textContent.startsWith('✓'),
2111
+ 'a toggle shows its current state', toggleItem('自己溜达').textContent)
2112
+ const wanderBefore = fifth.config.wander
2113
+ toggleItem('自己溜达').dispatch('click', itemEvent())
2114
+ check(fifth.config.wander === !wanderBefore, 'clicking a toggle really flips the config',
2115
+ `${wanderBefore} -> ${fifth.config.wander}`)
2116
+ body5.dispatch('contextmenu', rightClickOnHer().ev)
2117
+ const reopened = toggleItem('自己溜达')
2118
+ check(reopened !== undefined && reopened.textContent.startsWith(' '),
2119
+ 'and reopening the menu shows the new state',
2120
+ reopened ? reopened.textContent : `菜单没重开(${itemsOf().length} 项)`)
2121
+ if (reopened !== undefined) {
2122
+ reopened.dispatch('click', itemEvent())
2123
+ check(fifth.config.wander === wanderBefore, 'toggling back restores it', String(fifth.config.wander))
2124
+ }
2125
+
2126
+ // 换皮肤:精灵图必须真的换掉,位置和情绪不能动。
2127
+ // 自己开菜单,不依赖上一节留下的状态 —— 隐含的跨节状态是测试脆弱性的常见来源。
2128
+ body5.dispatch('contextmenu', rightClickOnHer().ev)
2129
+ check(menuOf() !== null, 'the menu opens for the skin checks')
2130
+ const skinBefore = fifth.skin
2131
+ const posBefore = fifth.position
2132
+ const srcBeforeSkin = h.srcs[h.srcs.length - 1]
2133
+ /* 同样不写死名字:挑一套「和当前不同」的皮肤,用它的显示名去点菜单 */
2134
+ const targetSkin = fifth.skins.find((id) => id !== skinBefore)
2135
+ const targetLabel = fifth.skinLabels.find((s) => s.id === targetSkin).label
2136
+ const targetEl = itemsOf().filter((i) => i.textContent.replace(/^[✓ ]\s*/, '') === targetLabel)[0]
2137
+ check(targetEl !== undefined, 'the other skin has a menu entry', String(targetSkin))
2138
+ targetEl.dispatch('click', itemEvent())
2139
+ check(fifth.skin === targetSkin, 'choosing a skin switches to it', String(fifth.skin))
2140
+ check(fifth.skin !== skinBefore, 'and it is a different skin than before', `${skinBefore} -> ${fifth.skin}`)
2141
+ check(h.srcs[h.srcs.length - 1] !== srcBeforeSkin, 'the sprite is swapped for the new skin',
2142
+ `${String(srcBeforeSkin).slice(0, 22)} -> ${String(h.srcs[h.srcs.length - 1]).slice(0, 22)}`)
2143
+ check(fifth.position.x === posBefore.x && fifth.position.y === posBefore.y,
2144
+ 'switching skins leaves her where she was', `${fifth.position.x},${fifth.position.y}`)
2145
+ check(JSON.parse(h.win.localStorage.getItem('dsh-wisp:skin:v1') || 'null')?.id === targetSkin,
2146
+ 'the choice is remembered', h.win.localStorage.getItem('dsh-wisp:skin:v1'))
2147
+ check(fifth.setSkin('no-such-skin') === false && fifth.skin === targetSkin,
2148
+ 'an unknown skin name is refused rather than blanking her', String(fifth.skin))
2149
+ check(fifth.configure({ skin: 'deepsea' }) && fifth.skin === 'deepsea',
2150
+ 'configure({skin}) goes through the same path as the menu', String(fifth.skin))
2151
+
2152
+ // 点「放大一点」—— 真改尺寸,并且菜单自己关掉
2153
+ body5.dispatch('contextmenu', rightClickOnHer().ev)
2154
+ const menuItems = itemsOf()
2155
+ byText('放大一点')[0].dispatch('click', itemEvent())
2156
+ check(fifth.element.style.width === '630px' && fifth.element.style.height === '945px',
2157
+ 'a menu action runs (zoom one step up)', `${fifth.element.style.width}x${fifth.element.style.height}`)
2158
+ check(menuOf() === null, 'choosing an item closes the menu')
2159
+
2160
+ // 再右键一次,用「回到右下角」归位(此时她已经被放大了,兜底尺寸要跟着变)
2161
+ const secondClick = rightClickOnHer()
2162
+ body5.dispatch('contextmenu', secondClick.ev)
2163
+ check(menuOf() !== null, 'the menu reopens')
2164
+ byText('右下角')[0].dispatch('click', itemEvent())
2165
+ const homed = fifth.position
2166
+ check(homed.x === secondClick.homeX && homed.y === secondClick.homeY,
2167
+ 'the trip-home item works at the current size', `${homed.x},${homed.y} (want ${secondClick.homeX},${secondClick.homeY})`)
2168
+ check(JSON.parse(h.win.localStorage.getItem('dsh-wisp:position:v1')).x === homed.x,
2169
+ 'the remembered position follows her home')
2170
+
2171
+ // 菜单的关闭时机:Esc / 点外面 / 滚轮
2172
+ body5.dispatch('contextmenu', rightClickOnHer().ev)
2173
+ check(menuOf() !== null, 'the menu opens again for the dismissal checks')
2174
+ h.win.dispatch('keydown', { key: 'Escape' })
2175
+ check(menuOf() === null, 'Escape closes the menu')
2176
+ body5.dispatch('contextmenu', rightClickOnHer().ev)
2177
+ h.win.dispatch('pointerdown', { clientX: 900, clientY: 100 })
2178
+ check(menuOf() === null, 'a click elsewhere closes the menu')
2179
+ /* ---- 菜单的键盘可达性 --------------------------------------------- */
2180
+ // role="menu" 而没有键盘支持,对屏幕阅读器来说比不声明更糟
2181
+ body5.dispatch('contextmenu', rightClickOnHer().ev)
2182
+ check(menuOf() !== null, 'the menu opens for the keyboard checks')
2183
+ const kbd = () => ({
2184
+ at: h.document.activeElement,
2185
+ label: h.document.activeElement?.textContent ?? '(none)',
2186
+ zero: itemsOf().filter((i) => i.tabIndex === 0).length,
2187
+ })
2188
+ check(kbd().at !== null && kbd().at.tagName === 'BUTTON',
2189
+ 'opening the menu focuses its first item', kbd().label)
2190
+ check(kbd().at?.className === 'wisp-menu-item' && kbd().label === '说句话',
2191
+ 'and the focus lands on the first real action', kbd().label)
2192
+ check(kbd().zero === 1, 'exactly one item is in the tab order (roving tabindex)', `${kbd().zero}`)
2193
+ check(itemsOf()[0].getAttribute('aria-selected') === 'true',
2194
+ 'the focused item is marked as selected for assistive tech')
2195
+
2196
+ const firstLabel = itemsOf()[0].textContent
2197
+ const lastLabel = itemsOf()[itemsOf().length - 1].textContent
2198
+ h.win.dispatch('keydown', { key: 'ArrowDown', preventDefault() {} })
2199
+ check(kbd().label === itemsOf()[1].textContent, 'ArrowDown moves to the next item', kbd().label)
2200
+ check(kbd().zero === 1, 'still exactly one item in the tab order', `${kbd().zero}`)
2201
+ h.win.dispatch('keydown', { key: 'ArrowUp', preventDefault() {} })
2202
+ h.win.dispatch('keydown', { key: 'ArrowUp', preventDefault() {} })
2203
+ check(kbd().label === lastLabel, 'ArrowUp wraps around to the last item', `${kbd().label} (last is ${lastLabel})`)
2204
+ h.win.dispatch('keydown', { key: 'Home', preventDefault() {} })
2205
+ check(kbd().label === firstLabel, 'Home jumps to the first item', kbd().label)
2206
+ h.win.dispatch('keydown', { key: 'End', preventDefault() {} })
2207
+ check(kbd().label === lastLabel, 'End jumps to the last item', `${kbd().label} (last is ${lastLabel})`)
2208
+ // 小标题只是分组标签,键盘不该停在一个按不动的东西上
2209
+ h.win.dispatch('keydown', { key: 'ArrowDown', preventDefault() {} })
2210
+ h.win.dispatch('keydown', { key: 'ArrowUp', preventDefault() {} })
2211
+ check(itemsOf().every((i) => i.getAttribute('role') === 'menuitem'),
2212
+ 'only real actions are in the menu, headers are not focusable',
2213
+ menuOf().querySelectorAll('.wisp-menu-head').length + ' header(s) skipped')
2214
+
2215
+ // 焦点项是原生 <button>:Enter / Space 的激活交给浏览器,自己再拦一次会双重触发
2216
+ const focusedNow = h.document.activeElement
2217
+ check(focusedNow?.tagName === 'BUTTON' && focusedNow.getAttribute('type') === 'button',
2218
+ 'the focused item is a native button, so Enter and Space activate it natively',
2219
+ `${focusedNow?.tagName} type=${focusedNow?.getAttribute('type')}`)
2220
+
2221
+ h.win.dispatch('keydown', { key: 'Escape', preventDefault() {} })
2222
+ check(menuOf() === null, 'Escape closes the menu from the keyboard')
2223
+ check(h.document.activeElement?.className === 'wisp-body',
2224
+ 'and focus returns to her instead of falling into <body>',
2225
+ String(h.document.activeElement?.className ?? h.document.activeElement?.tagName))
2226
+ check(h.document.activeElement?.getAttribute('tabindex') === '0',
2227
+ 'her body is focusable, which is what makes the menu reachable without a mouse',
2228
+ String(h.document.activeElement?.getAttribute('tabindex')))
2229
+
2230
+ /* ---- 键盘入口:声明了 role="menu" 就得有办法用键盘打开它 ---------------- */
2231
+ const bodyKey = (key, opts = {}) => body5.dispatch('keydown', Object.assign(
2232
+ { key, preventDefault() {}, stopPropagation() {} }, opts))
2233
+ check(body5.getAttribute('tabindex') === '0', 'she is in the tab order',
2234
+ `tabindex=${body5.getAttribute('tabindex')}`)
2235
+ check(body5.getAttribute('aria-haspopup') === 'menu',
2236
+ 'and she announces that activating her opens a menu', String(body5.getAttribute('aria-haspopup')))
2237
+ bodyKey('Enter')
2238
+ check(menuOf() !== null, 'Enter opens the menu from her body')
2239
+ h.win.dispatch('keydown', { key: 'Escape', preventDefault() {} })
2240
+ bodyKey(' ')
2241
+ check(menuOf() !== null, 'so does Space')
2242
+ h.win.dispatch('keydown', { key: 'Escape', preventDefault() {} })
2243
+ bodyKey('F10', { shiftKey: true })
2244
+ check(menuOf() !== null, 'and Shift+F10, the platform convention for a context menu')
2245
+ h.win.dispatch('keydown', { key: 'Escape', preventDefault() {} })
2246
+ bodyKey('a')
2247
+ check(menuOf() === null, 'an unrelated key does nothing', 'no menu on a plain letter')
2248
+
2249
+ // Tab 离开 = 关掉,别把人困在菜单里
2250
+ body5.dispatch('contextmenu', rightClickOnHer().ev)
2251
+ check(menuOf() !== null, 'the menu reopens')
2252
+ h.win.dispatch('keydown', { key: 'Tab', preventDefault() {} })
2253
+ check(menuOf() === null, 'Tab leaves the menu rather than trapping focus in it')
2254
+
2255
+ // 滚轮关闭 + 关闭时必须摘掉自己的监听器(用 wheel 计数,它不是唤醒事件,
2256
+ // 不会被唤醒逻辑的增删干扰)
2257
+ const wheelsIdle = h.win.listenerCount('wheel')
2258
+ body5.dispatch('contextmenu', rightClickOnHer().ev)
2259
+ check(menuOf() !== null, 'the menu opens for the scroll check')
2260
+ check(h.win.listenerCount('wheel') === wheelsIdle + 1, 'the menu adds its own dismissal listener')
2261
+ h.win.dispatch('wheel', {})
2262
+ check(menuOf() === null, 'scrolling closes the menu')
2263
+ check(h.win.listenerCount('wheel') === wheelsIdle, 'and removes its listener when it closes')
2264
+
2265
+ // 但踱步不能在她被拖拽 / 睡着 / 忙碌时乱动
2266
+ fifth.move(300, 300)
2267
+ fifth.mood('sleep')
2268
+ h.advance(6000, 200)
2269
+ check(fifth.position.x === 300 && fifth.position.y === 300, 'no strolling while she is asleep',
2270
+ `${fifth.position.x},${fifth.position.y}`)
2271
+
2272
+ /* ---- 躲起来 / 叫回来 ------------------------------------------------ */
2273
+ const menuHost5 = () => fifth.element.parentNode
2274
+ const tabOf = () => menuHost5().querySelector('.wisp-tab')
2275
+ fifth.mood('idle')
2276
+ body5.dispatch('contextmenu', rightClickOnHer().ev)
2277
+ byText('先躲起来')[0].dispatch('click', itemEvent())
2278
+ check(fifth.hidden === true, 'the menu can hide her', String(fifth.hidden))
2279
+ check(fifth.element.dataset.hidden === 'true', 'hiding marks the root so CSS can take her out of the way')
2280
+ check(menuOf() === null, 'hiding closes the menu')
2281
+ check(tabOf() !== null, 'a mini tab is left behind rather than nothing')
2282
+ // 必须查当前实例的 layer:layerEl 是第一次挂载时的节点,3g 拆掉后已经游离,
2283
+ // 但仍然抱着它自己的旧气泡 —— 用它做断言等于在测一个已死的 DOM。
2284
+ check(menuHost5().querySelectorAll('.wisp-say').length === 0,
2285
+ 'hiding takes the speech bubble with her', 'the bubble lives in the layer, not in the root')
2286
+ const thumb = tabOf().querySelectorAll('img')
2287
+ check(thumb.length === 1, 'the tab carries a thumbnail', `${thumb.length} img`)
2288
+ check(thumb[0]?.className === 'wisp-tab-thumb', 'the thumbnail is addressable by class',
2289
+ String(thumb[0]?.className))
2290
+ check(String(thumb[0].getAttribute('src')).indexOf('blob:') === 0,
2291
+ 'the thumbnail goes through the blob path like every sprite',
2292
+ String(thumb[0].getAttribute('src')).slice(0, 22))
2293
+ check(JSON.parse(h.win.localStorage.getItem('dsh-wisp:hidden:v1') || 'null')?.hidden === true,
2294
+ 'the hidden state is remembered', h.win.localStorage.getItem('dsh-wisp:hidden:v1'))
2295
+
2296
+ // 躲着的时候她不该在幕后继续溜达
2297
+ const awayPos = fifth.position
2298
+ h.advance(9000, 300)
2299
+ check(fifth.position.x === awayPos.x && fifth.position.y === awayPos.y,
2300
+ 'no strolling while she is away', `${fifth.position.x},${fifth.position.y}`)
2301
+
2302
+ // 点迷你按钮回来
2303
+ tabOf().dispatch('click', itemEvent())
2304
+ check(fifth.hidden === false, 'the tab brings her back', String(fifth.hidden))
2305
+ check(fifth.element.dataset.hidden === 'false', 'the root is visible again')
2306
+ check(tabOf() === null, 'the tab is removed once she is back')
2307
+
2308
+ // 藏着的状态要能跨挂载保留,否则刷新后她会在你以为藏起来时突然出现
2309
+ fifth.hide()
2310
+ fifth.destroy()
2311
+ plugin.apply(h.ctx, { reactions: false, wander: false })
2312
+ const resumed = h.win.__wisp
2313
+ check(resumed.hidden === true, 'a hidden state survives a remount', String(resumed.hidden))
2314
+ check(resumed.element.parentNode.querySelector('.wisp-tab') !== null,
2315
+ 'and the mini tab comes back with it', 'a hidden companion with no way back is a trap')
2316
+ resumed.show()
2317
+ resumed.destroy()
2318
+ fifth.destroy()
2319
+
2320
+ /* ------------------------------- 3i-quinquies. 尺寸记忆 / 角落 / 首次提示 */
2321
+ head('3i-quinquies. remembered size, four corners, and the one-time hint')
2322
+
2323
+ plugin.apply(h.ctx, { reactions: false, wander: false, size: 4 })
2324
+ const sized = h.win.__wisp
2325
+ check(sized.element.style.width === '560px', 'a fresh mount uses the configured size', sized.element.style.width)
2326
+ sized.configure({ size: 6 })
2327
+ check(sized.element.style.width === '840px', 'configure() resizes her', sized.element.style.width)
2328
+ const prefsRaw = h.win.localStorage.getItem('dsh-wisp:prefs:v1')
2329
+ check(JSON.parse(prefsRaw || 'null')?.size === 6, 'the size lands in storage', String(prefsRaw))
2330
+ sized.destroy()
2331
+
2332
+ // 重挂载:菜单里调过的尺寸必须活过刷新,否则用户每次开页面都要重调。
2333
+ // 注意这里刻意【不】显式传 size —— 传了就等于项目钉死了尺寸,记忆不该生效。
2334
+ plugin.apply(h.ctx, { reactions: false, wander: false })
2335
+ const remembered = h.win.__wisp
2336
+ check(remembered.element.style.width === '840px',
2337
+ 'the remembered size survives a remount when the config does not pin one',
2338
+ `${remembered.element.style.width}, default would be 560px`)
2339
+ remembered.configure({ size: 2 })
2340
+ check(remembered.element.style.width === '280px', 'and it can still be changed', remembered.element.style.width)
2341
+ remembered.destroy()
2342
+
2343
+ // 但配置里显式写死的 size 是项目级设定,比一次临时缩放更权威
2344
+ plugin.apply(h.ctx, { reactions: false, wander: false, size: 3 })
2345
+ const pinned = h.win.__wisp
2346
+ check(pinned.element.style.width === '420px',
2347
+ 'an explicit config size beats the remembered one', `${pinned.element.style.width}, remembered 280px`)
2348
+
2349
+ // 四个角落
2350
+ const bodyOf = (inst) => inst.element.querySelector('.wisp-body')
2351
+ const boxOf = (inst) => ({
2352
+ w: parseInt(inst.element.style.width, 10),
2353
+ h: parseInt(inst.element.style.height, 10),
2354
+ })
2355
+ const openMenuOn = (inst) => {
2356
+ const b = boxOf(inst)
2357
+ const p = inst.position
2358
+ bodyOf(inst).dispatch('contextmenu', ctxEvent(p.x + b.w / 2, p.y + b.h / 2))
2359
+ }
2360
+ const pickExact = (inst, text) => {
2361
+ const found = inst.element.parentNode.querySelectorAll('.wisp-menu-item').filter((i) => i.textContent === text)
2362
+ if (found.length !== 1) return false
2363
+ found[0].dispatch('click', itemEvent())
2364
+ return true
2365
+ }
2366
+ // 四个角落。期望值按【当前】尺寸算 —— 写死尺寸的话,一旦尺寸变了断言会
2367
+ // 指着正确的实现说它错。
2368
+ const cur = boxOf(pinned)
2369
+ const EXPECT = {
2370
+ '左上角': { x: 26, y: 46 },
2371
+ '左下角': { x: 26, y: h.win.innerHeight - 46 - cur.h },
2372
+ '右上角': { x: h.win.innerWidth - 26 - cur.w, y: 46 },
2373
+ '右下角': { x: h.win.innerWidth - 26 - cur.w, y: h.win.innerHeight - 46 - cur.h },
2374
+ }
2375
+ for (const [label, want] of Object.entries(EXPECT)) {
2376
+ openMenuOn(pinned)
2377
+ const hit = pickExact(pinned, label)
2378
+ check(hit && pinned.position.x === want.x && pinned.position.y === want.y,
2379
+ `the ${label} item puts her there`, `${pinned.position.x},${pinned.position.y} (want ${want.x},${want.y})`)
2380
+ }
2381
+ pinned.destroy()
2382
+
2383
+ // 首次见面提一句右键;第二次不该再念叨。
2384
+ // 新 harness 必须把全局窗口切过去(active = fresh)并重新求值,否则挂载会落到
2385
+ // 上一个 harness 上 —— 用完还要还原 active,不然后面的 section 会查错窗口。
2386
+ const fresh = createHarness({ timer: true, composerText: null })
2387
+ const prevActive = active
2388
+ active = fresh
2389
+ fresh.evaluate(clientSrc)
2390
+ const freshPlugin = fresh.module().default
2391
+ freshPlugin.apply(fresh.ctx, { reactions: false, wander: false })
2392
+ const firstEver = fresh.win.__wisp
2393
+ check(typeof firstEver?.destroy === 'function', 'a second harness mounts its own instance',
2394
+ Object.keys(firstEver ?? {}).slice(0, 3).join(','))
2395
+ fresh.advance(1000, 100)
2396
+ const greeted = fresh.all('wisp-say').length
2397
+ fresh.advance(4200, 200)
2398
+ check(fresh.all('wisp-say').length > greeted, 'the first ever mount mentions the right-click menu',
2399
+ `${fresh.all('wisp-say').length - greeted} extra bubble(s)`)
2400
+ check(fresh.win.localStorage.getItem('dsh-wisp:hint:v1') !== null, 'and records that it did',
2401
+ String(fresh.win.localStorage.getItem('dsh-wisp:hint:v1')))
2402
+ firstEver.destroy()
2403
+ const afterFirstHint = fresh.all('wisp-say').length
2404
+ freshPlugin.apply(fresh.ctx, { reactions: false, wander: false })
2405
+ fresh.advance(5200, 200)
2406
+ check(fresh.all('wisp-say').length === afterFirstHint + 1,
2407
+ 'a later mount only greets, it does not hint again',
2408
+ `${fresh.all('wisp-say').length - afterFirstHint} bubble(s), expected exactly the hello`)
2409
+ fresh.win.__wisp.destroy()
2410
+ active = prevActive
2411
+
2412
+ /* ------------------------------------------- 3i-ter. 关掉踱步就不动 ---- */
2413
+ plugin.apply(h.ctx, { reactions: false, wander: false })
2414
+ const sixth = h.win.__wisp
2415
+ sixth.move(300, 300)
2416
+ h.advance(3000, 200)
2417
+ check(sixth.position.x === 300 && sixth.position.y === 300, 'wander: false keeps her still',
2418
+ `${sixth.position.x},${sixth.position.y}`)
2419
+ sixth.destroy()
2420
+
2421
+ /* ------------------------------ 3j. storage that throws (opaque origin) - */
2422
+ head('3j. a page without usable storage still works')
2423
+
2424
+ for (const mode of ['throwing', 'absent']) {
2425
+ const hs = createHarness({ timer: true, storage: mode })
2426
+ active = hs
2427
+ let error = null
2428
+ try {
2429
+ hs.evaluate(clientSrc)
2430
+ const mod = hs.module()
2431
+ mod.apply(hs.ctx, {})
2432
+ hs.advance(100)
2433
+ const body = hs.find('wisp-body')
2434
+ body?.dispatch('pointerdown', { button: 0, clientX: 900, clientY: 700, preventDefault() {}, stopPropagation() {} })
2435
+ hs.win.dispatch('pointermove', { clientX: 400, clientY: 300 })
2436
+ hs.win.dispatch('pointerup', {})
2437
+ const handle = hs.win.__wisp
2438
+ handle.destroy()
2439
+ } catch (e) { error = e }
2440
+ check(error === null, `mounts and tears down with storage ${mode}`, error ? String(error.message) : '')
2441
+ }
2442
+
2443
+ /* --------------------------------- 3k. the rAF fallback (no timer svc) -- */
2444
+ head('3k. requestAnimationFrame fallback when the timer service is absent')
2445
+
2446
+ const hf = createHarness({ timer: false, composerText: null })
2447
+ active = hf
2448
+ hf.evaluate(clientSrc)
2449
+ const modF = hf.module()
2450
+ modF.apply(hf.ctx, { reactions: false, chatterMs: 0, sleepAfterMs: 5000, wander: false })
2451
+ const apiF = hf.win.__wisp
2452
+ const rootF = apiF.element
2453
+ check(apiF?.clock === 'animation-frame', 'falls back to the frame clock', String(apiF?.clock))
2454
+ check(hf.frames.length > 0, 'the fallback arms a frame while work is pending')
2455
+ hf.advance(6000, 50)
2456
+ check(rootF.dataset.mood === 'sleep', 'the fallback still sleeps on schedule', String(rootF.dataset.mood))
2457
+ // Long enough that the bubble job the sleep line may have armed is gone too.
2458
+ hf.advance(8000, 50)
2459
+ check(hf.frames.length === 0, 'the fallback stops the frame loop once nothing is pending',
2460
+ `${hf.frames.length} frame(s) armed`)
2461
+ apiF.destroy()
2462
+ check(hf.frames.length === 0, 'teardown leaves no armed frame', `${hf.frames.length} frame(s) armed`)
2463
+
2464
+ /* ------------------------------------------------- 3l. 长时间运行的泄漏 */
2465
+ head('3l. soak: nothing accumulates across hundreds of interactions')
2466
+
2467
+ /* 其余测试都是"一次性"的:挂载、动几下、拆掉。没有一条验证过**反复**开关菜单、反复换皮肤、
2468
+ 反复进出状态之后,定时器 / 监听器 / DOM 节点会不会累积。虚拟时钟让这件事很便宜。 */
2469
+ const soak = createHarness({ timer: true, composerText: '' })
2470
+ const keepSoak = active
2471
+ active = soak
2472
+ soak.evaluate(clientSrc)
2473
+ const soakPlugin = soak.module().default
2474
+ soakPlugin.apply(soak.ctx, { wander: true, wanderMs: 5000, reactions: true, celebrate: false })
2475
+ const soakApi = soak.win.__wisp
2476
+ const soakBody = soakApi.element.querySelector('.wisp-body')
2477
+ soak.advance(2000, 100)
2478
+
2479
+ const EVENT_TYPES = ['pointerdown', 'pointermove', 'pointerup', 'keydown', 'wheel', 'resize', 'contextmenu']
2480
+ const domCounts = () => ({
2481
+ menu: soakApi.element.parentNode.querySelectorAll('.wisp-menu').length,
2482
+ tab: soakApi.element.parentNode.querySelectorAll('.wisp-tab').length,
2483
+ say: soakApi.element.parentNode.querySelectorAll('.wisp-say').length,
2484
+ img: soakApi.element.querySelectorAll('.wisp-img').length,
2485
+ })
2486
+ const baseline = {
2487
+ timers: soak.pendingTimers().length,
2488
+ listeners: Object.fromEntries(EVENT_TYPES.map((t) => [t, soak.win.listenerCount(t)])),
2489
+ }
2490
+
2491
+ const CYCLES = 120
2492
+ for (let i = 0; i < CYCLES; i++) {
2493
+ // 菜单:开 → 方向键走一遍 → Esc
2494
+ const centre = { clientX: soakApi.position.x + 280, clientY: soakApi.position.y + 420 }
2495
+ soakBody.dispatch('contextmenu', { ...centre, preventDefault() {}, stopPropagation() {} })
2496
+ soak.win.dispatch('keydown', { key: 'ArrowDown', preventDefault() {} })
2497
+ soak.win.dispatch('keydown', { key: 'ArrowUp', preventDefault() {} })
2498
+ soak.win.dispatch('keydown', { key: 'Escape', preventDefault() {} })
2499
+ // 情绪与气泡
2500
+ soakApi.mood(i % 2 === 0 ? 'happy' : 'sleep')
2501
+ soakApi.say('soak ' + i)
2502
+ // 皮肤来回换:blob URL 必须复用,不能每轮新建
2503
+ soakApi.setSkin(i % 2 === 0 ? 'classic' : 'deepsea')
2504
+ // 躲起来再叫回来(每隔几轮,别每轮都动)
2505
+ if (i % 7 === 0) { soakApi.hide(); soak.advance(60, 30); soakApi.show() }
2506
+ // 拖一下
2507
+ soakBody.dispatch('pointerdown', { button: 0, clientX: centre.clientX, clientY: centre.clientY, preventDefault() {}, stopPropagation() {} })
2508
+ soak.win.dispatch('pointermove', { clientX: centre.clientX + 30, clientY: centre.clientY + 10 })
2509
+ soak.win.dispatch('pointerup', {})
2510
+ soak.advance(1500, 150)
2511
+ }
2512
+
2513
+ const grown = {}
2514
+ for (const [type, before] of Object.entries(baseline.listeners)) {
2515
+ const now = soak.win.listenerCount(type)
2516
+ if (now !== before) grown[type] = `${before} -> ${now}`
2517
+ }
2518
+ check(Object.keys(grown).length === 0,
2519
+ `window listeners are unchanged after ${CYCLES} interaction cycles`,
2520
+ Object.keys(grown).length ? JSON.stringify(grown) : 'every type back to its baseline')
2521
+ // 基线里含 hello / 首次提示 / 精灵预热这些**一次性**任务,它们正常结束会让计数下降;
2522
+ // 这里要钉住的是"不增长",不是"相等"。
2523
+ check(soak.pendingTimers().length <= baseline.timers,
2524
+ 'and no timer accumulated', `${baseline.timers} -> ${soak.pendingTimers().length}(一次性任务结束后会下降)`)
2525
+
2526
+ const counts = domCounts()
2527
+ check(counts.menu === 0 && counts.tab === 0,
2528
+ 'transient UI leaves nothing behind', `menu=${counts.menu} tab=${counts.tab}`)
2529
+ check(counts.say <= 1, 'at most one speech bubble exists', `${counts.say}`)
2530
+ check(counts.img <= 2, 'at most the two cross-fade layers exist', `${counts.img}`)
2531
+
2532
+ /* 皮肤来回换 120 次,如果 blob URL 每轮都新建,这里会是 120+ —— 缓存命中应该是常数 */
2533
+ check(soak.urLs.length <= 12,
2534
+ 'switching skins reuses cached blob URLs instead of minting new ones',
2535
+ `${soak.urLs.length} object URLs ever created across ${Array.isArray(api.skins) ? api.skins.length : 0} skins and ${CYCLES} switches`)
2536
+
2537
+ soakApi.destroy()
2538
+ check(soak.pendingTimers().length === 0 && soak.revoked.length === soak.urLs.length,
2539
+ 'teardown still releases everything after the soak',
2540
+ `timers=${soak.pendingTimers().length}, revoked ${soak.revoked.length}/${soak.urLs.length}`)
2541
+ active = keepSoak
2542
+ } catch (error) {
2543
+ bad('browser half executes', `${error.constructor.name}: ${error.message}`)
2544
+ console.log(error.stack)
2545
+ } finally {
2546
+ active = null
2547
+ for (const [key, value] of Object.entries(savedGlobals)) globalThis[key] = value
2548
+ }
2549
+ }
2550
+
2551
+ /* ================================================== 3z. generation audit ==== */
2552
+ head('3z. every shipped sprite traces back to a text-only call')
2553
+
2554
+ /* 这条把「素材是纯文生图来的、且事后没被改过」变成常驻保证:
2555
+ 链校验 + 重算入包文件哈希 + 代理登记表里不得出现 edit_image(图生图)。
2556
+ 注意它证明的范围:代理那条(工具名)是硬证据;自我声明的参数只覆盖有 declare 的那些。 */
2557
+ const audit = verifyAudit(here)
2558
+ check(audit.problems.length === 0 && audit.assets.length > 0,
2559
+ 'the generation audit chain verifies and covers shipped sprites',
2560
+ audit.entries + ' 条记录 / 覆盖 ' + audit.assets.length + ' 个素材' +
2561
+ (audit.problems.length ? ';问题:' + audit.problems.slice(0, 3).join(' | ') : ''))
2562
+ check(audit.img2img.length === 0,
2563
+ 'no image-to-image call appears in the audit',
2564
+ audit.img2img.length ? audit.img2img.slice(0, 3).map((e) => e.name).join(', ')
2565
+ : audit.assets.length + ' 个素材全部来自 generate_image / batch_generate_images')
2566
+
2567
+ /* ======================================================= 4. packaging ====== */
2568
+
2569
+ head('4. packaging consistency')
2570
+
2571
+ const patchPath = pkg.dsh?.bundle?.patch ? resolve(here, pkg.dsh.bundle.patch) : null
2572
+ if (patchPath && existsSync(patchPath)) {
2573
+ ok('dsh.bundle.patch exists', pkg.dsh.bundle.patch)
2574
+ const yml = readFileSync(patchPath, 'utf8')
2575
+ const nameMatch = yml.match(/^\s*name:\s*["']?([^"'\s]+)["']?\s*$/m)
2576
+ const entry = nameMatch ? nameMatch[1] : null
2577
+ if (entry === pkg.name) ok('patch entry points at the host half', entry)
2578
+ else bad('patch entry points at the host half', `patch says '${entry}', should be '${pkg.name}' (never '<pkg>/client')`)
2579
+ } else bad('dsh.bundle.patch exists')
2580
+
2581
+ if (!pkg.dsh?.client?.inject && !pkg.dsh?.client?.shared) ok('no inject/shared declarations')
2582
+ else bad('inject/shared declared', 'a local-path install cannot satisfy them')
2583
+
2584
+ /* ---- 5. shipping readiness: sprites inlined, attribution present ---- */
2585
+
2586
+ head('5. redistribution readiness')
2587
+
2588
+ /* 包里的文档不能指向包外:别人 `npm i` 之后,绝对路径与开发机的 `_perf/` 都是死引用。
2589
+ 这条正好能抓住"README 说探针可复现、而探针在包外"这类错误。 */
2590
+ if (existsSync(join(here, 'README.md'))) {
2591
+ const docs = ['README.md', 'NOTICE.md', 'tools/CHARACTER-PROMPT.md']
2592
+ .filter((f) => existsSync(join(here, f)))
2593
+ .map((f) => readFileSync(join(here, f), 'utf8'))
2594
+ .join('\n')
2595
+ // 注意:正则里要匹配**一个**反斜杠,写多了就成了假通过(第一次就写错了,README 里明明有
2596
+ // `F:\...` 却报"没有包外引用")。
2597
+ const outside = [...new Set([...docs.matchAll(/[A-Z]:\\[^\s`)|,。]+|_perf[/\\][^\s`)|,。]*/g)].map((m) => m[0]))]
2598
+ check(outside.length === 0, 'no shipped doc points at files outside the package',
2599
+ outside.length ? outside.slice(0, 4).join(' | ') : 'every referenced path is inside the package')
2600
+
2601
+ /* 文档里提到的每个 tools/* 都必须在 files[] 里,否则收到包的人找不到它 */
2602
+ const mentioned = [...new Set([...docs.matchAll(/tools\/([A-Za-z0-9_.-]+)/g)].map((m) => 'tools/' + m[1]))]
2603
+ const declared = new Set(JSON.parse(readFileSync(join(here, 'package.json'), 'utf8')).files ?? [])
2604
+ const undeclared = mentioned.filter((m) => !declared.has(m))
2605
+ check(undeclared.length === 0, 'every tool the docs mention actually ships',
2606
+ undeclared.length ? undeclared.join(' | ') : `${mentioned.length} tool(s) referenced, all in files[]`)
2607
+ }
2608
+
2609
+ if (clientSrc !== null) {
2610
+ /* 每一套皮肤都必须凑齐四个情绪。只数 data: URI 的总数是不够的 —— 总数对上
2611
+ 也可能是某一套缺一张、另一套多一张,而缺一张的皮肤切过去就是空白。 */
2612
+ const table = clientSrc.match(/const SPRITES = (\{[\s\S]*?\n {4}\})/)
2613
+ let skinReport = 'table not found'
2614
+ let skinsOk = false
2615
+ if (table) {
2616
+ try {
2617
+ const value = new Function(`return ${table[1]}`)()
2618
+ const skins = Object.keys(value)
2619
+ const missing = []
2620
+ for (const skin of skins) {
2621
+ for (const mood of ['idle', 'happy', 'sleepy', 'work', 'attn', 'poked']) {
2622
+ const uri = value[skin]?.[mood]
2623
+ if (typeof uri !== 'string' || uri.indexOf('data:image/') !== 0) missing.push(`${skin}/${mood}`)
2624
+ }
2625
+ }
2626
+ skinsOk = skins.length > 0 && missing.length === 0
2627
+ skinReport = `${skins.length} skin(s): ${skins.join(', ')}${missing.length ? ' | missing ' + missing.join(', ') : ''}`
2628
+ } catch (error) {
2629
+ skinReport = `table does not evaluate: ${error.message}`
2630
+ }
2631
+ }
2632
+ if (skinsOk) ok('every skin carries all five moods as data URIs', skinReport)
2633
+ else bad('every skin carries all five moods as data URIs', skinReport)
2634
+
2635
+ if (clientSrc.includes('__SPRITES__')) bad('no leftover build placeholder', 'run `node build.mjs`')
2636
+ else ok('no leftover build placeholder')
2637
+
2638
+ // The bundle must be self-contained: a share carries no asset paths.
2639
+ const refsAssetFile = /['"][^'"]*\.(?:webp|png)['"]/.test(clientSrc)
2640
+ if (!refsAssetFile) ok('bundle references no external image files')
2641
+ else bad('bundle references no external image files', 'a share would break on missing assets')
2642
+
2643
+ const stamped = clientSrc.match(/const VERSION = '([^']+)'/)
2644
+ if (stamped && stamped[1] === pkg.version) ok('the bundle stamps the package version', stamped[1])
2645
+ else bad('the bundle stamps the package version', `bundle ${stamped?.[1] ?? 'none'} vs package ${pkg.version}`)
2646
+ }
2647
+
2648
+ if (existsSync(join(here, 'NOTICE.md'))) ok('NOTICE.md present', 'character provenance + image licence')
2649
+ else bad('NOTICE.md present', 'required when redistributing the character images')
2650
+
2651
+ const notice = existsSync(join(here, 'NOTICE.md')) ? readFileSync(join(here, 'NOTICE.md'), 'utf8') : ''
2652
+ if (/非官方|unofficial|not affiliated/i.test(notice)) ok('NOTICE states it is unofficial')
2653
+ else bad('NOTICE states it is unofficial')
2654
+
2655
+ if (/CC BY-NC-SA/i.test(notice)) ok('NOTICE separates the image licence from MIT')
2656
+ else bad('NOTICE separates the image licence from MIT')
2657
+
2658
+ if (pkg.private === true) bad('package is publishable', 'remove "private": true to share it')
2659
+ else ok('package is publishable')
2660
+
2661
+ if (pkg.dsh?.client?.platform) ok('dsh.client.platform declared', pkg.dsh.client.platform)
2662
+ else bad('dsh.client.platform declared')
2663
+
2664
+ const deps = Object.keys(pkg.dependencies ?? {}).length + Object.keys(pkg.peerDependencies ?? {}).length
2665
+ if (deps === 0) ok('no dependencies at all')
2666
+ else bad(`${deps} dependency declaration(s)`)
2667
+
2668
+ // Every path the manifest promises must exist, or an install ships a hole.
2669
+ const declaredFiles = Array.isArray(pkg.files) ? pkg.files : []
2670
+ const missingFiles = declaredFiles.filter((rel) => !existsSync(join(here, rel)))
2671
+ if (declaredFiles.length === 0) bad('package.json files[] declares the shipped set')
2672
+ else if (missingFiles.length === 0) ok('every path in files[] exists', `${declaredFiles.length} entries`)
2673
+ else bad('every path in files[] exists', `missing: ${missingFiles.join(', ')}`)
2674
+
2675
+ /* ============================================================= done ====== */
2676
+
2677
+ head(failures === 0 ? 'ALL CHECKS PASSED' : `${failures} CHECK(S) FAILED`)
2678
+ console.log(failures === 0
2679
+ ? ' Both halves match their contracts. A remaining failure would be outside\n this package: restart the app to rebuild its loader graph.\n'
2680
+ : ' Fix the FAIL lines above before reloading the app.\n')
2681
+ process.exit(failures === 0 ? 0 : 1)