dsh-wisp 1.29.0 → 1.31.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
@@ -258,7 +258,7 @@ const skinCount = await page.evaluate(() => {
258
258
  const s = window.__wisp.skins
259
259
  return Array.isArray(s) ? s.length : Object.keys(s ?? {}).length
260
260
  })
261
- check(menu !== null && menu.items.length === 7, 'the menu lists every entry',
261
+ check(menu !== null && menu.items.length === 8, 'the menu lists every entry',
262
262
  `${menu?.items.length} items at the top level`)
263
263
 
264
264
  // 键盘:打开即聚焦第一项,方向键移动,Esc 关闭并把焦点还给她
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,149 @@ 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}`)
2335
- 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))
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()
2345
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())
2450
+ body5.dispatch('contextmenu', rightClickOnHer().ev)
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
+ check(!itemsOf().some((i) => i.textContent.includes('版本与更新')),
2459
+ 'the version entry is gone from the menu (the header shows it now)')
2460
+ check(itemsOf().some((i) => i.textContent.includes('关于她')),
2461
+ 'but 关于她 is still reachable')
2462
+
2463
+ check(body5.querySelectorAll('.wisp-menu').length === 0,
2464
+ 'the menu lives beside her body, not inside it', 'inside it would be mirrored and scale with her')
2346
2465
 
2347
2466
  // 换皮肤:精灵图必须真的换掉,位置和情绪不能动。
2348
2467
  // 自己开菜单,不依赖上一节留下的状态 —— 隐含的跨节状态是测试脆弱性的常见来源。
@@ -2371,20 +2490,31 @@ if (clientSrc !== null) {
2371
2490
  check(fifth.configure({ skin: 'deepsea' }) && fifth.skin === 'deepsea',
2372
2491
  'configure({skin}) goes through the same path as the menu', String(fifth.skin))
2373
2492
 
2374
- // 点「放大一点」—— 真改尺寸,并且菜单自己关掉(大小两项在「外观」组里,先展开)
2493
+ // 大小现在是滑块:用键盘调(不关菜单),再用菜单动作验证"选了就关"
2375
2494
  body5.dispatch('contextmenu', rightClickOnHer().ev)
2376
- check(expand('外观'), 'the size entries need the appearance group open')
2377
- byText('放大一点')[0].dispatch('click', itemEvent())
2495
+ check(menuOf() !== null, 'the menu opens for the size check')
2496
+ const sizeSlider = menuOf().querySelector('.wisp-slider')
2497
+ check(sizeSlider !== null, 'the slider is there')
2498
+ h.win.dispatch('keydown', { key: 'Home', preventDefault() {}, stopPropagation() {} })
2499
+ for (let i = 0; i < 5; i++) {
2500
+ h.win.dispatch('keydown', { key: 'ArrowDown', preventDefault() {}, stopPropagation() {} })
2501
+ }
2502
+ h.win.dispatch('keydown', { key: 'ArrowRight', preventDefault() {}, stopPropagation() {} })
2503
+ h.win.dispatch('keydown', { key: 'ArrowRight', preventDefault() {}, stopPropagation() {} })
2378
2504
  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')
2505
+ 'the slider really resized her (two steps up)', `${fifth.element.style.width}x${fifth.element.style.height}`)
2506
+ check(menuOf() !== null, 'and the slider keeps the menu open, unlike an action row')
2507
+ h.win.dispatch('keydown', { key: 'Escape', preventDefault() {}, stopPropagation() {} })
2508
+ check(menuOf() === null, 'Escape closes it when you are done')
2381
2509
 
2382
2510
  // 再右键一次,用「回到右下角」归位(此时她已经被放大了,兜底尺寸要跟着变)
2383
2511
  const secondClick = rightClickOnHer()
2384
2512
  body5.dispatch('contextmenu', secondClick.ev)
2385
2513
  check(menuOf() !== null, 'the menu reopens')
2386
2514
  check(expand('位置'), 'the corners live in the position group')
2387
- byText('右下角')[0].dispatch('click', itemEvent())
2515
+ const homeBtn = menuOf().querySelectorAll('.wisp-corner').find((b) => b.dataset && b.dataset.corner === '右下角')
2516
+ check(Boolean(homeBtn), 'the corner button is there')
2517
+ homeBtn.dispatch('click', itemEvent())
2388
2518
  const homed = fifth.position
2389
2519
  check(homed.x === secondClick.homeX && homed.y === secondClick.homeY,
2390
2520
  'the trip-home item works at the current size', `${homed.x},${homed.y} (want ${secondClick.homeX},${secondClick.homeY})`)
@@ -2410,7 +2540,7 @@ if (clientSrc !== null) {
2410
2540
  })
2411
2541
  check(kbd().at !== null && kbd().at.tagName === 'BUTTON',
2412
2542
  'opening the menu focuses its first item', kbd().label)
2413
- check(kbd().at?.className === 'wisp-menu-item' && kbd().label === '关于她…',
2543
+ check(kbd().at?.className === 'wisp-menu-item' && /^专注 /.test(String(kbd().label)),
2414
2544
  'and the focus lands on the first real action', kbd().label)
2415
2545
  check(kbd().zero === 1, 'exactly one item is in the tab order (roving tabindex)', `${kbd().zero}`)
2416
2546
  check(itemsOf()[0].getAttribute('aria-selected') === 'true',
@@ -2586,6 +2716,14 @@ if (clientSrc !== null) {
2586
2716
  found[0].dispatch('click', itemEvent())
2587
2717
  return true
2588
2718
  }
2719
+ /* 四个角落现在是 2×2 罗盘里的按钮(不是 .wisp-menu-item),所以单独一个选择器。 */
2720
+ const pickCorner = (inst, text) => {
2721
+ const found = inst.element.parentNode.querySelectorAll('.wisp-corner')
2722
+ .filter((i) => i.dataset && i.dataset.corner === text)
2723
+ if (found.length !== 1) return false
2724
+ found[0].dispatch('click', itemEvent())
2725
+ return true
2726
+ }
2589
2727
  /* 菜单改成分组之后,组里的条目要先把组展开才看得见。
2590
2728
  这个 helper 让"点某个分组里的东西"是一次调用,而不是每处各写一遍。 */
2591
2729
  const openGroupOn = (inst, name) => {
@@ -2607,8 +2745,8 @@ if (clientSrc !== null) {
2607
2745
  }
2608
2746
  for (const [label, want] of Object.entries(EXPECT)) {
2609
2747
  openMenuOn(pinned)
2610
- openGroupOn(pinned, '位置') // 四个角落现在收在「位置」组里
2611
- const hit = pickExact(pinned, label)
2748
+ openGroupOn(pinned, '位置') // 四个角落现在收在「位置」组里,而且是罗盘按钮
2749
+ const hit = pickCorner(pinned, label)
2612
2750
  check(hit && pinned.position.x === want.x && pinned.position.y === want.y,
2613
2751
  `the ${label} item puts her there`, `${pinned.position.x},${pinned.position.y} (want ${want.x},${want.y})`)
2614
2752
  }
@@ -3064,6 +3202,135 @@ head('3y-2. night nagging, coming back, and the streak')
3064
3202
  active = keepBroken
3065
3203
  }
3066
3204
 
3205
+ /* ============================== 3y-3. focus timer & hidden pause ========== */
3206
+ head('3y-3. the focus timer, and going quiet while the page is hidden')
3207
+
3208
+ {
3209
+ const watch = (h, ms, step = 250) => {
3210
+ const live = () => h.all('wisp-say').filter((el) => el.removed !== true).map((el) => el.textContent)
3211
+ const seen = new Set(live())
3212
+ for (let left = ms; left > 0; left -= step) {
3213
+ h.advance(Math.min(step, left), Math.min(step, 200))
3214
+ for (const t of live()) seen.add(t)
3215
+ }
3216
+ return [...seen]
3217
+ }
3218
+ const strip = (pool, n) => (pool ?? []).map((t) => t.split('{n}').join(String(n)))
3219
+ const moodOf = (h) => h.find('wisp-root').dataset.mood
3220
+
3221
+ /* ---- 专注计时器 -------------------------------------------------------- */
3222
+ const focus = createHarness({ timer: true, composerText: '' })
3223
+ const keepFocus = active
3224
+ active = focus
3225
+ focus.evaluate(clientSrc)
3226
+ focus.module().default.apply(focus.ctx, {
3227
+ reactions: true, wander: false, celebrate: false,
3228
+ careAfterMs: 0, hungerMs: 0, night: false, sleepAfterMs: 3600000,
3229
+ })
3230
+ focus.advance(1300, 100)
3231
+ const focusApi = focus.win.__wisp
3232
+
3233
+ check(focusApi.focus.active === false, 'no focus session at the start')
3234
+ check(focusApi.doctor().focus.active === false && focusApi.doctor().focus.done === 0,
3235
+ 'and doctor says so', JSON.stringify(focusApi.doctor().focus))
3236
+
3237
+ const started = focusApi.startFocus(25)
3238
+ check(started.active === true && started.minutes === 25 && started.leftMin === 25,
3239
+ 'starting a session reports it back', JSON.stringify(started))
3240
+ check(focusApi.doctor().focus.active === true, 'doctor sees the session too')
3241
+ const startSaid = watch(focus, 1200)
3242
+ check(startSaid.some((t) => strip(linesInBundle()?.focusStart, 25).includes(t)),
3243
+ 'and she says the session started', startSaid.slice(-1).join('') || '(没说)')
3244
+
3245
+ /* 专注期间即使她没在忙,姿势也该是"干活中" —— 稳态不许把她挪走 */
3246
+ focus.advance(120000, 5000)
3247
+ check(moodOf(focus) === 'alert', 'she holds the working pose for the whole session', moodOf(focus))
3248
+
3249
+ /* 戳她 = 问还剩多久,而且不算戳着玩 */
3250
+ const pokeOnce = () => {
3251
+ const b = focus.find('wisp-body')
3252
+ b.dispatch('pointerdown', {
3253
+ button: 0, clientX: focusApi.position.x + 260, clientY: focusApi.position.y + 400,
3254
+ preventDefault() {}, stopPropagation() {},
3255
+ })
3256
+ focus.win.dispatch('pointerup', {})
3257
+ focus.advance(200, 50)
3258
+ }
3259
+ /* 24 分钟时戳她 —— 报的应该是"还剩 24 分钟"这类的话 */
3260
+ pokeOnce()
3261
+ const pokeSaid = watch(focus, 800)
3262
+ const leftPool = [].concat(strip(linesInBundle()?.focusLeft, 24), strip(linesInBundle()?.focusLeft, 23))
3263
+ check(pokeSaid.some((t) => leftPool.includes(t)),
3264
+ 'poking her during a session asks how long is left', pokeSaid.slice(-2).join(' / ') || '(没说)')
3265
+ check(moodOf(focus) !== 'poked', 'and it does not count as poking for fun', moodOf(focus))
3266
+
3267
+ /* 到点:她说收工,状态回到非激活,计数 +1 */
3268
+ const doneSaid = watch(focus, 26 * 60000, 30000)
3269
+ check(focusApi.focus.active === false, 'the session ends on its own', JSON.stringify(focusApi.focus))
3270
+ check(focusApi.focus.done === 1, 'and the finished count goes up', String(focusApi.focus.done))
3271
+ check(doneSaid.some((t) => strip(linesInBundle()?.focusDone, 25).includes(t)),
3272
+ 'and she tells you it is over', doneSaid.slice(-1).join('') || '(没说)')
3273
+
3274
+ /* 参数夹取:0 用默认时长,过大的被夹到 600 */
3275
+ check(focusApi.startFocus(0).minutes === focusApi.config.focusMinutes,
3276
+ 'zero falls back to the configured default', String(focusApi.startFocus(0).minutes))
3277
+ check(focusApi.startFocus(9999).minutes === 600, 'an absurd duration is clamped', String(focusApi.focus.minutes))
3278
+ const stopSaid = watch(focus, 400)
3279
+ focusApi.stopFocus()
3280
+ const stopSaid2 = watch(focus, 600)
3281
+ void stopSaid
3282
+ check(focusApi.focus.active === false, 'stopFocus ends it', JSON.stringify(focusApi.focus))
3283
+ check([].concat(stopSaid, stopSaid2).some((t) => (linesInBundle()?.focusStop ?? []).includes(t)),
3284
+ 'and she acknowledges the stop', [].concat(stopSaid, stopSaid2).slice(-1).join('') || '(没说)')
3285
+ check(focusApi.stopFocus() === false, 'stopping when nothing is running is a no-op')
3286
+ focus.win.__wisp.destroy()
3287
+ active = keepFocus
3288
+
3289
+ /* ---- 页面藏起来就暂停 -------------------------------------------------- */
3290
+ const quiet = createHarness({ timer: true, composerText: '' })
3291
+ const keepQuiet = active
3292
+ active = quiet
3293
+ quiet.evaluate(clientSrc)
3294
+ quiet.module().default.apply(quiet.ctx, {
3295
+ reactions: true, wander: true, celebrate: false,
3296
+ careAfterMs: 0, hungerMs: 0, night: false, sleepAfterMs: 3600000, backAfterMs: 60000,
3297
+ })
3298
+ quiet.advance(1300, 100)
3299
+ const quietApi = quiet.win.__wisp
3300
+ check(quietApi.doctor().timers.poll === true && quietApi.doctor().timers.paused === false,
3301
+ 'while visible the poll is armed', JSON.stringify(quietApi.doctor().timers))
3302
+
3303
+ quiet.document.visibilityState = 'hidden'
3304
+ quiet.document.dispatch('visibilitychange', {})
3305
+ const timers = quietApi.doctor().timers
3306
+ check(timers.paused === true && timers.poll === false && timers.chatter === false && timers.wander === false,
3307
+ 'hiding the page stops all three timers', JSON.stringify(timers))
3308
+
3309
+ /* 藏起来期间:来了一堆报错,她也不该有任何反应(轮询停了) */
3310
+ quiet.state.errors = 3
3311
+ quiet.state.busy = true
3312
+ quiet.advance(2000, 500)
3313
+ quiet.state.busy = false
3314
+ quiet.advance(20000, 2000)
3315
+ check(moodOf(quiet) !== 'worried', 'a hidden page gets no reaction at all', moodOf(quiet))
3316
+ check(quiet.all('wisp-say').filter((el) => el.removed !== true).length === 0,
3317
+ 'and she stays completely quiet', String(quiet.all('wisp-say').length))
3318
+
3319
+ quiet.document.visibilityState = 'visible'
3320
+ quiet.document.dispatch('visibilitychange', {})
3321
+ const after = quietApi.doctor().timers
3322
+ check(after.paused === false && after.poll === true && after.wander === true,
3323
+ 'coming back re-arms them', JSON.stringify(after))
3324
+ /* 回来之后同一批报错就该被看见了 */
3325
+ quiet.state.busy = true
3326
+ quiet.advance(1300, 100)
3327
+ quiet.state.busy = false
3328
+ quiet.advance(3000, 200)
3329
+ check(moodOf(quiet) === 'worried', 'and now the same errors do get a reaction', moodOf(quiet))
3330
+ quiet.win.__wisp.destroy()
3331
+ active = keepQuiet
3332
+ }
3333
+
3067
3334
  /* ================================================== 3z. generation audit ==== */
3068
3335
  head('3z. every shipped sprite traces back to a text-only call')
3069
3336