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.
- package/README.md +869 -10
- package/assets/canon/attn.webp +0 -0
- package/assets/canon/happy.webp +0 -0
- package/assets/canon/idle.webp +0 -0
- package/assets/canon/poked.webp +0 -0
- package/assets/canon/proud.webp +0 -0
- package/assets/canon/sleepy.webp +0 -0
- package/assets/canon/work.webp +0 -0
- package/assets/classic/attn.webp +0 -0
- package/assets/classic/happy.webp +0 -0
- package/assets/classic/idle.webp +0 -0
- package/assets/classic/poked.webp +0 -0
- package/assets/classic/proud.webp +0 -0
- package/assets/classic/sleepy.webp +0 -0
- package/assets/classic/work.webp +0 -0
- package/assets/deepsea/attn.webp +0 -0
- package/assets/deepsea/happy.webp +0 -0
- package/assets/deepsea/idle.webp +0 -0
- package/assets/deepsea/poked.webp +0 -0
- package/assets/deepsea/proud.webp +0 -0
- package/assets/deepsea/sleepy.webp +0 -0
- package/assets/deepsea/work.webp +0 -0
- package/assets/night/attn.webp +0 -0
- package/assets/night/happy.webp +0 -0
- package/assets/night/idle.webp +0 -0
- package/assets/night/poked.webp +0 -0
- package/assets/night/proud.webp +0 -0
- package/assets/night/sleepy.webp +0 -0
- package/assets/night/work.webp +0 -0
- package/assets/pajama/attn.webp +0 -0
- package/assets/pajama/happy.webp +0 -0
- package/assets/pajama/idle.webp +0 -0
- package/assets/pajama/poked.webp +0 -0
- package/assets/pajama/proud.webp +0 -0
- package/assets/pajama/sleepy.webp +0 -0
- package/assets/pajama/work.webp +0 -0
- package/audit/generation-audit.jsonl +35 -0
- package/build.mjs +93 -30
- package/cordis.patch.yml +2 -0
- package/lib/client.js +1293 -41
- package/lib/client.template.js +1248 -37
- package/lib/index.js +115 -12
- package/package.json +8 -1
- package/tools/CHARACTER-PROMPT.md +221 -3
- package/tools/assets.mjs +39 -16
- package/tools/audit-log.mjs +245 -0
- package/tools/bundle-cost.mjs +105 -0
- package/tools/pack-check.mjs +78 -0
- package/tools/perf.mjs +186 -0
- package/tools/smoke.mjs +577 -0
- package/tools/tiers.mjs +104 -0
- package/verify-wisp.mjs +2681 -872
- package/assets/happy.webp +0 -0
- package/assets/happy_hi.webp +0 -0
- package/assets/happy_md.webp +0 -0
- package/assets/happy_sm.webp +0 -0
- package/assets/idle.webp +0 -0
- package/assets/idle_hi.webp +0 -0
- package/assets/idle_md.webp +0 -0
- package/assets/idle_sm.webp +0 -0
- package/assets/sleepy.webp +0 -0
- package/assets/sleepy_hi.webp +0 -0
- package/assets/sleepy_md.webp +0 -0
- package/assets/sleepy_sm.webp +0 -0
- package/assets/work.webp +0 -0
- package/assets/work_hi.webp +0 -0
- package/assets/work_md.webp +0 -0
- 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
|
-
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
let
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
return
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
)
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
const
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
const
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
const
|
|
673
|
-
check(
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
const
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
check(
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
h.advance(
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
const
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
check(
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
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)
|