dsh-wisp 1.30.0 → 1.32.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
@@ -207,7 +207,8 @@ function createHarness(options = {}) {
207
207
  if (typeof sel !== 'string' || sel === '') return []
208
208
  if (sel.startsWith('.')) {
209
209
  const cls = sel.slice(1)
210
- return this.descendants().filter((d) => d.className === cls)
210
+ /* 同 matchAll:类选择器按类名列表匹配,不是精确相等 */
211
+ return this.descendants().filter((d) => String(d.className || '').split(/\s+/).includes(cls))
211
212
  }
212
213
  const tag = sel.toUpperCase()
213
214
  return this.descendants().filter((d) => d.tagName === tag)
@@ -479,8 +480,10 @@ function createHarness(options = {}) {
479
480
  pendingTimers: () => timers.filter((t) => !t.cancelled),
480
481
  /** 假 document:有些断言要直接查插件往页面里放了什么(例如窗口背景那条规则)。 */
481
482
  document: documentShim,
482
- find: (className) => created.find((e) => e.className === className) ?? null,
483
- all: (className) => created.filter((e) => e.className === className),
483
+ /* 按**类名列表**匹配,和 matchAll 一致:真实 DOM 的 .a 选择器本来就命中有多个类的元素,
484
+ 精确相等会让 'wisp-menu-item wisp-menu-row' 这类行在替身里变成不存在(这个坑踩过两次)。 */
485
+ find: (className) => created.find((e) => String(e.className || '').split(/\s+/).includes(className)) ?? null,
486
+ all: (className) => created.filter((e) => String(e.className || '').split(/\s+/).includes(className)),
484
487
  /** Evaluate lib/client.js the way the shell does, with every trap armed. */
485
488
  evaluate(src) {
486
489
  const traps = {}
@@ -1478,7 +1481,8 @@ if (clientSrc !== null) {
1478
1481
  /* 开关在「行为」组里:先展开(分组行带着 data-group) */
1479
1482
  const groupRowOf = (name) => eatLive().find((el) => el.dataset && el.dataset.group === name)
1480
1483
  groupRowOf('行为').dispatch('click', { preventDefault() {}, stopPropagation() {} })
1481
- const eatToggle = eatLive().find((el) => (el.textContent || '').includes('饿了'))
1484
+ /* 开关行现在是真的开关控件:文字在子 span 里,按 switchKey 找才靠谱。 */
1485
+ const eatToggle = eatLive().find((el) => el.dataset && el.dataset.switchKey === 'hungry')
1482
1486
  check(Boolean(eatToggle), 'the menu carries a rice toggle',
1483
1487
  eat.all('wisp-menu-item').filter((el) => el.removed !== true).length + ' 项')
1484
1488
  if (eatToggle) {
@@ -2238,6 +2242,10 @@ if (clientSrc !== null) {
2238
2242
  // 菜单挂在 layer 上(与气泡同一层,屏幕坐标系),不是挂在会被镜像/缩放的 root 上
2239
2243
  const menuHost = () => fifth.element.parentNode
2240
2244
  const menuOf = () => menuHost().querySelector('.wisp-menu')
2245
+ /* itemsOf() 会把**已移除的旧菜单**也返回(替身的既定行为),
2246
+ 所以凡是数数的地方都先滤掉 removed —— 这个坑在菜单测试里踩过不止一次。
2247
+ 名字不能用 liveItems:上面的标签菜单那节已经用过(同一作用域)。 */
2248
+ const liveMenuItems = () => menuHost().querySelectorAll('.wisp-menu-item').filter((el) => el.removed !== true)
2241
2249
  const itemsOf = () => menuHost().querySelectorAll('.wisp-menu-item')
2242
2250
  // 尺寸会变(菜单里就能放大缩小),所以每次都要按当前实时状态算,不能用写死常量
2243
2251
  const boxNow = () => ({
@@ -2265,7 +2273,7 @@ if (clientSrc !== null) {
2265
2273
  原来 19 个条目全平铺、只靠三行不可点的头部标签分区 —— 那不是结构,是排版。
2266
2274
  现在顶层 7 项,外观 / 行为 / 位置 三组各自折叠。 */
2267
2275
  const topLevel = itemsOf()
2268
- check(topLevel.length === 7, 'the top level holds seven entries, not nineteen',
2276
+ check(topLevel.length === 8, 'the top level stays short',
2269
2277
  `${topLevel.length} 项:${topLevel.map((i) => i.textContent).join(' | ')}`)
2270
2278
  const groupRow = (name) => itemsOf().find((i) => i.dataset && i.dataset.group === name)
2271
2279
  const groupNames = ['外观', '行为', '位置']
@@ -2293,12 +2301,11 @@ if (clientSrc !== null) {
2293
2301
  }
2294
2302
  check(expand('外观'), 'clicking a group expands it in place',
2295
2303
  `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 大小`)
2304
+ check(liveMenuItems().length > 8 && liveMenuItems().length >= skinCount,
2305
+ 'opening the group adds its entries (大小不再是条目,改成了滑块)',
2306
+ `展开后 ${liveMenuItems().length} 项(收起时 8)`)
2299
2307
  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) */
2308
+ check(liveMenuItems().length === 8, 'and the menu is back to its top level', String(liveMenuItems().length))
2302
2309
  const byText = (t) => itemsOf().filter((i) => i.textContent.includes(t))
2303
2310
  expand('外观')
2304
2311
  check(groupRow('外观').getAttribute('aria-expanded') === 'true',
@@ -2312,37 +2319,259 @@ if (clientSrc !== null) {
2312
2319
  const skinItems = itemsOf().filter((i) => skinNames.includes(plainSkinLabel(i)))
2313
2320
  check(skinItems.length === skinCount,
2314
2321
  'every shipped skin is offered',
2315
- `${skinItems.length} 项 / ${skinCount} 套;名字表=${JSON.stringify(skinNames)};菜单=${itemsOf().map((i) => i.textContent).join('|')}`)
2316
- // 勾现在有三处(三个行为开关都默认开)—— 皮肤那一段必须**恰有一个**勾
2322
+ `${skinItems.length} 项 / ${skinCount} 套;名字表=${JSON.stringify(skinNames)}`)
2317
2323
  check(skinItems.filter((i) => i.textContent.startsWith('✓')).length === 1,
2318
2324
  'exactly one skin is ticked as current',
2319
2325
  skinItems.map((i) => i.textContent).join(' / '))
2320
2326
  check(body5.querySelectorAll('.wisp-menu').length === 0,
2321
2327
  'the menu lives beside her body, not inside it', 'inside it would be mirrored and scale with her')
2322
2328
 
2323
- /* ---- 三个行为开关(原本在设置页里,那页撤掉后落到菜单) -------------- */
2329
+ /* ---- 重做之后的结构:头部 / 分隔线 / 摘要 / 键盘提示 --------------------
2330
+ 这一节盯着"交互设计"本身,而不只是"条目在不在"。 */
2331
+ const liveMenu = () => menuOf()
2332
+ check(liveMenu().querySelectorAll('.wisp-menu-title').length === 1,
2333
+ 'the menu opens with a header that says who it is',
2334
+ liveMenu().querySelectorAll('.wisp-menu-title').map((e) => e.textContent).join(''))
2335
+ const headerTitle = liveMenu().querySelector('.wisp-menu-title')
2336
+ const headerSub = liveMenu().querySelector('.wisp-menu-sub')
2337
+ check(headerTitle !== null && headerTitle.textContent === 'DeepSeek娘',
2338
+ 'and the name is hers', headerTitle ? headerTitle.textContent : '(没有)')
2339
+ check(headerSub !== null && String(headerSub.textContent).includes('v'),
2340
+ 'the version is visible without opening anything', headerSub ? headerSub.textContent : '(没有)')
2341
+ check(liveMenu().querySelectorAll('.wisp-menu-sep').length >= 3,
2342
+ 'sections are separated instead of stacked flat',
2343
+ String(liveMenu().querySelectorAll('.wisp-menu-sep').length))
2344
+ check(liveMenu().querySelectorAll('.wisp-menu-hint').length === 1,
2345
+ 'and the menu tells you how to drive it from the keyboard')
2346
+
2347
+ /* ---- 摘要行:收起时也知道里面是什么 ---- */
2348
+ const summaryOf = (name) => {
2349
+ const row = groupRow(name)
2350
+ if (!row) return null
2351
+ const sum = row.querySelector ? row.querySelector('.wisp-menu-sum') : null
2352
+ return sum ? String(sum.textContent) : null
2353
+ }
2354
+ check(summaryOf('外观') === api.skinLabels.find((s) => s.id === fifth.skin).label,
2355
+ 'the appearance row shows which skin is on', String(summaryOf('外观')))
2356
+ check(/^\d+\/\d+ 开$/.test(String(summaryOf('行为'))),
2357
+ 'the behaviour row counts how many switches are on', String(summaryOf('行为')))
2358
+ check(typeof summaryOf('位置') === 'string' && summaryOf('位置').length > 0,
2359
+ 'the position row says where she is', String(summaryOf('位置')))
2360
+
2361
+ /* ---- 开关:真开关控件,而且点完不关菜单 ---- */
2362
+ check(collapse('外观'), 'close the skin group again')
2324
2363
  check(expand('行为'), 'the behaviour group opens') /* 开关都在这一组里 */
2325
- const toggleItem = (label) => itemsOf().find((i) => i.textContent.replace(/^[✓ ]\s*/, '') === label)
2326
- const togglesPresent = ['自己溜达', '跟随状态', '跑完撒花'].every((l) => toggleItem(l) !== undefined)
2327
- check(togglesPresent, 'all three behaviour toggles are in the menu',
2328
- itemsOf().filter((i) => /溜达|跟随状态|撒花/.test(i.textContent)).map((i) => i.textContent).join(' / '))
2329
- check(toggleItem('自己溜达').textContent.startsWith('✓'),
2330
- 'a toggle shows its current state', toggleItem('自己溜达').textContent)
2364
+ const switchOf = (key) => itemsOf().find((i) => i.dataset && i.dataset.switchKey === key)
2365
+ const switchKeys = ['wander', 'reactions', 'celebrate', 'hungry', 'night']
2366
+ check(switchKeys.every((k) => switchOf(k) !== undefined), 'every behaviour has a switch row',
2367
+ switchKeys.map((k) => k + '=' + (switchOf(k) ? '有' : '无')).join(' '))
2368
+ const wanderSwitch = switchOf('wander')
2369
+ check(wanderSwitch.getAttribute('role') === 'menuitemcheckbox'
2370
+ && wanderSwitch.getAttribute('aria-checked') === 'true',
2371
+ 'a switch is a checkbox that says whether it is on',
2372
+ `role=${wanderSwitch.getAttribute('role')} aria-checked=${wanderSwitch.getAttribute('aria-checked')}`)
2373
+ check(wanderSwitch.querySelector('.wisp-switch') !== null
2374
+ && wanderSwitch.querySelector('.wisp-switch').dataset.on === 'true',
2375
+ 'and it has a visible on/off control, not a ✓ in the text')
2331
2376
  const wanderBefore = fifth.config.wander
2332
- toggleItem('自己溜达').dispatch('click', itemEvent())
2333
- check(fifth.config.wander === !wanderBefore, 'clicking a toggle really flips the config',
2377
+ wanderSwitch.dispatch('click', itemEvent())
2378
+ check(fifth.config.wander === !wanderBefore, 'clicking a switch really flips the config',
2334
2379
  `${wanderBefore} -> ${fifth.config.wander}`)
2380
+ check(menuOf() !== null, 'and the menu stays open while you flick switches')
2381
+ check(groupRow('行为') && groupRow('行为').dataset.open === 'true',
2382
+ 'with the group still expanded')
2383
+ const flipped = switchOf('wander')
2384
+ check(flipped && flipped.getAttribute('aria-checked') === 'false'
2385
+ && flipped.querySelector('.wisp-switch').dataset.on === 'false',
2386
+ 'and the control redraws in its new state',
2387
+ flipped ? flipped.getAttribute('aria-checked') : '(没了)')
2388
+ flipped.dispatch('click', itemEvent())
2389
+ check(fifth.config.wander === wanderBefore, 'toggling back restores it', String(fifth.config.wander))
2390
+
2391
+ /* ---- 滑块:连续量直接调,键盘也能调,而且不关菜单 ---- */
2392
+ const sliderEl = menuOf().querySelector ? menuOf().querySelector('.wisp-slider') : null
2393
+ check(sliderEl !== null && sliderEl.getAttribute('role') === 'slider',
2394
+ 'size is a slider, not a pair of nudge buttons',
2395
+ sliderEl ? sliderEl.getAttribute('role') : '(没有)')
2396
+ const sizeBefore = fifth.config.size
2397
+ check(Number(sliderEl.getAttribute('aria-valuenow')) === sizeBefore,
2398
+ 'and it reports the current size', sliderEl.getAttribute('aria-valuenow'))
2399
+ /* 用真实键盘走到滑块:它前面还有 5 个可聚焦项(两个专注 + 三个分组)。
2400
+ 不调用内部函数 —— 那不是公开面,测它就等于测实现。 */
2401
+ const pressKey = (key, shift) => h.win.dispatch('keydown', { key, shiftKey: shift === true, preventDefault() {}, stopPropagation() {} })
2402
+ /* 先把两个展开过的分组收起来:展开的组会把子项插进可聚焦序列,
2403
+ 焦点顺序就不确定了 —— 不依赖"第几次方向键刚好落在滑块上"这种脆弱假设。 */
2404
+ /* 那时它们本来就是收起的 —— 断言"collapse 成功"会假失败,改成"确保是收起的"。 */
2405
+ const ensureCollapsed = (name) => {
2406
+ const row = groupRow(name)
2407
+ if (row && row.dataset.open === 'true') collapse(name)
2408
+ return groupRow(name) !== undefined && groupRow(name).dataset.open === 'false'
2409
+ }
2410
+ check(ensureCollapsed('外观') && ensureCollapsed('行为'),
2411
+ 'both groups are collapsed, so the focus order is stable')
2412
+ /* 判断"焦点在滑块上"要看**当前**那个元素:菜单每次重画都会换新元素,
2413
+ 用之前抓到的 sliderEl 做同一性比较会假失败(收起分组就重画过)。 */
2414
+ const focusedIsSlider = () => {
2415
+ const el = h.document.activeElement
2416
+ return Boolean(el && el.dataset && el.dataset.slider === '1')
2417
+ }
2418
+ /* 走到滑块:Home 回到第一项,然后一直往下,直到焦点真的落在滑块上。
2419
+ 不数"第几次" —— 可聚焦项的数量会随分组展开变化,数次数是脆的。 */
2420
+ const focusSlider = () => {
2421
+ h.win.dispatch('keydown', { key: 'Home', preventDefault() {}, stopPropagation() {} })
2422
+ for (let i = 0; i < 16; i++) {
2423
+ if (focusedIsSlider()) return true
2424
+ pressKey('ArrowDown')
2425
+ }
2426
+ return focusedIsSlider()
2427
+ }
2428
+ check(focusSlider(), 'the slider is reachable with the arrow keys',
2429
+ h.document.activeElement ? String(h.document.activeElement.className) : '(没有焦点)')
2430
+ pressKey('ArrowRight')
2431
+ check(Math.abs(fifth.config.size - (sizeBefore + 0.25)) < 1e-9,
2432
+ 'ArrowRight grows her by a quarter step', `${sizeBefore} -> ${fifth.config.size}`)
2433
+ check(menuOf() !== null && focusedIsSlider(),
2434
+ 'and it neither closes the menu nor steals focus from the slider',
2435
+ menuOf() === null ? '菜单关了' : String(h.document.activeElement && h.document.activeElement.className))
2436
+ pressKey('ArrowLeft')
2437
+ check(Math.abs(fifth.config.size - sizeBefore) < 1e-9, 'ArrowLeft shrinks it back', String(fifth.config.size))
2438
+ const valueLabel = menuOf().querySelector('.wisp-slider-value')
2439
+ check(valueLabel !== null && valueLabel.textContent === Math.round(fifth.config.size * 100) + '%',
2440
+ 'and the percentage label follows', valueLabel ? valueLabel.textContent : '(没有)')
2441
+
2442
+ /* ---- 位置:2×2 的方位控件 ---- */
2443
+ check(expand('位置'), 'the position group opens')
2444
+ const gridButtons = menuOf().querySelectorAll('.wisp-corner')
2445
+ check(gridButtons.length === 4, 'the four corners are a 2×2 compass, not four text rows',
2446
+ String(gridButtons.length) + ' 个')
2447
+ /* 她此刻并不在任何角落(位置是拖出来的),所以先点一个角落归位,再看标记。 */
2448
+ const toTopLeft = gridButtons.find((b) => b.dataset && b.dataset.corner === '左上角')
2449
+ toTopLeft.dispatch('click', itemEvent())
2335
2450
  body5.dispatch('contextmenu', rightClickOnHer().ev)
2336
- /* 重开菜单=一次新手势,所以分组回到收起状态(这是设计)。要再点开关就得再展开。 */
2337
- check(expand('行为'), 'reopening the menu collapses the groups again')
2338
- const reopened = toggleItem('自己溜达')
2339
- check(reopened !== undefined && reopened.textContent.startsWith(' '),
2340
- 'and reopening the menu shows the new state',
2341
- reopened ? reopened.textContent : `菜单没重开(${itemsOf().length} 项)`)
2342
- if (reopened !== undefined) {
2343
- reopened.dispatch('click', itemEvent())
2344
- check(fifth.config.wander === wanderBefore, 'toggling back restores it', String(fifth.config.wander))
2451
+ check(expand('位置'), 'reopen the position group')
2452
+ const marked = menuOf().querySelectorAll('.wisp-corner').filter((b) => b.dataset && b.dataset.current === 'true')
2453
+ check(marked.length === 1 && marked[0].dataset.corner === '左上角',
2454
+ 'and the corner she is actually in is marked',
2455
+ menuOf().querySelectorAll('.wisp-corner').map((b) => b.textContent + (b.dataset.current ? '*' : '')).join(' '))
2456
+
2457
+ /* ---- 悬停展开 / 移开收起 ------------------------------------------------
2458
+ 这是"指针类"交互。替身里没有真鼠标,但它有**事件派发 + 虚拟时钟**,
2459
+ 所以这套逻辑能被完整测到:派 pointerenter、推进时钟、看菜单结构变没变。
2460
+ 测不到的是浏览器的命中测试本身("指针真的在这一行上")—— 那部分由冒烟里的
2461
+ 真 Chromium 兜着,这里测的是**状态机**:延迟、宽限、钉住、触屏、焦点保护。 */
2462
+ const hoverBlock = (name) => menuHost().querySelectorAll('.wisp-menu-group')
2463
+ .find((el) => el.dataset && el.dataset.groupBlock === name && el.removed !== true)
2464
+ const hoverIn = (name, pointerType) => hoverBlock(name).dispatch('pointerenter', { pointerType })
2465
+ const hoverOut = (name, pointerType) => hoverBlock(name).dispatch('pointerleave', { pointerType })
2466
+ const isOpen = (name) => {
2467
+ const row = groupRow(name)
2468
+ return row !== undefined && row.dataset.open === 'true'
2469
+ }
2470
+
2471
+ ensureCollapsed('外观'); ensureCollapsed('行为'); ensureCollapsed('位置')
2472
+ check(!isOpen('外观') && !isOpen('行为'), 'every group starts collapsed')
2473
+
2474
+ /* 悬停要**有延迟**:鼠标只是路过就展开会抖 —— 这是 hover intent,不是可有可无的润色 */
2475
+ hoverIn('外观', 'mouse')
2476
+ check(!isOpen('外观'), 'hovering does not open it instantly (a passing mouse would twitch it)')
2477
+ h.advance(200, 40)
2478
+ check(isOpen('外观'), 'after the intent delay it opens')
2479
+
2480
+ /* 移开也要有宽限:斜着往子项移动时,中途不能收 */
2481
+ hoverOut('外观', 'mouse')
2482
+ h.advance(80, 40)
2483
+ check(isOpen('外观'), 'leaving does not close it instantly either')
2484
+ h.advance(400, 40)
2485
+ check(!isOpen('外观'), 'after the grace period it closes')
2486
+
2487
+ /* 路过不展开:进去又马上出来,推进很久也不该开过 */
2488
+ hoverIn('行为', 'mouse')
2489
+ hoverOut('行为', 'mouse')
2490
+ h.advance(800, 40)
2491
+ check(!isOpen('行为'), 'a mouse that just passes over never opens a group')
2492
+
2493
+ /* 子项在**同一个悬停区**里 —— 这是"从标题移到子项不会关"的实现方式 */
2494
+ hoverIn('外观', 'mouse')
2495
+ h.advance(200, 40)
2496
+ const blockEl = hoverBlock('外观')
2497
+ const kids = blockEl.querySelector ? blockEl.querySelector('.wisp-menu-sub') : null
2498
+ check(kids !== null && kids.parentNode === blockEl,
2499
+ 'the children sit inside the same hover area, so moving into them is not leaving',
2500
+ kids === null ? '(没有子项容器)' : String(kids.parentNode === blockEl))
2501
+
2502
+ /* 触屏没有悬停:不判 pointerType 的话,手指一碰就会莫名其妙展开 */
2503
+ hoverOut('外观', 'mouse')
2504
+ h.advance(400, 40)
2505
+ hoverIn('外观', 'touch')
2506
+ h.advance(600, 40)
2507
+ check(!isOpen('外观'), 'a touch pointer never opens a group by hovering')
2508
+ groupRow('外观').dispatch('click', itemEvent())
2509
+ check(isOpen('外观'), 'but tapping it still works')
2510
+
2511
+ /* 点击 = 钉住:鼠标移开也不关(悬停是预览,点击是决定) */
2512
+ hoverOut('外观', 'mouse')
2513
+ h.advance(800, 40)
2514
+ check(isOpen('外观'), 'a group you clicked stays open when the mouse leaves')
2515
+ groupRow('外观').dispatch('click', itemEvent())
2516
+ check(!isOpen('外观'), 'clicking it again collapses it')
2517
+
2518
+ /* 手风琴:悬停到另一组时,被钉住的那一组会让位 */
2519
+ groupRow('外观').dispatch('click', itemEvent())
2520
+ check(isOpen('外观'), 'pin the appearance group again')
2521
+ hoverIn('行为', 'mouse')
2522
+ h.advance(200, 40)
2523
+ check(isOpen('行为') && !isOpen('外观'),
2524
+ 'hovering another group opens it and lets the pinned one go',
2525
+ `外观=${isOpen('外观')} 行为=${isOpen('行为')}`)
2526
+ hoverOut('行为', 'mouse')
2527
+ h.advance(600, 40)
2528
+ check(!isOpen('行为'), 'and it closes again after the grace')
2529
+
2530
+ /* 焦点在这一组里时,鼠标移开不该把它收掉(否则键盘用户正操作着,鼠标一动就没了) */
2531
+ hoverIn('外观', 'mouse')
2532
+ h.advance(200, 40)
2533
+ check(isOpen('外观'), 'hover it open again (not pinned this time)')
2534
+ const focusGroupRow = () => {
2535
+ pressKey('Home')
2536
+ for (let i = 0; i < 16; i++) {
2537
+ const el = h.document.activeElement
2538
+ if (el && el.dataset && el.dataset.group === '外观') return true
2539
+ pressKey('ArrowDown')
2540
+ }
2541
+ return false
2542
+ }
2543
+ check(focusGroupRow(), 'keyboard focus can land on the group row')
2544
+ const focusOnGroupRow = () => {
2545
+ const el = h.document.activeElement
2546
+ return Boolean(el && el.dataset && el.dataset.group === '外观')
2345
2547
  }
2548
+ check(focusOnGroupRow(), 'the focus really is on that row')
2549
+ hoverOut('外观', 'mouse')
2550
+ h.advance(300, 40)
2551
+ check(focusOnGroupRow(), 'hovering out does not move the focus')
2552
+ h.advance(500, 40)
2553
+ const activeNow = h.document.activeElement
2554
+ check(isOpen('外观'), 'while focus is inside that group, leaving with the mouse does not close it',
2555
+ `focus=${activeNow ? String(activeNow.className) + '/' + JSON.stringify(activeNow.dataset) : '(无)'}`)
2556
+ /* 焦点挪走之后,它就该按规则收起了 */
2557
+ pressKey('Home')
2558
+ hoverOut('外观', 'mouse')
2559
+ h.advance(800, 40)
2560
+ check(!isOpen('外观'), 'once focus leaves, the hover rules apply again')
2561
+ pressKey('Escape')
2562
+ check(menuOf() === null, 'Escape still closes the menu after all that hovering')
2563
+ /* 后面的检查要看菜单,重新打开 */
2564
+ body5.dispatch('contextmenu', rightClickOnHer().ev)
2565
+ check(menuOf() !== null, 'the menu reopens for the remaining structure checks')
2566
+
2567
+ /* ---- 菜单里不该再有"版本与更新":版本号在头部,检查更新在弹窗里 ---- */
2568
+ check(!itemsOf().some((i) => i.textContent.includes('版本与更新')),
2569
+ 'the version entry is gone from the menu (the header shows it now)')
2570
+ check(itemsOf().some((i) => i.textContent.includes('关于她')),
2571
+ 'but 关于她 is still reachable')
2572
+
2573
+ check(body5.querySelectorAll('.wisp-menu').length === 0,
2574
+ 'the menu lives beside her body, not inside it', 'inside it would be mirrored and scale with her')
2346
2575
 
2347
2576
  // 换皮肤:精灵图必须真的换掉,位置和情绪不能动。
2348
2577
  // 自己开菜单,不依赖上一节留下的状态 —— 隐含的跨节状态是测试脆弱性的常见来源。
@@ -2371,20 +2600,31 @@ if (clientSrc !== null) {
2371
2600
  check(fifth.configure({ skin: 'deepsea' }) && fifth.skin === 'deepsea',
2372
2601
  'configure({skin}) goes through the same path as the menu', String(fifth.skin))
2373
2602
 
2374
- // 点「放大一点」—— 真改尺寸,并且菜单自己关掉(大小两项在「外观」组里,先展开)
2603
+ // 大小现在是滑块:用键盘调(不关菜单),再用菜单动作验证"选了就关"
2375
2604
  body5.dispatch('contextmenu', rightClickOnHer().ev)
2376
- check(expand('外观'), 'the size entries need the appearance group open')
2377
- byText('放大一点')[0].dispatch('click', itemEvent())
2605
+ check(menuOf() !== null, 'the menu opens for the size check')
2606
+ const sizeSlider = menuOf().querySelector('.wisp-slider')
2607
+ check(sizeSlider !== null, 'the slider is there')
2608
+ h.win.dispatch('keydown', { key: 'Home', preventDefault() {}, stopPropagation() {} })
2609
+ for (let i = 0; i < 5; i++) {
2610
+ h.win.dispatch('keydown', { key: 'ArrowDown', preventDefault() {}, stopPropagation() {} })
2611
+ }
2612
+ h.win.dispatch('keydown', { key: 'ArrowRight', preventDefault() {}, stopPropagation() {} })
2613
+ h.win.dispatch('keydown', { key: 'ArrowRight', preventDefault() {}, stopPropagation() {} })
2378
2614
  check(fifth.element.style.width === '630px' && fifth.element.style.height === '945px',
2379
- 'a menu action runs (zoom one step up)', `${fifth.element.style.width}x${fifth.element.style.height}`)
2380
- check(menuOf() === null, 'choosing an item closes the menu')
2615
+ 'the slider really resized her (two steps up)', `${fifth.element.style.width}x${fifth.element.style.height}`)
2616
+ check(menuOf() !== null, 'and the slider keeps the menu open, unlike an action row')
2617
+ h.win.dispatch('keydown', { key: 'Escape', preventDefault() {}, stopPropagation() {} })
2618
+ check(menuOf() === null, 'Escape closes it when you are done')
2381
2619
 
2382
2620
  // 再右键一次,用「回到右下角」归位(此时她已经被放大了,兜底尺寸要跟着变)
2383
2621
  const secondClick = rightClickOnHer()
2384
2622
  body5.dispatch('contextmenu', secondClick.ev)
2385
2623
  check(menuOf() !== null, 'the menu reopens')
2386
2624
  check(expand('位置'), 'the corners live in the position group')
2387
- byText('右下角')[0].dispatch('click', itemEvent())
2625
+ const homeBtn = menuOf().querySelectorAll('.wisp-corner').find((b) => b.dataset && b.dataset.corner === '右下角')
2626
+ check(Boolean(homeBtn), 'the corner button is there')
2627
+ homeBtn.dispatch('click', itemEvent())
2388
2628
  const homed = fifth.position
2389
2629
  check(homed.x === secondClick.homeX && homed.y === secondClick.homeY,
2390
2630
  'the trip-home item works at the current size', `${homed.x},${homed.y} (want ${secondClick.homeX},${secondClick.homeY})`)
@@ -2410,7 +2650,7 @@ if (clientSrc !== null) {
2410
2650
  })
2411
2651
  check(kbd().at !== null && kbd().at.tagName === 'BUTTON',
2412
2652
  'opening the menu focuses its first item', kbd().label)
2413
- check(kbd().at?.className === 'wisp-menu-item' && kbd().label === '关于她…',
2653
+ check(kbd().at?.className === 'wisp-menu-item' && /^专注 /.test(String(kbd().label)),
2414
2654
  'and the focus lands on the first real action', kbd().label)
2415
2655
  check(kbd().zero === 1, 'exactly one item is in the tab order (roving tabindex)', `${kbd().zero}`)
2416
2656
  check(itemsOf()[0].getAttribute('aria-selected') === 'true',
@@ -2586,6 +2826,14 @@ if (clientSrc !== null) {
2586
2826
  found[0].dispatch('click', itemEvent())
2587
2827
  return true
2588
2828
  }
2829
+ /* 四个角落现在是 2×2 罗盘里的按钮(不是 .wisp-menu-item),所以单独一个选择器。 */
2830
+ const pickCorner = (inst, text) => {
2831
+ const found = inst.element.parentNode.querySelectorAll('.wisp-corner')
2832
+ .filter((i) => i.dataset && i.dataset.corner === text)
2833
+ if (found.length !== 1) return false
2834
+ found[0].dispatch('click', itemEvent())
2835
+ return true
2836
+ }
2589
2837
  /* 菜单改成分组之后,组里的条目要先把组展开才看得见。
2590
2838
  这个 helper 让"点某个分组里的东西"是一次调用,而不是每处各写一遍。 */
2591
2839
  const openGroupOn = (inst, name) => {
@@ -2607,8 +2855,8 @@ if (clientSrc !== null) {
2607
2855
  }
2608
2856
  for (const [label, want] of Object.entries(EXPECT)) {
2609
2857
  openMenuOn(pinned)
2610
- openGroupOn(pinned, '位置') // 四个角落现在收在「位置」组里
2611
- const hit = pickExact(pinned, label)
2858
+ openGroupOn(pinned, '位置') // 四个角落现在收在「位置」组里,而且是罗盘按钮
2859
+ const hit = pickCorner(pinned, label)
2612
2860
  check(hit && pinned.position.x === want.x && pinned.position.y === want.y,
2613
2861
  `the ${label} item puts her there`, `${pinned.position.x},${pinned.position.y} (want ${want.x},${want.y})`)
2614
2862
  }