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/README.md +91 -0
- package/lib/client.js +404 -42
- package/lib/client.template.js +404 -42
- package/package.json +1 -1
- package/tools/smoke.mjs +1 -1
- package/verify-wisp.mjs +307 -40
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 ===
|
|
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
|
-
|
|
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
|
-
|
|
483
|
-
|
|
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
|
-
|
|
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 ===
|
|
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(
|
|
2297
|
-
'the
|
|
2298
|
-
|
|
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(
|
|
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)}
|
|
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
|
|
2326
|
-
const
|
|
2327
|
-
check(
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
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
|
-
|
|
2333
|
-
check(fifth.config.wander === !wanderBefore, 'clicking a
|
|
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
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
const
|
|
2339
|
-
check(
|
|
2340
|
-
'
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
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(
|
|
2377
|
-
|
|
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
|
-
'
|
|
2380
|
-
check(menuOf()
|
|
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
|
-
|
|
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 =
|
|
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
|
|