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/README.md +88 -0
- package/lib/client.js +378 -46
- package/lib/client.template.js +378 -46
- package/package.json +1 -1
- package/tools/smoke.mjs +1 -1
- package/verify-wisp.mjs +287 -39
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,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)}
|
|
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}`)
|
|
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
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
'
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
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(
|
|
2377
|
-
|
|
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
|
-
'
|
|
2380
|
-
check(menuOf()
|
|
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
|
-
|
|
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 =
|
|
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
|
}
|