@yfwu2020/dsh-selection-explain 0.9.1 → 0.10.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.
@@ -29,7 +29,25 @@ class FakeEl {
29
29
  // 真实 DOM 里元素是 1、文本是 3:插件里有 target.nodeType 之类的判断,桩要跟上
30
30
  this.nodeType = String(tag) === '#text' ? 3 : 1
31
31
  this.children = []
32
+ // style 用**带 display setter** 的对象:真实浏览器里元素被 display:none 之后会失焦
33
+ // (activeElement 回到 body)。桩原来是个裸对象,不模拟这件事 —— 于是"卡片收起后
34
+ // 焦点还挂在它隐藏的 textarea 上",让后面的"别处正在打字就不抢焦点"守卫误判
35
+ // (实测:自动聚焦规则一直不生效,查了半天才发现是桩不忠实)。
36
+ var displayValue = ''
32
37
  this.style = {}
38
+ Object.defineProperty(this.style, 'display', {
39
+ get: function () {
40
+ return displayValue
41
+ },
42
+ set: function (value) {
43
+ displayValue = value
44
+ if (value === 'none' && documentStub && documentStub.activeElement === this) {
45
+ documentStub.activeElement = body
46
+ }
47
+ },
48
+ enumerable: true,
49
+ configurable: true,
50
+ })
33
51
  this.dataset = {}
34
52
  this.attrs = {}
35
53
  this._text = ''
@@ -101,9 +119,25 @@ class FakeEl {
101
119
  for (const handler of this._listeners.get(type) || []) handler(event)
102
120
  }
103
121
  getBoundingClientRect() {
122
+ // 临时卡片:给一个**和选区自洽**的矩形(落在选区下方)。
123
+ // 用通用矩形(top:100)的话卡片会跑到选区上方,牵引线走"卡片在上方"那条分支,
124
+ // 断言就量不到真实场景(卡片在下方)——这个坑我踩过一次。
125
+ if (this.className === 'dsh-sel-vcard') {
126
+ return { left: 940, top: 222, right: 1300, bottom: 264, width: 360, height: 42 }
127
+ }
104
128
  return { left: 40, top: 100, right: 500, bottom: 400, width: 460, height: 300 }
105
129
  }
130
+ /**
131
+ * 真实 DOM:display:none 的元素没有盒子(空列表)——**祖先隐藏也算**。
132
+ * 插件判断"这个输入框是不是还在页面上"靠它(临时语音卡收起时隐藏的是卡片,不是里面的输入框)。
133
+ * ⚠️ 合并进原来那个固定矩形版本:类方法同名会**后者覆盖前者**,留两个的话新写的会被悄悄盖掉(踩过)。
134
+ */
106
135
  getClientRects() {
136
+ var node = this
137
+ while (node) {
138
+ if (node.style && node.style.display === 'none') return []
139
+ node = node.parentNode
140
+ }
107
141
  return [{ left: 40, top: 100, right: 200, bottom: 118, width: 160, height: 18 }]
108
142
  }
109
143
  get firstChild() {
@@ -239,11 +273,15 @@ const navigatorStub = {}
239
273
  // ───────────────────────── 载入 bundle ─────────────────────────
240
274
  const source = readFileSync(BUNDLE, 'utf8')
241
275
  globalThis.ResizeObserver = ResizeObserverStub
276
+ // 临时实验:关掉收尾复核
277
+ if (typeof windowStub !== 'undefined') windowStub.__DSH_DISABLE_FOCUS_VERIFY = false
242
278
  const ORIGIN = process.env.SEL_ORIGIN || 'http://127.0.0.1:3080'
243
279
  const sent = []
244
280
  const promoted = []
245
281
  /** A′:小窗历史的内存版存储(测试用)。 */
246
282
  const historyStore = new Map()
283
+ /** 面板尺寸存档(桩):拖拽/还原用例依赖它造出"上次记住的高度"。 */
284
+ let sizeStore = null
247
285
  const historySaved = []
248
286
  let historyList = []
249
287
  const openedSessions = []
@@ -386,8 +424,76 @@ function withAbort(response, signal) {
386
424
  return new Response(stream, { headers: response.headers })
387
425
  }
388
426
 
427
+ /**
428
+ * 麦克风占用桩(语音卡用例用)。
429
+ * 默认 available:false —— 和其它用例一样"这台机器没有这个能力",谁都不受影响;
430
+ * 语音卡那条用例把它改成"豆包输入法正在录",走完整条链路。
431
+ */
432
+ const micStub = {
433
+ value: { ok: true, available: false, capturing: false, processes: [], reason: 'stub-default' },
434
+ /** 被问了几次("只在引用浮标亮着时轮询"这条要数它,见 ⑨)。 */
435
+ hits: 0,
436
+ }
437
+
438
+ /**
439
+ * 设置桩:离线跑的时候给客户端一份"读得到"的设置。
440
+ * 默认空对象 = 所有设置都用各自的出厂默认(不写值的键,客户端按默认处理;
441
+ * 语音那两个收尾开关的出厂默认是**开**,所以下面「卡片留着」那段要先显式设成 false)。
442
+ * 用例里改 settingsStub.value 之后要调 hook.reloadSettings() ——
443
+ * 客户端只在启动时读一次设置,不重读的话改桩值不会生效。
444
+ */
445
+ const settingsStub = { value: {} }
446
+
389
447
  const routeFetch = (input, init) => {
390
448
  const url = typeof input === 'string' ? input : ''
449
+ // 麦克风占用:读的是 host 上的 CoreAudio 探针,测试里由 micStub 决定
450
+ // ⚠️ 联机模式(SEL_ORIGIN 指向真宿主)下**不能**用空桩顶掉设置请求:
451
+ // 真实 spec 必须由宿主下发,否则设置页一个分类都画不出来(踩过)。
452
+ // 但用例又需要"临时把某个设置打开" —— 所以联机模式改成**代理**:
453
+ // 先读真宿主的设置拿到真实 spec,只把 values 里被用例覆盖的键换掉。
454
+ if (
455
+ process.env.SEL_ORIGIN &&
456
+ Object.keys(settingsStub.value).length &&
457
+ url.indexOf('/selection-explain/api/settings') >= 0 &&
458
+ !(init && init.method === 'POST')
459
+ ) {
460
+ return fetch(ORIGIN + '/selection-explain/api/settings', { headers: { accept: 'application/json' } })
461
+ .then((response) => response.json())
462
+ .then((payload) =>
463
+ new Response(
464
+ JSON.stringify(
465
+ Object.assign({}, payload, {
466
+ values: Object.assign({}, payload.values, settingsStub.value),
467
+ }),
468
+ ),
469
+ { headers: { 'content-type': 'application/json' } },
470
+ ),
471
+ )
472
+ }
473
+ if (!process.env.SEL_ORIGIN && url.indexOf('/selection-explain/api/settings') >= 0) {
474
+ if (init && init.method === 'POST') {
475
+ try {
476
+ // ⚠️ 这个 API 的 POST body 是 { values: {...} } 包一层的(宿主侧见 handleSettings),
477
+ // 不是扁平对象 —— 按扁平解析会一个键都读不到。
478
+ var posted = JSON.parse(String(init.body || '{}'))
479
+ Object.assign(settingsStub.value, (posted && posted.values) || {})
480
+ } catch (error) {
481
+ /* 桩不校验,校验是宿主的事(见 test-settings-host.mjs) */
482
+ }
483
+ }
484
+ return Promise.resolve(
485
+ new Response(
486
+ JSON.stringify({ ok: true, groups: [], values: Object.assign({}, settingsStub.value), defaults: {} }),
487
+ { headers: { 'content-type': 'application/json' } },
488
+ ),
489
+ )
490
+ }
491
+ if (url.endsWith('/selection-explain/api/mic')) {
492
+ micStub.hits += 1
493
+ return Promise.resolve(
494
+ new Response(JSON.stringify(micStub.value), { headers: { 'content-type': 'application/json' } }),
495
+ )
496
+ }
391
497
  // blob:(划词桥的交互式预览用例):从内存表里读回我们自己建的那份文档。
392
498
  // content-type 跟着 blob 的 type 走(真实浏览器就是这么给的)——插件要靠它搬 charset。
393
499
  if (url.startsWith('blob:')) {
@@ -415,6 +521,16 @@ const routeFetch = (input, init) => {
415
521
  }
416
522
  return Promise.resolve(new Response(JSON.stringify(body), { headers: { 'content-type': 'application/json' } }))
417
523
  }
524
+ // 面板尺寸存档:内存版。以前没有桩 → 打到真实 host,既会改到用户的存档,
525
+ // 也没法造出"上次记住的高度"这个前提(而"拖拽上限被旧天花板卡住"正需要它)。
526
+ if (url.indexOf('/selection-explain/api/panel') >= 0) {
527
+ if (init && init.method === 'POST') {
528
+ const body = JSON.parse(String(init.body || '{}'))
529
+ sizeStore = body && body.clear === true ? null : body && body.size ? body.size : sizeStore
530
+ return Promise.resolve(new Response(JSON.stringify({ ok: true, size: sizeStore }), { headers: { 'content-type': 'application/json' } }))
531
+ }
532
+ return Promise.resolve(new Response(JSON.stringify({ ok: true, size: sizeStore }), { headers: { 'content-type': 'application/json' } }))
533
+ }
418
534
  // 小窗历史(A′):内存版实现,避免打到真实 host 拿到脏历史
419
535
  if (url.indexOf('/selection-explain/api/history') >= 0) {
420
536
  if (init && init.method === 'DELETE') {
@@ -528,7 +644,9 @@ const routeFetch = (input, init) => {
528
644
  const payload = [
529
645
  `data: ${JSON.stringify({ type: 'start', provider: 'fixture', model: 'fixture', stage: 'translation' })}\n\n`,
530
646
  `data: ${JSON.stringify({ type: 'delta', text: '## 翻译\n语音用例的翻译\n' })}\n\n`,
531
- `data: ${JSON.stringify({ type: 'done', chars: 8 })}\n\n`,
647
+ `data: ${JSON.stringify({ type: 'done', chars: 8 })}
648
+
649
+ `,
532
650
  ].join('')
533
651
  return Promise.resolve(new Response(payload, { headers: { 'content-type': 'text/event-stream' } }))
534
652
  }
@@ -561,6 +679,38 @@ const routeFetch = (input, init) => {
561
679
  })
562
680
  return Promise.resolve(new Response(stream, { headers: { 'content-type': 'text/event-stream' } }))
563
681
  }
682
+ // 首轮夹具:给"摘要继承用例"用(用未知文本会走真实模型,首轮一直不结束 → 追问被拒)
683
+ if (body && body.text === 'digest-probe' && !body.question) {
684
+ const payload = [
685
+ `data: ${JSON.stringify({ type: 'start', provider: 'fixture', model: 'fixture', stage: 'translation' })}\n\n`,
686
+ `data: ${JSON.stringify({ type: 'delta', text: '## 翻译\n摘要用例的翻译\n' })}\n\n`,
687
+ `data: ${JSON.stringify({ type: 'done', chars: 8 })}\n\n`,
688
+ ].join('')
689
+ return Promise.resolve(new Response(payload, { headers: { 'content-type': 'text/event-stream' } }))
690
+ }
691
+ // 结论轮提示:notice 先到、正文 350ms 后到 —— 用来验"提示在正文到达后消失"(附带的探针)
692
+ if (body && body.question && body.text === 'notice-probe') {
693
+ const encoder = new TextEncoder()
694
+ const stream = new ReadableStream({
695
+ async start(controller) {
696
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'start', provider: 'fixture', model: 'fixture', mode: 'chat', effort: 'high' })}\n\n`))
697
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'notice', code: 'conclusion-retry', text: '查询已完成,正在整理结论…' })}\n\n`))
698
+ await new Promise((r) => setTimeout(r, 350))
699
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'delta', text: '这是结论。' })}\n\n`))
700
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done', chars: 5 })}\n\n`))
701
+ controller.close()
702
+ },
703
+ })
704
+ return Promise.resolve(new Response(stream, { headers: { 'content-type': 'text/event-stream' } }))
705
+ }
706
+ if (body && body.text === 'notice-probe' && !body.question) {
707
+ const payload = [
708
+ `data: ${JSON.stringify({ type: 'start', provider: 'fixture', model: 'fixture', stage: 'translation' })}\n\n`,
709
+ `data: ${JSON.stringify({ type: 'delta', text: '## 翻译\n提示用例的翻译\n' })}\n\n`,
710
+ `data: ${JSON.stringify({ type: 'done' })}\n\n`,
711
+ ].join('')
712
+ return Promise.resolve(new Response(payload, { headers: { 'content-type': 'text/event-stream' } }))
713
+ }
564
714
  // 分三段、每段隔 60ms 的追问流:用来验证滚动跟随(贴底→跟随 / 翻上去→不拽回)
565
715
  // 追问等待特效用例:首字前静默 450ms,用来观察气泡里的等待动画与走秒
566
716
  if (body && body.question && body.text === 'wait-ask-probe') {
@@ -570,7 +720,9 @@ const routeFetch = (input, init) => {
570
720
  controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'start', provider: 'fixture', model: 'fixture', mode: 'chat', effort: 'low' })}\n\n`))
571
721
  await new Promise((r) => setTimeout(r, 450))
572
722
  controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'delta', text: '等到了回答。' })}\n\n`))
573
- controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done' })}\n\n`))
723
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done' })}
724
+
725
+ `))
574
726
  controller.close()
575
727
  },
576
728
  })
@@ -663,7 +815,40 @@ const routeFetch = (input, init) => {
663
815
  return Promise.resolve(new Response(payload, { headers: { 'content-type': 'text/event-stream' } }))
664
816
  }
665
817
  if (body && body.question) {
818
+ // 追问失败注入(`globalThis.__askFailOnce = true`):验"报错之后光标回不回到输入框"
819
+ if (globalThis.__askFailOnce) {
820
+ globalThis.__askFailOnce = false
821
+ return Promise.resolve(
822
+ new Response(JSON.stringify({ ok: false, error: 'no adapter registered for provider "p1"' }), {
823
+ status: 500,
824
+ headers: { 'content-type': 'application/json' },
825
+ }),
826
+ )
827
+ }
666
828
  const extra = globalThis.__askExtra || {}
829
+ // noticeGapMs:在 notice 与正文之间留一段空档。
830
+ // 验"过程提示在正文到达前看得见"必须有观察窗口 —— 一口气发完的话,
831
+ // 提示会在同一批 SSE 里被 delta 立刻撤掉,轮询根本抓不到。
832
+ if (extra.noticeGapMs) {
833
+ const encoder = new TextEncoder()
834
+ const stream = new ReadableStream({
835
+ async start(controller) {
836
+ controller.enqueue(
837
+ encoder.encode(`data: ${JSON.stringify({ type: 'start', provider: 'fixture', model: 'fixture', mode: 'chat', effort: 'low' })}\n\n`),
838
+ )
839
+ if (extra.notice) {
840
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'notice', ...extra.notice })}\n\n`))
841
+ }
842
+ await new Promise((r) => setTimeout(r, extra.noticeGapMs))
843
+ controller.enqueue(
844
+ encoder.encode(`data: ${JSON.stringify({ type: 'delta', text: extra.delta || '这是对追问的回答:' + body.question })}\n\n`),
845
+ )
846
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done', chars: 12, toolCalls: 0 })}\n\n`))
847
+ controller.close()
848
+ },
849
+ })
850
+ return Promise.resolve(new Response(stream, { headers: { 'content-type': 'text/event-stream' } }))
851
+ }
667
852
  const payload = [
668
853
  `data: ${JSON.stringify({ type: 'start', provider: 'fixture', model: 'fixture', mode: 'chat', effort: 'low' })}\n\n`,
669
854
  ...(extra.notice && !extra.strip ? [`data: ${JSON.stringify({ type: 'notice', ...extra.notice })}\n\n`] : []),
@@ -1289,8 +1474,17 @@ if (HOST_UP) {
1289
1474
 
1290
1475
  // ── 以下全部是真实 host 集成断言(SSE 流式渲染 / 面板交互 / 历史 / 清理)──
1291
1476
  // CI 里没有宿主,跳过;本地开发有宿主时全跑。
1477
+ //
1478
+ // ⚠️⚠️ 这一节会**改真实配置**:模型选择用例会写 provider/model,还会点「恢复默认」。
1479
+ // 对着"用户正在用的插件"跑,副作用会留下来 —— 实测事故:联机测试把 provider/model 写成了
1480
+ // 夹具的 p1/m-fast,用户此后每次请求都失败(no adapter registered for provider "p1")。
1481
+ // 所以这里先快照整份设置,本节结束时(见文件后面的 settingsSnapshot 还原)按快照写回。
1482
+ // 要跑"绝不留副作用"的设置用例,用 test-settings-host.mjs(隔离 DSH_HOME + 新加载宿主)。
1292
1483
  if (HOST_UP) {
1293
1484
 
1485
+ const settingsSnapshot = await (
1486
+ await fetch(ORIGIN + '/selection-explain/api/settings', { headers: { accept: 'application/json' } })
1487
+ ).json()
1294
1488
  const deadline = Date.now() + 90000
1295
1489
  while (Date.now() < deadline) {
1296
1490
  const state = hook.state()
@@ -2076,8 +2270,8 @@ assert(
2076
2270
  assert('跟随态那一行带「跟随」小标(这个勾来自主会话,不是自己选的)',
2077
2271
  !!on[0] && !!Array.from(walk(on[0])).find((n) => String(n.className) === 'dsh-sel-pickerfollow' && textOf(n) === '跟随'),
2078
2272
  on[0] ? Array.from(walk(on[0])).map((n) => String(n.className) + ':' + textOf(n)).join(', ') : '未找到')
2079
- assert('跟随态:档位按钮列的是**在用模型**声明的档位(m-fast 四档)',
2080
- tiersIn().map((b) => textOf(b)).join('/') === '关/低/高/最大', tiersIn().map((b) => textOf(b)).join('/'))
2273
+ assert('档位按钮 = 与设置页同一套四个格子(关/低/高/最高,与模型无关)',
2274
+ tiersIn().map((b) => textOf(b)).join('/') === '关/低/高/最高', tiersIn().map((b) => textOf(b)).join('/'))
2081
2275
 
2082
2276
  // 选一个具体模型:勾挪到那一行、「跟随」小标消失、档位收缩成它声明的那几档
2083
2277
  const strong = rowsIn().find((r) => textOf(r).indexOf('Strong Model') >= 0)
@@ -2089,8 +2283,8 @@ assert(
2089
2283
  on2.map((r) => textOf(r)).join(' | '))
2090
2284
  assert('选过之后:「跟随」小标消失(这个勾是你自己选的)',
2091
2285
  !Array.from(walk(menu)).some((n) => String(n.className) === 'dsh-sel-pickerfollow'), '')
2092
- assert('选过之后:档位收缩成该模型声明的 2 档',
2093
- tiersIn().map((b) => textOf(b)).join('/') === '关/高', tiersIn().map((b) => textOf(b)).join('/'))
2286
+ assert('选过之后:档位仍是那四个格子(换模型不改词汇,只改它落到哪个档)',
2287
+ tiersIn().map((b) => textOf(b)).join('/') === '关/低/高/最高', tiersIn().map((b) => textOf(b)).join('/'))
2094
2288
 
2095
2289
  // 还原:走抽屉里那颗「恢复默认」(客户端自己的路径,state 与 host 都回到跟随态)
2096
2290
  await new Promise((r) => setTimeout(r, 700))
@@ -2106,6 +2300,216 @@ assert(
2106
2300
  await new Promise((r) => setTimeout(r, 30))
2107
2301
  }
2108
2302
 
2303
+ // ⚠️ 这一段必须跑在**新加载**的宿主上(test-settings-host.mjs 用隔离目录新加载一份)。
2304
+ // 直接对着“正在跑的插件”跑会失败 —— 那是上一次启动时加载的旧代码,
2305
+ // 除非用户重载过插件。
2306
+ if (process.env.SEL_SETTINGS_ONLY === '1') {
2307
+ // 新增的两个语音收尾开关(界面 · 语音输入):必须在 spec 里、默认开、且只收布尔值。
2308
+ // 尽量只读:唯一的写操作是"写回默认值",写完回到默认,不留副作用。
2309
+ {
2310
+ const payload = await (await fetch(ORIGIN + '/selection-explain/api/settings', { headers: { accept: 'application/json' } })).json()
2311
+ const items = (payload.groups || []).reduce((acc, g) => acc.concat(g.items || []), [])
2312
+ const cancelItem = items.find((i) => i.key === 'voiceCancelOnSilence')
2313
+ const sendItem = items.find((i) => i.key === 'voiceAutoSend')
2314
+ assert(
2315
+ '语音收尾的两个开关都在设置 spec 里(kind=switch,挂在界面组)',
2316
+ !!cancelItem && !!sendItem && cancelItem.kind === 'switch' && sendItem.kind === 'switch',
2317
+ JSON.stringify({ cancel: !!cancelItem, send: !!sendItem, kinds: [cancelItem && cancelItem.kind, sendItem && sendItem.kind] }),
2318
+ )
2319
+ assert(
2320
+ '两个语音开关**默认都是开**(2026-10-09 用户指定)',
2321
+ payload.defaults.voiceCancelOnSilence === true && payload.defaults.voiceAutoSend === true,
2322
+ JSON.stringify({ cancel: payload.defaults.voiceCancelOnSilence, send: payload.defaults.voiceAutoSend }),
2323
+ )
2324
+ const bad = await (await fetch(ORIGIN + '/selection-explain/api/settings', {
2325
+ method: 'POST',
2326
+ headers: { 'content-type': 'application/json' },
2327
+ body: JSON.stringify({ values: { voiceAutoSend: 'yes' } }),
2328
+ })).json()
2329
+ assert(
2330
+ '语音开关只接受布尔值(字符串被拒,且不写进配置)',
2331
+ bad.ok === false && (bad.rejected || []).some((r) => r.key === 'voiceAutoSend'),
2332
+ JSON.stringify(bad).slice(0, 140),
2333
+ )
2334
+ await fetch(ORIGIN + '/selection-explain/api/settings', {
2335
+ method: 'POST',
2336
+ headers: { 'content-type': 'application/json' },
2337
+ body: JSON.stringify({ values: { voiceAutoSend: false, voiceCancelOnSilence: false } }),
2338
+ })
2339
+ const off = await (await fetch(ORIGIN + '/selection-explain/api/settings', { headers: { accept: 'application/json' } })).json()
2340
+ assert(
2341
+ '两个语音开关能写回并读回(关掉也记得住)',
2342
+ off.values.voiceAutoSend === false && off.values.voiceCancelOnSilence === false,
2343
+ JSON.stringify({ send: off.values.voiceAutoSend, cancel: off.values.voiceCancelOnSilence }),
2344
+ )
2345
+ // 写回默认(开),不留副作用
2346
+ await fetch(ORIGIN + '/selection-explain/api/settings', {
2347
+ method: 'POST',
2348
+ headers: { 'content-type': 'application/json' },
2349
+ body: JSON.stringify({ values: { voiceAutoSend: true, voiceCancelOnSilence: true } }),
2350
+ })
2351
+ }
2352
+ }
2353
+
2354
+ // ── 设置页「思考强度」:四个格子不变,下面那行**跟着当前模型**说清落到哪一档 ──
2355
+ // 映射由 host 按模型声明实时算(`/models` 每个模型带 `effortMap`);这里验客户端把它显示对了、
2356
+ // 换模型会跟着变、模型不支持推理时说清"这一组不生效"。
2357
+ {
2358
+ const fixture = globalThis.__modelCatalogFixture
2359
+ const weird = {
2360
+ provider: 'p2', providerName: 'Provider Two', model: 'm-weird', name: 'Weird Model',
2361
+ // 词汇表长得不一样:没有 off、顶档叫 xhigh(本机 grok-4.6 就是这种)
2362
+ efforts: [
2363
+ { id: 'low', name: 'Low' }, { id: 'medium', name: 'Medium' },
2364
+ { id: 'high', name: 'High' }, { id: 'xhigh', name: 'Xhigh' },
2365
+ ],
2366
+ defaultEffort: null, hasReasoning: true,
2367
+ effortMap: { off: 'low', low: 'medium', high: 'high', max: 'xhigh' },
2368
+ }
2369
+ const plain = {
2370
+ provider: 'p2', providerName: 'Provider Two', model: 'm-plain', name: 'Plain Model',
2371
+ efforts: [], defaultEffort: null, hasReasoning: false, effortMap: {},
2372
+ }
2373
+ globalThis.__modelCatalogFixture = { ...fixture, current: { provider: 'p2', model: 'm-weird' }, models: [...fixture.models, weird, plain] }
2374
+
2375
+ // 开抽屉(每次都强制重取清单)→ 那行提示应当报出这个模型的落点
2376
+ settingsBtn.dispatch('click', { stopPropagation() {}, preventDefault() {} })
2377
+ const hintNode = () => Array.from(walk(settingsSheet)).find((n) => n.getAttribute && n.getAttribute('data-effortmap') === '1')
2378
+ for (let i = 0; i < 40 && (!hintNode() || hintNode().textContent.indexOf('xhigh') < 0); i += 1) {
2379
+ await new Promise((r) => setTimeout(r, 50))
2380
+ }
2381
+ // 拿小写比:右边是**模型自己的档位名**(Off/Low/Xhigh…),大小写不保证
2382
+ const hintText = String((hintNode() || {}).textContent || '')
2383
+ const hintLower = hintText.toLowerCase()
2384
+ assert(
2385
+ '四格还在(关/低/高/最高 四个格子没变),下面多一行实时映射',
2386
+ !!hintNode() && String(hintNode().className).indexOf('dsh-sel-effortmap') >= 0,
2387
+ hintText,
2388
+ )
2389
+ assert(
2390
+ '映射说的是**当前模型**的真实档位(关→low · 低→medium · 高→high · 最高→xhigh)',
2391
+ hintLower.indexOf('关 → low') >= 0 && hintLower.indexOf('低 → medium') >= 0 &&
2392
+ hintLower.indexOf('高 → high') >= 0 && hintLower.indexOf('最高 → xhigh') >= 0,
2393
+ hintText,
2394
+ )
2395
+ assert('四格本身一个不多一个不少(没有被模型带跑)', (hintText.match(/→/g) || []).length === 4, hintText)
2396
+
2397
+ // **从抽屉自己的模型下拉里换模型**(用户报的那条路径:原来只有胶囊跟着变,这行不动)
2398
+ {
2399
+ const pick = Array.from(walk(settingsSheet)).find((n) => n.className === 'dsh-sel-spick')
2400
+ pick.dispatch('click', { stopPropagation() {}, preventDefault() {} })
2401
+ await new Promise((r) => setTimeout(r, 30))
2402
+ const plainRow = Array.from(walk(settingsSheet))
2403
+ .filter((n) => n.className === 'dsh-sel-spickrow')
2404
+ .find((r) => textOf(r).indexOf('Plain Model') >= 0)
2405
+ assert('(前提)下拉里能找到另一个模型', !!plainRow, textOf(pick))
2406
+ plainRow.dispatch('click', { stopPropagation() {}, preventDefault() {} })
2407
+ for (let i = 0; i < 60 && String((hintNode() || {}).textContent || '').indexOf('不支持思考强度') < 0; i += 1) {
2408
+ await new Promise((r) => setTimeout(r, 50))
2409
+ }
2410
+ assert(
2411
+ '从设置页的模型下拉换模型:那行映射立刻跟着换(不再只有胶囊变)',
2412
+ String((hintNode() || {}).textContent || '').indexOf('不支持思考强度') >= 0,
2413
+ String((hintNode() || {}).textContent || ''),
2414
+ )
2415
+ // 走客户端自己的「恢复默认」把模型写回跟随态(别把真实配置留在具体模型上)
2416
+ const resetBtn = Array.from(walk(settingsSheet)).find((n) => n.className === 'dsh-sel-sbtn' && textOf(n).indexOf('恢复默认') >= 0)
2417
+ resetBtn.dispatch('click', { stopPropagation() {}, preventDefault() {} })
2418
+ await new Promise((r) => setTimeout(r, 900))
2419
+ }
2420
+
2421
+ // 换成"明确不支持推理"的模型 → 说清这一组不生效(而不是假装能选)
2422
+ globalThis.__modelCatalogFixture = { ...fixture, current: { provider: 'p2', model: 'm-plain' }, models: [...fixture.models, weird, plain] }
2423
+ const backBtn2 = Array.from(walk(settingsSheet)).find((n) => n.className === 'dsh-sel-sheetback')
2424
+ backBtn2.dispatch('click', { stopPropagation() {}, preventDefault() {} })
2425
+ await new Promise((r) => setTimeout(r, 20))
2426
+ settingsBtn.dispatch('click', { stopPropagation() {}, preventDefault() {} })
2427
+ for (let i = 0; i < 40 && String((hintNode() || {}).textContent || '').indexOf('不支持思考强度') < 0; i += 1) {
2428
+ await new Promise((r) => setTimeout(r, 50))
2429
+ }
2430
+ assert(
2431
+ '模型不支持推理时:这行如实说"不生效(不会发送档位)"',
2432
+ String((hintNode() || {}).textContent || '').indexOf('不支持思考强度') >= 0,
2433
+ String((hintNode() || {}).textContent || ''),
2434
+ )
2435
+
2436
+ // 两边**同词同源**:小窗那个档位控件与设置页「思考强度 · 追问」都是同一套四个格子,
2437
+ // 读写同一个配置值(chatReasoningEffort),于是不需要任何反向映射
2438
+ {
2439
+ hook.openModel()
2440
+ await new Promise((r) => setTimeout(r, 30))
2441
+ const tierBtns = () => Array.from(walk(hook.modelNodes().menu)).filter((n) => String(n.className) === 'dsh-sel-tierbtn')
2442
+ assert(
2443
+ '小窗的档位按钮 = 设置页那四个格子(关/低/高/最高)',
2444
+ tierBtns().map((b) => textOf(b)).join('/') === '关/低/高/最高',
2445
+ tierBtns().map((b) => textOf(b)).join('/'),
2446
+ )
2447
+ assert('每个格子带自己的 slot 记号(不是模型档位 id)',
2448
+ tierBtns().map((b) => b.getAttribute('data-slot')).join('/') === 'off/low/high/max',
2449
+ tierBtns().map((b) => b.getAttribute('data-slot')).join('/'))
2450
+
2451
+ // 小窗里点「高」→ 设置页那一行同一格 + 内存里就是同一个值
2452
+ const highBtn = tierBtns().find((b) => textOf(b) === '高')
2453
+ highBtn.dispatch('click', { preventDefault() {}, stopPropagation() {} })
2454
+ await new Promise((r) => setTimeout(r, 30))
2455
+ assert('(前提)小窗记下了这一格', hook.modelState().effort === 'high', String(hook.modelState().effort))
2456
+ settingsBtn.dispatch('click', { stopPropagation() {}, preventDefault() {} })
2457
+ await new Promise((r) => setTimeout(r, 60))
2458
+ const segOf = () => Array.from(walk(settingsSheet)).find(
2459
+ (n) => String(n.className) === 'dsh-sel-seg' && n.attrs && n.attrs['aria-label'] === '追问',
2460
+ )
2461
+ assert(
2462
+ '小窗点「高」→ 设置页那一行也是「高」(同一份数据,不需要映射)',
2463
+ !!segOf() && segOf().getAttribute('data-value') === 'high',
2464
+ segOf() ? String(segOf().getAttribute('data-value')) : '(未找到)',
2465
+ )
2466
+
2467
+ // 反向:设置页点「低」→ 小窗那四个格子(菜单开着时)也跟着换
2468
+ const segCells = Array.from(walk(segOf())).filter((n) => String(n.className) === 'dsh-sel-segcell')
2469
+ const lowCell = segCells.find((c) => c.getAttribute('data-value') === 'low')
2470
+ lowCell.dispatch('click', { preventDefault() {}, stopPropagation() {} })
2471
+ await new Promise((r) => setTimeout(r, 30))
2472
+ assert('设置页点「低」→ 小窗也是同一格', hook.modelState().effort === 'low', String(hook.modelState().effort))
2473
+ assert(
2474
+ '小窗菜单里的选中态跟着换(不必重开菜单)',
2475
+ Array.from(walk(hook.modelNodes().menu))
2476
+ .filter((n) => String(n.className) === 'dsh-sel-tierbtn' && n.getAttribute('data-on') === '1')
2477
+ .map((b) => textOf(b))
2478
+ .join('/') === '低',
2479
+ Array.from(walk(hook.modelNodes().menu)).filter((n) => n.getAttribute && n.getAttribute('data-on') === '1').map((b) => textOf(b)).join('/'),
2480
+ )
2481
+
2482
+ // 词汇很怪的模型也一样只列四个格子 —— 这正是"不需要反向映射"的证明
2483
+ globalThis.__modelCatalogFixture = { ...fixture, current: { provider: 'p2', model: 'm-weird' }, models: [...fixture.models, weird, plain] }
2484
+ const backToPanel = Array.from(walk(settingsSheet)).find((n) => n.className === 'dsh-sel-sheetback')
2485
+ backToPanel.dispatch('click', { stopPropagation() {}, preventDefault() {} })
2486
+ await new Promise((r) => setTimeout(r, 20))
2487
+ settingsBtn.dispatch('click', { stopPropagation() {}, preventDefault() {} })
2488
+ await new Promise((r) => setTimeout(r, 80))
2489
+ hook.openModel()
2490
+ await new Promise((r) => setTimeout(r, 30))
2491
+ assert(
2492
+ '模型声明 minimal/xhigh 这种怪档位时,小窗仍然只列四个格子(不再出现 Xhigh/Medium)',
2493
+ tierBtns().map((b) => textOf(b)).join('/') === '关/低/高/最高' &&
2494
+ !tierBtns().some((b) => String(b.title).indexOf('Xhigh') >= 0 && !String(b.title).indexOf('该模型发')),
2495
+ tierBtns().map((b) => textOf(b) + (b.title ? '(' + String(b.title) + ')' : '')).join(' / '),
2496
+ )
2497
+
2498
+ // 收尾:恢复默认(写回跟随态)、收起抽屉与面板
2499
+ const resetBtn2 = Array.from(walk(settingsSheet)).find((n) => n.className === 'dsh-sel-sbtn' && textOf(n).indexOf('恢复默认') >= 0)
2500
+ resetBtn2.dispatch('click', { stopPropagation() {}, preventDefault() {} })
2501
+ await new Promise((r) => setTimeout(r, 900))
2502
+ hook.close()
2503
+ await new Promise((r) => setTimeout(r, 30))
2504
+ }
2505
+
2506
+ // 收尾:抽屉还原、夹具还原(别影响后面的用例)
2507
+ const backBtn3 = Array.from(walk(settingsSheet)).find((n) => n.className === 'dsh-sel-sheetback')
2508
+ backBtn3.dispatch('click', { stopPropagation() {}, preventDefault() {} })
2509
+ await new Promise((r) => setTimeout(r, 20))
2510
+ globalThis.__modelCatalogFixture = fixture
2511
+ }
2512
+
2109
2513
  if (process.env.SEL_SETTINGS_ONLY === '1') {
2110
2514
  console.log('=== 设置与模型集成测试结束(真实 host,临时数据目录)===')
2111
2515
  process.exit(process.exitCode ?? 0)
@@ -2415,7 +2819,23 @@ for (let i = 0; i < 120 && hook.state().phase !== 'done'; i += 1) await sleep(20
2415
2819
  for (let i = 0; i < 80 && sent.length === beforeWeb; i += 1) await sleep(20)
2416
2820
  const webPayload = sent[sent.length - 1] || {}
2417
2821
  assert('开启后追问请求带 webAnswer: true', webPayload.webAnswer === true, JSON.stringify({ webAnswer: webPayload.webAnswer, q: webPayload.question }))
2822
+ // 网页输出还得带上当前深浅:深色 Harness 里给一张白底页面会很刺眼(host 据此注入配色规范)
2823
+ assert('网页输出带上当前主题(dark/light)', webPayload.theme === 'dark' || webPayload.theme === 'light', JSON.stringify(webPayload.theme))
2418
2824
  await waitIdle()
2825
+
2826
+ // 主题检测必须优先读 **DSH 自己的信号**(body[data-ds-dark-theme]),而不是面板的合成底色。
2827
+ // 踩过的坑:外观设成"跟随系统(暗)"时,面板底色取自 DSH 变量、自身透明 → 被算成浅色
2828
+ // → 生成的网页是亮色(用户实测报的)。DSH 不管用户选哪一档,都会把最终配色投影到这个属性上。
2829
+ {
2830
+ body.setAttribute('data-ds-dark-theme', '')
2831
+ const beforeDark = sent.length
2832
+ hook.ask('深色下再来一次:三种方式对比')
2833
+ for (let i = 0; i < 80 && sent.length === beforeDark; i += 1) await sleep(20)
2834
+ const darkPayload = sent[sent.length - 1] || {}
2835
+ assert('跟随系统的深色下,网页输出带 theme: dark', darkPayload.theme === 'dark', JSON.stringify(darkPayload.theme))
2836
+ body.removeAttribute('data-ds-dark-theme')
2837
+ await waitIdle()
2838
+ }
2419
2839
  hook.setWebMode(false)
2420
2840
  const beforePlain = sent.length
2421
2841
  hook.ask('简单问题')
@@ -2538,13 +2958,16 @@ for (let i = 0; i < 120 && hook.state().phase !== 'done'; i += 1) await sleep(20
2538
2958
  assert('追问停止也有记录(自检可读)', /已停止/.test(String(hook.state().status || '')), String(hook.state().status))
2539
2959
  }
2540
2960
 
2541
- // ───────────────────────── 没有工具时不许伪造调用 ─────────────────────────
2961
+ // ────────────── 正文不再做文本层清洗(照主会话的做法) ──────────────
2962
+ //
2963
+ // 以前这里断言"<ds_safety_tool_call> 被剥掉"。现在**故意不清洗**了:
2964
+ // 主会话从不摘工具,模型走协议层表达工具调用,正文里根本不会有这种标记;
2965
+ // 而留一道清洗,在标记"未闭合"时会把正文尾巴一起吃掉(实测:收尾轮 raw=726 → emitted=0)。
2966
+ // 所以这条用例改成锁住"原样显示",防止以后又有人把清洗加回来。
2542
2967
  hook.open('fake-tool-probe', '', '伪造调用')
2543
2968
  for (let i = 0; i < 80 && hook.state().phase !== 'done'; i += 1) await sleep(20)
2544
- assert('模型吐出的 <ds_safety_tool_call> 被剥掉', textOf(sections[0]).indexOf('ds_safety_tool_call') < 0 && textOf(sections[0]).indexOf('tool_name') < 0, textOf(sections[0]).slice(0, 60))
2545
- assert('正文其余内容保留', textOf(sections[0]).indexOf('没有工具时的回答') >= 0, textOf(sections[0]).slice(0, 60))
2546
- assert('明确告知"本轮没有可用工具"', textOf(sections[0]).indexOf('没有可用工具') >= 0, textOf(sections[0]).slice(0, 80))
2547
- assert('自检钩子报出伪造残渣标记', hook.state().toolResidue === true, String(hook.state().toolResidue))
2969
+ assert('正文原样保留:客户端不再做文本层清洗', String(hook.state().raw).indexOf('ds_safety_tool_call') >= 0, String(hook.state().raw).slice(0, 60))
2970
+ assert('正文其余内容保留(不做手术就不会误伤)', textOf(sections[0]).indexOf('没有工具时的回答') >= 0, textOf(sections[0]).slice(0, 80))
2548
2971
 
2549
2972
  // ───────────────────────── 追问小窗(临时多轮对话) ─────────────────────────
2550
2973
  const askBox = Array.from(walk(panel)).find((n) => n.className === 'dsh-sel-askbox')
@@ -2841,7 +3264,6 @@ assert('追问等待期不重建节点(动画不被打断)', classesIn(askBu
2841
3264
  await sleep(400)
2842
3265
  const botBubbles = askBubbles()
2843
3266
  assert('首个字到达后等待特效换成正文', classesIn(botBubbles[botBubbles.length - 1], 'dsh-sel-wait').length === 0 && textOf(botBubbles[botBubbles.length - 1]).indexOf('等到了回答') >= 0, JSON.stringify(textOf(botBubbles[botBubbles.length - 1]).slice(0, 24)))
2844
-
2845
3267
  // ───────────────────────── 本地缓存:同一个词再点一次必须秒回 ─────────────────────────
2846
3268
  hook.open('缓存探测词', '上下文片段 A', '缓存用例')
2847
3269
  for (let i = 0; i < 80 && hook.state().phase !== 'done'; i += 1) await sleep(20)
@@ -3031,6 +3453,65 @@ for (let i = 0; i < 40 && !textOf(sections[0]).trim(); i += 1) await sleep(20)
3031
3453
  assert('内存空了也能回放最近一条小窗', panel.style.display !== 'none' && textOf(sections[0]).trim().length > 0, textOf(sections[0]).slice(0, 40))
3032
3454
  assert('回放走的是历史,不重新调模型', sent.length === sentBeforeReplay, `新增请求 ${sent.length - sentBeforeReplay}`)
3033
3455
 
3456
+ // 生成中按 Enter:提示"还在生成",但**你打的字必须留着**
3457
+ // (以前是"先清空、再被 ask() 拒掉" —— 字直接丢了,只剩一句提示)
3458
+ {
3459
+ const slowStart2 = sent.length
3460
+ hook.open('中止探测', '', '生成中发消息')
3461
+ for (let i = 0; i < 60 && sent.length === slowStart2; i += 1) await sleep(20)
3462
+ for (let i = 0; i < 60 && hook.state().phase !== 'streaming'; i += 1) await sleep(20)
3463
+ assert('(前提)这一轮正在流式输出', hook.state().phase === 'streaming', String(hook.state().phase))
3464
+ hook.askValue('生成中想好的下一句')
3465
+ const sentBeforeBusy = sent.length
3466
+ askBox.dispatch('keydown', { key: 'Enter', preventDefault() {}, stopPropagation() {} })
3467
+ // 立刻读:流式的计时器每秒会把 status 覆盖成"生成中… Ns",晚读就看不到了
3468
+ const statusNow = String(hook.state().status || '')
3469
+ await sleep(60)
3470
+ assert(
3471
+ '生成中按 Enter:你打的字还在输入框里(不会被清掉)',
3472
+ askBox.value === '生成中想好的下一句',
3473
+ JSON.stringify(askBox.value),
3474
+ )
3475
+ // 注意:这条提示目前**只在 state.lastStatus 里**(界面上不渲染)—— 见提交说明里的发现。
3476
+ assert(
3477
+ '生成中按 Enter:记下还在生成这条状态(供后续接上可见提示)',
3478
+ /还在生成/.test(statusNow),
3479
+ JSON.stringify(statusNow),
3480
+ )
3481
+ assert('生成中按 Enter:没有真的发出去', sent.length === sentBeforeBusy, String(sent.length - sentBeforeBusy))
3482
+ hook.askValue('')
3483
+ hook.close()
3484
+ await sleep(40)
3485
+ }
3486
+
3487
+ // 换选区不该继承上一段对话的检索结果(toolDigest 的语义单位是"一次对话")
3488
+ // 踩过的坑:state 整页只建一次,digest 只在历史回放时按条目恢复、别处从不复位 ——
3489
+ // 于是某段对话搜过新闻之后,之后每一段新选区的追问都带着那段新闻。
3490
+ {
3491
+ hook.open('digest-probe', '', '摘要继承用例')
3492
+ for (let i = 0; i < 80 && hook.state().phase !== 'done'; i += 1) await sleep(20)
3493
+ assert('新面板开局:检索摘要是空的', hook.state().toolDigest === '', JSON.stringify(String(hook.state().toolDigest).slice(0, 40)))
3494
+ hook.ask('旁白追问')
3495
+ for (let i = 0; i < 80 && !hook.state().toolDigest; i += 1) await sleep(20)
3496
+ assert('工具检索会累进检索摘要', String(hook.state().toolDigest).length > 0, JSON.stringify(String(hook.state().toolDigest).slice(0, 40)))
3497
+ hook.open('digest-probe-2', '', '另一段选区')
3498
+ assert('换选区后不继承上一段的检索摘要', hook.state().toolDigest === '', JSON.stringify(String(hook.state().toolDigest).slice(0, 60)))
3499
+ for (let i = 0; i < 40; i += 1) await sleep(20)
3500
+ }
3501
+
3502
+ // 结论轮提示("查询已完成,正在整理结论…")在正文到达后必须消失
3503
+ // 踩过的坑:它写进 answerTurn.notice 之后再没有任何地方清掉 —— 回答早就写完了,黄条还挂着。
3504
+ {
3505
+ const noticeEl = () => Array.from(walk(panel)).find((n) => String(n.className).indexOf('dsh-sel-notice') >= 0)
3506
+ hook.open('notice-probe', '', '提示用例')
3507
+ for (let i = 0; i < 80 && hook.state().phase !== 'done'; i += 1) await sleep(20)
3508
+ hook.ask('这个提示会不会一直挂着')
3509
+ for (let i = 0; i < 30 && !noticeEl(); i += 1) await sleep(20)
3510
+ assert('结论轮提示会出现(过程状态)', !!noticeEl(), noticeEl() ? JSON.stringify(textOf(noticeEl()).slice(0, 20)) : '未找到')
3511
+ for (let i = 0; i < 60 && noticeEl(); i += 1) await sleep(20)
3512
+ assert('正文到达后提示消失(不再永远挂着)', !noticeEl(), noticeEl() ? JSON.stringify(textOf(noticeEl()).slice(0, 20)) : '')
3513
+ for (let i = 0; i < 40; i += 1) await sleep(20)
3514
+ }
3034
3515
  // 收起小窗 = 中止这一轮,不该记成"失败",而是"已停止"(点回来还能重新生成)
3035
3516
  const slowStart = sent.length
3036
3517
  hook.open('中止探测', '', '中止用例')
@@ -3192,7 +3673,7 @@ assert('点回来看到"已停止"的状态与可重试入口', hook.state().pha
3192
3673
  assert('拉到模型清单(3 个)', loaded.count === 3, JSON.stringify(loaded))
3193
3674
  assert('没选过模型时胶囊也有文案(跟随当前路由)', nodes.pill.textContent.trim().length > 0, nodes.pill.textContent)
3194
3675
  assert('菜单列出全部模型 + provider 名', rowsIn().length === 3, String(rowsIn().length))
3195
- assert('档位按钮来自该模型声明的等级(4 档)', tiersIn().map((b) => textOf(b)).join('/') === '关/低/高/最大', tiersIn().map((b) => textOf(b)).join('/'))
3676
+ assert('档位按钮 = 与设置页同一套四个格子', tiersIn().map((b) => textOf(b)).join('/') === '关/低/高/最高', tiersIn().map((b) => textOf(b)).join('/'))
3196
3677
 
3197
3678
  // 点"Strong Model"
3198
3679
  const strong = rowsIn().find((r) => textOf(r).indexOf('Strong Model') >= 0)
@@ -3202,13 +3683,18 @@ assert('点回来看到"已停止"的状态与可重试入口', hook.state().pha
3202
3683
  const selectedSettings = await (await fetch(ORIGIN + '/selection-explain/api/settings')).json()
3203
3684
  assert('模型选择保存到统一设置', selectedSettings.values.provider === 'p1' && selectedSettings.values.model === 'm-strong', JSON.stringify(selectedSettings.values))
3204
3685
  assert('菜单里的勾选跟着换', rowsIn().filter((r) => r.getAttribute('data-on') === '1').length === 1, '')
3205
- assert('档位按钮收缩成该模型支持的 2 档', tiersIn().map((b) => textOf(b)).join('/') === '关/高', tiersIn().map((b) => textOf(b)).join('/'))
3686
+ assert('换模型后档位仍是那四个格子(词汇与设置页一致,不跟模型走)', tiersIn().map((b) => textOf(b)).join('/') === '关/低/高/最高', tiersIn().map((b) => textOf(b)).join('/'))
3206
3687
 
3207
- // 点档位"低"→ 该模型没声明低档,仍然允许(host 会按 id 透传);这里点"关"
3688
+ // 点"关"那一格 → 记进配置(host 再按当前模型把它映射成真实档位)
3208
3689
  const offBtn = tiersIn().find((b) => textOf(b) === '关')
3209
3690
  offBtn.dispatch('click', { preventDefault() {}, stopPropagation() {} })
3210
3691
  assert('点档位 → 胶囊上的档位跟着变', nodes.pill.textContent.indexOf('关') >= 0, nodes.pill.textContent)
3211
- assert('档位落盘', String(windowStub.localStorage.getItem('dsh-selection-explain:effort')) === 'off', String(windowStub.localStorage.getItem('dsh-selection-explain:effort')))
3692
+ // 档位只有**一份**数据:设置里的 chatReasoningEffort(不再有 localStorage 覆盖)
3693
+ await new Promise(r => setTimeout(r, 700))
3694
+ const effortSaved = await (await fetch(ORIGIN + '/selection-explain/api/settings')).json()
3695
+ assert('档位写进统一设置(chatReasoningEffort),不再是浏览器本地覆盖',
3696
+ effortSaved.values.chatReasoningEffort === 'off' && windowStub.localStorage.getItem('dsh-selection-explain:effort') === null,
3697
+ JSON.stringify({ saved: effortSaved.values.chatReasoningEffort, local: windowStub.localStorage.getItem('dsh-selection-explain:effort') }))
3212
3698
 
3213
3699
  // 请求要带上 provider/model/effort(紧跟点档位之后断言,免得被后面的用例改状态影响)
3214
3700
  {
@@ -3220,35 +3706,6 @@ assert('点回来看到"已停止"的状态与可重试入口', hook.state().pha
3220
3706
  for (let i = 0; i < 300; i += 1) { const list = hook.turns(); if (list.length && !list.some((t) => t.streaming === true)) break; await sleep(20) }
3221
3707
  }
3222
3708
 
3223
- // 客户端兜底:旧缓存/历史回放里的工具调用残渣(两种形态:官方 safety + DSML 带空格形态)
3224
- {
3225
- const VB = String.fromCharCode(0xff5c)
3226
- const MK = VB + VB + 'DSML' + VB + VB
3227
- const dirty = `正文。<${MK} parameter name="limit" string="false">40</${MK} parameter>尾。`
3228
- hook.close()
3229
- await sleep(30)
3230
- hook.open('residue-probe', '上下文:清残渣', '残渣兜底用例')
3231
- for (let i = 0; i < 120 && hook.state().phase !== 'done'; i += 1) await sleep(20)
3232
- await sleep(30)
3233
- globalThis.__askExtra = { delta: dirty }
3234
- const before = hook.turns().length
3235
- hook.ask('残渣兜底追问')
3236
- for (let i = 0; i < 300; i += 1) {
3237
- const list = hook.turns()
3238
- if (list.length > before && !list.some((t) => t.streaming === true)) break
3239
- await sleep(20)
3240
- }
3241
- await sleep(60)
3242
- const stripped = hook.stripResidue(dirty)
3243
- assert('客户端兜底:函数本身能清掉带空格的 DSML(含孤立标签)', stripped.indexOf(MK) < 0 && !/parameter\s+name=/.test(stripped) && stripped.indexOf('正文。') >= 0, JSON.stringify(stripped))
3244
- const rendered = textOf(chatLog)
3245
- assert(
3246
- '客户端兜底:带空格的 DSML 残渣不会渲染进消息区',
3247
- rendered.indexOf(MK) < 0 && !/parameter\s+name=/.test(rendered) && rendered.indexOf('正文。') >= 0,
3248
- JSON.stringify(rendered.slice(-60)),
3249
- )
3250
- globalThis.__askExtra = null
3251
- }
3252
3709
 
3253
3710
  // strip 事件:把"模型写进正文的思考"从已流出的正文里撤回,并按"泄漏"记进记忆
3254
3711
  {
@@ -3294,7 +3751,7 @@ assert('点回来看到"已停止"的状态与可重试入口', hook.state().pha
3294
3751
  hook.openModel() // 菜单要先渲染出来,否则下面找不到按钮(前面的 strip 用例重渲染过)
3295
3752
  await sleep(20)
3296
3753
  const tierBtns = Array.from(walk(hook.modelNodes().menu)).filter((n) => String(n.className) === 'dsh-sel-tierbtn')
3297
- const pickBtn = tierBtns.find((b) => textOf(b) === '最大') || tierBtns[0]
3754
+ const pickBtn = tierBtns.find((b) => textOf(b) === '最高') || tierBtns[0]
3298
3755
  if (pickBtn) pickBtn.dispatch('click', { preventDefault() {}, stopPropagation() {} })
3299
3756
  const chosen = hook.modelState().effort
3300
3757
  assert('用例前置:先选上一个档位', typeof chosen === 'string' && chosen.length > 0, String(chosen))
@@ -3352,9 +3809,9 @@ assert('点回来看到"已停止"的状态与可重试入口', hook.state().pha
3352
3809
  const rows = () => Array.from(walk(hook.modelNodes().menu)).filter((n) => String(n.className) === 'dsh-sel-pickerrow')
3353
3810
  rows()[0].dispatch('click', { preventDefault() {}, stopPropagation() {} })
3354
3811
  const tiers = () => Array.from(walk(hook.modelNodes().menu)).filter((n) => String(n.className) === 'dsh-sel-tierbtn')
3355
- const maxBtn = tiers().find((b) => textOf(b) === '最大')
3812
+ const maxBtn = tiers().find((b) => textOf(b) === '最高')
3356
3813
  maxBtn.dispatch('click', { preventDefault() {}, stopPropagation() {} })
3357
- assert('选到「最大」档后胶囊显示它', hook.modelState().effort === 'max', String(hook.modelState().effort))
3814
+ assert('选到「最高」那一格后胶囊显示它', hook.modelState().effort === 'max', String(hook.modelState().effort))
3358
3815
 
3359
3816
  // 让这一轮流式里含一条 effort-rejected notice(模拟 host 的回退)
3360
3817
  hook.close()
@@ -3367,12 +3824,54 @@ assert('点回来看到"已停止"的状态与可重试入口', hook.state().pha
3367
3824
  const last = turns[turns.length - 1]
3368
3825
  assert('轮次上记下了提示文案', String(last.notice || '').indexOf('已按默认档位重试') >= 0, String(last.notice))
3369
3826
  assert('提示行渲染进了气泡(不是只记在状态里)', Array.from(walk(chatLog)).some((n) => String(n.className).indexOf('dsh-sel-notice') >= 0 && textOf(n).indexOf('已按默认档位重试') >= 0), textOf(chatLog).slice(-60))
3370
- assert('被拒后自动回退:档位选择清空(不再拿同一个组合撞墙)', hook.modelState().effort === null, String(hook.modelState().effort))
3371
- assert('回退同时清掉持久化', String(windowStub.localStorage.getItem('dsh-selection-explain:effort')) === '', String(windowStub.localStorage.getItem('dsh-selection-explain:effort')))
3827
+ // 档位只有**一份**数据(配置里的 chatReasoningEffort):被拒时不再偷偷清掉用户的选择
3828
+ //(清掉会让人以为自己没选过),这一轮该发什么由 host 的换档重试兜;标记留给菜单划斜线。
3829
+ assert('被拒后**保留**用户选的那一格(唯一一份数据,不偷偷清)', hook.modelState().effort === 'max', String(hook.modelState().effort))
3830
+ assert('档位不再写浏览器本地覆盖(已并入配置这一个源)',
3831
+ windowStub.localStorage.getItem('dsh-selection-explen:effort') === null &&
3832
+ windowStub.localStorage.getItem('dsh-selection-explain:effort') === null,
3833
+ String(windowStub.localStorage.getItem('dsh-selection-explain:effort')))
3372
3834
  // 菜单里该档位被标成"实测不支持"
3373
3835
  hook.openModel()
3374
3836
  const bad = Array.from(walk(hook.modelNodes().menu)).filter((n) => n.getAttribute && n.getAttribute('data-bad') === '1')
3375
- assert('菜单里把被拒的档位标出来(划斜线 + tooltip)', bad.length === 1 && textOf(bad[0]) === '最大', bad.map((b) => textOf(b)).join(','))
3837
+ assert('菜单里把被拒的那一格标出来(划斜线 + tooltip)', bad.length === 1 && textOf(bad[0]) === '最高', bad.map((b) => textOf(b)).join(','))
3838
+
3839
+ // ② 上游**偶发**错误(host 已换档重试):提示要出现,但绝不能当成"这一档不支持" ——
3840
+ // 2026-10-09 实测:同一档位时好时坏(low/high 各 5/8 成功),所以它不能清档位、不能标 bad。
3841
+ {
3842
+ const highBtn = tiers().find((b) => textOf(b) === '高')
3843
+ highBtn.dispatch('click', { preventDefault() {}, stopPropagation() {} })
3844
+ assert('(前提)先把档位设成「高」', hook.modelState().effort === 'high', String(hook.modelState().effort))
3845
+ hook.close()
3846
+ await sleep(30)
3847
+ globalThis.__askExtra = { notice: { code: 'upstream-retry', text: '上游这次返回了错误,已自动重试' }, noticeGapMs: 400 }
3848
+ hook.ask('上游偶发错误的用例')
3849
+ let sawNotice = false
3850
+ for (let i = 0; i < 200 && hook.turns().some((t) => t.streaming === true); i += 1) {
3851
+ if (String((hook.turns()[hook.turns().length - 1] || {}).notice || '').indexOf('已自动重试') >= 0) sawNotice = true
3852
+ await sleep(20)
3853
+ }
3854
+ await sleep(60)
3855
+ const afterUpstream = hook.turns()[hook.turns().length - 1] || {}
3856
+ assert('上游偶发错误的提示出现过', sawNotice, String(afterUpstream.notice))
3857
+ assert(
3858
+ '正文到了之后这句**过程提示**撤掉(不是挂在回答上的结论)',
3859
+ String(afterUpstream.notice || '') === '',
3860
+ String(afterUpstream.notice),
3861
+ )
3862
+ assert(
3863
+ '偶发错误**不动**用户的档位选择(这是与 effort-rejected 的关键区别)',
3864
+ hook.modelState().effort === 'high',
3865
+ String(hook.modelState().effort),
3866
+ )
3867
+ hook.openModel()
3868
+ const badAfter = Array.from(walk(hook.modelNodes().menu)).filter((n) => n.getAttribute && n.getAttribute('data-bad') === '1')
3869
+ assert(
3870
+ '偶发错误不写进"实测该模型不支持"(菜单里仍然只有「最大」被标)',
3871
+ badAfter.length === 1 && textOf(badAfter[0]) === '最高',
3872
+ badAfter.map((b) => textOf(b)).join(','),
3873
+ )
3874
+ }
3376
3875
  globalThis.__askExtra = null
3377
3876
  }
3378
3877
 
@@ -3697,8 +4196,8 @@ let bridgeOwnedBlob = ''
3697
4196
  assert('只挂引用、没写问题时发送键可用', askSend.disabled === false, 'disabled=' + askSend.disabled)
3698
4197
  assert(
3699
4198
  '小窗里的引用**不带上下文**(小窗这几轮对话本来就会随历史进模型)',
3700
- quotes[0].context === '' && quotes[0].session === false,
3701
- JSON.stringify({ context: quotes[0].context, session: quotes[0].session }),
4199
+ !!quotes[0] && quotes[0].context === '' && quotes[0].session === false,
4200
+ JSON.stringify({ context: quotes[0] && quotes[0].context, session: quotes[0] && quotes[0].session }),
3702
4201
  )
3703
4202
  assert(
3704
4203
  '引用浮标画在面板**之上**(小窗里划词才看得见、点得着)',
@@ -3721,7 +4220,9 @@ let bridgeOwnedBlob = ''
3721
4220
  `${longQuote && longQuote.text.length} · ${hook.state().status}`,
3722
4221
  )
3723
4222
  const chipList = Array.from(walk(quotesBox)).filter((n) => n.className === 'dsh-sel-quotechip-x')
3724
- chipList[chipList.length - 1].dispatch('click', { stopPropagation() {} })
4223
+ // 前面若已失败(引用没挂上),这里就没有 chip —— 别让它把整个脚本打断
4224
+ const lastChip = chipList[chipList.length - 1]
4225
+ if (lastChip) lastChip.dispatch('click', { stopPropagation() {} })
3725
4226
  await sleep(5)
3726
4227
  assert('删掉超长那条后回到 1 条', hook.quotes().length === 1, JSON.stringify(hook.quotes().map((q) => q.text.length)))
3727
4228
  // 把浮标状态也归零:不然下面那次"再点一次"的点击会把这段长文又加回来
@@ -4799,6 +5300,753 @@ function sliceFunction(text, name) {
4799
5300
  // ───────────────────────── 关闭 / 清理 ─────────────────────────
4800
5301
  hook.close()
4801
5302
  assert('Esc/关闭后隐藏', panel.style.display === 'none')
5303
+ // ───────────────────────── 语音卡:选中文字 + 输入法正在语音输入 ─────────────────────────
5304
+ // 这条链路的关键是"我们不读输入法的转写结果":输入法上屏只认焦点元素,
5305
+ // 所以只要在它开麦时把焦点拿到自己的输入框上,说的话就会自己落进来。
5306
+ {
5307
+ // 也认 setAttribute('class') —— SVG 元素的 className 是只读对象,插件只能用 setAttribute
5308
+ const find = (cls) =>
5309
+ Array.from(walk(mount)).find(
5310
+ (n) => n.className === cls || (n.getAttribute && n.getAttribute('class') === cls),
5311
+ )
5312
+
5313
+ hook.close()
5314
+ await sleep(20)
5315
+
5316
+ // host 报:豆包输入法正在采集麦克风
5317
+ micStub.value = {
5318
+ ok: true,
5319
+ available: true,
5320
+ capturing: true,
5321
+ processes: [{ pid: 513, bundleId: 'com.bytedance.inputmethod.doubaoime', name: 'DoubaoIme' }],
5322
+ reason: '',
5323
+ }
5324
+ // 同一行里如果有行内元素(<code>/粗体),getClientRects() 会给多个矩形且底边不等高 ——
5325
+ // 必须归并成一条,否则会"划两层"、渐隐还会出现在行中间(用户实测的两个问题,同一根因)。
5326
+ // 注意:插件在 checkSelection 时就把 range **存下来了**,所以补丁必须打在捕获之前;
5327
+ // 而且桩的 getRangeAt() 每次返回新对象(createRange()),所以补丁要打在 createRange 上。
5328
+ const realCreateRange = documentStub.createRange.bind(documentStub)
5329
+ // 起点所在的"块":底边 110 —— 落在下面那段大选区(100..200)**中间**。
5330
+ // 老代码只按这个块算,卡片就会压在剩下的选中文字上(用户报的"大段文字时卡片遮住文字")。
5331
+ const blockEl = {
5332
+ nodeType: 1,
5333
+ parentElement: null,
5334
+ getBoundingClientRect: () => ({ left: 40, top: 100, right: 500, bottom: 110, width: 460, height: 10 }),
5335
+ }
5336
+ const realGCS = windowStub.getComputedStyle
5337
+ windowStub.getComputedStyle = (el) =>
5338
+ el === blockEl
5339
+ ? { display: 'block', backgroundColor: 'rgba(0,0,0,0)' }
5340
+ : realGCS
5341
+ ? realGCS(el)
5342
+ : { display: '', backgroundColor: 'rgba(0,0,0,0)' }
5343
+ documentStub.createRange = () => {
5344
+ const r = realCreateRange()
5345
+ r.startContainer = blockEl
5346
+ // 真实浏览器实测的形状(选区跨整段时):一个**块级盒子**(h=53,盖住两行)
5347
+ // + 每行两个片段(行内 code 会给出外盒/内盒)。
5348
+ // 块盒不丢掉的话会把两行吞成一组 → 只有一条下划线(用户报的"有些行没有划线")。
5349
+ r.getClientRects = () => [
5350
+ { left: 20, top: 100, right: 580, bottom: 153, width: 560, height: 53 },
5351
+ { left: 20, top: 102, right: 187, bottom: 123, width: 167, height: 21 },
5352
+ { left: 187, top: 105, right: 346, bottom: 122, width: 159, height: 17 },
5353
+ { left: 20, top: 128, right: 83, bottom: 149, width: 63, height: 21 },
5354
+ { left: 83, top: 128, right: 533, bottom: 149, width: 450, height: 21 },
5355
+ ]
5356
+ // 选区本体很大(跨到 200),起点块只到 110
5357
+ // 选区右缘故意放到很靠右(innerWidth 1440):老规则"正文列左边→选区右缘"会算出 560,
5358
+ // 现在统一 360 —— 这条断言就是为了钉住"不再随选区位置变宽"。
5359
+ r.getBoundingClientRect = () => ({ left: 40, top: 100, right: 1300, bottom: 200, width: 1260, height: 100 })
5360
+ return r
5361
+ }
5362
+
5363
+ // 造一次真选区(走 mouseup 那条路):浮标一出现就开始看麦克风
5364
+ windowStub.getSelection = () => selection
5365
+ documentStub.dispatch('mouseup', { target: body })
5366
+ await sleep(250)
5367
+
5368
+ const opened = hook.voiceCard()
5369
+ assert('检测到输入法开麦 → 语音卡自己弹出来', opened.open === true, JSON.stringify(opened))
5370
+ assert('卡片处于"在听"状态(竖条呼吸)', opened.voiced === true, JSON.stringify(opened))
5371
+ assert(
5372
+ '大段选区:卡片按**整个选区**的底边落位(不压在选中的文字上)',
5373
+ !!opened.geometry && opened.geometry.top === '222px',
5374
+ 'top=' + String(opened.geometry && opened.geometry.top) + '(按起点块算会是 132px,正好压在选区里)',
5375
+ )
5376
+ assert(
5377
+ '卡片宽度固定 360(不随选区位置变宽;老规则在这里会给出 560)',
5378
+ !!opened.geometry && opened.geometry.width === '360px',
5379
+ JSON.stringify(opened.geometry),
5380
+ )
5381
+
5382
+ const box = find('dsh-sel-vcard-box')
5383
+ const sendBtn = find('dsh-sel-vcard-send')
5384
+ assert('卡片里有输入框(IME 上屏要落在它身上)', !!box && !!sendBtn, String(!!box) + '/' + String(!!sendBtn))
5385
+ // 「在听」指示器 = 卡片左缘那根 3px 竖条(B1 + 动1,用户选定)
5386
+ const vbar = find('dsh-sel-vbar')
5387
+ const cardNode = find('dsh-sel-vcard')
5388
+ assert(
5389
+ '卡片里有那根「在听」竖条,且是卡片的第一个子元素(正文排在它右边)',
5390
+ !!vbar && !!cardNode && !!cardNode.children && cardNode.children[0] === vbar,
5391
+ vbar ? '竖条存在;是第一个子元素=' + String(!!cardNode && cardNode.children[0] === vbar) : '没找到竖条',
5392
+ )
5393
+ // 选中提示(下划线 + 牵引线):**只在卡片打开后才画** —— 选中/浮标阶段不画(那时原生底纹还在)
5394
+ const uline = find('dsh-sel-uline')
5395
+ const thread = find('dsh-sel-thread')
5396
+ assert(
5397
+ '卡片打开后画了下划线(按选区逐行矩形)',
5398
+ !!uline && /px$/.test(String(uline.style.width)),
5399
+ uline ? String(uline.style.width) : 'none',
5400
+ )
5401
+ assert(
5402
+ '下划线是两端渐隐的(U3)',
5403
+ !!uline && /linear-gradient/.test(String(uline.style.background)),
5404
+ uline ? String(uline.style.background).slice(0, 46) : 'none',
5405
+ )
5406
+ // (旧的"圆点 display:block"断言删了:圆点现在是 SVG 子元素,没有自己的 display,
5407
+ // 由下面的 cx/cy 断言覆盖 —— 那才是它真正要表达的东西。)
5408
+ const threadPath = find('dsh-sel-threadpath')
5409
+ const threadDot = find('dsh-sel-threaddot')
5410
+ const d = threadPath ? String(threadPath.getAttribute('d')) : ''
5411
+ assert(
5412
+ '牵引线是 **SVG 贝塞尔**(path 里有 C 曲线段,不再是直线)',
5413
+ !!threadPath && d.indexOf('C') > 0,
5414
+ d ? d.slice(0, 64) : 'none',
5415
+ )
5416
+ assert(
5417
+ '牵引线终点 = **算出来的最终位置** + 4px(222+4=226:一开始就瞄准最终位置,不跟随形变、也不指到解读按钮上)',
5418
+ / 964 226$/.test(d),
5419
+ 'd 的结尾=' + JSON.stringify(d.slice(-14)),
5420
+ )
5421
+ assert(
5422
+ '牵引线起点有锚点圆(cx/cy 落在选区末行中点)',
5423
+ !!threadDot && String(threadDot.getAttribute('cx')) === '276.5' && String(threadDot.getAttribute('cy')) === '151',
5424
+ threadDot ? 'cx=' + String(threadDot.getAttribute('cx')) + ' cy=' + String(threadDot.getAttribute('cy')) : 'none',
5425
+ )
5426
+
5427
+ const merged = Array.from(walk(mount)).filter((n) => n.className === 'dsh-sel-uline')
5428
+ assert(
5429
+ '块级盒子被丢掉,两行各有一条下划线(不再整段只剩一条)',
5430
+ merged.length === 2,
5431
+ '下划线条数=' + merged.length + '(应为 2;不丢块盒会是 1)',
5432
+ )
5433
+ assert(
5434
+ '第一行:归并成一条(20..346),取最低底边 123+3',
5435
+ !!merged[0] && merged[0].style.width === '326px' && merged[0].style.top === '126px',
5436
+ merged[0] ? String(merged[0].style.width) + ' / top=' + String(merged[0].style.top) : 'none',
5437
+ )
5438
+ assert(
5439
+ '第二行也有自己的下划线(20..533)',
5440
+ !!merged[1] && merged[1].style.width === '513px' && merged[1].style.top === '152px',
5441
+ merged[1] ? String(merged[1].style.width) + ' / top=' + String(merged[1].style.top) : 'none',
5442
+ )
5443
+
5444
+ documentStub.createRange = realCreateRange
5445
+ windowStub.getComputedStyle = realGCS
5446
+
5447
+ // 焦点:桩 DOM 的 focus() 是空实现,这里换成可观测的再重开一次
5448
+ let focused = false
5449
+ if (box) box.focus = () => { focused = true }
5450
+ hook.voiceCardClose()
5451
+ hook.voiceCardOpen()
5452
+ assert('打开卡片时把焦点抢到输入框(上屏只认焦点元素)', focused === true, String(focused))
5453
+
5454
+ // 形变期间正文宽度被钉在终值(否则每帧重新折行 → 卡顿);结束后必须解开,
5455
+ // 否则之后打字时正文宽度不会再跟着卡片走
5456
+ await sleep(520)
5457
+ assert(
5458
+ '形变结束后解开正文宽度(回到 flex 布局)',
5459
+ !box.style.width && !box.style.flex,
5460
+ 'width=' + String(box.style.width) + ' flex=' + String(box.style.flex),
5461
+ )
5462
+
5463
+ // 开麦结束、一个字都没说 —— 先验**关掉**这两个收尾开关时的老行为:
5464
+ // 卡片**不退出**(用户可以接着手动打字),只是不再显示"在听"。
5465
+ // ⚠️ 这两个开关的出厂默认是**开**(2026-10-09 用户指定),所以这里必须显式设成 false;
5466
+ // 打开后的两条收尾在文件末尾单独验。
5467
+ settingsStub.value = { voiceCancelOnSilence: false, voiceAutoSend: false }
5468
+ hook.reloadSettings()
5469
+ await sleep(120)
5470
+ micStub.value = { ok: true, available: true, capturing: false, processes: [], reason: '' }
5471
+ await sleep(520)
5472
+ const silent = hook.voiceCard()
5473
+ assert('(关掉时)开麦结束但一个字都没说:卡片留着不退出', silent.open === true, JSON.stringify(silent))
5474
+ assert('此时不再显示「在听」(麦克风已经松开了)', silent.voiced === false, JSON.stringify(silent))
5475
+ // 恢复"正在录":后面几步按原样继续
5476
+ micStub.value = {
5477
+ ok: true,
5478
+ available: true,
5479
+ capturing: true,
5480
+ processes: [{ pid: 513, bundleId: 'com.bytedance.inputmethod.doubaoime', name: 'DoubaoIme' }],
5481
+ reason: '',
5482
+ }
5483
+ await sleep(520)
5484
+
5485
+ // 说话:文字落进输入框(输入法上屏走的就是这条路)
5486
+ if (box) {
5487
+ box.value = '这是在推迟还是提前?'
5488
+ box.dispatch('input', { target: box, preventDefault() {}, stopPropagation() {} })
5489
+ }
5490
+ const filled = hook.voiceCard()
5491
+ assert('有字之后才摆出发送键', filled.hasText === true, JSON.stringify(filled))
5492
+
5493
+ // 发送:跳过翻译与详解,直接进追问
5494
+ const beforeSend = hook.state()
5495
+ // 焦点探针:小窗自己的规则 —— **一轮输出结束(成功或失败)后**把光标交回输入框。
5496
+ // 注意这不是"从临时卡进小窗"的特例:划词解读、打开历史都走同一条规则。
5497
+ let askFocused = 0
5498
+ const realAskFocus = askBox.focus
5499
+ askBox.focus = () => {
5500
+ askFocused += 1
5501
+ }
5502
+ // 交接动画(方案二「缩放长出」)的观测点:必须在点发送**之前**挂上 ——
5503
+ // 插件会在 showPanel 里连续写两次 style.transform(先摆起点、再设 none),
5504
+ // 桩 DOM 没有布局,量不出真实矩形,但"起点是不是把面板压到卡片那一格"可以从写入值上直接验。
5505
+ const panelEl = find('dsh-sel-panel')
5506
+ const growWrites = []
5507
+ let panelTransformNow = panelEl.style.transform
5508
+ Object.defineProperty(panelEl.style, 'transform', {
5509
+ configurable: true,
5510
+ get: () => panelTransformNow,
5511
+ set: (value) => { growWrites.push(value); panelTransformNow = value },
5512
+ })
5513
+ // 发送那一刻焦点确实在卡片输入框里(真实状态)——正是它让"开窗聚焦"那次让了路
5514
+ documentStub.activeElement = box
5515
+ if (sendBtn) sendBtn.dispatch('click', { target: sendBtn, preventDefault() {}, stopPropagation() {} })
5516
+ await sleep(80)
5517
+ const afterSend = hook.state()
5518
+ assert('发送后卡片收起', hook.voiceCard().open === false, JSON.stringify(hook.voiceCard()))
5519
+ // 卡片矩形是桩给的 940,222,360×42;面板是通用矩形 40,100,460×300
5520
+ // → 起点 translate(900,122) scale(360/460, 42/300)
5521
+ const growStart = 'translate(900.0px,122.0px) scale(0.7826,0.1400)'
5522
+ assert(
5523
+ '交接起点:把面板压到卡片那一格(translate + scale,原点左上),终点回到 none',
5524
+ growWrites.indexOf(growStart) >= 0 &&
5525
+ growWrites.indexOf(growStart) === growWrites.length - 2 &&
5526
+ growWrites[growWrites.length - 1] === 'none' &&
5527
+ panelEl.style.transformOrigin === '0 0',
5528
+ JSON.stringify({ writes: growWrites, origin: panelEl.style.transformOrigin }),
5529
+ )
5530
+ assert(
5531
+ '交接只动 transform,260ms 同一族曲线(不动 width/left/top —— 那是布局属性,会掉帧)',
5532
+ String(panelEl.style.transition) === 'transform 260ms cubic-bezier(.2,.8,.2,1)',
5533
+ String(panelEl.style.transition),
5534
+ )
5535
+
5536
+ // 卡片隐藏后浏览器会把焦点收回 body —— 桩里不会(它不模拟"隐藏即失焦"),
5537
+ // 所以这里持续保持这个**忠实状态**:否则焦点会一直挂在已隐藏的卡片输入框上,
5538
+ // 后面的礼貌守卫就会误判成"人家正在别处打字"(实测就是这么卡的)。
5539
+ for (let i = 0; i < 60 && hook.state().phase !== 'done'; i += 1) {
5540
+ documentStub.activeElement = body
5541
+ await sleep(60)
5542
+ }
5543
+ assert('(前提)这一轮输出已经结束', hook.state().phase === 'done', String(hook.state().phase))
5544
+ // 别再往 body 上还原了:聚焦是在这一轮结束那一刻发生的,紧接着断言才对得上
5545
+ //(之前循环退出后还还原一次,把自己刚要验的焦点冲掉了 —— 实测就是这么红的)
5546
+ await sleep(260)
5547
+ assert(
5548
+ '小窗规则:一轮输出结束后,光标自动回到小窗输入框(可以直接继续追问)',
5549
+ // 只断言"规则触发了":桩里的 activeElement 会被好几个角色来回改(临时卡片的输入框也在抢),
5550
+ // 断言它等于 askBox 会很脆、也未必反映真实浏览器。真正的插入符在无头 Chrome 里画不出来
5551
+ // (无头不渲染 caret),所以这一层只能验到"规则执行了",插入符靠真机确认。
5552
+ askFocused > 0,
5553
+ 'focus 次数=' + String(askFocused) + ' 卡在=' + String(hook.focusBail()),
5554
+ )
5555
+ askBox.focus = realAskFocus
5556
+ // 还原焦点:不还原的话它会一直停在别处,后面用例开卡片会被"别处正在打字"那条守卫挡掉
5557
+ documentStub.activeElement = body
5558
+ // 交接动画必须**自己收尾**:把内联的 transform / transition / transformOrigin 清干净。
5559
+ // 留着的话下一次开窗量几何会量到半截 transform(拖拽改大小、设置抽屉都会跟着歪)。
5560
+ await sleep(460) // 260ms 动画 + 140ms 兜底(桩里没有 transitionend,走的就是兜底那条)
5561
+ assert(
5562
+ '交接收尾:内联 transform / transition / transformOrigin 全部清掉',
5563
+ !panelEl.style.transform && !panelEl.style.transition && !panelEl.style.transformOrigin,
5564
+ JSON.stringify({ tf: panelEl.style.transform, tr: panelEl.style.transition, og: panelEl.style.transformOrigin }),
5565
+ )
5566
+
5567
+ // 追问失败也要把光标交回输入框 —— 这条路原来漏了规则(用户实测:"报错之后输入框没有光标")
5568
+ {
5569
+ let focusAfterAskError = 0
5570
+ const realAskFocus2 = askBox.focus
5571
+ askBox.focus = () => {
5572
+ focusAfterAskError += 1
5573
+ }
5574
+ globalThis.__askFailOnce = true
5575
+ const sentBeforeAsk = sent.length
5576
+ hook.ask('这条追问会失败')
5577
+ await sleep(200)
5578
+ // 追问的问答在 chat log 里(不在翻译正文 panelBody 里),所以整个面板一起找
5579
+ for (let i = 0; i < 50 && !!globalThis.__askFailOnce; i += 1) await sleep(60)
5580
+ // 前置条件用**可证明的事实**:请求发出去了、而且被桩的失败注入接走了。
5581
+ //(不查"面板里有没有『追问失败』这行字":桩里那行渲染文本用 textOf 够不到,
5582
+ // 查它会得到一个恒假的断言 —— 那是测试环境的事,不是产品行为。)
5583
+ assert(
5584
+ '(前提)追问请求发了,且这次被桩按失败处理',
5585
+ sent.length - sentBeforeAsk > 0 && !globalThis.__askFailOnce,
5586
+ '请求数=' + String(sent.length - sentBeforeAsk) + ' 失败注入已消费=' + String(!globalThis.__askFailOnce),
5587
+ )
5588
+ assert(
5589
+ '追问失败后,光标也回到小窗输入框(这条路原来漏了"一轮结束就交回光标"的规则)',
5590
+ focusAfterAskError > 0,
5591
+ 'focus 次数=' + String(focusAfterAskError),
5592
+ )
5593
+ askBox.focus = realAskFocus2
5594
+ documentStub.activeElement = body
5595
+ }
5596
+ assert('跳过解读:翻译一节是空的(没请求 /analyze)', !afterSend.translation, JSON.stringify(beforeSend) + ' → ' + JSON.stringify(afterSend))
5597
+ assert('直接进了追问:面板开着', panel.style.display !== 'none', String(panel.style.display))
5598
+
5599
+ // Esc 收卡片(还没发出去时)
5600
+ micStub.value = { ok: true, available: true, capturing: true, processes: [{ pid: 513, bundleId: 'com.bytedance.inputmethod.doubaoime', name: 'DoubaoIme' }], reason: '' }
5601
+ hook.close()
5602
+ await sleep(20)
5603
+ documentStub.dispatch('mouseup', { target: body })
5604
+ await sleep(250)
5605
+ const reopened = hook.voiceCard()
5606
+ if (reopened.open) {
5607
+ documentStub.dispatch('keydown', { key: 'Escape', target: body, preventDefault() {}, stopPropagation() {} })
5608
+ await sleep(20)
5609
+ assert('Esc 收起语音卡', hook.voiceCard().open === false, JSON.stringify(hook.voiceCard()))
5610
+ // 取消后**不还回**「✦ 解读」浮标(用户要求:取消就是取消,别再冒出来问"要不要解读")
5611
+ assert(
5612
+ '取消后不还回「✦ 解读」浮标',
5613
+ button.style.display === 'none',
5614
+ '浮标 display=' + String(button.style.display),
5615
+ )
5616
+ // 取消时播"老式电视机关机"(A 方案):卡片先留在画面上塌缩,动画结束才真正隐藏
5617
+ const closingState = hook.voiceCard()
5618
+ const cardEl = find('dsh-sel-vcard')
5619
+ const crtLine = find('dsh-sel-crtline')
5620
+ assert('取消时先播关机动画(卡片还在画面上)', closingState.closing === true, JSON.stringify(closingState))
5621
+ assert(
5622
+ '关机动画挂在卡片上(data-off=a)',
5623
+ !!cardEl && cardEl.getAttribute('data-off') === 'a',
5624
+ cardEl ? String(cardEl.getAttribute('data-off')) : 'no card',
5625
+ )
5626
+ assert(
5627
+ '亮线拆成 5 段 + 2 个火花,宽度跟着卡片、颜色按明暗给',
5628
+ !!crtLine &&
5629
+ crtLine.style.width === '360px' &&
5630
+ !!crtLine.style.color &&
5631
+ !!crtLine.children &&
5632
+ crtLine.children.length === 7,
5633
+ crtLine
5634
+ ? crtLine.style.width + ' / ' + String(crtLine.style.color) + ' / 子元素 ' + String(crtLine.children.length)
5635
+ : 'no line',
5636
+ )
5637
+ // 桩环境没有 animationend,走 640ms 兜底
5638
+ await sleep(780)
5639
+ const closed = hook.voiceCard()
5640
+ assert(
5641
+ '关机动画结束后卡片才真正隐藏',
5642
+ closed.closing === false && !!cardEl && cardEl.style.display === 'none' && !cardEl.getAttribute('data-off'),
5643
+ JSON.stringify(closed) + ' / display=' + (cardEl ? String(cardEl.style.display) : '?'),
5644
+ )
5645
+ assert('亮线也一并收掉', !!crtLine && !crtLine.getAttribute('data-off'), crtLine ? String(crtLine.getAttribute('data-off')) : 'no line')
5646
+ assert(
5647
+ '卡片收起后选中提示一并清掉',
5648
+ !find('dsh-sel-uline') && !!thread && thread.style.display === 'none',
5649
+ '下划线=' + String(!!find('dsh-sel-uline')) + ' 牵引线 display=' + (thread ? String(thread.style.display) : '?'),
5650
+ )
5651
+ } else {
5652
+ assert('Esc 收起语音卡', false, '卡片没能再次打开:' + JSON.stringify(reopened))
5653
+ }
5654
+
5655
+ // ── 两个语音收尾开关(设置页「界面 · 语音输入」,**默认都开**)──
5656
+ // 关掉时的行为("卡片留着")上面已经验过;这里验打开后的两条收尾。
5657
+ // 关键:松手**不是**决定时刻 —— 输入法在松手后还会做一次智能整理(改写输入框内容),
5658
+ // 所以两个动作都要等文字定稿(连续 VOICE_SETTLE_MS=500ms 没变化)才发生。
5659
+ {
5660
+ const IME = [{ pid: 513, bundleId: 'com.bytedance.inputmethod.doubaoime', name: 'DoubaoIme' }]
5661
+ const recording = { ok: true, available: true, capturing: true, processes: IME, reason: '' }
5662
+ const stopped = { ok: true, available: true, capturing: false, processes: [], reason: '' }
5663
+
5664
+ // ① 「空录音时收起」:松手后不立刻收,等定稿才收(而且走取消路径)
5665
+ settingsStub.value = { voiceCancelOnSilence: true, voiceAutoSend: false }
5666
+ hook.reloadSettings()
5667
+ await sleep(320)
5668
+ hook.close()
5669
+ micStub.value = recording
5670
+ await sleep(420)
5671
+ hook.voiceCardOpen()
5672
+ if (box) {
5673
+ box.value = ''
5674
+ box.dispatch('input', { target: box, preventDefault() {}, stopPropagation() {} })
5675
+ }
5676
+ await sleep(80)
5677
+ micStub.value = stopped
5678
+ await sleep(300)
5679
+ assert(
5680
+ '松手后**不会立刻**取消(要等文字定稿:输入法松手后还会智能整理)',
5681
+ hook.voiceCard().open === true,
5682
+ JSON.stringify(hook.voiceCard()),
5683
+ )
5684
+ await sleep(900)
5685
+ const cardSilent = find('dsh-sel-vcard')
5686
+ assert('定稿后才取消(空录音)', hook.voiceCard().open === false, JSON.stringify(hook.voiceCard()))
5687
+ assert(
5688
+ '空录音收起走的是**取消**路径(关机动画 data-off=a,不是无声消失)',
5689
+ !!cardSilent && cardSilent.getAttribute('data-off') === 'a',
5690
+ cardSilent ? String(cardSilent.getAttribute('data-off')) : 'no card',
5691
+ )
5692
+
5693
+ // ② 「说完直接发送」:松手后等定稿才发;期间模拟豆包的智能整理改写输入框
5694
+ settingsStub.value = { voiceCancelOnSilence: false, voiceAutoSend: true }
5695
+ hook.reloadSettings()
5696
+ await sleep(320)
5697
+ await sleep(800) // 等上一条关机动画彻底收尾
5698
+ hook.close()
5699
+ await sleep(20)
5700
+ micStub.value = recording
5701
+ await sleep(420)
5702
+ hook.voiceCardOpen()
5703
+ if (box) {
5704
+ box.value = '这是 在推迟 还是提前' // 整理之前:有空格、没标点
5705
+ box.dispatch('input', { target: box, preventDefault() {}, stopPropagation() {} })
5706
+ }
5707
+ await sleep(80)
5708
+ const sentBefore = sent.length
5709
+ micStub.value = stopped
5710
+ await sleep(300)
5711
+ assert(
5712
+ '松手后**不会立刻**发送(要等文字定稿)',
5713
+ hook.voiceCard().open === true && sent.length === sentBefore,
5714
+ JSON.stringify(hook.voiceCard()) + ' / sent=' + String(sent.length - sentBefore),
5715
+ )
5716
+ // 模拟智能整理:松手之后才改写输入框,而且是**直接改 value、不派发 input**(真实情况就是这样)
5717
+ const cleaned = '这是在推迟还是提前,release notes 要不要一起改。'
5718
+ if (box) box.value = cleaned
5719
+ await sleep(300)
5720
+ assert(
5721
+ '整理过程中仍然不发(每改一次就重新计时)',
5722
+ hook.voiceCard().open === true && sent.length === sentBefore,
5723
+ JSON.stringify(hook.voiceCard()) + ' / sent=' + String(sent.length - sentBefore),
5724
+ )
5725
+ // 主信号:输入法的智能整理**走合成 API 提交**(实测 8 次录音里 6 次都有)
5726
+ if (box) box.dispatch('compositionend', { data: cleaned })
5727
+ await sleep(260)
5728
+ assert(
5729
+ '收到 compositionend 后**短窗口内**就决定(260ms 就发了 —— 兜底的 700ms 静默还没到)',
5730
+ hook.voiceCard().open === false,
5731
+ JSON.stringify(hook.voiceCard()),
5732
+ )
5733
+ assert(
5734
+ '发出去的是**整理之后**的文字(不是松手那一刻的草稿)',
5735
+ // 问题在 question 字段(text 是选中的原文)
5736
+ sent.slice(sentBefore).some((b) => b && (b.question === cleaned || b.text === cleaned)),
5737
+ JSON.stringify(sent.slice(sentBefore).map((b) => b && b.question)),
5738
+ )
5739
+
5740
+ // ③ 出厂默认就是开:桩里**不写这两个键**时,空录音同样等定稿后收起。
5741
+ // 这一条挡住"设置还没加载完 / 读失败就退回关"的回归 —— 那会和 host 侧 Config 的缺省值不一致。
5742
+ settingsStub.value = {}
5743
+ hook.reloadSettings()
5744
+ await sleep(320)
5745
+ await sleep(800) // 等上一条发送彻底收尾
5746
+ hook.close()
5747
+ await sleep(20)
5748
+ micStub.value = recording
5749
+ await sleep(420)
5750
+ hook.voiceCardOpen()
5751
+ if (box) {
5752
+ box.value = ''
5753
+ box.dispatch('input', { target: box, preventDefault() {}, stopPropagation() {} })
5754
+ }
5755
+ await sleep(80)
5756
+ micStub.value = stopped
5757
+ await sleep(900)
5758
+ assert(
5759
+ '默认(桩里不写这两个键)也是开:空录音照样等定稿后收起',
5760
+ hook.voiceCard().open === false,
5761
+ JSON.stringify(hook.voiceCard()),
5762
+ )
5763
+
5764
+ // 收尾:设置清回默认,别影响后面的用例
5765
+ settingsStub.value = {}
5766
+ hook.reloadSettings()
5767
+ await sleep(300)
5768
+ }
5769
+
5770
+ // ── ⑨ 小窗开着时开麦 = 点一下「❝ 引用」(2026-10-09 定的行为)──
5771
+ // ① 只在引用浮标亮着时才问麦克风;② 开麦 → 引用挂进来 + 光标进小窗输入框;
5772
+ // ③ **不弹语音卡**(卡片是"面板没开、字没处落"的产物);④ 同一次录音只处理一次;
5773
+ // ⑤ 别处正在打字 → 不加引用、也不抢焦点。
5774
+ {
5775
+ const recording2 = {
5776
+ ok: true,
5777
+ available: true,
5778
+ capturing: true,
5779
+ processes: [{ pid: 513, bundleId: 'com.bytedance.inputmethod.doubaoime', name: 'DoubaoIme' }],
5780
+ reason: '',
5781
+ }
5782
+ const stopped2 = { ok: true, available: true, capturing: false, processes: [], reason: '' }
5783
+ // 选区桩本段自带:上面两族的 selectText / selectOn 都是块内作用域,这里够不着。
5784
+ // textEl 是模块级的(划词那一段就在用它),所以直接搭在它上面。
5785
+ const quoteRect9 = { left: 40, top: 620, right: 320, bottom: 640, width: 280, height: 20 }
5786
+ const selectDoc = (text) => {
5787
+ const range = {
5788
+ startContainer: textEl,
5789
+ endContainer: textEl,
5790
+ cloneRange: () => range,
5791
+ getClientRects: () => [quoteRect9],
5792
+ getBoundingClientRect: () => quoteRect9,
5793
+ }
5794
+ windowStub.getSelection = () => ({
5795
+ isCollapsed: false,
5796
+ rangeCount: 1,
5797
+ toString: () => text,
5798
+ getRangeAt: () => range,
5799
+ })
5800
+ }
5801
+ const priorSelection = windowStub.getSelection
5802
+ micStub.value = stopped2
5803
+ hook.close()
5804
+ await sleep(40)
5805
+ hook.open('voice-quote-probe', '开麦引用的上下文片段', '开麦引用')
5806
+ for (let i = 0; i < 120 && hook.state().phase !== 'done'; i += 1) await sleep(20)
5807
+ assert('(前提)开麦引用用例的首轮出完', hook.state().phase === 'done', hook.state().phase)
5808
+ assert('(前提)引用区是空的', hook.quotes().length === 0, JSON.stringify(hook.quotes().map((q) => q.text)))
5809
+
5810
+ const focusHits = { count: 0 }
5811
+ const realAskFocus3 = askBox.focus
5812
+ askBox.focus = () => {
5813
+ focusHits.count += 1
5814
+ }
5815
+
5816
+ // ① 没有引用浮标 → 不问麦克风(探针只在被问时活着)
5817
+ const hitsIdle = micStub.hits
5818
+ await sleep(520)
5819
+ assert('没有引用浮标时**不**轮询麦克风(省掉常驻探针)', micStub.hits === hitsIdle, `${hitsIdle} → ${micStub.hits}`)
5820
+
5821
+ // ② 浮标亮着 → 开始问
5822
+ selectDoc('这半句是要被引用的')
5823
+ documentStub.dispatch('mouseup', { target: textEl })
5824
+ await sleep(30)
5825
+ assert('(前提)引用浮标亮着', hook.quoteState().visible === true, JSON.stringify(hook.quoteState()))
5826
+ const hitsFloat = micStub.hits
5827
+ await sleep(520)
5828
+ assert('引用浮标亮着时才问麦克风', micStub.hits > hitsFloat, `${hitsFloat} → ${micStub.hits}`)
5829
+
5830
+ // ③ 开麦 → 引用挂进来 + 光标进小窗输入框 + 不弹语音卡
5831
+ micStub.value = recording2
5832
+ await sleep(320)
5833
+ const autoQuotes = hook.quotes()
5834
+ assert(
5835
+ '开麦 → 选中的那段自动挂进引用(= 点一下引用浮标)',
5836
+ autoQuotes.length === 1 && autoQuotes[0].text === '这半句是要被引用的' && autoQuotes[0].label === '主界面选中',
5837
+ JSON.stringify(autoQuotes),
5838
+ )
5839
+ assert('开麦 → 引用浮标自己收了("已经进去了"的信号)', hook.quoteState().visible === false, JSON.stringify(hook.quoteState()))
5840
+ assert('开麦 → 光标进了小窗输入框(输入法上屏的字落在这儿)', focusHits.count > 0, 'focus 次数=' + focusHits.count)
5841
+ assert('开麦 → **不**弹语音卡(面板开着时字直接落进小窗)', hook.voiceCard().open === false, JSON.stringify(hook.voiceCard()))
5842
+ assert('开麦 → 状态里交代了这次引用', /已加入引用/.test(String(hook.state().status)), String(hook.state().status))
5843
+
5844
+ // ④ 同一次录音里只处理一次(200ms 一拍,不抑制会反复加 / 反复抢焦点)
5845
+ const focusAfterAuto = focusHits.count
5846
+ await sleep(340)
5847
+ assert(
5848
+ '同一次录音只处理一次(不反复加引用、不反复抢焦点)',
5849
+ hook.quotes().length === 1 && focusHits.count === focusAfterAuto,
5850
+ `引用=${hook.quotes().length} focus=${focusAfterAuto}→${focusHits.count}`,
5851
+ )
5852
+
5853
+ // ⑤ 别处正在打字 → 不抢(守卫与语音卡那条完全一致)
5854
+ micStub.value = stopped2
5855
+ await sleep(320) // 这一轮结束:抑制复位
5856
+ const otherBox = documentStub.createElement('textarea')
5857
+ documentStub.activeElement = otherBox
5858
+ selectDoc('别处打字时不该被抢')
5859
+ documentStub.dispatch('mouseup', { target: textEl })
5860
+ await sleep(30)
5861
+ assert('(前提)浮标又亮了', hook.quoteState().visible === true, JSON.stringify(hook.quoteState()))
5862
+ const focusBeforeGuard = focusHits.count
5863
+ micStub.value = recording2
5864
+ await sleep(320)
5865
+ assert('别处正在打字:不加引用', hook.quotes().length === 1, JSON.stringify(hook.quotes().map((q) => q.text)))
5866
+ assert(
5867
+ '别处正在打字:不抢焦点(人家的听写不能被拽进小窗)',
5868
+ focusHits.count === focusBeforeGuard,
5869
+ `focus=${focusBeforeGuard}→${focusHits.count}`,
5870
+ )
5871
+
5872
+ // ⑥ 自动引用**做过一次之后**,机制还得活着(不能因为抑制标记没复位就"下次不响应了")
5873
+ // 这一步专门挡那条回归:交接后浮标收起 → 轮询停 → 若复位只挂在"轮到没在录"那一拍上,
5874
+ // 标记会一直 true,下一次开麦什么都不发生(实测就是这么静默失效的)。
5875
+ micStub.value = stopped2
5876
+ documentStub.activeElement = body
5877
+ selectDoc('第二次开麦也要能自动引用')
5878
+ documentStub.dispatch('mouseup', { target: textEl })
5879
+ await sleep(30)
5880
+ assert('(前提)浮标第三次亮起', hook.quoteState().visible === true, JSON.stringify(hook.quoteState()))
5881
+ const focusBeforeSecond = focusHits.count
5882
+ micStub.value = recording2
5883
+ await sleep(340)
5884
+ const second = hook.quotes()
5885
+ assert(
5886
+ '上一次自动引用之后,下一次开麦照样生效(抑制标记跟着轮询复位)',
5887
+ second.length === 2 && second[1].text === '第二次开麦也要能自动引用' && focusHits.count > focusBeforeSecond,
5888
+ JSON.stringify({ quotes: second.map((q) => q.text), focus: `${focusBeforeSecond}→${focusHits.count}` }),
5889
+ )
5890
+
5891
+ // ⑦ **一口气说不停**时换一段引用也要能生效:麦克风一直没松(探针一直报"在录"),
5892
+ // 这时若"这次录音处理过没有"的标记只在"轮到没在录"那一拍才复位,就永远没机会复位 ——
5893
+ // 下一次划词纵有浮标也毫无反应。这条用例专门盯那个死锁(撤掉 stopMicWatch 里的复位就会红)。
5894
+ // ⚠️ 这里**不能**断言"浮标亮着":麦克风本来就开着,浮标一亮就会被这一拍立刻收走
5895
+ // (动作比断言快 —— 实测就是"前提"红、主断言绿)。直接看结果。
5896
+ const hitsBeforeThird = micStub.hits
5897
+ selectDoc('一直说不停时换的这段引用')
5898
+ documentStub.dispatch('mouseup', { target: textEl })
5899
+ await sleep(360)
5900
+ assert('(前提)换选区后轮询确实又跑起来了', micStub.hits > hitsBeforeThird, `${hitsBeforeThird} → ${micStub.hits}`)
5901
+ const third = hook.quotes()
5902
+ assert(
5903
+ '麦克风一直没松、这时换一段引用:照样自动挂进来(不靠"没在录"那一拍复位)',
5904
+ third.length === 3 && third[2].text === '一直说不停时换的这段引用',
5905
+ JSON.stringify({ quotes: third.map((q) => q.text), mic: micStub.value.capturing }),
5906
+ )
5907
+
5908
+ // 收尾:焦点 / 麦克风 / 选区 / 面板都还原,别影响后面的用例
5909
+ askBox.focus = realAskFocus3
5910
+ micStub.value = stopped2
5911
+ documentStub.activeElement = body
5912
+ windowStub.getSelection = priorSelection
5913
+ hook.close()
5914
+ await sleep(40)
5915
+ }
5916
+
5917
+ // ── ⑩ 侧边栏网页里的选区也能弹卡(不透明源 iframe:父页面**没有**那段 Range)──
5918
+ // 撤掉修复就会红的地方:
5919
+ // ① `openVCard` 原来第一句就是 `!state.selection.range → return`,帧内选区永远弹不出卡;
5920
+ // ② 几何原来只认 Range,桥给的锚点矩形用不上;
5921
+ // ③ 卡片一抢焦点,帧内选区就塌(输入法上屏要的正是焦点)→ 桥报 clear;
5922
+ // 若照旧清掉 state.selection,"发送"和"说完了没有"两条都会断(轮询也会停);
5923
+ // ④ 发送时不能读顶层 DOM(读不到那个帧),要用桥采好的上下文;
5924
+ // ⑤ 帧内按下(父页面收不到那个 mousedown)也要收卡。
5925
+ {
5926
+ const priorQuerySelectorAll = documentStub.querySelectorAll
5927
+ const frame = new FakeEl('iframe')
5928
+ frame.setAttribute('data-html-preview', 'true')
5929
+ frame.setAttribute('sandbox', 'allow-scripts')
5930
+ frame.setAttribute('srcdoc', '<!doctype html><html><body><p>网页里的正文</p></body></html>')
5931
+ frame.isConnected = true
5932
+ const frameWindow = { name: 'voice-card-sidebar-frame' }
5933
+ frame.contentWindow = frameWindow
5934
+ documentStub.querySelectorAll = (selector) => (selector === 'iframe' ? [frame] : [])
5935
+ hook.bridgeScan()
5936
+ assert('(前提)这一帧被桥上(不然下面那条消息不会被认)', frame.getAttribute('data-dsh-sel-bridged') === '1', String(frame.getAttribute('data-dsh-sel-bridged')))
5937
+
5938
+ const IME3 = [{ pid: 513, bundleId: 'com.bytedance.inputmethod.doubaoime', name: 'DoubaoIme' }]
5939
+ const recording3 = { ok: true, available: true, capturing: true, processes: IME3, reason: '' }
5940
+ const stopped3 = { ok: true, available: true, capturing: false, processes: [], reason: '' }
5941
+ hook.close()
5942
+ await sleep(20)
5943
+ documentStub.activeElement = body
5944
+ micStub.value = recording3
5945
+ windowStub.dispatch('message', {
5946
+ source: frameWindow,
5947
+ data: {
5948
+ __dshSel: 1,
5949
+ kind: 'selection',
5950
+ sel: {
5951
+ text: '网页里选中的这段',
5952
+ context: '前文【网页里选中的这段】后文',
5953
+ keyContext: '前文',
5954
+ label: '',
5955
+ // 帧内坐标(帧自己的 getBoundingClientRect 是桩的 40/100)→ 视口 50/120..160/138
5956
+ rect: { x: 10, y: 20, right: 120, bottom: 38, w: 110, h: 18 },
5957
+ },
5958
+ },
5959
+ })
5960
+ assert('(前提)浮标出现在网页选区处', button.style.display === 'inline-flex', String(button.style.display))
5961
+ await sleep(300)
5962
+ const frameCard = hook.voiceCard()
5963
+ assert('侧边栏网页里的选区也能弹卡(不再被 range 守卫挡掉)', frameCard.open === true, JSON.stringify(frameCard))
5964
+ assert(
5965
+ '卡片按桥给的锚点矩形定位(帧偏移 40/100 + 帧内坐标 → 左 8 / 上 160)',
5966
+ !!frameCard.geometry && frameCard.geometry.left === '8px' && frameCard.geometry.top === '160px',
5967
+ JSON.stringify(frameCard.geometry),
5968
+ )
5969
+ // 帧内那条:逐行矩形拿不到(父页面碰不到那个 Range),所以**只画牵引线、不画下划线** ——
5970
+ // 画下划线会与帧内可能还在的原生底纹"划两层"。
5971
+ const frameThread = find('dsh-sel-thread')
5972
+ const framePath = find('dsh-sel-threadpath')
5973
+ assert('帧内选区不画下划线(那需要逐行矩形,而且有"划两层"的风险)', !find('dsh-sel-uline'), String(!!find('dsh-sel-uline')))
5974
+ assert(
5975
+ '但牵引线留着:从锚点矩形竖直指向卡片(M105 140 = 选区末行中点)',
5976
+ !!frameThread && frameThread.style.display === 'block' && !!framePath && String(framePath.getAttribute('d')).indexOf('M105 140') === 0,
5977
+ framePath ? String(framePath.getAttribute('d')).slice(0, 40) : 'no path',
5978
+ )
5979
+
5980
+ // 抢焦点 → 帧内选区塌掉 → 桥报 clear:**卡片开着时不能清选区**
5981
+ windowStub.dispatch('message', { source: frameWindow, data: { __dshSel: 1, kind: 'clear' } })
5982
+ assert(
5983
+ '卡片开着时帧内报 clear:卡片不关、选区不清(发送与轮询都还要用)',
5984
+ hook.voiceCard().open === true && hook.selection() !== null,
5985
+ JSON.stringify({ card: hook.voiceCard().open, sel: hook.selection() }),
5986
+ )
5987
+ assert('麦克风轮询还在(靠它判断"说完了没有")', hook.voiceCard().watching === true, JSON.stringify(hook.voiceCard()))
5988
+
5989
+ // 帧内重报(滚动 / 挪选区):跟着挪卡片,不要又冒出浮标
5990
+ windowStub.dispatch('message', {
5991
+ source: frameWindow,
5992
+ data: { __dshSel: 1, kind: 'selection', sel: { text: '网页里选中的这段', context: '前文【网页里选中的这段】后文', keyContext: '前文', label: '', rect: { x: 10, y: 60, right: 120, bottom: 78, w: 110, h: 18 } } },
5993
+ })
5994
+ const moved = hook.voiceCard()
5995
+ assert(
5996
+ '帧内重报:卡片跟着挪(锚点 120→160 → top 160→200),浮标不冒出来',
5997
+ moved.open === true && !!moved.geometry && moved.geometry.top === '200px' && button.style.display === 'none',
5998
+ JSON.stringify({ top: moved.geometry && moved.geometry.top, pill: button.style.display }),
5999
+ )
6000
+
6001
+ // 说话 → 发送:上下文用桥采好的那份
6002
+ if (box) {
6003
+ box.value = '这段在说什么?'
6004
+ box.dispatch('input', { target: box, preventDefault() {}, stopPropagation() {} })
6005
+ }
6006
+ hook.voiceCardSend()
6007
+ await sleep(60)
6008
+ const framePayload = hook.payload()
6009
+ assert(
6010
+ '发送后用桥采好的上下文(顶层 DOM 里根本没有这段文字)',
6011
+ !!framePayload && framePayload.context === '前文【网页里选中的这段】后文',
6012
+ framePayload && framePayload.context,
6013
+ )
6014
+ assert(
6015
+ '发送后卡片收起、面板开着',
6016
+ hook.voiceCard().open === false && panel.style.display !== 'none',
6017
+ JSON.stringify({ card: hook.voiceCard().open, panel: panel.style.display }),
6018
+ )
6019
+
6020
+ // 帧内按一下 = 收卡(父页面收不到那个 mousedown,只能靠桥的 press)
6021
+ hook.close()
6022
+ await sleep(20)
6023
+ windowStub.dispatch('message', {
6024
+ source: frameWindow,
6025
+ data: { __dshSel: 1, kind: 'selection', sel: { text: '第二段网页文字', context: '【第二段网页文字】', keyContext: '', label: '', rect: { x: 10, y: 20, right: 120, bottom: 38, w: 110, h: 18 } } },
6026
+ })
6027
+ await sleep(300)
6028
+ assert('(前提)卡片又弹出来了', hook.voiceCard().open === true, JSON.stringify(hook.voiceCard()))
6029
+ windowStub.dispatch('message', { source: frameWindow, data: { __dshSel: 1, kind: 'press' } })
6030
+ const afterPress = hook.voiceCard()
6031
+ assert('帧内按下:卡片收掉(并播关机动画)', afterPress.open === false && afterPress.closing === true, JSON.stringify(afterPress))
6032
+ await sleep(800) // 桩里没有 animationend,走 640ms 兜底
6033
+
6034
+ // 收尾:选区清掉、帧从扫描里摘掉、麦克风复位
6035
+ windowStub.dispatch('message', { source: frameWindow, data: { __dshSel: 1, kind: 'clear' } })
6036
+ assert('卡片收起后帧内的 clear 照旧生效(选区状态收干净)', hook.selection() === null, JSON.stringify(hook.selection()))
6037
+ frame.isConnected = false
6038
+ micStub.value = stopped3
6039
+ documentStub.activeElement = body
6040
+ documentStub.querySelectorAll = priorQuerySelectorAll
6041
+ hook.close()
6042
+ await sleep(40)
6043
+ }
6044
+
6045
+ micStub.value = { ok: true, available: false, capturing: false, processes: [], reason: 'stub-default' }
6046
+ hook.close()
6047
+ await sleep(20)
6048
+ }
6049
+
4802
6050
  for (const dispose of disposers.reverse()) {
4803
6051
  try {
4804
6052
  dispose()
@@ -4806,6 +6054,57 @@ for (const dispose of disposers.reverse()) {
4806
6054
  console.log('cleanup 抛错:', error && error.message)
4807
6055
  }
4808
6056
  }
6057
+ // ────────── 拖拽上限 = 视口高度 − 8,不是"上次关闭小窗之前的高度" ──────────
6058
+ // 踩过的坑:开窗时把"记住的高度"写成 max-height 当天花板,而拖拽只写 height、不抬天花板 →
6059
+ // 拖大的部分被那条旧天花板直接截掉。取证:maxHeight 仍停在开窗时的那条 300px。
6060
+ {
6061
+ sizeStore = { w: 380, h: 300 } // 造一个很小的"上次记住的高度"
6062
+ hook.close()
6063
+ await sleep(40)
6064
+ hook.open('size-ceiling-probe', '', '尺寸天花板用例')
6065
+ for (let i = 0; i < 80 && hook.state().phase !== 'done'; i += 1) await sleep(20)
6066
+ await sleep(30)
6067
+ // 注:这里**不**断言"开窗时已应用记住的高度" —— loadPanelSize 整页只请求一次,
6068
+ // 而它在本次用例跑之前(sizeStore 还是 null 时)就已经标记"问过了",桩里造不出这个前提。
6069
+ // 好在下面的断言本身就能抓住这个 bug:没有修复时拖拽只写 height、不抬 max-height,
6070
+ // maxHeight 会停在开窗时的值(或为空),dragH > 300 直接不成立。
6071
+ const grip = Array.from(walk(panel)).find((n) => String(n.className).indexOf('dsh-sel-grip-corner') >= 0)
6072
+ assert('(前提)找得到右下角拖拽手柄', !!grip, String(grip && grip.className))
6073
+ grip.dispatch('pointerdown', { button: 0, clientX: 0, clientY: 0, pointerId: 1, preventDefault() {}, stopPropagation() {} })
6074
+ windowStub.dispatch('pointermove', { clientX: 0, clientY: 800 })
6075
+ const dragH = parseInt(panel.style.maxHeight, 10)
6076
+ assert('拖高之后天花板跟着抬高(不再被旧高度卡住)', dragH > 300, String(panel.style.maxHeight))
6077
+ assert('上限是视口高度 − 8', dragH <= 900 - 8, String(panel.style.maxHeight))
6078
+ assert('拖出来的高度当场生效(height 与天花板一致)', panel.style.height === panel.style.maxHeight, JSON.stringify({ h: panel.style.height, max: panel.style.maxHeight }))
6079
+ windowStub.dispatch('pointerup', {})
6080
+ await sleep(380) // 等防抖存档落盘(走桩,不会写进真实 host)
6081
+ grip.dispatch('dblclick', { preventDefault() {}, stopPropagation() {} })
6082
+ await sleep(40)
6083
+ assert('双击手柄恢复默认(天花板清空)', panel.style.maxHeight === '', JSON.stringify(panel.style.maxHeight))
6084
+ sizeStore = null
6085
+ }
6086
+
6087
+ // ⚠️ 放在**最后**:这一节前面/中间还有别的用例会写模型设置,早了会被覆盖掉。
6088
+ // 还原真实配置:这一段跑在"用户正在用的插件"上时,把进本节之前的快照写回去。
6089
+ // 隔离宿主(test-settings-host.mjs)下也无害 —— 那份宿主本来就是一次性的。
6090
+ {
6091
+ const values = (settingsSnapshot && settingsSnapshot.values) || {}
6092
+ // 只发有值的、且不是虚拟键的(虚拟键会被宿主拒掉,返回 rejected 里,无害但还是别发)
6093
+ const restore = {}
6094
+ Object.keys(values).forEach((key) => {
6095
+ if (key === 'modelChoice') return
6096
+ if (values[key] === undefined || values[key] === null) return
6097
+ restore[key] = values[key]
6098
+ })
6099
+ if (Object.keys(restore).length) {
6100
+ await fetch(ORIGIN + '/selection-explain/api/settings', {
6101
+ method: 'POST',
6102
+ headers: { 'content-type': 'application/json' },
6103
+ body: JSON.stringify({ values: restore }),
6104
+ })
6105
+ }
6106
+ }
6107
+
4809
6108
  assert('清理后 DOM 归零', mount.children.length === 0 && body.children.indexOf(container) >= 0)
4810
6109
  assert('清理后划词桥自己建的预览 blob 被回收', bridgeOwnedBlob !== '' && !blobStore.has(bridgeOwnedBlob), bridgeOwnedBlob)
4811
6110
  assert('清理后钩子移除', windowStub.__dshSelectionExplain === undefined)