dsh-wisp 1.42.0 → 1.48.0
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 +666 -29
- package/assets/canon/attn.webp +0 -0
- package/assets/canon/eat.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/eat.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/work.webp +0 -0
- package/assets/deepsea/attn.webp +0 -0
- package/assets/deepsea/eat.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/motion/idle.webp +0 -0
- package/assets/motion/sleepy.webm +0 -0
- package/assets/motion/sleepy.webp +0 -0
- package/assets/motion/swim_attn.webp +0 -0
- package/assets/motion/swim_eat.webp +0 -0
- package/assets/motion/swim_happy.webp +0 -0
- package/assets/motion/swim_idle.webp +0 -0
- package/assets/motion/swim_poked.webp +0 -0
- package/assets/motion/swim_proud.webp +0 -0
- package/assets/motion/swim_sleepy.webp +0 -0
- package/assets/motion/swim_work.webp +0 -0
- package/assets/night/attn.webp +0 -0
- package/assets/night/eat.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/eat.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/assets/swim/attn.webp +0 -0
- package/assets/swim/eat.webp +0 -0
- package/assets/swim/happy.webp +0 -0
- package/assets/swim/idle.webp +0 -0
- package/assets/swim/poked.webp +0 -0
- package/assets/swim/proud.webp +0 -0
- package/assets/swim/sleepy.webp +0 -0
- package/assets/swim/work.webp +0 -0
- package/audit/generation-audit.jsonl +57 -0
- package/build.mjs +130 -8
- package/cordis.patch.yml +5 -0
- package/lib/client.js +1014 -104
- package/lib/client.template.js +953 -65
- package/lib/index.js +311 -16
- package/package.json +1 -1
- package/tools/CHARACTER-PROMPT.md +65 -9
- package/tools/audit-log.mjs +11 -2
- package/tools/engine-probe.mjs +500 -2
- package/verify-wisp.mjs +1576 -51
package/verify-wisp.mjs
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
EXIT CODE 0 = both halves match their contract.
|
|
30
30
|
========================================================================== */
|
|
31
31
|
|
|
32
|
-
import { readFileSync, statSync, existsSync } from 'node:fs'
|
|
32
|
+
import { readFileSync, statSync, existsSync, readdirSync } from 'node:fs'
|
|
33
33
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
34
34
|
import { dirname, join, resolve } from 'node:path'
|
|
35
35
|
import { verifyAudit } from './tools/audit-log.mjs'
|
|
@@ -38,6 +38,16 @@ const here = dirname(fileURLToPath(import.meta.url))
|
|
|
38
38
|
const pkgPath = join(here, 'package.json')
|
|
39
39
|
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
|
40
40
|
|
|
41
|
+
/* 泳装那八条帧动画素材(v1.47.0)。判据是**磁盘上实际有什么**,不是"应该有什么":
|
|
42
|
+
一条都没有(还没生成)与八条都在(生成完了)都是合法状态,**只到一半**不是 ——
|
|
43
|
+
半套素材在页面上表现为"她有些状态不动",而那和"没有素材"长得一模一样。
|
|
44
|
+
投递机制(motion 清单 + 宿主路由 + 客户端 URL 解析)在没有素材时照样成立:
|
|
45
|
+
它是一条"有文件就发、没有就静默降级"的路。 */
|
|
46
|
+
const MOTION_DIR = join(here, 'assets', 'motion')
|
|
47
|
+
const SHIPPED_SWIM = existsSync(MOTION_DIR)
|
|
48
|
+
? readdirSync(MOTION_DIR).filter((f) => /^swim_[a-z]+\.webp$/.test(f)).sort()
|
|
49
|
+
: []
|
|
50
|
+
|
|
41
51
|
const hostPath = resolve(here, pkg.main ?? 'lib/index.js')
|
|
42
52
|
const clientRel = pkg.exports?.['./client']?.default
|
|
43
53
|
const clientPath = clientRel ? resolve(here, clientRel) : null
|
|
@@ -109,9 +119,12 @@ class FakeBlob {
|
|
|
109
119
|
if (active) active.blobs.push(this)
|
|
110
120
|
}
|
|
111
121
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
122
|
+
/* 替身 URL 必须**同时**是那个构造函数:v1.47.0 起客户端的动图源是
|
|
123
|
+
`new URL('wisp-motion/<文件>', document.baseURI)` —— 只给 createObjectURL 的
|
|
124
|
+
假 URL 会让这一行当场抛错,而那是"替身不像真页面",不是插件的问题。 */
|
|
125
|
+
class FakeURL extends URL {
|
|
126
|
+
static createObjectURL() { const url = `blob:dsh-app/verify-${++urlSeq}`; if (active) active.urLs.push(url); return url }
|
|
127
|
+
static revokeObjectURL(url) { if (active) active.revoked.push(url) }
|
|
115
128
|
}
|
|
116
129
|
/* The constructor writes the backing field directly: routing its own
|
|
117
130
|
initialisation through the instrumented setter would record a phantom empty
|
|
@@ -170,8 +183,23 @@ function createHarness(options = {}) {
|
|
|
170
183
|
className: '', textContent: '', id: '', value: '',
|
|
171
184
|
listeners: Object.create(null),
|
|
172
185
|
_src: '',
|
|
173
|
-
focus()
|
|
174
|
-
|
|
186
|
+
/* focus() / blur() 必须**真的派发事件**:真实 DOM 里 focus 事件是会响的,
|
|
187
|
+
而插件正靠它把"这一次焦点是谁给的"翻译成画面(键盘 -> 贴剪影的辉光;
|
|
188
|
+
鼠标 -> 什么都不画)。替身不发事件时,这条产品行为在预检里永远测不出来 ——
|
|
189
|
+
而它出错的样子正是用户报的"有时会出现素材的矩形边界线"。
|
|
190
|
+
顺序照真实语义:activeElement 先更新,所以 focus 事件里读到的已经是新元素。 */
|
|
191
|
+
focus() {
|
|
192
|
+
if (activeEl === el) return
|
|
193
|
+
const previous = activeEl
|
|
194
|
+
activeEl = el
|
|
195
|
+
if (previous) previous.dispatch('blur', { type: 'blur', target: previous })
|
|
196
|
+
el.dispatch('focus', { type: 'focus', target: el })
|
|
197
|
+
},
|
|
198
|
+
blur() {
|
|
199
|
+
if (activeEl !== el) return
|
|
200
|
+
activeEl = null
|
|
201
|
+
el.dispatch('blur', { type: 'blur', target: el })
|
|
202
|
+
},
|
|
175
203
|
get firstChild() { return this.children[0] ?? null },
|
|
176
204
|
get src() { return this._src },
|
|
177
205
|
set src(v) { this._src = v; if (this.tagName === 'IMG') harness.srcs.push(v) },
|
|
@@ -247,6 +275,25 @@ function createHarness(options = {}) {
|
|
|
247
275
|
},
|
|
248
276
|
})
|
|
249
277
|
}
|
|
278
|
+
if (String(tag).toLowerCase() === 'video') {
|
|
279
|
+
/* <video> 的替身(v1.45.0)。v1.46.1 起产品里**一个 <video> 都不该建**
|
|
280
|
+
(帧动画换成了动图 <img>),所以这个替身现在是个哨兵:谁把媒体通道加回来,
|
|
281
|
+
它就会开始记录 play()/pause(),而 3f-ter 里那条"一次都没有"立刻会红。
|
|
282
|
+
真实浏览器里 play()/pause() 是**方法**、currentTime 是**可写属性**,
|
|
283
|
+
替身缺了它们,那条断言就只能靠读源码文本 —— 读文本证明不了行为。 */
|
|
284
|
+
el.currentTime = 0
|
|
285
|
+
el.paused = true
|
|
286
|
+
el.play = () => {
|
|
287
|
+
el.paused = false
|
|
288
|
+
if (active) active.videoPlays.push(el)
|
|
289
|
+
return Promise.resolve()
|
|
290
|
+
}
|
|
291
|
+
el.pause = () => {
|
|
292
|
+
el.paused = true
|
|
293
|
+
if (active) active.videoPauses.push(el)
|
|
294
|
+
}
|
|
295
|
+
el.load = () => {}
|
|
296
|
+
}
|
|
250
297
|
created.push(el)
|
|
251
298
|
return el
|
|
252
299
|
}
|
|
@@ -314,6 +361,10 @@ function createHarness(options = {}) {
|
|
|
314
361
|
const documentShim = {
|
|
315
362
|
body: bodyEl,
|
|
316
363
|
head: headEl,
|
|
364
|
+
/* 动图源的基准(v1.47.0):客户端只写 `wisp-motion/<文件>`,其它一律由页面
|
|
365
|
+
自己的 base 带出来。替身给一个真实的 Web 载体地址,而不是
|
|
366
|
+
`dsh-app://app/` —— 后者没有 HTTP 服务器,正是"路由不在"的那一档。 */
|
|
367
|
+
baseURI: options.baseURI ?? 'http://127.0.0.1:19387/',
|
|
317
368
|
get activeElement() { return activeEl },
|
|
318
369
|
/* 真 document 有事件接口,替身也得有 —— 否则"插件是否正确处理文档级 Esc"根本测不到,
|
|
319
370
|
而且少一个方法会让插件在替身里整个起不来(这坑踩过一次)。 */
|
|
@@ -438,11 +489,15 @@ function createHarness(options = {}) {
|
|
|
438
489
|
|
|
439
490
|
const styleInserts = []
|
|
440
491
|
|
|
492
|
+
/* 由**别的包**注册的客户端服务(v1.46.0):余额那条路要的 remote.account 就是这种。
|
|
493
|
+
默认空表 —— 一条通道都没有的情形才测得到。 */
|
|
494
|
+
const extraServices = options.services && typeof options.services === 'object' ? options.services : {}
|
|
441
495
|
const ctx = {
|
|
442
496
|
get: (name) => {
|
|
443
497
|
if (name === 'timer') return timerService
|
|
444
498
|
if (name === 'styles') return { insert: (css) => { styleInserts.push(css); return () => { styleInserts.pop() } } }
|
|
445
499
|
if (name === 'theme') return { getTheme: () => themeSnapshot }
|
|
500
|
+
if (Object.prototype.hasOwnProperty.call(extraServices, name)) return extraServices[name]
|
|
446
501
|
return undefined
|
|
447
502
|
},
|
|
448
503
|
on: (name, fn) => {
|
|
@@ -456,6 +511,9 @@ function createHarness(options = {}) {
|
|
|
456
511
|
const harness = {
|
|
457
512
|
state, ctx, win, document: documentShim, bodyEl, created, frames, timers, queries,
|
|
458
513
|
srcs: [], urLs: [], revoked: [], blobs: [], styleInserts,
|
|
514
|
+
/* <video> 的播放记录(v1.45.0)。v1.46.1 起它的用途反过来:断言"这一层一次
|
|
515
|
+
play()/pause() 都没有" —— 图片路径上不存在媒体通道,也不该有人偷偷搭回来。 */
|
|
516
|
+
videoPlays: [], videoPauses: [],
|
|
459
517
|
loaded: null,
|
|
460
518
|
timerService,
|
|
461
519
|
get teardown() { return runEffects },
|
|
@@ -504,9 +562,13 @@ function createHarness(options = {}) {
|
|
|
504
562
|
// 默认不给 —— 这样"没有 host 时降级"才测得到;要测检查更新就传 hostCall。
|
|
505
563
|
const hostSeat = typeof options.hostCall === 'function' ? { call: options.hostCall } : undefined
|
|
506
564
|
// eslint-disable-next-line no-new-func
|
|
507
|
-
/* 追加 sourceURL:new Function 里的代码否则在堆栈里是 anonymous,定位不到行。
|
|
508
|
-
|
|
509
|
-
|
|
565
|
+
/* 追加 sourceURL:new Function 里的代码否则在堆栈里是 anonymous,定位不到行。
|
|
566
|
+
navigator 必须显式注入:浏览器里裸 navigator === window.navigator,而 **Node 21+
|
|
567
|
+
也有全局 navigator**(navigator.language = 系统语言)。不注入的话,客户端读到的是
|
|
568
|
+
Node 的 —— 中文机器上绿、英文 runner 上红(真踩过:Node 22 上 24 项台词池检查全红,
|
|
569
|
+
Node 20 上因为那个全局还不存在而"恰好"绿)。 */
|
|
570
|
+
new Function('window', 'document', 'host', 'navigator', ...TRAPPED, src + '\n//# sourceURL=wisp-client-half.js')(
|
|
571
|
+
win, documentShim, hostSeat, win.navigator ?? {}, ...TRAPPED.map((name) => traps[name]),
|
|
510
572
|
)
|
|
511
573
|
},
|
|
512
574
|
module() {
|
|
@@ -541,6 +603,52 @@ if (!existsSync(hostPath)) {
|
|
|
541
603
|
}
|
|
542
604
|
}
|
|
543
605
|
|
|
606
|
+
/* ================================ 1c. the host half serves the frame clips == */
|
|
607
|
+
|
|
608
|
+
head('1c. the host half claims /wisp-motion/ on the platform HTTP carrier (v1.47.0)')
|
|
609
|
+
|
|
610
|
+
/* 这一节查的是**接线**,不是 handler 本身(handler 在 4b 里被真字节驱动过一遍)。
|
|
611
|
+
为什么单独查:`ctx.inject(['webServer'], cb)` 是唯一一处"把插件的路由挂到平台载体上"
|
|
612
|
+
的调用 —— 名字、kind、path 写错了都不会有任何报错,页面上只表现为"她怎么不动了"
|
|
613
|
+
(静默降级)。用假 ctx 把这条链走一遍,就不必等到重启应用才发现。 */
|
|
614
|
+
{
|
|
615
|
+
const mod = await import(pathToFileURL(hostPath).href + '?probe=motion-wiring')
|
|
616
|
+
const registered = []
|
|
617
|
+
const effects = []
|
|
618
|
+
let injected = null
|
|
619
|
+
const webCtx = {
|
|
620
|
+
webServer: { register: (route) => { registered.push(route); return () => {} } },
|
|
621
|
+
effect: (fn, label) => { effects.push({ label, dispose: fn() }) },
|
|
622
|
+
}
|
|
623
|
+
const ctx = {
|
|
624
|
+
inject: (deps, cb) => { injected = deps; cb(webCtx) },
|
|
625
|
+
effect: () => () => {},
|
|
626
|
+
}
|
|
627
|
+
let threw = null
|
|
628
|
+
try { mod.apply(ctx, {}) } catch (error) { threw = error }
|
|
629
|
+
check(threw === null && Array.isArray(injected) && injected.includes('webServer') && registered.length === 1,
|
|
630
|
+
'apply() claims its route through ctx.inject([\'webServer\']) — exactly one registration',
|
|
631
|
+
`inject=${JSON.stringify(injected)} routes=${registered.length}${threw ? ` threw=${threw.message}` : ''}`)
|
|
632
|
+
check(registered[0]?.kind === 'prefix' && registered[0]?.path === mod.MOTION_PATH
|
|
633
|
+
&& typeof registered[0]?.handler === 'function',
|
|
634
|
+
'and it is a prefix route at the path the client half asks for, with a real handler',
|
|
635
|
+
`${String(registered[0]?.kind)} ${String(registered[0]?.path)} handler=${typeof registered[0]?.handler}`)
|
|
636
|
+
check(effects.length === 1 && /wisp/i.test(String(effects[0]?.label ?? '')),
|
|
637
|
+
'registered as an OWNED effect, so disabling the package takes the route down with it',
|
|
638
|
+
String(effects[0]?.label ?? '(no effect)'))
|
|
639
|
+
/* 载体没有 webServer(Electron 没有 HTTP 服务器)不是加载失败 —— 这条路是**可选**的,
|
|
640
|
+
少一条路由的画面就是静态立绘。两条都要真的试:一个空的 inject 回调、一个连
|
|
641
|
+
ctx.inject 都没有的 ctx(更老的宿主)。 */
|
|
642
|
+
let silent = null
|
|
643
|
+
let noInject = null
|
|
644
|
+
try { silent = mod.registerMotionRoute({ inject: (deps, cb) => cb({}) }) } catch (error) { silent = { threw: String(error.message) } }
|
|
645
|
+
try { noInject = mod.registerMotionRoute({}) } catch (error) { noInject = { threw: String(error.message) } }
|
|
646
|
+
check(silent?.registered === true && !('threw' in (silent ?? {}))
|
|
647
|
+
&& noInject?.registered === false && typeof noInject?.why === 'string',
|
|
648
|
+
'and a carrier with no webServer (or no ctx.inject at all) degrades to "no route", never to a load failure',
|
|
649
|
+
`empty-carrier=${JSON.stringify(silent)} no-inject=${JSON.stringify(noInject)}`)
|
|
650
|
+
}
|
|
651
|
+
|
|
544
652
|
/* ================================================= 1b. the update handler == */
|
|
545
653
|
|
|
546
654
|
head('1b. the host half answers checkUpdate through the platform web service')
|
|
@@ -551,7 +659,7 @@ head('1b. the host half answers checkUpdate through the platform web service')
|
|
|
551
659
|
{
|
|
552
660
|
const mod = await import(pathToFileURL(hostPath).href + '?probe=update')
|
|
553
661
|
const { readPublishedVersion, compareVersions, registerHandlers, inject } = mod
|
|
554
|
-
const { readBalance, accountClientMetadata, normalizeWallets, resolveWebService } = mod
|
|
662
|
+
const { readBalance, accountClientMetadata, normalizeWallets, resolveWebService, readManagedVersion } = mod
|
|
555
663
|
|
|
556
664
|
/* web 服务必须声明为**可选**:硬依赖一个没加载的服务会让 fiber 永远 waiting,
|
|
557
665
|
她会直接从界面上消失。这条是安全属性,不是风格偏好。 */
|
|
@@ -584,6 +692,22 @@ head('1b. the host half answers checkUpdate through the platform web service')
|
|
|
584
692
|
const U = { npm: 'https://npm/x', github: 'https://gh/y' }
|
|
585
693
|
const only = (key, outcome) => webWith({ [U[key]]: outcome })
|
|
586
694
|
|
|
695
|
+
/* 源可以换:国内镜像 / 私有 registry 只需要把 config.urls 指过去。
|
|
696
|
+
这一条钉住"换源真的会去问新地址"—— 否则"支持镜像"只是文档里的一句话。 */
|
|
697
|
+
const asked = []
|
|
698
|
+
const recorder = {
|
|
699
|
+
fetch: async (request) => {
|
|
700
|
+
asked.push(request.url)
|
|
701
|
+
return { url: request.url, statusCode: 200, body: { kind: 'text', content: '{"version":"1.2.3"}' }, truncated: false }
|
|
702
|
+
},
|
|
703
|
+
}
|
|
704
|
+
const mirrorUrls = { npm: 'https://registry.npmmirror.com/dsh-wisp/latest' }
|
|
705
|
+
const mirror = await readPublishedVersion(recorder, mirrorUrls, undefined, 50)
|
|
706
|
+
check(mirror.ok === true && mirror.latest === '1.2.3' && mirror.from === 'npm'
|
|
707
|
+
&& asked.length === 1 && asked[0] === mirrorUrls.npm,
|
|
708
|
+
'the npm source can be pointed at a mirror through config (only the configured URL is asked)',
|
|
709
|
+
JSON.stringify({ asked, verdict: mirror }))
|
|
710
|
+
|
|
587
711
|
const good = await readPublishedVersion(only('npm', { body: '{"name":"dsh-wisp","version":"9.9.9"}' }), U)
|
|
588
712
|
check(good.ok === true && good.latest === '9.9.9' && good.from === 'npm',
|
|
589
713
|
'a 200 with a version field is read correctly', JSON.stringify(good))
|
|
@@ -647,18 +771,71 @@ head('1b. the host half answers checkUpdate through the platform web service')
|
|
|
647
771
|
/* ---- 服务在**调用时**解析,而不是吃启动那一刻的快照 ----
|
|
648
772
|
宿主半包由 bundle patch 插进来,可能比 web 服务先就位;而 optional 依赖在启动时缺席
|
|
649
773
|
并不会事后补上 —— `ctx.web` 会永远是 undefined,检查更新从此永久失效。
|
|
650
|
-
`ctx.get('web')` 是调用时查表,所以这条竞态修得掉。 */
|
|
651
|
-
const lateService = { fetch: async () => ({ statusCode: 200, body: { kind: 'text', content: '{"version":"9.9.9"}' } }) }
|
|
774
|
+
`ctx.get('web')` 是调用时查表,所以这条竞态修得掉。 */ const lateService = { fetch: async () => ({ statusCode: 200, body: { kind: 'text', content: '{"version":"9.9.9"}' } }) }
|
|
652
775
|
const declaredService = { fetch: async () => ({ statusCode: 200, body: { kind: 'text', content: '{"version":"1.0.0"}' } }) }
|
|
653
|
-
check(resolveWebService({ get: () => lateService, web: declaredService }) === lateService,
|
|
776
|
+
check(resolveWebService({ get: () => lateService, web: declaredService }).service === lateService,
|
|
654
777
|
'the web service is resolved at CALL time, not from the startup-time property')
|
|
655
|
-
check(resolveWebService({ get: () => undefined, web: declaredService }) === declaredService,
|
|
778
|
+
check(resolveWebService({ get: () => undefined, web: declaredService }).service === declaredService,
|
|
656
779
|
'with ctx.get answering nothing, the declared property is still the fallback')
|
|
657
|
-
check(resolveWebService({ get: () => { throw new Error('restricted ctx') }, web: declaredService }) === declaredService,
|
|
780
|
+
check(resolveWebService({ get: () => { throw new Error('restricted ctx') }, web: declaredService }).service === declaredService,
|
|
658
781
|
'a throwing ctx.get falls back instead of breaking the check')
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
782
|
+
/* "有个对象但不是 web 服务"也算 partial:把形状说出来,让 readVersion 去报
|
|
783
|
+
"有服务但没有 fetch" —— 那比一句"没有通道"更能指认问题在哪一层。 */
|
|
784
|
+
check(resolveWebService({ web: { notAFetchService: true } }).how === 'partial'
|
|
785
|
+
&& resolveWebService({ web: { notAFetchService: true } }).service !== undefined
|
|
786
|
+
&& resolveWebService(undefined).service === undefined,
|
|
787
|
+
'a non-service object is passed through as partial so the failure can name its shape')
|
|
788
|
+
|
|
789
|
+
/* ---- 拿不到服务时,必须能说清"看见了什么" ------------------------------------
|
|
790
|
+
真机上出现过"两条路都拿不到、界面只说没有联网通道",而那句话没法追问。
|
|
791
|
+
诊断至少要回答:ctx.get 是不是函数、拿到的东西有没有 fetch。 */
|
|
792
|
+
const blind = resolveWebService({ get: () => ({ search: () => {} }) })
|
|
793
|
+
check(blind.service !== undefined && blind.how === 'partial' && blind.why.includes('-fetch'),
|
|
794
|
+
'a service without a callable fetch is reported as partial, with its shape spelled out',
|
|
795
|
+
JSON.stringify({ how: blind.how, why: blind.why }))
|
|
796
|
+
const nothing = resolveWebService({})
|
|
797
|
+
check(nothing.service === undefined && nothing.how === 'none'
|
|
798
|
+
&& nothing.why.includes('ctx.get("web")→undefined') && nothing.why.includes('ctx.web→undefined'),
|
|
799
|
+
'and a genuinely missing service says which lookups came back undefined', nothing.why)
|
|
800
|
+
const noGet = resolveWebService(undefined)
|
|
801
|
+
check(noGet.service === undefined && noGet.how === 'none', 'no ctx at all is reported, not thrown', noGet.why)
|
|
802
|
+
|
|
803
|
+
/* ---- 兜底通道:web 拿不到时问插件管理器"这个包是什么版本" ---------------------
|
|
804
|
+
它走的是应用自己的网络出口(安装前 inspect 本来就要去 registry 问),
|
|
805
|
+
所以这条路不依赖插件能否拿到 web 服务。 */
|
|
806
|
+
const managedCtx = {
|
|
807
|
+
get: (name) => (name === 'pluginManager'
|
|
808
|
+
? { inspect: async (spec) => ({ status: 'accepted', name: spec, version: '7.7.7', registry: 'https://mirror.example' }) }
|
|
809
|
+
: undefined),
|
|
810
|
+
}
|
|
811
|
+
const managed = await readManagedVersion(managedCtx, 'dsh-wisp', 500)
|
|
812
|
+
check(managed.ok === true && managed.version === '7.7.7' && managed.registry === 'https://mirror.example',
|
|
813
|
+
'the plugin manager can be asked for the published version as a fallback channel',
|
|
814
|
+
JSON.stringify(managed))
|
|
815
|
+
const noManager = await readManagedVersion({ get: () => undefined }, 'dsh-wisp', 500)
|
|
816
|
+
check(noManager.ok === false && noManager.reason === 'no-plugin-manager',
|
|
817
|
+
'and its absence is a named reason, not an exception', JSON.stringify(noManager))
|
|
818
|
+
const refused = await readManagedVersion({ get: () => ({ inspect: async () => ({ status: 'refused', reason: 'no such package' }) }) }, 'dsh-wisp', 500)
|
|
819
|
+
check(refused.ok === false && refused.reason === 'inspect-refused' && refused.detail === 'no such package',
|
|
820
|
+
'a refusal keeps the manager\'s own words', JSON.stringify(refused))
|
|
821
|
+
|
|
822
|
+
/* 端到端:没有 web 服务、但有插件管理器 → 检查更新照样能给出答案,并说明走的哪条通道 */
|
|
823
|
+
{
|
|
824
|
+
const handlers = new Map()
|
|
825
|
+
const originalHarness = globalThis.harness
|
|
826
|
+
globalThis.harness = { handle: (name, fn) => { handlers.set(name, fn); return () => handlers.delete(name) } }
|
|
827
|
+
try {
|
|
828
|
+
registerHandlers(managedCtx, { urls: U })
|
|
829
|
+
const viaManager = await handlers.get('checkUpdate')({ current: '1.0.0' })
|
|
830
|
+
check(viaManager.ok === true && viaManager.latest === '7.7.7' && viaManager.from === 'npm'
|
|
831
|
+
&& viaManager.diag && viaManager.diag.via === 'pluginManager',
|
|
832
|
+
'with no web service but a plugin manager, the check still answers — through the fallback',
|
|
833
|
+
JSON.stringify(viaManager))
|
|
834
|
+
} finally {
|
|
835
|
+
if (originalHarness === undefined) delete globalThis.harness
|
|
836
|
+
else globalThis.harness = originalHarness
|
|
837
|
+
}
|
|
838
|
+
}
|
|
662
839
|
|
|
663
840
|
/* ---- 每个源一个自己的截止时间 ----
|
|
664
841
|
共用一个信号时,一个卡住的源到点会把另一个**已经拿到结果**的源一起作废,
|
|
@@ -910,15 +1087,28 @@ if (clientSrc !== null) {
|
|
|
910
1087
|
/* ------------------------------------------------- 3b. sprite pipeline -- */
|
|
911
1088
|
head('3b. sprite pipeline (the broken-image regression)')
|
|
912
1089
|
|
|
1090
|
+
/* v1.47.0:`h.srcs` 里现在**混着两种源** —— 立绘是 `blob:`(data URI 解码出来的
|
|
1091
|
+
object URL),帧动画是宿主路由下的文件 URL。混在一起数,两件事就都说不清了,
|
|
1092
|
+
所以先按来源分开,再各查各的。 */
|
|
1093
|
+
const spriteSrcs = () => h.srcs.filter((s) => typeof s === 'string' && s.startsWith('blob:'))
|
|
1094
|
+
const motionSrcs = () => h.srcs.filter((s) => typeof s === 'string' && s.indexOf('/wisp-motion/') >= 0)
|
|
1095
|
+
|
|
913
1096
|
if (h.srcs.length === 0) {
|
|
914
1097
|
bad('sprite assigned to an <img>', 'no src was ever set')
|
|
915
|
-
} else if (
|
|
916
|
-
ok('sprites use blob: object URLs',
|
|
1098
|
+
} else if (spriteSrcs().length > 0 && spriteSrcs().every((s) => s.startsWith('blob:'))) {
|
|
1099
|
+
ok('sprites use blob: object URLs', spriteSrcs()[0])
|
|
917
1100
|
} else if (h.srcs.some((s) => typeof s === 'string' && s.startsWith('data:'))) {
|
|
918
1101
|
bad('sprites must not use data: URIs', 'this shell renders them as a broken image')
|
|
919
1102
|
} else {
|
|
920
1103
|
bad('sprite URL scheme', JSON.stringify(h.srcs).slice(0, 200))
|
|
921
1104
|
}
|
|
1105
|
+
/* 帧动画走的是**另一条**路(v1.47.0):宿主半包注册的 `/wisp-motion/` 路由,
|
|
1106
|
+
以页面自己的 base 解析。它必须既不是 blob:(那意味着素材又被内联回来了),
|
|
1107
|
+
也不是 data:(这个壳画不出来)。 */
|
|
1108
|
+
check(motionSrcs().length > 0
|
|
1109
|
+
&& motionSrcs().every((s) => /^http:\/\/127\.0\.0\.1:19387\/wisp-motion\/[A-Za-z0-9._-]+\.webp$/.test(s)),
|
|
1110
|
+
'the frame-animation source is the host route resolved against the page base — never an inlined blob or data URI (v1.47.0)',
|
|
1111
|
+
`${motionSrcs().length} motion src(s): ${motionSrcs()[0] ?? '(none)'}`)
|
|
922
1112
|
check(h.blobs.length > 0, 'base64 decoded into Blob objects', `${h.blobs.length} blob(s)`)
|
|
923
1113
|
check(h.blobs.every((b) => b.type.startsWith('image/')), 'blob MIME types are images', h.blobs[0]?.type)
|
|
924
1114
|
|
|
@@ -974,7 +1164,7 @@ if (clientSrc !== null) {
|
|
|
974
1164
|
active = keepBt
|
|
975
1165
|
api.mood('happy')
|
|
976
1166
|
check(root.dataset.mood === 'happy', 'mood() control works')
|
|
977
|
-
check(
|
|
1167
|
+
check(spriteSrcs().at(-1) !== spriteSrcs()[0], 'sprite switches with mood', String(spriteSrcs().at(-1)))
|
|
978
1168
|
|
|
979
1169
|
// The swap must be a cross-fade: two layers for the length of the fade,
|
|
980
1170
|
// then the outgoing one is dropped. A hard `src` swap reads as a glitch.
|
|
@@ -1061,9 +1251,9 @@ if (clientSrc !== null) {
|
|
|
1061
1251
|
h.state.pending = 'q-1'
|
|
1062
1252
|
h.advance(1300, 100)
|
|
1063
1253
|
check(root.dataset.mood === 'attn', 'a pending question switches her to attention', String(root.dataset.mood))
|
|
1064
|
-
check(
|
|
1254
|
+
check(spriteSrcs().at(-1) !== spriteSrcs()[0],
|
|
1065
1255
|
'the attention state has its own sprite rather than borrowing idle',
|
|
1066
|
-
String(
|
|
1256
|
+
String(spriteSrcs().at(-1)).slice(0, 22))
|
|
1067
1257
|
check(bubbles() > beforeAttn, 'she says something about it',
|
|
1068
1258
|
layerEl.querySelector('.wisp-say')?.textContent)
|
|
1069
1259
|
|
|
@@ -2021,13 +2211,13 @@ if (clientSrc !== null) {
|
|
|
2021
2211
|
const size0 = ixApi.config.size
|
|
2022
2212
|
wheel(-100, { ctrlKey: true })
|
|
2023
2213
|
const sizeUp = ixApi.config.size
|
|
2024
|
-
check(Math.abs(sizeUp - (size0 + 0.
|
|
2214
|
+
check(Math.abs(sizeUp - (size0 + 0.1)) < 1e-9, 'ctrl+wheel up makes her a step bigger',
|
|
2025
2215
|
String(size0) + ' -> ' + String(sizeUp))
|
|
2026
2216
|
wheel(100, { ctrlKey: true })
|
|
2027
2217
|
check(Math.abs(ixApi.config.size - size0) < 1e-9, 'and ctrl+wheel down takes it back',
|
|
2028
2218
|
String(ixApi.config.size))
|
|
2029
2219
|
wheel(-100, { metaKey: true })
|
|
2030
|
-
check(Math.abs(ixApi.config.size - (size0 + 0.
|
|
2220
|
+
check(Math.abs(ixApi.config.size - (size0 + 0.1)) < 1e-9, '⌘+wheel does the same on macOS',
|
|
2031
2221
|
String(ixApi.config.size))
|
|
2032
2222
|
wheel(100, { metaKey: true })
|
|
2033
2223
|
|
|
@@ -2107,8 +2297,10 @@ if (clientSrc !== null) {
|
|
|
2107
2297
|
|
|
2108
2298
|
/* 平台事实(源码级查过):宿主半包跑在 vm 沙箱里,harness 只给
|
|
2109
2299
|
{ defineTool, registerTool, handle },沙箱里 nodeApiTraps() 挡住 Node API ——
|
|
2110
|
-
|
|
2111
|
-
|
|
2300
|
+
插件**自己**既不能联网、也不能写文件。但"装"这一步不用她自己做(v1.48.0):
|
|
2301
|
+
平台把插件管理器做成了客户端可用的 Remote 命名空间(ctx.remote.pluginManager),
|
|
2302
|
+
installBundle 交给它就行 —— 平台自己的「插件」设置页走的是同一条路。
|
|
2303
|
+
这一节测能真正做到的那几件事,安装那一段在下面 3d-quindecies-bis。 */
|
|
2112
2304
|
const uv = createHarness({ timer: true, composerText: '', clipboard: true })
|
|
2113
2305
|
const keepUv = active
|
|
2114
2306
|
active = uv
|
|
@@ -2127,9 +2319,12 @@ if (clientSrc !== null) {
|
|
|
2127
2319
|
check(typeof update?.whatsNew === 'string' && update.whatsNew.length > 0,
|
|
2128
2320
|
'the what-is-new note covers the CURRENT version', String(update?.whatsNew))
|
|
2129
2321
|
|
|
2130
|
-
/*
|
|
2131
|
-
|
|
2132
|
-
|
|
2322
|
+
/* 能力边界要如实报告 —— 但 v1.48.0 起"能自己更新"这句**成立了一半**:
|
|
2323
|
+
她自己没有网络也没有 Node,可她能**让平台去装**(remote.pluginManager)。
|
|
2324
|
+
所以这里验的是"她说得准",不是"她说不能"。 */
|
|
2325
|
+
check(update.canSelfUpdate === true && update.install !== undefined,
|
|
2326
|
+
'she no longer claims she cannot install an update — the platform plugin manager can',
|
|
2327
|
+
JSON.stringify({ canSelfUpdate: update.canSelfUpdate, via: update.install?.via }))
|
|
2133
2328
|
check(typeof update.hint === 'string' && update.hint.includes('dsh-wisp'),
|
|
2134
2329
|
'but she does say which package to install', String(update.hint))
|
|
2135
2330
|
check(/沙箱|sandbox/.test(String(update.why)), 'and why — in plain words', String(update.why))
|
|
@@ -2198,11 +2393,136 @@ if (clientSrc !== null) {
|
|
|
2198
2393
|
active = keepHc
|
|
2199
2394
|
}
|
|
2200
2395
|
|
|
2396
|
+
|
|
2397
|
+
/* ---- 3d-quindecies-bis. 直接装上最新版(v1.48.0)-------------------------
|
|
2398
|
+
客户端半包没有 host 座位,但 installUpdate **不需要它**:平台自己的「插件」设置页
|
|
2399
|
+
走的就是 ctx.remote.pluginManager.installBundle。用假命名空间把几种结果走一遍。 */
|
|
2400
|
+
{
|
|
2401
|
+
const inPool = (pool, text) => (Array.isArray(pool) ? pool : [])
|
|
2402
|
+
.some((line) => String(text).indexOf(String(line).split('{')[0]) === 0)
|
|
2403
|
+
const mkInstaller = (services) => createHarness({ timer: true, composerText: '', services })
|
|
2404
|
+
const runInstall = async (services, version) => {
|
|
2405
|
+
const hx = mkInstaller(services)
|
|
2406
|
+
const keep = active
|
|
2407
|
+
active = hx
|
|
2408
|
+
hx.evaluate(clientSrc)
|
|
2409
|
+
hx.module().default.apply(hx.ctx, { reactions: true, wander: false, celebrate: false })
|
|
2410
|
+
hx.advance(1300, 100)
|
|
2411
|
+
const state = await hx.win.__wisp.installUpdate(version)
|
|
2412
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
2413
|
+
const line = String(hx.all('wisp-say').at(-1)?.textContent ?? '')
|
|
2414
|
+
const doctor = hx.win.__wisp.doctor().update.install
|
|
2415
|
+
hx.win.__wisp.destroy()
|
|
2416
|
+
active = keep
|
|
2417
|
+
return { state, line, doctor }
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
let asked = null
|
|
2421
|
+
const applied = await runInstall({
|
|
2422
|
+
'remote.pluginManager': {
|
|
2423
|
+
installBundle: (spec) => { asked = spec; return Promise.resolve({ ok: true, value: { changed: true, application: 'applied', stage: 'install', target: spec } }) },
|
|
2424
|
+
},
|
|
2425
|
+
}, '9.9.9')
|
|
2426
|
+
check(asked === 'dsh-wisp@9.9.9', 'the install asks for exactly the version that was offered', String(asked))
|
|
2427
|
+
check(applied.state.state === 'installed' && applied.state.application === 'applied',
|
|
2428
|
+
'a successful install lands in the installed state', JSON.stringify(applied.state))
|
|
2429
|
+
check(inPool(linesInBundle()?.installDone, applied.line)
|
|
2430
|
+
&& applied.line.includes('9.9.9') && !applied.line.includes('dsh-wisp@'),
|
|
2431
|
+
'and she says it is installed — the bare version, not the dsh-wisp@ spec', applied.line)
|
|
2432
|
+
check(applied.doctor.via === 'remote.pluginManager' && applied.doctor.state === 'installed',
|
|
2433
|
+
'doctor() reports the channel and the last install result', JSON.stringify(applied.doctor))
|
|
2434
|
+
|
|
2435
|
+
const restart = await runInstall({
|
|
2436
|
+
'remote.pluginManager': {
|
|
2437
|
+
installBundle: (spec) => Promise.resolve({ ok: true, value: { changed: true, application: 'restart-required', stage: 'install', target: spec } }),
|
|
2438
|
+
},
|
|
2439
|
+
}, '9.9.9')
|
|
2440
|
+
check(restart.state.state === 'installed' && restart.state.application === 'restart-required'
|
|
2441
|
+
&& inPool(linesInBundle()?.installRestart, restart.line),
|
|
2442
|
+
'when the platform says a restart is required she says exactly that, not "done"',
|
|
2443
|
+
restart.line)
|
|
2444
|
+
|
|
2445
|
+
const refused = await runInstall({
|
|
2446
|
+
'remote.pluginManager': {
|
|
2447
|
+
installBundle: () => Promise.resolve({ ok: false, error: { code: 'no-matching-version' } }),
|
|
2448
|
+
},
|
|
2449
|
+
}, '9.9.9')
|
|
2450
|
+
check(refused.state.state === 'failed' && refused.state.reason === 'no-matching-version'
|
|
2451
|
+
&& inPool(linesInBundle()?.installNotPublished, refused.line) && refused.line.includes('9.9.9'),
|
|
2452
|
+
'a registry that does not have that version yet is said in its own words',
|
|
2453
|
+
refused.line)
|
|
2454
|
+
|
|
2455
|
+
const blewUp = await runInstall({
|
|
2456
|
+
'remote.pluginManager': { installBundle: () => { throw new Error('boom') } },
|
|
2457
|
+
}, '9.9.9')
|
|
2458
|
+
check(blewUp.state.state === 'failed' && blewUp.state.reason === 'call-failed'
|
|
2459
|
+
&& blewUp.state.detail === 'boom' && inPool(linesInBundle()?.installFailed, blewUp.line),
|
|
2460
|
+
'a throwing install is caught and reported in plain words', JSON.stringify(blewUp.state))
|
|
2461
|
+
|
|
2462
|
+
const noManager = await runInstall({}, '9.9.9')
|
|
2463
|
+
check(noManager.state.state === 'unsupported' && noManager.doctor.via === null
|
|
2464
|
+
&& inPool(linesInBundle()?.installUnsupported, noManager.line),
|
|
2465
|
+
'a shell without the plugin manager is its own sentence, never a silent no-op',
|
|
2466
|
+
noManager.line)
|
|
2467
|
+
|
|
2468
|
+
/* 弹窗里的那一步:查到新版 → 「安装 vX」按钮 → 点它真的按那一版去装 */
|
|
2469
|
+
{
|
|
2470
|
+
let clicked = null
|
|
2471
|
+
const dlgHx = createHarness({
|
|
2472
|
+
timer: true, composerText: '',
|
|
2473
|
+
services: { 'remote.pluginManager': { installBundle: (spec) => { clicked = spec; return Promise.resolve({ ok: true, value: { changed: true, application: 'applied', stage: 'install', target: spec } }) } } },
|
|
2474
|
+
})
|
|
2475
|
+
const keepDlgHx = active
|
|
2476
|
+
active = dlgHx
|
|
2477
|
+
dlgHx.win.fetch = (url) => Promise.resolve({
|
|
2478
|
+
ok: true,
|
|
2479
|
+
json: () => Promise.resolve({ version: '9.9.9' }),
|
|
2480
|
+
})
|
|
2481
|
+
dlgHx.evaluate(clientSrc)
|
|
2482
|
+
dlgHx.module().default.apply(dlgHx.ctx, { reactions: false, wander: false, celebrate: false })
|
|
2483
|
+
dlgHx.advance(1300, 100)
|
|
2484
|
+
const dlgApi2 = dlgHx.win.__wisp
|
|
2485
|
+
await dlgApi2.checkForUpdate()
|
|
2486
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
2487
|
+
dlgApi2.openAbout()
|
|
2488
|
+
const actions = dlgHx.all('wisp-dialog-action').filter((el) => el.removed !== true)
|
|
2489
|
+
const installBtn = actions.find((el) => String(el.textContent).includes('安装 v9.9.9'))
|
|
2490
|
+
check(installBtn !== undefined,
|
|
2491
|
+
'once a newer version is known, the about dialog offers an install button for THAT version',
|
|
2492
|
+
actions.map((el) => el.textContent).join(' | '))
|
|
2493
|
+
if (installBtn !== undefined) installBtn.dispatch('click', { preventDefault() {}, stopPropagation() {} })
|
|
2494
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
2495
|
+
check(clicked === 'dsh-wisp@9.9.9',
|
|
2496
|
+
'and clicking it installs that very version through the plugin manager', String(clicked))
|
|
2497
|
+
check(dlgApi2.dialog === null, 'the dialog gets out of the way while it installs', String(dlgApi2.dialog))
|
|
2498
|
+
dlgHx.win.__wisp.destroy()
|
|
2499
|
+
active = keepDlgHx
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
/* 英文覆盖层:安装这几句也要有,而且一个汉字都不许有 */
|
|
2503
|
+
const enInstallKeys = ['installChecking', 'installDone', 'installRestart', 'installPending',
|
|
2504
|
+
'installCancelled', 'installNotPublished', 'installFailed', 'installUnsupported']
|
|
2505
|
+
const enInstallBundle = readFileSync(join(here, 'lib', 'client.js'), 'utf8')
|
|
2506
|
+
const enInstallLiteral = enInstallBundle.match(/const LINES_EN = (\{[\s\S]*?\n {4}\})/)
|
|
2507
|
+
let enInstallPools = null
|
|
2508
|
+
try { enInstallPools = enInstallLiteral ? new Function('return ' + enInstallLiteral[1])() : null } catch (error) { enInstallPools = null }
|
|
2509
|
+
const enInstallMissing = enInstallKeys.filter((k) => !Array.isArray(enInstallPools?.[k]) || enInstallPools[k].length === 0)
|
|
2510
|
+
check(enInstallMissing.length === 0, 'every install line has an English pool',
|
|
2511
|
+
enInstallMissing.length ? 'missing ' + enInstallMissing.join(', ') : enInstallKeys.length + ' pools')
|
|
2512
|
+
const enInstallHan = []
|
|
2513
|
+
for (const key of enInstallKeys) {
|
|
2514
|
+
for (const line of (enInstallPools?.[key] ?? [])) if (/[\u4e00-\u9fff]/.test(line)) enInstallHan.push(key + ': ' + line)
|
|
2515
|
+
}
|
|
2516
|
+
check(enInstallHan.length === 0, 'and none of them contains a Han character', enInstallHan.join(' | ') || 'clean')
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2201
2519
|
/* 失败时也要能追问:doctor() 必须留下**每个源各自的**原因,
|
|
2202
2520
|
否则"没查成"就是一句没法追的话(这正是用户实际遇到的情形)。 */
|
|
2203
2521
|
const diagHarness = mkChecker(async () => ({
|
|
2204
2522
|
ok: false,
|
|
2205
2523
|
reason: 'no-web-service',
|
|
2524
|
+
/* 宿主现在会一起带回"它看见了什么" —— 失败台词背后的细节靠这一段。 */
|
|
2525
|
+
diag: { via: 'none', how: 'none', why: 'ctx.get("web")→undefined; ctx.web→undefined', manager: 'no-plugin-manager' },
|
|
2206
2526
|
sources: {
|
|
2207
2527
|
npm: { ok: false, reason: 'no-web-service' },
|
|
2208
2528
|
github: { ok: false, reason: 'fetch-failed', detail: 'getaddrinfo ENOTFOUND' },
|
|
@@ -2220,6 +2540,19 @@ if (clientSrc !== null) {
|
|
|
2220
2540
|
&& diagLast.sources.github.detail === 'getaddrinfo ENOTFOUND',
|
|
2221
2541
|
'a failed check keeps every source reason, so doctor() can say WHY it failed',
|
|
2222
2542
|
JSON.stringify(diagLast))
|
|
2543
|
+
|
|
2544
|
+
/* 台词是给人读的一句话,技术细节不进嘴;但细节必须看得见 —— 失败时排进弹窗。
|
|
2545
|
+
(真机上出现过台词后面挂一串 ctx.get(...)→undefined 的英文碎片。) */
|
|
2546
|
+
diagHarness.win.__wisp.showUpdate()
|
|
2547
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
2548
|
+
diagHarness.advance(200, 50)
|
|
2549
|
+
const failSaid = String(diagHarness.all('wisp-say').at(-1)?.textContent ?? '')
|
|
2550
|
+
check(!/[A-Za-z]{3,}/.test(failSaid),
|
|
2551
|
+
'the failure line stays human — no technical fragments in the spoken sentence', failSaid)
|
|
2552
|
+
const diagDialog = diagHarness.all('wisp-dialog-p').map((el) => el.textContent).join('\n')
|
|
2553
|
+
check(diagDialog.includes('看到的东西') && diagDialog.includes('取服务的方式'),
|
|
2554
|
+
'and the dialog spells out what the host actually saw',
|
|
2555
|
+
diagDialog.replace(/\n/g, ' | ').slice(0, 120))
|
|
2223
2556
|
diagHarness.win.__wisp.destroy()
|
|
2224
2557
|
active = keepDiag
|
|
2225
2558
|
|
|
@@ -2258,6 +2591,27 @@ if (clientSrc !== null) {
|
|
|
2258
2591
|
check(new Set([noChannelSaid, noHostSaid, networkSaid]).size === 3,
|
|
2259
2592
|
'the three failure kinds really are three different sentences')
|
|
2260
2593
|
|
|
2594
|
+
/* "没接上宿主"和"宿主拿不到服务"是链路上**不同的一环**断了 —— 必须分开说,
|
|
2595
|
+
否则那句话永远说不清断在哪(1.43.3 之前两者共用同一句,真机上就卡在这)。 */
|
|
2596
|
+
const noSeat = createHarness({ timer: true, composerText: '' }) // 默认替身没有 host 座位
|
|
2597
|
+
const keepNoSeat = active
|
|
2598
|
+
active = noSeat
|
|
2599
|
+
noSeat.evaluate(clientSrc)
|
|
2600
|
+
noSeat.module().default.apply(noSeat.ctx, { reactions: false, wander: false, celebrate: false })
|
|
2601
|
+
noSeat.advance(1000, 100)
|
|
2602
|
+
const noSeatRes = await noSeat.win.__wisp.checkForUpdate()
|
|
2603
|
+
noSeat.win.__wisp.showUpdate()
|
|
2604
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
2605
|
+
noSeat.advance(200, 50)
|
|
2606
|
+
const noSeatSaid = String(noSeat.all('wisp-say').at(-1)?.textContent ?? '')
|
|
2607
|
+
check(noSeatRes.state === 'failed' && noSeatRes.reason === 'fetch-failed',
|
|
2608
|
+
'no host seat at all gets its OWN sentence — the first link in the chain',
|
|
2609
|
+
`${noSeatRes.state} | ${noSeatSaid}`)
|
|
2610
|
+
check(noSeatSaid !== noChannelSaid,
|
|
2611
|
+
'and it is distinguishable from "the host ran but has no web service"', `${noSeatSaid} ≠ ${noChannelSaid}`)
|
|
2612
|
+
noSeat.win.__wisp.destroy()
|
|
2613
|
+
active = keepNoSeat
|
|
2614
|
+
|
|
2261
2615
|
/* 宿主调用抛错:不能变成未处理的 rejection */
|
|
2262
2616
|
const thrower = mkChecker('boom')
|
|
2263
2617
|
const keepThrower = active
|
|
@@ -2279,8 +2633,9 @@ if (clientSrc !== null) {
|
|
|
2279
2633
|
noHost.module().default.apply(noHost.ctx, { reactions: true, wander: false, celebrate: false })
|
|
2280
2634
|
noHost.advance(1300, 100)
|
|
2281
2635
|
const unsupported = await noHost.win.__wisp.checkForUpdate()
|
|
2282
|
-
check(
|
|
2283
|
-
'with no host seat
|
|
2636
|
+
check(noSeatRes.state === 'failed' && noSeatRes.reason === 'fetch-failed',
|
|
2637
|
+
'with no host seat the CLIENT half falls back to its own fetch (no more unsupported)',
|
|
2638
|
+
JSON.stringify(noSeatRes))
|
|
2284
2639
|
const stillWorks = noHost.win.__wisp.showUpdate()
|
|
2285
2640
|
check(stillWorks.version === pkgVersion && stillWorks.checking === true,
|
|
2286
2641
|
'and the update entry still works without it', JSON.stringify(stillWorks))
|
|
@@ -2325,8 +2680,10 @@ if (clientSrc !== null) {
|
|
|
2325
2680
|
/* ------------- 3d-sexdecies. she can look up your balance ----------------- */
|
|
2326
2681
|
head('3d-sexdecies. she can look up your balance, and says which kind of "no" it is')
|
|
2327
2682
|
|
|
2328
|
-
/*
|
|
2329
|
-
|
|
2683
|
+
/* 余额有两条通道,读的是**同一个**平台账户服务(凭据留在宿主手里):
|
|
2684
|
+
① 客户端 Remote(默认,bundle 形态只有这条真的通);
|
|
2685
|
+
② host 座位(动态插件形态才有),这边用 host.call 问宿主半包。
|
|
2686
|
+
本节先测 host 座位那条(假座位),再测 Remote 那条(下面 3d-septendecies)。
|
|
2330
2687
|
四种"没读到"必须是四句不同的话 —— 全说成"查不到"就是在骗人。 */
|
|
2331
2688
|
const balanceHarness = (reply) => createHarness({
|
|
2332
2689
|
timer: true, composerText: '',
|
|
@@ -2438,6 +2795,126 @@ if (clientSrc !== null) {
|
|
|
2438
2795
|
balThrower.win.__wisp.destroy()
|
|
2439
2796
|
active = keepBalThrower
|
|
2440
2797
|
|
|
2798
|
+
/* ------------- 3d-septendecies. the balance channel that really exists ------
|
|
2799
|
+
平台自己的「账户」设置页走的是 ctx.remote.account.*(Remote,信封 { ok, value })。
|
|
2800
|
+
bundle 形态的客户端半包没有 host 座位,所以**这才**是真机上那条路 ——
|
|
2801
|
+
1.46.0 之前它根本没被走过,菜单里点一下永远回"这个壳里查不了余额"。 */
|
|
2802
|
+
const runRemote = async (account, hostCall) => {
|
|
2803
|
+
const hx = createHarness({
|
|
2804
|
+
timer: true, composerText: '',
|
|
2805
|
+
...(hostCall ? { hostCall } : {}),
|
|
2806
|
+
services: { 'remote.account': account },
|
|
2807
|
+
})
|
|
2808
|
+
const keep = active
|
|
2809
|
+
active = hx
|
|
2810
|
+
hx.evaluate(clientSrc)
|
|
2811
|
+
hx.module().default.apply(hx.ctx, { reactions: true, wander: false, celebrate: false })
|
|
2812
|
+
hx.advance(1300, 100)
|
|
2813
|
+
const res = await hx.win.__wisp.checkBalance()
|
|
2814
|
+
const line = await spoken(hx)
|
|
2815
|
+
const doctor = hx.win.__wisp.doctor().balance
|
|
2816
|
+
hx.win.__wisp.destroy()
|
|
2817
|
+
active = keep
|
|
2818
|
+
return { res, line, doctor }
|
|
2819
|
+
}
|
|
2820
|
+
|
|
2821
|
+
let seenClient = null
|
|
2822
|
+
const remoteReady = await runRemote({
|
|
2823
|
+
getBalance: async (client) => {
|
|
2824
|
+
seenClient = client
|
|
2825
|
+
return {
|
|
2826
|
+
ok: true,
|
|
2827
|
+
value: { status: 'ready', value: [{ currency: 'CNY', balance: '110.00' }], bonusWallets: [{ currency: 'CNY', balance: '10.00' }] },
|
|
2828
|
+
}
|
|
2829
|
+
},
|
|
2830
|
+
getState: async () => ({ ok: true, value: { status: 'credential-stored' } }),
|
|
2831
|
+
})
|
|
2832
|
+
check(remoteReady.res.state === 'ready' && remoteReady.res.wallets[0].balance === '110.00'
|
|
2833
|
+
&& remoteReady.res.bonusWallets[0].balance === '10.00',
|
|
2834
|
+
'the account Remote is a real channel: a wrapped ready answer lands in the ready state',
|
|
2835
|
+
JSON.stringify(remoteReady.res))
|
|
2836
|
+
check(remoteReady.line.includes('¥110.00') && fromPool(linesInBundle()?.balance, remoteReady.line, '¥110.00')
|
|
2837
|
+
&& remoteReady.line.includes('¥10.00'),
|
|
2838
|
+
'and she says the amount, credit part included', remoteReady.line)
|
|
2839
|
+
/* 服务端**没有**任何默认值:不传身份它直接 TypeError。这条因此必须钉住。 */
|
|
2840
|
+
check(seenClient !== null && seenClient.version === pkg.version
|
|
2841
|
+
&& typeof seenClient.locale === 'string' && seenClient.locale !== ''
|
|
2842
|
+
&& typeof seenClient.timezoneOffsetSeconds === 'number' && Number.isFinite(seenClient.timezoneOffsetSeconds),
|
|
2843
|
+
'the Remote call carries this call\'s own identity (version / locale / UTC offset)',
|
|
2844
|
+
JSON.stringify(seenClient))
|
|
2845
|
+
check(remoteReady.doctor.remoteSeat === true && remoteReady.doctor.via === 'remote',
|
|
2846
|
+
'doctor() reports the Remote seat and which channel this read took',
|
|
2847
|
+
JSON.stringify(remoteReady.doctor))
|
|
2848
|
+
|
|
2849
|
+
/* 两条通道都在 → 走平台原生那条 Remote;host 座位只当兜底 */
|
|
2850
|
+
const bothSeats = await runRemote(
|
|
2851
|
+
{
|
|
2852
|
+
getBalance: async () => ({ ok: true, value: { status: 'ready', value: [{ currency: 'CNY', balance: '110.00' }], bonusWallets: [] } }),
|
|
2853
|
+
getState: async () => ({ ok: true, value: { status: 'credential-stored' } }),
|
|
2854
|
+
},
|
|
2855
|
+
async () => ({ ok: true, status: 'ready', wallets: [{ currency: 'CNY', balance: '999.00' }], bonusWallets: [] }),
|
|
2856
|
+
)
|
|
2857
|
+
check(bothSeats.res.state === 'ready' && bothSeats.res.wallets[0].balance === '110.00'
|
|
2858
|
+
&& bothSeats.doctor.via === 'remote' && bothSeats.doctor.hostSeat === true,
|
|
2859
|
+
'with both seats live the platform Remote wins, and the host seat stays declared',
|
|
2860
|
+
JSON.stringify(bothSeats.doctor))
|
|
2861
|
+
|
|
2862
|
+
const remoteSignedOut = await runRemote({
|
|
2863
|
+
getBalance: async () => ({ ok: true, value: null }),
|
|
2864
|
+
getState: async () => ({ ok: true, value: { status: 'signed-out' } }),
|
|
2865
|
+
})
|
|
2866
|
+
check(remoteSignedOut.res.state === 'signed-out'
|
|
2867
|
+
&& fromPool(linesInBundle()?.balanceSignedOut, remoteSignedOut.line, ''),
|
|
2868
|
+
'a null balance while signed out is reported as signed out', remoteSignedOut.line)
|
|
2869
|
+
|
|
2870
|
+
const remoteMoved = await runRemote({
|
|
2871
|
+
getBalance: async () => ({ ok: true, value: null }),
|
|
2872
|
+
getState: async () => ({ ok: true, value: { status: 'credential-stored' } }),
|
|
2873
|
+
})
|
|
2874
|
+
check(remoteMoved.res.state === 'unavailable'
|
|
2875
|
+
&& fromPool(linesInBundle()?.balanceUnavailable, remoteMoved.line, ''),
|
|
2876
|
+
'a null balance while STILL signed in is "try again", not "log in"', remoteMoved.line)
|
|
2877
|
+
|
|
2878
|
+
const remoteRefused = await runRemote({ getBalance: async () => ({ ok: false, reason: 'disconnected' }) })
|
|
2879
|
+
check(remoteRefused.res.state === 'failed' && remoteRefused.res.reason === 'disconnected'
|
|
2880
|
+
&& fromPool(linesInBundle()?.balanceFailed, remoteRefused.line, ''),
|
|
2881
|
+
'a refused envelope is a failure and keeps the reason', JSON.stringify(remoteRefused.res))
|
|
2882
|
+
|
|
2883
|
+
const remoteFailed = await runRemote({
|
|
2884
|
+
getBalance: async () => ({ ok: true, value: { status: 'failed' } }),
|
|
2885
|
+
getState: async () => ({ ok: true, value: { status: 'credential-stored' } }),
|
|
2886
|
+
})
|
|
2887
|
+
check(remoteFailed.res.state === 'failed' && remoteFailed.res.reason === 'service-failed'
|
|
2888
|
+
&& !/110\.00/.test(remoteFailed.line),
|
|
2889
|
+
'a platform "failed" inside the envelope never becomes a zero balance', JSON.stringify(remoteFailed.res))
|
|
2890
|
+
|
|
2891
|
+
const remoteThrew = await runRemote({ getBalance: async () => { throw new Error('boom') } })
|
|
2892
|
+
check(remoteThrew.res.state === 'failed' && remoteThrew.res.reason === 'call-failed'
|
|
2893
|
+
&& remoteThrew.res.detail === 'boom',
|
|
2894
|
+
'a throwing Remote call is caught and reported', JSON.stringify(remoteThrew.res))
|
|
2895
|
+
|
|
2896
|
+
/* 一个永远不回答的 Remote(连接断了但 promise 不落地):不许让气泡停在"看一下……"。
|
|
2897
|
+
服务和宿主半包那条路一样不接受 AbortSignal,只能自己竞速 —— 8 秒。 */
|
|
2898
|
+
{
|
|
2899
|
+
const hung = createHarness({
|
|
2900
|
+
timer: true, composerText: '',
|
|
2901
|
+
services: { 'remote.account': { getBalance: () => new Promise(() => {}) } },
|
|
2902
|
+
})
|
|
2903
|
+
const keepHung = active
|
|
2904
|
+
active = hung
|
|
2905
|
+
hung.evaluate(clientSrc)
|
|
2906
|
+
hung.module().default.apply(hung.ctx, { reactions: true, wander: false, celebrate: false })
|
|
2907
|
+
hung.advance(1300, 100)
|
|
2908
|
+
const pending = hung.win.__wisp.checkBalance()
|
|
2909
|
+
hung.advance(9000, 250)
|
|
2910
|
+
const res = await pending
|
|
2911
|
+
check(res.state === 'failed' && res.reason === 'call-failed' && res.detail === 'timeout',
|
|
2912
|
+
'a Remote call that never answers is cut off instead of hanging the bubble',
|
|
2913
|
+
JSON.stringify(res))
|
|
2914
|
+
hung.win.__wisp.destroy()
|
|
2915
|
+
active = keepHung
|
|
2916
|
+
}
|
|
2917
|
+
|
|
2441
2918
|
/* 英文覆盖层:余额这几句也要有,而且一个汉字都不许有 —— 1.36 的英文是覆盖层,
|
|
2442
2919
|
新加的池子漏翻就会让英文界面的人看到中文。 */
|
|
2443
2920
|
const enBundle = readFileSync(join(here, 'lib', 'client.js'), 'utf8')
|
|
@@ -2826,7 +3303,515 @@ if (clientSrc !== null) {
|
|
|
2826
3303
|
`pointermove=${mo.win.listenerCount('pointermove')} blur=${mo.win.listenerCount('blur')}`)
|
|
2827
3304
|
active = keepMo
|
|
2828
3305
|
|
|
3306
|
+
/* ---------------- 3f-ter. 帧动画(v1.45.0;v1.46.1 起是动图 <img>;v1.46.5 起两条)-- */
|
|
3307
|
+
head('3f-ter. the frame animations: animated WebPs that step aside to freeze')
|
|
3308
|
+
|
|
3309
|
+
/* 这一段测的是"叠加在立绘上的那层动图",而它有四个**只有行为能证明**的点:
|
|
3310
|
+
与立绘同一格、不吃指针、显示时立绘退场(v1.45.2)、以及"该冻结时它藏起来、
|
|
3311
|
+
画面回到立绘"(v1.46.1:<img> 上的动图停不下来,藏起来是唯一的冻结方式)。
|
|
3312
|
+
单开一个替身:这里要真的睡着(sleepAfterMs 在别的替身里是一小时,
|
|
3313
|
+
而直接 mood('sleep') 更直接,也不会打乱别人的时间轴)。 */
|
|
3314
|
+
const mv = createHarness({ timer: true, composerText: '' })
|
|
3315
|
+
const keepMv = active
|
|
3316
|
+
active = mv
|
|
3317
|
+
mv.evaluate(clientSrc)
|
|
3318
|
+
mv.module().default.apply(mv.ctx, {
|
|
3319
|
+
reactions: false, wander: false, celebrate: false, sleepAfterMs: 3600000,
|
|
3320
|
+
})
|
|
3321
|
+
mv.advance(1200, 100)
|
|
3322
|
+
const mvApi = mv.win.__wisp
|
|
3323
|
+
const mvRoot = mv.find('wisp-root')
|
|
3324
|
+
const mvLean = mv.find('wisp-lean')
|
|
3325
|
+
const mvLive = () => mv.all('wisp-video').find((el) => el.removed !== true) ?? null
|
|
3326
|
+
|
|
3327
|
+
/* 静态立绘"看不看得见"(v1.45.2)在替身里由**两件事**共同决定:
|
|
3328
|
+
① 根上的 data-frame —— 行为,syncMotion() 写的那个属性;属性不写 = 退场没发生。
|
|
3329
|
+
② 样式表里把属性翻译成 visibility:hidden 的那条规则 —— 映射;规则没了,
|
|
3330
|
+
属性写了也白写(画面照旧两层)。
|
|
3331
|
+
行内那一份是样式表缺失时的兜底,也是同一件事的第二个落点。
|
|
3332
|
+
替身没有级联引擎,所以这个 helper 把三者合成"浏览器会算出来的值",
|
|
3333
|
+
而每一条断言仍然把三者**分开点名** —— 合成值对了、其中一条断了,一样要红。 */
|
|
3334
|
+
const mvFrameRule = (String(mv.styleInserts[0] ?? '').match(/\.wisp-root\[data-frame="on"\][^{]*\{[^}]*\}/) ?? [''])[0]
|
|
3335
|
+
const mvVis = (el) => (
|
|
3336
|
+
String(el?.style?.visibility ?? '') === 'hidden'
|
|
3337
|
+
|| (mvRoot.dataset.frame === 'on' && mvFrameRule.includes('visibility:hidden'))
|
|
3338
|
+
? 'hidden'
|
|
3339
|
+
: 'visible'
|
|
3340
|
+
)
|
|
3341
|
+
|
|
3342
|
+
/* ---- idle 也接上了(v1.46.5):它是**默认**状态,所以动作层从挂载那一刻就在 ----
|
|
3343
|
+
这一段同时证明三件事:素材真的在包里、MOTION_OF 里接上了、syncMotion() 在挂载
|
|
3344
|
+
那一刻真的跑过(在那之前这一层只在进入 sleep 时才建元素)。 */
|
|
3345
|
+
const mvIdle = mvLive()
|
|
3346
|
+
const mvIdleImgs = mvRoot.querySelectorAll('.wisp-img')
|
|
3347
|
+
check(mvIdle !== null && mvIdle.tagName === 'IMG' && mvIdle.dataset.clip === 'idle',
|
|
3348
|
+
'the idle state builds the motion layer at mount — the standing loop is wired in, not merely shipped (v1.46.5)',
|
|
3349
|
+
mvIdle ? `${mvIdle.tagName} clip=${String(mvIdle.dataset.clip)}` : 'no motion layer built while idle')
|
|
3350
|
+
check(mvIdle !== null && mvIdle.style.display !== 'none' && mvIdleImgs.length === 1
|
|
3351
|
+
&& mvVis(mvIdleImgs[0]) === 'hidden' && mvRoot.dataset.frame === 'on',
|
|
3352
|
+
'and it owns the picture from the first frame — the sprite is out of the way, never double-exposed (v1.45.2 rules, idle edition)',
|
|
3353
|
+
`display=${mvIdle?.style.display} sprites=${mvIdleImgs.length} sprite=${mvVis(mvIdleImgs[0])} data-frame=${String(mvRoot.dataset.frame)}`)
|
|
3354
|
+
/* idle **不带**尺寸校正(差 1.0~1.6%,小于 2% 那条线):行内必须是 none。
|
|
3355
|
+
这一条和下面 sleepy 那条 1.186 是**分开点名**的 —— 只查一条的话,"base 那条
|
|
3356
|
+
1.186 落到 idle 头上"(她会被放大 18.6%)这种错会整段漏过去。 */
|
|
3357
|
+
check(mvIdle !== null && mvIdle.style.transform === 'none',
|
|
3358
|
+
'the idle clip carries NO size correction — she fills 97.2% of that frame vs 98.2% of the sprite, under the 2% line (v1.46.5)',
|
|
3359
|
+
`inline transform=${JSON.stringify(mvIdle?.style.transform)}`)
|
|
3360
|
+
|
|
3361
|
+
/* 没有动作素材的状态**连元素都不建**:摆一个永远不动、还要白解 3 MB 的空盒子,
|
|
3362
|
+
代价是真的,收益是零。idle 接上之后,"默认那一档没有素材"在这个包里再也造不出来
|
|
3363
|
+
—— 所以把 MOTION 表里 idle 那一条**删掉**再挂一次:那才是真的"这个状态没有素材"。
|
|
3364
|
+
判据在 syncMotion() 里(查不到 key 就不建元素),不是"素材恰好缺失"。
|
|
3365
|
+
v1.47.0 起表里那条长这样:`"idle": 'idle.webp',` —— 删的是**清单里的一行**,
|
|
3366
|
+
不是一段 base64(投递改了,这一段测试的意图没变)。 */
|
|
3367
|
+
const noIdleSrc = String(clientSrc).replace(/^\s*"idle": '[^']*',$/m, '')
|
|
3368
|
+
const mvNone = createHarness({ timer: true, composerText: '' })
|
|
3369
|
+
active = mvNone
|
|
3370
|
+
mvNone.evaluate(noIdleSrc)
|
|
3371
|
+
mvNone.module().default.apply(mvNone.ctx, {
|
|
3372
|
+
reactions: false, wander: false, celebrate: false, sleepAfterMs: 3600000,
|
|
3373
|
+
})
|
|
3374
|
+
mvNone.advance(1200, 100)
|
|
3375
|
+
const mvNoneRoot = mvNone.find('wisp-root')
|
|
3376
|
+
check(noIdleSrc !== clientSrc
|
|
3377
|
+
&& mvNoneRoot.querySelectorAll('.wisp-video').length === 0 && mvNoneRoot.querySelectorAll('video').length === 0,
|
|
3378
|
+
'a mood with no clip does not even build the motion layer — no element, no base64 decode',
|
|
3379
|
+
`${mvNoneRoot.querySelectorAll('.wisp-video').length} motion layer(s) with the clip removed from the table`)
|
|
3380
|
+
/* 没有素材的状态:立绘就是画面里的那个人,而且**没有第二个"她"** —— 动图不建、属性不挂。 */
|
|
3381
|
+
const mvNoneImgs = mvNoneRoot.querySelectorAll('.wisp-img')
|
|
3382
|
+
check(mvNoneImgs.length === 1 && String(mvNoneImgs[0]?.style?.visibility ?? '') !== 'hidden'
|
|
3383
|
+
&& mvNoneRoot.dataset.frame === undefined,
|
|
3384
|
+
'with no clip the frame never takes the picture: one visible sprite, no "frame owns it" attribute (v1.45.2)',
|
|
3385
|
+
`sprites=${mvNoneImgs.length} inline=${String(mvNoneImgs[0]?.style?.visibility ?? '') || '(unset)'} data-frame=${String(mvNoneRoot.dataset.frame)}`)
|
|
3386
|
+
const mvNoneApi = mvNone.win.__wisp
|
|
3387
|
+
check(mvNoneApi.doctor().motion.frame.built === false && mvNoneApi.doctor().motion.frame.asset === null,
|
|
3388
|
+
'and doctor() says so: nothing built, no clip for this state',
|
|
3389
|
+
JSON.stringify(mvNoneApi.doctor().motion.frame))
|
|
3390
|
+
mvNoneApi.destroy()
|
|
3391
|
+
active = mv
|
|
3392
|
+
|
|
3393
|
+
/* ---- 换状态 = 换素材源,同一个元素(v1.46.5)----
|
|
3394
|
+
两段素材都进包之后,这条才有内容:一次性实现的陷阱是"只在第一次建元素、
|
|
3395
|
+
之后再也不改 src" —— 那样她睡着以后还在放站着的循环,而画面上看不出错。 */
|
|
3396
|
+
const mvIdleSrc = String(mvIdle.src)
|
|
3397
|
+
mvApi.mood('sleep')
|
|
3398
|
+
mv.advance(400, 100)
|
|
3399
|
+
check(mvLive() === mvIdle && mvIdle.dataset.clip === 'sleepy'
|
|
3400
|
+
&& String(mvIdle.src) !== mvIdleSrc && String(mvIdle.src) === 'http://127.0.0.1:19387/wisp-motion/sleepy.webp',
|
|
3401
|
+
'switching state re-points the SAME element at the other clip — the layer is not a one-shot build (v1.46.5)',
|
|
3402
|
+
`reused=${mvLive() === mvIdle} clip=${String(mvIdle.dataset.clip)} srcChanged=${String(mvIdle.src) !== mvIdleSrc} src=${String(mvIdle.src)}`)
|
|
3403
|
+
mvApi.mood('idle')
|
|
3404
|
+
mv.advance(400, 100)
|
|
3405
|
+
check(mvLive() === mvIdle && mvIdle.dataset.clip === 'idle' && String(mvIdle.src) === mvIdleSrc,
|
|
3406
|
+
'and switching back re-points it at the standing loop again — the source follows every transition, not just the first',
|
|
3407
|
+
`reused=${mvLive() === mvIdle} clip=${String(mvIdle.dataset.clip)} srcBack=${String(mvIdle.src) === mvIdleSrc}`)
|
|
3408
|
+
mvApi.mood('sleep')
|
|
3409
|
+
mv.advance(400, 100)
|
|
3410
|
+
check(mvIdle.style.display !== 'none' && mvIdle.dataset.clip === 'sleepy',
|
|
3411
|
+
'and the sleeping loop is what is on screen after the round trip',
|
|
3412
|
+
`display=${mvIdle.style.display || '(default)'} clip=${String(mvIdle.dataset.clip)}`)
|
|
3413
|
+
|
|
3414
|
+
const mvMotion = mvLive()
|
|
3415
|
+
const mvImgs = mvLean.querySelectorAll('.wisp-img')
|
|
3416
|
+
const mvImg = mvImgs[0] ?? null
|
|
3417
|
+
check(mvMotion !== null && mvMotion.tagName === 'IMG',
|
|
3418
|
+
'a mood that HAS a clip builds an <img> overlay — the picture pipeline, not a media pipeline',
|
|
3419
|
+
String(mvMotion?.tagName))
|
|
3420
|
+
/* v1.46.1 的核心:**一个 <video> 都不该存在**。这个壳不还原 VP9 的 alpha,
|
|
3421
|
+
而图片路径的 alpha 是硬的(见 build.mjs 的 motion 段与 WHATS_NEW)。 */
|
|
3422
|
+
check(mvRoot.querySelectorAll('video').length === 0,
|
|
3423
|
+
'and not a single <video> is built anywhere in the tree — the alpha has to come from the image path',
|
|
3424
|
+
`${mvRoot.querySelectorAll('video').length} video element(s)`)
|
|
3425
|
+
check(mvMotion !== null && mvMotion.parentNode === mvLean && mvImgs.length === 1,
|
|
3426
|
+
'it sits in the same layer as the sprite, next to it — so it tilts and squashes with her',
|
|
3427
|
+
`motion in ${String(mvMotion?.parentNode?.className)}, ${mvImgs.length} sprite layer(s)`)
|
|
3428
|
+
/* 与立绘**逐条同款**的行内盒模型:另起一套定位,就会出现"动起来时她跳了一下"
|
|
3429
|
+
—— 那是只有真机上才看得见的错位。 */
|
|
3430
|
+
const inlineBoxOf = (el) => ['position', 'top', 'left', 'width', 'height', 'objectFit', 'objectPosition']
|
|
3431
|
+
.map((k) => String(el?.style?.[k] ?? ''))
|
|
3432
|
+
const spriteBox = inlineBoxOf(mvImg)
|
|
3433
|
+
const motionBox = inlineBoxOf(mvMotion)
|
|
3434
|
+
check(mvImg !== null && motionBox.join('|') === spriteBox.join('|')
|
|
3435
|
+
&& motionBox[0] === 'absolute' && motionBox[3] === '100%' && motionBox[5] === 'contain'
|
|
3436
|
+
&& motionBox[6] === 'bottom center',
|
|
3437
|
+
'and it is laid out exactly like the sprite it overlays (inset 0 / 100% / contain / bottom center)',
|
|
3438
|
+
`motion[${motionBox.join(',')}] vs sprite[${spriteBox.join(',')}]`)
|
|
3439
|
+
|
|
3440
|
+
check(mvMotion !== null && mvMotion.getAttribute('alt') === '' && mvMotion.getAttribute('aria-hidden') === 'true'
|
|
3441
|
+
&& mvMotion.draggable === false,
|
|
3442
|
+
'and it is marked as decoration: empty alt, aria-hidden, not draggable',
|
|
3443
|
+
`alt=${JSON.stringify(mvMotion?.getAttribute('alt'))} aria-hidden=${JSON.stringify(mvMotion?.getAttribute('aria-hidden'))} draggable=${String(mvMotion?.draggable)}`)
|
|
3444
|
+
|
|
3445
|
+
const mvCss = String(mv.styleInserts[0] ?? '')
|
|
3446
|
+
const mvRule = (mvCss.match(/\.wisp-video\{[^}]*\}/) ?? [''])[0]
|
|
3447
|
+
check(mvMotion !== null && mvMotion.style.pointerEvents === 'none' && mvRule.includes('pointer-events:none'),
|
|
3448
|
+
'the motion layer never takes a pointer event — her silhouette hit layer still owns every click',
|
|
3449
|
+
`inline=${mvMotion?.style.pointerEvents} rule=${mvRule.slice(0, 60)}…`)
|
|
3450
|
+
/* 尺寸校正(v1.46.4):动作层必须带那条 transform —— 少了它,"画面交给她"的那一刻
|
|
3451
|
+
她比立绘**小 15.7%**(实测包围盒:动图 0.8352 个盒高 vs 立绘 0.9883)。
|
|
3452
|
+
两条落点都点名:样式表给正常壳,行内给"样式表没插进来"的壳(精灵图 / 两层盒子 /
|
|
3453
|
+
动图各有前例)。transform-origin 必须是**脚底**(50% 100%):缩放不挪底边,
|
|
3454
|
+
底边距才收得回来,"她落地"这件事才对得上。
|
|
3455
|
+
这几个数是**推导出来的**,不是抄的:放大倍率 = 0.9883/0.8352 = 1.1834(取 1.186,
|
|
3456
|
+
97 帧之间包围盒自己抖 ±0.2%);下移量 t 由 0.0883*s - s*t = 0.0117 反解 = 7.84%。
|
|
3457
|
+
详见 client.template.js 里 .wisp-video 上那段推导。 */
|
|
3458
|
+
const mvZoomRule = /transform:scale\(1\.186\)\s*translateY\(7\.84%\)/.test(mvRule)
|
|
3459
|
+
const mvZoomOrigin = /transform-origin:50% 100%/.test(mvRule)
|
|
3460
|
+
check(mvZoomRule && mvZoomOrigin,
|
|
3461
|
+
'the stylesheet scales the motion layer by 1.186 about a bottom-centre origin — her height AND her ground contact match the static sprite (v1.46.4)',
|
|
3462
|
+
mvRule ? `.wisp-video{…} zoom=${mvZoomRule} origin=${mvZoomOrigin}` : 'no .wisp-video rule in the stylesheet')
|
|
3463
|
+
check(mvMotion !== null && mvMotion.style.transform === 'scale(1.186) translateY(7.84%)'
|
|
3464
|
+
&& mvMotion.style.transformOrigin === '50% 100%',
|
|
3465
|
+
'and the inline mirror carries the same correction, for shells where the stylesheet never arrives',
|
|
3466
|
+
`inline transform=${mvMotion?.style.transform} origin=${mvMotion?.style.transformOrigin}`)
|
|
3467
|
+
/* v1.46.5:**样式表那一份**也要把 idle 显式排除掉 —— base 那条 1.186 是给 sleepy 的,
|
|
3468
|
+
落到 idle 头上就是放大 18.6%(而页面上只会看起来"她今天有点大")。行内那份上面
|
|
3469
|
+
单独点了名,这里点样式表那份:两个落点各查各的,缺一个就有一个壳是错的。 */
|
|
3470
|
+
const mvIdleRule = (String(clientSrc).match(/\.wisp-video\[data-clip="idle"\]\{[^}]*\}/) ?? [''])[0]
|
|
3471
|
+
check(/transform:none/.test(mvIdleRule) && !/scale\(/.test(mvIdleRule),
|
|
3472
|
+
'the stylesheet names the idle clip too, so the sleeping 1.186 can never land on it (v1.46.5)',
|
|
3473
|
+
mvIdleRule || 'no .wisp-video[data-clip="idle"] rule in the bundle')
|
|
3474
|
+
/* reduced-motion 那条兜底:媒体查询够不着 JS 的显隐状态,但它至少能让这层不出现在
|
|
3475
|
+
画面上(JS 拿不到 matchMedia 的壳里,冻结就只剩这一道)。 */
|
|
3476
|
+
const mvRmAt = mvCss.indexOf('@media (prefers-reduced-motion:reduce)')
|
|
3477
|
+
/* 媒体查询是第二道闸:JS 拿不到 matchMedia 的壳里,冻结就只剩这一条 —— 它必须
|
|
3478
|
+
仍然能把动作层从画面上拿掉(v1.46.1 之后这层是 <img>,display:none 照旧管用)。 */
|
|
3479
|
+
check(mvRmAt >= 0 && mvCss.slice(mvRmAt).includes('.wisp-video{display:none!important}'),
|
|
3480
|
+
'the reduced-motion block still hides the motion layer in CSS — the JS gate is not the only one',
|
|
3481
|
+
mvRmAt >= 0 ? 'found in the stylesheet insert' : 'no @media (prefers-reduced-motion:reduce) block')
|
|
3482
|
+
/* 动作层**不许有 CSS filter**(v1.45.3 立的规矩,v1.46.1 照旧):滤镜会给这一层
|
|
3483
|
+
再插一次栅格化,alpha 剪影被重新合成一遍 —— 那正是"她整个人变黑"那次的成因;
|
|
3484
|
+
阴影与调色只属于静态立绘。
|
|
3485
|
+
所以这条断言盯的必须是结构,而不是某一行的字面量:
|
|
3486
|
+
① 睡眠那档只许改 --wisp-sprite;② 只有 .wisp-img 去读它;③ 动作层一个字都不许碰。 */
|
|
3487
|
+
const sleepGrade = /\[data-mood="sleep"\]\{[^}]*--wisp-sprite\s*:/.test(String(clientSrc))
|
|
3488
|
+
const spriteTakesGrade = /\.wisp-img\{[^}]*filter:var\(--wisp-glow\)\s*var\(--wisp-sprite\)/.test(String(clientSrc))
|
|
3489
|
+
check(!/\.wisp-video\{[^}]*filter\s*:/.test(String(clientSrc))
|
|
3490
|
+
&& sleepGrade && spriteTakesGrade
|
|
3491
|
+
&& !/\[data-mood="sleep"\]\s*\.wisp-video\{[^}]*--wisp-sprite/.test(String(clientSrc)),
|
|
3492
|
+
'the sleeping colour grade stays on the STATIC sprite only - the motion layer carries no filter at all',
|
|
3493
|
+
(sleepGrade ? '' : 'no [data-mood="sleep"]{--wisp-sprite:…} rule; ')
|
|
3494
|
+
+ (spriteTakesGrade ? '' : 'the sprite does not compose var(--wisp-glow) var(--wisp-sprite); '))
|
|
3495
|
+
/* 属性 -> 画面的**映射**:属性在、规则在,退场才真的发生。
|
|
3496
|
+
用 visibility 而不是 display:none —— 盒子留着,布局不动,退场/回场不跳。 */
|
|
3497
|
+
check(mvFrameRule.includes('visibility:hidden') && !mvFrameRule.includes('display:none'),
|
|
3498
|
+
'the stylesheet takes the sprite out through visibility, never display — the box stays, so handing the picture over does not make her jump (v1.45.2)',
|
|
3499
|
+
mvFrameRule || 'no .wisp-root[data-frame="on"] .wisp-img rule in the stylesheet')
|
|
3500
|
+
|
|
3501
|
+
/* 素材字节走哪条路(v1.47.0 换了):**不再经过 Blob**。动图是宿主半包用
|
|
3502
|
+
`/wisp-motion/` 发出来的文件,客户端只把 URL 指给 <img>;base 取页面自己的
|
|
3503
|
+
(替身给的是真实的 Web 载体地址,见 documentShim.baseURI)。
|
|
3504
|
+
两个方向都要点名:
|
|
3505
|
+
① 这一层的 src 必须是那条路由下的,**不是** data: URI(这个壳把 data: 当坏图,
|
|
3506
|
+
1.44 那次碎图就是这么来的);
|
|
3507
|
+
② 包里**一个** MB 级 clip blob 都不该有 —— 有就说明构建又把素材内联回来了,
|
|
3508
|
+
而那正是这一版要修的那件事(八条泳装 +30 MB ⇒ 单文件 ~45 MB)。 */
|
|
3509
|
+
const mvClipBlobs = mv.blobs.filter((b) => String(b.type) === 'image/webp' && b.size > 1024 * 1024)
|
|
3510
|
+
check(mvMotion !== null && String(mvMotion.src) === 'http://127.0.0.1:19387/wisp-motion/sleepy.webp'
|
|
3511
|
+
&& mvClipBlobs.length === 0,
|
|
3512
|
+
'the clip is fetched from the host route the page can resolve — and NOTHING multi-megabyte is inlined or blob-decoded any more (v1.47.0)',
|
|
3513
|
+
`src=${String(mvMotion?.src)} · ${mvClipBlobs.length} MB-scale clip blob(s)`)
|
|
3514
|
+
check(mvMotion !== null && mvMotion.style.display !== 'none',
|
|
3515
|
+
'the animation is on screen at the default level', `display=${mvMotion?.style.display || '(default)'}`)
|
|
3516
|
+
/* 需求的那一格:**动图在画面上的时候立绘必须不在场**(动图那一列由上面那条钉住)。
|
|
3517
|
+
两层同时可见就是重影 —— 动图的透明处挡不住下面那张静帧。 */
|
|
3518
|
+
check(mvMotion.style.display !== 'none'
|
|
3519
|
+
&& mvVis(mvImg) === 'hidden' && mvRoot.dataset.frame === 'on'
|
|
3520
|
+
&& mvImg.style.visibility === 'hidden' && mvApi.doctor().motion.frame.spriteHidden === true
|
|
3521
|
+
&& mvApi.doctor().motion.frame.showing === true,
|
|
3522
|
+
'and while the animation is on screen the static sprite is GONE — two visible layers is a double image, not a fallback (v1.45.2)',
|
|
3523
|
+
`display=${mvMotion.style.display} sprite=${mvVis(mvImg)} inline=${mvImg?.style.visibility || '(unset)'} data-frame=${String(mvRoot.dataset.frame)} doctor=${JSON.stringify(mvApi.doctor().motion.frame.spriteHidden)}`)
|
|
3524
|
+
|
|
3525
|
+
/* ---- 冻结规则之一:静止档 ---- */
|
|
3526
|
+
/* 动图没有"暂停"可以喊(<img> 上没有 pause(),也没有 currentTime 可以写回 0),
|
|
3527
|
+
所以冻结的唯一正确行为是**把它藏起来、把画面交回立绘** —— 下面这几条盯的就是
|
|
3528
|
+
这件事:看不见动图 + 看得见立绘,两者缺一不可。 */
|
|
3529
|
+
mvApi.configure({ motion: 'off' })
|
|
3530
|
+
check(mvMotion.style.display === 'none',
|
|
3531
|
+
'motion: off takes the animation off screen — an animated <img> cannot be paused, so hiding it IS the freeze',
|
|
3532
|
+
`display=${mvMotion.style.display}`)
|
|
3533
|
+
check(mvImg !== null && mvImg.removed !== true
|
|
3534
|
+
&& String(mvImg.style.opacity) !== '0' && String(mvImg.style.display) !== 'none',
|
|
3535
|
+
'and the static sprite is what is left on screen — the fallback is the point of freezing',
|
|
3536
|
+
`sprite opacity=${mvImg?.style.opacity || '(default)'} display=${mvImg?.style.display || '(default)'}`)
|
|
3537
|
+
check(mvMotion.style.display === 'none' && mvVis(mvImg) === 'visible'
|
|
3538
|
+
&& mvRoot.dataset.frame === undefined && mvImg.style.visibility !== 'hidden'
|
|
3539
|
+
&& mvApi.doctor().motion.frame.showing === false && mvApi.doctor().motion.frame.frozen === true,
|
|
3540
|
+
'and it is actually VISIBLE again, not merely present: the freeze hands the picture back to the sprite (v1.45.2)',
|
|
3541
|
+
`motion=${mvMotion.style.display} sprite=${mvVis(mvImg)} inline=${mvImg?.style.visibility || '(unset)'} data-frame=${String(mvRoot.dataset.frame)}`)
|
|
3542
|
+
|
|
3543
|
+
/* ---- 恢复:同一个元素接着动,不重建 ---- */
|
|
3544
|
+
mvApi.configure({ motion: 'full' })
|
|
3545
|
+
check(mvMotion.style.display !== 'none' && mvLive() === mvMotion,
|
|
3546
|
+
'putting the level back shows that same element instead of rebuilding it',
|
|
3547
|
+
`display=${mvMotion.style.display || '(default)'}`)
|
|
3548
|
+
/* 来回切:可见性必须跟着**每一次**切换走。只切一次的实现在这里就露馅了。 */
|
|
3549
|
+
check(mvVis(mvImg) === 'hidden' && mvRoot.dataset.frame === 'on',
|
|
3550
|
+
'and resuming takes the sprite out of the picture again — the switch follows every transition, not just the first one (v1.45.2)',
|
|
3551
|
+
`sprite=${mvVis(mvImg)} data-frame=${String(mvRoot.dataset.frame)}`)
|
|
3552
|
+
|
|
3553
|
+
/* ---- 躲起来(display:none)也不该占着解码器 ---- */
|
|
3554
|
+
mvApi.hide()
|
|
3555
|
+
check(mvMotion.style.display === 'none',
|
|
3556
|
+
'hiding her away takes the animation off screen too — an invisible 1.9 MB loop must not keep decoding',
|
|
3557
|
+
`display=${mvMotion.style.display}`)
|
|
3558
|
+
/* "被躲起来"也是冻结规则的一条:动图退场、画面归立绘 —— 她回来时看到的该是那张立绘。 */
|
|
3559
|
+
check(mvMotion.style.display === 'none'
|
|
3560
|
+
&& mvVis(mvImg) === 'visible' && mvRoot.dataset.frame === undefined,
|
|
3561
|
+
'and while she is hidden away the frame gives the picture back as well (v1.45.2)',
|
|
3562
|
+
`motion=${mvMotion.style.display} sprite=${mvVis(mvImg)} data-frame=${String(mvRoot.dataset.frame)}`)
|
|
3563
|
+
mvApi.show()
|
|
3564
|
+
check(mvMotion.style.display !== 'none', 'and calling her back shows it again', `display=${mvMotion.style.display || '(default)'}`)
|
|
3565
|
+
check(mvMotion.style.display !== 'none' && mvVis(mvImg) === 'hidden'
|
|
3566
|
+
&& mvRoot.dataset.frame === 'on',
|
|
3567
|
+
'and the animation takes the picture again the moment she is back (v1.45.2)',
|
|
3568
|
+
`motion=${mvMotion.style.display || '(default)'} sprite=${mvVis(mvImg)} data-frame=${String(mvRoot.dataset.frame)}`)
|
|
3569
|
+
|
|
3570
|
+
/* ---- 冻结规则之二:系统要求减少动态效果 ---- */
|
|
3571
|
+
const mvr = createHarness({ timer: true, composerText: '', reducedMotion: true })
|
|
3572
|
+
active = mvr
|
|
3573
|
+
mvr.evaluate(clientSrc)
|
|
3574
|
+
mvr.module().default.apply(mvr.ctx, {
|
|
3575
|
+
reactions: false, wander: false, celebrate: false, sleepAfterMs: 3600000,
|
|
3576
|
+
})
|
|
3577
|
+
mvr.advance(1200, 100)
|
|
3578
|
+
const mvrApi = mvr.win.__wisp
|
|
3579
|
+
mvrApi.mood('sleep')
|
|
3580
|
+
mvr.advance(400, 100)
|
|
3581
|
+
const mvrRoot = mvr.find('wisp-root')
|
|
3582
|
+
const mvrMotion = mvr.all('wisp-video').find((el) => el.removed !== true) ?? null
|
|
3583
|
+
check(mvrMotion !== null && mvrMotion.tagName === 'IMG' && mvrMotion.style.display === 'none',
|
|
3584
|
+
'under prefers-reduced-motion the motion layer never goes on screen (an <img> has no other way to be frozen)',
|
|
3585
|
+
`${String(mvrMotion?.tagName)} display=${mvrMotion?.style.display}`)
|
|
3586
|
+
check(mvrRoot.querySelectorAll('.wisp-img').length === 1 && mvrApi.doctor().motion.frame.frozen === true,
|
|
3587
|
+
'and the static sprite is the whole picture, with doctor() saying why',
|
|
3588
|
+
`sprites=${mvrRoot.querySelectorAll('.wisp-img').length} doctor.frozen=${mvrApi.doctor().motion.frame.frozen}`)
|
|
3589
|
+
check(mvrRoot.dataset.frame === undefined
|
|
3590
|
+
&& String(mvrRoot.querySelector('.wisp-img')?.style?.visibility ?? '') !== 'hidden',
|
|
3591
|
+
'and under reduced motion the sprite is visible — the animation never takes the picture (v1.45.2)',
|
|
3592
|
+
`inline=${String(mvrRoot.querySelector('.wisp-img')?.style?.visibility ?? '') || '(unset)'} data-frame=${String(mvrRoot.dataset.frame)}`)
|
|
3593
|
+
mvrApi.destroy()
|
|
3594
|
+
active = mv
|
|
3595
|
+
|
|
3596
|
+
/* ---- 加载失败:静默降级,不冒泡、不留一个空盒子 ---- */
|
|
3597
|
+
let mvErrThrow = null
|
|
3598
|
+
try { mvMotion.dispatch('error', {}) } catch (error) { mvErrThrow = error }
|
|
3599
|
+
check(mvErrThrow === null && mvMotion.style.display === 'none'
|
|
3600
|
+
&& mvRoot.querySelectorAll('.wisp-img').length === 1,
|
|
3601
|
+
'a clip that fails to load degrades silently: the motion layer hides, the sprite stays, nothing throws',
|
|
3602
|
+
`threw=${mvErrThrow === null ? 'no' : String(mvErrThrow.message)} display=${mvMotion.style.display}`)
|
|
3603
|
+
/* 失败是**静默**的,所以"兜底真的看得见"必须是个断言:动图这一层退场之后,
|
|
3604
|
+
立绘要回到画面上(visibility 回 visible),而不是留下一个空盒子。 */
|
|
3605
|
+
check(mvMotion.style.display === 'none' && mvVis(mvImg) === 'visible'
|
|
3606
|
+
&& mvRoot.dataset.frame === undefined && mvImg.style.visibility !== 'hidden',
|
|
3607
|
+
'and the failed clip hands the picture back: the motion layer is gone and the sprite is visible again, silently (v1.45.2)',
|
|
3608
|
+
`motion=${mvMotion.style.display} sprite=${mvVis(mvImg)} inline=${mvImg?.style.visibility || '(unset)'} data-frame=${String(mvRoot.dataset.frame)}`)
|
|
3609
|
+
check(mvApi.doctor().motion.frame.spriteHidden === false,
|
|
3610
|
+
'and doctor() says the sprite owns the picture again, not just that the motion layer is gone (v1.45.2)',
|
|
3611
|
+
JSON.stringify(mvApi.doctor().motion.frame))
|
|
3612
|
+
check(mvApi.doctor().motion.frame.failed === true,
|
|
3613
|
+
'and doctor() reports the degraded frame animation instead of leaving it a mystery',
|
|
3614
|
+
JSON.stringify(mvApi.doctor().motion.frame))
|
|
3615
|
+
/* 失败之后不再反复重试:重试一个已经坏掉的源只会每 1.2 秒烧一次解码。 */
|
|
3616
|
+
mvApi.mood('idle')
|
|
3617
|
+
mvApi.mood('sleep')
|
|
3618
|
+
mv.advance(200, 100)
|
|
3619
|
+
check(mvMotion.style.display === 'none' && mvLive() === mvMotion,
|
|
3620
|
+
'and it is not re-created or retried on the next nap', `display=${mvMotion.style.display}`)
|
|
3621
|
+
/* 失败是**按素材**记的(v1.46.5):坏掉的是睡着那一段,站着那段没坏 —— 她醒着的时候
|
|
3622
|
+
照常动。把"失败"做成一个全局开关的实现在这里会露出来(她会从此一辈子不动)。 */
|
|
3623
|
+
mvApi.mood('idle')
|
|
3624
|
+
mv.advance(200, 100)
|
|
3625
|
+
check(mvLive() === mvMotion && mvMotion.dataset.clip === 'idle'
|
|
3626
|
+
&& mvMotion.style.display !== 'none' && mvApi.doctor().motion.frame.failed === false,
|
|
3627
|
+
'the failure belongs to the clip, not to the layer: the standing loop still plays after the sleeping one died (v1.46.5)',
|
|
3628
|
+
`clip=${String(mvMotion.dataset.clip)} display=${mvMotion.style.display || '(default)'} doctor.failed=${mvApi.doctor().motion.frame.failed}`)
|
|
3629
|
+
/* 动图这一层**一次 play()/pause() 都不该出现**:那是媒体通道的 API,图片路径上没有它,
|
|
3630
|
+
也不该有人偷偷搭一条回来。替身把每一次 play/pause 记在 harness 上,所以"一次都没有"
|
|
3631
|
+
在这里是能数出来的事实,而不是从源码里读出来的一句话。 */
|
|
3632
|
+
check(mv.videoPlays.length === 0 && mv.videoPauses.length === 0,
|
|
3633
|
+
'the motion layer never calls play()/pause() — the media pipeline is gone, not merely unused',
|
|
3634
|
+
`${mv.videoPlays.length} play(s), ${mv.videoPauses.length} pause(s) across the whole section`)
|
|
3635
|
+
mvApi.destroy()
|
|
3636
|
+
active = keepMv
|
|
3637
|
+
|
|
2829
3638
|
/* ------------------------------------------------------ 3g. teardown --- */
|
|
3639
|
+
/* ---- 冻结规则之三(v1.47.0):独立的「帧动画」开关 ----------------------
|
|
3640
|
+
动图是画面里唯一会自己动的东西,所以它有一个**只管画面**的开关。关掉之后
|
|
3641
|
+
画面交给静态立绘,而呼吸 / 一次性动作照旧(幅度档一个字没动)—— 这正是它
|
|
3642
|
+
和「动作幅度 → 静止」的分工。四个冻结来源共用 motionFrozen() 那一处判断。 */
|
|
3643
|
+
{
|
|
3644
|
+
const fv = createHarness({ timer: true, composerText: '' })
|
|
3645
|
+
const keepFv = active
|
|
3646
|
+
active = fv
|
|
3647
|
+
fv.evaluate(clientSrc)
|
|
3648
|
+
fv.module().default.apply(fv.ctx, { reactions: false, wander: false, celebrate: false })
|
|
3649
|
+
fv.advance(1200, 100)
|
|
3650
|
+
const fvApi = fv.win.__wisp
|
|
3651
|
+
const fvRoot = fv.find('wisp-root')
|
|
3652
|
+
const fvMotion = () => fv.all('wisp-video').find((el) => el.removed !== true) ?? null
|
|
3653
|
+
const fvImg = () => fv.all('wisp-img').find((el) => el.removed !== true) ?? null
|
|
3654
|
+
|
|
3655
|
+
check(fvApi.doctor().motion.frame.enabled === true && fvApi.doctor().motion.frame.showing === true,
|
|
3656
|
+
'the frame animation is on by default', JSON.stringify(fvApi.doctor().motion.frame))
|
|
3657
|
+
|
|
3658
|
+
fvApi.configure({ frame: false })
|
|
3659
|
+
const fvOff = fvApi.doctor().motion
|
|
3660
|
+
check(fvOff.frame.enabled === false && fvOff.frame.showing === false
|
|
3661
|
+
&& fvOff.frame.frozen === true
|
|
3662
|
+
&& fvMotion() !== null && fvMotion().style.display === 'none',
|
|
3663
|
+
'switching the frame animation off takes it off screen at once — no reload needed',
|
|
3664
|
+
'enabled=' + fvOff.frame.enabled + ' showing=' + fvOff.frame.showing + ' display=' + (fvMotion() && fvMotion().style.display))
|
|
3665
|
+
check(mvVis(fvImg()) === 'visible' && fvRoot.dataset.frame === undefined,
|
|
3666
|
+
'and the static sprite is what is left on screen — the same hand-over as any other freeze',
|
|
3667
|
+
'sprite=' + mvVis(fvImg()) + ' data-frame=' + String(fvRoot.dataset.frame))
|
|
3668
|
+
check(fvOff.level === 'full' && fvOff.amp === 1,
|
|
3669
|
+
'while the amplitude level is untouched: this switch only takes the animated frames away',
|
|
3670
|
+
'level=' + fvOff.level + ' amp=' + fvOff.amp)
|
|
3671
|
+
|
|
3672
|
+
fvApi.configure({ frame: true })
|
|
3673
|
+
check(fvApi.doctor().motion.frame.enabled === true && fvApi.doctor().motion.frame.showing === true
|
|
3674
|
+
&& mvVis(fvImg()) === 'hidden' && fvRoot.dataset.frame === 'on',
|
|
3675
|
+
'switching it back on resumes the same element and hands it the picture again',
|
|
3676
|
+
'showing=' + fvApi.doctor().motion.frame.showing + ' sprite=' + mvVis(fvImg()) + ' data-frame=' + String(fvRoot.dataset.frame))
|
|
3677
|
+
|
|
3678
|
+
fv.win.__wisp.destroy()
|
|
3679
|
+
active = keepFv
|
|
3680
|
+
}
|
|
3681
|
+
|
|
3682
|
+
/* ---- 3f-quater. 泳装:素材按**皮肤+状态**点(v1.47.0)------------------------
|
|
3683
|
+
这一版最容易错的不是"素材没生成",而是**接错了**:泳装立绘配通用那段循环
|
|
3684
|
+
(穿着泳装播别的皮肤的动作),或者换了皮肤只换了图、没换动图那一层。
|
|
3685
|
+
两条都在画面上不报错,所以这里逐个状态点一遍,而且查的是"图 + 几何"两样。 */
|
|
3686
|
+
{
|
|
3687
|
+
const sw = createHarness({ timer: true, composerText: '' })
|
|
3688
|
+
const keepSw = active
|
|
3689
|
+
active = sw
|
|
3690
|
+
sw.evaluate(clientSrc)
|
|
3691
|
+
sw.module().default.apply(sw.ctx, { skin: 'swim', reactions: false, wander: false, celebrate: false })
|
|
3692
|
+
sw.advance(1200, 100)
|
|
3693
|
+
const swApi = sw.win.__wisp
|
|
3694
|
+
const swRoot = sw.find('wisp-root')
|
|
3695
|
+
const swMotion = () => sw.all('wisp-video').find((el) => el.removed !== true) ?? null
|
|
3696
|
+
const swSprite = () => sw.all('wisp-img').find((el) => el.removed !== true) ?? null
|
|
3697
|
+
const fitMap = (() => {
|
|
3698
|
+
const src = (/const MOTION_FIT = (\{[^}]*\})/.exec(clientSrc) ?? [])[1]
|
|
3699
|
+
try { return src ? new Function(`return ${src}`)() : {} } catch (error) { return {} }
|
|
3700
|
+
})()
|
|
3701
|
+
check(swApi.skin === 'swim' && swRoot.dataset.mood === 'idle',
|
|
3702
|
+
'a companion can start on the swimsuit skin and land in the idle state',
|
|
3703
|
+
`skin=${String(swApi.skin)} mood=${String(swRoot.dataset.mood)}`)
|
|
3704
|
+
|
|
3705
|
+
/* 素材在不在,决定这一段**查什么**(见文件上方 SHIPPED_SWIM):
|
|
3706
|
+
八条都在 -> 逐条查"图 + 几何";
|
|
3707
|
+
一条都没有 -> 查她**静默降级**,而且**不许**退回通用那段循环(穿着泳装播别的
|
|
3708
|
+
皮肤的动作,比不动更糟)。两条路都是真的断言,没有空过的一档。 */
|
|
3709
|
+
const SWIM = [
|
|
3710
|
+
['idle', 'swim_idle'], ['attn', 'swim_attn'], ['happy', 'swim_happy'],
|
|
3711
|
+
['sleep', 'swim_sleepy'], ['alert', 'swim_work'], ['proud', 'swim_proud'],
|
|
3712
|
+
['eat', 'swim_eat'], ['poked', 'swim_poked'],
|
|
3713
|
+
]
|
|
3714
|
+
if (SHIPPED_SWIM.length === 8) {
|
|
3715
|
+
const wrongClip = []
|
|
3716
|
+
const wrongSrc = []
|
|
3717
|
+
const wrongFit = []
|
|
3718
|
+
const wrongOwner = []
|
|
3719
|
+
for (const [state, clip] of SWIM) {
|
|
3720
|
+
swApi.mood(state)
|
|
3721
|
+
sw.advance(300, 100)
|
|
3722
|
+
const m = swMotion()
|
|
3723
|
+
if (String(m?.dataset?.clip ?? '') !== clip) wrongClip.push(`${state}→${String(m?.dataset?.clip)}`)
|
|
3724
|
+
const wantSrc = `http://127.0.0.1:19387/wisp-motion/${clip}.webp`
|
|
3725
|
+
if (String(m?.src ?? '') !== wantSrc) wrongSrc.push(`${state}→${String(m?.src)}`)
|
|
3726
|
+
/* 几何跟着**素材**走:MOTION_FIT 里有就用它,没有就是 none("不校正"是
|
|
3727
|
+
显式规则,不是"忘了写")—— 换源不换几何就是 v1.46.5 抓到过的那条。 */
|
|
3728
|
+
if (String(m?.style?.transform ?? '') !== (fitMap[clip] ?? 'none')) {
|
|
3729
|
+
wrongFit.push(`${state}: ${String(m?.style?.transform)} vs ${fitMap[clip] ?? 'none'}`)
|
|
3730
|
+
}
|
|
3731
|
+
if (m === null || m.style.display === 'none' || mvVis(swSprite()) !== 'hidden') {
|
|
3732
|
+
wrongOwner.push(`${state}: display=${String(m?.style?.display)} sprite=${mvVis(swSprite())}`)
|
|
3733
|
+
}
|
|
3734
|
+
}
|
|
3735
|
+
check(wrongClip.length === 0,
|
|
3736
|
+
'all eight swimsuit states build THEIR OWN clip — the layer names the clip the skin+state maps to',
|
|
3737
|
+
wrongClip.length ? wrongClip.join(' | ') : SWIM.map(([s, c]) => `${s}=${c}`).join(', '))
|
|
3738
|
+
check(wrongSrc.length === 0,
|
|
3739
|
+
'and each one loads it from the host route, resolved from the page base — no inlined bytes, no stale source',
|
|
3740
|
+
wrongSrc.length ? wrongSrc.join(' | ') : `e.g. ${String(swMotion()?.src)}`)
|
|
3741
|
+
check(wrongFit.length === 0,
|
|
3742
|
+
'and the geometry is chosen PER CLIP — switching the source rewrites the transform with it (v1.46.5 rule, swimsuit edition)',
|
|
3743
|
+
wrongFit.length ? wrongFit.join(' | ') : `fit=${JSON.stringify(fitMap)}`)
|
|
3744
|
+
check(wrongOwner.length === 0,
|
|
3745
|
+
'and while a swimsuit clip plays, the static swimsuit sprite is out of the picture — one of her, never two',
|
|
3746
|
+
wrongOwner.length ? wrongOwner.join(' | ') : `sprite=hidden · data-frame=${String(swRoot.dataset.frame)}`)
|
|
3747
|
+
check(swApi.doctor().motion.frame.asset === 'swim_poked'
|
|
3748
|
+
&& String(swApi.doctor().motion.frame.src).endsWith('/wisp-motion/swim_poked.webp'),
|
|
3749
|
+
'and doctor() reports both the clip name and where it came from, so "why is she still" is answerable',
|
|
3750
|
+
JSON.stringify(swApi.doctor().motion.frame))
|
|
3751
|
+
|
|
3752
|
+
/* 没有素材的状态:worried 在这个皮肤上也没有动作段(它有自己的抖动)——
|
|
3753
|
+
这一层必须**藏起来**、立绘回到画面,而不是留一个空盒子。 */
|
|
3754
|
+
swApi.mood('worried')
|
|
3755
|
+
sw.advance(300, 100)
|
|
3756
|
+
check(swMotion() === null || swMotion().style.display === 'none',
|
|
3757
|
+
'a swimsuit state with no clip takes the frame layer off screen and hands the picture back',
|
|
3758
|
+
`display=${String(swMotion()?.style?.display)} sprite=${mvVis(swSprite())}`)
|
|
3759
|
+
|
|
3760
|
+
/* 换皮肤 = 换一整套**动图**(v1.47.0):只换图不换动图层,是这一版最容易漏的一处。
|
|
3761
|
+
两个方向一起点:泳装 → 通用(回到 idle/sleepy),这也是"图与几何一起换"的证据。 */
|
|
3762
|
+
swApi.mood('sleep')
|
|
3763
|
+
sw.advance(300, 100)
|
|
3764
|
+
const swimSleepTransform = String(swMotion()?.style?.transform ?? '')
|
|
3765
|
+
const beforeSkinClip = String(swMotion()?.dataset?.clip ?? '')
|
|
3766
|
+
swApi.setSkin('deepsea')
|
|
3767
|
+
sw.advance(300, 100)
|
|
3768
|
+
check(String(swMotion()?.dataset?.clip ?? '') === 'sleepy'
|
|
3769
|
+
&& String(swMotion()?.src ?? '') === 'http://127.0.0.1:19387/wisp-motion/sleepy.webp'
|
|
3770
|
+
&& String(swMotion()?.style?.transform ?? '') === 'scale(1.186) translateY(7.84%)'
|
|
3771
|
+
&& swimSleepTransform !== String(swMotion()?.style?.transform ?? ''),
|
|
3772
|
+
'switching the skin re-points the SAME frame layer at that skin’s clip — picture AND geometry change together (v1.47.0)',
|
|
3773
|
+
`${beforeSkinClip} ${swimSleepTransform} → ${String(swMotion()?.dataset?.clip)} ${String(swMotion()?.style?.transform)}`)
|
|
3774
|
+
check(swApi.setSkin('swim') === true && String(swMotion()?.dataset?.clip ?? '') === 'swim_sleepy',
|
|
3775
|
+
'and switching back returns the swimsuit loop, not the shared one',
|
|
3776
|
+
String(swMotion()?.dataset?.clip))
|
|
3777
|
+
} else {
|
|
3778
|
+
/* 素材还没生成(0/8):泳装那一档**一条动图都不该建**,而且**不许**退回通用
|
|
3779
|
+
那段循环 —— 她是穿着泳装的人,播别的皮肤的动作比不动更糟。这一条查的就是
|
|
3780
|
+
"按皮肤点素材"这件事本身:换一套皮肤,同一个元素该出现/消失。 */
|
|
3781
|
+
const leaked = []
|
|
3782
|
+
const silent = []
|
|
3783
|
+
for (const [state] of SWIM) {
|
|
3784
|
+
swApi.mood(state)
|
|
3785
|
+
sw.advance(300, 100)
|
|
3786
|
+
const m = swMotion()
|
|
3787
|
+
if (m !== null && m.style.display !== 'none') leaked.push(`${state}:${String(m.dataset.clip)}`)
|
|
3788
|
+
if (mvVis(swSprite()) !== 'visible' || swRoot.dataset.frame === 'on') {
|
|
3789
|
+
silent.push(`${state}: sprite=${mvVis(swSprite())} frame=${String(swRoot.dataset.frame)}`)
|
|
3790
|
+
}
|
|
3791
|
+
}
|
|
3792
|
+
check(leaked.length === 0,
|
|
3793
|
+
'with no swimsuit clips shipped, NONE of the eight states plays a clip — the shared loop never leaks onto the swimsuit skin (v1.47.0)',
|
|
3794
|
+
leaked.length ? leaked.join(' | ') : `0/8 clips built across ${SWIM.length} states`)
|
|
3795
|
+
check(silent.length === 0 && swApi.doctor().motion.frame.asset === null,
|
|
3796
|
+
'and the static swimsuit sprite keeps the picture the whole time — the documented silent degradation, not an empty box',
|
|
3797
|
+
silent.length ? silent.join(' | ') : `sprite visible, data-frame=${String(swRoot.dataset.frame)}, doctor.asset=null`)
|
|
3798
|
+
/* 反面:同一套素材在**别的皮肤**上照旧播 —— 上一条若是"动图整个坏了",
|
|
3799
|
+
这里会红。两件事必须分得开:"这个皮肤没有素材"和"帧动画坏了"。 */
|
|
3800
|
+
swApi.mood('sleep')
|
|
3801
|
+
sw.advance(300, 100)
|
|
3802
|
+
const swimSleepQuiet = swMotion() === null || swMotion().style.display === 'none'
|
|
3803
|
+
swApi.setSkin('deepsea')
|
|
3804
|
+
sw.advance(300, 100)
|
|
3805
|
+
check(swimSleepQuiet && String(swMotion()?.dataset?.clip ?? '') === 'sleepy'
|
|
3806
|
+
&& String(swMotion()?.src ?? '') === 'http://127.0.0.1:19387/wisp-motion/sleepy.webp',
|
|
3807
|
+
'and switching to a skin WITH clips brings the frame animation straight back — missing swimsuit art is not a broken frame layer',
|
|
3808
|
+
`swim/sleep -> ${swimSleepQuiet ? 'no clip' : String(swMotion()?.dataset?.clip)}; deepsea/sleep -> clip=${String(swMotion()?.dataset?.clip)}`)
|
|
3809
|
+
}
|
|
3810
|
+
|
|
3811
|
+
sw.win.__wisp.destroy()
|
|
3812
|
+
active = keepSw
|
|
3813
|
+
}
|
|
3814
|
+
|
|
2830
3815
|
head('3g. teardown')
|
|
2831
3816
|
|
|
2832
3817
|
const liveBefore = h.pendingTimers().length
|
|
@@ -2864,7 +3849,9 @@ if (clientSrc !== null) {
|
|
|
2864
3849
|
'exactly one companion is mounted', `${layers.filter((l) => l.parentNode === h.bodyEl).length} attached`)
|
|
2865
3850
|
const second = h.win.__wisp
|
|
2866
3851
|
check(second !== api && typeof second?.destroy === 'function', 'the handle points at the new instance')
|
|
2867
|
-
|
|
3852
|
+
/* v1.47.0:挂载时最后写的一次 src 是**动图**那条路由(idle 有素材),所以这里
|
|
3853
|
+
要看的是立绘那一串 —— 这条断言问的是"新实例有没有拿到被吊销的 blob"。 */
|
|
3854
|
+
const freshSrcs = spriteSrcs().slice(-1)
|
|
2868
3855
|
check(freshSrcs[0]?.startsWith('blob:') && !h.revoked.includes(freshSrcs[0]),
|
|
2869
3856
|
'the re-mount is not handed a revoked blob URL', String(freshSrcs[0]))
|
|
2870
3857
|
const restored = second.position
|
|
@@ -3042,7 +4029,7 @@ if (clientSrc !== null) {
|
|
|
3042
4029
|
check(collapse('外观'), 'close the skin group again')
|
|
3043
4030
|
check(expand('行为'), 'the behaviour group opens') /* 开关都在这一组里 */
|
|
3044
4031
|
const switchOf = (key) => itemsOf().find((i) => i.dataset && i.dataset.switchKey === key)
|
|
3045
|
-
const switchKeys = ['wander', 'reactions', 'celebrate', 'hungry', 'night']
|
|
4032
|
+
const switchKeys = ['frame', 'wander', 'reactions', 'celebrate', 'hungry', 'night']
|
|
3046
4033
|
check(switchKeys.every((k) => switchOf(k) !== undefined), 'every behaviour has a switch row',
|
|
3047
4034
|
switchKeys.map((k) => k + '=' + (switchOf(k) ? '有' : '无')).join(' '))
|
|
3048
4035
|
const wanderSwitch = switchOf('wander')
|
|
@@ -3068,6 +4055,20 @@ if (clientSrc !== null) {
|
|
|
3068
4055
|
flipped.dispatch('click', itemEvent())
|
|
3069
4056
|
check(fifth.config.wander === wanderBefore, 'toggling back restores it', String(fifth.config.wander))
|
|
3070
4057
|
|
|
4058
|
+
/* 帧动画那一格(v1.47.0):点一下 config 翻面,画面**当场**跟着走(不用刷新)。 */
|
|
4059
|
+
const frameSwitch = switchOf('frame')
|
|
4060
|
+
const frameBefore = fifth.config.frame
|
|
4061
|
+
frameSwitch.dispatch('click', itemEvent())
|
|
4062
|
+
check(fifth.config.frame === !frameBefore
|
|
4063
|
+
&& fifth.doctor().motion.frame.enabled === (fifth.config.frame !== false)
|
|
4064
|
+
&& fifth.doctor().motion.frame.showing === (fifth.config.frame !== false),
|
|
4065
|
+
'the frame-animation switch flips its own config, and the picture follows immediately',
|
|
4066
|
+
frameBefore + ' -> ' + fifth.config.frame + ' / enabled=' + fifth.doctor().motion.frame.enabled)
|
|
4067
|
+
switchOf('frame').dispatch('click', itemEvent())
|
|
4068
|
+
check(fifth.config.frame === frameBefore
|
|
4069
|
+
&& fifth.doctor().motion.frame.showing === true,
|
|
4070
|
+
'and toggling it back hands the picture back to the animation', String(fifth.config.frame))
|
|
4071
|
+
|
|
3071
4072
|
/* ---- 滑块:连续量直接调,键盘也能调,而且不关菜单 ---- */
|
|
3072
4073
|
const sliderEl = menuOf().querySelector ? menuOf().querySelector('.wisp-slider') : null
|
|
3073
4074
|
check(sliderEl !== null && sliderEl.getAttribute('role') === 'slider',
|
|
@@ -3108,8 +4109,8 @@ if (clientSrc !== null) {
|
|
|
3108
4109
|
check(focusSlider(), 'the slider is reachable with the arrow keys',
|
|
3109
4110
|
h.document.activeElement ? String(h.document.activeElement.className) : '(没有焦点)')
|
|
3110
4111
|
pressKey('ArrowRight')
|
|
3111
|
-
check(Math.abs(fifth.config.size - (sizeBefore + 0.
|
|
3112
|
-
'ArrowRight grows her by
|
|
4112
|
+
check(Math.abs(fifth.config.size - (sizeBefore + 0.1)) < 1e-9,
|
|
4113
|
+
'ArrowRight grows her by exactly one step', `${sizeBefore} -> ${fifth.config.size}`)
|
|
3113
4114
|
check(menuOf() !== null && focusedIsSlider(),
|
|
3114
4115
|
'and it neither closes the menu nor steals focus from the slider',
|
|
3115
4116
|
menuOf() === null ? '菜单关了' : String(h.document.activeElement && h.document.activeElement.className))
|
|
@@ -3276,7 +4277,7 @@ if (clientSrc !== null) {
|
|
|
3276
4277
|
check(expand('外观'), 'and the skins group can be opened')
|
|
3277
4278
|
const skinBefore = fifth.skin
|
|
3278
4279
|
const posBefore = fifth.position
|
|
3279
|
-
const srcBeforeSkin =
|
|
4280
|
+
const srcBeforeSkin = spriteSrcs().at(-1)
|
|
3280
4281
|
/* 同样不写死名字:挑一套「和当前不同」的皮肤,用它的显示名去点菜单 */
|
|
3281
4282
|
const targetSkin = fifth.skins.find((id) => id !== skinBefore)
|
|
3282
4283
|
const targetLabel = fifth.skinLabels.find((s) => s.id === targetSkin).label
|
|
@@ -3285,8 +4286,8 @@ if (clientSrc !== null) {
|
|
|
3285
4286
|
targetEl.dispatch('click', itemEvent())
|
|
3286
4287
|
check(fifth.skin === targetSkin, 'choosing a skin switches to it', String(fifth.skin))
|
|
3287
4288
|
check(fifth.skin !== skinBefore, 'and it is a different skin than before', `${skinBefore} -> ${fifth.skin}`)
|
|
3288
|
-
check(
|
|
3289
|
-
`${String(srcBeforeSkin).slice(0, 22)} -> ${String(
|
|
4289
|
+
check(spriteSrcs().at(-1) !== srcBeforeSkin, 'the sprite is swapped for the new skin',
|
|
4290
|
+
`${String(srcBeforeSkin).slice(0, 22)} -> ${String(spriteSrcs().at(-1)).slice(0, 22)}`)
|
|
3290
4291
|
check(fifth.position.x === posBefore.x && fifth.position.y === posBefore.y,
|
|
3291
4292
|
'switching skins leaves her where she was', `${fifth.position.x},${fifth.position.y}`)
|
|
3292
4293
|
check(JSON.parse(h.win.localStorage.getItem('dsh-wisp:skin:v1') || 'null')?.id === targetSkin,
|
|
@@ -3307,7 +4308,7 @@ if (clientSrc !== null) {
|
|
|
3307
4308
|
}
|
|
3308
4309
|
h.win.dispatch('keydown', { key: 'ArrowRight', preventDefault() {}, stopPropagation() {} })
|
|
3309
4310
|
h.win.dispatch('keydown', { key: 'ArrowRight', preventDefault() {}, stopPropagation() {} })
|
|
3310
|
-
check(fifth.element.style.width === '
|
|
4311
|
+
check(fifth.element.style.width === '588px' && fifth.element.style.height === '882px',
|
|
3311
4312
|
'the slider really resized her (two steps up)', `${fifth.element.style.width}x${fifth.element.style.height}`)
|
|
3312
4313
|
check(menuOf() !== null, 'and the slider keeps the menu open, unlike an action row')
|
|
3313
4314
|
h.win.dispatch('keydown', { key: 'Escape', preventDefault() {}, stopPropagation() {} })
|
|
@@ -3386,6 +4387,45 @@ if (clientSrc !== null) {
|
|
|
3386
4387
|
check(h.document.activeElement?.getAttribute('tabindex') === '0',
|
|
3387
4388
|
'her body is focusable, which is what makes the menu reachable without a mouse',
|
|
3388
4389
|
String(h.document.activeElement?.getAttribute('tabindex')))
|
|
4390
|
+
/* ---- 焦点环:不能是浏览器默认那个矩形 ------------------------------------
|
|
4391
|
+
用户报的"有时会出现素材的矩形边界线"就是它:默认焦点环画在 .wisp-body 这个
|
|
4392
|
+
560×840 的盒子上,而盒子里大部分是透明的 —— 屏幕上于是浮着一个套住素材的
|
|
4393
|
+
矩形线框。修法是两半,缺一不可:默认环关掉(样式表 + 行内各一份),键盘焦点
|
|
4394
|
+
改用**贴着剪影**的辉光表达(drop-shadow 跟的是 alpha 剪影,任何尺寸都不是矩形)。 */
|
|
4395
|
+
const ringCss = String(h.styleInserts[0] ?? '')
|
|
4396
|
+
check(/\.wisp-body:focus[^{]*\{[^}]*outline\s*:\s*(none|0)/.test(ringCss),
|
|
4397
|
+
'the browser default focus ring is switched off for her box (it draws a rectangle around the sprite)',
|
|
4398
|
+
(ringCss.match(/[^{}]*\.wisp-body:focus[^{]*\{[^}]*\}/) ?? ['(no such rule)'])[0].slice(0, 96))
|
|
4399
|
+
check(body5.style.outline === 'none',
|
|
4400
|
+
'and the inline mirror says the same, so a shell without the stylesheet does not get the box back',
|
|
4401
|
+
String(body5.style.outline))
|
|
4402
|
+
check(/\[data-focus="key"\]\{[^}]*--wisp-glow\s*:[^}]*drop-shadow\([^)]*var\(--wisp-accent\)/.test(ringCss)
|
|
4403
|
+
&& /\.wisp-img\{[^}]*filter:var\(--wisp-glow\)\s*var\(--wisp-sprite\)/.test(ringCss),
|
|
4404
|
+
'keyboard focus stays visible: an accent halo that follows her silhouette instead of a box',
|
|
4405
|
+
(ringCss.match(/\[data-focus="key"\]\{[^}]*\}/) ?? ['(no such rule)'])[0].slice(0, 96))
|
|
4406
|
+
/* Esc 关掉菜单 = 键盘路径:焦点交还给她,指示必须亮(上面刚走完这条路)。 */
|
|
4407
|
+
check(fifth.element.dataset.focus === 'key',
|
|
4408
|
+
'focus handed back by the keyboard lights the halo',
|
|
4409
|
+
String(fifth.element.dataset.focus))
|
|
4410
|
+
/* 鼠标点她也会把焦点交给她(onDown 里的 focusHer)—— 那一下**什么都不该画**,
|
|
4411
|
+
否则就退回用户看到的那条矩形边界线。 */
|
|
4412
|
+
body5.blur()
|
|
4413
|
+
h.win.dispatch('pointerdown', { clientX: 400, clientY: 300 })
|
|
4414
|
+
body5.focus()
|
|
4415
|
+
check(fifth.element.dataset.focus === undefined,
|
|
4416
|
+
'a pointer-driven focus paints nothing at all — no box, no halo',
|
|
4417
|
+
String(fifth.element.dataset.focus))
|
|
4418
|
+
/* 反过来也必须成立:关掉默认环**不能**变成"键盘用户什么都看不见"。 */
|
|
4419
|
+
body5.blur()
|
|
4420
|
+
h.win.dispatch('keydown', { key: 'Tab' })
|
|
4421
|
+
body5.focus()
|
|
4422
|
+
check(fifth.element.dataset.focus === 'key',
|
|
4423
|
+
'and keyboard focus lights it again — switching the default ring off must not blind keyboard users',
|
|
4424
|
+
String(fifth.element.dataset.focus))
|
|
4425
|
+
body5.blur()
|
|
4426
|
+
check(fifth.element.dataset.focus === undefined,
|
|
4427
|
+
'the halo is gone the moment focus leaves her',
|
|
4428
|
+
String(fifth.element.dataset.focus))
|
|
3389
4429
|
|
|
3390
4430
|
/* ---- 键盘入口:声明了 role="menu" 就得有办法用键盘打开它 ---------------- */
|
|
3391
4431
|
const bodyKey = (key, opts = {}) => body5.dispatch('keydown', Object.assign(
|
|
@@ -3708,10 +4748,24 @@ if (clientSrc !== null) {
|
|
|
3708
4748
|
check(counts.say <= 1, 'at most one speech bubble exists', `${counts.say}`)
|
|
3709
4749
|
check(counts.img <= 2, 'at most the two cross-fade layers exist', `${counts.img}`)
|
|
3710
4750
|
|
|
3711
|
-
/* 皮肤来回换 120 次,如果 blob URL 每轮都新建,这里会是 120+ ——
|
|
3712
|
-
|
|
4751
|
+
/* 皮肤来回换 120 次,如果 blob URL 每轮都新建,这里会是 120+ —— 缓存命中应该是常数。
|
|
4752
|
+
上限 12 -> 13:v1.45.0 起那段帧动画素材也走同一条 Blob 通道,它**只该解一次**
|
|
4753
|
+
(这一轮里她睡着了 60 次)。v1.46.5 再 +1 = 14:idle 也接上了素材(挂载时解一次,
|
|
4754
|
+
120 轮里她醒着 60 次,一次都不该重解)。
|
|
4755
|
+
**v1.47.0 起动图不再走 Blob**(它是宿主路由下的文件 URL),所以这个数只数立绘
|
|
4756
|
+
—— 14 这个上界照旧成立,只是现在有了富余。 */
|
|
4757
|
+
check(soak.urLs.length <= 14,
|
|
3713
4758
|
'switching skins reuses cached blob URLs instead of minting new ones',
|
|
3714
4759
|
`${soak.urLs.length} object URLs ever created across ${Array.isArray(api.skins) ? api.skins.length : 0} skins and ${CYCLES} switches`)
|
|
4760
|
+
/* 反过来钉"没有被内联回来":只要有一份 MB 级的 image/webp blob,就说明构建又把
|
|
4761
|
+
素材塞进单文件了 —— 那正是 1.47.0 要修的那件事(八条泳装 +30 MB ⇒ ~45 MB)。
|
|
4762
|
+
同时要求这一轮里动图的 src **一直是那条路由**:换皮肤、换状态都不许退回 data:。 */
|
|
4763
|
+
const soakClipBlobs = soak.blobs.filter((b) => String(b.type) === 'image/webp' && b.size > 1024 * 1024)
|
|
4764
|
+
const soakMotion = soak.srcs.filter((s) => typeof s === 'string' && s.indexOf('/wisp-motion/') >= 0)
|
|
4765
|
+
check(soakClipBlobs.length === 0 && soakMotion.length > 0
|
|
4766
|
+
&& soakMotion.every((s) => /^http:\/\/127\.0\.0\.1:19387\/wisp-motion\/[A-Za-z0-9._-]+\.webp$/.test(s)),
|
|
4767
|
+
'every frame-animation source stays on the host route across the whole soak - nothing multi-megabyte is decoded or inlined (v1.47.0)',
|
|
4768
|
+
`${soakClipBlobs.length} MB-scale clip blob(s); ${soakMotion.length} route src(s)`)
|
|
3715
4769
|
|
|
3716
4770
|
soakApi.destroy()
|
|
3717
4771
|
check(soak.pendingTimers().length === 0 && soak.revoked.length === soak.urLs.length,
|
|
@@ -3790,15 +4844,24 @@ head('3y. dialogs: about her, and the action preview')
|
|
|
3790
4844
|
|
|
3791
4845
|
const eatCell = cells.find((c) => c.dataset.mood === 'eat')
|
|
3792
4846
|
eatCell.dispatch('click', { preventDefault() {}, stopPropagation() {} })
|
|
3793
|
-
check(dlgApi.dialog ===
|
|
4847
|
+
check(dlgApi.dialog === 'actions',
|
|
4848
|
+
'clicking an action KEEPS the dialog open — you can try several in a row (v1.47.1)',
|
|
4849
|
+
String(dlgApi.dialog))
|
|
3794
4850
|
check(dlgApi.currentMood === 'eat', 'and she takes that pose', String(dlgApi.currentMood))
|
|
3795
4851
|
dlg.advance(1500, 100)
|
|
3796
4852
|
check(dlgApi.currentMood === 'eat',
|
|
3797
4853
|
'the reaction poll leaves the preview alone while it lasts', String(dlgApi.currentMood))
|
|
4854
|
+
|
|
4855
|
+
/* 连续预览:同一个窗口里再点第二个动作,表情跟着换,窗仍然开着。 */
|
|
4856
|
+
const happyCell = dlg.all('wisp-cell').filter((el) => el.removed !== true).find((c) => c.dataset.mood === 'happy')
|
|
4857
|
+
happyCell.dispatch('click', { preventDefault() {}, stopPropagation() {} })
|
|
4858
|
+
check(dlgApi.dialog === 'actions' && dlgApi.currentMood === 'happy',
|
|
4859
|
+
'a second click previews the next action without reopening anything',
|
|
4860
|
+
'dialog=' + String(dlgApi.dialog) + ' mood=' + String(dlgApi.currentMood))
|
|
3798
4861
|
dlg.advance(9000, 500)
|
|
3799
|
-
check(dlgApi.currentMood !== '
|
|
4862
|
+
check(dlgApi.currentMood !== 'happy', 'and the preview does not stick forever', String(dlgApi.currentMood))
|
|
3800
4863
|
|
|
3801
|
-
|
|
4864
|
+
/* 关窗仍有三条路,先验右上角那个 ×(窗还开着,不用重开) */
|
|
3802
4865
|
const x = dlg.find('wisp-dialog-x')
|
|
3803
4866
|
check(x !== null, 'the close button is there')
|
|
3804
4867
|
x.dispatch('click', { preventDefault() {} })
|
|
@@ -4187,6 +5250,33 @@ head('3y-3. the focus timer, and going quiet while the page is hidden')
|
|
|
4187
5250
|
check(typeof quietApi.today.runs === 'number' && quietApi.today.runs >= 1,
|
|
4188
5251
|
'the day book is also readable from the api', JSON.stringify(quietApi.today))
|
|
4189
5252
|
|
|
5253
|
+
/* ---- 语言判定不能取决于 Node 版本 -------------------------------------------
|
|
5254
|
+
Node 21+ 自带全局 navigator(navigator.language = 系统语言)。harness 若不把它注入到
|
|
5255
|
+
被求值的作用域里,客户端读到的就是 Node 的 —— 于是同一份代码在中文开发机上绿、
|
|
5256
|
+
在英文 runner 上红。这条把 Node 的 navigator 特意伪装成 en-US,再断言她仍然按
|
|
5257
|
+
harness 自己的(没有 language 的)navigator 说中文。 */
|
|
5258
|
+
const nodeNav = Object.getOwnPropertyDescriptor(globalThis, 'navigator')
|
|
5259
|
+
try {
|
|
5260
|
+
Object.defineProperty(globalThis, 'navigator', {
|
|
5261
|
+
value: { language: 'en-US', languages: ['en-US'] }, configurable: true, writable: true,
|
|
5262
|
+
})
|
|
5263
|
+
const nodeNavHarness = createHarness({ timer: true, composerText: '' })
|
|
5264
|
+
const keepNodeNav = active
|
|
5265
|
+
active = nodeNavHarness
|
|
5266
|
+
nodeNavHarness.evaluate(clientSrc)
|
|
5267
|
+
nodeNavHarness.module().default.apply(nodeNavHarness.ctx, { reactions: false, wander: false, celebrate: false })
|
|
5268
|
+
nodeNavHarness.advance(1000, 200)
|
|
5269
|
+
const nodeNavSaid = String(nodeNavHarness.all('wisp-say').at(-1)?.textContent ?? '')
|
|
5270
|
+
check(/[\u4e00-\u9fa5]/.test(nodeNavSaid),
|
|
5271
|
+
'the harness hands the client its OWN navigator — Node 21+ has a global one and would leak its locale',
|
|
5272
|
+
`${nodeNavSaid.slice(0, 40)} | Node navigator=${globalThis.navigator.language}`)
|
|
5273
|
+
nodeNavHarness.win.__wisp.destroy()
|
|
5274
|
+
active = keepNodeNav
|
|
5275
|
+
} finally {
|
|
5276
|
+
if (nodeNav) Object.defineProperty(globalThis, 'navigator', nodeNav)
|
|
5277
|
+
else delete globalThis.navigator
|
|
5278
|
+
}
|
|
5279
|
+
|
|
4190
5280
|
/* ---- 英文台词:英文环境下她说英文,而且**一个汉字都不该出现** -------------------
|
|
4191
5281
|
语言是加载时判定的(读 navigator.language),所以这个 harness 要在 evaluate 之前
|
|
4192
5282
|
把 navigator 摆好。中文是兜底:没翻的条目仍然会说中文,这一条只验"翻译层真的生效"。 */
|
|
@@ -4294,6 +5384,257 @@ if (patchPath && existsSync(patchPath)) {
|
|
|
4294
5384
|
if (!pkg.dsh?.client?.inject && !pkg.dsh?.client?.shared) ok('no inject/shared declarations')
|
|
4295
5385
|
else bad('inject/shared declared', 'a local-path install cannot satisfy them')
|
|
4296
5386
|
|
|
5387
|
+
/* ============================ 4b. 帧动画素材进包了吗 ====================== */
|
|
5388
|
+
|
|
5389
|
+
head('4b. the clips ship as FILES — the bundle carries only their names (v1.47.0)')
|
|
5390
|
+
|
|
5391
|
+
/* v1.47.0 换了**投递方式**:表里不再有 base64,只有文件名;字节留在
|
|
5392
|
+
assets/motion/ 里,由宿主半包注册的 `/wisp-motion/` 路由发给页面。
|
|
5393
|
+
两个数因此分开盯,各有各的预算:
|
|
5394
|
+
|
|
5395
|
+
① **内联**:那张清单必须小到可以忽略(几条素材名,几十字节)。它曾经是
|
|
5396
|
+
8015.8 KB(两段 base64,v1.46.5 实测);八条泳装再内联会到 ~45 MB ——
|
|
5397
|
+
所以这一条的上界是"几百字节",不是"几 MB"。
|
|
5398
|
+
② **磁盘**:素材还是跟着包走的,涨了仍然要有人重新量一次。上界按**十条**估
|
|
5399
|
+
(通用 2 + 泳装 8):1.47.1 八条泳装落盘之后实测 **29800.0 KB / 29.1 MB**
|
|
5400
|
+
(每条 2617.0~3398.2 KB,最大的是通用的 sleepy),所以上界收到 **34000 KB**
|
|
5401
|
+
(33.2 MB,约 +14% 余量)。每次素材换代都要有人重新量一遍再改它,
|
|
5402
|
+
而不是让它自己漂。
|
|
5403
|
+
|
|
5404
|
+
上界写在这里而不是 build.mjs 里:构建负责**报**体积,预检负责**判**体积,
|
|
5405
|
+
一个数写两遍就是下一次漂移的起点。 */
|
|
5406
|
+
const MOTION_MANIFEST_BUDGET_BYTES = 2048
|
|
5407
|
+
const MOTION_DISK_BUDGET_KB = 34000
|
|
5408
|
+
{
|
|
5409
|
+
const kb = (n) => (n / 1024).toFixed(1)
|
|
5410
|
+
if (clientSrc === null) {
|
|
5411
|
+
bad('the motion clips ship with the package', 'lib/client.js is missing')
|
|
5412
|
+
} else {
|
|
5413
|
+
/* 表必须真的能求值:正则匹配到一段文本、而文本是注释里的,是踩过的坑
|
|
5414
|
+
(SPRITES 那次的注释占位符事故)。 */
|
|
5415
|
+
const motionTable = clientSrc.match(/const MOTION = (\{[\s\S]*?\n {4}\})/)
|
|
5416
|
+
let motionValue = null
|
|
5417
|
+
try { motionValue = motionTable ? new Function(`return ${motionTable[1]}`)() : null } catch (error) { motionValue = null }
|
|
5418
|
+
const keys = motionValue && typeof motionValue === 'object' ? Object.keys(motionValue) : []
|
|
5419
|
+
/** 清单里那一条指向的文件名(不是 data URI —— 那是 1.46.5 的老形状)。 */
|
|
5420
|
+
const clipFile = (k) => (typeof motionValue?.[k] === 'string' ? motionValue[k] : null)
|
|
5421
|
+
const clipPath = (k) => (clipFile(k) === null ? null : join(here, 'assets', 'motion', clipFile(k)))
|
|
5422
|
+
/* ① 表里只能是**文件名**:既有形状的检查,也是"没有被内联回来"的第一道。
|
|
5423
|
+
值以 data: 开头 = 构建退回内联了(八条泳装那 ~30 MB 会重新进单文件)。 */
|
|
5424
|
+
const named = keys.filter((k) => clipFile(k) !== null && /^[A-Za-z0-9][A-Za-z0-9._-]*\.webp$/.test(clipFile(k)))
|
|
5425
|
+
const stillInlined = keys.filter((k) => typeof motionValue[k] === 'string' && motionValue[k].startsWith('data:'))
|
|
5426
|
+
check(keys.length > 0 && named.length === keys.length && stillInlined.length === 0,
|
|
5427
|
+
'every MOTION entry is a clip FILE NAME — the bytes are not in the bundle any more (v1.47.0)',
|
|
5428
|
+
keys.length ? `${keys.join(', ')} → ${String(motionValue[keys[0]])}` : 'no MOTION table in the bundle')
|
|
5429
|
+
/* **通用那两段都要在**(v1.46.5):站着那一段和睡着那一段是两条独立的素材,
|
|
5430
|
+
只进一条(或者 build 的发现规则又把 motion/ 当成皮肤跳过了)在页面上表现为
|
|
5431
|
+
"她某个状态不动",而那和"没有素材"长得一模一样。
|
|
5432
|
+
v1.47.0 又多了泳装那八条 —— 它们由下面那条"谁在播"的反向检查兜住。 */
|
|
5433
|
+
check(keys.includes('idle') && keys.includes('sleepy'),
|
|
5434
|
+
'BOTH shared loops are still there — the standing one and the sleeping one',
|
|
5435
|
+
keys.length ? keys.join(', ') : 'no clips at all')
|
|
5436
|
+
|
|
5437
|
+
/* ① 内联的那一份必须**小**。这是这一版存在的理由:清单 = 素材名,
|
|
5438
|
+
八条泳装加进来也只多几十字节;换成字节就是 ~30 MB。 */
|
|
5439
|
+
const inlineBytes = Object.entries(motionValue ?? {}).reduce((n, [k, v]) => n + String(k).length + String(v).length + 8, 0)
|
|
5440
|
+
check(inlineBytes > 0 && inlineBytes <= MOTION_MANIFEST_BUDGET_BYTES,
|
|
5441
|
+
`the inlined part is only a NAME LIST — ${MOTION_MANIFEST_BUDGET_BYTES} B budget (a 8015.8 KB base64 table is what it replaced)`,
|
|
5442
|
+
`${inlineBytes} B for ${keys.length} clips`)
|
|
5443
|
+
|
|
5444
|
+
/* ② 磁盘上那八条 + 两条:素材仍然跟着包走,涨了要有第二个人重新量一次。 */
|
|
5445
|
+
const onDiskBytes = keys.reduce((n, k) => {
|
|
5446
|
+
const p = clipPath(k)
|
|
5447
|
+
return n + (p !== null && existsSync(p) ? readFileSync(p).length : 0)
|
|
5448
|
+
}, 0)
|
|
5449
|
+
check(onDiskBytes > 0 && onDiskBytes <= MOTION_DISK_BUDGET_KB * 1024,
|
|
5450
|
+
`and every clip it names really exists under assets/motion/, inside the ${MOTION_DISK_BUDGET_KB} KB disk budget`,
|
|
5451
|
+
`${kb(onDiskBytes)} KB on disk for ${keys.length} clips`)
|
|
5452
|
+
|
|
5453
|
+
/* ---- 边缘平滑度不是"看起来"的事,是**编码参数**的事(v1.46.4)----
|
|
5454
|
+
colorkey 的 blend 是边缘过渡带的半宽:0.02 那条带只有 0.04 的色距宽度,
|
|
5455
|
+
边缘几乎是**一刀切** —— 1 bit 的 alpha 在 560px 的显示尺寸上就是一排台阶
|
|
5456
|
+
("硬边 = 锯齿"这条规矩钉在这里,也钉在 client.template.js 的 .wisp-video 注释里)。
|
|
5457
|
+
查的是**文档里那条编码命令**:素材怎么来的可复现,这两条才有意义。
|
|
5458
|
+
v1.46.5 把两条素材的过渡都收到 **0.05** —— 这是 **≥ 0.05 那条线的下限**,
|
|
5459
|
+
不是"随手调小":那段 soft band 是动画体积的**大头**(实测:sleepy 在 q:v 40 下
|
|
5460
|
+
把 blend 归零,3405.7 KB → 1867.8 KB,省掉 45%)。收到下限、再把省下来的余量
|
|
5461
|
+
花在**去幕布色**上,是这一版两段素材能同时进包的原因。
|
|
5462
|
+
全不透明的阈值 sim+blend 保持原样(sleepy 0.24 / idle 0.38)—— 所以"她自己"
|
|
5463
|
+
一个像素都没少(实测不透明占比 17.379% / 27.016%,和上一版逐位相同)。
|
|
5464
|
+
只认那个写着 libwebp_anim 的代码块,免得命中变更日志里的历史参数。 */
|
|
5465
|
+
/* 1.47.0:文档里有**两块**写着 libwebp_anim 的命令(泳装那八条一条配方 + 通用两条
|
|
5466
|
+
的历史命令),所以这里查**每一块**:任何一条 posted 出去的编码命令都不许把 blend
|
|
5467
|
+
压到 0.05 以下。泳装那条的幕布色是**采样**来的(`#<采样色>`),所以匹配要接受占位
|
|
5468
|
+
形式的色值 —— 断言的是数字(similarity / blend),不是那个十六进制。 */
|
|
5469
|
+
const pipelineDoc = existsSync(join(here, 'README.md')) ? readFileSync(join(here, 'README.md'), 'utf8') : ''
|
|
5470
|
+
const pipelines = []
|
|
5471
|
+
for (const m of pipelineDoc.matchAll(/```[a-z]*\n([\s\S]*?)```/g)) {
|
|
5472
|
+
if (m[1].includes('libwebp_anim')) pipelines.push(m[1])
|
|
5473
|
+
}
|
|
5474
|
+
const colorkeys = pipelines
|
|
5475
|
+
.flatMap((p) => [...p.matchAll(/colorkey=#(?:[0-9A-Fa-f]{6}|<[^>\n]+>):([\d.]+):([\d.]+)/g)])
|
|
5476
|
+
.map((m) => ({ sim: m[1], blend: Number(m[2]) }))
|
|
5477
|
+
check(colorkeys.length > 0 && colorkeys.every((k) => k.blend >= 0.05),
|
|
5478
|
+
'every documented keying keeps a SOFT edge — blend >= 0.05, because a hard alpha cut IS the jagged edge (v1.46.4)',
|
|
5479
|
+
colorkeys.length ? colorkeys.map((k) => `sim ${k.sim} blend ${k.blend}`).join(' · ') : 'no colorkey line in the documented motion pipeline')
|
|
5480
|
+
/* 放宽过渡会把幕布的边一起放出来(绿幕:0.078% → 0.119%;品红幕:idle 那 1.09% 的
|
|
5481
|
+
紫边就是这么来的)—— 所以那一步 RGB 去边是**配套的**,不是可选项。绿幕那条是
|
|
5482
|
+
min(G, max(R,B))(和 tools/keyout.mjs 给立绘用的是同一条规则),品红幕那条把
|
|
5483
|
+
"R、B 都比 G 高"的那一份减掉。两条都在文档里。 */
|
|
5484
|
+
check(pipelines.some((p) => /geq=|despill/.test(p)),
|
|
5485
|
+
'and the documented pipeline de-fringes the screen colour in RGB — a soft transition lets the curtain edge back in',
|
|
5486
|
+
pipelines.length
|
|
5487
|
+
? (pipelines.map((p) => (p.split('\n').find((l) => /geq=|despill/.test(l)) ?? '').trim()).find((l) => l !== '') ?? '(no de-fringe line)').slice(0, 140)
|
|
5488
|
+
: 'no motion pipeline block in README.md')
|
|
5489
|
+
|
|
5490
|
+
/* 投递的另一半:**宿主真的在发**(v1.47.0)。这条路由不在时她只会静默不动,
|
|
5491
|
+
所以"注册了没有"不能靠读源码回答 —— 这里把 handler 拿出来、用真请求打一遍。 */
|
|
5492
|
+
/* 两个半包对**同一条路由**必须说同一个名字:客户端解析 `wisp-motion/<文件>`,
|
|
5493
|
+
宿主在 `/wisp-motion/` 上注册。这两个字符串写在不同文件里、构建也不查它们,
|
|
5494
|
+
于是"改了一处忘了另一处"在页面上只会表现为她不动(静默降级),没有任何报错。
|
|
5495
|
+
顺手把宿主那一半**真的跑一遍**:拿真字节过一遍 handler,状态码 / 内容类型 /
|
|
5496
|
+
内容都必须对 —— 这是这条投递链上唯一会出错的地方。 */
|
|
5497
|
+
const hostMod = await import(pathToFileURL(hostPath).href + '?probe=motion')
|
|
5498
|
+
const clientRoute = (/const MOTION_ROUTE = '([^']+)'/.exec(clientSrc) ?? [])[1] ?? null
|
|
5499
|
+
check(typeof hostMod.MOTION_PATH === 'string' && clientRoute !== null
|
|
5500
|
+
&& '/' + clientRoute === hostMod.MOTION_PATH,
|
|
5501
|
+
'the client half and the host half name the SAME route — one string, two files',
|
|
5502
|
+
`client "${String(clientRoute)}" vs host "${String(hostMod.MOTION_PATH)}"`)
|
|
5503
|
+
|
|
5504
|
+
const served = []
|
|
5505
|
+
for (const key of keys) {
|
|
5506
|
+
const file = clipFile(key)
|
|
5507
|
+
const diskPath = clipPath(key)
|
|
5508
|
+
const onDisk = diskPath !== null && existsSync(diskPath) ? readFileSync(diskPath) : null
|
|
5509
|
+
const response = { status: 0, headers: null, body: null }
|
|
5510
|
+
await hostMod.motionHandler(hostMod.defaultReadBytes)({ method: 'GET', url: `${hostMod.MOTION_PATH}${file}` }, {
|
|
5511
|
+
writeHead(status, headers) { response.status = status; response.headers = headers },
|
|
5512
|
+
end(chunk) { response.body = chunk ?? null },
|
|
5513
|
+
destroy() {},
|
|
5514
|
+
})
|
|
5515
|
+
const body = response.body === null || response.body === undefined ? null : Buffer.from(response.body)
|
|
5516
|
+
const identical = onDisk !== null && body !== null && body.length === onDisk.length && body.equals(onDisk)
|
|
5517
|
+
if (response.status !== 200 || String(response.headers?.['content-type']) !== 'image/webp' || !identical) {
|
|
5518
|
+
served.push(`${key}: ${response.status} ${String(response.headers?.['content-type'])} identical=${identical}`)
|
|
5519
|
+
}
|
|
5520
|
+
}
|
|
5521
|
+
check(keys.length > 0 && served.length === 0,
|
|
5522
|
+
'and the host route really serves each clip: 200, image/webp, bytes identical to the file on disk',
|
|
5523
|
+
served.length ? served.join(' | ') : keys.map((k) => `${k} → ${hostMod.MOTION_PATH}${clipFile(k)}`).join(', '))
|
|
5524
|
+
/* 它不是一个文件服务器:只有 assets/motion/ 下那一个名字、只有 GET/HEAD。 */
|
|
5525
|
+
const refused = []
|
|
5526
|
+
for (const [label, url, method, want] of [
|
|
5527
|
+
['traversal', `${hostMod.MOTION_PATH}../../package.json`, 'GET', 404],
|
|
5528
|
+
['nested', `${hostMod.MOTION_PATH}a/b.webp`, 'GET', 404],
|
|
5529
|
+
['non-webp', `${hostMod.MOTION_PATH}idle.png`, 'GET', 404],
|
|
5530
|
+
['bare prefix', hostMod.MOTION_PATH, 'GET', 404],
|
|
5531
|
+
['write', `${hostMod.MOTION_PATH}${clipFile(keys[0])}`, 'POST', 405],
|
|
5532
|
+
]) {
|
|
5533
|
+
const response = { status: 0 }
|
|
5534
|
+
await hostMod.motionHandler(hostMod.defaultReadBytes)({ method, url }, {
|
|
5535
|
+
writeHead(status) { response.status = status }, end() {}, destroy() {},
|
|
5536
|
+
})
|
|
5537
|
+
if (response.status !== want) refused.push(`${label}: ${response.status} (want ${want})`)
|
|
5538
|
+
}
|
|
5539
|
+
check(refused.length === 0,
|
|
5540
|
+
'and it stays a closed list — traversal, nested paths, other extensions and writes are all refused',
|
|
5541
|
+
refused.length ? refused.join(' | ') : 'traversal/nested/non-webp/prefix → 404, POST → 405')
|
|
5542
|
+
|
|
5543
|
+
/* 磁盘上那份文件**真的是动图**(RIFF/WEBP + ANMF 帧块):MIME 是写死的,
|
|
5544
|
+
素材被换成一张静图时,"她不动了"会和"没有素材"长得一模一样。
|
|
5545
|
+
顺手把**几何**也钉住(v1.46.4):720x1280 / 97 帧 / 41ms 一帧(24fps)这三条是
|
|
5546
|
+
"这份素材是怎么来的"的一部分 —— 换 q:v、换 colorkey 都可以,动这几条就是另一份
|
|
5547
|
+
素材了;而"她变糊了"和"她掉帧了"在页面上都不会报错,只会有人某天觉得"她今天不太对"。
|
|
5548
|
+
容器结构:VP8X 的画布宽高各 3 字节(存的是值-1),ANMF 的帧时长在块内偏移 12 处,
|
|
5549
|
+
同样 3 字节、单位毫秒。 */
|
|
5550
|
+
const animated = []
|
|
5551
|
+
const geometry = []
|
|
5552
|
+
for (const key of keys) {
|
|
5553
|
+
const file = clipPath(key)
|
|
5554
|
+
if (file === null || !existsSync(file)) continue
|
|
5555
|
+
const onDisk = readFileSync(file)
|
|
5556
|
+
const riff = onDisk.subarray(0, 4).toString('latin1') === 'RIFF' && onDisk.subarray(8, 12).toString('latin1') === 'WEBP'
|
|
5557
|
+
let canvasW = 0
|
|
5558
|
+
let canvasH = 0
|
|
5559
|
+
let frames = 0
|
|
5560
|
+
let firstDur = 0
|
|
5561
|
+
for (let off = 12; off + 8 <= onDisk.length;) {
|
|
5562
|
+
const id = onDisk.subarray(off, off + 4).toString('latin1')
|
|
5563
|
+
const size = onDisk.readUInt32LE(off + 4)
|
|
5564
|
+
if (id === 'VP8X') {
|
|
5565
|
+
canvasW = onDisk.readUIntLE(off + 12, 3) + 1
|
|
5566
|
+
canvasH = onDisk.readUIntLE(off + 15, 3) + 1
|
|
5567
|
+
} else if (id === 'ANMF') {
|
|
5568
|
+
frames += 1
|
|
5569
|
+
if (firstDur === 0) firstDur = onDisk.readUIntLE(off + 20, 3)
|
|
5570
|
+
}
|
|
5571
|
+
off += 8 + size + (size % 2)
|
|
5572
|
+
}
|
|
5573
|
+
if (!riff || frames < 2) animated.push(`${key}: riff=${riff} frames=${frames}`)
|
|
5574
|
+
if (`${canvasW}x${canvasH}` !== '720x1280' || frames !== 97 || Math.round(1000 / firstDur) !== 24) {
|
|
5575
|
+
geometry.push(`${key}: ${canvasW}x${canvasH} / ${frames} frames / ${firstDur}ms`)
|
|
5576
|
+
}
|
|
5577
|
+
}
|
|
5578
|
+
check(animated.length === 0,
|
|
5579
|
+
'and it is an ANIMATED WebP (RIFF/WEBP with a chain of ANMF frames), not a still',
|
|
5580
|
+
animated.length ? animated.join(' | ') : keys.map((k) => `${k}: ${readFileSync(clipPath(k)).toString('latin1').split('ANMF').length - 1} frames`).join(', '))
|
|
5581
|
+
check(geometry.length === 0,
|
|
5582
|
+
'and its geometry did not drift — 720x1280, 97 frames, 41ms each (24 fps): quality and keying may change, the frame budget may not (v1.46.4)',
|
|
5583
|
+
geometry.length ? geometry.join(' | ') : keys.map((k) => `${k}: 720x1280 / 97 frames / 41 ms = 24 fps`).join(', '))
|
|
5584
|
+
|
|
5585
|
+
/* 谁在播哪一段(v1.47.0 起是**按皮肤**点的)。
|
|
5586
|
+
正向:每个 `<皮肤>:<状态>` 指向的素材必须真的在清单里(打错一个字 = 那个状态
|
|
5587
|
+
永远不动,而画面看不出错)。
|
|
5588
|
+
反向:清单里**没有状态会播**的素材是白带的体积(磁盘预算里最贵的就是它)。 */
|
|
5589
|
+
const moodMap = (/const MOTION_OF = (\{[^}]*\})/.exec(clientSrc) ?? [])[1]
|
|
5590
|
+
let wired = null
|
|
5591
|
+
try { wired = moodMap ? new Function(`return ${moodMap}`)() : null } catch (error) { wired = null }
|
|
5592
|
+
const baseMap = (/const MOTION_BASE = (\{[^}]*\})/.exec(clientSrc) ?? [])[1]
|
|
5593
|
+
let base = null
|
|
5594
|
+
try { base = baseMap ? new Function(`return ${baseMap}`)() : null } catch (error) { base = null }
|
|
5595
|
+
const wiredClips = [...Object.values(wired ?? {}), ...Object.values(base ?? {})]
|
|
5596
|
+
/* 孤儿只算**这个包真的带了的**通用素材:没生成的泳装素材在表里写着是**对的**
|
|
5597
|
+
(那正是接线),它在页面上走静默降级,不是坏行为。素材有没有是上面那条的事。 */
|
|
5598
|
+
const orphans = Object.keys(wired ?? {}).filter((m) => !String(wired[m]).startsWith('swim_')
|
|
5599
|
+
&& typeof motionValue?.[wired[m]] !== 'string')
|
|
5600
|
+
const badKeys = Object.keys(wired ?? {}).filter((m) => !/^[a-z]+:.+$/.test(m))
|
|
5601
|
+
const unwired = keys.filter((k) => !wiredClips.includes(k))
|
|
5602
|
+
check(wired !== null && base !== null && base.idle === 'idle' && base.sleep === 'sleepy'
|
|
5603
|
+
&& orphans.length === 0 && unwired.length === 0 && badKeys.length === 0,
|
|
5604
|
+
'every <skin>:<state> is wired to a clip that is really shipped — and no clip ships that no state can play',
|
|
5605
|
+
wired ? `${JSON.stringify(wired)} · base=${JSON.stringify(base)}${unwired.length ? ` · 没人播:${unwired.join(', ')}` : ''}${orphans.length ? ` · 查不到:${orphans.join(', ')}` : ''}` : 'no MOTION_OF map found')
|
|
5606
|
+
/* 泳装那八条(v1.47.0)。判据是**磁盘上实际带着什么**(见文件上方 SHIPPED_SWIM):
|
|
5607
|
+
"一条都没生成"与"八条都在"都是合法状态 —— 前者走既有的静默降级(画面回到立绘),
|
|
5608
|
+
半套才是坏的。所以这里查的是**接线与素材一一对上**,两个方向都查:
|
|
5609
|
+
① 每个泳装状态指向的素材,只要它在包里就必须真的能查到(名字打错一条 = 那个
|
|
5610
|
+
状态永远不动,而画面不会报错);
|
|
5611
|
+
② 包里每一条泳装素材都必须有状态会播(没人播 = 白带的体积)。 */
|
|
5612
|
+
const swimKeys = Object.keys(wired ?? {}).filter((k) => k.startsWith('swim:'))
|
|
5613
|
+
const swimClips = swimKeys.map((k) => wired[k])
|
|
5614
|
+
const swimMissing = swimKeys.filter((k) => SHIPPED_SWIM.includes(`${wired[k]}.webp`)
|
|
5615
|
+
&& typeof motionValue?.[wired[k]] !== 'string')
|
|
5616
|
+
const swimUnwired = SHIPPED_SWIM.map((f) => f.slice(0, -'.webp'.length))
|
|
5617
|
+
.filter((c) => !Object.values(wired ?? {}).includes(c))
|
|
5618
|
+
check(SHIPPED_SWIM.length === 0 || SHIPPED_SWIM.length === 8,
|
|
5619
|
+
'the eight swimsuit clips ship as a SET — never half of them',
|
|
5620
|
+
SHIPPED_SWIM.length
|
|
5621
|
+
? `${SHIPPED_SWIM.length} clip(s): ${SHIPPED_SWIM.join(', ')}`
|
|
5622
|
+
: '0/8 generated yet — the swimsuit states fall back to the shared loops, silently by design')
|
|
5623
|
+
check(swimKeys.length === 8 && new Set(swimKeys).size === 8
|
|
5624
|
+
&& (SHIPPED_SWIM.length === 0 || new Set(swimClips).size === 8)
|
|
5625
|
+
&& swimMissing.length === 0 && swimUnwired.length === 0,
|
|
5626
|
+
'all eight swimsuit states are wired, each to its OWN clip — and every clip that ships has a state to play it (v1.47.0)',
|
|
5627
|
+
`wired ${swimKeys.length}/8 · shipped ${SHIPPED_SWIM.length}/8`
|
|
5628
|
+
+ (swimUnwired.length ? ` · 没人播:${swimUnwired.join(', ')}` : '')
|
|
5629
|
+
+ (swimMissing.length ? ` · 查不到:${swimMissing.join(', ')}` : ''))
|
|
5630
|
+
|
|
5631
|
+
check(!/['"][^'"]*\.(webm|mp4|mov)['"]/.test(clientSrc),
|
|
5632
|
+
'the bundle references no external video file', 'no .webm/.mp4/.mov string in the bundle')
|
|
5633
|
+
check(!clientSrc.includes('__MOTION_LITERAL__'),
|
|
5634
|
+
'no leftover motion placeholder', 'run `node build.mjs`')
|
|
5635
|
+
}
|
|
5636
|
+
}
|
|
5637
|
+
|
|
4297
5638
|
/* ---- 5. shipping readiness: sprites inlined, attribution present ---- */
|
|
4298
5639
|
|
|
4299
5640
|
head('5. redistribution readiness')
|
|
@@ -4319,6 +5660,143 @@ if (existsSync(join(here, 'README.md'))) {
|
|
|
4319
5660
|
undeclared.length ? undeclared.join(' | ') : `${mentioned.length} tool(s) referenced, all in files[]`)
|
|
4320
5661
|
}
|
|
4321
5662
|
|
|
5663
|
+
/* ---------------------------------------------------------------------------
|
|
5664
|
+
1.43.0:一批修复各自的不变式。
|
|
5665
|
+
|
|
5666
|
+
每条修复背后都有一句"如果再犯就会露出来"的断言 —— 没有它,这些修复会在下一次
|
|
5667
|
+
重构里悄悄退回原样(这批里有三条正是这么来的)。 */
|
|
5668
|
+
{
|
|
5669
|
+
const h = createHarness({ timer: true, composerText: '', wanderMs: 150, wanderRange: 200 })
|
|
5670
|
+
const keep = active
|
|
5671
|
+
active = h
|
|
5672
|
+
h.evaluate(clientSrc)
|
|
5673
|
+
h.module().default.apply(h.ctx, { reactions: false, wander: true, celebrate: false })
|
|
5674
|
+
h.advance(1200, 100)
|
|
5675
|
+
const api = h.win.__wisp
|
|
5676
|
+
const root = h.document.querySelector('.wisp-root')
|
|
5677
|
+
const tiltDeg = () => {
|
|
5678
|
+
const n = Number(String(root.style.getPropertyValue('--wisp-tilt') || '0deg').replace('deg', ''))
|
|
5679
|
+
return Number.isFinite(n) ? n : 0
|
|
5680
|
+
}
|
|
5681
|
+
|
|
5682
|
+
/* 脚底光晕在 motion 层、不在 lean 层:歪的是她,不该是地板 */
|
|
5683
|
+
const shineEl = h.document.querySelector('.wisp-shine')
|
|
5684
|
+
const motionEl = h.document.querySelector('.wisp-motion')
|
|
5685
|
+
check(shineEl !== null && motionEl !== null && shineEl.parentNode === motionEl,
|
|
5686
|
+
'the ground glow lives outside the tilt layer',
|
|
5687
|
+
shineEl && shineEl.parentNode ? String(shineEl.parentNode.className) : '(missing)')
|
|
5688
|
+
|
|
5689
|
+
/* 静止档的 z:静止 ≠ 冻成满不透明 */
|
|
5690
|
+
check(clientSrc.includes('.wisp-root[data-motion="off"] .wisp-zzz{opacity:.5}'),
|
|
5691
|
+
'the still level shows a subdued z instead of a frozen opaque one')
|
|
5692
|
+
const rmAt = clientSrc.indexOf('@media (prefers-reduced-motion:reduce)')
|
|
5693
|
+
check(rmAt >= 0 && clientSrc.slice(rmAt, rmAt + 800).includes('.wisp-zzz{animation:none!important;opacity:.5}'),
|
|
5694
|
+
'and reduced motion does the same for it')
|
|
5695
|
+
|
|
5696
|
+
/* 滑块的默认值必须落在步长格点上(否则用户动了就回不到出厂大小) */
|
|
5697
|
+
const wheelStep = Number((/const WHEEL_STEP = ([0-9.]+)/.exec(clientSrc) ?? [])[1])
|
|
5698
|
+
const keyStep = Number((/ev\.shiftKey \? 1 : ([0-9.]+)/.exec(clientSrc) ?? [])[1])
|
|
5699
|
+
const sizeLo = Number((/size: \{ kind: 'num', lo: ([0-9.]+)/.exec(clientSrc) ?? [])[1])
|
|
5700
|
+
const defaultsBlock = clientSrc.match(/const DEFAULTS = \{([\s\S]*?)\n {4}\}/)
|
|
5701
|
+
const defSize = Number((/size:\s*([0-9.]+)/.exec(defaultsBlock ? defaultsBlock[1] : '') ?? [])[1])
|
|
5702
|
+
check(wheelStep > 0 && wheelStep === keyStep, 'the wheel and the slider share exactly one step',
|
|
5703
|
+
`wheel=${wheelStep} key=${keyStep}`)
|
|
5704
|
+
const steps = (defSize - sizeLo) / wheelStep
|
|
5705
|
+
check(Number.isFinite(steps) && Math.abs(steps - Math.round(steps)) < 1e-9,
|
|
5706
|
+
'the DEFAULT size is reachable from lo in whole steps — otherwise a user can never get back to it',
|
|
5707
|
+
`lo=${sizeLo} default=${defSize} step=${wheelStep} → ${steps} steps`)
|
|
5708
|
+
|
|
5709
|
+
/* 鼠标路过会侧身;手指路过不会(而且会把姿势归零)。
|
|
5710
|
+
y 必须取她的**中线**:侧身有"从正上方路过不倾"这一层(level 因子),
|
|
5711
|
+
在脚下 700px 处派发事件本来就该是 0 度 —— 那样测的是另一条规则。 */
|
|
5712
|
+
const nearX = api.position.x + 280
|
|
5713
|
+
const nearY = api.position.y + 420
|
|
5714
|
+
h.win.dispatch('pointermove', { clientX: nearX + 24, clientY: nearY, pointerType: 'mouse' })
|
|
5715
|
+
check(tiltDeg() !== 0, 'a mouse passing by still leans her', `${tiltDeg()}deg`)
|
|
5716
|
+
h.win.dispatch('pointermove', { clientX: nearX + 24, clientY: nearY, pointerType: 'touch' })
|
|
5717
|
+
check(tiltDeg() === 0, 'a finger passing by does not — and clears the pose', `${tiltDeg()}deg`)
|
|
5718
|
+
|
|
5719
|
+
/* 指针离开文档:姿势归零(blur 只覆盖"切走应用") */
|
|
5720
|
+
h.win.dispatch('pointermove', { clientX: nearX + 24, clientY: nearY, pointerType: 'mouse' })
|
|
5721
|
+
check(tiltDeg() !== 0, 'leaning again before the pointer leaves', `${tiltDeg()}deg`)
|
|
5722
|
+
h.document.dispatch('mouseleave', {})
|
|
5723
|
+
check(tiltDeg() === 0, 'the pose resets when the pointer leaves the document', `${tiltDeg()}deg`)
|
|
5724
|
+
|
|
5725
|
+
/* 撞到边界不再"原地歪":位置没变就不该有侧倾 */
|
|
5726
|
+
api.move(1360, 300)
|
|
5727
|
+
h.win.dispatch('pointerdown', {
|
|
5728
|
+
clientX: 1360 + 280, clientY: 300 + 700, button: 0, pointerType: 'mouse',
|
|
5729
|
+
preventDefault() {}, stopPropagation() {},
|
|
5730
|
+
})
|
|
5731
|
+
h.win.dispatch('pointermove', { clientX: 1360 + 280 + 60, clientY: 300 + 700, pointerType: 'mouse' })
|
|
5732
|
+
check(api.position.x === 1360, 'a drag into the right edge stays clamped', String(api.position.x))
|
|
5733
|
+
check(tiltDeg() === 0, 'and a clamped drag does not tilt her in place', `${tiltDeg()}deg`)
|
|
5734
|
+
h.win.dispatch('pointerup', {})
|
|
5735
|
+
|
|
5736
|
+
/* 静止档也要停住 JS 溜达 —— CSS 关不掉坐标写入 */
|
|
5737
|
+
api.configure({ motion: 'off', wander: true, wanderMs: 150, wanderRange: 200 })
|
|
5738
|
+
const still = { x: api.position.x, y: api.position.y }
|
|
5739
|
+
h.advance(4000, 100)
|
|
5740
|
+
check(api.position.x === still.x && api.position.y === still.y,
|
|
5741
|
+
'motion: off stops the walk too, not only the animation', `${api.position.x},${api.position.y}`)
|
|
5742
|
+
api.configure({ motion: 'full' })
|
|
5743
|
+
h.advance(6000, 100)
|
|
5744
|
+
check(api.position.x !== still.x || api.position.y !== still.y,
|
|
5745
|
+
'and with motion back on she strolls again', `${api.position.x},${api.position.y}`)
|
|
5746
|
+
|
|
5747
|
+
/* 拨开关之后子面板要留在原地(点开的就该一直开着)。
|
|
5748
|
+
contextmenu 要派给**她的本体**(监听挂在 body 上),派给 window 不会走到那一层。 */
|
|
5749
|
+
h.document.querySelector('.wisp-body').dispatch('contextmenu', {
|
|
5750
|
+
clientX: api.position.x + 280, clientY: api.position.y + 420,
|
|
5751
|
+
preventDefault() {}, stopPropagation() {},
|
|
5752
|
+
})
|
|
5753
|
+
const groupRow = h.all('wisp-menu-item').find((el) => el.dataset && el.dataset.group === '行为')
|
|
5754
|
+
check(groupRow !== undefined, 'the behaviour group row is there to click', String(h.all('wisp-menu-item').length) + ' rows')
|
|
5755
|
+
if (groupRow) {
|
|
5756
|
+
groupRow.dispatch('click', { preventDefault() {}, stopPropagation() {} })
|
|
5757
|
+
h.advance(60, 20)
|
|
5758
|
+
const flyoutBefore = h.all('wisp-submenu').length
|
|
5759
|
+
const aSwitch = h.all('wisp-switch')[0]
|
|
5760
|
+
check(flyoutBefore > 0 && aSwitch !== undefined, 'its flyout is open with switches in it', String(flyoutBefore))
|
|
5761
|
+
if (aSwitch) {
|
|
5762
|
+
aSwitch.dispatch('click', { preventDefault() {}, stopPropagation() {} })
|
|
5763
|
+
h.advance(60, 20)
|
|
5764
|
+
check(h.all('wisp-submenu').length > 0,
|
|
5765
|
+
'toggling a switch keeps the flyout open — the panel vanishing is the most annoying version of this',
|
|
5766
|
+
`${h.all('wisp-submenu').length} panel(s)`)
|
|
5767
|
+
}
|
|
5768
|
+
}
|
|
5769
|
+
api.destroy()
|
|
5770
|
+
active = keep
|
|
5771
|
+
}
|
|
5772
|
+
|
|
5773
|
+
/* GitHub 上更新、npm 上还是旧版时:提示要改口,并且复制的是仓库地址而不是包名 */
|
|
5774
|
+
{
|
|
5775
|
+
const gh = createHarness({
|
|
5776
|
+
timer: true, composerText: '', clipboard: true,
|
|
5777
|
+
hostCall: async () => ({
|
|
5778
|
+
ok: true, latest: '9.9.9', from: 'github',
|
|
5779
|
+
sources: { npm: { ok: true, version: '1.0.0' }, github: { ok: true, version: '9.9.9' } },
|
|
5780
|
+
}),
|
|
5781
|
+
})
|
|
5782
|
+
const keepGh = active
|
|
5783
|
+
active = gh
|
|
5784
|
+
gh.evaluate(clientSrc)
|
|
5785
|
+
gh.module().default.apply(gh.ctx, { reactions: false, wander: false, celebrate: false })
|
|
5786
|
+
gh.advance(1200, 100)
|
|
5787
|
+
gh.win.__wisp.showUpdate()
|
|
5788
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
5789
|
+
gh.advance(200, 50)
|
|
5790
|
+
check(gh.clipboardWrites[gh.clipboardWrites.length - 1] === 'https://github.com/969246694/dsh-wisp',
|
|
5791
|
+
'when only GitHub has the new version she copies the repo URL, not the package name that installs the old one',
|
|
5792
|
+
JSON.stringify(gh.clipboardWrites))
|
|
5793
|
+
check(String(gh.win.__wisp.doctor().update.hint).includes('npm 上还是旧版'),
|
|
5794
|
+
'and doctor() records which hint she actually used',
|
|
5795
|
+
String(gh.win.__wisp.doctor().update.hint))
|
|
5796
|
+
gh.win.__wisp.destroy()
|
|
5797
|
+
active = keepGh
|
|
5798
|
+
}
|
|
5799
|
+
|
|
4322
5800
|
if (clientSrc !== null) {
|
|
4323
5801
|
/* ---------------------------------------------------------------------------
|
|
4324
5802
|
配置键是这套"多端"里最容易漂的一处:它在**三个地方**各出现一次 ——
|
|
@@ -4389,10 +5867,25 @@ if (clientSrc !== null) {
|
|
|
4389
5867
|
if (clientSrc.includes('__SPRITES__')) bad('no leftover build placeholder', 'run `node build.mjs`')
|
|
4390
5868
|
else ok('no leftover build placeholder')
|
|
4391
5869
|
|
|
4392
|
-
|
|
4393
|
-
|
|
4394
|
-
|
|
4395
|
-
|
|
5870
|
+
/* 自足性(1.43 立的规矩):立绘是**内联**的 —— 一个包外路径都不能有,别人装走
|
|
5871
|
+
也能直接画出来。
|
|
5872
|
+
v1.47.0 给这条规矩开了一个**明确的例外**:帧动画素材是按 URL 外置加载的
|
|
5873
|
+
(八条泳装 +30 MB,塞不进单文件),它的文件名就是 MOTION 清单里那几条,
|
|
5874
|
+
由宿主路由发出来。所以这里查的是两件事,而不是笼统的"不许出现 .webp":
|
|
5875
|
+
① 包体里出现的 .webp 文件名**只能**是 MOTION 清单里那几条 —— 多一条就意味着
|
|
5876
|
+
有人又写死了一个路径,而那条路径在别人机器上不存在;
|
|
5877
|
+
② 立绘那一份仍然是 data URI(下面上面那条断言已经逐格查过)。
|
|
5878
|
+
文件**不存在**不算失败:那是"这一段没有素材",客户端有静默降级。 */
|
|
5879
|
+
const motionBlock = (clientSrc.match(/const MOTION = \{[\s\S]*?\n {4}\}/) ?? [''])[0]
|
|
5880
|
+
const manifestFiles = [...motionBlock.matchAll(/'([A-Za-z0-9._-]+\.webp)'/g)].map((m) => m[1])
|
|
5881
|
+
const namedFiles = [...new Set([...clientSrc.matchAll(/['"]([A-Za-z0-9._-]+\.webp)['"]/g)].map((m) => m[1]))]
|
|
5882
|
+
const strayFiles = namedFiles.filter((f) => !manifestFiles.includes(f))
|
|
5883
|
+
if (manifestFiles.length > 0 && strayFiles.length === 0) {
|
|
5884
|
+
ok('the bundle names no image file outside the MOTION manifest', `${manifestFiles.length} clip name(s): ${manifestFiles.join(', ')}`)
|
|
5885
|
+
} else {
|
|
5886
|
+
bad('the bundle names no image file outside the MOTION manifest',
|
|
5887
|
+
strayFiles.length ? `stray: ${strayFiles.join(', ')}` : 'no MOTION manifest found')
|
|
5888
|
+
}
|
|
4396
5889
|
|
|
4397
5890
|
const stamped = clientSrc.match(/const VERSION = '([^']+)'/)
|
|
4398
5891
|
if (stamped && stamped[1] === pkg.version) ok('the bundle stamps the package version', stamped[1])
|
|
@@ -4440,6 +5933,38 @@ if (existsSync(join(here, 'README.md'))) {
|
|
|
4440
5933
|
check(readmeVersion === pkg.version, 'the README header quotes the packaged version',
|
|
4441
5934
|
`README ${readmeVersion ?? '(没写)'} vs package ${pkg.version}`)
|
|
4442
5935
|
|
|
5936
|
+
/* 客户端自己抓的那条路必须真的能成:给它一个可用的 window.fetch,她要报出版本。
|
|
5937
|
+
这就是 1.44.0 的全部要点 —— bundle 客户端半包是普通页面脚本,fetch 可用。 */
|
|
5938
|
+
{
|
|
5939
|
+
const fetched = createHarness({ timer: true, composerText: '' })
|
|
5940
|
+
const keepFetched = active
|
|
5941
|
+
active = fetched
|
|
5942
|
+
fetched.win.fetch = (url) => Promise.resolve({
|
|
5943
|
+
ok: true,
|
|
5944
|
+
json: () => Promise.resolve(String(url).indexOf('raw.githubusercontent') >= 0 ? { version: '1.0.0' } : { version: '9.9.9' }),
|
|
5945
|
+
})
|
|
5946
|
+
fetched.evaluate(clientSrc)
|
|
5947
|
+
fetched.module().default.apply(fetched.ctx, { reactions: false, wander: false, celebrate: false })
|
|
5948
|
+
fetched.advance(1000, 100)
|
|
5949
|
+
const clientRes = await fetched.win.__wisp.checkForUpdate()
|
|
5950
|
+
check(clientRes.state === 'available' && clientRes.latest === '9.9.9' && clientRes.from === 'npm',
|
|
5951
|
+
'with a usable window.fetch the CLIENT half does the whole check itself — no host RPC involved',
|
|
5952
|
+
JSON.stringify(clientRes))
|
|
5953
|
+
fetched.win.__wisp.destroy()
|
|
5954
|
+
active = keepFetched
|
|
5955
|
+
}
|
|
5956
|
+
/* 动作层**绝不许吃 CSS filter**(v1.45.3 立的规矩,v1.46.1 换成动图 <img> 之后照旧):
|
|
5957
|
+
滤镜会给这一层再插一次栅格化,alpha 剪影被重新合成一遍就是"她整个人变黑"的成因。
|
|
5958
|
+
阴影与调色只属于静态立绘。 */
|
|
5959
|
+
{
|
|
5960
|
+
const css = String(clientSrc)
|
|
5961
|
+
const motionRule = (css.match(/\.wisp-video\{[^}]*\}/g) || []).join(' ')
|
|
5962
|
+
check(motionRule.length > 0 && !/filter\s*:/.test(motionRule),
|
|
5963
|
+
'no CSS filter is applied to the motion layer (a filter re-rasterises the alpha cutout)',
|
|
5964
|
+
motionRule.slice(0, 160))
|
|
5965
|
+
check(!/\[data-mood="[a-z]+"\]\s*\.wisp-video\{[^}]*filter\s*:/.test(css),
|
|
5966
|
+
'and no mood rule re-adds a filter to the motion layer either')
|
|
5967
|
+
}
|
|
4443
5968
|
const quoted = Number((/当前 \*\*(\d+) 项全 PASS/.exec(readmeText) ?? [])[1])
|
|
4444
5969
|
const total = checks + 1
|
|
4445
5970
|
if (quoted === total) {
|