@yfwu2020/dsh-selection-explain 0.8.3 → 0.9.1
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 +37 -0
- package/lib/client.js +427 -5
- package/lib/index.js +147 -7
- package/lib/index.js.map +1 -1
- package/package.json +2 -2
- package/scripts/smoke-panel.mjs +323 -0
- package/scripts/test-client.mjs +74 -3
- package/scripts/test-escape-focus.mjs +66 -0
- package/src/client/index.js +427 -5
- package/src/index.ts +147 -8
package/scripts/smoke-panel.mjs
CHANGED
|
@@ -92,6 +92,7 @@ function sse(events) {
|
|
|
92
92
|
return new Response(body, { headers: { 'content-type': 'text/event-stream' } })
|
|
93
93
|
}
|
|
94
94
|
var pillPosStore = null // 内存版 host 存储(/api/pill)
|
|
95
|
+
var panelSizeStore = null // 内存版 host 存储(/api/panel,只存尺寸)
|
|
95
96
|
window.fetch = function (input, init) {
|
|
96
97
|
var url = String(input)
|
|
97
98
|
var body = {}
|
|
@@ -105,6 +106,17 @@ window.fetch = function (input, init) {
|
|
|
105
106
|
if (url.indexOf('/selection-explain/api/history') >= 0) {
|
|
106
107
|
return Promise.resolve(new Response(JSON.stringify({ ok: true, entry: null, entries: [] }), { headers: { 'content-type': 'application/json' } }))
|
|
107
108
|
}
|
|
109
|
+
if (url.indexOf('/selection-explain/api/panel') >= 0) {
|
|
110
|
+
var isGetPanel = !init || !init.method || String(init.method).toUpperCase() === 'GET'
|
|
111
|
+
if (isGetPanel) return Promise.resolve(new Response(JSON.stringify({ ok: true, size: panelSizeStore }), { headers: { 'content-type': 'application/json' } }))
|
|
112
|
+
if (body.clear === true) {
|
|
113
|
+
panelSizeStore = null
|
|
114
|
+
return Promise.resolve(new Response(JSON.stringify({ ok: true, size: null }), { headers: { 'content-type': 'application/json' } }))
|
|
115
|
+
}
|
|
116
|
+
// 只认 w/h:位置不该出现在这个载荷里
|
|
117
|
+
panelSizeStore = body.size ? { w: body.size.w, h: body.size.h, hadXY: ('x' in body.size || 'y' in body.size) } : null
|
|
118
|
+
return Promise.resolve(new Response(JSON.stringify({ ok: true, size: panelSizeStore }), { headers: { 'content-type': 'application/json' } }))
|
|
119
|
+
}
|
|
108
120
|
if (url.indexOf('/selection-explain/api/pill') >= 0) {
|
|
109
121
|
var isGet = !init || !init.method || String(init.method).toUpperCase() === 'GET'
|
|
110
122
|
if (isGet) return Promise.resolve(new Response(JSON.stringify({ ok: true, position: pillPosStore }), { headers: { 'content-type': 'application/json' } }))
|
|
@@ -296,6 +308,276 @@ async function main() {
|
|
|
296
308
|
await waitFor(function () { return hook.state().phase === 'done' }, 4000)
|
|
297
309
|
check('首轮渲染完成', hook.state().phase === 'done', hook.state().phase)
|
|
298
310
|
|
|
311
|
+
// ── 面板 / 设置抽屉的真实尺寸 ──
|
|
312
|
+
// 为什么要有这一段:单测的 DOM 桩**没有布局**。这里曾经有条按阶段收窄到 440px 的规则,
|
|
313
|
+
// 因为 CSS 声明块漏了收尾花括号被整条丢弃(属性对、规则不生效,单测照样全绿);
|
|
314
|
+
// 修好闭合后它又暴露了"追问一下宽度就从 440 跳到 540"的问题 —— 两件事都只有真浏览器量得出来。
|
|
315
|
+
// 现在宽度**不随阶段变**(恒 540),下面同时钉住"首轮就是 540"和"追问之后仍是 540"。
|
|
316
|
+
var WIDE_WIDTH = 540
|
|
317
|
+
{
|
|
318
|
+
var panelEl = document.querySelector('.dsh-sel-panel')
|
|
319
|
+
var beforeRect = panelEl.getBoundingClientRect()
|
|
320
|
+
check('首轮阶段宽度就是常规宽度(不再按阶段收窄到 440)',
|
|
321
|
+
Math.abs(beforeRect.width - WIDE_WIDTH) <= 2,
|
|
322
|
+
'stage=' + panelEl.getAttribute('data-stage') + ' width=' + Math.round(beforeRect.width))
|
|
323
|
+
|
|
324
|
+
var settingsBtn = Array.prototype.slice.call(panelEl.querySelectorAll('.dsh-sel-action'))
|
|
325
|
+
.filter(function (b) { return b.textContent.indexOf('设置') >= 0 })[0]
|
|
326
|
+
check('头部有「设置」键', !!settingsBtn, settingsBtn ? settingsBtn.textContent : '未找到')
|
|
327
|
+
if (settingsBtn) {
|
|
328
|
+
clickOn(settingsBtn)
|
|
329
|
+
await waitFor(function () { return panelEl.getAttribute('data-settings') === '1' }, 1200)
|
|
330
|
+
await sleep(60)
|
|
331
|
+
var openRect = panelEl.getBoundingClientRect()
|
|
332
|
+
var sheetEl = panelEl.querySelector('.dsh-sel-sheet')
|
|
333
|
+
var sheetBody = sheetEl && sheetEl.querySelector('.dsh-sel-sheetbody')
|
|
334
|
+
// 抽屉是 inset:0 贴在面板上的:面板多大多小,抽屉就多大多小。
|
|
335
|
+
// 而面板高度是"解读内容有多长"撑出来的 —— 刚开窗时内容很短,
|
|
336
|
+
// 不锁下限的话设置列表只剩十几个像素(实测 17px),等于没法用。
|
|
337
|
+
// 设置窗口是 inset:0 贴在面板上的:**宽度必须与小窗一致**,
|
|
338
|
+
// 开抽屉不该让面板自己"胖一圈"或"瘦一圈"(高度才需要补下限)。
|
|
339
|
+
check('开设置抽屉后:设置窗口宽度与小窗完全一致(不额外加宽)',
|
|
340
|
+
Math.abs(openRect.width - beforeRect.width) <= 0.5,
|
|
341
|
+
'抽屉=' + openRect.width.toFixed(1) + ' 小窗=' + beforeRect.width.toFixed(1))
|
|
342
|
+
check('开设置抽屉后:高度有下限(不再被内容长短绑架)',
|
|
343
|
+
openRect.height >= 500,
|
|
344
|
+
'height=' + Math.round(openRect.height) + '(首轮未开抽屉时 ' + Math.round(beforeRect.height) + ')')
|
|
345
|
+
var bodyH = sheetBody ? sheetBody.clientHeight : 0
|
|
346
|
+
check('设置列表可视高度够用(不是一条缝)',
|
|
347
|
+
bodyH >= 200,
|
|
348
|
+
'sheetBody.clientHeight=' + Math.round(bodyH))
|
|
349
|
+
// 关掉,别影响后面的用例
|
|
350
|
+
var backBtn = sheetEl && sheetEl.querySelector('.dsh-sel-sheetback')
|
|
351
|
+
if (backBtn) clickOn(backBtn)
|
|
352
|
+
await waitFor(function () { return panelEl.getAttribute('data-settings') !== '1' }, 1200)
|
|
353
|
+
await sleep(40)
|
|
354
|
+
var restored = panelEl.getBoundingClientRect()
|
|
355
|
+
check('关掉抽屉后:小窗宽度不变(开合抽屉不动宽度)',
|
|
356
|
+
Math.abs(restored.width - beforeRect.width) <= 0.5 && Math.abs(restored.width - WIDE_WIDTH) <= 2,
|
|
357
|
+
'width=' + Math.round(restored.width))
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ── 拖动 / 改大小 ──
|
|
362
|
+
// 规则:**尺寸**记住(用户拖角调过的),**位置不记**(每次开窗都按选区重新锚定)。
|
|
363
|
+
function pointerOn(node, type, x, y) {
|
|
364
|
+
node.dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y, pointerId: 1, button: 0, buttons: 1 }))
|
|
365
|
+
}
|
|
366
|
+
{
|
|
367
|
+
var panelEl2 = document.querySelector('.dsh-sel-panel')
|
|
368
|
+
var before = hook.panelGeom()
|
|
369
|
+
|
|
370
|
+
// ① 拖右下角 → 变宽变高,并且只有尺寸被存下来
|
|
371
|
+
var corner = panelEl2.querySelector('.dsh-sel-grip-corner')
|
|
372
|
+
check('有右下角改大小手柄', !!corner, corner ? corner.className : '未找到')
|
|
373
|
+
if (corner) {
|
|
374
|
+
pointerOn(corner, 'pointerdown', 0, 0)
|
|
375
|
+
pointerOn(window, 'pointermove', 140, 90)
|
|
376
|
+
pointerOn(window, 'pointerup', 140, 90)
|
|
377
|
+
await sleep(60)
|
|
378
|
+
var grown = hook.panelGeom()
|
|
379
|
+
check('拖角能让小窗变大',
|
|
380
|
+
Math.round(grown.w) > Math.round(before.w) + 100 && Math.round(grown.h) > Math.round(before.h) + 60,
|
|
381
|
+
'w ' + Math.round(before.w) + '→' + Math.round(grown.w) + ' h ' + Math.round(before.h) + '→' + Math.round(grown.h))
|
|
382
|
+
check('改过大小会打标记(设置页就不再插手高度)', grown.resized === true, String(grown.resized))
|
|
383
|
+
await sleep(420) // 防抖 300ms
|
|
384
|
+
check('尺寸存到了 host(下次开窗回到这个大小)',
|
|
385
|
+
!!panelSizeStore && Math.abs(panelSizeStore.w - grown.w) <= 1 && Math.abs(panelSizeStore.h - grown.h) <= 1,
|
|
386
|
+
JSON.stringify(panelSizeStore))
|
|
387
|
+
check('存档里**没有位置**(载荷只有 w/h)', !!panelSizeStore && panelSizeStore.hadXY === false,
|
|
388
|
+
JSON.stringify(panelSizeStore))
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ② 拖标题栏:这次能挪动,但**不该存位置**
|
|
392
|
+
var headEl = panelEl2.querySelector('.dsh-sel-head')
|
|
393
|
+
var posBefore = hook.panelGeom()
|
|
394
|
+
pointerOn(headEl, 'mousedown', posBefore.x + 60, posBefore.y + 12)
|
|
395
|
+
window.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: posBefore.x + 60 + 70, clientY: posBefore.y + 12 + 44 }))
|
|
396
|
+
window.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }))
|
|
397
|
+
await sleep(60)
|
|
398
|
+
var moved = hook.panelGeom()
|
|
399
|
+
check('拖标题栏能挪动小窗(本次有效)',
|
|
400
|
+
Math.abs(moved.x - posBefore.x) > 40 && Math.abs(moved.y - posBefore.y) > 20,
|
|
401
|
+
'x ' + Math.round(posBefore.x) + '→' + Math.round(moved.x) + ' y ' + Math.round(posBefore.y) + '→' + Math.round(moved.y))
|
|
402
|
+
await sleep(420)
|
|
403
|
+
check('拖动**不会**把位置存进 host(位置记忆已按要求去掉)',
|
|
404
|
+
!!panelSizeStore && panelSizeStore.hadXY === false,
|
|
405
|
+
JSON.stringify(panelSizeStore))
|
|
406
|
+
|
|
407
|
+
// ③ 尺寸能读回来(模拟刷新:清掉内联尺寸,再让客户端重读一次)
|
|
408
|
+
panelEl2.style.width = ''
|
|
409
|
+
panelEl2.style.height = ''
|
|
410
|
+
panelEl2.removeAttribute('data-resized')
|
|
411
|
+
hook.loadPanelSize()
|
|
412
|
+
await sleep(80)
|
|
413
|
+
var restored2 = hook.panelGeom()
|
|
414
|
+
// 宽度按存档精确恢复;高度只作为天花板 —— 内容撑不到就自己短着(见 ⑥)
|
|
415
|
+
check('宽度能按存档恢复',
|
|
416
|
+
Math.abs(restored2.w - panelSizeStore.w) <= 1,
|
|
417
|
+
'w=' + Math.round(restored2.w) + ' / 存档 ' + Math.round(panelSizeStore.w))
|
|
418
|
+
check('高度不超过存档的上限',
|
|
419
|
+
restored2.h <= panelSizeStore.h + 1,
|
|
420
|
+
'h=' + Math.round(restored2.h) + ' / 上限 ' + Math.round(panelSizeStore.h))
|
|
421
|
+
|
|
422
|
+
// ④ 重新开窗 → 必须重新按选区锚定(这是"位置不记"的核心保证)
|
|
423
|
+
// 先把窗口挪到一个明显偏离锚点的位置,再关掉重开。
|
|
424
|
+
panelEl2.style.left = '7px'
|
|
425
|
+
panelEl2.style.top = '7px'
|
|
426
|
+
hook.close()
|
|
427
|
+
await sleep(40)
|
|
428
|
+
hook.open('重新锚定探测', '上下文片段 ABC', '冒烟')
|
|
429
|
+
await sleep(60)
|
|
430
|
+
var reopened = hook.panelGeom()
|
|
431
|
+
check('重新开窗会重新按选区锚定(位置没被记住)',
|
|
432
|
+
reopened.x > 20 && reopened.y > 20,
|
|
433
|
+
'x=' + Math.round(reopened.x) + ' y=' + Math.round(reopened.y) + '(若被记住应是 7,7)')
|
|
434
|
+
|
|
435
|
+
// ⑤ 双击角标 → 恢复默认尺寸
|
|
436
|
+
if (corner) {
|
|
437
|
+
corner.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true, view: window }))
|
|
438
|
+
await sleep(80)
|
|
439
|
+
var afterReset = hook.panelGeom()
|
|
440
|
+
check('双击角标恢复默认尺寸(清掉存档)',
|
|
441
|
+
afterReset.resized === false && panelSizeStore === null,
|
|
442
|
+
'resized=' + afterReset.resized + ' store=' + JSON.stringify(panelSizeStore))
|
|
443
|
+
await sleep(420)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ⑥ 记住的高度只能当**天花板**:新会话内容短时面板必须缩回去,不能留一大片空白。
|
|
447
|
+
// 这条是用户报的回归 —— 高度被当成固定值写上去,新开一个会话(只有一小段翻译)
|
|
448
|
+
// 面板硬撑到上次那么高,下面全是空的。
|
|
449
|
+
panelSizeStore = { w: 620, h: 700, hadXY: false }
|
|
450
|
+
panelEl2.style.width = ''
|
|
451
|
+
panelEl2.style.height = ''
|
|
452
|
+
panelEl2.style.maxHeight = ''
|
|
453
|
+
panelEl2.removeAttribute('data-resized')
|
|
454
|
+
hook.loadPanelSize()
|
|
455
|
+
await sleep(80)
|
|
456
|
+
hook.close()
|
|
457
|
+
await sleep(40)
|
|
458
|
+
hook.open('自适应高度探测', '上下文片段 ABC', '冒烟')
|
|
459
|
+
await sleep(100)
|
|
460
|
+
var fresh = hook.panelGeom()
|
|
461
|
+
var blank = panelEl2.clientHeight - panelEl2.scrollHeight
|
|
462
|
+
check('新会话高度自适应:面板里没有大片空白',
|
|
463
|
+
blank <= 4,
|
|
464
|
+
'clientHeight=' + panelEl2.clientHeight + ' scrollHeight=' + panelEl2.scrollHeight + ' 空白=' + blank)
|
|
465
|
+
check('存档的高度只当天花板(没被当成固定高度)',
|
|
466
|
+
fresh.h < 680,
|
|
467
|
+
'h=' + Math.round(fresh.h) + '(存档上限 700,若被当固定值用就该是 700)')
|
|
468
|
+
check('宽度仍沿用存档的值(宽度固定、高度自适应)',
|
|
469
|
+
Math.abs(fresh.w - 620) <= 1,
|
|
470
|
+
'w=' + Math.round(fresh.w))
|
|
471
|
+
|
|
472
|
+
// ⑦ 双击 = 自适应 = 恢复默认尺寸(同一个动作):把窗口**拉小**之后再双击,
|
|
473
|
+
// 高度不该还被旧的上限卡着 —— 那是"只清了 width/height、漏了 maxHeight"的症状。
|
|
474
|
+
panelSizeStore = { w: 340, h: 250, hadXY: false }
|
|
475
|
+
panelEl2.style.width = ''
|
|
476
|
+
panelEl2.style.height = ''
|
|
477
|
+
panelEl2.style.maxHeight = ''
|
|
478
|
+
panelEl2.removeAttribute('data-resized')
|
|
479
|
+
hook.loadPanelSize()
|
|
480
|
+
await sleep(80)
|
|
481
|
+
hook.close()
|
|
482
|
+
await sleep(40)
|
|
483
|
+
hook.open('拉小后双击探测', '上下文片段 ABC', '冒烟')
|
|
484
|
+
await sleep(120)
|
|
485
|
+
var shrunk = hook.panelGeom()
|
|
486
|
+
check('存档的小尺寸生效(宽度 340、高度被 250 卡住)',
|
|
487
|
+
Math.abs(shrunk.w - 340) <= 1,
|
|
488
|
+
'w=' + Math.round(shrunk.w) + ' h=' + Math.round(shrunk.h))
|
|
489
|
+
corner.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true, view: window }))
|
|
490
|
+
await sleep(120)
|
|
491
|
+
var back = hook.panelGeom()
|
|
492
|
+
check('双击后宽度回到默认(不再被存档的 340 卡住)',
|
|
493
|
+
Math.abs(back.w - 340) > 40,
|
|
494
|
+
'w ' + Math.round(shrunk.w) + '→' + Math.round(back.w))
|
|
495
|
+
check('双击后高度上限也被清掉(不再被 250 卡住)',
|
|
496
|
+
panelEl2.style.maxHeight === '' && back.h > 260,
|
|
497
|
+
'maxHeight=' + JSON.stringify(panelEl2.style.maxHeight) + ' h=' + Math.round(back.h))
|
|
498
|
+
await sleep(420)
|
|
499
|
+
|
|
500
|
+
// ⑦.5 刚拖完角标就双击还原:防抖里那次没落盘的保存**不能**把还原撤销掉。
|
|
501
|
+
// savePanelSize 在定时器触发时才读当前尺寸 —— 不掐掉的话它 300ms 后会读到
|
|
502
|
+
// "已还原的默认尺寸",反过来当成用户选的尺寸存下来,宽度被钉死。
|
|
503
|
+
pointerOn(corner, 'pointerdown', 0, 0)
|
|
504
|
+
pointerOn(window, 'pointermove', 90, 60)
|
|
505
|
+
pointerOn(window, 'pointerup', 90, 60)
|
|
506
|
+
await sleep(40) // 远小于 300ms 防抖:保存还没落盘
|
|
507
|
+
corner.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true, view: window }))
|
|
508
|
+
await sleep(500) // 跨过防抖窗口,看那次保存会不会"复活"
|
|
509
|
+
check('刚拖完就双击还原:待落的保存不会把还原撤销掉',
|
|
510
|
+
panelSizeStore === null && panelEl2.style.width === '' && panelEl2.getAttribute('data-resized') === null,
|
|
511
|
+
'store=' + JSON.stringify(panelSizeStore) + ' inline=' + JSON.stringify(panelEl2.style.width) +
|
|
512
|
+
' resized=' + panelEl2.getAttribute('data-resized'))
|
|
513
|
+
|
|
514
|
+
// ⑧ 恢复默认尺寸**不等于没有上限**:上限从"用户调过的"换回"CSS 默认的" min(78vh,720px)。
|
|
515
|
+
// 清掉的是用户那条自定义上限,不是限制本身。
|
|
516
|
+
{
|
|
517
|
+
var cap = Math.min(window.innerHeight * 0.78, 720)
|
|
518
|
+
var computedMax = getComputedStyle(panelEl2).maxHeight
|
|
519
|
+
check('恢复默认后高度仍有上限(回到 CSS 默认 min(78vh,720px))',
|
|
520
|
+
panelEl2.style.maxHeight === '' && computedMax !== 'none' &&
|
|
521
|
+
Math.abs(parseFloat(computedMax) - cap) <= 2,
|
|
522
|
+
'inline=' + JSON.stringify(panelEl2.style.maxHeight) + ' computed=' + computedMax +
|
|
523
|
+
' 期望=' + Math.round(cap) + 'px')
|
|
524
|
+
// 塞一段超长内容:面板不该被撑破上限,多出来的部分在正文里滚动
|
|
525
|
+
// 塞一段超长内容量一次 —— 量完必须**收拾干净**:这一段动的是真面板,
|
|
526
|
+
// 面板高度会 423 → 634 → 423,滚动位置也会变;不还原就会影响后面那些
|
|
527
|
+
// 依赖坐标的划词用例(跑多次能撞见)。
|
|
528
|
+
var bodyEl = panelEl2.querySelector('.dsh-sel-body')
|
|
529
|
+
var savedScroll = bodyEl.scrollTop
|
|
530
|
+
var filler = document.createElement('div')
|
|
531
|
+
filler.style.cssText = 'height:3000px;flex:0 0 auto'
|
|
532
|
+
bodyEl.appendChild(filler)
|
|
533
|
+
await sleep(80)
|
|
534
|
+
var tall = panelEl2.getBoundingClientRect().height
|
|
535
|
+
check('内容超长时面板不越过上限(超出部分内部滚动)',
|
|
536
|
+
tall <= cap + 2,
|
|
537
|
+
'h=' + Math.round(tall) + ' 上限=' + Math.round(cap) + ' body 可滚=' + (bodyEl.scrollHeight > bodyEl.clientHeight))
|
|
538
|
+
filler.remove()
|
|
539
|
+
bodyEl.scrollTop = savedScroll
|
|
540
|
+
void panelEl2.offsetHeight // 强制回流,别让后面的用例量到中间态
|
|
541
|
+
await sleep(140)
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// ⑨ 手柄不该有常驻的视觉标识(用户要求去掉右下角那道小斜纹)
|
|
545
|
+
var cornerAfter = getComputedStyle(corner, '::after')
|
|
546
|
+
check('右下角手柄没有常驻标识(不再画小斜纹)',
|
|
547
|
+
!cornerAfter.content || cornerAfter.content === 'none' || cornerAfter.content === 'normal',
|
|
548
|
+
'content=' + cornerAfter.content)
|
|
549
|
+
check('手柄仍在(只是不可见,鼠标移上去光标会变)',
|
|
550
|
+
getComputedStyle(corner).cursor === 'nwse-resize',
|
|
551
|
+
'cursor=' + getComputedStyle(corner).cursor)
|
|
552
|
+
|
|
553
|
+
// ⑩ 贴着屏幕边缘时双击恢复默认尺寸 → 面板不能被顶出屏幕
|
|
554
|
+
// 复现:先拉宽到接近满屏并推到右边,再双击角标(宽度会跳回默认 540,位置是旧的)
|
|
555
|
+
var vw = window.innerWidth
|
|
556
|
+
var vh = window.innerHeight
|
|
557
|
+
pointerOn(corner, 'pointerdown', 0, 0)
|
|
558
|
+
pointerOn(window, 'pointermove', vw, vh) // 尽量拉大
|
|
559
|
+
pointerOn(window, 'pointerup', vw, vh)
|
|
560
|
+
await sleep(80)
|
|
561
|
+
// 推到右下角
|
|
562
|
+
var big = hook.panelGeom()
|
|
563
|
+
var headEl2 = panelEl2.querySelector('.dsh-sel-head')
|
|
564
|
+
pointerOn(headEl2, 'mousedown', big.x + 60, big.y + 12)
|
|
565
|
+
window.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: vw + 500, clientY: vh + 500 }))
|
|
566
|
+
window.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }))
|
|
567
|
+
await sleep(80)
|
|
568
|
+
var beforeReset = hook.panelGeom()
|
|
569
|
+
corner.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true, view: window }))
|
|
570
|
+
await sleep(120)
|
|
571
|
+
var afterDbl = hook.panelGeom()
|
|
572
|
+
check('贴边双击恢复默认尺寸后,面板仍在屏幕内',
|
|
573
|
+
afterDbl.x >= 0 && afterDbl.y >= 0 &&
|
|
574
|
+
afterDbl.x + afterDbl.w <= vw + 1 && afterDbl.y + afterDbl.h <= vh + 1,
|
|
575
|
+
'x=' + Math.round(afterDbl.x) + ' y=' + Math.round(afterDbl.y) +
|
|
576
|
+
' 右=' + Math.round(afterDbl.x + afterDbl.w) + '/' + vw +
|
|
577
|
+
' 下=' + Math.round(afterDbl.y + afterDbl.h) + '/' + vh +
|
|
578
|
+
'(双击前 x=' + Math.round(beforeReset.x) + ' w=' + Math.round(beforeReset.w) + ')')
|
|
579
|
+
}
|
|
580
|
+
|
|
299
581
|
// ① 翻译节
|
|
300
582
|
var picked1 = await quoteOnce('翻译节划词', function () {
|
|
301
583
|
return { node: sectionContent('translation'), needle: '迁移跑得比预期久' }
|
|
@@ -381,8 +663,13 @@ async function main() {
|
|
|
381
663
|
hook.close()
|
|
382
664
|
hook.open('冒烟探针', '上下文片段 ABC', '冒烟')
|
|
383
665
|
await waitFor(function () { return hook.state().phase === 'done' }, 4000)
|
|
666
|
+
var widthBeforeAsk = Math.round(document.querySelector('.dsh-sel-panel').getBoundingClientRect().width)
|
|
384
667
|
hook.ask('冒烟追问')
|
|
385
668
|
await waitFor(function () { return hook.state().asking === false }, 4000)
|
|
669
|
+
var widthAfterAsk = Math.round(document.querySelector('.dsh-sel-panel').getBoundingClientRect().width)
|
|
670
|
+
check('追问前后宽度不变(不再从 440 跳到 540)',
|
|
671
|
+
widthBeforeAsk === widthAfterAsk && Math.abs(widthAfterAsk - WIDE_WIDTH) <= 2,
|
|
672
|
+
'追问前 ' + widthBeforeAsk + 'px → 追问后 ' + widthAfterAsk + 'px')
|
|
386
673
|
var bubbles = document.querySelectorAll('.dsh-sel-chatlog .dsh-sel-bubble-bot')
|
|
387
674
|
var bot = bubbles[bubbles.length - 1]
|
|
388
675
|
check('追问气泡渲染出来了', !!bot && bot.textContent.indexOf('冒烟用例') >= 0, bot ? bot.textContent.slice(0, 24) : '未找到')
|
|
@@ -447,6 +734,42 @@ async function main() {
|
|
|
447
734
|
}
|
|
448
735
|
}
|
|
449
736
|
|
|
737
|
+
// ⑨-b 选中的文字被删掉 → 浮标跟着消失(用户报的:文字没了,浮标还赖在屏幕上)
|
|
738
|
+
{
|
|
739
|
+
hook.close()
|
|
740
|
+
await sleep(80)
|
|
741
|
+
var planBtn2 = document.querySelector('.dsh-sel-btn:not(.dsh-sel-quotebtn)')
|
|
742
|
+
var docp3 = document.getElementById('docp')
|
|
743
|
+
selectIn(docp3, 'the migration ran long')
|
|
744
|
+
mouseUpOn(docp3)
|
|
745
|
+
var lit = await waitFor(function () {
|
|
746
|
+
return planBtn2 && planBtn2.style.display !== 'none' && planBtn2.getBoundingClientRect().width > 0
|
|
747
|
+
}, 1500)
|
|
748
|
+
check('(准备)主界面划词浮出「✦ 解读」', lit === true, planBtn2 ? planBtn2.style.display : '未找到按钮')
|
|
749
|
+
|
|
750
|
+
// ① 选区塌陷(Backspace / Delete / 打字覆盖 / 剪切走的都是这条):浏览器会发 selectionchange
|
|
751
|
+
window.getSelection().removeAllRanges()
|
|
752
|
+
var gone = await waitFor(function () { return planBtn2.style.display === 'none' }, 800)
|
|
753
|
+
check('选中的文字被删掉(选区塌陷)→ 浮标消失', gone === true, planBtn2 ? planBtn2.style.display : '未找到按钮')
|
|
754
|
+
|
|
755
|
+
// ② 选中的节点被整个换掉(流式回答重渲染就是这条):那段文字没了,浮标也不该留着。
|
|
756
|
+
// ⚠️ 先确认浮标**真的收掉了**再往下走:不然 ① 漏掉时浮标一直亮着,
|
|
757
|
+
// 下面的"再次亮着"会白捡一个 PASS(这条用例就测不出东西了)。
|
|
758
|
+
var settled = await waitFor(function () { return planBtn2.style.display === 'none' }, 800)
|
|
759
|
+
check('(准备 ②)① 之后浮标确实是收着的', settled === true, planBtn2 ? planBtn2.style.display : '未找到按钮')
|
|
760
|
+
selectIn(docp3, 'the migration ran long')
|
|
761
|
+
mouseUpOn(docp3)
|
|
762
|
+
var lit2 = await waitFor(function () {
|
|
763
|
+
return planBtn2 && planBtn2.style.display !== 'none' && planBtn2.getBoundingClientRect().width > 0
|
|
764
|
+
}, 1500)
|
|
765
|
+
check('(准备 ②)浮标再次亮着', lit2 === true, planBtn2 ? planBtn2.style.display : '未找到按钮')
|
|
766
|
+
docp3.textContent = '(这段文字已经被换掉了)'
|
|
767
|
+
var gone2 = await waitFor(function () { return planBtn2.style.display === 'none' }, 800)
|
|
768
|
+
check('选中的节点被摘掉(文字被换掉)→ 浮标也消失', gone2 === true, planBtn2 ? planBtn2.style.display : '未找到按钮')
|
|
769
|
+
docp3.textContent = 'Dev: the migration ran long, so we ship Wednesday.'
|
|
770
|
+
await sleep(30)
|
|
771
|
+
}
|
|
772
|
+
|
|
450
773
|
// ⑩ 浮标图标的**墨迹**要在画布里居中(用户报的:"✦ 和 解读 好像没对齐,也没在按钮里居中")
|
|
451
774
|
// 为什么量 getBBox 而不是截图:浮动按钮是 align-items:center 的 flex,居中的是**画布盒子**,
|
|
452
775
|
// 图形在画布里偏一点,图标就整体偏一点 —— 老星形路径 y 占 1.2~12.4(中心 6.8,不是 8),
|
package/scripts/test-client.mjs
CHANGED
|
@@ -1091,6 +1091,62 @@ assert('选中文字后浮标出现', !!button && button.style.display === 'inli
|
|
|
1091
1091
|
|
|
1092
1092
|
}
|
|
1093
1093
|
|
|
1094
|
+
// ───────────────────────── 选中的文字被删掉 → 浮标必须消失 ─────────────────────────
|
|
1095
|
+
// 用户报的:划词浮标出来之后,把那段文字删了(Backspace / 打字覆盖 / 内容刷新把节点换掉),
|
|
1096
|
+
// 浮标还赖在屏幕上不走,点下去解读的是一段**屏幕上已经不存在**的文字。
|
|
1097
|
+
{
|
|
1098
|
+
const rect = { left: 40, top: 100, right: 200, bottom: 118, width: 160, height: 18 }
|
|
1099
|
+
/** 造一段"落在 node 上"的选区(锚点就是 node —— isConnected 那条判定要用它)。 */
|
|
1100
|
+
const selectOn = (node, text) => {
|
|
1101
|
+
const range = {
|
|
1102
|
+
startContainer: node,
|
|
1103
|
+
endContainer: node,
|
|
1104
|
+
cloneRange: () => range,
|
|
1105
|
+
getClientRects: () => [rect],
|
|
1106
|
+
getBoundingClientRect: () => rect,
|
|
1107
|
+
}
|
|
1108
|
+
windowStub.getSelection = () => ({
|
|
1109
|
+
isCollapsed: false,
|
|
1110
|
+
rangeCount: 1,
|
|
1111
|
+
toString: () => text,
|
|
1112
|
+
getRangeAt: () => range,
|
|
1113
|
+
})
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// ① 拖拽划词进行中(鼠标按着):不弹浮标 —— 时机要和"松手才弹"一致
|
|
1117
|
+
selectOn(textEl, 'the migration ran long')
|
|
1118
|
+
documentStub.dispatch('mousedown', { target: container })
|
|
1119
|
+
documentStub.dispatch('selectionchange', {})
|
|
1120
|
+
await sleep(20)
|
|
1121
|
+
assert('拖拽中(鼠标按着)不弹浮标', button.style.display === 'none', String(button.style.display))
|
|
1122
|
+
documentStub.dispatch('mouseup', { target: container })
|
|
1123
|
+
await sleep(20)
|
|
1124
|
+
assert('松手后照常弹浮标', button.style.display === 'inline-flex', String(button.style.display))
|
|
1125
|
+
|
|
1126
|
+
// ② 选区塌陷(Backspace / Delete / 打字覆盖 / 剪切走的都是这条):浏览器发 selectionchange
|
|
1127
|
+
windowStub.getSelection = () => ({ isCollapsed: true, rangeCount: 0, toString: () => '' })
|
|
1128
|
+
documentStub.dispatch('selectionchange', {})
|
|
1129
|
+
await sleep(20)
|
|
1130
|
+
assert('选中的文字被删掉(选区塌陷)→ 浮标消失', button.style.display === 'none', String(button.style.display))
|
|
1131
|
+
|
|
1132
|
+
// ③ 选区**没塌陷**、锚点节点却已经被摘掉(Firefox 的行为):靠 isConnected 判死
|
|
1133
|
+
selectOn(textEl, 'the migration ran long')
|
|
1134
|
+
documentStub.dispatch('mouseup', { target: container })
|
|
1135
|
+
await sleep(20)
|
|
1136
|
+
assert('(准备)浮标重新亮着', button.style.display === 'inline-flex', String(button.style.display))
|
|
1137
|
+
textEl.isConnected = false
|
|
1138
|
+
documentStub.dispatch('selectionchange', {})
|
|
1139
|
+
await sleep(20)
|
|
1140
|
+
assert('选中的节点被摘掉(选区没塌陷)→ 浮标也消失', button.style.display === 'none', String(button.style.display))
|
|
1141
|
+
delete textEl.isConnected
|
|
1142
|
+
|
|
1143
|
+
// 还原成开头那段选区:后面的用例还要点这个浮标开小窗
|
|
1144
|
+
windowStub.getSelection = () => selection
|
|
1145
|
+
documentStub.dispatch('mouseup', { target: body })
|
|
1146
|
+
await sleep(20)
|
|
1147
|
+
assert('(还原)浮标回到可见', button.style.display === 'inline-flex', String(button.style.display))
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1094
1150
|
// 引用(❝)的**离线**契约:浮标进了浮层、CSS 规则齐全(真实交互在宿主可达时另有一段)
|
|
1095
1151
|
{
|
|
1096
1152
|
const css = readFileSync(BUNDLE, 'utf8')
|
|
@@ -1104,6 +1160,21 @@ assert('选中文字后浮标出现', !!button && button.style.display === 'inli
|
|
|
1104
1160
|
|
|
1105
1161
|
|
|
1106
1162
|
// ───────────────────────── 点击 → 弹窗 + 真实 host 流 ─────────────────────────
|
|
1163
|
+
// ⚠️ 开场先清一次设置覆盖。
|
|
1164
|
+
// 这套用例后面会把 provider/model 写成夹具里的假 provider(p1),而它落在**真实 host** 上;
|
|
1165
|
+
// 万一上一次跑崩在中途(没走到收尾的 reset),残留的 p1 会让接下来的真实解读全部 503 ——
|
|
1166
|
+
// 表现为"流式渲染失败"一大片,看起来像插件坏了,其实是脏状态。
|
|
1167
|
+
// 所以清理要放在**最前面**(收尾那次仍然保留,正常路径下不留痕迹)。
|
|
1168
|
+
try {
|
|
1169
|
+
await fetch(ORIGIN + '/selection-explain/api/settings', {
|
|
1170
|
+
method: 'POST',
|
|
1171
|
+
headers: { 'content-type': 'application/json' },
|
|
1172
|
+
body: JSON.stringify({ reset: true }),
|
|
1173
|
+
})
|
|
1174
|
+
} catch (error) {
|
|
1175
|
+
/* host 不可达时后面本来就会跳过在线断言 */
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1107
1178
|
button.dispatch('click', { preventDefault() {}, stopPropagation() {} })
|
|
1108
1179
|
const panel = mount.children[0].children.find((c) => c.className === 'dsh-sel-panel')
|
|
1109
1180
|
// 面板内的固定引用点(后续断言共用)
|
|
@@ -1240,7 +1311,7 @@ assert(
|
|
|
1240
1311
|
|
|
1241
1312
|
// 只有翻译时的轻量形态:窄面板 / 无序号 / CTA 在内容流里(但**可以越过详解直接追问**)
|
|
1242
1313
|
const translateCard = sections.find((node) => node.getAttribute('data-sec') === 'translation')
|
|
1243
|
-
assert('首轮面板标为 translation
|
|
1314
|
+
assert('首轮面板标为 translation 阶段(决定追问框显示,不决定宽度)', panel.getAttribute('data-stage') === 'translation', String(panel.getAttribute('data-stage')))
|
|
1244
1315
|
assert(
|
|
1245
1316
|
'两节标题都不带序号(①② 已去掉)',
|
|
1246
1317
|
!Array.from(walk(panel)).some((n) => n.tagName === 'B' && /^[①②12]$/.test(textOf(n))),
|
|
@@ -1264,7 +1335,7 @@ assert(
|
|
|
1264
1335
|
assert('前情只带翻译:不留空的「详解:」', seed.indexOf('前情') >= 0 && seed.indexOf('详解:') < 0, seed.slice(0, 70))
|
|
1265
1336
|
for (let i = 0; i < 80 && textOf(chatLog).indexOf('这是对追问的回答') < 0; i += 1) await sleep(20)
|
|
1266
1337
|
assert('回答渲染进小窗消息区', textOf(chatLog).indexOf('这是对追问的回答') >= 0, textOf(chatLog).slice(0, 60))
|
|
1267
|
-
assert('
|
|
1338
|
+
assert('一旦有对话,面板切到 detail 阶段', panel.getAttribute('data-stage') === 'detail', String(panel.getAttribute('data-stage')))
|
|
1268
1339
|
assert('发过追问后「展开详解」自动收起', expandBtn.style.display === 'none', String(expandBtn.style.display))
|
|
1269
1340
|
|
|
1270
1341
|
// 换一段新的选中文字:状态重置,CTA 该回来(后面的"点展开"流程从这里接着走)。
|
|
@@ -1295,7 +1366,7 @@ for (let i = 0; i < 100 && !(hook.parts().detail || '').trim(); i += 1) await sl
|
|
|
1295
1366
|
for (let i = 0; i < 100 && hook.state().phase !== 'done'; i += 1) await sleep(50)
|
|
1296
1367
|
assert('第二阶段产出详解', (hook.parts().detail || '').trim().length > 0, (hook.parts().detail || '').trim().slice(0, 60).replace(/\n/g, ' '))
|
|
1297
1368
|
assert('展开后按钮隐藏、详解卡片出现', expandBtn.style.display === 'none' && detailCard.style.display !== 'none', `btn=${expandBtn.style.display} card=${detailCard.style.display}`)
|
|
1298
|
-
assert('展开后面板切到 detail
|
|
1369
|
+
assert('展开后面板切到 detail 阶段', panel.getAttribute('data-stage') === 'detail', String(panel.getAttribute('data-stage')))
|
|
1299
1370
|
assert(
|
|
1300
1371
|
'展开后标题仍不带序号',
|
|
1301
1372
|
!Array.from(walk(panel)).some((n) => n.tagName === 'B' && /^[①②12]$/.test(textOf(n))),
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/** Run the actual document Esc handler with isolated focus and panel state. No host/API access. */
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
import vm from 'node:vm'
|
|
5
|
+
const source = readFileSync(new URL('../src/client/index.js', import.meta.url), 'utf8')
|
|
6
|
+
const start = source.indexOf(' var offKeyDown = listen(document,')
|
|
7
|
+
const end = source.indexOf('\n\n var offSettingsOpen', start)
|
|
8
|
+
assert.ok(start >= 0 && end > start)
|
|
9
|
+
const ask = {}, historyInput = {}, outside = {}, expandedChat = { closest: selector => selector.startsWith('[data-dsh-floating-chat]') ? {} : null }, compactChat = { closest: () => null }, nativeMenu = { closest: selector => selector.startsWith('[role=') ? {} : null }
|
|
10
|
+
let handler, fullView = true, calls = [], activeWindow = null, chatExpanded = true
|
|
11
|
+
const context = vm.createContext({
|
|
12
|
+
panelOpen: true, settingsOpen: false,
|
|
13
|
+
panel: { contains: node => node === ask },
|
|
14
|
+
historyList: { contains: node => node === historyInput },
|
|
15
|
+
document: { querySelector: selector => selector === '[data-dsh-full-view]' && fullView
|
|
16
|
+
? { getAttribute: () => activeWindow }
|
|
17
|
+
: selector.startsWith('[data-dsh-floating-chat]') && chatExpanded ? {} : null },
|
|
18
|
+
listen(target, type, callback, capture) { assert.equal(type, 'keydown'); assert.equal(capture, true); handler = callback },
|
|
19
|
+
captureRow: { getAttribute: () => '0' }, settingsByKey: {},
|
|
20
|
+
closeModelPicks() { calls.push('menu') },
|
|
21
|
+
closeSettings() { calls.push('settings'); context.settingsOpen = false },
|
|
22
|
+
cancelVoice() { calls.push('voice') },
|
|
23
|
+
closePanel() { calls.push('panel'); context.panelOpen = false },
|
|
24
|
+
})
|
|
25
|
+
new vm.Script(source.slice(start, end)).runInContext(context)
|
|
26
|
+
function escape(target, extra = {}) {
|
|
27
|
+
const event = { key: 'Escape', target, ...extra, preventDefault() { calls.push('prevent') }, stopPropagation() { calls.push('stop') } }
|
|
28
|
+
handler(event)
|
|
29
|
+
}
|
|
30
|
+
function reset() { calls = []; activeWindow = null; chatExpanded = true; context.panelOpen = true; context.settingsOpen = false; context.settingsByKey = {}; context.captureRow.getAttribute = () => '0' }
|
|
31
|
+
reset(); activeWindow = 'chat'; escape(ask)
|
|
32
|
+
assert.deepEqual(calls, [], '最后点击聊天后,即使键盘仍在解读输入框,也让聊天先退出')
|
|
33
|
+
reset(); activeWindow = 'explanation'; escape(expandedChat)
|
|
34
|
+
assert.equal(context.panelOpen, false, '最后点击解读消息后,即使键盘仍在聊天,也先退解读')
|
|
35
|
+
reset(); activeWindow = 'none'; escape(expandedChat)
|
|
36
|
+
assert.equal(context.panelOpen, false, '点击两窗外后不使用旧键盘焦点,优先退解读')
|
|
37
|
+
reset(); activeWindow = 'chat'; chatExpanded = false; escape(compactChat)
|
|
38
|
+
assert.equal(context.panelOpen, false, '已收起的聊天不占下一次退出顺序')
|
|
39
|
+
reset()
|
|
40
|
+
escape(outside)
|
|
41
|
+
assert.equal(context.panelOpen, false, '焦点在两窗之外时优先关闭解读')
|
|
42
|
+
reset(); escape(expandedChat)
|
|
43
|
+
assert.deepEqual(calls, [], '展开聊天的 Esc 交给聊天,不关闭解读')
|
|
44
|
+
assert.equal(context.panelOpen, true)
|
|
45
|
+
reset(); escape(compactChat)
|
|
46
|
+
assert.equal(context.panelOpen, false, '聊天收起后焦点留在紧凑条,再按 Esc 仍能关闭解读')
|
|
47
|
+
reset(); escape(nativeMenu)
|
|
48
|
+
assert.deepEqual(calls, [], '原生菜单先处理自身 Esc,不连带关闭解读')
|
|
49
|
+
for (const extra of [{ isComposing: true }, { keyCode: 229 }, { defaultPrevented: true }]) {
|
|
50
|
+
reset(); escape(ask, extra); assert.deepEqual(calls, [], '输入法或已处理的 Esc 不关闭解读')
|
|
51
|
+
}
|
|
52
|
+
reset(); context.settingsOpen = true; context.settingsByKey = { model: { menu: { getAttribute: () => '1' } } }; escape(ask)
|
|
53
|
+
assert.deepEqual(calls, ['prevent', 'stop', 'menu'])
|
|
54
|
+
assert.equal(context.panelOpen, true)
|
|
55
|
+
reset(); context.settingsOpen = true; escape(ask)
|
|
56
|
+
assert.deepEqual(calls, ['prevent', 'stop', 'settings'])
|
|
57
|
+
assert.equal(context.panelOpen, true)
|
|
58
|
+
reset(); context.captureRow.getAttribute = () => '1'; escape(ask)
|
|
59
|
+
assert.deepEqual(calls, ['prevent', 'stop', 'voice'])
|
|
60
|
+
assert.equal(context.panelOpen, true)
|
|
61
|
+
for (const target of [ask, historyInput]) {
|
|
62
|
+
reset(); escape(target); assert.deepEqual(calls, ['prevent', 'stop', 'panel']); assert.equal(context.panelOpen, false)
|
|
63
|
+
}
|
|
64
|
+
reset(); fullView = false; escape(outside)
|
|
65
|
+
assert.equal(context.panelOpen, false, '普通分栏保留解读原来的全局 Esc 行为')
|
|
66
|
+
console.log('PASS focused Esc: independent windows, IME, nested settings, voice, history and split-mode behavior')
|