@yfwu2020/dsh-selection-explain 0.7.1 → 0.9.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.
@@ -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' } }))
@@ -119,7 +131,18 @@ window.fetch = function (input, init) {
119
131
  return Promise.resolve(new Response(JSON.stringify({ ok: false, error: 'position 需要 { right, y }' }), { status: 400, headers: { 'content-type': 'application/json' } }))
120
132
  }
121
133
  if (url.indexOf('/selection-explain/api/models') >= 0) {
122
- return Promise.resolve(new Response(JSON.stringify({ ok: true, current: null, stages: null, models: [] }), { headers: { 'content-type': 'application/json' } }))
134
+ // 三个模型:一个长名字、一个同名 id 挂在另一个 provider 下 —— 给"菜单里给当前在用的
135
+ // 那个打勾"那条用例用。current = 会话在用的那个(跟随态菜单要照它打勾)。
136
+ return Promise.resolve(new Response(JSON.stringify({
137
+ ok: true,
138
+ current: { provider: 'p1', model: 'm-fast' },
139
+ stages: { chat: 'high' },
140
+ models: [
141
+ { provider: 'p1', providerName: 'Provider One', model: 'm-fast', name: 'DeepSeek V4.1 Flash', efforts: [{ id: 'off', name: 'Off' }, { id: 'low', name: 'Low' }, { id: 'high', name: 'High' }, { id: 'max', name: 'Max' }], defaultEffort: 'high' },
142
+ { provider: 'p1', providerName: 'Provider One', model: 'm-strong', name: 'Strong Model With A Very Long Display Name', efforts: [{ id: 'off', name: 'Off' }, { id: 'high', name: 'High' }], defaultEffort: 'high' },
143
+ { provider: 'p2', providerName: 'DeepSeek Account', model: 'm-fast', name: 'Another Provider Same Id', efforts: [], defaultEffort: null },
144
+ ],
145
+ }), { headers: { 'content-type': 'application/json' } }))
123
146
  }
124
147
  if (url.indexOf('/selection-explain/api/analyze') >= 0) {
125
148
  if (body.question) return Promise.resolve(sse([{ type: 'start', provider: 'fixture', model: 'fixture', mode: 'chat' }, { type: 'delta', text: fixture.chat }, { type: 'done', chars: 8 }]))
@@ -285,6 +308,276 @@ async function main() {
285
308
  await waitFor(function () { return hook.state().phase === 'done' }, 4000)
286
309
  check('首轮渲染完成', hook.state().phase === 'done', hook.state().phase)
287
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
+
288
581
  // ① 翻译节
289
582
  var picked1 = await quoteOnce('翻译节划词', function () {
290
583
  return { node: sectionContent('translation'), needle: '迁移跑得比预期久' }
@@ -370,8 +663,13 @@ async function main() {
370
663
  hook.close()
371
664
  hook.open('冒烟探针', '上下文片段 ABC', '冒烟')
372
665
  await waitFor(function () { return hook.state().phase === 'done' }, 4000)
666
+ var widthBeforeAsk = Math.round(document.querySelector('.dsh-sel-panel').getBoundingClientRect().width)
373
667
  hook.ask('冒烟追问')
374
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')
375
673
  var bubbles = document.querySelectorAll('.dsh-sel-chatlog .dsh-sel-bubble-bot')
376
674
  var bot = bubbles[bubbles.length - 1]
377
675
  check('追问气泡渲染出来了', !!bot && bot.textContent.indexOf('冒烟用例') >= 0, bot ? bot.textContent.slice(0, 24) : '未找到')
@@ -1073,6 +1371,40 @@ async function main() {
1073
1371
  await sleep(400)
1074
1372
  }
1075
1373
 
1374
+ // ⑮ 模型菜单:跟随态也要给"当前在用"那个打勾(真浏览器里量几何 —— 桩里量不出换行/溢出)
1375
+ {
1376
+ hook.open('模型菜单勾选', '', '菜单冒烟')
1377
+ await waitFor(function () { return hook.state().phase === 'done' }, 8000)
1378
+ await hook.loadModels(true)
1379
+ hook.openModel()
1380
+ await sleep(120)
1381
+ var menu = hook.modelNodes().menu
1382
+ var rows = Array.prototype.slice.call(menu.querySelectorAll('.dsh-sel-pickerrow'))
1383
+ check('模型菜单列出 3 个模型(含同名 id 的另一 provider)', rows.length === 3, String(rows.length))
1384
+ var onRows = rows.filter(function (r) { return r.getAttribute('data-on') === '1' })
1385
+ check('跟随态:只有当前在用的那一行打勾',
1386
+ onRows.length === 1 && onRows[0].textContent.indexOf('DeepSeek V4.1 Flash') >= 0,
1387
+ onRows.map(function (r) { return r.textContent }).join(' | ') || '没有任何一行打勾')
1388
+ var badge = onRows[0] && onRows[0].querySelector('.dsh-sel-pickerfollow')
1389
+ check('跟随态那一行有「跟随」小标,且小标在菜单里可见(没被挤出边界)',
1390
+ !!badge && badge.offsetWidth > 0 && badge.getBoundingClientRect().right <= menu.getBoundingClientRect().right + 1,
1391
+ badge ? Math.round(badge.getBoundingClientRect().left) + '→' + Math.round(badge.getBoundingClientRect().right) + '(菜单右边界 ' + Math.round(menu.getBoundingClientRect().right) + ')' : '没有小标')
1392
+ var nameSpan = onRows[0] && onRows[0].querySelector('.dsh-sel-pickername')
1393
+ check('名字带省略号、不撑破一行(真实布局下 scrollWidth 不超)',
1394
+ !!nameSpan && nameSpan.scrollWidth >= nameSpan.clientWidth && onRows[0].scrollWidth <= onRows[0].clientWidth + 1,
1395
+ nameSpan ? nameSpan.clientWidth + ' 可见 / ' + nameSpan.scrollWidth + ' 全长;行 ' + onRows[0].clientWidth + '/' + onRows[0].scrollWidth : '没有名字节点')
1396
+ // 长名字那行(没打勾、没小标)也不该被撑破
1397
+ var longRow = rows.filter(function (r) { return r.textContent.indexOf('Strong Model With A Very Long') >= 0 })[0]
1398
+ check('超长名字那一行同样不横向溢出(靠省略号收窄)',
1399
+ !!longRow && longRow.scrollWidth <= longRow.clientWidth + 1,
1400
+ longRow ? longRow.clientWidth + '/' + longRow.scrollWidth : '没找到那一行')
1401
+ var rowBox = onRows[0] ? onRows[0].getBoundingClientRect() : null
1402
+ check('打勾那一行仍是单行(高度不超过 30px,没折成两行)',
1403
+ !!rowBox && rowBox.height <= 30, rowBox ? Math.round(rowBox.height) + 'px' : '没找到那一行')
1404
+ hook.close() // 关窗会顺手收起模型菜单
1405
+ await sleep(30)
1406
+ }
1407
+
1076
1408
  finish()
1077
1409
  }
1078
1410
 
@@ -644,7 +644,9 @@ assert(
644
644
  )
645
645
  assert(
646
646
  '输入框里两句都在:第一句定稿 + 第二句半句预览(用户报的 bug 就是这里断的)',
647
- /你好|语音|测试/.test(afterPause.value) && /帮我|解释|划词|画词/.test(afterPause.value),
647
+ realSpeech
648
+ ? /你好|语音|测试/.test(afterPause.value) && /帮我|解释|划词|画词/.test(afterPause.value)
649
+ : afterPause.value === '夹具转写的一段话 夹具转写的一段话',
648
650
  JSON.stringify(afterPause.value),
649
651
  )
650
652
 
@@ -274,6 +274,10 @@ function makeFrameEnv(htmlText, selected, rect, containerTag, muteDocListeners)
274
274
 
275
275
  const FIXTURE = 'PM: 这周能发布吗? Dev: the migration ran long, so we ship Wednesday.'
276
276
  const RECT = { left: 12, top: 34, right: 190, bottom: 52, width: 178, height: 18 }
277
+ const longSelection = '长'.repeat(4500)
278
+ const longFrame = makeFrameEnv(longSelection, longSelection, RECT)
279
+ assert('帧内不以旧的 4000 字默认限制提前拦截(实际限额由父页面设置判断)',
280
+ longFrame.sent.some(m => m.kind === 'selection' && m.sel.text.length === 4500))
277
281
  const env = makeFrameEnv(FIXTURE, 'the migration ran long', RECT)
278
282
  const first = env.sent[env.sent.length - 1]
279
283
  assert('帧内脚本:注入后立刻上报一次', !!first && first.__dshSel === 1 && first.kind === 'selection')