dsh-wisp 1.27.0 → 1.29.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-wisp",
3
- "version": "1.27.0",
3
+ "version": "1.29.0",
4
4
  "description": "DeepSeek娘 — an unofficial floating desktop companion for the DeepSeek Harness UI. Zero dependencies; sprites inlined as data URIs; schedules on the Client timer service instead of the trapped browser timer globals.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
package/tools/smoke.mjs CHANGED
@@ -224,9 +224,28 @@ 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')
@@ -239,8 +258,8 @@ 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 === 14 + skinCount, 'the menu lists every entry',
243
- `${menu?.items.length} items = 14 + ${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,23 @@ 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) },
323
+ documentListenerCountOf(type) { return (documentListeners[type] ?? []).length },
311
324
  createElement: (t) => makeEl(t),
312
325
  getElementById: (id) => created.find((e) => e.id === id && !e.removed) ?? null,
313
326
  /* 采样点上的元素栈:按"最上面优先"返回。窗口背景那一段要靠它判断
@@ -476,7 +489,8 @@ function createHarness(options = {}) {
476
489
  // 默认不给 —— 这样"没有 host 时降级"才测得到;要测检查更新就传 hostCall。
477
490
  const hostSeat = typeof options.hostCall === 'function' ? { call: options.hostCall } : undefined
478
491
  // eslint-disable-next-line no-new-func
479
- new Function('window', 'document', 'host', ...TRAPPED, src)(
492
+ /* 追加 sourceURL:new Function 里的代码否则在堆栈里是 anonymous,定位不到行。 */
493
+ new Function('window', 'document', 'host', ...TRAPPED, src + '\n//# sourceURL=wisp-client-half.js')(
480
494
  win, documentShim, hostSeat, ...TRAPPED.map((name) => traps[name]),
481
495
  )
482
496
  },
@@ -1455,12 +1469,16 @@ if (clientSrc !== null) {
1455
1469
  ctxEvent 定义在文件后半部分,在这里用就是 TDZ。 */
1456
1470
  eat.win.__wisp.configure({ hungry: true })
1457
1471
  const eatBody = eat.find('wisp-root').querySelector('.wisp-body')
1458
- eatBody.dispatch('keydown', {
1472
+ const openEatMenu = () => eatBody.dispatch('keydown', {
1459
1473
  type: 'keydown', key: 'Enter', shiftKey: false, defaultPrevented: false,
1460
1474
  preventDefault() { this.defaultPrevented = true }, stopPropagation() {},
1461
1475
  })
1462
- const eatToggle = eat.all('wisp-menu-item').filter((el) => el.removed !== true)
1463
- .find((el) => (el.textContent || '').includes('饿了'))
1476
+ const eatLive = () => eat.all('wisp-menu-item').filter((el) => el.removed !== true)
1477
+ openEatMenu()
1478
+ /* 开关在「行为」组里:先展开(分组行带着 data-group) */
1479
+ const groupRowOf = (name) => eatLive().find((el) => el.dataset && el.dataset.group === name)
1480
+ groupRowOf('行为').dispatch('click', { preventDefault() {}, stopPropagation() {} })
1481
+ const eatToggle = eatLive().find((el) => (el.textContent || '').includes('饿了'))
1464
1482
  check(Boolean(eatToggle), 'the menu carries a rice toggle',
1465
1483
  eat.all('wisp-menu-item').filter((el) => el.removed !== true).length + ' 项')
1466
1484
  if (eatToggle) {
@@ -2243,14 +2261,49 @@ if (clientSrc !== null) {
2243
2261
  固定部分 = 6 个动作 + 4 个开关 + 4 个角落 = 14;其余是每套皮肤一项。 */
2244
2262
  /* api.skins 是数组(SKIN_IDS 的拷贝),不是对象 —— 用 Object.keys 只是碰巧数对了。 */
2245
2263
  const skinCount = Array.isArray(api.skins) ? api.skins.length : Object.keys(api.skins ?? {}).length
2246
- check(itemsOf().length === 14 + skinCount,
2247
- 'the menu lists fixed actions and corners plus exactly one entry per skin',
2248
- `${itemsOf().length} items = 14 + ${skinCount} skin(s)`)
2264
+ /* ---- 结构:顶层只放动作,其余收进可折叠分组 ----------------------------
2265
+ 原来 19 个条目全平铺、只靠三行不可点的头部标签分区 —— 那不是结构,是排版。
2266
+ 现在顶层 7 项,外观 / 行为 / 位置 三组各自折叠。 */
2267
+ const topLevel = itemsOf()
2268
+ check(topLevel.length === 7, 'the top level holds seven entries, not nineteen',
2269
+ `${topLevel.length} 项:${topLevel.map((i) => i.textContent).join(' | ')}`)
2270
+ const groupRow = (name) => itemsOf().find((i) => i.dataset && i.dataset.group === name)
2271
+ const groupNames = ['外观', '行为', '位置']
2272
+ check(groupNames.every((n) => groupRow(n) !== undefined), 'the three groups are there',
2273
+ groupNames.map((n) => `${n}=${groupRow(n) ? '有' : '无'}`).join(' '))
2274
+ check(groupNames.every((n) => groupRow(n).dataset.open === 'false'),
2275
+ 'and they start collapsed', groupNames.map((n) => groupRow(n).dataset.open).join(','))
2276
+ check(groupNames.every((n) => groupRow(n).getAttribute('aria-expanded') === 'false'),
2277
+ 'with aria-expanded telling assistive tech the same thing')
2278
+ /* 展开一个分组:点它一下,菜单在原地重开,条目多出这一组的孩子 */
2279
+ /* 展开/收起**只从 DOM 判断**:分组行自己带着 data-open 与 aria-expanded,
2280
+ 而 api 这个绑定是第一个替身的(728 行),不是这一节的实例 —— 拿它读菜单状态
2281
+ 会得到"菜单明明开着、API 却说没开"的假失败(踩过)。 */
2282
+ const expand = (name) => {
2283
+ const row = groupRow(name)
2284
+ if (!row) return false
2285
+ row.dispatch('click', itemEvent())
2286
+ return groupRow(name) !== undefined && groupRow(name).dataset.open === 'true'
2287
+ }
2288
+ const collapse = (name) => {
2289
+ const row = groupRow(name)
2290
+ if (!row) return false
2291
+ row.dispatch('click', itemEvent())
2292
+ return groupRow(name) !== undefined && groupRow(name).dataset.open === 'false'
2293
+ }
2294
+ check(expand('外观'), 'clicking a group expands it in place',
2295
+ `row=${JSON.stringify(groupRow('外观') ? groupRow('外观').dataset : null)} 项=${itemsOf().length}`)
2296
+ check(itemsOf().length === 7 + skinCount + 2,
2297
+ 'the skins and the two size entries appear once it is open',
2298
+ `${itemsOf().length} 项 = 7 + ${skinCount} 皮肤 + 2 大小`)
2299
+ check(collapse('外观'), 'clicking it again collapses it')
2300
+ check(itemsOf().length === 7, 'and the menu is back to seven entries', String(itemsOf().length))
2301
+ /* 换一套皮肤之后,展开的分组要自动收起(closeMenu 会清掉 activeGroup) */
2249
2302
  const byText = (t) => itemsOf().filter((i) => i.textContent.includes(t))
2250
- const heads = menuOf().querySelectorAll('.wisp-menu-head')
2251
- check(heads.length === 3, 'the behaviour, position and skin sections each get a header', `${heads.length} headers`)
2252
- check(heads[0].textContent === '行为' && heads[1].textContent === '位置' && heads[2].textContent === '皮肤',
2253
- 'the headers name their sections', heads.map((x) => x.textContent).join(' / '))
2303
+ expand('外观')
2304
+ check(groupRow('外观').getAttribute('aria-expanded') === 'true',
2305
+ 'and aria-expanded follows the expansion')
2306
+ /* 皮肤现在在「外观」组里 —— 上一步已经把该组展开了。 */
2254
2307
  /* 从 API 派生,**不写死皮肤名**。写死过「初版女仆」,改名成「素绘女仆」之后
2255
2308
  三条断言当场全炸,其中一条 undefined.dispatch 还把整个套件截断了。 */
2256
2309
  /* 既有 API 的形状是**有序数组** [{id,label}],不是 id→label 的对象。 */
@@ -2268,6 +2321,7 @@ if (clientSrc !== null) {
2268
2321
  'the menu lives beside her body, not inside it', 'inside it would be mirrored and scale with her')
2269
2322
 
2270
2323
  /* ---- 三个行为开关(原本在设置页里,那页撤掉后落到菜单) -------------- */
2324
+ check(expand('行为'), 'the behaviour group opens') /* 开关都在这一组里 */
2271
2325
  const toggleItem = (label) => itemsOf().find((i) => i.textContent.replace(/^[✓ ]\s*/, '') === label)
2272
2326
  const togglesPresent = ['自己溜达', '跟随状态', '跑完撒花'].every((l) => toggleItem(l) !== undefined)
2273
2327
  check(togglesPresent, 'all three behaviour toggles are in the menu',
@@ -2279,6 +2333,8 @@ if (clientSrc !== null) {
2279
2333
  check(fifth.config.wander === !wanderBefore, 'clicking a toggle really flips the config',
2280
2334
  `${wanderBefore} -> ${fifth.config.wander}`)
2281
2335
  body5.dispatch('contextmenu', rightClickOnHer().ev)
2336
+ /* 重开菜单=一次新手势,所以分组回到收起状态(这是设计)。要再点开关就得再展开。 */
2337
+ check(expand('行为'), 'reopening the menu collapses the groups again')
2282
2338
  const reopened = toggleItem('自己溜达')
2283
2339
  check(reopened !== undefined && reopened.textContent.startsWith(' '),
2284
2340
  'and reopening the menu shows the new state',
@@ -2292,6 +2348,7 @@ if (clientSrc !== null) {
2292
2348
  // 自己开菜单,不依赖上一节留下的状态 —— 隐含的跨节状态是测试脆弱性的常见来源。
2293
2349
  body5.dispatch('contextmenu', rightClickOnHer().ev)
2294
2350
  check(menuOf() !== null, 'the menu opens for the skin checks')
2351
+ check(expand('外观'), 'and the skins group can be opened')
2295
2352
  const skinBefore = fifth.skin
2296
2353
  const posBefore = fifth.position
2297
2354
  const srcBeforeSkin = h.srcs[h.srcs.length - 1]
@@ -2314,9 +2371,9 @@ if (clientSrc !== null) {
2314
2371
  check(fifth.configure({ skin: 'deepsea' }) && fifth.skin === 'deepsea',
2315
2372
  'configure({skin}) goes through the same path as the menu', String(fifth.skin))
2316
2373
 
2317
- // 点「放大一点」—— 真改尺寸,并且菜单自己关掉
2374
+ // 点「放大一点」—— 真改尺寸,并且菜单自己关掉(大小两项在「外观」组里,先展开)
2318
2375
  body5.dispatch('contextmenu', rightClickOnHer().ev)
2319
- const menuItems = itemsOf()
2376
+ check(expand('外观'), 'the size entries need the appearance group open')
2320
2377
  byText('放大一点')[0].dispatch('click', itemEvent())
2321
2378
  check(fifth.element.style.width === '630px' && fifth.element.style.height === '945px',
2322
2379
  'a menu action runs (zoom one step up)', `${fifth.element.style.width}x${fifth.element.style.height}`)
@@ -2326,6 +2383,7 @@ if (clientSrc !== null) {
2326
2383
  const secondClick = rightClickOnHer()
2327
2384
  body5.dispatch('contextmenu', secondClick.ev)
2328
2385
  check(menuOf() !== null, 'the menu reopens')
2386
+ check(expand('位置'), 'the corners live in the position group')
2329
2387
  byText('右下角')[0].dispatch('click', itemEvent())
2330
2388
  const homed = fifth.position
2331
2389
  check(homed.x === secondClick.homeX && homed.y === secondClick.homeY,
@@ -2352,7 +2410,7 @@ if (clientSrc !== null) {
2352
2410
  })
2353
2411
  check(kbd().at !== null && kbd().at.tagName === 'BUTTON',
2354
2412
  'opening the menu focuses its first item', kbd().label)
2355
- check(kbd().at?.className === 'wisp-menu-item' && kbd().label === '说句话',
2413
+ check(kbd().at?.className === 'wisp-menu-item' && kbd().label === '关于她…',
2356
2414
  'and the focus lands on the first real action', kbd().label)
2357
2415
  check(kbd().zero === 1, 'exactly one item is in the tab order (roving tabindex)', `${kbd().zero}`)
2358
2416
  check(itemsOf()[0].getAttribute('aria-selected') === 'true',
@@ -2528,6 +2586,16 @@ if (clientSrc !== null) {
2528
2586
  found[0].dispatch('click', itemEvent())
2529
2587
  return true
2530
2588
  }
2589
+ /* 菜单改成分组之后,组里的条目要先把组展开才看得见。
2590
+ 这个 helper 让"点某个分组里的东西"是一次调用,而不是每处各写一遍。 */
2591
+ const openGroupOn = (inst, name) => {
2592
+ const host = inst.element.parentNode
2593
+ const row = host.querySelectorAll('.wisp-menu-item').find((i) => i.dataset && i.dataset.group === name)
2594
+ if (!row) return false
2595
+ row.dispatch('click', itemEvent())
2596
+ return host.querySelectorAll('.wisp-menu-item')
2597
+ .some((i) => i.dataset && i.dataset.group === name && i.dataset.open === 'true')
2598
+ }
2531
2599
  // 四个角落。期望值按【当前】尺寸算 —— 写死尺寸的话,一旦尺寸变了断言会
2532
2600
  // 指着正确的实现说它错。
2533
2601
  const cur = boxOf(pinned)
@@ -2539,6 +2607,7 @@ if (clientSrc !== null) {
2539
2607
  }
2540
2608
  for (const [label, want] of Object.entries(EXPECT)) {
2541
2609
  openMenuOn(pinned)
2610
+ openGroupOn(pinned, '位置') // 四个角落现在收在「位置」组里
2542
2611
  const hit = pickExact(pinned, label)
2543
2612
  check(hit && pinned.position.x === want.x && pinned.position.y === want.y,
2544
2613
  `the ${label} item puts her there`, `${pinned.position.x},${pinned.position.y} (want ${want.x},${want.y})`)
@@ -2706,6 +2775,7 @@ if (clientSrc !== null) {
2706
2775
  active = keepSoak
2707
2776
  } catch (error) {
2708
2777
  bad('browser half executes', `${error.constructor.name}: ${error.message}`)
2778
+ if (error && error.stack) console.log(String(error.stack).split('\n').slice(0, 6).map((l) => ' ' + l.trim()).join('\n'))
2709
2779
  console.log(error.stack)
2710
2780
  } finally {
2711
2781
  active = null
@@ -2713,6 +2783,287 @@ if (clientSrc !== null) {
2713
2783
  }
2714
2784
  }
2715
2785
 
2786
+ /* ================================================== 3y. dialogs ============ */
2787
+ head('3y. dialogs: about her, and the action preview')
2788
+
2789
+ /* 菜单顶层只留动作,"她是谁"和"她有哪些动作"放进弹窗。这里测的就是这两个弹窗:
2790
+ 能不能开、内容对不对、三种关法,以及**反复开合不会把文档级监听越堆越多**
2791
+ (那是真写进去过的 bug:清理函数是个空壳)。 */
2792
+ {
2793
+ const dlg = createHarness({ timer: true, composerText: '' })
2794
+ const keepDlg = active
2795
+ active = dlg
2796
+ dlg.evaluate(clientSrc)
2797
+ dlg.module().default.apply(dlg.ctx, { reactions: true, wander: false, celebrate: false })
2798
+ dlg.advance(1300, 100)
2799
+ const dlgApi = dlg.win.__wisp
2800
+ const bundleVersion = /const VERSION = '([^']+)'/.exec(clientSrc)?.[1] ?? null
2801
+
2802
+ /* 替身的 find() 会把已移除的节点也返回(它自己的约定),所以要显式认 removed。 */
2803
+ const dialogGone = () => {
2804
+ const el = dlg.find('wisp-dialog')
2805
+ return el === null || el.removed === true
2806
+ }
2807
+ /* 按段落/页脚类读文本:简化 DOM 不会汇总子节点 textContent,读卡片本身只会得到空串。 */
2808
+ const dialogText = () => [].concat(
2809
+ dlg.all('wisp-dialog-p').filter((el) => el.removed !== true).map((el) => String(el.textContent)),
2810
+ dlg.all('wisp-dialog-foot-l').filter((el) => el.removed !== true).map((el) => String(el.textContent)),
2811
+ dlg.all('wisp-dialog-foot-r').filter((el) => el.removed !== true).map((el) => String(el.textContent)),
2812
+ ).join('\n')
2813
+
2814
+ check(dlgApi.dialog === null, 'nothing is open at the start', String(dlgApi.dialog))
2815
+ check(dialogGone(), 'and no dialog sits in the DOM before it is asked for')
2816
+
2817
+ dlgApi.openAbout()
2818
+ check(dlgApi.dialog === 'about', 'asking about her opens the about dialog', String(dlgApi.dialog))
2819
+ const card = dlg.find('wisp-dialog')
2820
+ check(card !== null, 'the dialog card is in the DOM')
2821
+ check(dlg.find('wisp-dialog-backdrop') !== null, 'with a backdrop behind it')
2822
+ const aboutText = dialogText()
2823
+ check(aboutText.includes('DeepSeek娘'), 'it says who she is')
2824
+ check(bundleVersion !== null && aboutText.includes(bundleVersion),
2825
+ 'and which version this is', `${bundleVersion} / 文本里找得到: ${aboutText.includes(String(bundleVersion))}`)
2826
+ check(aboutText.includes('社区'), 'and credits the community the look comes from')
2827
+ check(aboutText.includes('非官方'), 'and says plainly that it is unofficial')
2828
+
2829
+ dlg.document.dispatch('keydown', { key: 'Escape', preventDefault() {} })
2830
+ check(dlgApi.dialog === null && dialogGone(), 'Escape closes it',
2831
+ `dialog=${String(dlgApi.dialog)} 监听数=${dlg.document.documentListenerCount}`)
2832
+ check(dlg.document.documentListenerCountOf('keydown') === 1,
2833
+ 'exactly one document-level key listener exists', String(dlg.document.documentListenerCountOf('keydown')))
2834
+ check(dlg.document.documentListenerCountOf('visibilitychange') === 1,
2835
+ 'and exactly one visibility listener (added for the come-back greeting)',
2836
+ String(dlg.document.documentListenerCountOf('visibilitychange')))
2837
+
2838
+ dlgApi.openActions()
2839
+ check(dlgApi.dialog === 'actions', 'the action preview opens', String(dlgApi.dialog))
2840
+ const cells = dlg.all('wisp-cell').filter((el) => el.removed !== true)
2841
+ check(cells.length === 8, 'and lists eight actions — one per mood', `${cells.length} 格`)
2842
+ check(cells.every((c) => typeof c.dataset.mood === 'string' && c.dataset.mood !== ''),
2843
+ 'each cell names its mood', cells.map((c) => c.dataset.mood).join(','))
2844
+ check(cells.every((c) => c.querySelector('img') !== null), 'each cell carries a sprite')
2845
+
2846
+ const eatCell = cells.find((c) => c.dataset.mood === 'eat')
2847
+ eatCell.dispatch('click', { preventDefault() {}, stopPropagation() {} })
2848
+ check(dlgApi.dialog === null, 'clicking an action closes the dialog so you can see her')
2849
+ check(dlgApi.currentMood === 'eat', 'and she takes that pose', String(dlgApi.currentMood))
2850
+ dlg.advance(1500, 100)
2851
+ check(dlgApi.currentMood === 'eat',
2852
+ 'the reaction poll leaves the preview alone while it lasts', String(dlgApi.currentMood))
2853
+ dlg.advance(9000, 500)
2854
+ check(dlgApi.currentMood !== 'eat', 'and the preview does not stick forever', String(dlgApi.currentMood))
2855
+
2856
+ dlgApi.openActions()
2857
+ const x = dlg.find('wisp-dialog-x')
2858
+ check(x !== null, 'the close button is there')
2859
+ x.dispatch('click', { preventDefault() {} })
2860
+ check(dlgApi.dialog === null, 'and it closes the dialog')
2861
+
2862
+ dlgApi.openAbout()
2863
+ dlg.find('wisp-dialog-backdrop').dispatch('pointerdown', {})
2864
+ check(dlgApi.dialog === null, 'clicking the backdrop closes it too')
2865
+
2866
+ for (let i = 0; i < 3; i++) { dlgApi.openAbout(); dlgApi.closeDialog() }
2867
+ check(dlg.document.documentListenerCountOf('keydown') === 1,
2868
+ 'opening and closing repeatedly does not stack listeners',
2869
+ String(dlg.document.documentListenerCountOf('keydown')))
2870
+
2871
+ dlgApi.openAbout()
2872
+ dlgApi.destroy()
2873
+ check(dlgApi.dialog === null, 'destroy() takes the dialog with it')
2874
+ active = keepDlg
2875
+ }
2876
+
2877
+ /* ============================== 3y-2. night / come back / streak =========== */
2878
+ head('3y-2. night nagging, coming back, and the streak')
2879
+
2880
+ /* 这三件事都靠**可配置的时间**,所以不需要伪造时钟:
2881
+ 把深夜窗口套在当前那个小时上,行为就该发生;套偏一个小时,就不该发生。 */
2882
+ {
2883
+ const hourNowReal = new Date().getHours()
2884
+ const mk = (options) => {
2885
+ const h = createHarness(Object.assign({ timer: true, composerText: '在打字' }, options))
2886
+ h.evaluate(clientSrc)
2887
+ return h
2888
+ }
2889
+ const liveSaid = (h) => h.all('wisp-say').filter((el) => el.removed !== true).map((el) => el.textContent)
2890
+ const saidLines = (h) => liveSaid(h)
2891
+ /* 推进 ms,把过程中出现过的每一句话都收集起来。 */
2892
+ const watch = (h, ms, step = 250) => {
2893
+ const seen = new Set(liveSaid(h))
2894
+ for (let left = ms; left > 0; left -= step) {
2895
+ h.advance(Math.min(step, left), Math.min(step, 200))
2896
+ for (const t of liveSaid(h)) seen.add(t)
2897
+ }
2898
+ return [...seen]
2899
+ }
2900
+ const inPool = (pool, text) => Array.isArray(pool) && pool.includes(text)
2901
+
2902
+ /* ---- 深夜劝睡 ---------------------------------------------------------- */
2903
+ const night = mk()
2904
+ const keepNight = active
2905
+ active = night
2906
+ night.module().default.apply(night.ctx, {
2907
+ reactions: true, wander: false, celebrate: false,
2908
+ careAfterMs: 0, hungerMs: 0, sleepAfterMs: 3600000,
2909
+ /* 让"现在"落在深夜窗口里:从当前小时开始,到下一个小时结束 */
2910
+ bedtimeHour: hourNowReal, wakeHour: (hourNowReal + 1) % 24, nightMs: 60000,
2911
+ })
2912
+ night.advance(1300, 100)
2913
+ const nightApi = night.win.__wisp
2914
+ check(nightApi.doctor().night.enabled === true
2915
+ && nightApi.doctor().night.deepFrom === (hourNowReal + 2) % 24,
2916
+ 'doctor reports the night window and where the deeper register starts',
2917
+ JSON.stringify(nightApi.doctor().night))
2918
+ const nightSaid = watch(night, 70000)
2919
+ check(nightSaid.some((t) => inPool(linesInBundle()?.nightLate, t)),
2920
+ 'inside the window she speaks up, from the late pool',
2921
+ nightSaid.slice(-2).join(' / ') || '(没说)')
2922
+ check(nightApi.doctor().night.lastAt > 0, 'and the check time is recorded',
2923
+ String(nightApi.doctor().night.lastAt))
2924
+
2925
+ /* 窗口外不该说话 */
2926
+ const day = mk()
2927
+ const keepDay = active
2928
+ active = day
2929
+ day.module().default.apply(day.ctx, {
2930
+ reactions: true, wander: false, celebrate: false,
2931
+ careAfterMs: 0, hungerMs: 0, sleepAfterMs: 3600000,
2932
+ bedtimeHour: (hourNowReal + 3) % 24, wakeHour: (hourNowReal + 4) % 24, nightMs: 60000,
2933
+ })
2934
+ day.advance(1300, 100)
2935
+ const daySaid = watch(day, 200000)
2936
+ check(!daySaid.some((t) => inPool(linesInBundle()?.nightLate, t) || inPool(linesInBundle()?.nightDeep, t)),
2937
+ 'outside the window she says nothing about bedtime', saidLines(day).slice(-2).join(' / ') || '(没说)')
2938
+ day.win.__wisp.destroy()
2939
+ active = keepDay
2940
+
2941
+ /* 深度深夜:把 bedtime 往前挪两小时,当前小时就落进"嘴硬心软"那一档 */
2942
+ const deep = mk()
2943
+ const keepDeep = active
2944
+ active = deep
2945
+ deep.module().default.apply(deep.ctx, {
2946
+ reactions: true, wander: false, celebrate: false,
2947
+ careAfterMs: 0, hungerMs: 0, sleepAfterMs: 3600000,
2948
+ bedtimeHour: (hourNowReal + 22) % 24, wakeHour: (hourNowReal + 1) % 24, nightMs: 60000,
2949
+ })
2950
+ deep.advance(1300, 100)
2951
+ const deepSaid = watch(deep, 70000)
2952
+ check(deepSaid.some((t) => inPool(linesInBundle()?.nightDeep, t)),
2953
+ 'two hours past bedtime the register changes', deepSaid.slice(-2).join(' / ') || '(没说)')
2954
+
2955
+ /* 两种关法 */
2956
+ const nightLines = () => [].concat(linesInBundle()?.nightLate ?? [], linesInBundle()?.nightDeep ?? [])
2957
+ deep.win.__wisp.configure({ night: false })
2958
+ check(!watch(deep, 300000).some((t) => nightLines().includes(t)),
2959
+ 'night: false turns the whole thing off')
2960
+ deep.win.__wisp.configure({ night: true, nightMs: 0 })
2961
+ check(!watch(deep, 300000).some((t) => nightLines().includes(t)), 'so does nightMs: 0')
2962
+ deep.win.__wisp.destroy()
2963
+ active = keepDeep
2964
+
2965
+ /* ---- 离开又回来 -------------------------------------------------------- */
2966
+ const back = mk()
2967
+ const keepBack = active
2968
+ active = back
2969
+ back.module().default.apply(back.ctx, {
2970
+ reactions: true, wander: false, celebrate: false, careAfterMs: 0, hungerMs: 0,
2971
+ sleepAfterMs: 3600000, backAfterMs: 60000,
2972
+ })
2973
+ back.advance(1300, 100)
2974
+ const backApi = back.win.__wisp
2975
+ /* 替身没有 visibilityState,直接摆上再派发事件 —— 这正是真浏览器里发生的事。 */
2976
+ back.document.visibilityState = 'hidden'
2977
+ back.document.dispatch('visibilitychange', {})
2978
+ back.advance(120000, 1000)
2979
+ back.document.visibilityState = 'visible'
2980
+ back.document.dispatch('visibilitychange', {})
2981
+ const shortBack = watch(back, 400)
2982
+ check(shortBack.some((t) => inPool(linesInBundle()?.backSoon, t)),
2983
+ 'coming back after a short while gets the short line', shortBack.slice(-2).join(' / ') || '(没说)')
2984
+
2985
+ back.document.visibilityState = 'hidden'
2986
+ back.document.dispatch('visibilitychange', {})
2987
+ back.advance(30 * 60000, 5000)
2988
+ back.document.visibilityState = 'visible'
2989
+ back.document.dispatch('visibilitychange', {})
2990
+ const longBack = watch(back, 400)
2991
+ check(longBack.some((t) => inPool(linesInBundle()?.backLong, t)),
2992
+ 'a long absence gets the other line', longBack.slice(-2).join(' / ') || '(没说)')
2993
+
2994
+ /* 走开一小会儿不该说话(只看"回来池",别把别的行为的话算进来) */
2995
+ const backLines = () => [].concat(linesInBundle()?.backSoon ?? [], linesInBundle()?.backLong ?? [])
2996
+ back.document.visibilityState = 'hidden'
2997
+ back.document.dispatch('visibilitychange', {})
2998
+ back.advance(5000, 1000)
2999
+ back.document.visibilityState = 'visible'
3000
+ back.document.dispatch('visibilitychange', {})
3001
+ check(!watch(back, 600).some((t) => backLines().includes(t)),
3002
+ 'a blink of absence is not worth a line')
3003
+
3004
+ back.win.__wisp.configure({ backAfterMs: 0 })
3005
+ back.document.visibilityState = 'hidden'
3006
+ back.document.dispatch('visibilitychange', {})
3007
+ back.advance(30 * 60000, 5000)
3008
+ back.document.visibilityState = 'visible'
3009
+ back.document.dispatch('visibilitychange', {})
3010
+ check(!watch(back, 600).some((t) => backLines().includes(t)),
3011
+ 'backAfterMs: 0 turns the greeting off')
3012
+ back.win.__wisp.destroy()
3013
+ active = keepBack
3014
+
3015
+ /* ---- 连续天数 ---------------------------------------------------------- */
3016
+ /* 用 localStorage 直接种一段连续的记录:今天 + 往前三天 = 连续 4 天。 */
3017
+ const keyFor = (offset) => {
3018
+ const d = new Date(Date.now() - offset * 86400000)
3019
+ return d.getFullYear() + '-' + (d.getMonth() + 1) + '-' + d.getDate()
3020
+ }
3021
+ const memorySeed = {
3022
+ 'dsh-wisp:memory:v1': JSON.stringify({
3023
+ days: [keyFor(3), keyFor(2), keyFor(1), keyFor(0)],
3024
+ said: [], corners: {}, skins: {}, remarkDay: keyFor(0),
3025
+ }),
3026
+ }
3027
+ const streak = mk({ storageSeed: memorySeed })
3028
+ const keepStreak = active
3029
+ active = streak
3030
+ streak.module().default.apply(streak.ctx, {
3031
+ reactions: true, wander: false, celebrate: false, careAfterMs: 0, hungerMs: 0, sleepAfterMs: 3600000,
3032
+ })
3033
+ const streakSaid = watch(streak, 5000)
3034
+ const streakApi = streak.win.__wisp
3035
+ check(streakApi.doctor().streak === 4, 'doctor reports a four-day streak', String(streakApi.doctor().streak))
3036
+ const streakPool = [].concat(linesInBundle()?.streak ?? [], linesInBundle()?.milestone ?? [])
3037
+ .map((t) => t.split('{n}').join('4'))
3038
+ check(streakSaid.some((t) => streakPool.includes(t)),
3039
+ 'and she mentions it the next time you meet', streakSaid.slice(-3).join(' / ') || '(没说)')
3040
+ streak.win.__wisp.destroy()
3041
+ active = keepStreak
3042
+
3043
+ /* 断了就从头数:今天之前缺一天 */
3044
+ const brokenSeed = {
3045
+ 'dsh-wisp:memory:v1': JSON.stringify({
3046
+ days: [keyFor(5), keyFor(4), keyFor(0)], said: [], corners: {}, skins: {}, remarkDay: keyFor(0),
3047
+ }),
3048
+ }
3049
+ const broken = mk({ storageSeed: brokenSeed })
3050
+ const keepBroken = active
3051
+ active = broken
3052
+ broken.module().default.apply(broken.ctx, {
3053
+ reactions: true, wander: false, celebrate: false, careAfterMs: 0, hungerMs: 0, sleepAfterMs: 3600000,
3054
+ })
3055
+ const brokenSaid = watch(broken, 5000)
3056
+ check(broken.win.__wisp.doctor().streak === 1,
3057
+ 'a gap resets it to one (and one is not worth mentioning)',
3058
+ String(broken.win.__wisp.doctor().streak))
3059
+ const streakPool2 = [].concat(linesInBundle()?.streak ?? [], linesInBundle()?.milestone ?? [])
3060
+ .map((t) => t.split('{n}').join('1'))
3061
+ check(!brokenSaid.some((t) => streakPool2.includes(t)),
3062
+ 'so she stays quiet about it', brokenSaid.slice(-2).join(' / ') || '(没说)')
3063
+ broken.win.__wisp.destroy()
3064
+ active = keepBroken
3065
+ }
3066
+
2716
3067
  /* ================================================== 3z. generation audit ==== */
2717
3068
  head('3z. every shipped sprite traces back to a text-only call')
2718
3069