dsh-wisp 1.27.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/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
  },
@@ -1455,12 +1468,16 @@ if (clientSrc !== null) {
1455
1468
  ctxEvent 定义在文件后半部分,在这里用就是 TDZ。 */
1456
1469
  eat.win.__wisp.configure({ hungry: true })
1457
1470
  const eatBody = eat.find('wisp-root').querySelector('.wisp-body')
1458
- eatBody.dispatch('keydown', {
1471
+ const openEatMenu = () => eatBody.dispatch('keydown', {
1459
1472
  type: 'keydown', key: 'Enter', shiftKey: false, defaultPrevented: false,
1460
1473
  preventDefault() { this.defaultPrevented = true }, stopPropagation() {},
1461
1474
  })
1462
- const eatToggle = eat.all('wisp-menu-item').filter((el) => el.removed !== true)
1463
- .find((el) => (el.textContent || '').includes('饿了'))
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('饿了'))
1464
1481
  check(Boolean(eatToggle), 'the menu carries a rice toggle',
1465
1482
  eat.all('wisp-menu-item').filter((el) => el.removed !== true).length + ' 项')
1466
1483
  if (eatToggle) {
@@ -2243,14 +2260,49 @@ if (clientSrc !== null) {
2243
2260
  固定部分 = 6 个动作 + 4 个开关 + 4 个角落 = 14;其余是每套皮肤一项。 */
2244
2261
  /* api.skins 是数组(SKIN_IDS 的拷贝),不是对象 —— 用 Object.keys 只是碰巧数对了。 */
2245
2262
  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)`)
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) */
2249
2301
  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(' / '))
2302
+ expand('外观')
2303
+ check(groupRow('外观').getAttribute('aria-expanded') === 'true',
2304
+ 'and aria-expanded follows the expansion')
2305
+ /* 皮肤现在在「外观」组里 —— 上一步已经把该组展开了。 */
2254
2306
  /* 从 API 派生,**不写死皮肤名**。写死过「初版女仆」,改名成「素绘女仆」之后
2255
2307
  三条断言当场全炸,其中一条 undefined.dispatch 还把整个套件截断了。 */
2256
2308
  /* 既有 API 的形状是**有序数组** [{id,label}],不是 id→label 的对象。 */
@@ -2268,6 +2320,7 @@ if (clientSrc !== null) {
2268
2320
  'the menu lives beside her body, not inside it', 'inside it would be mirrored and scale with her')
2269
2321
 
2270
2322
  /* ---- 三个行为开关(原本在设置页里,那页撤掉后落到菜单) -------------- */
2323
+ check(expand('行为'), 'the behaviour group opens') /* 开关都在这一组里 */
2271
2324
  const toggleItem = (label) => itemsOf().find((i) => i.textContent.replace(/^[✓ ]\s*/, '') === label)
2272
2325
  const togglesPresent = ['自己溜达', '跟随状态', '跑完撒花'].every((l) => toggleItem(l) !== undefined)
2273
2326
  check(togglesPresent, 'all three behaviour toggles are in the menu',
@@ -2279,6 +2332,8 @@ if (clientSrc !== null) {
2279
2332
  check(fifth.config.wander === !wanderBefore, 'clicking a toggle really flips the config',
2280
2333
  `${wanderBefore} -> ${fifth.config.wander}`)
2281
2334
  body5.dispatch('contextmenu', rightClickOnHer().ev)
2335
+ /* 重开菜单=一次新手势,所以分组回到收起状态(这是设计)。要再点开关就得再展开。 */
2336
+ check(expand('行为'), 'reopening the menu collapses the groups again')
2282
2337
  const reopened = toggleItem('自己溜达')
2283
2338
  check(reopened !== undefined && reopened.textContent.startsWith(' '),
2284
2339
  'and reopening the menu shows the new state',
@@ -2292,6 +2347,7 @@ if (clientSrc !== null) {
2292
2347
  // 自己开菜单,不依赖上一节留下的状态 —— 隐含的跨节状态是测试脆弱性的常见来源。
2293
2348
  body5.dispatch('contextmenu', rightClickOnHer().ev)
2294
2349
  check(menuOf() !== null, 'the menu opens for the skin checks')
2350
+ check(expand('外观'), 'and the skins group can be opened')
2295
2351
  const skinBefore = fifth.skin
2296
2352
  const posBefore = fifth.position
2297
2353
  const srcBeforeSkin = h.srcs[h.srcs.length - 1]
@@ -2314,9 +2370,9 @@ if (clientSrc !== null) {
2314
2370
  check(fifth.configure({ skin: 'deepsea' }) && fifth.skin === 'deepsea',
2315
2371
  'configure({skin}) goes through the same path as the menu', String(fifth.skin))
2316
2372
 
2317
- // 点「放大一点」—— 真改尺寸,并且菜单自己关掉
2373
+ // 点「放大一点」—— 真改尺寸,并且菜单自己关掉(大小两项在「外观」组里,先展开)
2318
2374
  body5.dispatch('contextmenu', rightClickOnHer().ev)
2319
- const menuItems = itemsOf()
2375
+ check(expand('外观'), 'the size entries need the appearance group open')
2320
2376
  byText('放大一点')[0].dispatch('click', itemEvent())
2321
2377
  check(fifth.element.style.width === '630px' && fifth.element.style.height === '945px',
2322
2378
  'a menu action runs (zoom one step up)', `${fifth.element.style.width}x${fifth.element.style.height}`)
@@ -2326,6 +2382,7 @@ if (clientSrc !== null) {
2326
2382
  const secondClick = rightClickOnHer()
2327
2383
  body5.dispatch('contextmenu', secondClick.ev)
2328
2384
  check(menuOf() !== null, 'the menu reopens')
2385
+ check(expand('位置'), 'the corners live in the position group')
2329
2386
  byText('右下角')[0].dispatch('click', itemEvent())
2330
2387
  const homed = fifth.position
2331
2388
  check(homed.x === secondClick.homeX && homed.y === secondClick.homeY,
@@ -2352,7 +2409,7 @@ if (clientSrc !== null) {
2352
2409
  })
2353
2410
  check(kbd().at !== null && kbd().at.tagName === 'BUTTON',
2354
2411
  'opening the menu focuses its first item', kbd().label)
2355
- check(kbd().at?.className === 'wisp-menu-item' && kbd().label === '说句话',
2412
+ check(kbd().at?.className === 'wisp-menu-item' && kbd().label === '关于她…',
2356
2413
  'and the focus lands on the first real action', kbd().label)
2357
2414
  check(kbd().zero === 1, 'exactly one item is in the tab order (roving tabindex)', `${kbd().zero}`)
2358
2415
  check(itemsOf()[0].getAttribute('aria-selected') === 'true',
@@ -2528,6 +2585,16 @@ if (clientSrc !== null) {
2528
2585
  found[0].dispatch('click', itemEvent())
2529
2586
  return true
2530
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
+ }
2531
2598
  // 四个角落。期望值按【当前】尺寸算 —— 写死尺寸的话,一旦尺寸变了断言会
2532
2599
  // 指着正确的实现说它错。
2533
2600
  const cur = boxOf(pinned)
@@ -2539,6 +2606,7 @@ if (clientSrc !== null) {
2539
2606
  }
2540
2607
  for (const [label, want] of Object.entries(EXPECT)) {
2541
2608
  openMenuOn(pinned)
2609
+ openGroupOn(pinned, '位置') // 四个角落现在收在「位置」组里
2542
2610
  const hit = pickExact(pinned, label)
2543
2611
  check(hit && pinned.position.x === want.x && pinned.position.y === want.y,
2544
2612
  `the ${label} item puts her there`, `${pinned.position.x},${pinned.position.y} (want ${want.x},${want.y})`)
@@ -2706,6 +2774,7 @@ if (clientSrc !== null) {
2706
2774
  active = keepSoak
2707
2775
  } catch (error) {
2708
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'))
2709
2778
  console.log(error.stack)
2710
2779
  } finally {
2711
2780
  active = null
@@ -2713,6 +2782,93 @@ if (clientSrc !== null) {
2713
2782
  }
2714
2783
  }
2715
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
+
2716
2872
  /* ================================================== 3z. generation audit ==== */
2717
2873
  head('3z. every shipped sprite traces back to a text-only call')
2718
2874