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/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
- check(cornerMenu !== null && cornerMenu.naiveBottom > 900,
228
- 'the case actually needs clamping (otherwise the next check is vacuous)',
229
- cornerMenu ? `unclamped bottom would be ${cornerMenu.naiveBottom} > 900` : 'n/a')
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
- // 项数从皮肤数推导(13 项固定 + 每套皮肤一项),不写死:加一套皮肤不该让测试变红 ——
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 === 13 + skinCount, 'the menu lists every entry',
243
- `${menu?.items.length} items = 13 + ${skinCount} skin(s)`)
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('window', 'document', 'host', ...TRAPPED, src)(
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 个动作 + 3 个开关 + 4 个角落 = 13;其余是每套皮肤一项。 */
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
- check(itemsOf().length === 13 + skinCount,
2125
- 'the menu lists fixed actions and corners plus exactly one entry per skin',
2126
- `${itemsOf().length} items = 13 + ${skinCount} skin(s)`)
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
- const heads = menuOf().querySelectorAll('.wisp-menu-head')
2129
- check(heads.length === 3, 'the behaviour, position and skin sections each get a header', `${heads.length} headers`)
2130
- check(heads[0].textContent === '行为' && heads[1].textContent === '位置' && heads[2].textContent === '皮肤',
2131
- 'the headers name their sections', heads.map((x) => x.textContent).join(' / '))
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
- const menuItems = itemsOf()
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
- for (const mood of ['idle', 'happy', 'sleepy', 'work', 'attn', 'poked']) {
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 five moods as data URIs', skinReport)
2676
- else bad('every skin carries all five moods as data URIs', skinReport)
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')