dsh-wisp 1.26.0 → 1.28.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 +84 -0
- package/assets/canon/eat.webp +0 -0
- package/assets/classic/eat.webp +0 -0
- package/assets/deepsea/eat.webp +0 -0
- package/assets/night/eat.webp +0 -0
- package/assets/pajama/eat.webp +0 -0
- package/build.mjs +1 -1
- package/lib/client.js +363 -53
- package/lib/client.template.js +358 -53
- package/package.json +1 -1
- package/tools/assets.mjs +1 -1
- package/tools/smoke.mjs +80 -6
- package/verify-wisp.mjs +294 -15
package/tools/smoke.mjs
CHANGED
|
@@ -224,23 +224,42 @@ const cornerMenu = await page.evaluate(() => {
|
|
|
224
224
|
})
|
|
225
225
|
check(cornerMenu !== null, 'a menu can be opened near the bottom of her body',
|
|
226
226
|
cornerMenu ? `pointer at y=${cornerMenu.pointerY}` : 'no candidate point hit her silhouette')
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
227
|
+
/* 顶层只有 7 项,"靠近底部"未必真的需要夹取 —— 原来那条 naiveBottom > 900 的守卫
|
|
228
|
+
在菜单变矮之后自己就失效了。改成:展开一个分组把菜单撑高,再验证同一个性质。
|
|
229
|
+
顺带这也是真浏览器里第一次验证折叠分组。 */
|
|
230
|
+
const tallMenu = await page.evaluate(() => {
|
|
231
|
+
const row = [...document.querySelectorAll('.wisp-menu-item')].find((i) => i.dataset && i.dataset.group)
|
|
232
|
+
if (!row) return null
|
|
233
|
+
row.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
|
|
234
|
+
const menu = document.querySelector('.wisp-menu')
|
|
235
|
+
if (!menu) return null
|
|
236
|
+
const r = menu.getBoundingClientRect()
|
|
237
|
+
return {
|
|
238
|
+
items: menu.querySelectorAll('.wisp-menu-item').length,
|
|
239
|
+
left: r.left, top: r.top, right: r.right, bottom: r.bottom,
|
|
240
|
+
vw: window.innerWidth, vh: window.innerHeight,
|
|
241
|
+
}
|
|
242
|
+
})
|
|
243
|
+
check(tallMenu !== null && tallMenu.items > 7, 'a group expands inside the real menu',
|
|
244
|
+
tallMenu ? `${tallMenu.items} 项` : '没找到分组行')
|
|
245
|
+
check(tallMenu !== null && tallMenu.left >= 0 && tallMenu.top >= 0
|
|
246
|
+
&& tallMenu.right <= tallMenu.vw && tallMenu.bottom <= tallMenu.vh,
|
|
247
|
+
'and the expanded menu still stays inside the viewport',
|
|
248
|
+
tallMenu ? JSON.stringify(tallMenu) : 'n/a')
|
|
230
249
|
check(cornerMenu !== null && cornerMenu.inside,
|
|
231
250
|
'and the menu is clamped back inside the viewport',
|
|
232
251
|
cornerMenu ? `bottom ${cornerMenu.bottom} (was ${cornerMenu.naiveBottom}), right ${cornerMenu.right}` : 'n/a')
|
|
233
252
|
|
|
234
253
|
const menu = await openMenu()
|
|
235
|
-
// 项数从皮肤数推导(
|
|
254
|
+
// 项数从皮肤数推导(14 项固定 + 每套皮肤一项),不写死:加一套皮肤不该让测试变红 ——
|
|
236
255
|
// 那是数据的错,不是行为的错。
|
|
237
256
|
// api.skins 是**数组**;用 Object.keys 数数组只是碰巧数对(预检里踩过这个坑,这里一并对齐)。
|
|
238
257
|
const skinCount = await page.evaluate(() => {
|
|
239
258
|
const s = window.__wisp.skins
|
|
240
259
|
return Array.isArray(s) ? s.length : Object.keys(s ?? {}).length
|
|
241
260
|
})
|
|
242
|
-
check(menu !== null && menu.items.length ===
|
|
243
|
-
`${menu?.items.length} items
|
|
261
|
+
check(menu !== null && menu.items.length === 7, 'the menu lists every entry',
|
|
262
|
+
`${menu?.items.length} items at the top level`)
|
|
244
263
|
|
|
245
264
|
// 键盘:打开即聚焦第一项,方向键移动,Esc 关闭并把焦点还给她
|
|
246
265
|
const kbd = await page.evaluate(() => {
|
|
@@ -266,6 +285,9 @@ check(kbd.focusAfter === 'wisp-body', 'and focus lands back on her, not on <body
|
|
|
266
285
|
const openedAgain = await openMenu()
|
|
267
286
|
check(openedAgain !== null, 'the menu reopens for the activation check')
|
|
268
287
|
const zoomed = await page.evaluate(() => {
|
|
288
|
+
/* 大小两项收在「外观」组里:先展开,再点。 */
|
|
289
|
+
const group = [...document.querySelectorAll('.wisp-menu-item')].find((i) => i.dataset && i.dataset.group === '外观')
|
|
290
|
+
if (group) group.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
|
|
269
291
|
const item = [...document.querySelectorAll('.wisp-menu-item')].find((i) => i.textContent === '放大一点')
|
|
270
292
|
if (!item) return { missing: true }
|
|
271
293
|
item.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
|
|
@@ -274,6 +296,58 @@ const zoomed = await page.evaluate(() => {
|
|
|
274
296
|
check(zoomed.width === '630px' && zoomed.menuGone, 'a menu item runs and closes the menu',
|
|
275
297
|
zoomed.missing ? 'the item was not found' : `width=${zoomed.width}`)
|
|
276
298
|
|
|
299
|
+
/* 两个弹窗在真浏览器里的样子 —— 可见性、图片真的解码出来,这些假 DOM 测不到。
|
|
300
|
+
注意位置:必须放在键盘那一段**之后**。放前面会扰动它依赖的焦点状态
|
|
301
|
+
(实测:键盘段会退化成"焦点在哪都行"然后连挂三条)。 */
|
|
302
|
+
const aboutView = await page.evaluate(() => {
|
|
303
|
+
window.__wisp.openAbout()
|
|
304
|
+
const card = document.querySelector('.wisp-dialog')
|
|
305
|
+
const back = document.querySelector('.wisp-dialog-backdrop')
|
|
306
|
+
if (!card) return { missing: true }
|
|
307
|
+
const r = card.getBoundingClientRect()
|
|
308
|
+
const text = [...card.querySelectorAll('.wisp-dialog-p, .wisp-dialog-foot-l, .wisp-dialog-foot-r')]
|
|
309
|
+
.map((el) => el.textContent).join(' ')
|
|
310
|
+
return {
|
|
311
|
+
visible: r.width > 200 && r.height > 80,
|
|
312
|
+
inView: r.left >= 0 && r.top >= 0 && r.right <= window.innerWidth && r.bottom <= window.innerHeight,
|
|
313
|
+
hasBackdrop: back !== null,
|
|
314
|
+
text,
|
|
315
|
+
}
|
|
316
|
+
})
|
|
317
|
+
check(aboutView.visible === true && aboutView.inView === true, 'the about dialog is really on screen',
|
|
318
|
+
JSON.stringify(aboutView))
|
|
319
|
+
check(aboutView.hasBackdrop === true && (aboutView.text || '').includes('DeepSeek娘'),
|
|
320
|
+
'with a backdrop and her name in it', String((aboutView.text || '').slice(0, 48)))
|
|
321
|
+
|
|
322
|
+
const actionsView = await page.evaluate(async () => {
|
|
323
|
+
window.__wisp.openActions()
|
|
324
|
+
const cells = [...document.querySelectorAll('.wisp-cell')]
|
|
325
|
+
await new Promise((resolve) => setTimeout(resolve, 400))
|
|
326
|
+
const loaded = cells.filter((c) => {
|
|
327
|
+
const im = c.querySelector('img')
|
|
328
|
+
return im && im.complete && im.naturalWidth > 0
|
|
329
|
+
}).length
|
|
330
|
+
const r = document.querySelector('.wisp-dialog').getBoundingClientRect()
|
|
331
|
+
return {
|
|
332
|
+
cells: cells.length, loaded,
|
|
333
|
+
inView: r.left >= 0 && r.top >= 0 && r.right <= window.innerWidth && r.bottom <= window.innerHeight,
|
|
334
|
+
}
|
|
335
|
+
})
|
|
336
|
+
check(actionsView.cells === 8, 'the action preview lists eight cells', String(actionsView.cells))
|
|
337
|
+
check(actionsView.loaded === 8, 'and every sprite in it actually decoded',
|
|
338
|
+
actionsView.loaded + '/' + actionsView.cells)
|
|
339
|
+
check(actionsView.inView === true, 'and it fits on screen', String(actionsView.inView))
|
|
340
|
+
|
|
341
|
+
const previewed = await page.evaluate(() => {
|
|
342
|
+
const cell = [...document.querySelectorAll('.wisp-cell')].find((c) => c.dataset.mood === 'proud')
|
|
343
|
+
cell.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
|
|
344
|
+
return { dialog: window.__wisp.dialog, mood: window.__wisp.currentMood }
|
|
345
|
+
})
|
|
346
|
+
check(previewed.dialog === null && previewed.mood === 'proud',
|
|
347
|
+
'clicking a cell closes the dialog and poses her', JSON.stringify(previewed))
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
|
|
277
351
|
/* 键盘入口:假 DOM 证明不了原生按键处理与事件冒泡(窗口上的捕获监听会先看到 keydown),
|
|
278
352
|
所以这里用真实按键事件走一遍。 */
|
|
279
353
|
const focusHer = await page.evaluate(() => {
|
package/verify-wisp.mjs
CHANGED
|
@@ -304,10 +304,22 @@ function createHarness(options = {}) {
|
|
|
304
304
|
}
|
|
305
305
|
const pendingNode = {}
|
|
306
306
|
|
|
307
|
+
const documentListeners = Object.create(null)
|
|
307
308
|
const documentShim = {
|
|
308
309
|
body: bodyEl,
|
|
309
310
|
head: headEl,
|
|
310
311
|
get activeElement() { return activeEl },
|
|
312
|
+
/* 真 document 有事件接口,替身也得有 —— 否则"插件是否正确处理文档级 Esc"根本测不到,
|
|
313
|
+
而且少一个方法会让插件在替身里整个起不来(这坑踩过一次)。 */
|
|
314
|
+
addEventListener(type, fn) { (documentListeners[type] ??= []).push(fn) },
|
|
315
|
+
removeEventListener(type, fn) {
|
|
316
|
+
const list = documentListeners[type]
|
|
317
|
+
if (!list) return
|
|
318
|
+
const at = list.indexOf(fn)
|
|
319
|
+
if (at >= 0) list.splice(at, 1)
|
|
320
|
+
},
|
|
321
|
+
dispatch(type, event) { for (const fn of [...(documentListeners[type] ?? [])]) fn(event ?? {}) },
|
|
322
|
+
get documentListenerCount() { return Object.values(documentListeners).reduce((n, l) => n + l.length, 0) },
|
|
311
323
|
createElement: (t) => makeEl(t),
|
|
312
324
|
getElementById: (id) => created.find((e) => e.id === id && !e.removed) ?? null,
|
|
313
325
|
/* 采样点上的元素栈:按"最上面优先"返回。窗口背景那一段要靠它判断
|
|
@@ -476,7 +488,8 @@ function createHarness(options = {}) {
|
|
|
476
488
|
// 默认不给 —— 这样"没有 host 时降级"才测得到;要测检查更新就传 hostCall。
|
|
477
489
|
const hostSeat = typeof options.hostCall === 'function' ? { call: options.hostCall } : undefined
|
|
478
490
|
// eslint-disable-next-line no-new-func
|
|
479
|
-
new Function
|
|
491
|
+
/* 追加 sourceURL:new Function 里的代码否则在堆栈里是 anonymous,定位不到行。 */
|
|
492
|
+
new Function('window', 'document', 'host', ...TRAPPED, src + '\n//# sourceURL=wisp-client-half.js')(
|
|
480
493
|
win, documentShim, hostSeat, ...TRAPPED.map((name) => traps[name]),
|
|
481
494
|
)
|
|
482
495
|
},
|
|
@@ -1350,6 +1363,132 @@ if (clientSrc !== null) {
|
|
|
1350
1363
|
care.win.__wisp.destroy()
|
|
1351
1364
|
active = keepCare
|
|
1352
1365
|
|
|
1366
|
+
/* ---------------- 3d-octodecies. 干饭 ----------------------------------- */
|
|
1367
|
+
head('3d-octodecies. she gets hungry and says so (the rice thing)')
|
|
1368
|
+
|
|
1369
|
+
/* 这是社区的设定里最像"她"的一条:白米饭是算力的硬通货。行为本身很简单 ——
|
|
1370
|
+
连续活动够久就该吃一碗 —— 但要测的是**判据**:别一回来就喊饿、别连着喊、
|
|
1371
|
+
能关掉。careAfterMs 关掉,免得住坐提醒和它互相干扰。 */
|
|
1372
|
+
const eat = createHarness({ timer: true, composerText: '在打字' })
|
|
1373
|
+
const keepEat = active
|
|
1374
|
+
active = eat
|
|
1375
|
+
eat.evaluate(clientSrc)
|
|
1376
|
+
eat.module().default.apply(eat.ctx, {
|
|
1377
|
+
reactions: true, wander: false, celebrate: false,
|
|
1378
|
+
careAfterMs: 0, hungerMs: 600000, sleepAfterMs: 3600000,
|
|
1379
|
+
})
|
|
1380
|
+
eat.advance(1300, 100)
|
|
1381
|
+
const eatApi = eat.win.__wisp
|
|
1382
|
+
const eatMood = () => eat.find('wisp-root').dataset.mood
|
|
1383
|
+
const eatUntil = (limitMs, chunk = 1000) => {
|
|
1384
|
+
for (let left = limitMs; left > 0; left -= chunk) {
|
|
1385
|
+
eat.advance(Math.min(chunk, left), 200)
|
|
1386
|
+
if (eatMood() === 'eat') return true
|
|
1387
|
+
}
|
|
1388
|
+
return false
|
|
1389
|
+
}
|
|
1390
|
+
const bubbleIsEat = () => {
|
|
1391
|
+
const text = eat.all('wisp-say').at(-1)?.textContent
|
|
1392
|
+
return Array.isArray(linesInBundle()?.eat) && linesInBundle().eat.includes(text)
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
check(Array.isArray(linesInBundle()?.eat) && linesInBundle().eat.length >= 8,
|
|
1396
|
+
'the rice pool exists and has some lines in it', `${linesInBundle()?.eat?.length ?? '?'} 句`)
|
|
1397
|
+
check(eatApi.doctor().hunger.enabled === true && eatApi.doctor().hunger.everyMs === 600000,
|
|
1398
|
+
'doctor reports the hunger setting', JSON.stringify(eatApi.doctor().hunger))
|
|
1399
|
+
|
|
1400
|
+
// 9 分钟:还没到
|
|
1401
|
+
let sawEarlyEat = false
|
|
1402
|
+
for (let left = 9 * 60000; left > 0; left -= 1000) {
|
|
1403
|
+
eat.advance(1000, 200)
|
|
1404
|
+
if (eatMood() === 'eat') { sawEarlyEat = true; break }
|
|
1405
|
+
}
|
|
1406
|
+
check(!sawEarlyEat, 'nine minutes in she is not hungry yet', eatMood())
|
|
1407
|
+
|
|
1408
|
+
check(eatUntil(200000), 'past the threshold she gets a bowl out')
|
|
1409
|
+
check(bubbleIsEat(), 'and the line comes from the rice pool',
|
|
1410
|
+
(eat.all('wisp-say').at(-1)?.textContent ?? '(无)'))
|
|
1411
|
+
check(eatMood() === 'eat' && eatApi.currentMood === 'eat',
|
|
1412
|
+
'and both the DOM and the reported mood say eat', `${eatMood()} / ${eatApi.currentMood}`)
|
|
1413
|
+
|
|
1414
|
+
// 稳态轮询不能把它抹掉
|
|
1415
|
+
eat.advance(1000, 100)
|
|
1416
|
+
check(eatMood() === 'eat', 'the reaction poll does not overwrite the meal', eatMood())
|
|
1417
|
+
|
|
1418
|
+
// 吃完自己回去(EAT_MS = 5.2 秒)
|
|
1419
|
+
for (let left = 8000; left > 0; left -= 500) eat.advance(500, 100)
|
|
1420
|
+
check(eatMood() === 'alert', 'and the meal ends on its own, back to alert', eatMood())
|
|
1421
|
+
|
|
1422
|
+
// 不该连着说
|
|
1423
|
+
let saidAgain = false
|
|
1424
|
+
for (let left = 60000; left > 0; left -= 1000) {
|
|
1425
|
+
eat.advance(1000, 200)
|
|
1426
|
+
if (eatMood() === 'eat') { saidAgain = true; break }
|
|
1427
|
+
}
|
|
1428
|
+
check(!saidAgain, 'she does not ask for rice again a minute later', eatMood())
|
|
1429
|
+
check(eatUntil(600000), 'but another full stretch does bring it back')
|
|
1430
|
+
|
|
1431
|
+
// 离开很久之后回来:不该一进门就喊饿
|
|
1432
|
+
eat.win.__wisp.configure({ hungerMs: 300000 })
|
|
1433
|
+
eat.state.composerText = null
|
|
1434
|
+
eat.advance(20 * 60000, 5000)
|
|
1435
|
+
eat.state.composerText = '回来了'
|
|
1436
|
+
eat.advance(1300, 100)
|
|
1437
|
+
let instantHunger = false
|
|
1438
|
+
for (let left = 120000; left > 0; left -= 1000) {
|
|
1439
|
+
eat.advance(1000, 200)
|
|
1440
|
+
if (eatMood() === 'eat') { instantHunger = true; break }
|
|
1441
|
+
}
|
|
1442
|
+
check(!instantHunger, 'after a long absence she does not ask for rice the moment you return', eatMood())
|
|
1443
|
+
check(eatUntil(300000), 'only a fresh full stretch makes her hungry')
|
|
1444
|
+
/* 让这一顿吃完(EAT_MS = 5.2 秒)再测关闭 —— 否则"还停在 eat"会被误判成"关不掉"。
|
|
1445
|
+
(这个坑久坐提醒那节也踩过,同样用一段推进收尾。) */
|
|
1446
|
+
eat.advance(8000, 500)
|
|
1447
|
+
|
|
1448
|
+
// 关掉这个行为(两种关法)
|
|
1449
|
+
eat.win.__wisp.configure({ hungerMs: 0 })
|
|
1450
|
+
let offByZero = false
|
|
1451
|
+
for (let left = 20 * 60000; left > 0; left -= 5000) {
|
|
1452
|
+
eat.advance(5000, 500)
|
|
1453
|
+
if (eatMood() === 'eat') { offByZero = true; break }
|
|
1454
|
+
}
|
|
1455
|
+
check(!offByZero, 'hungerMs: 0 turns it off', eatMood())
|
|
1456
|
+
eat.win.__wisp.configure({ hungerMs: 300000, hungry: false })
|
|
1457
|
+
let offByFlag = false
|
|
1458
|
+
for (let left = 20 * 60000; left > 0; left -= 5000) {
|
|
1459
|
+
eat.advance(5000, 500)
|
|
1460
|
+
if (eatMood() === 'eat') { offByFlag = true; break }
|
|
1461
|
+
}
|
|
1462
|
+
check(!offByFlag, 'and hungry: false turns it off even with a live timer', eatMood())
|
|
1463
|
+
check(eatApi.config.hungry === false, 'and the config says so', String(eatApi.config.hungry))
|
|
1464
|
+
|
|
1465
|
+
// 菜单开关:先把菜单叫出来(菜单项是开菜单时才建的),点一下就该翻转
|
|
1466
|
+
/* 主菜单的入口是 .wisp-body 上的 keydown(Enter / 空格 / ContextMenu / Shift+F10),
|
|
1467
|
+
不是 contextmenu —— 后者只挂在"躲起来"之后的迷你标签上。事件对象同样自建:
|
|
1468
|
+
ctxEvent 定义在文件后半部分,在这里用就是 TDZ。 */
|
|
1469
|
+
eat.win.__wisp.configure({ hungry: true })
|
|
1470
|
+
const eatBody = eat.find('wisp-root').querySelector('.wisp-body')
|
|
1471
|
+
const openEatMenu = () => eatBody.dispatch('keydown', {
|
|
1472
|
+
type: 'keydown', key: 'Enter', shiftKey: false, defaultPrevented: false,
|
|
1473
|
+
preventDefault() { this.defaultPrevented = true }, stopPropagation() {},
|
|
1474
|
+
})
|
|
1475
|
+
const eatLive = () => eat.all('wisp-menu-item').filter((el) => el.removed !== true)
|
|
1476
|
+
openEatMenu()
|
|
1477
|
+
/* 开关在「行为」组里:先展开(分组行带着 data-group) */
|
|
1478
|
+
const groupRowOf = (name) => eatLive().find((el) => el.dataset && el.dataset.group === name)
|
|
1479
|
+
groupRowOf('行为').dispatch('click', { preventDefault() {}, stopPropagation() {} })
|
|
1480
|
+
const eatToggle = eatLive().find((el) => (el.textContent || '').includes('饿了'))
|
|
1481
|
+
check(Boolean(eatToggle), 'the menu carries a rice toggle',
|
|
1482
|
+
eat.all('wisp-menu-item').filter((el) => el.removed !== true).length + ' 项')
|
|
1483
|
+
if (eatToggle) {
|
|
1484
|
+
/* 元素上的点击走 harness 的 dispatch(type, event) —— 不是浏览器的 dispatchEvent(没有那个方法)。 */
|
|
1485
|
+
eatToggle.dispatch('click', { preventDefault() {}, stopPropagation() {} })
|
|
1486
|
+
check(eat.win.__wisp.config.hungry === false, 'and clicking it turns the behaviour off',
|
|
1487
|
+
String(eat.win.__wisp.config.hungry))
|
|
1488
|
+
}
|
|
1489
|
+
eat.win.__wisp.destroy()
|
|
1490
|
+
active = keepEat
|
|
1491
|
+
|
|
1353
1492
|
/* 睡着了也得被叫醒:这条单独测,因为它需要 sleepAfterMs 很短 */
|
|
1354
1493
|
const sleepCare = createHarness({ timer: true, composerText: '在打字' })
|
|
1355
1494
|
const keepSleepCare = active
|
|
@@ -2118,17 +2257,52 @@ if (clientSrc !== null) {
|
|
|
2118
2257
|
check(menuOf() !== null, 'the right-click opens a context menu')
|
|
2119
2258
|
check(menuOf()?.getAttribute('role') === 'menu', 'the menu carries menu semantics')
|
|
2120
2259
|
/* 项数从皮肤数**推导**,不写死:加一套皮肤不该让测试变红(那是数据的错,不是行为的错)。
|
|
2121
|
-
固定部分 = 6 个动作 +
|
|
2260
|
+
固定部分 = 6 个动作 + 4 个开关 + 4 个角落 = 14;其余是每套皮肤一项。 */
|
|
2122
2261
|
/* api.skins 是数组(SKIN_IDS 的拷贝),不是对象 —— 用 Object.keys 只是碰巧数对了。 */
|
|
2123
2262
|
const skinCount = Array.isArray(api.skins) ? api.skins.length : Object.keys(api.skins ?? {}).length
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2263
|
+
/* ---- 结构:顶层只放动作,其余收进可折叠分组 ----------------------------
|
|
2264
|
+
原来 19 个条目全平铺、只靠三行不可点的头部标签分区 —— 那不是结构,是排版。
|
|
2265
|
+
现在顶层 7 项,外观 / 行为 / 位置 三组各自折叠。 */
|
|
2266
|
+
const topLevel = itemsOf()
|
|
2267
|
+
check(topLevel.length === 7, 'the top level holds seven entries, not nineteen',
|
|
2268
|
+
`${topLevel.length} 项:${topLevel.map((i) => i.textContent).join(' | ')}`)
|
|
2269
|
+
const groupRow = (name) => itemsOf().find((i) => i.dataset && i.dataset.group === name)
|
|
2270
|
+
const groupNames = ['外观', '行为', '位置']
|
|
2271
|
+
check(groupNames.every((n) => groupRow(n) !== undefined), 'the three groups are there',
|
|
2272
|
+
groupNames.map((n) => `${n}=${groupRow(n) ? '有' : '无'}`).join(' '))
|
|
2273
|
+
check(groupNames.every((n) => groupRow(n).dataset.open === 'false'),
|
|
2274
|
+
'and they start collapsed', groupNames.map((n) => groupRow(n).dataset.open).join(','))
|
|
2275
|
+
check(groupNames.every((n) => groupRow(n).getAttribute('aria-expanded') === 'false'),
|
|
2276
|
+
'with aria-expanded telling assistive tech the same thing')
|
|
2277
|
+
/* 展开一个分组:点它一下,菜单在原地重开,条目多出这一组的孩子 */
|
|
2278
|
+
/* 展开/收起**只从 DOM 判断**:分组行自己带着 data-open 与 aria-expanded,
|
|
2279
|
+
而 api 这个绑定是第一个替身的(728 行),不是这一节的实例 —— 拿它读菜单状态
|
|
2280
|
+
会得到"菜单明明开着、API 却说没开"的假失败(踩过)。 */
|
|
2281
|
+
const expand = (name) => {
|
|
2282
|
+
const row = groupRow(name)
|
|
2283
|
+
if (!row) return false
|
|
2284
|
+
row.dispatch('click', itemEvent())
|
|
2285
|
+
return groupRow(name) !== undefined && groupRow(name).dataset.open === 'true'
|
|
2286
|
+
}
|
|
2287
|
+
const collapse = (name) => {
|
|
2288
|
+
const row = groupRow(name)
|
|
2289
|
+
if (!row) return false
|
|
2290
|
+
row.dispatch('click', itemEvent())
|
|
2291
|
+
return groupRow(name) !== undefined && groupRow(name).dataset.open === 'false'
|
|
2292
|
+
}
|
|
2293
|
+
check(expand('外观'), 'clicking a group expands it in place',
|
|
2294
|
+
`row=${JSON.stringify(groupRow('外观') ? groupRow('外观').dataset : null)} 项=${itemsOf().length}`)
|
|
2295
|
+
check(itemsOf().length === 7 + skinCount + 2,
|
|
2296
|
+
'the skins and the two size entries appear once it is open',
|
|
2297
|
+
`${itemsOf().length} 项 = 7 + ${skinCount} 皮肤 + 2 大小`)
|
|
2298
|
+
check(collapse('外观'), 'clicking it again collapses it')
|
|
2299
|
+
check(itemsOf().length === 7, 'and the menu is back to seven entries', String(itemsOf().length))
|
|
2300
|
+
/* 换一套皮肤之后,展开的分组要自动收起(closeMenu 会清掉 activeGroup) */
|
|
2127
2301
|
const byText = (t) => itemsOf().filter((i) => i.textContent.includes(t))
|
|
2128
|
-
|
|
2129
|
-
check(
|
|
2130
|
-
|
|
2131
|
-
|
|
2302
|
+
expand('外观')
|
|
2303
|
+
check(groupRow('外观').getAttribute('aria-expanded') === 'true',
|
|
2304
|
+
'and aria-expanded follows the expansion')
|
|
2305
|
+
/* 皮肤现在在「外观」组里 —— 上一步已经把该组展开了。 */
|
|
2132
2306
|
/* 从 API 派生,**不写死皮肤名**。写死过「初版女仆」,改名成「素绘女仆」之后
|
|
2133
2307
|
三条断言当场全炸,其中一条 undefined.dispatch 还把整个套件截断了。 */
|
|
2134
2308
|
/* 既有 API 的形状是**有序数组** [{id,label}],不是 id→label 的对象。 */
|
|
@@ -2146,6 +2320,7 @@ if (clientSrc !== null) {
|
|
|
2146
2320
|
'the menu lives beside her body, not inside it', 'inside it would be mirrored and scale with her')
|
|
2147
2321
|
|
|
2148
2322
|
/* ---- 三个行为开关(原本在设置页里,那页撤掉后落到菜单) -------------- */
|
|
2323
|
+
check(expand('行为'), 'the behaviour group opens') /* 开关都在这一组里 */
|
|
2149
2324
|
const toggleItem = (label) => itemsOf().find((i) => i.textContent.replace(/^[✓ ]\s*/, '') === label)
|
|
2150
2325
|
const togglesPresent = ['自己溜达', '跟随状态', '跑完撒花'].every((l) => toggleItem(l) !== undefined)
|
|
2151
2326
|
check(togglesPresent, 'all three behaviour toggles are in the menu',
|
|
@@ -2157,6 +2332,8 @@ if (clientSrc !== null) {
|
|
|
2157
2332
|
check(fifth.config.wander === !wanderBefore, 'clicking a toggle really flips the config',
|
|
2158
2333
|
`${wanderBefore} -> ${fifth.config.wander}`)
|
|
2159
2334
|
body5.dispatch('contextmenu', rightClickOnHer().ev)
|
|
2335
|
+
/* 重开菜单=一次新手势,所以分组回到收起状态(这是设计)。要再点开关就得再展开。 */
|
|
2336
|
+
check(expand('行为'), 'reopening the menu collapses the groups again')
|
|
2160
2337
|
const reopened = toggleItem('自己溜达')
|
|
2161
2338
|
check(reopened !== undefined && reopened.textContent.startsWith(' '),
|
|
2162
2339
|
'and reopening the menu shows the new state',
|
|
@@ -2170,6 +2347,7 @@ if (clientSrc !== null) {
|
|
|
2170
2347
|
// 自己开菜单,不依赖上一节留下的状态 —— 隐含的跨节状态是测试脆弱性的常见来源。
|
|
2171
2348
|
body5.dispatch('contextmenu', rightClickOnHer().ev)
|
|
2172
2349
|
check(menuOf() !== null, 'the menu opens for the skin checks')
|
|
2350
|
+
check(expand('外观'), 'and the skins group can be opened')
|
|
2173
2351
|
const skinBefore = fifth.skin
|
|
2174
2352
|
const posBefore = fifth.position
|
|
2175
2353
|
const srcBeforeSkin = h.srcs[h.srcs.length - 1]
|
|
@@ -2192,9 +2370,9 @@ if (clientSrc !== null) {
|
|
|
2192
2370
|
check(fifth.configure({ skin: 'deepsea' }) && fifth.skin === 'deepsea',
|
|
2193
2371
|
'configure({skin}) goes through the same path as the menu', String(fifth.skin))
|
|
2194
2372
|
|
|
2195
|
-
// 点「放大一点」——
|
|
2373
|
+
// 点「放大一点」—— 真改尺寸,并且菜单自己关掉(大小两项在「外观」组里,先展开)
|
|
2196
2374
|
body5.dispatch('contextmenu', rightClickOnHer().ev)
|
|
2197
|
-
|
|
2375
|
+
check(expand('外观'), 'the size entries need the appearance group open')
|
|
2198
2376
|
byText('放大一点')[0].dispatch('click', itemEvent())
|
|
2199
2377
|
check(fifth.element.style.width === '630px' && fifth.element.style.height === '945px',
|
|
2200
2378
|
'a menu action runs (zoom one step up)', `${fifth.element.style.width}x${fifth.element.style.height}`)
|
|
@@ -2204,6 +2382,7 @@ if (clientSrc !== null) {
|
|
|
2204
2382
|
const secondClick = rightClickOnHer()
|
|
2205
2383
|
body5.dispatch('contextmenu', secondClick.ev)
|
|
2206
2384
|
check(menuOf() !== null, 'the menu reopens')
|
|
2385
|
+
check(expand('位置'), 'the corners live in the position group')
|
|
2207
2386
|
byText('右下角')[0].dispatch('click', itemEvent())
|
|
2208
2387
|
const homed = fifth.position
|
|
2209
2388
|
check(homed.x === secondClick.homeX && homed.y === secondClick.homeY,
|
|
@@ -2230,7 +2409,7 @@ if (clientSrc !== null) {
|
|
|
2230
2409
|
})
|
|
2231
2410
|
check(kbd().at !== null && kbd().at.tagName === 'BUTTON',
|
|
2232
2411
|
'opening the menu focuses its first item', kbd().label)
|
|
2233
|
-
check(kbd().at?.className === 'wisp-menu-item' && kbd().label === '
|
|
2412
|
+
check(kbd().at?.className === 'wisp-menu-item' && kbd().label === '关于她…',
|
|
2234
2413
|
'and the focus lands on the first real action', kbd().label)
|
|
2235
2414
|
check(kbd().zero === 1, 'exactly one item is in the tab order (roving tabindex)', `${kbd().zero}`)
|
|
2236
2415
|
check(itemsOf()[0].getAttribute('aria-selected') === 'true',
|
|
@@ -2406,6 +2585,16 @@ if (clientSrc !== null) {
|
|
|
2406
2585
|
found[0].dispatch('click', itemEvent())
|
|
2407
2586
|
return true
|
|
2408
2587
|
}
|
|
2588
|
+
/* 菜单改成分组之后,组里的条目要先把组展开才看得见。
|
|
2589
|
+
这个 helper 让"点某个分组里的东西"是一次调用,而不是每处各写一遍。 */
|
|
2590
|
+
const openGroupOn = (inst, name) => {
|
|
2591
|
+
const host = inst.element.parentNode
|
|
2592
|
+
const row = host.querySelectorAll('.wisp-menu-item').find((i) => i.dataset && i.dataset.group === name)
|
|
2593
|
+
if (!row) return false
|
|
2594
|
+
row.dispatch('click', itemEvent())
|
|
2595
|
+
return host.querySelectorAll('.wisp-menu-item')
|
|
2596
|
+
.some((i) => i.dataset && i.dataset.group === name && i.dataset.open === 'true')
|
|
2597
|
+
}
|
|
2409
2598
|
// 四个角落。期望值按【当前】尺寸算 —— 写死尺寸的话,一旦尺寸变了断言会
|
|
2410
2599
|
// 指着正确的实现说它错。
|
|
2411
2600
|
const cur = boxOf(pinned)
|
|
@@ -2417,6 +2606,7 @@ if (clientSrc !== null) {
|
|
|
2417
2606
|
}
|
|
2418
2607
|
for (const [label, want] of Object.entries(EXPECT)) {
|
|
2419
2608
|
openMenuOn(pinned)
|
|
2609
|
+
openGroupOn(pinned, '位置') // 四个角落现在收在「位置」组里
|
|
2420
2610
|
const hit = pickExact(pinned, label)
|
|
2421
2611
|
check(hit && pinned.position.x === want.x && pinned.position.y === want.y,
|
|
2422
2612
|
`the ${label} item puts her there`, `${pinned.position.x},${pinned.position.y} (want ${want.x},${want.y})`)
|
|
@@ -2584,6 +2774,7 @@ if (clientSrc !== null) {
|
|
|
2584
2774
|
active = keepSoak
|
|
2585
2775
|
} catch (error) {
|
|
2586
2776
|
bad('browser half executes', `${error.constructor.name}: ${error.message}`)
|
|
2777
|
+
if (error && error.stack) console.log(String(error.stack).split('\n').slice(0, 6).map((l) => ' ' + l.trim()).join('\n'))
|
|
2587
2778
|
console.log(error.stack)
|
|
2588
2779
|
} finally {
|
|
2589
2780
|
active = null
|
|
@@ -2591,6 +2782,93 @@ if (clientSrc !== null) {
|
|
|
2591
2782
|
}
|
|
2592
2783
|
}
|
|
2593
2784
|
|
|
2785
|
+
/* ================================================== 3y. dialogs ============ */
|
|
2786
|
+
head('3y. dialogs: about her, and the action preview')
|
|
2787
|
+
|
|
2788
|
+
/* 菜单顶层只留动作,"她是谁"和"她有哪些动作"放进弹窗。这里测的就是这两个弹窗:
|
|
2789
|
+
能不能开、内容对不对、三种关法,以及**反复开合不会把文档级监听越堆越多**
|
|
2790
|
+
(那是真写进去过的 bug:清理函数是个空壳)。 */
|
|
2791
|
+
{
|
|
2792
|
+
const dlg = createHarness({ timer: true, composerText: '' })
|
|
2793
|
+
const keepDlg = active
|
|
2794
|
+
active = dlg
|
|
2795
|
+
dlg.evaluate(clientSrc)
|
|
2796
|
+
dlg.module().default.apply(dlg.ctx, { reactions: true, wander: false, celebrate: false })
|
|
2797
|
+
dlg.advance(1300, 100)
|
|
2798
|
+
const dlgApi = dlg.win.__wisp
|
|
2799
|
+
const bundleVersion = /const VERSION = '([^']+)'/.exec(clientSrc)?.[1] ?? null
|
|
2800
|
+
|
|
2801
|
+
/* 替身的 find() 会把已移除的节点也返回(它自己的约定),所以要显式认 removed。 */
|
|
2802
|
+
const dialogGone = () => {
|
|
2803
|
+
const el = dlg.find('wisp-dialog')
|
|
2804
|
+
return el === null || el.removed === true
|
|
2805
|
+
}
|
|
2806
|
+
/* 按段落/页脚类读文本:简化 DOM 不会汇总子节点 textContent,读卡片本身只会得到空串。 */
|
|
2807
|
+
const dialogText = () => [].concat(
|
|
2808
|
+
dlg.all('wisp-dialog-p').filter((el) => el.removed !== true).map((el) => String(el.textContent)),
|
|
2809
|
+
dlg.all('wisp-dialog-foot-l').filter((el) => el.removed !== true).map((el) => String(el.textContent)),
|
|
2810
|
+
dlg.all('wisp-dialog-foot-r').filter((el) => el.removed !== true).map((el) => String(el.textContent)),
|
|
2811
|
+
).join('\n')
|
|
2812
|
+
|
|
2813
|
+
check(dlgApi.dialog === null, 'nothing is open at the start', String(dlgApi.dialog))
|
|
2814
|
+
check(dialogGone(), 'and no dialog sits in the DOM before it is asked for')
|
|
2815
|
+
|
|
2816
|
+
dlgApi.openAbout()
|
|
2817
|
+
check(dlgApi.dialog === 'about', 'asking about her opens the about dialog', String(dlgApi.dialog))
|
|
2818
|
+
const card = dlg.find('wisp-dialog')
|
|
2819
|
+
check(card !== null, 'the dialog card is in the DOM')
|
|
2820
|
+
check(dlg.find('wisp-dialog-backdrop') !== null, 'with a backdrop behind it')
|
|
2821
|
+
const aboutText = dialogText()
|
|
2822
|
+
check(aboutText.includes('DeepSeek娘'), 'it says who she is')
|
|
2823
|
+
check(bundleVersion !== null && aboutText.includes(bundleVersion),
|
|
2824
|
+
'and which version this is', `${bundleVersion} / 文本里找得到: ${aboutText.includes(String(bundleVersion))}`)
|
|
2825
|
+
check(aboutText.includes('社区'), 'and credits the community the look comes from')
|
|
2826
|
+
check(aboutText.includes('非官方'), 'and says plainly that it is unofficial')
|
|
2827
|
+
|
|
2828
|
+
dlg.document.dispatch('keydown', { key: 'Escape', preventDefault() {} })
|
|
2829
|
+
check(dlgApi.dialog === null && dialogGone(), 'Escape closes it',
|
|
2830
|
+
`dialog=${String(dlgApi.dialog)} 监听数=${dlg.document.documentListenerCount}`)
|
|
2831
|
+
check(dlg.document.documentListenerCount === 1,
|
|
2832
|
+
'exactly one document-level key listener exists', String(dlg.document.documentListenerCount))
|
|
2833
|
+
|
|
2834
|
+
dlgApi.openActions()
|
|
2835
|
+
check(dlgApi.dialog === 'actions', 'the action preview opens', String(dlgApi.dialog))
|
|
2836
|
+
const cells = dlg.all('wisp-cell').filter((el) => el.removed !== true)
|
|
2837
|
+
check(cells.length === 8, 'and lists eight actions — one per mood', `${cells.length} 格`)
|
|
2838
|
+
check(cells.every((c) => typeof c.dataset.mood === 'string' && c.dataset.mood !== ''),
|
|
2839
|
+
'each cell names its mood', cells.map((c) => c.dataset.mood).join(','))
|
|
2840
|
+
check(cells.every((c) => c.querySelector('img') !== null), 'each cell carries a sprite')
|
|
2841
|
+
|
|
2842
|
+
const eatCell = cells.find((c) => c.dataset.mood === 'eat')
|
|
2843
|
+
eatCell.dispatch('click', { preventDefault() {}, stopPropagation() {} })
|
|
2844
|
+
check(dlgApi.dialog === null, 'clicking an action closes the dialog so you can see her')
|
|
2845
|
+
check(dlgApi.currentMood === 'eat', 'and she takes that pose', String(dlgApi.currentMood))
|
|
2846
|
+
dlg.advance(1500, 100)
|
|
2847
|
+
check(dlgApi.currentMood === 'eat',
|
|
2848
|
+
'the reaction poll leaves the preview alone while it lasts', String(dlgApi.currentMood))
|
|
2849
|
+
dlg.advance(9000, 500)
|
|
2850
|
+
check(dlgApi.currentMood !== 'eat', 'and the preview does not stick forever', String(dlgApi.currentMood))
|
|
2851
|
+
|
|
2852
|
+
dlgApi.openActions()
|
|
2853
|
+
const x = dlg.find('wisp-dialog-x')
|
|
2854
|
+
check(x !== null, 'the close button is there')
|
|
2855
|
+
x.dispatch('click', { preventDefault() {} })
|
|
2856
|
+
check(dlgApi.dialog === null, 'and it closes the dialog')
|
|
2857
|
+
|
|
2858
|
+
dlgApi.openAbout()
|
|
2859
|
+
dlg.find('wisp-dialog-backdrop').dispatch('pointerdown', {})
|
|
2860
|
+
check(dlgApi.dialog === null, 'clicking the backdrop closes it too')
|
|
2861
|
+
|
|
2862
|
+
for (let i = 0; i < 3; i++) { dlgApi.openAbout(); dlgApi.closeDialog() }
|
|
2863
|
+
check(dlg.document.documentListenerCount === 1,
|
|
2864
|
+
'opening and closing repeatedly does not stack listeners', String(dlg.document.documentListenerCount))
|
|
2865
|
+
|
|
2866
|
+
dlgApi.openAbout()
|
|
2867
|
+
dlgApi.destroy()
|
|
2868
|
+
check(dlgApi.dialog === null, 'destroy() takes the dialog with it')
|
|
2869
|
+
active = keepDlg
|
|
2870
|
+
}
|
|
2871
|
+
|
|
2594
2872
|
/* ================================================== 3z. generation audit ==== */
|
|
2595
2873
|
head('3z. every shipped sprite traces back to a text-only call')
|
|
2596
2874
|
|
|
@@ -2661,7 +2939,8 @@ if (clientSrc !== null) {
|
|
|
2661
2939
|
const skins = Object.keys(value)
|
|
2662
2940
|
const missing = []
|
|
2663
2941
|
for (const skin of skins) {
|
|
2664
|
-
|
|
2942
|
+
/* 八个情绪:加了 eat(干饭)之后这里也要跟上 —— 漏一个就会让"每套皮肤都齐"变成假绿。 */
|
|
2943
|
+
for (const mood of ['idle', 'happy', 'sleepy', 'work', 'attn', 'poked', 'proud', 'eat']) {
|
|
2665
2944
|
const uri = value[skin]?.[mood]
|
|
2666
2945
|
if (typeof uri !== 'string' || uri.indexOf('data:image/') !== 0) missing.push(`${skin}/${mood}`)
|
|
2667
2946
|
}
|
|
@@ -2672,8 +2951,8 @@ if (clientSrc !== null) {
|
|
|
2672
2951
|
skinReport = `table does not evaluate: ${error.message}`
|
|
2673
2952
|
}
|
|
2674
2953
|
}
|
|
2675
|
-
if (skinsOk) ok('every skin carries all
|
|
2676
|
-
else bad('every skin carries all
|
|
2954
|
+
if (skinsOk) ok('every skin carries all eight moods as data URIs', skinReport)
|
|
2955
|
+
else bad('every skin carries all eight moods as data URIs', skinReport)
|
|
2677
2956
|
|
|
2678
2957
|
if (clientSrc.includes('__SPRITES__')) bad('no leftover build placeholder', 'run `node build.mjs`')
|
|
2679
2958
|
else ok('no leftover build placeholder')
|