@yfwu2020/dsh-selection-explain 0.1.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.
@@ -0,0 +1,2486 @@
1
+ /**
2
+ * 划词解读 client bundle 的无浏览器集成测试。
3
+ * - 真实:lib/client.js 源文件、真实 host 路由(/selection-explain/api/analyze,真实 LLM 流)
4
+ * - 打桩:最小 DOM(够 renderRich / 定位 / 选区用)、React.createElement、shell.overlay 槽
5
+ * 覆盖:apply → 槽注册 → 划词浮标 → 点击 → SSE 流式渲染 → 两节内容 → 清理
6
+ *
7
+ * 用法:node scripts/test-client.mjs [lib/client.js 路径]
8
+ * SEL_ORIGIN=http://127.0.0.1:3080 node scripts/test-client.mjs # host 已在跑
9
+ * 退出码:全部 PASS = 0。
10
+ */
11
+ import { readFileSync } from 'node:fs'
12
+ import { dirname, resolve } from 'node:path'
13
+ import { fileURLToPath } from 'node:url'
14
+
15
+ const HERE = dirname(fileURLToPath(import.meta.url))
16
+ const BUNDLE = process.argv[2] || resolve(HERE, '..', 'lib', 'client.js')
17
+
18
+ // ───────────────────────── 最小 DOM ─────────────────────────
19
+ function textOf(node) {
20
+ if (!node) return ''
21
+ let out = node._text || ''
22
+ for (const child of node.children || []) out += textOf(child)
23
+ return out
24
+ }
25
+
26
+ class FakeEl {
27
+ constructor(tag) {
28
+ this.tagName = String(tag).toUpperCase()
29
+ // 真实 DOM 里元素是 1、文本是 3:插件里有 target.nodeType 之类的判断,桩要跟上
30
+ this.nodeType = String(tag) === '#text' ? 3 : 1
31
+ this.children = []
32
+ this.style = {}
33
+ this.dataset = {}
34
+ this.attrs = {}
35
+ this._text = ''
36
+ this.parentNode = null
37
+ this.className = ''
38
+ this.offsetWidth = 0
39
+ this.offsetHeight = 0
40
+ this.scrollTop = 0
41
+ this.scrollHeight = 0
42
+ this.clientHeight = 0
43
+ this._listeners = new Map()
44
+ }
45
+ appendChild(child) {
46
+ if (child.parentNode) child.parentNode.removeChild(child)
47
+ child.parentNode = this
48
+ this.children.push(child)
49
+ return child
50
+ }
51
+ removeChild(child) {
52
+ const i = this.children.indexOf(child)
53
+ if (i >= 0) this.children.splice(i, 1)
54
+ child.parentNode = null
55
+ return child
56
+ }
57
+ contains(node) {
58
+ let cur = node
59
+ while (cur) {
60
+ if (cur === this) return true
61
+ cur = cur.parentNode
62
+ }
63
+ return false
64
+ }
65
+ setAttribute(key, value) {
66
+ this.attrs[key] = String(value)
67
+ }
68
+ removeAttribute(key) {
69
+ delete this.attrs[key]
70
+ }
71
+ getAttribute(key) {
72
+ return this.attrs[key] ?? null
73
+ }
74
+ addEventListener(type, handler) {
75
+ if (!this._listeners.has(type)) this._listeners.set(type, [])
76
+ this._listeners.get(type).push(handler)
77
+ }
78
+ removeEventListener(type, handler) {
79
+ const list = this._listeners.get(type) || []
80
+ const i = list.indexOf(handler)
81
+ if (i >= 0) list.splice(i, 1)
82
+ }
83
+ dispatch(type, event) {
84
+ for (const handler of this._listeners.get(type) || []) handler(event)
85
+ }
86
+ getBoundingClientRect() {
87
+ return { left: 40, top: 100, right: 500, bottom: 400, width: 460, height: 300 }
88
+ }
89
+ getClientRects() {
90
+ return [{ left: 40, top: 100, right: 200, bottom: 118, width: 160, height: 18 }]
91
+ }
92
+ get textContent() {
93
+ return textOf(this)
94
+ }
95
+ set textContent(value) {
96
+ this.children = []
97
+ this._text = value === undefined || value === null ? '' : String(value)
98
+ }
99
+ get innerText() {
100
+ return textOf(this)
101
+ }
102
+ querySelector(selector) {
103
+ // 只认 .class 选择器(够 placePill 量费用胶囊用);真实 DOM 的行为由浏览器实测覆盖
104
+ const want = typeof selector === 'string' && selector.startsWith('.') ? selector.slice(1) : null
105
+ if (!want) return null
106
+ for (const node of walk(this)) {
107
+ if (String(node.className || '').split(/\s+/).includes(want)) return node
108
+ }
109
+ return null
110
+ }
111
+ focus() {}
112
+ select() {}
113
+ }
114
+
115
+ const head = new FakeEl('head')
116
+ const body = new FakeEl('body')
117
+ const documentStub = {
118
+ head,
119
+ body,
120
+ title: 'stub-doc',
121
+ _listeners: new Map(),
122
+ createElement: (tag) => new FakeEl(tag),
123
+ createElementNS: (_ns, tag) => new FakeEl(tag),
124
+ createTextNode: (text) => {
125
+ const node = new FakeEl('#text')
126
+ node._text = String(text)
127
+ return node
128
+ },
129
+ addEventListener(type, handler) {
130
+ if (!this._listeners.has(type)) this._listeners.set(type, [])
131
+ this._listeners.get(type).push(handler)
132
+ },
133
+ removeEventListener(type, handler) {
134
+ const list = this._listeners.get(type) || []
135
+ const i = list.indexOf(handler)
136
+ if (i >= 0) list.splice(i, 1)
137
+ },
138
+ dispatch(type, event) {
139
+ for (const handler of [...(this._listeners.get(type) || [])]) handler(event)
140
+ },
141
+ // 默认没有费用胶囊;测试里挂上假的(globalThis.__fakeSpendWidget)后就能被量到。
142
+ // 真实 DOM 是 id="dsh-spend-widget"(无 class)> .dsu-widget > .dsu-pill,这里两种都认。
143
+ querySelector: (selector) =>
144
+ selector === '.dsu-widget' || selector === '.dsh-spend-widget' ? globalThis.__fakeSpendWidget || null : null,
145
+ getElementById: (id) => (id === 'dsh-spend-widget' ? globalThis.__fakeSpendWidget || null : null),
146
+ createRange() {
147
+ return {
148
+ _node: null,
149
+ _end: null,
150
+ selectNodeContents(node) {
151
+ this._node = node
152
+ },
153
+ setEnd(node, offset) {
154
+ this._end = { node, offset }
155
+ },
156
+ cloneRange() {
157
+ const clone = { ...this, cloneRange: this.cloneRange }
158
+ return Object.assign(Object.create(Object.getPrototypeOf(this)), this)
159
+ },
160
+ toString() {
161
+ const full = textOf(this._node)
162
+ return this._end ? full.slice(0, this._end.offset) : full
163
+ },
164
+ getClientRects: () => [{ left: 40, top: 100, right: 200, bottom: 118, width: 160, height: 18 }],
165
+ getBoundingClientRect: () => ({ left: 40, top: 100, right: 200, bottom: 118, width: 160, height: 18 }),
166
+ }
167
+ },
168
+ }
169
+
170
+ /** ResizeObserver 桩:只记录回调,测试里手动 fire(触发时机由浏览器决定,逻辑在这里验)。 */
171
+ const resizeObservers = []
172
+ class ResizeObserverStub {
173
+ constructor(cb) {
174
+ this.cb = cb
175
+ resizeObservers.push(this)
176
+ }
177
+ observe() {}
178
+ disconnect() {
179
+ this.disconnected = true
180
+ }
181
+ fire() {
182
+ if (!this.disconnected) this.cb([])
183
+ }
184
+ }
185
+
186
+ const windowStub = {
187
+ innerWidth: 1440,
188
+ innerHeight: 900,
189
+ _listeners: new Map(),
190
+ addEventListener(type, handler) {
191
+ if (!this._listeners.has(type)) this._listeners.set(type, [])
192
+ this._listeners.get(type).push(handler)
193
+ },
194
+ removeEventListener(type, handler) {
195
+ const list = this._listeners.get(type) || []
196
+ const i = list.indexOf(handler)
197
+ if (i >= 0) list.splice(i, 1)
198
+ },
199
+ dispatch(type, event) {
200
+ for (const handler of [...(this._listeners.get(type) || [])]) handler(event)
201
+ },
202
+ localStorage: {
203
+ _map: new Map(),
204
+ getItem(key) {
205
+ return this._map.has(key) ? this._map.get(key) : null
206
+ },
207
+ setItem(key, value) {
208
+ this._map.set(key, String(value))
209
+ },
210
+ },
211
+ getSelection: () => null,
212
+ __ModuleLoader__: { load: (spec) => { globalThis.__captured = spec } },
213
+ }
214
+
215
+ // bundle 通过 new Function 注入这些全局(Node 24 的 navigator 是只读 getter,不能挂 globalThis)
216
+ const navigatorStub = {}
217
+
218
+ // ───────────────────────── 载入 bundle ─────────────────────────
219
+ const source = readFileSync(BUNDLE, 'utf8')
220
+ globalThis.ResizeObserver = ResizeObserverStub
221
+ const ORIGIN = process.env.SEL_ORIGIN || 'http://127.0.0.1:3080'
222
+ const sent = []
223
+ const promoted = []
224
+ /** A′:小窗历史的内存版存储(测试用)。 */
225
+ const historyStore = new Map()
226
+ const historySaved = []
227
+ let historyList = []
228
+ const openedSessions = []
229
+ /** 散文样本(固定 fixture,避免依赖真实网络/缓存命中)。 */
230
+ const PROSE_PROBE = 'the migration ran long, so we ship Wednesday'
231
+
232
+ /** Python 代码选区用例(验证语言标记影响高亮规则)。 */
233
+ const PY_SNIPPET = ['def add(a, b):', ' # 这里是可以相加的数字', ' return a + b'].join('\n')
234
+
235
+ /** 代码选区用例:真实的代码形状(会被 looksLikeCode 判成 code)。 */
236
+ const CODE_SNIPPET = [
237
+ 'const MAX = 4000',
238
+ 'function clampText(text) {',
239
+ ' return text.length > MAX ? text.slice(0, MAX) : text;',
240
+ '}',
241
+ ].join('\n')
242
+
243
+ /** 渲染专项测试的固定模型输出(不经过真实 LLM,保证断言稳定)。 */
244
+ const RENDER_FIXTURE = [
245
+ '## 翻译',
246
+ '- **slipped** /slɪpt/ 英 /slɪpt/ 美',
247
+ ' - **v.** 滑落;滑倒(过去式)',
248
+ ' - **adj.** 被延误的',
249
+ '- **slipped**:滑落;滑倒',
250
+ '- **slip**(动词):滑倒;滑落',
251
+ ' - 嵌套项:被延误、被错过',
252
+ '1. 有序第一项',
253
+ '2. 有序第二项',
254
+ '',
255
+ '### 小标题',
256
+ '正文段落,含 **加粗** 与 `code`。',
257
+ '',
258
+ '> 在本句中:(期限)被错过、被推迟',
259
+ '',
260
+ '## 详解',
261
+ '- **关键词**:在上下文里的具体所指(详见 [官方文档](https://example.com/docs))',
262
+ '| 说法 | 在此处的含义 |',
263
+ '| --- | --- |',
264
+ '| the migration | 发布前那次数据迁移 |',
265
+ '| ran long | 耗时超出预期 |',
266
+ '',
267
+ '```',
268
+ 'npm run build',
269
+ '```',
270
+ ].join('\n')
271
+
272
+ /**
273
+ * 让 AbortSignal 真的生效:真实 fetch 被 abort 时会取消 body 流,
274
+ * 而这里的手搓 Response 不会——不补这一层,"收起小窗 = 中止这一轮"就测不出来
275
+ * (客户端的 read() 永远不 reject,请求会一路跑完)。
276
+ */
277
+ function withAbort(response, signal) {
278
+ if (!response.body || signal.aborted) return response
279
+ const reader = response.body.getReader()
280
+ signal.addEventListener('abort', () => {
281
+ try {
282
+ reader.cancel().catch(() => {})
283
+ } catch {
284
+ /* noop */
285
+ }
286
+ })
287
+ const stream = new ReadableStream({
288
+ start(controller) {
289
+ let aborted = false
290
+ signal.addEventListener('abort', () => {
291
+ aborted = true
292
+ const error = new Error('The operation was aborted.')
293
+ error.name = 'AbortError'
294
+ try {
295
+ controller.error(error)
296
+ } catch {
297
+ /* noop */
298
+ }
299
+ })
300
+ const pump = () =>
301
+ reader
302
+ .read()
303
+ .then(({ done, value }) => {
304
+ if (aborted) return
305
+ if (done) {
306
+ try {
307
+ controller.close()
308
+ } catch {
309
+ /* noop */
310
+ }
311
+ return
312
+ }
313
+ controller.enqueue(value)
314
+ return pump()
315
+ })
316
+ .catch((error) => {
317
+ try {
318
+ controller.error(error)
319
+ } catch {
320
+ /* noop */
321
+ }
322
+ })
323
+ pump()
324
+ },
325
+ })
326
+ return new Response(stream, { headers: response.headers })
327
+ }
328
+
329
+ const routeFetch = (input, init) => {
330
+ const url = typeof input === 'string' ? input : ''
331
+ // 模型清单:用 fixture(真实 host 有 41 个模型,跑用例时不该依赖它)
332
+ if (url.indexOf('/selection-explain/api/models') >= 0) {
333
+ const body = globalThis.__modelCatalogFixture || { ok: true, current: null, stages: null, models: [] }
334
+ return Promise.resolve(new Response(JSON.stringify(body), { headers: { 'content-type': 'application/json' } }))
335
+ }
336
+ // 小窗历史(A′):内存版实现,避免打到真实 host 拿到脏历史
337
+ if (url.indexOf('/selection-explain/api/history') >= 0) {
338
+ if (init && init.method === 'DELETE') {
339
+ const m = /[?&]key=([^&]+)/.exec(url)
340
+ const key = m ? decodeURIComponent(m[1]) : ''
341
+ const entry = historyStore.get(key) || null
342
+ if (entry) historyStore.delete(key)
343
+ return Promise.resolve(new Response(JSON.stringify({ ok: true, removed: entry ? 1 : 0, entry }), { headers: { 'content-type': 'application/json' } }))
344
+ }
345
+ if (init && init.method === 'POST' && url.indexOf('restore=1') >= 0) {
346
+ const body = JSON.parse(String(init.body))
347
+ if (body && body.entry && body.entry.key) historyStore.set(body.entry.key, body.entry)
348
+ return Promise.resolve(new Response(JSON.stringify({ ok: true, size: historyStore.size }), { headers: { 'content-type': 'application/json' } }))
349
+ }
350
+ if (init && init.method === 'POST') {
351
+ const entry = JSON.parse(String(init.body))
352
+ historyStore.set(entry.key, { ...entry, at: Date.now() })
353
+ historySaved.push(entry)
354
+ return Promise.resolve(new Response(JSON.stringify({ ok: true, size: historyStore.size }), { headers: { 'content-type': 'application/json' } }))
355
+ }
356
+ const key = /[?&]key=([^&]+)/.exec(url)
357
+ if (key) {
358
+ const entry = historyStore.get(decodeURIComponent(key[1])) || null
359
+ return Promise.resolve(new Response(JSON.stringify({ ok: true, entry }), { headers: { 'content-type': 'application/json' } }))
360
+ }
361
+ const entries = Array.from(historyStore.values())
362
+ .sort((a, b) => (b.at || 0) - (a.at || 0))
363
+ .map((e) => ({ key: e.key, text: e.text, label: e.label, at: e.at, turns: (e.turns || []).filter((t) => t.role === 'user').length, hasDetail: !!(e.parts && e.parts.detail), pinned: !!e.pinned }))
364
+ historyList = entries
365
+ return Promise.resolve(new Response(JSON.stringify({ ok: true, entries }), { headers: { 'content-type': 'application/json' } }))
366
+ }
367
+ if (url.indexOf('/selection-explain/api/promote') >= 0) {
368
+ try {
369
+ promoted.push(JSON.parse(String(init && init.body)))
370
+ } catch (error) {
371
+ promoted.push({ parse_error: String(error) })
372
+ }
373
+ return Promise.resolve(
374
+ new Response(JSON.stringify({ ok: true, sessionId: 'session-promoted-1', cwd: '/tmp/project', chars: 42 }), {
375
+ headers: { 'content-type': 'application/json' },
376
+ }),
377
+ )
378
+ }
379
+ let body = null
380
+ if (init && init.body) {
381
+ try {
382
+ body = JSON.parse(String(init.body))
383
+ } catch (error) {
384
+ body = { parse_error: String(error) }
385
+ }
386
+ sent.push(body)
387
+ }
388
+ if (body && body.text === 'stage-probe') {
389
+ const only = body.stage === 'detail' ? '## 详解\nSTAGE-DETAIL' : '## 翻译\nSTAGE-TRANSLATION'
390
+ const payload = [
391
+ `data: ${JSON.stringify({ type: 'start', provider: 'fixture', model: 'fixture', stage: body.stage || 'translation' })}\n\n`,
392
+ `data: ${JSON.stringify({ type: 'delta', text: only })}\n\n`,
393
+ `data: ${JSON.stringify({ type: 'done', chars: only.length })}\n\n`,
394
+ ].join('')
395
+ return Promise.resolve(new Response(payload, { headers: { 'content-type': 'text/event-stream' } }))
396
+ }
397
+ // 慢速流:start 立刻回,250ms 一个空 delta(触发重绘)、450ms 才吐内容
398
+ // —— 用来验证"思考期不空窗"+"等待节点不被重绘打断"+"等待里走秒"
399
+ if (body && body.text === 'slow-probe' && !body.question) {
400
+ const encoder = new TextEncoder()
401
+ const stream = new ReadableStream({
402
+ async start(controller) {
403
+ controller.enqueue(
404
+ encoder.encode(`data: ${JSON.stringify({ type: 'start', provider: 'fixture', model: 'fixture', stage: 'translation', effort: 'high' })}\n\n`),
405
+ )
406
+ await new Promise((r) => setTimeout(r, 250))
407
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'thought', text: '先判断这段英文在句子里的角色……' })}\n\n`))
408
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'delta', text: '' })}\n\n`))
409
+ await new Promise((r) => setTimeout(r, 650))
410
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'delta', text: '## 翻译\n慢速翻译结果\n' })}\n\n`))
411
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done', chars: 8 })}\n\n`))
412
+ controller.close()
413
+ },
414
+ })
415
+ return Promise.resolve(new Response(stream, { headers: { 'content-type': 'text/event-stream' } }))
416
+ }
417
+ // 分三段、每段隔 60ms 的追问流:用来验证滚动跟随(贴底→跟随 / 翻上去→不拽回)
418
+ // 追问等待特效用例:首字前静默 450ms,用来观察气泡里的等待动画与走秒
419
+ if (body && body.question && body.text === 'wait-ask-probe') {
420
+ const encoder = new TextEncoder()
421
+ const stream = new ReadableStream({
422
+ async start(controller) {
423
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'start', provider: 'fixture', model: 'fixture', mode: 'chat', effort: 'low' })}\n\n`))
424
+ await new Promise((r) => setTimeout(r, 450))
425
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'delta', text: '等到了回答。' })}\n\n`))
426
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done' })}\n\n`))
427
+ controller.close()
428
+ },
429
+ })
430
+ return Promise.resolve(new Response(stream, { headers: { 'content-type': 'text/event-stream' } }))
431
+ }
432
+ if (body && body.text === 'wait-ask-probe' && !body.question) {
433
+ const payload = [
434
+ `data: ${JSON.stringify({ type: 'start', provider: 'fixture', model: 'fixture', stage: 'translation' })}\n\n`,
435
+ `data: ${JSON.stringify({ type: 'delta', text: '## 翻译\n等待用例的翻译\n' })}\n\n`,
436
+ `data: ${JSON.stringify({ type: 'done' })}\n\n`,
437
+ ].join('')
438
+ return Promise.resolve(new Response(payload, { headers: { 'content-type': 'text/event-stream' } }))
439
+ }
440
+ if (body && body.question && body.text === 'slow-ask-probe') {
441
+ const encoder = new TextEncoder()
442
+ const stream = new ReadableStream({
443
+ async start(controller) {
444
+ controller.enqueue(
445
+ encoder.encode(`data: ${JSON.stringify({ type: 'start', provider: 'fixture', model: 'fixture', mode: 'chat', effort: 'low' })}\n\n`),
446
+ )
447
+ for (const piece of ['第一段回答。', '第二段回答。', '第三段回答。']) {
448
+ await new Promise((r) => setTimeout(r, 60))
449
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'delta', text: piece })}\n\n`))
450
+ }
451
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done' })}\n\n`))
452
+ controller.close()
453
+ },
454
+ })
455
+ return Promise.resolve(new Response(stream, { headers: { 'content-type': 'text/event-stream' } }))
456
+ }
457
+ if (body && body.text === 'slow-ask-probe' && !body.question) {
458
+ const payload = [
459
+ `data: ${JSON.stringify({ type: 'start', provider: 'fixture', model: 'fixture', stage: 'translation' })}\n\n`,
460
+ `data: ${JSON.stringify({ type: 'delta', text: '## 翻译\n滚动用例的翻译\n' })}\n\n`,
461
+ `data: ${JSON.stringify({ type: 'done', chars: 8 })}\n\n`,
462
+ ].join('')
463
+ return Promise.resolve(new Response(payload, { headers: { 'content-type': 'text/event-stream' } }))
464
+ }
465
+ // 分块流式的网页回答:一次 delta 一小段(真实模型就是这样,之前一次给完的用例盖不住)
466
+ if (body && body.question === '分块网页追问') {
467
+ const encoder = new TextEncoder()
468
+ const page = '<!DOCTYPE html>\n<html><body><h1>分块渲染</h1><p>这段 HTML 被切成 6 段吐出来。</p></body></html>'
469
+ const stream = new ReadableStream({
470
+ async start(controller) {
471
+ const send = (o) => controller.enqueue(encoder.encode(`data: ${JSON.stringify(o)}\n\n`))
472
+ send({ type: 'start', provider: 'fixture', model: 'fixture', mode: 'chat', effort: 'high' })
473
+ send({ type: 'delta', text: '给你一个页面:\n\n```html\n' })
474
+ await new Promise((r) => setTimeout(r, 150))
475
+ const step = Math.ceil(page.length / 5)
476
+ for (let i = 0; i < page.length; i += step) {
477
+ send({ type: 'delta', text: page.slice(i, i + step) })
478
+ await new Promise((r) => setTimeout(r, 150))
479
+ }
480
+ send({ type: 'delta', text: '\n```\n' })
481
+ send({ type: 'done', chars: page.length })
482
+ controller.close()
483
+ },
484
+ })
485
+ return Promise.resolve(new Response(stream, { headers: { 'content-type': 'text/event-stream' } }))
486
+ }
487
+ // 慢速追问:分三段、每段 700ms,留出"按停止"的窗口
488
+ if (body && body.question === '慢速追问') {
489
+ const encoder = new TextEncoder()
490
+ const stream = new ReadableStream({
491
+ async start(controller) {
492
+ const send = (o) => controller.enqueue(encoder.encode(`data: ${JSON.stringify(o)}\n\n`))
493
+ send({ type: 'start', provider: 'fixture', model: 'fixture', mode: 'chat', effort: 'high' })
494
+ send({ type: 'delta', text: '第一段回答。' })
495
+ await new Promise((r) => setTimeout(r, 700))
496
+ send({ type: 'delta', text: '第二段回答。' })
497
+ await new Promise((r) => setTimeout(r, 700))
498
+ send({ type: 'delta', text: '第三段回答。' })
499
+ send({ type: 'done', chars: 15 })
500
+ controller.close()
501
+ },
502
+ })
503
+ return Promise.resolve(new Response(stream, { headers: { 'content-type': 'text/event-stream' } }))
504
+ }
505
+ // 追问里的"过程旁白":先吐英文盘算 → 调工具 → drop 撤回 → 再给中文结论
506
+ if (body && body.question === '旁白追问') {
507
+ const payload = [
508
+ `data: ${JSON.stringify({ type: 'start', provider: 'fixture', model: 'fixture', mode: 'chat', effort: 'high', tools: ['web_search'] })}\n\n`,
509
+ `data: ${JSON.stringify({ type: 'delta', text: 'Let me think about it and search again. ' })}\n\n`,
510
+ `data: ${JSON.stringify({ type: 'tool', phase: 'start', callId: 'n2', name: 'web_search', detail: '今日新闻' })}\n\n`,
511
+ `data: ${JSON.stringify({ type: 'tool', phase: 'done', callId: 'n2', name: 'web_search', detail: '今日新闻', ok: true, ms: 500, chars: 300, preview: 'x', urls: [] })}\n\n`,
512
+ `data: ${JSON.stringify({ type: 'drop', chars: 37, text: 'Let me think about it and search again.' })}\n\n`,
513
+ `data: ${JSON.stringify({ type: 'delta', text: '这是追问的正式回答。' })}\n\n`,
514
+ `data: ${JSON.stringify({ type: 'done', chars: 10, toolCalls: 1 })}\n\n`,
515
+ ].join('')
516
+ return Promise.resolve(new Response(payload, { headers: { 'content-type': 'text/event-stream' } }))
517
+ }
518
+ if (body && body.question) {
519
+ const extra = globalThis.__askExtra || {}
520
+ const payload = [
521
+ `data: ${JSON.stringify({ type: 'start', provider: 'fixture', model: 'fixture', mode: 'chat', effort: 'low' })}\n\n`,
522
+ ...(extra.notice && !extra.strip ? [`data: ${JSON.stringify({ type: 'notice', ...extra.notice })}\n\n`] : []),
523
+ `data: ${JSON.stringify({ type: 'delta', text: extra.delta || '这是对追问的回答:' + body.question })}\n\n`,
524
+ ...(extra.strip ? [`data: ${JSON.stringify({ type: 'strip', text: extra.strip })}\n\n`] : []),
525
+ ...(extra.notice && extra.strip ? [`data: ${JSON.stringify({ type: 'notice', ...extra.notice })}\n\n`] : []),
526
+ `data: ${JSON.stringify({ type: 'done', chars: 12, toolCalls: 0 })}\n\n`,
527
+ ].join('')
528
+ return Promise.resolve(new Response(payload, { headers: { 'content-type': 'text/event-stream' } }))
529
+ }
530
+ // 工具正在跑的那段时间(流式):用来验证"等待提示说在检索,但不泄露查询词/链接"
531
+ // 中止用例:首字很快到,但整轮要 2.5s——留出"收起小窗"的窗口
532
+ if (body && body.text === '中止探测') {
533
+ const encoder = new TextEncoder()
534
+ const stream = new ReadableStream({
535
+ async start(controller) {
536
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'start', provider: 'fixture', model: 'fixture', stage: 'translation' })}\n\n`))
537
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'delta', text: '## 翻译\n中止用例的开头\n' })}\n\n`))
538
+ await new Promise((r) => setTimeout(r, 2500))
539
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done', chars: 10 })}\n\n`))
540
+ controller.close()
541
+ },
542
+ })
543
+ return Promise.resolve(new Response(stream, { headers: { 'content-type': 'text/event-stream' } }))
544
+ }
545
+ // 「发过追问后 CTA 收起 / 换选区后 CTA 复位」专用:首轮只给翻译,stage=detail 时给详解
546
+ if (body && body.text === 'cta-reset-probe') {
547
+ const only = body.stage === 'detail' ? '## 详解\nCTA 复位用例的详解\n' : '## 翻译\nCTA 复位用例的翻译\n'
548
+ const payload = [
549
+ `data: ${JSON.stringify({ type: 'start', provider: 'fixture', model: 'fixture', stage: body.stage === 'detail' ? 'detail' : 'translation' })}\n\n`,
550
+ `data: ${JSON.stringify({ type: 'delta', text: only })}\n\n`,
551
+ `data: ${JSON.stringify({ type: 'done', chars: only.length })}\n\n`,
552
+ ].join('')
553
+ return Promise.resolve(new Response(payload, { headers: { 'content-type': 'text/event-stream' } }))
554
+ }
555
+ // 网页预览用例:先流一半(此时不该有 iframe),再补齐收尾
556
+ if (body && body.text === 'html-probe') {
557
+ const encoder = new TextEncoder()
558
+ const stream = new ReadableStream({
559
+ async start(controller) {
560
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'start', provider: 'fixture', model: 'fixture', stage: 'translation' })}\n\n`))
561
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'delta', text: '## 翻译\n给你一个页面:\n\n```html\n<!DOCTYPE html>\n<html><body><h1>Hello 预览</h1></body></html>\n' })}\n\n`))
562
+ await new Promise((r) => setTimeout(r, 400))
563
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'delta', text: '```\n\n```js\nconst a = 1\n```\n\n```\n<div>只是片段</div>\n```\n' })}\n\n`))
564
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done', chars: 120 })}\n\n`))
565
+ controller.close()
566
+ },
567
+ })
568
+ return Promise.resolve(new Response(stream, { headers: { 'content-type': 'text/event-stream' } }))
569
+ }
570
+ if (body && body.text === 'tool-live-probe') {
571
+ const encoder = new TextEncoder()
572
+ const stream = new ReadableStream({
573
+ async start(controller) {
574
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'start', provider: 'fixture', model: 'fixture', tools: ['web_search'] })}\n\n`))
575
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'tool', phase: 'start', callId: 'L1', name: 'web_search', detail: '检索中的查询词' })}\n\n`))
576
+ await new Promise((r) => setTimeout(r, 500))
577
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'tool', phase: 'done', callId: 'L1', name: 'web_search', detail: '检索中的查询词', ok: true, ms: 520, chars: 999, preview: '检索结果原文预览', urls: ['https://example.com/secret-source'] })}\n\n`))
578
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'delta', text: '## 解读\n模型消化后的结论\n' })}\n\n`))
579
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done', chars: 10, toolCalls: 1 })}\n\n`))
580
+ controller.close()
581
+ },
582
+ })
583
+ return Promise.resolve(new Response(stream, { headers: { 'content-type': 'text/event-stream' } }))
584
+ }
585
+ // 档位提示用例:start 报了 effort=max,然后静默 6s+(没有思考流)→ 提示行必须写 max
586
+ if (body && body.text === 'effort-hint-probe') {
587
+ const encoder = new TextEncoder()
588
+ const stream = new ReadableStream({
589
+ async start(controller) {
590
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'start', provider: 'fixture', model: 'fixture', effort: 'max', stage: 'translation' })}\n\n`))
591
+ // 提示行是"等过 6 秒"才写的,静默必须明显长于 6s,否则 done 会把它抢掉
592
+ await new Promise((r) => setTimeout(r, 8500))
593
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'delta', text: '## 翻译\n档位提示用例\n' })}\n\n`))
594
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done', chars: 10 })}\n\n`))
595
+ controller.close()
596
+ },
597
+ })
598
+ return Promise.resolve(new Response(stream, { headers: { 'content-type': 'text/event-stream' } }))
599
+ }
600
+ // 工具轮里的"过程旁白":先流一段英文盘算 → 调工具 → 发 drop 撤回 → 再给中文结论
601
+ if (body && body.text === 'narrate-probe') {
602
+ const payload = [
603
+ `data: ${JSON.stringify({ type: 'start', provider: 'fixture', model: 'fixture', tools: ['web_search'] })}\n\n`,
604
+ `data: ${JSON.stringify({ type: 'delta', text: 'I have sources but no content. Maybe I should search once more. ' })}\n\n`,
605
+ `data: ${JSON.stringify({ type: 'tool', phase: 'start', callId: 'n1', name: 'web_search', detail: '今日新闻' })}\n\n`,
606
+ `data: ${JSON.stringify({ type: 'tool', phase: 'done', callId: 'n1', name: 'web_search', detail: '今日新闻', ok: true, ms: 900, chars: 500, preview: '标题列表', urls: [] })}\n\n`,
607
+ `data: ${JSON.stringify({ type: 'drop', chars: 64, text: 'I have sources but no content. Maybe I should search once more. ' })}\n\n`,
608
+ `data: ${JSON.stringify({ type: 'delta', text: '## 翻译\n这是撤回旁白之后的正式结论。\n' })}\n\n`,
609
+ `data: ${JSON.stringify({ type: 'done', chars: 20, toolCalls: 1 })}\n\n`,
610
+ ].join('')
611
+ return Promise.resolve(new Response(payload, { headers: { 'content-type': 'text/event-stream' } }))
612
+ }
613
+ // drop 的 text 和真正流出去的对不上(老缓存/异常)时,必须退化成按字数撤
614
+ if (body && body.text === 'narrate-fallback-probe') {
615
+ const payload = [
616
+ `data: ${JSON.stringify({ type: 'start', provider: 'fixture', model: 'fixture', tools: ['web_search'] })}\n\n`,
617
+ `data: ${JSON.stringify({ type: 'delta', text: 'NARRATION-A NARRATION-B ' })}\n\n`,
618
+ `data: ${JSON.stringify({ type: 'tool', phase: 'start', callId: 'n3', name: 'web_search', detail: 'x' })}\n\n`,
619
+ `data: ${JSON.stringify({ type: 'tool', phase: 'done', callId: 'n3', name: 'web_search', detail: 'x', ok: true, ms: 10, chars: 1, preview: '', urls: [] })}\n\n`,
620
+ `data: ${JSON.stringify({ type: 'drop', chars: 24, text: '(对不上的文本)' })}\n\n`,
621
+ `data: ${JSON.stringify({ type: 'delta', text: '## 翻译\n兜底撤回后的结论。\n' })}\n\n`,
622
+ `data: ${JSON.stringify({ type: 'done', chars: 12, toolCalls: 1 })}\n\n`,
623
+ ].join('')
624
+ return Promise.resolve(new Response(payload, { headers: { 'content-type': 'text/event-stream' } }))
625
+ }
626
+ if (body && body.text === 'tool-probe') {
627
+ const payload = [
628
+ `data: ${JSON.stringify({ type: 'start', provider: 'fixture', model: 'fixture', tools: ['web_search', 'web_fetch'] })}\n\n`,
629
+ `data: ${JSON.stringify({ type: 'tool', phase: 'start', callId: 'c1', name: 'web_search', detail: 'MCP 协议' })}\n\n`,
630
+ `data: ${JSON.stringify({ type: 'delta', text: '## 翻译\n工具测试\n\n## 详解\n先查再答\n' })}\n\n`,
631
+ `data: ${JSON.stringify({ type: 'tool', phase: 'done', callId: 'c1', name: 'web_search', detail: 'MCP 协议', ok: true, ms: 1800, chars: 420, preview: 'MCP(Model Context Protocol)是…', urls: ['https://modelcontextprotocol.io/docs', 'https://example.com/mcp'] })}\n\n`,
632
+ `data: ${JSON.stringify({ type: 'done', chars: 18, toolCalls: 1 })}\n\n`,
633
+ ].join('')
634
+ return Promise.resolve(new Response(payload, { headers: { 'content-type': 'text/event-stream' } }))
635
+ }
636
+ // 没有工具可用时,模型会把调用写成正文(实测)——客户端必须剥掉
637
+ if (body && body.text === 'fake-tool-probe') {
638
+ const payload = [
639
+ `data: ${JSON.stringify({ type: 'start', provider: 'fixture', model: 'fixture', tools: [] })}\n\n`,
640
+ `data: ${JSON.stringify({ type: 'delta', text: '<ds_safety_tool_call>\n<tool_name>web_search</tool_name>\n<parameters>\n<query>DSH 插件</query>\n</parameters>\n</ds_safety_tool_call>\n\n## 翻译\n没有工具时的回答\n' })}\n\n`,
641
+ `data: ${JSON.stringify({ type: 'done', chars: 30 })}\n\n`,
642
+ ].join('')
643
+ return Promise.resolve(new Response(payload, { headers: { 'content-type': 'text/event-stream' } }))
644
+ }
645
+ if (body && body.text === '尾注探测') {
646
+ const out = ['```js', 'const a = 1 // 尾注', '```'].join('\n')
647
+ const payload = [
648
+ `data: ${JSON.stringify({ type: 'start', provider: 'fixture', model: 'fixture', stage: 'translation' })}\n\n`,
649
+ `data: ${JSON.stringify({ type: 'delta', text: out })}\n\n`,
650
+ `data: ${JSON.stringify({ type: 'done' })}\n\n`,
651
+ ].join('')
652
+ return Promise.resolve(new Response(payload, { headers: { 'content-type': 'text/event-stream' } }))
653
+ }
654
+ if (body && body.text === '稳定key探测') {
655
+ const out = '## 解读\n稳定 key 用例内容\n'
656
+ const payload = [
657
+ `data: ${JSON.stringify({ type: 'start', provider: 'fixture', model: 'fixture', stage: 'translation' })}\n\n`,
658
+ `data: ${JSON.stringify({ type: 'delta', text: out })}\n\n`,
659
+ `data: ${JSON.stringify({ type: 'done', chars: out.length })}\n\n`,
660
+ ].join('')
661
+ return Promise.resolve(new Response(payload, { headers: { 'content-type': 'text/event-stream' } }))
662
+ }
663
+ if (body && body.text === PROSE_PROBE) {
664
+ const out = '## 翻译\n- **the migration ran long**:这次迁移耗时超出预期\n'
665
+ const payload = [
666
+ `data: ${JSON.stringify({ type: 'start', provider: 'fixture', model: 'fixture', stage: 'translation' })}\n\n`,
667
+ `data: ${JSON.stringify({ type: 'delta', text: out })}\n\n`,
668
+ `data: ${JSON.stringify({ type: 'done', chars: out.length })}\n\n`,
669
+ ].join('')
670
+ return Promise.resolve(new Response(payload, { headers: { 'content-type': 'text/event-stream' } }))
671
+ }
672
+ if (body && body.text === PY_SNIPPET) {
673
+ const out = ['## 注释', '```python', '# 两数相加', 'def add(a, b):', ' return a + b', '```', '', '> 小结:返回两数之和。'].join('\n')
674
+ const payload = [
675
+ `data: ${JSON.stringify({ type: 'start', provider: 'fixture', model: 'fixture', stage: 'translation', mode: 'code' })}\n\n`,
676
+ `data: ${JSON.stringify({ type: 'delta', text: out })}\n\n`,
677
+ `data: ${JSON.stringify({ type: 'done', chars: out.length })}\n\n`,
678
+ ].join('')
679
+ return Promise.resolve(new Response(payload, { headers: { 'content-type': 'text/event-stream' } }))
680
+ }
681
+ if (body && body.text === CODE_SNIPPET) {
682
+ const out = [
683
+ '## 注释',
684
+ '```js',
685
+ '// 定义文本长度上限',
686
+ 'const MAX = 4000',
687
+ '// 声明一个把文本截断到上限的函数',
688
+ 'function clampText(text) {',
689
+ ' // 超限就截断,否则原样返回',
690
+ ' return text.length > MAX ? text.slice(0, MAX) : text;',
691
+ '}',
692
+ '```',
693
+ '',
694
+ '> 小结:把输入文本按 4000 字截断后再返回。',
695
+ ].join('\n')
696
+ const payload = [
697
+ `data: ${JSON.stringify({ type: 'start', provider: 'fixture', model: 'fixture', stage: 'translation', mode: 'code' })}\n\n`,
698
+ `data: ${JSON.stringify({ type: 'delta', text: out })}\n\n`,
699
+ `data: ${JSON.stringify({ type: 'done', chars: out.length })}\n\n`,
700
+ ].join('')
701
+ return Promise.resolve(new Response(payload, { headers: { 'content-type': 'text/event-stream' } }))
702
+ }
703
+ if (body && body.text === '缓存探测词') {
704
+ const only = body.stage === 'detail' ? '## 详解\n详解内容(缓存用例)\n' : '## 解读\n缓存测试内容\n'
705
+ const payload = [
706
+ `data: ${JSON.stringify({ type: 'start', provider: 'fixture', model: 'fixture', stage: body.stage || 'translation' })}\n\n`,
707
+ `data: ${JSON.stringify({ type: 'delta', text: only })}\n\n`,
708
+ `data: ${JSON.stringify({ type: 'done', chars: only.length })}\n\n`,
709
+ ].join('')
710
+ return Promise.resolve(new Response(payload, { headers: { 'content-type': 'text/event-stream' } }))
711
+ }
712
+ if (body && body.text === '中文标题探测') {
713
+ const payload = [
714
+ `data: ${JSON.stringify({ type: 'start', provider: 'fixture', model: 'fixture', stage: 'translation' })}\n\n`,
715
+ `data: ${JSON.stringify({ type: 'delta', text: '## 解读\n就是"把临时窗口变成正式会话"。\n\n> 在本句中:指把这次解读搬进一个正式会话。\n' })}\n\n`,
716
+ `data: ${JSON.stringify({ type: 'done', chars: 30 })}\n\n`,
717
+ ].join('')
718
+ return Promise.resolve(new Response(payload, { headers: { 'content-type': 'text/event-stream' } }))
719
+ }
720
+ if (body && body.text === 'legacy-heading-probe') {
721
+ const payload = [
722
+ `data: ${JSON.stringify({ type: 'start', provider: 'fixture', model: 'fixture' })}\n\n`,
723
+ `data: ${JSON.stringify({ type: 'delta', text: '## 翻译\n旧标题兼容\n\n## 语境含义\n旧标题下的解释\n' })}\n\n`,
724
+ `data: ${JSON.stringify({ type: 'done' })}\n\n`,
725
+ ].join('')
726
+ return Promise.resolve(new Response(payload, { headers: { 'content-type': 'text/event-stream' } }))
727
+ }
728
+ if (body && body.text === 'residue-probe' && !body.question) {
729
+ const payload = [
730
+ `data: ${JSON.stringify({ type: 'start', provider: 'fixture', model: 'fixture', stage: 'translation', effort: 'high' })}\n\n`,
731
+ `data: ${JSON.stringify({ type: 'delta', text: '## 翻译\n残渣用例的翻译\n' })}\n\n`,
732
+ `data: ${JSON.stringify({ type: 'done', chars: 12 })}\n\n`,
733
+ ].join('')
734
+ return Promise.resolve(new Response(payload, { headers: { 'content-type': 'text/event-stream' } }))
735
+ }
736
+ if (body && body.text === 'strip-probe' && !body.question) {
737
+ const payload = [
738
+ `data: ${JSON.stringify({ type: 'start', provider: 'fixture', model: 'fixture', stage: 'translation', effort: 'high' })}\n\n`,
739
+ `data: ${JSON.stringify({ type: 'delta', text: '## 翻译\n泄漏用例的翻译\n\n## 语境含义\n泄漏用例的解释\n' })}\n\n`,
740
+ `data: ${JSON.stringify({ type: 'done', chars: 30 })}\n\n`,
741
+ ].join('')
742
+ return Promise.resolve(new Response(payload, { headers: { 'content-type': 'text/event-stream' } }))
743
+ }
744
+ if (body && body.text === 'shared-cache-probe') {
745
+ const payload = [
746
+ `data: ${JSON.stringify({ type: 'start', provider: 'opencode-go', model: 'deepseek-v4.1-flash', cached: true })}\n\n`,
747
+ `data: ${JSON.stringify({ type: 'delta', text: '## 翻译\n来自共享缓存\n\n## 语境含义\n直接回放\n' })}\n\n`,
748
+ `data: ${JSON.stringify({ type: 'done', chars: 24, cached: true })}\n\n`,
749
+ ].join('')
750
+ return Promise.resolve(new Response(payload, { headers: { 'content-type': 'text/event-stream' } }))
751
+ }
752
+ if (body && body.text === 'slipped') {
753
+ const payload = [
754
+ `data: ${JSON.stringify({ type: 'start', provider: 'fixture', model: 'fixture-model' })}\n\n`,
755
+ `data: ${JSON.stringify({ type: 'delta', text: RENDER_FIXTURE })}\n\n`,
756
+ `data: ${JSON.stringify({ type: 'done', chars: RENDER_FIXTURE.length })}\n\n`,
757
+ ].join('')
758
+ return Promise.resolve(new Response(payload, { headers: { 'content-type': 'text/event-stream' } }))
759
+ }
760
+ return fetch(typeof input === 'string' && input.startsWith('/') ? ORIGIN + input : input, init)
761
+ }
762
+
763
+ const browserFetch = (input, init) => {
764
+ const out = routeFetch(input, init)
765
+ const signal = init && init.signal
766
+ if (!signal || !out || typeof out.then !== 'function') return out
767
+ return out.then((response) => (response && response.body ? withAbort(response, signal) : response))
768
+ }
769
+ new Function('window', 'document', 'location', 'navigator', 'requestAnimationFrame', 'fetch', source)(
770
+ windowStub,
771
+ documentStub,
772
+ { href: ORIGIN + '/' },
773
+ navigatorStub,
774
+ (cb) => setTimeout(cb, 0),
775
+ browserFetch,
776
+ )
777
+ const captured = globalThis.__captured
778
+ /** 深度优先遍历 DOM 桩。 */
779
+ function* walk(node) {
780
+ for (const child of node.children || []) {
781
+ yield child
782
+ yield* walk(child)
783
+ }
784
+ }
785
+
786
+ /** 把 DOM 桩打成缩进大纲,便于人眼核对层次。 */
787
+ function outline(node, depth) {
788
+ if (!node || depth > 5) return ''
789
+ const indent = ' '.repeat(depth)
790
+ const cls = node.className ? '.' + node.className.split(' ').join('.') : ''
791
+ const level = node.getAttribute && node.getAttribute('data-level') !== null ? `[level=${node.getAttribute('data-level')}]` : ''
792
+ const num = node.getAttribute && node.getAttribute('data-num') ? '[num]' : ''
793
+ const own = (node._text || '').trim()
794
+ let out = `${indent}${node.tagName.toLowerCase()}${cls}${level}${num}${own ? ' ' + own.slice(0, 70) : ''}\n`
795
+ for (const child of node.children || []) out += outline(child, depth + 1)
796
+ return out
797
+ }
798
+
799
+ const assert = (label, ok, extra) => {
800
+ console.log(`${ok ? 'PASS' : 'FAIL'} ${label}${extra ? ' — ' + extra : ''}`)
801
+ if (!ok) process.exitCode = 1
802
+ }
803
+
804
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
805
+ assert('bundle 注册 ModuleLoader 工厂', !!captured && typeof captured.factory === 'function', captured && captured.id)
806
+
807
+ const React = {
808
+ createElement: (type, props, ...children) => ({ type, props: props || {}, children }),
809
+ }
810
+ const modules = captured.factory((name) => {
811
+ if (name === 'react') return React
812
+ throw new Error('unexpected require: ' + name)
813
+ })
814
+ assert("导出 apply / inject(['slots'])", typeof modules.apply === 'function' && Array.isArray(modules.inject) && modules.inject[0] === 'slots')
815
+
816
+ // ───────────────────────── 假 ctx + 槽注册 ─────────────────────────
817
+ const disposers = []
818
+ let registration = null
819
+ const ctx = {
820
+ effect(fn) {
821
+ const d = fn()
822
+ if (typeof d === 'function') disposers.push(d)
823
+ return () => {}
824
+ },
825
+ on() {},
826
+ get(name) {
827
+ if (name === 'sessions') {
828
+ return {
829
+ list: { getSnapshot: () => ({ current: 'session-stub-1' }) },
830
+ open: (id) => {
831
+ openedSessions.push(String(id))
832
+ },
833
+ }
834
+ }
835
+ return undefined
836
+ },
837
+ slots: {
838
+ inject(key, callback) {
839
+ assert('注册到 shell.overlay 槽', key === 'shell.overlay', key)
840
+ const d = callback()
841
+ if (typeof d === 'function') disposers.push(d)
842
+ return d
843
+ },
844
+ register(options, component) {
845
+ registration = { options, component }
846
+ return () => {}
847
+ },
848
+ },
849
+ }
850
+ modules.apply(ctx)
851
+ assert('槽注册带 id/order', !!registration && registration.options.id === '@yfwu2020/dsh-selection-explain' && registration.options.order === 20, JSON.stringify(registration && registration.options))
852
+
853
+ // React 渲染 → ref 挂载 layer
854
+ const mount = new FakeEl('div')
855
+ const element = registration.component()
856
+ assert('浮层组件返回 React 元素', !!element && element.type === 'div')
857
+ element.props.ref(mount)
858
+ assert('layer 容器已挂进浮层', mount.children.length === 1 && mount.children[0].className === 'dsh-sel-layer')
859
+
860
+ // ───────────────────────── 划词 → 浮标 ─────────────────────────
861
+ const container = new FakeEl('div')
862
+ const textEl = new FakeEl('span')
863
+ textEl._text = 'Dev: the migration ran long, so we ship Wednesday. PM: fine, freeze features.'
864
+ container.appendChild(textEl)
865
+ body.appendChild(container)
866
+ const selection = {
867
+ isCollapsed: false,
868
+ rangeCount: 1,
869
+ toString: () => 'the migration ran long',
870
+ getRangeAt: () => documentStub.createRange(),
871
+ }
872
+ windowStub.getSelection = () => selection
873
+ documentStub.dispatch('mouseup', { target: body })
874
+ await new Promise((r) => setTimeout(r, 20))
875
+ const button = mount.children[0].children.find((c) => c.className === 'dsh-sel-btn')
876
+ assert('选中文字后浮标出现', !!button && button.style.display === 'inline-flex', button && `left=${button.style.left} top=${button.style.top}`)
877
+ // 浮现动效:只在"隐藏 → 显示"那一次挂动效类;已可见时只平移;animationend 后自动摘掉
878
+ {
879
+ const css = readFileSync(BUNDLE, 'utf8')
880
+ assert('CSS 契约:有 pop 关键帧(74% → 100%)', /@keyframes dsh-sel-pop\{from\{opacity:0;transform:scale\(\.74\) translateY\(6px\)\}to\{opacity:1;transform:none\}\}/.test(css), '')
881
+ assert('CSS 契约:动效 240ms + 带过冲的缓动', /\.dsh-sel-btn\[data-pop="1"\]\{animation:dsh-sel-pop \.24s cubic-bezier\(\.34,1\.56,\.64,1\) both\}/.test(css), '')
882
+ assert('CSS 契约:缩放原点是锚点(右下角)', /\.dsh-sel-btn\{transform-origin:100% 100%/.test(css), '')
883
+ assert('CSS 契约:reduced-motion 下关掉 pop', /@media \(prefers-reduced-motion:reduce\)\{[\s\S]{0,400}?\.dsh-sel-btn\[data-pop="1"\]\{animation:none\}/.test(css), '')
884
+
885
+ }
886
+
887
+
888
+ // ───────────────────────── 点击 → 弹窗 + 真实 host 流 ─────────────────────────
889
+ button.dispatch('click', { preventDefault() {}, stopPropagation() {} })
890
+ const panel = mount.children[0].children.find((c) => c.className === 'dsh-sel-panel')
891
+ // 面板内的固定引用点(后续断言共用)
892
+ const sections = Array.from(walk(panel)).filter((node) => node.className.indexOf('dsh-sel-sec') >= 0)
893
+ const panelHead = Array.from(walk(panel)).find((node) => node.className === 'dsh-sel-head')
894
+ const histBtn = Array.from(walk(panelHead)).find((n) => n.className.indexOf('dsh-sel-action') >= 0 && textOf(n).indexOf('最近') >= 0)
895
+ const histList = mount.children[0].children.find((n) => n.className === 'dsh-sel-history')
896
+ assert('「最近聊过的」是独立浮层,不在消息滚动区里(B 方案)', !!histList && histList.parentNode.className === 'dsh-sel-layer' && !panel.contains(histList), histList ? histList.parentNode.className : '未找到')
897
+ const detailCard = sections.find((node) => node.getAttribute('data-sec') === 'detail')
898
+ const expandBtn = Array.from(walk(panel)).find((node) => node.className === 'dsh-sel-expand')
899
+ const chatLog = Array.from(walk(panel)).find((node) => node.className === 'dsh-sel-chatlog')
900
+ const askRow = Array.from(walk(panel)).find((node) => node.className === 'dsh-sel-ask')
901
+ assert('弹窗打开', !!panel && panel.style.display === 'flex')
902
+ assert('弹窗显示选中文字', !!panel && textOf(panel).indexOf('the migration ran long') >= 0)
903
+
904
+ const hook = windowStub.__dshSelectionExplain
905
+ assert('自检钩子可用', !!hook && typeof hook.state === 'function')
906
+ const ping = await hook.ping()
907
+ assert('host ping 可达', !!ping && ping.ok === true, JSON.stringify(ping && ping.route))
908
+ assert('请求载荷携带当前会话 id', sent.length > 0 && sent[0].sessionId === 'session-stub-1', JSON.stringify(sent[0] && { sessionId: sent[0].sessionId, label: sent[0].label }))
909
+ assert('载荷不带 effort(推理档位固定在 host 配置)', sent[0] && sent[0].effort === undefined, String(sent[0] && sent[0].effort))
910
+ assert(
911
+ '三档档位都能从 /ping 读到',
912
+ !!ping && !!ping.reasoningEffortByStage && typeof ping.reasoningEffortByStage.translation === 'string' && typeof ping.reasoningEffortByStage.detail === 'string' && typeof ping.reasoningEffortByStage.chat === 'string',
913
+ JSON.stringify(ping && ping.reasoningEffortByStage),
914
+ )
915
+ {
916
+ // 默认值断言走源码(不依赖 3080 上那份部署配置改没改)
917
+ const hostSrc = readFileSync(resolve(HERE, '..', 'src', 'index.ts'), 'utf8')
918
+ assert(
919
+ '默认不设输出上限(config 0 = 不传 maxTokens,交给模型自己)',
920
+ /maxTokens: z\.number\(\)\.min\(0\)\.max\(32000\)\.default\(0\)/.test(hostSrc) &&
921
+ /maxTokens: rawConfig\?\.maxTokens \?\? 0/.test(hostSrc) &&
922
+ // 两处调用点(主循环 + 结论轮)都必须"只在 >0 时传"(续写轮已随"输出长度与中断"一起去掉)
923
+ (hostSrc.match(/config\.maxTokens > 0 \? \{ maxTokens: config\.maxTokens \} : \{\}/g) || []).length >= 2,
924
+ 'src/index.ts',
925
+ )
926
+ assert(
927
+ '已按要求去掉"输出长度与中断"机制(不再检测截断、不再续写/短收尾/提示)',
928
+ !/chunk\.reason\.kind === 'max-tokens'/.test(hostSrc) &&
929
+ !/runContinuation/.test(hostSrc) &&
930
+ !/markTailRepair/.test(hostSrc) &&
931
+ !/code: 'continue'/.test(hostSrc) &&
932
+ !/code: 'tail-unfinished'/.test(hostSrc) &&
933
+ !/code: 'tail-residue'/.test(hostSrc) &&
934
+ !/code: 'wrap-up'/.test(hostSrc),
935
+ 'src/index.ts',
936
+ )
937
+ // 小窗的搜索工具改用 free-search 插件(advanced_search / platform_search),不再用官方 web_search
938
+ assert(
939
+ '备用联网工具:默认清单是 web_search(没装联网插件时补上)',
940
+ /fallbackToolNames: z\.string\(\)\.default\('web_search'\)/.test(hostSrc) &&
941
+ /fallbackToolNames: rawConfig\?\.fallbackToolNames \?\? 'web_search'/.test(hostSrc) &&
942
+ /const SEARCH_TOOL_NAMES = \['advanced_search', 'platform_search', 'web_search'\]/.test(hostSrc) &&
943
+ /function resolveToolNames\(preferred: string\[\], fallback: string\[\], visible: string\[\]\): string\[\]/.test(hostSrc),
944
+ 'src/index.ts',
945
+ )
946
+ assert(
947
+ '默认白名单用 free-search 的两个搜索工具(web_search 移出首选、留作备用)',
948
+ /toolNames: z\.string\(\)\.default\('advanced_search,platform_search,read,grep,glob'\)/.test(hostSrc) &&
949
+ /toolNames: rawConfig\?\.toolNames \?\? 'advanced_search,platform_search,read,grep,glob'/.test(hostSrc),
950
+ 'src/index.ts',
951
+ )
952
+ assert('客户端给了新搜索工具中文标签', /\(name === 'advanced_search'\) return '联网搜索'/.test(readFileSync(resolve(HERE, '..', 'src', 'client', 'index.js'), 'utf8')), '')
953
+ assert(
954
+ '追问档位默认 high(翻译仍 low、详解仍 high)',
955
+ /chatReasoningEffort: z\.string\(\)\.default\('high'\)/.test(hostSrc) &&
956
+ /chatReasoningEffort: rawConfig\?\.chatReasoningEffort \?\? 'high'/.test(hostSrc) &&
957
+ /translationReasoningEffort: z\.string\(\)\.default\('low'\)/.test(hostSrc),
958
+ 'src/index.ts',
959
+ )
960
+ }
961
+
962
+ const deadline = Date.now() + 90000
963
+ while (Date.now() < deadline) {
964
+ const state = hook.state()
965
+ if (state.phase === 'done' || state.phase === 'error') break
966
+ await new Promise((r) => setTimeout(r, 400))
967
+ }
968
+ const final = hook.state()
969
+ const stage1 = hook.parts()
970
+ assert('流式渲染完成(无错误)', final.phase === 'done', `phase=${final.phase} chars=${final.chars}`)
971
+ assert('首轮只产出翻译节', (stage1.translation || '').trim().length > 0 && !(stage1.detail || '').trim(), JSON.stringify({ stage: stage1.stage, t: stage1.translation.length, d: stage1.detail.length }))
972
+ assert('首轮请求带 stage=translation', sent[0] && sent[0].stage === 'translation', String(sent[0] && sent[0].stage))
973
+ assert('首轮不显示详解卡片', !!detailCard && detailCard.style.display === 'none', detailCard ? detailCard.style.display : '未找到')
974
+ assert(
975
+ '底部有「展开详解」按钮且不带说明行',
976
+ !!expandBtn && textOf(expandBtn).trim() === '↓ 展开详解',
977
+ expandBtn ? JSON.stringify(textOf(expandBtn)) : '未找到',
978
+ )
979
+
980
+ // 只有翻译时的轻量形态:窄面板 / 无序号 / CTA 在内容流里(但**可以越过详解直接追问**)
981
+ const translateCard = sections.find((node) => node.getAttribute('data-sec') === 'translation')
982
+ assert('首轮面板标为 translation 阶段(宽度收窄)', panel.getAttribute('data-stage') === 'translation', String(panel.getAttribute('data-stage')))
983
+ assert(
984
+ '两节标题都不带序号(①② 已去掉)',
985
+ !Array.from(walk(panel)).some((n) => n.tagName === 'B' && /^[①②12]$/.test(textOf(n))),
986
+ Array.from(walk(panel)).filter((n) => n.tagName === 'B').map((n) => textOf(n)).join(','),
987
+ )
988
+ assert('翻译出完就能追问(不必先展开详解)', !!askRow && askRow.style.display !== 'none', askRow ? askRow.style.display : '未找到')
989
+ assert(
990
+ '展开 CTA 在正文内容流里(详解卡片之后)',
991
+ !!expandBtn && expandBtn.parentNode === detailCard.parentNode,
992
+ expandBtn && expandBtn.parentNode ? expandBtn.parentNode.className : '未找到',
993
+ )
994
+
995
+ // 越过详解直接追问:翻译刚出来就能问,不必先点「展开详解」
996
+ {
997
+ const before = sent.length
998
+ hook.ask('不展开详解,直接问一句')
999
+ for (let i = 0; i < 80 && sent.length === before; i += 1) await sleep(20)
1000
+ const payload = sent[sent.length - 1] || {}
1001
+ assert('越过详解也能发出追问', payload.question === '不展开详解,直接问一句', JSON.stringify({ q: payload.question, stage: payload.stage }))
1002
+ const seed = payload.history && payload.history[0] ? String(payload.history[0].text) : ''
1003
+ assert('前情只带翻译:不留空的「详解:」', seed.indexOf('前情') >= 0 && seed.indexOf('详解:') < 0, seed.slice(0, 70))
1004
+ for (let i = 0; i < 80 && textOf(chatLog).indexOf('这是对追问的回答') < 0; i += 1) await sleep(20)
1005
+ assert('回答渲染进小窗消息区', textOf(chatLog).indexOf('这是对追问的回答') >= 0, textOf(chatLog).slice(0, 60))
1006
+ assert('一旦有对话,面板恢复宽版(消息区更好读)', panel.getAttribute('data-stage') === 'detail', String(panel.getAttribute('data-stage')))
1007
+ assert('发过追问后「展开详解」自动收起', expandBtn.style.display === 'none', String(expandBtn.style.display))
1008
+
1009
+ // 换一段新的选中文字:状态重置,CTA 该回来(后面的"点展开"流程从这里接着走)。
1010
+ // 用英文 fixture(标题才是「翻译」,后面的"两节标题"断言才对得上),且它首轮只出翻译、不出详解。
1011
+ hook.open('cta-reset-probe', '', 'CTA 复位')
1012
+ for (let i = 0; i < 120 && hook.state().phase !== 'done'; i += 1) await sleep(20)
1013
+ assert('新选区里 CTA 回来', expandBtn.style.display !== 'none', String(expandBtn.style.display))
1014
+ assert('新选区把追问记录清空', hook.turns().filter((t) => t.role === 'user').length === 0, JSON.stringify(hook.turns().map((t) => t.role)))
1015
+ }
1016
+
1017
+ // 点击展开:第二阶段(完整上下文 + 详解)
1018
+ const beforeExpand = sent.length
1019
+ expandBtn.dispatch('click', { stopPropagation() {} })
1020
+ for (let i = 0; i < 200; i += 1) {
1021
+ const parts = hook.parts()
1022
+ if ((parts.detail || '').trim().length > 0) break
1023
+ await new Promise((r) => setTimeout(r, 100))
1024
+ }
1025
+ const stage2 = hook.parts()
1026
+ const expandPayload = sent[sent.length - 1]
1027
+ assert(
1028
+ '展开请求带 stage=detail,且不再强制 refresh(详解也能命中缓存)',
1029
+ expandPayload.stage === 'detail' && expandPayload.refresh === undefined,
1030
+ JSON.stringify({ stage: expandPayload.stage, refresh: expandPayload.refresh, sent: sent.length - beforeExpand }),
1031
+ )
1032
+ // 偶发失败过:只等"有内容"会在流还没收尾时抢跑,这里等到阶段真正结束再断言
1033
+ for (let i = 0; i < 100 && !(hook.parts().detail || '').trim(); i += 1) await sleep(50)
1034
+ for (let i = 0; i < 100 && hook.state().phase !== 'done'; i += 1) await sleep(50)
1035
+ assert('第二阶段产出详解', (hook.parts().detail || '').trim().length > 0, (hook.parts().detail || '').trim().slice(0, 60).replace(/\n/g, ' '))
1036
+ assert('展开后按钮隐藏、详解卡片出现', expandBtn.style.display === 'none' && detailCard.style.display !== 'none', `btn=${expandBtn.style.display} card=${detailCard.style.display}`)
1037
+ assert('展开后面板切到 detail 阶段(恢复宽度)', panel.getAttribute('data-stage') === 'detail', String(panel.getAttribute('data-stage')))
1038
+ assert(
1039
+ '展开后标题仍不带序号',
1040
+ !Array.from(walk(panel)).some((n) => n.tagName === 'B' && /^[①②12]$/.test(textOf(n))),
1041
+ )
1042
+ assert('展开后出现追问输入框', !!askRow && askRow.style.display !== 'none', askRow ? askRow.style.display : '未找到')
1043
+ assert('DOM 渲染出两节标题', textOf(panel).indexOf('翻译') >= 0 && textOf(panel).indexOf('详解') >= 0)
1044
+ assert(
1045
+ '页眉不再显示模型名(模型只在 composer 的胶囊里)',
1046
+ !Array.from(walk(panelHead)).some((n) => /fixture/.test(textOf(n))),
1047
+ Array.from(walk(panelHead)).map((n) => textOf(n)).join('|').slice(0, 60),
1048
+ )
1049
+ assert('耗时信息仍有记录(不再上界面,自检可读)', /耗时/.test(String(hook.state().status || '')), String(hook.state().status))
1050
+
1051
+ // ───────────────────────── 渲染层次(固定输出,离线可验) ─────────────────────────
1052
+ hook.open('slipped', '', '渲染测试')
1053
+ for (let i = 0; i < 80 && hook.state().phase !== 'done'; i += 1) {
1054
+ await new Promise((r) => setTimeout(r, 25))
1055
+ }
1056
+ const translationContent = sections[0] && Array.from(walk(sections[0])).find((n) => n.className === 'dsh-sel-c')
1057
+ const detailContent = sections[1] && Array.from(walk(sections[1])).find((n) => n.className === 'dsh-sel-c')
1058
+
1059
+ function classesIn(root, cls) {
1060
+ return Array.from(walk(root)).filter((node) => node.className.split(' ').indexOf(cls) >= 0)
1061
+ }
1062
+ const flatLists = classesIn(translationContent, 'dsh-sel-list')
1063
+ const levels = flatLists.map((node) => node.getAttribute('data-level'))
1064
+ const orderedItems = classesIn(translationContent, 'dsh-sel-item').filter((n) => n.getAttribute('data-num') === '1')
1065
+ assert('两节各自成卡片(data-sec 标记)', sections.length === 2 && sections[0].getAttribute('data-sec') === 'translation' && sections[1].getAttribute('data-sec') === 'detail')
1066
+ assert(
1067
+ '列表按缩进分级(顶层 + 嵌套各成表,编号切换另起表)',
1068
+ levels[0] === '0' && levels.indexOf('1') >= 0,
1069
+ '数据级别=' + levels.join(','),
1070
+ )
1071
+ assert('有序列表保留编号', orderedItems.length === 2 && textOf(orderedItems[0]).indexOf('1.') === 0, orderedItems.map((n) => textOf(n).slice(0, 12)).join(' | '))
1072
+ assert('小标题走独立子标题节点', classesIn(translationContent, 'dsh-sel-sub').some((n) => textOf(n).indexOf('小标题') >= 0))
1073
+ assert('加粗与行内代码分别成节点', classesIn(translationContent, 'dsh-sel-strong').length >= 2 && classesIn(translationContent, 'dsh-sel-code').length === 1)
1074
+ {
1075
+ const rendered = Array.from(translationContent.children)
1076
+ const echoLine = rendered.find((n) => textOf(n) === '滑落;滑倒')
1077
+ assert(
1078
+ '译文回声前缀被剥掉(行首的「- **选中文字**:」被去掉,只留内容)',
1079
+ !!echoLine && echoLine.tagName === 'P' && !rendered.some((n) => textOf(n) === 'slipped'),
1080
+ echoLine ? echoLine.tagName + ':' + textOf(echoLine) : rendered.slice(0, 3).map((n) => textOf(n)).join(' | '),
1081
+ )
1082
+ // 带音标的那一行是"词头 + 音标",词头要留着(否则只剩一串斜杠)
1083
+ const ipaLine = rendered.find((n) => textOf(n).indexOf('/slɪpt/') >= 0)
1084
+ const ipaText = ipaLine ? textOf(ipaLine) : ''
1085
+ assert(
1086
+ '带音标的词头行不被剥(词头在前、音标在后,都留着)',
1087
+ !!ipaLine && ipaText.indexOf('slipped') >= 0 && ipaText.indexOf('/slɪpt/') > ipaText.indexOf('slipped'),
1088
+ ipaText || '未找到音标行',
1089
+ )
1090
+ }
1091
+ const pres = classesIn(detailContent, 'dsh-sel-pre')
1092
+ assert(
1093
+ '围栏代码块渲染(保留原始缩进、不走行内解析)',
1094
+ pres.length === 1 && textOf(pres[0]).indexOf('npm run build') >= 0,
1095
+ pres.length ? JSON.stringify(textOf(pres[0])) : '未找到 pre',
1096
+ )
1097
+ const callouts = classesIn(translationContent, 'dsh-sel-callout')
1098
+ {
1099
+ // 词头是父项(level 0),词性义项是缩进子项(level 1)——
1100
+ // 用户看到的"单词本身和词性一个层级"就是这个层次没拉开
1101
+ const lists = classesIn(translationContent, 'dsh-sel-list')
1102
+ const levelOf = (list) => list.getAttribute('data-level')
1103
+ const itemsAt = (lvl) => lists.filter((n) => levelOf(n) === String(lvl)).flatMap((n) => Array.from(n.children).map((c) => textOf(c)))
1104
+ const top = itemsAt(0)
1105
+ const sub = itemsAt(1)
1106
+ assert('词头在父级(level 0)', top.some((t) => t.indexOf('slipped') >= 0 && t.indexOf('/slɪpt/') > 0), JSON.stringify(top.slice(0, 3)))
1107
+ const hasPos = (t) => /^•?\s*(v\.|adj\.|n\.|adv\.|prep\.|conj\.|pron\.)/.test(t)
1108
+ assert(
1109
+ '词性义项缩进成子级(level 1),父级里没有裸词性行',
1110
+ sub.filter(hasPos).length >= 2 && !top.some(hasPos),
1111
+ JSON.stringify({ 子级词性行: sub.filter(hasPos).slice(0, 3), 父级: top.slice(0, 3) }),
1112
+ )
1113
+ }
1114
+ assert(
1115
+ '单词条目带音标:词头加粗、音标原文原样渲染',
1116
+ textOf(translationContent).indexOf('/slɪpt/') >= 0 && classesIn(translationContent, 'dsh-sel-strong').length > 0,
1117
+ textOf(translationContent).slice(0, 90),
1118
+ )
1119
+ assert(
1120
+ '翻译节结论条高亮渲染(标签胶囊 + 正文)',
1121
+ callouts.length === 1 && textOf(callouts[0]).indexOf('在本句中') === 0 && textOf(callouts[0]).indexOf('被推迟') > 0,
1122
+ callouts.length ? textOf(callouts[0]).slice(0, 40) : '未找到结论条',
1123
+ )
1124
+ // 链接:markdown 链接渲染成可点的 a[href]
1125
+ const answerLinks = classesIn(detailContent, 'dsh-sel-link')
1126
+ assert('正文里的 markdown 链接渲染成可点链接', answerLinks.length === 1 && answerLinks[0].getAttribute('href') === 'https://example.com/docs' && textOf(answerLinks[0]) === '官方文档', answerLinks.map((n) => textOf(n) + '→' + n.getAttribute('href')).join(','))
1127
+ assert('链接带 target=_blank 与 noopener', answerLinks[0].getAttribute('target') === '_blank' && /noopener/.test(answerLinks[0].getAttribute('rel') || ''), answerLinks[0].getAttribute('rel'))
1128
+ const tables = classesIn(detailContent, 'dsh-sel-table')
1129
+ const headerCells = tables.length ? classesIn(tables[0], 'dsh-sel-table') : []
1130
+ const ths = tables.length ? Array.from(walk(tables[0])).filter((n) => n.tagName === 'TH') : []
1131
+ const tds = tables.length ? Array.from(walk(tables[0])).filter((n) => n.tagName === 'TD') : []
1132
+ assert(
1133
+ '对照表格渲染(表头 2 列 + 2 行数据)',
1134
+ tables.length === 1 && tables[0].tagName === 'TABLE' && ths.length === 2 && tds.length === 4,
1135
+ `table=${tables.length} th=${ths.length} td=${tds.length}`,
1136
+ )
1137
+ console.log('\n--- 渲染结构:翻译节 ---')
1138
+ console.log(outline(translationContent, 0).slice(0, 1200))
1139
+ console.log('--- 渲染结构:详解节 ---')
1140
+ console.log(outline(detailContent, 0).slice(0, 900))
1141
+
1142
+ // ───────────────────────── 共享缓存命中(host 回放) ─────────────────────────
1143
+ hook.open('shared-cache-probe', '', '缓存测试')
1144
+ for (let i = 0; i < 60 && hook.state().phase !== 'done'; i += 1) {
1145
+ await new Promise((r) => setTimeout(r, 20))
1146
+ }
1147
+ assert(
1148
+ '共享缓存命中时状态栏标注「共享缓存」',
1149
+ /共享缓存/.test(textOf(panel)) && textOf(panel).indexOf('来自共享缓存') >= 0,
1150
+ textOf(panel).match(/完成[^重]{0,40}/)?.[0] || textOf(panel).slice(0, 40),
1151
+ )
1152
+
1153
+ assert(
1154
+ '右上角是「最近 + 升格 + 关闭」三个,没有复制按钮',
1155
+ !Array.from(walk(panelHead)).some((n) => textOf(n).indexOf('复制') >= 0) &&
1156
+ Array.from(walk(panelHead)).filter((n) => n.className === 'dsh-sel-icon').length === 1 &&
1157
+ Array.from(walk(panelHead)).filter((n) => n.className.indexOf('dsh-sel-action') >= 0).length === 2 &&
1158
+ Array.from(walk(panelHead)).some((n) => n.className.indexOf('dsh-sel-action') >= 0 && textOf(n).indexOf('最近') >= 0),
1159
+ Array.from(walk(panelHead)).map((n) => n.className + ':' + textOf(n).slice(0, 6)).join(' | '),
1160
+ )
1161
+ const promoteBtn = Array.from(walk(panelHead)).find((n) => n.className.indexOf('dsh-sel-action') >= 0 && textOf(n).indexOf('升格') >= 0)
1162
+ assert(
1163
+ '升格按钮是自适应宽度的文字按钮(不再挤进 24px 方块)',
1164
+ !!promoteBtn && textOf(promoteBtn).indexOf('升格') >= 0,
1165
+ promoteBtn ? promoteBtn.className + '|' + textOf(promoteBtn) : '未找到',
1166
+ )
1167
+
1168
+ // ───────────────────────── 升格为正式会话 ─────────────────────────
1169
+ // 先走一遍两阶段(翻译 → 展开详解),确保升格时取的是两个阶段的结果而不是最后一段流
1170
+ hook.open('stage-probe', '', '分阶段')
1171
+ for (let i = 0; i < 80 && hook.state().phase !== 'done'; i += 1) await new Promise((r) => setTimeout(r, 20))
1172
+ hook.expand()
1173
+ for (let i = 0; i < 80 && !(hook.parts().detail || '').trim(); i += 1) await new Promise((r) => setTimeout(r, 20))
1174
+ assert(
1175
+ '两阶段各自的结果都留存(raw 只会有最后一段)',
1176
+ (hook.parts().translation || '').indexOf('STAGE-TRANSLATION') >= 0 && (hook.parts().detail || '').indexOf('STAGE-DETAIL') >= 0,
1177
+ JSON.stringify(hook.parts()),
1178
+ )
1179
+
1180
+ const promoteButton = Array.from(walk(panelHead)).find((n) => n.className.indexOf('dsh-sel-action') >= 0 && textOf(n).indexOf('升格') >= 0)
1181
+ promoteButton.dispatch('click', { stopPropagation() {} })
1182
+ for (let i = 0; i < 60 && promoted.length === 0; i += 1) {
1183
+ await new Promise((r) => setTimeout(r, 20))
1184
+ }
1185
+ for (let i = 0; i < 60 && openedSessions.length === 0; i += 1) {
1186
+ await new Promise((r) => setTimeout(r, 20))
1187
+ }
1188
+ const promotePayload = promoted[0] || {}
1189
+ assert(
1190
+ '升格请求带上两节结论(分阶段取,翻译不丢)',
1191
+ !!promotePayload.text &&
1192
+ (promotePayload.translation || '').indexOf('STAGE-TRANSLATION') >= 0 &&
1193
+ (promotePayload.detail || '').indexOf('STAGE-DETAIL') >= 0 &&
1194
+ Array.isArray(promotePayload.turns),
1195
+ JSON.stringify({ t: (promotePayload.translation || '').slice(0, 20), d: (promotePayload.detail || '').slice(0, 20) }),
1196
+ )
1197
+ assert('升格请求剔除种子轮次(seed 不入正式会话)', !(promotePayload.turns || []).some((t) => t.seed === true))
1198
+ assert('升格后跳到新会话', openedSessions[0] === 'session-promoted-1', JSON.stringify(openedSessions))
1199
+ assert('升格结果仍有记录(自检可读)', /已升格/.test(String(hook.state().status || '')), String(hook.state().status))
1200
+ for (let i = 0; i < 60 && panel.style.display !== 'none'; i += 1) {
1201
+ await new Promise((r) => setTimeout(r, 20))
1202
+ }
1203
+ assert('升格后小窗自动关闭', panel.style.display === 'none', 'display=' + panel.style.display)
1204
+
1205
+ assert('页脚不再有档位开关', !Array.from(walk(panel)).some((n) => /深入|快速/.test(textOf(n)) && n.className === 'dsh-sel-more'))
1206
+ {
1207
+ const quoteEl = Array.from(walk(panel)).find((n) => n.className === 'dsh-sel-quote')
1208
+ const bodyEl = Array.from(walk(panel)).find((n) => n.className === 'dsh-sel-body')
1209
+ assert('选中文字条在滚动区里(跟着消息一起滚,不再钉在顶部)', !!quoteEl && !!bodyEl && quoteEl.parentNode === bodyEl, quoteEl ? String(quoteEl.parentNode && quoteEl.parentNode.className) : '未找到')
1210
+ assert('选中文字条是滚动区的第一项(在最顶端,不在消息后面)', !!bodyEl && bodyEl.children[0] === quoteEl, bodyEl ? bodyEl.children.map((n) => n.className).join(' > ') : '')
1211
+ // CSS 契约(桩环境不跑样式,直接查产物里的规则文本)
1212
+ const cssText = readFileSync(BUNDLE, 'utf8')
1213
+ assert('选中文字条不被 flex 压扁(flex:0 0 auto)', /\.dsh-sel-quote\{[^']*flex:0 0 auto/.test(cssText), '')
1214
+ assert('选中文字条可自动换行 + 上限内滚动', /\.dsh-sel-quote\{[\s\S]{0,900}?white-space:pre-wrap/.test(cssText) && /\.dsh-sel-quote\{[\s\S]{0,900}?max-height:84px/.test(cssText), '')
1215
+ assert('选中文字条不显示滚动条', /\.dsh-sel-quote::-webkit-scrollbar/.test(cssText) && /display:none/.test(cssText) && /\.dsh-sel-quote\{[\s\S]{0,900}?scrollbar-width:none/.test(cssText), '')
1216
+ }
1217
+
1218
+ // ───────────────────────── 工具调用:只进上下文,不进小窗 ─────────────────────────
1219
+ // 明确要求:小窗里只允许出现"模型处理后的结果"。工具日志(卡片、查询词、命中字数、
1220
+ // 预览原文、链接、耗时)一律不渲染——它们只是模型的原料。
1221
+ hook.open('tool-probe', '', '工具测试')
1222
+ for (let i = 0; i < 80 && hook.state().phase !== 'done'; i += 1) await sleep(20)
1223
+ assert(
1224
+ '页脚不再报"用过 N 次工具"',
1225
+ !/用过/.test(textOf(panel)),
1226
+ textOf(panel).match(/完成[^重]{0,50}/)?.[0] || textOf(panel).slice(0, 60),
1227
+ )
1228
+ assert('小窗里没有工具卡片容器', !Array.from(walk(panel)).some((n) => n.className === 'dsh-sel-toollog'), 'dsh-sel-toollog')
1229
+
1230
+ const toolText = textOf(panel)
1231
+ assert('面板里没有工具卡片节点', !Array.from(walk(panel)).some((n) => /^dsh-sel-tool/.test(n.className)), '')
1232
+ assert('查询词不进小窗', toolText.indexOf('MCP 协议') < 0, toolText.slice(0, 80))
1233
+ assert('命中字数/耗时不进小窗', !/420 字/.test(toolText) && !/1\.8s/.test(toolText), '')
1234
+ assert('工具结果预览不进小窗', toolText.indexOf('Model Context Protocol') < 0 && toolText.indexOf('modelcontextprotocol.io') < 0, '')
1235
+ assert('工具名不进小窗', toolText.indexOf('联网搜索') < 0 && toolText.indexOf('web_search') < 0, '')
1236
+ assert('内部仍记录工具调用(供自检与上下文)', hook.state().tools === 1, String(hook.state().tools))
1237
+ assert('工具摘要仍生成(追问时带给模型)', /联网搜索/.test(hook.state().toolDigest) && /MCP/.test(hook.state().toolDigest), hook.state().toolDigest.slice(0, 60))
1238
+
1239
+ hook.ask('那它和 LSP 有什么关系?')
1240
+ for (let i = 0; i < 80 && textOf(chatLog).indexOf('这是对追问的回答') < 0; i += 1) await sleep(20)
1241
+ const digPayload = sent[sent.length - 1]
1242
+ assert('追问请求带上 toolDigest', typeof digPayload.toolDigest === 'string' && digPayload.toolDigest.indexOf('MCP') >= 0, String(digPayload.toolDigest || '').slice(0, 60))
1243
+
1244
+ // 检索**正在进行**时:只有一句"正在检索资料",查询词/预览/链接仍然一个字都不出现
1245
+ hook.open('tool-live-probe', '', '检索中')
1246
+ let busySeen = false
1247
+ let busyHint = ''
1248
+ let busyText = ''
1249
+ for (let i = 0; i < 60 && hook.state().phase !== 'done'; i += 1) {
1250
+ await sleep(20)
1251
+ if (hook.state().toolBusy) {
1252
+ busySeen = true
1253
+ busyText = textOf(panel)
1254
+ const hint = Array.from(walk(panel)).find((n) => n.className === 'dsh-sel-waithint')
1255
+ if (hint && textOf(hint).indexOf('正在检索资料') >= 0) busyHint = textOf(hint)
1256
+ }
1257
+ }
1258
+ assert('检索期间有"正在检索资料"提示', busySeen && busyHint.indexOf('正在检索资料') >= 0, `busy=${busySeen} hint=${busyHint}`)
1259
+ assert('检索期间也不显示查询词', busyText.indexOf('检索中的查询词') < 0, busyText.slice(0, 80))
1260
+ assert('检索期间也不显示结果链接', busyText.indexOf('secret-source') < 0, '')
1261
+ assert('检索结束后清掉检索标记', hook.state().toolBusy === false, String(hook.state().toolBusy))
1262
+ assert('检索完成后正文照常显示', textOf(sections[0]).indexOf('模型消化后的结论') >= 0, textOf(sections[0]).slice(0, 60))
1263
+
1264
+ // 等待提示里的"推理档位"必须是**这次真正用的档位**:
1265
+ // 等待节点是在 start 事件之前建的,那里只能实时读,不能用创建时的兜底值。
1266
+ hook.open('effort-hint-probe', '', '档位提示')
1267
+ let effortHint = ''
1268
+ for (let i = 0; i < 70 && hook.state().phase !== 'done'; i += 1) {
1269
+ await sleep(200)
1270
+ const hint = Array.from(walk(panel)).find((n) => n.className === 'dsh-sel-waithint')
1271
+ if (hint && /推理档位/.test(textOf(hint))) { effortHint = textOf(hint); break }
1272
+ }
1273
+ assert('等待提示显示本次实际推理档位', /推理档位 max/.test(effortHint), effortHint || '未出现档位提示')
1274
+ for (let i = 0; i < 40 && hook.state().phase !== 'done'; i += 1) await sleep(100)
1275
+
1276
+ // ───────────────────────── 工具轮里的"过程旁白"不许当正文 ─────────────────────────
1277
+ hook.open('narrate-probe', '', '旁白探测')
1278
+ for (let i = 0; i < 80 && hook.state().phase !== 'done'; i += 1) await sleep(20)
1279
+ {
1280
+ const answer = String(hook.parts().translation || '')
1281
+ assert(
1282
+ '工具轮里的旁白被撤回(不进答案正文,一个字都不留)',
1283
+ answer.indexOf('I have') < 0 && answer.indexOf('Maybe I should search') < 0,
1284
+ answer.slice(0, 80),
1285
+ )
1286
+ assert('撤回后正式结论照常显示', answer.indexOf('这是撤回旁白之后的正式结论') >= 0, answer.slice(0, 80))
1287
+ assert(
1288
+ '旁白转成了思考尾巴(还能看到它在想什么)',
1289
+ String(hook.state().thought || '').indexOf('Maybe I should search') >= 0,
1290
+ String(hook.state().thought || '').slice(0, 60),
1291
+ )
1292
+ assert(
1293
+ '结束后等待节点被清掉(旁白不会留在界面上)',
1294
+ !Array.from(walk(sections[0])).some((n) => n.className === 'dsh-sel-waithint'),
1295
+ Array.from(walk(sections[0])).filter((n) => n.className === 'dsh-sel-waithint').map((n) => textOf(n)).join('|'),
1296
+ )
1297
+ }
1298
+ // text 对不上时退化成按字数撤(兜底路径)
1299
+ hook.open('narrate-fallback-probe', '', '兜底探测')
1300
+ for (let i = 0; i < 80 && hook.state().phase !== 'done'; i += 1) await sleep(20)
1301
+ {
1302
+ const answer = String(hook.parts().translation || '')
1303
+ assert(
1304
+ 'drop 的 text 对不上时按字数兜底撤回(一个字都不剩)',
1305
+ answer.indexOf('NARRATION') < 0 && answer.indexOf('兜底撤回后的结论') >= 0,
1306
+ JSON.stringify(answer.slice(0, 60)),
1307
+ )
1308
+ }
1309
+
1310
+ // 追问里同样要撤(askBox/chatLog 在这一节已就绪)
1311
+ await sleep(20)
1312
+ hook.ask('旁白追问')
1313
+ for (let i = 0; i < 80 && textOf(chatLog).indexOf('这是追问的正式回答') < 0; i += 1) await sleep(20)
1314
+ {
1315
+ const bubbleText = textOf(chatLog)
1316
+ assert('追问气泡里也撤掉了旁白', bubbleText.indexOf('Let me think') < 0, bubbleText.slice(-120))
1317
+ assert('追问的正式回答保留', bubbleText.indexOf('这是追问的正式回答') >= 0, bubbleText.slice(-120))
1318
+ }
1319
+
1320
+ // ───────────────────────── AI 回复里的网页:定稿后实时渲染成 iframe ─────────────────────────
1321
+ hook.open('html-probe', '', '网页预览')
1322
+ await sleep(150)
1323
+ {
1324
+ const during = Array.from(walk(sections[0])).filter((n) => n.className === 'dsh-sel-preview')
1325
+ assert('流式期间只给源码、不建 iframe(避免每帧重载闪白)', during.length === 0, `preview=${during.length}`)
1326
+ }
1327
+ for (let i = 0; i < 120 && hook.state().phase !== 'done'; i += 1) await sleep(20)
1328
+ {
1329
+ const blocks = Array.from(walk(sections[0])).filter((n) => n.className === 'dsh-sel-preview')
1330
+ assert('定稿后渲染出网页预览块', blocks.length === 1, `preview=${blocks.length}`)
1331
+ const block = blocks[0]
1332
+ const frame = block ? Array.from(walk(block)).find((n) => n.className === 'dsh-sel-previewframe') : null
1333
+ const srcdoc = frame ? String(frame.getAttribute('srcdoc') || '') : ''
1334
+ assert('预览用沙箱 iframe 装 HTML(srcdoc 带原文)', srcdoc.indexOf('<h1>Hello 预览</h1>') >= 0, srcdoc.slice(0, 60))
1335
+ assert('沙箱只给 allow-scripts、不给 allow-same-origin', frame && String(frame.getAttribute('sandbox')).indexOf('allow-scripts') >= 0 && String(frame.getAttribute('sandbox')).indexOf('allow-same-origin') < 0, frame ? String(frame.getAttribute('sandbox')) : '无')
1336
+ assert('同一块里也保留源码(可切到代码视图)', !!block && Array.from(walk(block)).some((n) => n.className === 'dsh-sel-pre'), '')
1337
+ assert('js 代码块不会被当成网页', Array.from(walk(sections[0])).filter((n) => n.className === 'dsh-sel-preview').length === 1 && textOf(sections[0]).indexOf('const a = 1') >= 0, '')
1338
+ assert('未标语言的 <div> 片段不会被当成网页', !Array.from(walk(sections[0])).some((n) => n.className === 'dsh-sel-previewframe' && String(n.getAttribute('srcdoc') || '').indexOf('只是片段') >= 0), '')
1339
+ assert('D:注入了窄容器自适应样式(viewport + reset)', srcdoc.indexOf('data-dsh-preview-reset') >= 0 && srcdoc.indexOf('width=device-width') >= 0, srcdoc.slice(0, 90))
1340
+ assert('注入点在 doctype 之后(不把文档推进怪异模式)', /^\s*<!DOCTYPE html>/i.test(srcdoc) && srcdoc.indexOf('data-dsh-preview-reset') > srcdoc.indexOf('<!DOCTYPE'), srcdoc.slice(0, 40))
1341
+ assert('注入了"上报自身高度"的 hook(消灭框内滚动条的前提)', srcdoc.indexOf('data-dsh-preview-hook') >= 0 && srcdoc.indexOf('__dshPreview') >= 0, '')
1342
+ assert('内置滚动条被隐藏(高度自适应后本来也不需要)', srcdoc.indexOf('::-webkit-scrollbar') >= 0 && srcdoc.indexOf('scrollbar-width:none') >= 0, '')
1343
+ assert('预览帧带 id(用于把上报的高度对回来)', !!frame && /^pv\d+$/.test(String(frame.getAttribute('data-preview-id'))), frame ? String(frame.getAttribute('data-preview-id')) : '无')
1344
+ // 交互:预览/代码切换 + 放大
1345
+ const tabs = Array.from(walk(block)).filter((n) => n.className === 'dsh-sel-previewtab')
1346
+ assert('默认停在预览视图', block.getAttribute('data-view') === 'preview' && tabs[0].getAttribute('data-on') === '1', `${block.getAttribute('data-view')} on=${tabs[0] && tabs[0].getAttribute('data-on')}`)
1347
+ tabs[1].dispatch('click', { stopPropagation() {} })
1348
+ assert('点「代码」切到源码视图', block.getAttribute('data-view') === 'code' && tabs[1].getAttribute('data-on') === '1', String(block.getAttribute('data-view')))
1349
+ tabs[0].dispatch('click', { stopPropagation() {} })
1350
+ const zoomBtn = Array.from(walk(block)).find((n) => n.className === 'dsh-sel-previewzoom')
1351
+ assert('默认是"全量展示"(整页铺在消息区,内部不滚)', block.getAttribute('data-full') === '1' && textOf(zoomBtn) === '⤡ 还原', `${block.getAttribute('data-full')} / ${textOf(zoomBtn)}`)
1352
+ zoomBtn.dispatch('click', { stopPropagation() {} })
1353
+ assert('点「还原」切回固定高度档', block.getAttribute('data-full') === '0' && textOf(zoomBtn) === '⤢ 全量', `${block.getAttribute('data-full')} / ${textOf(zoomBtn)}`)
1354
+ const cssFull = readFileSync(BUNDLE, 'utf8')
1355
+ assert('固定高度档是 320px(超出走内部滚动)', /\.dsh-sel-preview\[data-full="0"\] \.dsh-sel-previewframe\{height:320px\}/.test(cssFull), '')
1356
+ assert('旧的 data-zoom 高度档已移除', !/data-zoom/.test(cssFull), '')
1357
+ }
1358
+
1359
+ // ───────────────────────── 历史折叠(整页 HTML 只发 Markdown) ─────────────────────────
1360
+ {
1361
+ // ① 折叠函数本身:没有整页 HTML → 原样(快路径)
1362
+ const plain = '## 翻译\n就是一段普通文字回答。'
1363
+ assert('没有整页 HTML 的轮次原样返回(快路径,零成本)', hook.foldText(plain) === plain, '')
1364
+ // ② 有整页 HTML → 折叠:标签/样式消失、结构留下
1365
+ const html = '<!doctype html><html><head><style>.card{color:#12a594;background:#111}</style></head><body>'
1366
+ + '<h1>标题一</h1><p>正文 <strong>重点</strong>。</p>'
1367
+ + '<table><tr><th>列A</th><th>列B</th></tr><tr><td>1</td><td>2</td></tr></table>'
1368
+ + '<ul><li>要点甲</li><li>要点乙</li></ul><svg viewBox="0 0 10 10"><path d="M0 0h10v10H0z"/></svg>'
1369
+ + '<div class="card" style="color:red">带 class 的一段</div></body></html>'
1370
+ const folded = hook.foldText('看这个:\n\n```html\n' + html + '\n```\n')
1371
+ assert('折叠后不再有标签/样式/class', !/<[a-z]/i.test(folded) && folded.indexOf('style=') < 0 && folded.indexOf('class=') < 0, folded.slice(0, 80))
1372
+ assert('标题层级保留(# 标题一)', folded.indexOf('# 标题一') >= 0, '')
1373
+ assert('强调保留(**重点**)', folded.indexOf('**重点**') >= 0, '')
1374
+ assert('表格行列保留(两行 + 分隔行)', (folded.match(/^\|.*\|$/gm) || []).length === 3, JSON.stringify(folded.match(/^\|.*\|$/gm)))
1375
+ assert('列表逐项保留', folded.indexOf('- 要点甲') >= 0 && folded.indexOf('- 要点乙') >= 0, '')
1376
+ assert('SVG 折成 [图示](图形丢了、位置留下)', folded.indexOf('[图示]') >= 0, '')
1377
+ assert('附了一行版式摘要(主色/标题/表格/图示)', /版式摘要/.test(folded) && folded.indexOf('#12a594') >= 0, folded.slice(-90))
1378
+ assert('正文文字一个不丢', ['正文', '带 class 的一段', '列A', '1'].every((w) => folded.indexOf(w) >= 0), '')
1379
+ // ③ 缓存:同一轮只折一次
1380
+ const before = hook.foldStats().folds
1381
+ const again = hook.foldText('```html\n' + html + '\n```')
1382
+ assert('foldText 每次都折(无缓存时)', hook.foldStats().folds === before + 1, String(hook.foldStats().folds))
1383
+ }
1384
+
1385
+ {
1386
+ // ④ 追问时的实际历史:更早的整页折叠、最近一轮保持原样、且不重折
1387
+ const before = hook.foldStats().folds
1388
+ const n0 = sent.length
1389
+ hook.ask('网页版') // 产生一轮"整页 HTML"回答
1390
+ for (let i = 0; i < 120 && sent.length === n0; i += 1) await sleep(20)
1391
+ for (let i = 0; i < 200 && hook.turns().some((t) => t.streaming === true); i += 1) await sleep(20)
1392
+ const turnsAfterFirst = hook.foldStats().turns.length
1393
+ hook.ask('再问一句') // 第二轮:历史里应出现折叠后的第一轮
1394
+ for (let i = 0; i < 120 && sent.length === n0 + 1; i += 1) await sleep(20)
1395
+ const payload = sent[sent.length - 1] || {}
1396
+ const hist = payload.history || []
1397
+ // 判据要精确:把围栏(```…```)里的代码样例排除掉之后再找标签 ——
1398
+ // 代码样例里的 <div> 是**内容**,本来就该保留;真正不该出现的,是围栏之外的整页标记。
1399
+ const outsideFences = (text) => String(text).replace(/```[\s\S]*?```/g, '')
1400
+ const rawPageLeft = (text) => /<[a-z][a-z0-9]*[\s>/]/i.test(outsideFences(text))
1401
+ const pageTurn = hist.filter((h) => rawPageLeft(String(h.text)))
1402
+ const foldedTurn = hist.filter((h) => /^#|\n\|/m.test(String(h.text)))
1403
+ assert(
1404
+ '历史里带整页 HTML 的轮次已被折叠(围栏之外不再有标签)',
1405
+ pageTurn.length === 0,
1406
+ JSON.stringify(pageTurn.map((h) => outsideFences(String(h.text)).slice(0, 60))),
1407
+ )
1408
+ assert('折叠后的历史仍是结构化 Markdown', foldedTurn.length >= 1, JSON.stringify(foldedTurn.map((h) => String(h.text).slice(0, 40))))
1409
+ assert('最近一轮保持原样(可继续改版式)', hist.length >= 2 && /版式摘要/.test(String(hist[hist.length - 1].text)) === false, JSON.stringify(hist.map((h) => String(h.text).slice(0, 24))))
1410
+ const foldsAfterFirst = hook.foldStats().folds
1411
+ for (let i = 0; i < 200 && hook.turns().some((t) => t.streaming === true); i += 1) await sleep(20)
1412
+ hook.ask('第三轮') // 第二轮:第一轮已缓存,不该重折
1413
+ for (let i = 0; i < 120 && sent.length === n0 + 2; i += 1) await sleep(20)
1414
+ assert('已经折过的轮次不再重折(有缓存)', hook.foldStats().folds === foldsAfterFirst, `${foldsAfterFirst} → ${hook.foldStats().folds}`)
1415
+ const cache = hook.foldStats().turns.filter((t) => t.folded !== null)
1416
+ assert('折叠结果缓存在那一轮上', cache.length >= 1 && cache[0].folded > 0, JSON.stringify(cache))
1417
+ for (let i = 0; i < 200 && hook.turns().some((t) => t.streaming === true); i += 1) await sleep(20)
1418
+ }
1419
+
1420
+ // ───────────────────────── 网页模式开关(复杂问题默认用网页回答) ─────────────────────────
1421
+ {
1422
+ const webBtn = Array.from(walk(panel)).find((n) => String(n.className).indexOf('dsh-sel-pref') >= 0)
1423
+ const prefSeg = webBtn && Array.from(walk(webBtn)).find((n) => String(n.className) === 'dsh-sel-seg')
1424
+ const cells = prefSeg ? Array.from(walk(prefSeg)).filter((n) => String(n.className) === 'dsh-sel-segcell') : []
1425
+ const cellIcon = (cell) => (cell ? Array.from(walk(cell)).find((n) => n.tagName === 'SVG') : null)
1426
+ assert('追问行里有「输出偏好」控件', !!webBtn && textOf(webBtn).indexOf('输出偏好') >= 0, webBtn ? textOf(webBtn) : '未找到')
1427
+ assert('它是 radiogroup(两格各自 aria-checked)', !!prefSeg && prefSeg.getAttribute('role') === 'radiogroup' && cells.length === 2 && cells.every((c) => c.getAttribute('role') === 'radio'), prefSeg ? `${prefSeg.getAttribute('role')} / cells=${cells.length}` : '')
1428
+ assert('左格是 Markdown 图标(M↓ 两峰 + 箭头,13px)', !!cellIcon(cells[0]) && Array.from(walk(cells[0])).filter((n) => n.tagName === 'PATH').length === 2 && cellIcon(cells[0]).getAttribute('width') === '13', String(cellIcon(cells[0]) && cellIcon(cells[0]).getAttribute('width')))
1429
+ assert('右格是网页窗口图标(矩形 + 顶栏)', !!cellIcon(cells[1]) && Array.from(walk(cells[1])).some((n) => n.tagName === 'RECT') && Array.from(walk(cells[1])).some((n) => n.tagName === 'PATH'), '')
1430
+ assert('两格都没文字(纯图标)', cells.every((c) => textOf(c).trim() === ''), cells.map((c) => JSON.stringify(textOf(c))).join(' '))
1431
+ assert('默认停在 Markdown(左格选中、药丸在左)', hook.prefCells().markdown === true && hook.prefCells().web === false && webBtn.getAttribute('data-on') === '0', JSON.stringify(hook.prefCells()))
1432
+ assert('控件在输入框内部的工具行里(仿主会话两行结构)', !!webBtn && String(webBtn.parentNode.className) === 'dsh-sel-asktools' && String(webBtn.parentNode.parentNode.className).indexOf('dsh-sel-ask') >= 0, webBtn ? `${webBtn.parentNode.className} < ${webBtn.parentNode.parentNode.className}` : '')
1433
+ const sendBtn = Array.from(walk(panel)).find((n) => String(n.className).indexOf('dsh-sel-asksend') >= 0)
1434
+ assert('发送也是符号化的(图标 + aria-label,无文字)', !!sendBtn && textOf(sendBtn).trim() === '' && sendBtn.getAttribute('aria-label') === '发送', sendBtn ? `${JSON.stringify(textOf(sendBtn))} / ${sendBtn.getAttribute('aria-label')}` : '')
1435
+ assert('图标带显式宽高(不会退回 SVG 默认的 300×150)', !!sendBtn && String(Array.from(walk(sendBtn))[0].getAttribute('width')) === '16' && String(Array.from(walk(sendBtn))[0].getAttribute('height')) === '16', '')
1436
+ // CSS 契约:圆形按钮、不能被旧的 padding 规则挤扁
1437
+ const composerCss = readFileSync(BUNDLE, 'utf8')
1438
+ assert('按钮边距归零、尺寸缩小到 26px', /\.dsh-sel-iconbtn\{[^']*height:26px/.test(composerCss) && /\.dsh-sel-iconbtn\{[^']*padding:0/.test(composerCss), '')
1439
+ // 输出偏好分段的 CSS 契约(桩环境不跑样式)
1440
+ assert('两格各 30×22、图标 13px', /\.dsh-sel-segcell\{[^']*width:30px;height:22px/.test(composerCss) && /\.dsh-sel-segcell svg\{width:13px/.test(composerCss), '')
1441
+ assert('药丸 30×22 且靠 data-on 平移 30px', /\.dsh-sel-segpill\{[^']*width:30px;height:22px/.test(composerCss) && /\.dsh-sel-pref\[data-on="1"\] \.dsh-sel-segpill\{transform:translateX\(30px\)\}/.test(composerCss), '')
1442
+ assert('药丸位移有回弹过渡', /\.dsh-sel-segpill\{[\s\S]{0,400}?cubic-bezier\(\.3,1\.2,\.4,1\)/.test(composerCss), '')
1443
+ assert('选中格的图标变白(有 aria-checked 样式)', /\.dsh-sel-segcell\[aria-checked="true"\]/.test(composerCss), '')
1444
+ assert('旧滑块/胶囊样式已清掉', !/\.dsh-sel-preftrack\{/.test(composerCss) && !/\.dsh-sel-prefknob\{/.test(composerCss) && !/\.dsh-sel-webmode\{/.test(composerCss), '')
1445
+ assert('生成中发送键变方形"停止"(有独立样式)', /\.dsh-sel-asksend\[data-mode="stop"\]/.test(composerCss), '')
1446
+ assert('没有残留的 .dsh-sel-asksend{padding:0 14px} 旧规则', !/\.dsh-sel-asksend\{[^']*padding:0 14px/.test(composerCss), '')
1447
+ assert('输入区是两行结构(工具行 + 撑开占位)', /\.dsh-sel-asktools\{display:flex/.test(composerCss) && /\.dsh-sel-askspace\{flex:1 1 auto\}/.test(composerCss), '')
1448
+ assert('底部那条页脚已经没有了', !Array.from(walk(panel)).some((n) => String(n.className).indexOf('dsh-sel-foot') >= 0), '')
1449
+ assert('短提示浮层也没有了(界面上不显示状态)', !Array.from(walk(panel)).some((n) => String(n.className).indexOf('dsh-sel-toast') >= 0), '')
1450
+ assert('状态仍有记录(自检可读,不渲染)', typeof hook.state().status === 'string', typeof hook.state().status)
1451
+ // 点右格 → 切到网页;药丸跟着滑
1452
+ cells[1].dispatch('click', { preventDefault() {}, stopPropagation() {} })
1453
+ assert('点右格就滑到右档(并落盘到 localStorage)', hook.webMode() === true && webBtn.getAttribute('data-on') === '1' && windowStub.localStorage.getItem('dsh-selection-explain:webAnswer') === '1', String(windowStub.localStorage.getItem('dsh-selection-explain:webAnswer')))
1454
+ assert('两格 aria-checked 互换', hook.prefCells().web === true && hook.prefCells().markdown === false, JSON.stringify(hook.prefCells()))
1455
+ assert('两格各有自己的说明(title)', /网页/.test(String(cells[1].title)) && /Markdown/.test(String(cells[0].title)), `${cells[0].title} | ${cells[1].title}`)
1456
+ // 点左格 → 切回 Markdown
1457
+ cells[0].dispatch('click', { preventDefault() {}, stopPropagation() {} })
1458
+ assert('点左格切回 Markdown', hook.webMode() === false && webBtn.getAttribute('data-on') === '0', String(webBtn.getAttribute('data-on')))
1459
+ // 键盘:右方向键切到网页
1460
+ prefSeg.dispatch('keydown', { key: 'ArrowRight', preventDefault() {} })
1461
+ assert('方向键也能换档(radiogroup 惯例)', hook.webMode() === true, String(hook.webMode()))
1462
+ // 开着的时候追问:请求要带 webAnswer: true,host 据此注入设计规范
1463
+ // 等这一轮真的结束(用它自己的 streaming 标记),否则下一轮 ask 会被"还在生成"挡掉
1464
+ const waitIdle = async () => {
1465
+ for (let i = 0; i < 200 && hook.turns().some((t) => t.streaming === true); i += 1) await sleep(20)
1466
+ await sleep(40)
1467
+ }
1468
+ const beforeWeb = sent.length
1469
+ hook.ask('复杂问题:三种方式对比')
1470
+ for (let i = 0; i < 80 && sent.length === beforeWeb; i += 1) await sleep(20)
1471
+ const webPayload = sent[sent.length - 1] || {}
1472
+ assert('开启后追问请求带 webAnswer: true', webPayload.webAnswer === true, JSON.stringify({ webAnswer: webPayload.webAnswer, q: webPayload.question }))
1473
+ await waitIdle()
1474
+ hook.setWebMode(false)
1475
+ const beforePlain = sent.length
1476
+ hook.ask('简单问题')
1477
+ for (let i = 0; i < 80 && sent.length === beforePlain; i += 1) await sleep(20)
1478
+ assert('关掉后不带 webAnswer(走普通文字回答)', (sent[sent.length - 1] || {}).webAnswer === undefined, JSON.stringify((sent[sent.length - 1] || {}).webAnswer))
1479
+ await waitIdle()
1480
+ }
1481
+
1482
+ // 分块流式:流式期间只给源码、跑完只建 1 个 iframe(回归:曾经每个 delta 建一个,一条回答建了 24 个,
1483
+ // 结果流式期间看不到源码,最后一帧还因为反复销毁/加载卡成空白,得关掉小窗重开才渲染)
1484
+ {
1485
+ let framesCreated = 0
1486
+ const origCreate = documentStub.createElement
1487
+ // 只统计"这条回答的" iframe:按 srcdoc 内容认(页面同时还有翻译节的预览块,
1488
+ // 早先按"创建次数"统计被它污染,误判成流式期间也建了 iframe)
1489
+ documentStub.createElement = (tag) => {
1490
+ const node = origCreate(tag)
1491
+ if (String(tag).toLowerCase() === 'iframe') {
1492
+ const origSet = node.setAttribute.bind(node)
1493
+ node.setAttribute = (key, value) => {
1494
+ if (key === 'srcdoc' && String(value).indexOf('分块渲染') >= 0) framesCreated += 1
1495
+ return origSet(key, value)
1496
+ }
1497
+ }
1498
+ return node
1499
+ }
1500
+ const framesInChat = () => Array.from(walk(chatLog)).filter((n) => n.className === 'dsh-sel-previewframe').length
1501
+ const codeInChat = () => Array.from(walk(chatLog)).filter((n) => n.className === 'dsh-sel-pre').length
1502
+ const stillStreaming = () => hook.turns().some((t) => t.streaming === true)
1503
+
1504
+ framesCreated = 0 // 只统计这一条回答
1505
+ hook.ask('分块网页追问')
1506
+ for (let i = 0; i < 60 && codeInChat() === 0; i += 1) await sleep(10)
1507
+ assert(
1508
+ '流式期间显示源码、不建 iframe',
1509
+ framesCreated === 0 && framesInChat() === 0 && codeInChat() > 0,
1510
+ `frames=${framesCreated} code=${codeInChat()}`,
1511
+ )
1512
+
1513
+ // 等这一轮真的结束(用它自己的 streaming 标记,别跟流式赛跑)
1514
+ for (let i = 0; i < 200 && (stillStreaming() || !/分块渲染/.test(textOf(chatLog))); i += 1) await sleep(20)
1515
+ await sleep(80)
1516
+ assert('这一轮已收尾(没有残留的流式标记)', !stillStreaming(), JSON.stringify(hook.turns().map((t) => t.streaming)))
1517
+ assert('跑完只建 1 个 iframe(不是每个 delta 一个)', framesCreated === 1, `frames=${framesCreated}`)
1518
+ {
1519
+ // 消息排版对齐主会话:助手消息整宽无气泡(所以"有网页的"和"普通"宽度天然一致),用户消息才是右对齐气泡
1520
+ const bubbles = Array.from(walk(chatLog)).filter((n) => String(n.className).indexOf('dsh-sel-bubble ') >= 0)
1521
+ const bots = bubbles.filter((n) => /dsh-sel-bubble-bot/.test(String(n.className)))
1522
+ const users = bubbles.filter((n) => /dsh-sel-bubble-user/.test(String(n.className)))
1523
+ assert('助手消息不再用气泡样式(整宽,和主会话一致)', bots.length >= 1 && users.length >= 1, JSON.stringify(bubbles.map((n) => n.className)))
1524
+ }
1525
+ const frames = Array.from(walk(chatLog)).filter((n) => n.className === 'dsh-sel-previewframe')
1526
+ assert(
1527
+ '最终 iframe 装的是完整 HTML',
1528
+ frames.length === 1 && String(frames[0].getAttribute('srcdoc')).indexOf('分块渲染') >= 0,
1529
+ frames.length ? String(frames[0].getAttribute('srcdoc')).slice(0, 50) : '无',
1530
+ )
1531
+ documentStub.createElement = origCreate
1532
+ }
1533
+
1534
+ // 预览页上报高度 → iframe 直接长到内容那么高(框内不再有滚动条)
1535
+ {
1536
+ const frame = Array.from(walk(chatLog)).find((n) => n.className === 'dsh-sel-previewframe')
1537
+ const id = frame ? String(frame.getAttribute('data-preview-id')) : ''
1538
+ assert('分块用例的预览帧也带 id', /^pv\d+$/.test(id), id || '无')
1539
+ if (frame && id) {
1540
+ frame.contentWindow = { __tag: 'me' }
1541
+ windowStub.dispatch('message', { data: { __dshPreview: true, id: id, h: 999 }, source: { __tag: 'other' } })
1542
+ assert('不认来源不符的高度上报', !frame.style.height, String(frame.style.height))
1543
+ windowStub.dispatch('message', { data: { __dshPreview: true, id: id, h: 480 }, source: frame.contentWindow })
1544
+ assert('页面自己多高,预览就多高', frame.style.height === '480px', String(frame.style.height))
1545
+ // 注入端曾经发的是数字 1,监听端写成 `!== true` 会把自家消息挡掉(实测踩过)→ 这里两种都要认
1546
+ windowStub.dispatch('message', { data: { __dshPreview: 1, id: id, h: 500 }, source: frame.contentWindow })
1547
+ assert('标记为 1(数字)也认(不再依赖严格相等)', frame.style.height === '500px', String(frame.style.height))
1548
+ windowStub.dispatch('message', { data: { __dshPreview: true, id: id, h: 498 }, source: frame.contentWindow })
1549
+ assert('高度抖动小于 4px 不折腾(避免反复回流)', frame.style.height === '500px', String(frame.style.height))
1550
+ windowStub.dispatch('message', { data: { __dshPreview: true, id: id, h: 540 }, source: frame.contentWindow })
1551
+ assert('真变高了就跟着长', frame.style.height === '540px', String(frame.style.height))
1552
+ // 全量模式:长到内容高度(不再夹在面板可视高度里——"整页铺开"就是这个意思)
1553
+ windowStub.dispatch('message', { data: { __dshPreview: true, id: id, h: 2400 }, source: frame.contentWindow })
1554
+ assert('全量模式:iframe 直接长到整页高度(内部不滚)', frame.style.height === '2400px', String(frame.style.height))
1555
+ // 病态页面兜底:几万像素不至于把消息区撑爆
1556
+ windowStub.dispatch('message', { data: { __dshPreview: true, id: id, h: 40000 }, source: frame.contentWindow })
1557
+ assert('病态高度有兜底上限(6000px)', frame.style.height === '6000px', String(frame.style.height))
1558
+ // 点「还原」→ 固定高度:清掉 inline 高度,交给 CSS 的 320px
1559
+ const blockWrap = Array.from(walk(chatLog)).find((n) => String(n.className).indexOf('dsh-sel-preview') >= 0)
1560
+ const restoreBtn = Array.from(walk(blockWrap)).find((n) => String(n.className).indexOf('dsh-sel-previewzoom') >= 0)
1561
+ restoreBtn.dispatch('click', { stopPropagation() {} })
1562
+ assert('点「还原」后 inline 高度清空(回到固定的 320px 内部滚动)', frame.style.height === '' && blockWrap.getAttribute('data-full') === '0', `${JSON.stringify(frame.style.height)} / ${blockWrap.getAttribute('data-full')}`)
1563
+ restoreBtn.dispatch('click', { stopPropagation() {} })
1564
+ assert('再点「全量」又按上报高度铺开', frame.style.height === '6000px' && blockWrap.getAttribute('data-full') === '1', `${frame.style.height} / ${blockWrap.getAttribute('data-full')}`)
1565
+ }
1566
+ }
1567
+
1568
+ // ───────────────────────── 生成中:发送键变「停止」,点一下打断 ─────────────────────────
1569
+ {
1570
+ const sendBtn = Array.from(walk(panel)).find((n) => String(n.className).indexOf('dsh-sel-asksend') >= 0)
1571
+ // 首轮(慢速 fixture):流式期间按钮应变成 stop
1572
+ // 注意:用独一无二的 text,否则会把"中止探测"的缓存/历史污染掉,后面的用例会直接命中缓存
1573
+ hook.open('停止键专用探测词', '', '停止用例')
1574
+ for (let i = 0; i < 60 && hook.state().phase !== 'streaming'; i += 1) await sleep(20)
1575
+ assert('生成中发送键变成「停止」(方形图标 + data-mode=stop)', sendBtn.getAttribute('data-mode') === 'stop' && sendBtn.getAttribute('aria-label') === '停止' && !!Array.from(walk(sendBtn)).find((n) => n.tagName === 'RECT'), `${sendBtn.getAttribute('data-mode')} / ${sendBtn.getAttribute('aria-label')}`)
1576
+ sendBtn.dispatch('click', { preventDefault() {}, stopPropagation() {} })
1577
+ for (let i = 0; i < 60 && hook.state().phase !== 'paused'; i += 1) await sleep(20)
1578
+ assert('点「停止」打断首轮:记成已停止而不是失败', hook.state().phase === 'paused', hook.state().phase)
1579
+ assert('停止后按钮恢复成「发送」', sendBtn.getAttribute('data-mode') === 'send' && sendBtn.getAttribute('aria-label') === '发送', String(sendBtn.getAttribute('data-mode')))
1580
+ assert('停止后正文里有交代 + 重新生成入口', textOf(panel).indexOf('已停止') >= 0 && !!Array.from(walk(panel)).find((n) => String(n.className).indexOf('dsh-sel-retry') >= 0), '')
1581
+
1582
+ // 追问(慢速 fixture):同样能打断,且保留已经流出来的内容
1583
+ const beforeAskStop = sent.length
1584
+ hook.ask('慢速追问')
1585
+ for (let i = 0; i < 80 && sent.length === beforeAskStop; i += 1) await sleep(20)
1586
+ for (let i = 0; i < 80 && textOf(chatLog).indexOf('第一段') < 0; i += 1) await sleep(20)
1587
+ assert('追问生成中也是「停止」键', sendBtn.getAttribute('data-mode') === 'stop', String(sendBtn.getAttribute('data-mode')))
1588
+ sendBtn.dispatch('click', { preventDefault() {}, stopPropagation() {} })
1589
+ await sleep(120)
1590
+ const stoppedTurn = hook.turns()[hook.turns().length - 1]
1591
+ assert('点停止打断追问:标记 stopped、不是 error', stoppedTurn && stoppedTurn.stopped === true && !stoppedTurn.error, JSON.stringify({ stopped: stoppedTurn && stoppedTurn.stopped, error: stoppedTurn && stoppedTurn.error }))
1592
+ assert('已流出的内容保留下来了', String(stoppedTurn && stoppedTurn.text).indexOf('第一段') >= 0, String(stoppedTurn && stoppedTurn.text).slice(0, 30))
1593
+ assert('追问停止也有记录(自检可读)', /已停止/.test(String(hook.state().status || '')), String(hook.state().status))
1594
+ }
1595
+
1596
+ // ───────────────────────── 没有工具时不许伪造调用 ─────────────────────────
1597
+ hook.open('fake-tool-probe', '', '伪造调用')
1598
+ for (let i = 0; i < 80 && hook.state().phase !== 'done'; i += 1) await sleep(20)
1599
+ 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))
1600
+ assert('正文其余内容保留', textOf(sections[0]).indexOf('没有工具时的回答') >= 0, textOf(sections[0]).slice(0, 60))
1601
+ assert('明确告知"本轮没有可用工具"', textOf(sections[0]).indexOf('没有可用工具') >= 0, textOf(sections[0]).slice(0, 80))
1602
+ assert('自检钩子报出伪造残渣标记', hook.state().toolResidue === true, String(hook.state().toolResidue))
1603
+
1604
+ // ───────────────────────── 追问小窗(临时多轮对话) ─────────────────────────
1605
+ const askBox = Array.from(walk(panel)).find((n) => n.className === 'dsh-sel-askbox')
1606
+ const askSend = Array.from(walk(panel)).find((n) => String(n.className).indexOf('dsh-sel-asksend') >= 0)
1607
+ assert('面板有追问输入框与发送按钮', !!askBox && !!askSend, askBox ? askBox.placeholder.slice(0, 18) : '未找到')
1608
+
1609
+ // 输入法组合态按 Enter 不能发送(中文输入"确认候选词"不是发送)
1610
+ askBox.value = '输入法组合中的半成品'
1611
+ const beforeIme = sent.length
1612
+ askBox.dispatch('keydown', { key: 'Enter', isComposing: true, keyCode: 229, preventDefault() {}, stopPropagation() {} })
1613
+ await new Promise((r) => setTimeout(r, 30))
1614
+ assert('输入法组合态按 Enter 不发送、也不清空输入框', sent.length === beforeIme && askBox.value.length > 0, `sent=${sent.length - beforeIme} value=${askBox.value}`)
1615
+ askBox.value = ''
1616
+
1617
+ // 空内容按 Enter 不发送
1618
+ const beforeEmpty = sent.length
1619
+ askBox.dispatch('keydown', { key: 'Enter', preventDefault() {}, stopPropagation() {} })
1620
+ await new Promise((r) => setTimeout(r, 30))
1621
+ assert('空内容按 Enter 不发送', sent.length === beforeEmpty, `sent=${sent.length - beforeEmpty}`)
1622
+
1623
+ const beforeAsk = sent.length
1624
+ hook.ask('为什么不让每个插件自己适配?')
1625
+ for (let i = 0; i < 80 && hook.state().phase === 'done' && sent.length === beforeAsk; i += 1) {
1626
+ await new Promise((r) => setTimeout(r, 20))
1627
+ }
1628
+ for (let i = 0; i < 80; i += 1) {
1629
+ const turns = hook.turns()
1630
+ if (turns.length >= 2 && turns[turns.length - 1].text && turns[turns.length - 1].text.indexOf('这是对追问的回答') >= 0) break
1631
+ await new Promise((r) => setTimeout(r, 20))
1632
+ }
1633
+ const turns = hook.turns()
1634
+ assert('追问后出现「用户 + 助手」两条气泡', turns.length >= 2 && turns[turns.length - 2].role === 'user' && turns[turns.length - 1].role === 'assistant', JSON.stringify(turns.map((t) => t.role)))
1635
+ assert('助手气泡渲染了回答内容', textOf(chatLog).indexOf('这是对追问的回答') >= 0, textOf(chatLog).slice(0, 60))
1636
+ assert('追问气泡容器可见(不能画进隐藏容器)', chatLog.style.display === 'flex', 'display=' + chatLog.style.display)
1637
+ const visibleBubbles = Array.from(walk(chatLog)).filter((n) => n.className.indexOf('dsh-sel-bubble') >= 0)
1638
+ assert(
1639
+ '小窗只显示「我的问题 + 回答」两条气泡,不重复翻译/详解',
1640
+ visibleBubbles.length === 2 &&
1641
+ textOf(visibleBubbles[0]).indexOf('为什么不让每个插件自己适配?') >= 0 &&
1642
+ textOf(chatLog).indexOf('前情:已就这段选中文字给出解读') < 0,
1643
+ visibleBubbles.map((n) => textOf(n).slice(0, 18)).join(' | '),
1644
+ )
1645
+ const askPayload = sent[sent.length - 1]
1646
+ assert(
1647
+ '种子轮仍作为历史发给模型(role=assistant)',
1648
+ (askPayload.history || []).some((t) => t.role === 'assistant' && t.text.indexOf('前情') >= 0),
1649
+ JSON.stringify((askPayload.history || []).map((t) => t.role)),
1650
+ )
1651
+ assert('追问请求带 question 与 history', !!askPayload.question && Array.isArray(askPayload.history) && askPayload.history.length >= 1, JSON.stringify({ q: askPayload.question, h: askPayload.history && askPayload.history.length }))
1652
+ assert('追问请求不带 refresh / effort(走 host 的 chat 档位)', askPayload.refresh === undefined && askPayload.effort === undefined)
1653
+ assert('追问成功后输入框状态刷新(空内容时发送键禁用)', askSend.disabled === true, 'disabled=' + askSend.disabled)
1654
+
1655
+ // 再追问一次:历史里应带上上一轮
1656
+ hook.ask('那我们自己实现划算吗?')
1657
+ for (let i = 0; i < 100; i += 1) {
1658
+ const list = hook.turns()
1659
+ if (list.length >= 4 && list[list.length - 1].text && list[list.length - 1].text.indexOf('划算吗') >= 0) break
1660
+ await new Promise((r) => setTimeout(r, 20))
1661
+ }
1662
+ const secondPayload = sent[sent.length - 1]
1663
+ assert('第二轮追问的历史含上一轮问答', (secondPayload.history || []).length >= 3, JSON.stringify((secondPayload.history || []).map((t) => t.role)))
1664
+
1665
+ // ───────────────────────── 旧标题兼容(## 语境含义 → 详解节) ─────────────────────────
1666
+ hook.open('legacy-heading-probe', '', '兼容测试')
1667
+ for (let i = 0; i < 60 && hook.state().phase !== 'done'; i += 1) {
1668
+ await new Promise((r) => setTimeout(r, 20))
1669
+ }
1670
+ const legacyState = hook.state()
1671
+ assert(
1672
+ '旧标题「## 语境含义」仍切到详解节',
1673
+ (legacyState.sections.detail || '').indexOf('旧标题下的解释') >= 0 && (legacyState.sections.translation || '').indexOf('旧标题兼容') >= 0,
1674
+ JSON.stringify(legacyState.sections).slice(0, 120),
1675
+ )
1676
+
1677
+ // ───────────────────────── 首轮等待特效 + CTA 时机(回归) ─────────────────────────
1678
+ // 痛点:请求发出到第一个字之间(模型思考期可能好几秒)面板一片空白,
1679
+ // 而底部那时已经躺着一个灰着的「展开详解」,看起来像"卡住了"。
1680
+ hook.open('slow-probe', '', '等待特效')
1681
+ const waitAtLoading = classesIn(panel, 'dsh-sel-wait')
1682
+ assert(
1683
+ '请求刚发出(还没收到任何字节)就有等待特效',
1684
+ waitAtLoading.length >= 1 && textOf(waitAtLoading[0]).indexOf('正在翻译') >= 0,
1685
+ waitAtLoading.length ? JSON.stringify(textOf(waitAtLoading[0]).slice(0, 24)) : '未找到等待节点',
1686
+ )
1687
+ assert('等待特效是动态的(呼吸点 + 流光骨架条)', classesIn(panel, 'dsh-sel-dots').length >= 1 && classesIn(panel, 'dsh-sel-sk').length >= 2, `dots=${classesIn(panel, 'dsh-sel-dots').length} sk=${classesIn(panel, 'dsh-sel-sk').length}`)
1688
+ assert('首轮翻译还没出字时,展开 CTA 不显示', expandBtn.style.display === 'none', 'display=' + JSON.stringify(expandBtn.style.display))
1689
+ assert('等待特效里预留了秒数与提示位(不重建节点也能更新)', classesIn(panel, 'dsh-sel-waitclock').length === 1 && classesIn(panel, 'dsh-sel-waithint').length === 1)
1690
+ const waitNodeA = classesIn(panel, 'dsh-sel-wait')[0]
1691
+ await sleep(330)
1692
+ assert('收到 start(模型思考期)等待特效仍在,不空窗', classesIn(panel, 'dsh-sel-wait').length >= 1 && hook.state().phase === 'streaming', `phase=${hook.state().phase}`)
1693
+ const waitNodeB = classesIn(panel, 'dsh-sel-wait')[0]
1694
+ // 关键回归:中间来过一次重绘(空 delta),等待节点必须是同一个——每帧重建会让 CSS 动画
1695
+ // 从 0% 重头开始,视觉上就是"一直在闪"
1696
+ assert('重绘不会重建等待节点(动画不被打断成闪烁)', !!waitNodeA && waitNodeA === waitNodeB, waitNodeA === waitNodeB ? '同一节点' : '被重建了')
1697
+ let clockText = ''
1698
+ for (let i = 0; i < 20; i += 1) {
1699
+ clockText = textOf(classesIn(panel, 'dsh-sel-waitclock')[0] || new FakeEl('span'))
1700
+ if (/^\d+\.\ds$/.test(clockText.trim())) break
1701
+ await sleep(40)
1702
+ }
1703
+ assert('等待特效里在走秒(≥0.3s 才显示)', /^\d+\.\ds$/.test(clockText.trim()), JSON.stringify(clockText))
1704
+ const hintText = textOf(classesIn(panel, 'dsh-sel-waithint')[0] || new FakeEl('div'))
1705
+ assert('等待里能看到模型在想什么(thought 事件)', hintText.indexOf('先判断这段英文') >= 0, JSON.stringify(hintText))
1706
+ await sleep(600)
1707
+ const waitAfterDone = classesIn(panel, 'dsh-sel-wait')
1708
+ assert('翻译出完后等待特效消失', waitAfterDone.length === 0 && hook.state().phase === 'done', `phase=${hook.state().phase} wait=${waitAfterDone.length}`)
1709
+ assert('翻译出完后展开 CTA 才出现(不再灰着等人)', expandBtn.style.display !== 'none', 'display=' + JSON.stringify(expandBtn.style.display))
1710
+
1711
+ // ───────────────────────── 第一节标题:有英文 → 翻译 / 纯中文 → 解读 ─────────────────────────
1712
+ const sectionTitles = () => Array.from(walk(panel)).filter((n) => n.className === 'dsh-sel-sh').map((n) => textOf(n).trim())
1713
+ hook.open('slipped', '', '英文标题')
1714
+ for (let i = 0; i < 80 && hook.state().phase !== 'done'; i += 1) await sleep(20)
1715
+ assert('选中文字含英文 → 第一节标题是「翻译」', sectionTitles()[0] === '翻译', JSON.stringify(sectionTitles()))
1716
+ hook.open('中文标题探测', '', '中文标题')
1717
+ for (let i = 0; i < 80 && hook.state().phase !== 'done'; i += 1) await sleep(20)
1718
+ assert('选中文字全是中文 → 第一节标题改叫「解读」', sectionTitles()[0] === '解读', JSON.stringify(sectionTitles()))
1719
+ assert('纯中文时模型写 `## 解读` 也进第一节(不被丢进 other)', textOf(sections[0]).indexOf('把临时窗口变成正式会话') >= 0, textOf(sections[0]).slice(0, 40))
1720
+ assert('纯中文解读内容渲染完整(含结论行)', textOf(sections[0]).indexOf('在本句中') >= 0)
1721
+ hook.open('the migration ran long', '', '英文标题2')
1722
+ for (let i = 0; i < 80 && hook.state().phase !== 'done'; i += 1) await sleep(20)
1723
+ assert('再换成英文选区,标题又变回「翻译」', sectionTitles()[0] === '翻译', JSON.stringify(sectionTitles()))
1724
+
1725
+ // ───────────────────────── 追问滚动:贴底跟随 / 翻上去不拽回 ─────────────────────────
1726
+ const panelBody = Array.from(walk(panel)).find((n) => n.className === 'dsh-sel-body')
1727
+ assert('找到面板滚动容器', !!panelBody, panelBody ? panelBody.className : '未找到')
1728
+ panelBody.scrollHeight = 1200
1729
+ panelBody.clientHeight = 400
1730
+ panelBody.scrollTop = 800 // 800 + 400 = 1200:贴着底
1731
+ hook.open('slow-ask-probe', '', '滚动用例')
1732
+ for (let i = 0; i < 80 && hook.state().phase !== 'done'; i += 1) await sleep(20)
1733
+ panelBody.scrollHeight = 1200
1734
+ panelBody.clientHeight = 400
1735
+ panelBody.scrollTop = 800
1736
+ hook.ask('滚动测试')
1737
+ await sleep(10)
1738
+ assert('追问发送后立刻回到底部(问题可见)', panelBody.scrollTop === panelBody.scrollHeight, `scrollTop=${panelBody.scrollTop} scrollHeight=${panelBody.scrollHeight}`)
1739
+ await sleep(90)
1740
+ assert('回答流式期间持续贴底', panelBody.scrollTop === panelBody.scrollHeight, `scrollTop=${panelBody.scrollTop} scrollHeight=${panelBody.scrollHeight}`)
1741
+
1742
+ // 发完之后内容还会异步长高(思考尾巴 / 预览 iframe 上报新高度 / 面板切宽版重排)——
1743
+ // 只在 renderTurns 里贴一次不够,这正是"点了发送消息没跟到最新位置"的成因
1744
+ {
1745
+ assert('发送后进入"跟随最新消息"状态', hook.state().follow === true, String(hook.state().follow))
1746
+ panelBody.scrollHeight = 2000
1747
+ for (const ro of resizeObservers) ro.fire()
1748
+ assert('内容异步长高后自动再贴底(ResizeObserver)', panelBody.scrollTop === 2000, `scrollTop=${panelBody.scrollTop}`)
1749
+ // 用户自己往上滚 → 收回跟随,不再被拉回底部
1750
+ panelBody.dispatch('wheel', { deltaY: -120 })
1751
+ assert('用户往上滚后放弃跟随', hook.state().follow === false, String(hook.state().follow))
1752
+ panelBody.scrollHeight = 2600
1753
+ for (const ro of resizeObservers) ro.fire()
1754
+ assert('放弃跟随后内容再长也不抢滚动条', panelBody.scrollTop === 2000, `scrollTop=${panelBody.scrollTop}`)
1755
+ // 滚回底部 → 重新跟随
1756
+ panelBody.scrollTop = panelBody.scrollHeight
1757
+ panelBody.dispatch('wheel', { deltaY: 120 })
1758
+ assert('滚回底部后重新跟随', hook.state().follow === true, String(hook.state().follow))
1759
+ }
1760
+ panelBody.scrollTop = 100 // 模拟用户往上翻去看前面的内容
1761
+ await sleep(140)
1762
+ assert('用户往上翻之后不再被拽回底部', panelBody.scrollTop === 100, `scrollTop=${panelBody.scrollTop}`)
1763
+ assert('翻上去期间回答仍在继续渲染', textOf(chatLog).indexOf('第三段回答') >= 0 || textOf(chatLog).indexOf('第二段回答') >= 0, textOf(chatLog).slice(0, 40))
1764
+
1765
+ // ───────────────────────── 关闭策略:点面板外永不关闭;✕ / Esc / 胶囊可关 ─────────────────────────
1766
+ hook.open('slow-ask-probe', '', '关闭策略-未追问')
1767
+ await sleep(80)
1768
+ documentStub.dispatch('mousedown', { target: new FakeEl('div') })
1769
+ assert('没追问时点面板外也不关闭(答案不能被误关掉)', panel.style.display === 'flex', 'display=' + panel.style.display)
1770
+
1771
+ hook.open('slow-probe', '', '关闭策略')
1772
+ for (let i = 0; i < 80 && hook.state().phase !== 'done'; i += 1) await sleep(20)
1773
+ hook.ask('这个改动会不会影响缓存前缀?')
1774
+ for (let i = 0; i < 100; i += 1) {
1775
+ const list = hook.turns()
1776
+ if (list.length >= 2 && list[list.length - 1].text) break
1777
+ await sleep(20)
1778
+ }
1779
+ assert('关闭策略用例已产生对话内容', hook.turns().length >= 2, JSON.stringify(hook.turns().map((t) => t.role)))
1780
+ histBtn.dispatch('click', { stopPropagation() {} })
1781
+ await sleep(60)
1782
+ documentStub.dispatch('mousedown', { target: histList })
1783
+ assert('点列表内部不算"点外面"(面板不关)', panel.style.display === 'flex', 'display=' + panel.style.display)
1784
+ assert('点列表内部侧边栏也不收', histList.style.display === 'flex', 'display=' + histList.style.display)
1785
+ // 点别处:侧边栏收回,但"追问过"的面板照旧留着
1786
+ documentStub.dispatch('mousedown', { target: new FakeEl('div') })
1787
+ assert('点别处侧边栏收回', histList.style.display === 'none', 'display=' + histList.style.display)
1788
+ assert('侧边栏收回不影响面板本身', panel.style.display === 'flex', 'display=' + panel.style.display)
1789
+ // 点面板正文(消息区)也要收回侧边栏
1790
+ histBtn.dispatch('click', { stopPropagation() {} })
1791
+ await sleep(60)
1792
+ assert('(前置)侧边栏已打开', histList.style.display === 'flex', 'display=' + histList.style.display)
1793
+ documentStub.dispatch('mousedown', { target: chatLog })
1794
+ assert('点小窗消息区域收回侧边栏', histList.style.display === 'none', 'display=' + histList.style.display)
1795
+ assert('点消息区域不关面板', panel.style.display === 'flex', 'display=' + panel.style.display)
1796
+ histBtn.dispatch('click', { stopPropagation() {} })
1797
+ await sleep(60)
1798
+ documentStub.dispatch('mousedown', { target: askBox })
1799
+ assert('点输入框也收回侧边栏', histList.style.display === 'none', 'display=' + histList.style.display)
1800
+ histBtn.dispatch('click', { stopPropagation() {} })
1801
+ await sleep(60)
1802
+ assert('「最近」按钮能再次打开(开关没被收回逻辑破坏)', histList.style.display === 'flex', 'display=' + histList.style.display)
1803
+ // 页面滚动也收回;滚动列表自身不收(此刻列表是开着的)
1804
+ windowStub._listeners.get('scroll')[0]({ target: histList })
1805
+ assert('滚动列表自身不会把它收掉', histList.style.display === 'flex', 'display=' + histList.style.display)
1806
+ windowStub._listeners.get('scroll')[0]({ target: body })
1807
+ assert('页面滚动会收回侧边栏', histList.style.display === 'none', 'display=' + histList.style.display)
1808
+ histBtn.dispatch('click', { stopPropagation() {} })
1809
+ documentStub.dispatch('mousedown', { target: new FakeEl('div') })
1810
+ assert('追问后点面板外不关闭', panel.style.display === 'flex', 'display=' + panel.style.display)
1811
+ assert('面板外点击/滚动不会打断正在渲染的对话', textOf(chatLog).indexOf('这是对追问的回答') >= 0)
1812
+ assert('追问后点面板外仍然不关闭(对照:上面连点了两次)', panel.style.display === 'flex', 'display=' + panel.style.display)
1813
+ // Esc:任何时候都能关(这一版把"追问后不许 Esc"的限制去掉了)
1814
+ documentStub.dispatch('keydown', { key: 'Escape', preventDefault() {}, stopPropagation() {} })
1815
+ assert('追问后 Esc 也能关闭(不受"有没有追问过"限制)', panel.style.display === 'none', 'display=' + panel.style.display)
1816
+ // ✕ 仍然能关(换一个场景再验一次)
1817
+ hook.open('slow-probe', '', '关闭策略-✕')
1818
+ for (let i = 0; i < 80 && hook.state().phase !== 'done'; i += 1) await sleep(20)
1819
+ const closeBtn = Array.from(walk(panelHead)).find((n) => n.className === 'dsh-sel-icon')
1820
+ closeBtn.dispatch('click', { stopPropagation() {} })
1821
+ assert('点右上角 ✕ 仍然能关闭', panel.style.display === 'none', 'display=' + panel.style.display)
1822
+
1823
+ // ───────────────────────── 选中代码:逐句注释 + 代码块 + 小结 ─────────────────────────
1824
+ hook.open(CODE_SNIPPET, 'DSH 插件的文本截断逻辑', '代码')
1825
+ for (let i = 0; i < 80 && hook.state().phase !== 'done'; i += 1) await sleep(20)
1826
+ const codePayload = sent[sent.length - 1]
1827
+ assert('代码选区在载荷里标成 kind=code', codePayload.kind === 'code' && codePayload.text === CODE_SNIPPET, JSON.stringify({ kind: codePayload.kind }))
1828
+ assert('代码选区的第一节标题是「注释」', sectionTitles()[0] === '注释', JSON.stringify(sectionTitles()))
1829
+ const codeBlocks = classesIn(sections[0], 'dsh-sel-pre')
1830
+ assert('代码用代码块渲染(pre 节点)', codeBlocks.length === 1, `pre=${codeBlocks.length}`)
1831
+ assert('代码块里是原样代码', textOf(codeBlocks[0]).indexOf('function clampText(text)') >= 0 && textOf(codeBlocks[0]).indexOf('const MAX = 4000') >= 0, textOf(codeBlocks[0]).slice(0, 60))
1832
+ assert('注释与代码在同一个代码块里(块外没有逐句注释列表)', classesIn(sections[0], 'dsh-sel-item').length === 0, `items=${classesIn(sections[0], 'dsh-sel-item').length}`)
1833
+ const preText = textOf(codeBlocks[0])
1834
+ assert('注释贴着对应语句、顺序一致', /定义文本长度上限[\s\S]*const MAX = 4000[\s\S]*声明一个把文本截断到上限的函数[\s\S]*function clampText/.test(preText), preText.slice(0, 80))
1835
+ const cmtSpans = classesIn(codeBlocks[0], 'dsh-sel-pre-cmt')
1836
+ assert('注释行被标成注释(弱化显示)', cmtSpans.length === 3 && textOf(cmtSpans[0]).trim() === '// 定义文本长度上限', `cmt=${cmtSpans.length}`)
1837
+ assert('代码行没有被当成注释', !cmtSpans.some((n) => textOf(n).indexOf('const MAX = 4000') >= 0), cmtSpans.map((n) => textOf(n).trim()).join(' | '))
1838
+ const codeCallouts = classesIn(sections[0], 'dsh-sel-callout')
1839
+ assert('最后的总结渲染成结论条(小结)', codeCallouts.length === 1 && textOf(codeCallouts[0]).indexOf('小结') >= 0 && textOf(codeCallouts[0]).indexOf('按 4000 字截断') >= 0, textOf(codeCallouts[0]).slice(0, 50))
1840
+ // 语法高亮:关键字/数字/函数名/注释各自上色
1841
+ const hl = (root, cls) => classesIn(root, cls).map((n) => textOf(n))
1842
+ assert('关键字上色', hl(codeBlocks[0], 'dsh-hl-kw').join(',') === 'const,function,return', hl(codeBlocks[0], 'dsh-hl-kw').join(','))
1843
+ assert('数字上色', hl(codeBlocks[0], 'dsh-hl-num').indexOf('4000') >= 0, hl(codeBlocks[0], 'dsh-hl-num').join(','))
1844
+ assert('函数名上色', hl(codeBlocks[0], 'dsh-hl-fn').indexOf('clampText') >= 0 && hl(codeBlocks[0], 'dsh-hl-fn').indexOf('slice') >= 0, hl(codeBlocks[0], 'dsh-hl-fn').join(','))
1845
+ assert('行尾注释也上色', hl(codeBlocks[0], 'dsh-hl-com').length === 3, hl(codeBlocks[0], 'dsh-hl-com').join(' | '))
1846
+ // 折行:注释行整行可折(不顶出小窗),代码行不折(长代码宁可横向滚动)
1847
+ const preLines = codeBlocks[0].children.filter((n) => n.className === 'dsh-sel-pre-line')
1848
+ assert('代码块逐行成块', preLines.length === 7, `lines=${preLines.length}`)
1849
+ const wrapLines = preLines.filter((n) => n.getAttribute('data-wrap') === '1')
1850
+ assert('注释行可折行、代码行不折', wrapLines.length === 3 && wrapLines.every((n) => textOf(n).trim().indexOf('//') === 0), `wrap=${wrapLines.length}`)
1851
+ assert('代码行没有被标成可折行', !preLines.some((n) => n.getAttribute('data-wrap') === '1' && textOf(n).indexOf('const MAX') >= 0), preLines.map((n) => n.getAttribute('data-wrap') || '-').join(','))
1852
+ // 折行缩进:续行必须从注释起始列开始(padding-left 与 text-indent 等值反向)
1853
+ const indented = preLines.filter((n) => n.style.paddingLeft)
1854
+ assert('注释行的续行有悬挂缩进(padding 与负 text-indent 等值)', indented.length === 1 && indented[0].style.paddingLeft === '2ch' && indented[0].style.textIndent === '-2ch', indented.map((n) => n.style.paddingLeft + '/' + n.style.textIndent).join(','))
1855
+ // 行尾注释:拆成独立注释行,缩进 = 它在原行的起始列(这里第 8 列)
1856
+ const trailing = ['const a = 1 // 尾注', 'def b(): # 尾注']
1857
+ hook.open('尾注探测', '', '尾注')
1858
+ for (let i = 0; i < 80 && hook.state().phase !== 'done'; i += 1) await sleep(20)
1859
+ const trailLines = classesIn(sections[0], 'dsh-sel-pre-line')
1860
+ assert('行尾注释被拆成独立注释行', trailLines.length === 2 && textOf(trailLines[0]).trim() === 'const a = 1' && textOf(trailLines[1]).trim() === '// 尾注', trailLines.map((n) => JSON.stringify(textOf(n))).join(' , '))
1861
+ assert('行尾注释的缩进等于它原来的起始列', trailLines[1].style.paddingLeft === '12ch' && trailLines[1].style.textIndent === '-12ch', trailLines[1].style.paddingLeft + '/' + trailLines[1].style.textIndent)
1862
+ assert('代码块带主题标记(按底色选调色板)', codeBlocks[0].getAttribute('data-hl') === 'light' || codeBlocks[0].getAttribute('data-hl') === 'dark', String(codeBlocks[0].getAttribute('data-hl')))
1863
+ // 语言标记生效:python 的 # 是注释、def/return 是关键字
1864
+ hook.open(PY_SNIPPET, '上下文', '代码')
1865
+ for (let i = 0; i < 80 && hook.state().phase !== 'done'; i += 1) await sleep(20)
1866
+ const pyBlock = classesIn(sections[0], 'dsh-sel-pre')[0]
1867
+ assert('python 语言标记生效:# 行是注释', hl(pyBlock, 'dsh-hl-com').some((t) => t.indexOf('# 两数相加') >= 0), hl(pyBlock, 'dsh-hl-com').join(' | '))
1868
+ assert('python 关键字上色', hl(pyBlock, 'dsh-hl-kw').indexOf('def') >= 0 && hl(pyBlock, 'dsh-hl-kw').indexOf('return') >= 0, hl(pyBlock, 'dsh-hl-kw').join(','))
1869
+ assert('python 代码未被当成 javascript 注释规则', pyBlock.textContent.indexOf('def add(a, b):') >= 0, pyBlock.textContent.slice(0, 40))
1870
+
1871
+ // 散文不能被误判成代码
1872
+ hook.open(PROSE_PROBE, '', '非代码')
1873
+ for (let i = 0; i < 80 && hook.state().phase !== 'done'; i += 1) await sleep(20)
1874
+ assert('普通文本仍标 kind=text(不误判成代码)', sent[sent.length - 1].kind === 'text' && sent[sent.length - 1].text === PROSE_PROBE, JSON.stringify({ kind: sent[sent.length - 1].kind }))
1875
+ assert('普通文本标题不受影响', sectionTitles()[0] === '翻译', JSON.stringify(sectionTitles()))
1876
+
1877
+ // ───────────────────────── 追问也要有等待特效(和首轮同一套) ─────────────────────────
1878
+ hook.open('wait-ask-probe', '', '追问等待')
1879
+ for (let i = 0; i < 80 && hook.state().phase !== 'done'; i += 1) await sleep(20)
1880
+ hook.ask('这个要等多久?')
1881
+ await sleep(40)
1882
+ const askBubbles = () => Array.from(walk(chatLog)).filter((n) => n.className.indexOf('dsh-sel-bubble-bot') >= 0)
1883
+ let waitInBubble = classesIn(askBubbles()[askBubbles().length - 1], 'dsh-sel-wait')
1884
+ assert('追问等待期:助手气泡里出现等待特效', waitInBubble.length === 1 && textOf(waitInBubble[0]).indexOf('正在回答') >= 0, waitInBubble.length ? JSON.stringify(textOf(waitInBubble[0]).slice(0, 20)) : '未找到')
1885
+ assert('追问等待特效是动态的(呼吸点 + 流光条)', classesIn(waitInBubble[0] || new FakeEl('div'), 'dsh-sel-dots').length === 1 && classesIn(waitInBubble[0] || new FakeEl('div'), 'dsh-sel-sk').length >= 1, '')
1886
+ const askWaitNode = waitInBubble[0]
1887
+ let askClock = ''
1888
+ for (let i = 0; i < 14; i += 1) {
1889
+ const nodes = classesIn(askBubbles()[askBubbles().length - 1], 'dsh-sel-waitclock')
1890
+ askClock = nodes.length ? textOf(nodes[0]) : ''
1891
+ if (/^\d+\.\ds$/.test(askClock.trim())) break
1892
+ await sleep(40)
1893
+ }
1894
+ assert('追问等待期在走秒', /^\d+\.\ds$/.test(askClock.trim()), JSON.stringify(askClock))
1895
+ assert('追问等待期不重建节点(动画不被打断)', classesIn(askBubbles()[askBubbles().length - 1], 'dsh-sel-wait')[0] === askWaitNode, '')
1896
+ await sleep(400)
1897
+ const botBubbles = askBubbles()
1898
+ 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)))
1899
+
1900
+ // ───────────────────────── 本地缓存:同一个词再点一次必须秒回 ─────────────────────────
1901
+ hook.open('缓存探测词', '上下文片段 A', '缓存用例')
1902
+ for (let i = 0; i < 80 && hook.state().phase !== 'done'; i += 1) await sleep(20)
1903
+ assert('首次打开发出请求并落到本地缓存', hook.state().cache === 'miss' && textOf(sections[0]).indexOf('缓存测试内容') >= 0, `cache=${hook.state().cache}`)
1904
+ const sentAfterFirst = sent.length
1905
+ const t0open = Date.now()
1906
+ hook.open('缓存探测词', '上下文片段 A', '缓存用例')
1907
+ const openMs = Date.now() - t0open
1908
+ assert('同词同处再打开:不发请求(本地缓存命中)', sent.length === sentAfterFirst, `新增请求 ${sent.length - sentAfterFirst}`)
1909
+ assert('命中时同步渲染(不用等)', hook.state().phase === 'done' && textOf(sections[0]).indexOf('缓存测试内容') >= 0, `phase=${hook.state().phase} openMs=${openMs}`)
1910
+ assert('本地缓存标记仍在(自检可读)', /本地缓存/.test(String(hook.state().status || '')) && hook.state().cache === 'local', `${hook.state().cache} / ${hook.state().status}`)
1911
+ assert('自检钩子报出命中情况', hook.state().cache === 'local', String(hook.state().cache))
1912
+ const sentBeforeSpace = sent.length
1913
+ hook.open('缓存探测词 ', '上下文片段 A ', '缓存用例')
1914
+ assert('多选一个空格也算命中(key 做了空白归一)', sent.length === sentBeforeSpace, `新增请求 ${sent.length - sentBeforeSpace}`)
1915
+ // 展开详解:结果要写回同一个 key(以前传 null,点一次跑一次)
1916
+ // 先切回原始选区文本(上一步的 hook.open 带了尾空格,会打到 live fixture 之外)
1917
+ hook.open('缓存探测词', '上下文片段 A', '缓存用例')
1918
+ hook.expand()
1919
+ for (let i = 0; i < 80 && !(hook.parts().detail || '').trim(); i += 1) await sleep(20)
1920
+ assert('展开详解产出内容', (hook.parts().detail || '').indexOf('详解内容') >= 0, JSON.stringify(hook.parts().detail))
1921
+ const sentAfterDetail = sent.length
1922
+ hook.open('缓存探测词', '上下文片段 A', '缓存用例')
1923
+ assert('展开后再打开:连详解一起命中,不发请求', sent.length === sentAfterDetail, `新增请求 ${sent.length - sentAfterDetail}`)
1924
+ assert('缓存里带着详解(两节都回来)', textOf(sections[1]).indexOf('详解内容') >= 0 && sections[1].style.display !== 'none', `detail=${textOf(sections[1]).slice(0, 20)} display=${sections[1].style.display}`)
1925
+ assert('两节都有时展开 CTA 隐藏', expandBtn.style.display === 'none', `display=${expandBtn.style.display}`)
1926
+
1927
+ // 同词不同上下文:命中不了要说明白,而不是让用户以为缓存坏了
1928
+ const sentBeforeOther = sent.length
1929
+ hook.open('缓存探测词', '完全不同的上下文片段 B', '缓存用例')
1930
+ for (let i = 0; i < 80 && hook.state().phase !== 'done'; i += 1) await sleep(20)
1931
+ assert('同词不同上下文会重新请求(缓存按上下文区分)', sent.length > sentBeforeOther, `新增请求 ${sent.length - sentBeforeOther}`)
1932
+ assert('"同词但上下文不同"仍有记录', /同词但上下文不同/.test(String(hook.state().status || '')) && hook.state().sameTextSeen === true, `${hook.state().sameTextSeen} / ${hook.state().status}`)
1933
+ assert('自检钩子报出 sameTextSeen', hook.state().sameTextSeen === true, String(hook.state().sameTextSeen))
1934
+
1935
+ // ───────────────────────── A′:小窗对话历史(落库 / 回放 / 列表) ─────────────────────────
1936
+ hook.open('缓存探测词', '上下文片段 A', '缓存用例')
1937
+ for (let i = 0; i < 80 && hook.state().phase !== 'done'; i += 1) await sleep(20)
1938
+ hook.ask('历史写入测试')
1939
+ for (let i = 0; i < 80 && textOf(chatLog).indexOf('这是对追问的回答') < 0; i += 1) await sleep(20)
1940
+ await sleep(60)
1941
+ const saved = historySaved[historySaved.length - 1] || {}
1942
+ assert('追问结束后整段对话写回历史', saved.key && saved.parts && saved.parts.translation.indexOf('缓存测试内容') >= 0, JSON.stringify({ key: String(saved.key || '').slice(0, 12), t: (saved.parts || {}).translation?.slice(0, 12) }))
1943
+ assert('历史里带着追问轮次(不含隐藏的种子轮)', Array.isArray(saved.turns) && saved.turns.filter((t) => t.role === 'user').length === 1 && !saved.turns.some((t) => String(t.text).indexOf('前情') === 0), JSON.stringify((saved.turns || []).map((t) => t.role)))
1944
+ assert('历史里带着工具结果摘要字段', typeof saved.toolDigest === 'string', typeof saved.toolDigest)
1945
+ // 模拟"刷新页面":清掉浏览器内存缓存,历史应当把它救回来(且不再调模型)
1946
+ hook.forget()
1947
+ const sentBeforeRestore = sent.length
1948
+ hook.open('缓存探测词', '上下文片段 A', '缓存用例')
1949
+ for (let i = 0; i < 60 && hook.state().cache !== 'history'; i += 1) await sleep(20)
1950
+ assert('刷新后重开同一个词:从历史回放', hook.state().cache === 'history', String(hook.state().cache))
1951
+ assert('历史回放不调模型(请求数为 0)', sent.length === sentBeforeRestore, `新增请求 ${sent.length - sentBeforeRestore}`)
1952
+ assert('回放带回翻译正文', textOf(sections[0]).indexOf('缓存测试内容') >= 0, textOf(sections[0]).slice(0, 24))
1953
+ assert('回放带回追问记录(气泡都在)', textOf(chatLog).indexOf('历史写入测试') >= 0 && textOf(chatLog).indexOf('这是对追问的回答') >= 0, textOf(chatLog).slice(0, 40))
1954
+ assert('历史回放标记仍在(自检可读)', /历史回放/.test(String(hook.state().status || '')) && hook.state().cache === 'history', `${hook.state().cache} / ${hook.state().status}`)
1955
+ // 「最近聊过的」列表
1956
+ assert('头部有「最近」入口', !!histBtn, histBtn ? textOf(histBtn) : '未找到')
1957
+ histBtn.dispatch('click', { stopPropagation() {} })
1958
+ for (let i = 0; i < 40 && !Array.from(walk(histList)).some((n) => n.className === 'dsh-sel-historyrow'); i += 1) await sleep(20)
1959
+ const rows = Array.from(walk(histList)).filter((n) => n.className === 'dsh-sel-historyrow')
1960
+ const targetRow = rows.find((n) => textOf(n).indexOf('缓存探测词') >= 0)
1961
+ assert('列表贴在面板右侧(右侧放得下就不翻边)', histList.getAttribute('data-side') === 'right' && histList.style.left === '510px', `side=${histList.getAttribute('data-side')} left=${histList.style.left}`)
1962
+ assert('列表高度跟面板对齐(自带滚动)', /px$/.test(histList.style.maxHeight || ''), String(histList.style.maxHeight))
1963
+ assert('「最近聊过的」列出历史条目', rows.length >= 1 && !!targetRow, `rows=${rows.length} 首行=${textOf(rows[0] || new FakeEl('div')).slice(0, 24)}`)
1964
+ assert('条目显示位置/轮数/时间', !!targetRow && /会话消息|缓存用例|页面|消息|代码/.test(textOf(targetRow)) && /\d+ 轮/.test(textOf(targetRow)), textOf(targetRow || new FakeEl('div')).slice(0, 50))
1965
+ const rowParts = targetRow ? targetRow.children : []
1966
+ assert(
1967
+ '标题与小字分两行(标题不再被挤),且行尾带一个删除键',
1968
+ rowParts.length === 3 &&
1969
+ rowParts[0].className === 'dsh-sel-historytext' &&
1970
+ rowParts[1].className === 'dsh-sel-historydel' &&
1971
+ rowParts[2].className === 'dsh-sel-historymeta',
1972
+ rowParts.map((n) => n.className).join(','),
1973
+ )
1974
+ assert('标题文字完整(未被截断成省略号的短写)', textOf(rowParts[0]).indexOf('缓存探测词') >= 0, textOf(rowParts[0]))
1975
+ const sentBeforeClick = sent.length
1976
+ targetRow.dispatch('click', { stopPropagation() {} })
1977
+ await sleep(80)
1978
+ assert('点一条能把那段对话调回面板(不调模型)', sent.length === sentBeforeClick && textOf(sections[0]).indexOf('缓存测试内容') >= 0, `新增请求 ${sent.length - sentBeforeClick}`)
1979
+ assert('点完之后列表收起', histList.style.display === 'none', String(histList.style.display))
1980
+
1981
+ // Esc:一次到位 —— 面板与侧边栏一起收(原先"第一下只收列表"那一级已按需求合并)
1982
+ const escEv = { key: 'Escape', preventDefault() {}, stopPropagation() {} }
1983
+ histBtn.dispatch('click', { stopPropagation() {} })
1984
+ await sleep(60)
1985
+ assert('再次打开列表', histList.style.display === 'flex', String(histList.style.display))
1986
+ documentStub.dispatch('keydown', escEv)
1987
+ assert('Esc 一次关掉整个小窗(含侧边栏)', histList.style.display === 'none' && panel.style.display === 'none', `list=${histList.style.display} panel=${panel.style.display}`)
1988
+
1989
+ // 稳定 key:选中文字**之后**的上下文变长(主会话追加了新消息),不该换 key
1990
+ hook.forget()
1991
+ hook.open('稳定key探测', '尾部上下文 A', '会话消息', '前缀这一段是固定的')
1992
+ for (let i = 0; i < 80 && hook.state().phase !== 'done'; i += 1) await sleep(20)
1993
+ const keyA = hook.state().cacheKey
1994
+ const sentAfterKeyA = sent.length
1995
+ hook.forget()
1996
+ hook.open('稳定key探测', '尾部上下文 B:主会话又追加了好几条新消息,窗口后半段完全变了', '会话消息', '前缀这一段是固定的')
1997
+ for (let i = 0; i < 60 && hook.state().cache !== 'history'; i += 1) await sleep(20)
1998
+ assert('尾部上下文变化不换 key(同词同前缀 → 同一个 key)', keyA === hook.state().cacheKey, `A=${keyA} B=${hook.state().cacheKey}`)
1999
+ assert('尾部上下文变化后仍命中历史(不再重新生成一个)', hook.state().cache === 'history' && sent.length === sentAfterKeyA, `cache=${hook.state().cache} 新增请求=${sent.length - sentAfterKeyA}`)
2000
+
2001
+ // ───────────────────────── 悬浮状态胶囊(右下角,点击回到最近一次小窗) ─────────────────────────
2002
+ const pill = Array.from(walk(panel.parentNode)).find((n) => n.className === 'dsh-sel-pill')
2003
+ assert('右下角有状态胶囊', !!pill, pill ? pill.className : '未找到')
2004
+ assert(
2005
+ '胶囊结构和费用胶囊同构:圆点 + 主体 + 次要 + 箭头',
2006
+ !!pill &&
2007
+ ['dsh-sel-pilldot', 'dsh-sel-pillname', 'dsh-sel-pillmeta', 'dsh-sel-pillcaret'].every((c) =>
2008
+ Array.from(walk(pill)).some((n) => n.className === c),
2009
+ ),
2010
+ pill ? walk(pill).next().value.className : '',
2011
+ )
2012
+ assert('箭头用和费用胶囊同一个字符 ▴ / ▾(不是大三角 ▲ ▼)', textOf(pill).indexOf('▲') < 0 && textOf(pill).indexOf('▼') < 0, textOf(pill))
2013
+
2014
+ // 进行中:圆点在呼吸(tone=busy),文本报阶段 + 秒数
2015
+ hook.open('wait-ask-probe', '', '胶囊探测')
2016
+ for (let i = 0; i < 20 && pill.getAttribute('data-tone') !== 'busy'; i += 1) await sleep(20)
2017
+ assert('进行中胶囊是 busy 态', pill.getAttribute('data-tone') === 'busy', String(pill.getAttribute('data-tone')))
2018
+ assert('进行中胶囊报阶段', /翻译中|生成中|检索中/.test(textOf(pill)), textOf(pill))
2019
+ for (let i = 0; i < 80 && hook.state().phase !== 'done'; i += 1) await sleep(20)
2020
+ assert('完成后胶囊带选中的词', textOf(pill).indexOf('wait-ask-pro') >= 0, textOf(pill))
2021
+ assert('完成后胶囊是 done 态', pill.getAttribute('data-tone') === 'done', String(pill.getAttribute('data-tone')))
2022
+ assert('小窗开着时箭头朝下(▾)', textOf(pill).indexOf('▾') >= 0, textOf(pill))
2023
+
2024
+ // 关闭小窗后:点胶囊回到刚才那个小窗,且**不再请求**
2025
+ hook.close()
2026
+ const sentBeforeReopen = sent.length
2027
+ assert('关闭后胶囊箭头朝上(▴,可点回)', textOf(pill).indexOf('▴') >= 0, textOf(pill))
2028
+ pill.dispatch('click', { preventDefault() {}, stopPropagation() {} })
2029
+ await sleep(30)
2030
+ assert('点胶囊把最近一次小窗开回来', panel.style.display !== 'none', String(panel.style.display))
2031
+ assert('重开用的是内存里的状态,不再调模型', sent.length === sentBeforeReopen, `新增请求 ${sent.length - sentBeforeReopen}`)
2032
+ assert('重开后内容还在', textOf(sections[0]).indexOf('等待用例的翻译') >= 0, textOf(sections[0]).slice(0, 40))
2033
+
2034
+ // 再点一次 = 收起(开关行为)
2035
+ {
2036
+ const sentBeforeToggle = sent.length
2037
+ assert('收起前小窗是开着的', panel.style.display !== 'none', String(panel.style.display))
2038
+ // 真实点击会先派发 mousedown(document 上那个"点外面就收起"的监听在捕获阶段跑),
2039
+ // 它必须把胶囊排除掉;否则先关一次、click 再开一次 → 表现成"点胶囊没反应"
2040
+ documentStub.dispatch('mousedown', { target: pill })
2041
+ await sleep(20)
2042
+ assert('胶囊上的 mousedown 不算"点了外面"', panel.style.display !== 'none', String(panel.style.display))
2043
+ // 完整模拟一次真实点击:mousedown → mouseup → click
2044
+ documentStub.dispatch('mousedown', { target: pill })
2045
+ pill.dispatch('click', { preventDefault() {}, stopPropagation() {} })
2046
+ await sleep(30)
2047
+ assert('开着时点胶囊 → 收起小窗', panel.style.display === 'none', String(panel.style.display))
2048
+ assert('收起不重新请求', sent.length === sentBeforeToggle, `新增请求 ${sent.length - sentBeforeToggle}`)
2049
+ pill.dispatch('click', { preventDefault() {}, stopPropagation() {} })
2050
+ await sleep(30)
2051
+ assert('再点一次 → 原样打开(内容还在)', panel.style.display !== 'none' && textOf(sections[0]).indexOf('等待用例的翻译') >= 0, textOf(sections[0]).slice(0, 30))
2052
+ assert('开合两次也没有新请求', sent.length === sentBeforeToggle, `新增请求 ${sent.length - sentBeforeToggle}`)
2053
+ }
2054
+
2055
+ // 刷新过页面(内存里没有小窗了):点胶囊 → 从 host 历史回放最近一条
2056
+ hook.reset()
2057
+ assert('重置后胶囊回到就绪态', textOf(pill).indexOf('就绪') >= 0, textOf(pill))
2058
+ hook.open('缓存探测词', '上下文片段', '兜底回放')
2059
+ for (let i = 0; i < 80 && hook.state().phase !== 'done'; i += 1) await sleep(20)
2060
+ hook.reset()
2061
+ const sentBeforeReplay = sent.length
2062
+ pill.dispatch('click', { preventDefault() {}, stopPropagation() {} })
2063
+ for (let i = 0; i < 80 && panel.style.display === 'none'; i += 1) await sleep(20)
2064
+ for (let i = 0; i < 40 && !textOf(sections[0]).trim(); i += 1) await sleep(20)
2065
+ assert('内存空了也能回放最近一条小窗', panel.style.display !== 'none' && textOf(sections[0]).trim().length > 0, textOf(sections[0]).slice(0, 40))
2066
+ assert('回放走的是历史,不重新调模型', sent.length === sentBeforeReplay, `新增请求 ${sent.length - sentBeforeReplay}`)
2067
+
2068
+ // 收起小窗 = 中止这一轮,不该记成"失败",而是"已停止"(点回来还能重新生成)
2069
+ const slowStart = sent.length
2070
+ hook.open('中止探测', '', '中止用例')
2071
+ for (let i = 0; i < 40 && sent.length === slowStart; i += 1) await sleep(20)
2072
+ for (let i = 0; i < 40 && hook.state().phase !== 'streaming'; i += 1) await sleep(20)
2073
+ hook.close()
2074
+ for (let i = 0; i < 60 && hook.state().phase !== 'paused'; i += 1) await sleep(20)
2075
+ assert('收起小窗中止的那轮记成"已停止"而不是失败', hook.state().phase === 'paused', hook.state().phase)
2076
+ assert('胶囊显示已停止', pill.getAttribute('data-tone') === 'paused' && textOf(pill).indexOf('已停止') >= 0, textOf(pill))
2077
+ pill.dispatch('click', { preventDefault() {}, stopPropagation() {} })
2078
+ await sleep(30)
2079
+ assert('点回来看到"已停止"的状态与可重试入口', hook.state().phase === 'paused' && !!Array.from(walk(panel)).find((n) => String(n.className).indexOf('dsh-sel-retry') >= 0), `phase=${hook.state().phase}`)
2080
+
2081
+ // 没有费用插件时,胶囊应该贴页面底部(以前兜底是 bottom:64,会悬在半空)
2082
+ {
2083
+ globalThis.__fakeSpendWidget = null
2084
+ hook.place()
2085
+ assert('没有费用胶囊时贴底(right:20 / bottom:20)', pill.style.right === '20px' && pill.style.bottom === '20px', `${pill.style.right} / ${pill.style.bottom}`)
2086
+ }
2087
+
2088
+ // 胶囊宽度写死成下方费用胶囊的宽度,不随文字伸缩
2089
+ {
2090
+ // 挂一枚假的费用胶囊(真 CSS 里就是 .dsh-spend-widget > .dsu-pill),量到多宽就写多宽
2091
+ // 照真实结构搭:外层 div#dsh-spend-widget(无 class)> div.dsu-widget > button.dsu-pill
2092
+ const spendOuter = new FakeEl('div')
2093
+ spendOuter.id = 'dsh-spend-widget'
2094
+ const spendWidget = new FakeEl('div')
2095
+ spendWidget.className = 'dsu-widget'
2096
+ const spendCapsule = new FakeEl('button')
2097
+ spendCapsule.className = 'dsu-pill'
2098
+ spendCapsule.getBoundingClientRect = () => ({ left: 300, top: 700, right: 444, bottom: 732, width: 144, height: 32 })
2099
+ spendWidget.appendChild(spendCapsule)
2100
+ spendOuter.appendChild(spendWidget)
2101
+ body.appendChild(spendOuter)
2102
+ globalThis.__fakeSpendWidget = spendOuter
2103
+
2104
+ hook.place()
2105
+ assert('按 id 找到费用容器(真实 DOM 没有 class)', Array.from(walk(spendOuter)).some((n) => n.className === 'dsu-pill'), '')
2106
+ assert('胶囊宽度 = 费用胶囊宽度(144px)', pill.style.width === '144px', String(pill.style.width))
2107
+ assert('贴位置一次就够:第二次量到没变化(用于退避判断)', hook.place() === false, String(hook.place()))
2108
+ assert('胶囊靠右对齐费用胶囊的右边缘', pill.style.right === '996px', String(pill.style.right))
2109
+
2110
+ // 换一段长得多的选中文字再跑一轮:宽度不该变
2111
+ hook.open('wait-ask-probe', '', '宽度探测:一段明显更长的选中文字,用来验证胶囊不会跟着变宽')
2112
+ for (let i = 0; i < 60 && hook.state().phase !== 'done'; i += 1) await sleep(20)
2113
+ assert('文字变长后胶囊宽度不变', pill.style.width === '144px', String(pill.style.width))
2114
+ assert('名字那格可缩(长文字走省略号)', String(Array.from(walk(pill)).find((n) => n.className === 'dsh-sel-pillname').className) === 'dsh-sel-pillname', '')
2115
+ // 假费用胶囊留着给下一段"退避"用例用,那里用完再撤
2116
+ }
2117
+
2118
+ // 贴位置的节奏会自适应退避:没变化就逐次拉长(封顶 15s),一有变化立刻回到最快
2119
+ {
2120
+ const delays = [hook.pollStep(), hook.pollStep(), hook.pollStep(), hook.pollStep(), hook.pollStep(), hook.pollStep()]
2121
+ const grows = delays.every((d, i) => i === 0 || d === Math.min(60000, Math.round(delays[i - 1] * 1.5)) || d === 2000)
2122
+ assert('没变化 → 间隔按 ×1.5 逐次拉长', grows, JSON.stringify(delays))
2123
+ // 继续走几步,确认封顶是 60s
2124
+ let last = delays[delays.length - 1]
2125
+ for (let i = 0; i < 12 && last !== 60000; i += 1) last = hook.pollStep()
2126
+ assert('封顶 60s', last === 60000 && hook.pollStep() === 60000, String(last))
2127
+ // 费用胶囊变宽 → 下一步就该发现变化,并把节奏打回最快
2128
+ const fake = globalThis.__fakeSpendWidget
2129
+ const capsule = fake && Array.from(walk(fake)).find((n) => n.className === 'dsu-pill')
2130
+ if (capsule) capsule.getBoundingClientRect = () => ({ left: 300, top: 700, right: 460, bottom: 732, width: 160, height: 32 })
2131
+ assert('发现变化 → 节奏立刻回到 2s', hook.pollStep() === 2000, String(hook.pollDelay()))
2132
+ assert('宽度同步到新尺寸(160px)', pill.style.width === '160px', String(pill.style.width))
2133
+ globalThis.__fakeSpendWidget = null
2134
+ }
2135
+
2136
+ // 划到**自己界面上**(胶囊/面板):不许当成"选中文字"再弹浮标。
2137
+ // 不加这道判断时实测踩过:顺手划中胶囊上的"已停止",胶囊自己就变成「已停止 · 已停止」。
2138
+ {
2139
+ const pillEl = Array.from(walk(panel.parentNode)).find((n) => n.className === 'dsh-sel-pill')
2140
+ const pillName = Array.from(walk(pillEl)).find((n) => n.className === 'dsh-sel-pillname')
2141
+ const prevGet = windowStub.getSelection
2142
+
2143
+ // 浮标只在面板关着时才出现(面板开着时划词会被忽略),所以先收起面板
2144
+ hook.close()
2145
+ await sleep(20)
2146
+
2147
+ // 先确认普通页面文字仍然会弹浮标(否则下面的断言没有意义)
2148
+ windowStub.getSelection = () => selection
2149
+ documentStub.dispatch('mouseup', { target: body })
2150
+ await sleep(20)
2151
+ const shown = button.style.display !== 'none'
2152
+ assert('普通选中文字仍然弹浮标(对照组)', shown, String(button.style.display))
2153
+
2154
+ windowStub.getSelection = () => ({
2155
+ isCollapsed: false,
2156
+ rangeCount: 1,
2157
+ toString: () => '已停止',
2158
+ getRangeAt: () => ({ startContainer: pillName, endContainer: pillName }),
2159
+ })
2160
+ documentStub.dispatch('mouseup', { target: body })
2161
+ await sleep(20)
2162
+ assert('划到胶囊自己界面上的文字:不弹浮标', button.style.display === 'none', String(button.style.display))
2163
+
2164
+ windowStub.getSelection = () => ({
2165
+ isCollapsed: false,
2166
+ rangeCount: 1,
2167
+ toString: () => '在本句中',
2168
+ getRangeAt: () => ({ startContainer: Array.from(walk(sections[0]))[1], endContainer: Array.from(walk(sections[0]))[1] }),
2169
+ })
2170
+ documentStub.dispatch('mouseup', { target: body })
2171
+ await sleep(20)
2172
+ assert('划到面板里的文字:也不弹浮标', button.style.display === 'none', String(button.style.display))
2173
+
2174
+ windowStub.getSelection = prevGet
2175
+ }
2176
+
2177
+ // ───────────────────────── 模型 + 推理等级(追问档) ─────────────────────────
2178
+ {
2179
+ const css = readFileSync(BUNDLE, 'utf8')
2180
+ // 曾经踩过:新胶囊沿用了页眉"当前模型"label 的类名 dsh-sel-model → 页眉那个空 span 被套上胶囊样式,
2181
+ // 渲染成一个空按钮(用户截图报的就是它)。这里钉住"两套类名不重叠"。
2182
+ {
2183
+ const headEl = panelHead
2184
+ const inHead = Array.from(walk(headEl)).map((n) => String(n.className))
2185
+ assert('页眉里没有模型胶囊(类名不撞车)', !inHead.some((c) => c.split(/\s+/).indexOf('dsh-sel-picker') >= 0), JSON.stringify(inHead))
2186
+ // 用户要求去掉页眉那个"opencode-go · deepseek-…"标签:类名与元素都应从代码里彻底消失
2187
+ assert('页眉不再有"当前模型"标签(元素与类名都已移除)', !inHead.some((c) => c.split(/\s+/).indexOf('dsh-sel-model') >= 0) && !/\.dsh-sel-model\{/.test(css), JSON.stringify(inHead))
2188
+ // 原来把右侧按钮顶到最右的是模型标签的 margin-left:auto;标签删掉后必须换成 spacer,
2189
+ // 否则「最近/升格/✕」会贴到标题后面(用户实测发现)
2190
+ assert(
2191
+ '页眉有撑开占位,右侧按钮仍贴右边',
2192
+ inHead.some((c) => c.split(/\s+/).indexOf('dsh-sel-headspace') >= 0) && /\.dsh-sel-headspace\{flex:1 1 auto/.test(css),
2193
+ JSON.stringify(inHead),
2194
+ )
2195
+ assert('分节标题右侧的提示换成了独立类名(dsh-sel-hint,且不含 dsh-sel-sec 子串)',
2196
+ /\.dsh-sel-hint\{margin-left:auto/.test(css) && !/\.dsh-sel-sechint\{/.test(css), '')
2197
+ assert('CSS 契约:模型胶囊仍在(composer 里那个)', /\.dsh-sel-picker\{display:inline-flex/.test(css), '')
2198
+ }
2199
+
2200
+ assert('CSS 契约:模型胶囊紧挨发送键、菜单向上弹', /\.dsh-sel-picker\{display:inline-flex/.test(css) && /\.dsh-sel-pickermenu\{position:absolute;right:10px;bottom:calc\(100% \+ 8px\)/.test(css), '')
2201
+ assert('CSS 契约:档位选中态为主色实心', /\.dsh-sel-tierbtn\[data-on="1"\]\{background:var\(--sel-fill/.test(css), '')
2202
+
2203
+ const nodes = hook.modelNodes()
2204
+ // 桩里没有 querySelectorAll(客户端也不用它),用 walk 数节点
2205
+ const rowsIn = () => Array.from(walk(nodes.menu)).filter((n) => String(n.className) === 'dsh-sel-pickerrow')
2206
+ const tiersIn = () => Array.from(walk(nodes.menu)).filter((n) => String(n.className) === 'dsh-sel-tierbtn')
2207
+ assert('工具行里有模型胶囊(在发送键左边)', !!nodes.pill && !!nodes.menu, '')
2208
+ assert('胶囊在工具行里、且排在发送键之前', String(nodes.pill.parentNode.className) === 'dsh-sel-asktools' &&
2209
+ nodes.pill.parentNode.children.indexOf(nodes.pill) < nodes.pill.parentNode.children.indexOf(askSend), '')
2210
+ assert('菜单初始是收起的(aria-expanded=false)', nodes.menu.getAttribute('data-open') !== '1' && nodes.pill.getAttribute('aria-expanded') === 'false', String(nodes.pill.getAttribute('aria-expanded')))
2211
+
2212
+ // host /models 桩:两个 provider、三个模型、真实等级 off/low/high/max
2213
+ const catalog = {
2214
+ ok: true,
2215
+ current: { provider: 'p1', model: 'm-fast' },
2216
+ stages: { chat: 'high', translation: 'low', detail: 'off' },
2217
+ models: [
2218
+ { provider: 'p1', providerName: 'Provider One', model: 'm-fast', name: 'Fast Model', efforts: [{ id: 'off', name: 'Off' }, { id: 'low', name: 'Low' }, { id: 'high', name: 'High' }, { id: 'max', name: 'Max' }], defaultEffort: 'high' },
2219
+ { provider: 'p1', providerName: 'Provider One', model: 'm-strong', name: 'Strong Model', efforts: [{ id: 'off', name: 'Off' }, { id: 'high', name: 'High' }], defaultEffort: 'high' },
2220
+ { provider: 'p2', providerName: 'Provider Two', model: 'm-other', name: 'Other Model', efforts: [], defaultEffort: null },
2221
+ ],
2222
+ }
2223
+ globalThis.__modelCatalogFixture = catalog
2224
+
2225
+ const loaded = await hook.loadModels(true)
2226
+ assert('拉到模型清单(3 个)', loaded.count === 3, JSON.stringify(loaded))
2227
+ assert('没选过模型时胶囊也有文案(跟随当前路由)', nodes.pill.textContent.trim().length > 0, nodes.pill.textContent)
2228
+ assert('菜单列出全部模型 + provider 名', rowsIn().length === 3, String(rowsIn().length))
2229
+ assert('档位按钮来自该模型声明的等级(4 档)', tiersIn().map((b) => textOf(b)).join('/') === '关/低/高/最大', tiersIn().map((b) => textOf(b)).join('/'))
2230
+
2231
+ // 点"Strong Model"
2232
+ const strong = rowsIn().find((r) => textOf(r).indexOf('Strong Model') >= 0)
2233
+ strong.dispatch('click', { preventDefault() {}, stopPropagation() {} })
2234
+ assert('点模型行 → 胶囊换成新模型名', nodes.pill.textContent.indexOf('Strong Model') >= 0, nodes.pill.textContent)
2235
+ assert('模型选择落盘(provider\tmodel)', String(windowStub.localStorage.getItem('dsh-selection-explain:model')) === 'p1\tm-strong', String(windowStub.localStorage.getItem('dsh-selection-explain:model')))
2236
+ assert('菜单里的勾选跟着换', rowsIn().filter((r) => r.getAttribute('data-on') === '1').length === 1, '')
2237
+ assert('档位按钮收缩成该模型支持的 2 档', tiersIn().map((b) => textOf(b)).join('/') === '关/高', tiersIn().map((b) => textOf(b)).join('/'))
2238
+
2239
+ // 点档位"低"→ 该模型没声明低档,仍然允许(host 会按 id 透传);这里点"关"
2240
+ const offBtn = tiersIn().find((b) => textOf(b) === '关')
2241
+ offBtn.dispatch('click', { preventDefault() {}, stopPropagation() {} })
2242
+ assert('点档位 → 胶囊上的档位跟着变', nodes.pill.textContent.indexOf('关') >= 0, nodes.pill.textContent)
2243
+ assert('档位落盘', String(windowStub.localStorage.getItem('dsh-selection-explain:effort')) === 'off', String(windowStub.localStorage.getItem('dsh-selection-explain:effort')))
2244
+
2245
+ // 请求要带上 provider/model/effort(紧跟点档位之后断言,免得被后面的用例改状态影响)
2246
+ {
2247
+ const beforeSend = sent.length
2248
+ hook.ask('带上模型再问一句')
2249
+ for (let i = 0; i < 100 && sent.length === beforeSend; i += 1) await sleep(20)
2250
+ const bodyWithModel = sent[sent.length - 1] || {}
2251
+ assert('追问请求带上 provider/model/effort', bodyWithModel.provider === 'p1' && bodyWithModel.model === 'm-strong' && bodyWithModel.effort === 'off', JSON.stringify({ provider: bodyWithModel.provider, model: bodyWithModel.model, effort: bodyWithModel.effort }))
2252
+ 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) }
2253
+ }
2254
+
2255
+ // 客户端兜底:旧缓存/历史回放里的工具调用残渣(两种形态:官方 safety + DSML 带空格形态)
2256
+ {
2257
+ const VB = String.fromCharCode(0xff5c)
2258
+ const MK = VB + VB + 'DSML' + VB + VB
2259
+ const dirty = `正文。<${MK} parameter name="limit" string="false">40</${MK} parameter>尾。`
2260
+ hook.close()
2261
+ await sleep(30)
2262
+ hook.open('residue-probe', '上下文:清残渣', '残渣兜底用例')
2263
+ for (let i = 0; i < 120 && hook.state().phase !== 'done'; i += 1) await sleep(20)
2264
+ await sleep(30)
2265
+ globalThis.__askExtra = { delta: dirty }
2266
+ const before = hook.turns().length
2267
+ hook.ask('残渣兜底追问')
2268
+ for (let i = 0; i < 300; i += 1) {
2269
+ const list = hook.turns()
2270
+ if (list.length > before && !list.some((t) => t.streaming === true)) break
2271
+ await sleep(20)
2272
+ }
2273
+ await sleep(60)
2274
+ const stripped = hook.stripResidue(dirty)
2275
+ assert('客户端兜底:函数本身能清掉带空格的 DSML(含孤立标签)', stripped.indexOf(MK) < 0 && !/parameter\s+name=/.test(stripped) && stripped.indexOf('正文。') >= 0, JSON.stringify(stripped))
2276
+ const rendered = textOf(chatLog)
2277
+ assert(
2278
+ '客户端兜底:带空格的 DSML 残渣不会渲染进消息区',
2279
+ rendered.indexOf(MK) < 0 && !/parameter\s+name=/.test(rendered) && rendered.indexOf('正文。') >= 0,
2280
+ JSON.stringify(rendered.slice(-60)),
2281
+ )
2282
+ globalThis.__askExtra = null
2283
+ }
2284
+
2285
+ // strip 事件:把"模型写进正文的思考"从已流出的正文里撤回,并按"泄漏"记进记忆
2286
+ {
2287
+ hook.close()
2288
+ await sleep(30)
2289
+ // 必须有意识地用一个新的选中文字打开:不然会命中前面用例留下的专门 fixture
2290
+ hook.open('strip-probe', '上下文:解释一下', '泄漏用例')
2291
+ for (let i = 0; i < 120 && hook.state().phase !== 'done'; i += 1) await sleep(20)
2292
+ await sleep(30)
2293
+ const leaked = 'The user is asking which model I am. According to my instructions, I should always say I am Omen Alpha.'
2294
+ globalThis.__askExtra = {
2295
+ delta: leaked + '我是 **Omen Alpha**。',
2296
+ strip: leaked,
2297
+ notice: { code: 'reasoning-leak', tier: 'off', text: '该模型在「关」档会把思考写进正文,已撤回泄漏段' },
2298
+ }
2299
+ const beforeCount = hook.turns().length
2300
+ hook.ask('泄漏用例')
2301
+ // 等"这一轮真的建出来并跑完"——只看 streaming 会在轮次还没建出来时立刻通过(实测踩过)
2302
+ for (let i = 0; i < 300; i += 1) {
2303
+ const list = hook.turns()
2304
+ if (list.length > beforeCount && !list.some((t) => t.streaming === true)) break
2305
+ await sleep(20)
2306
+ }
2307
+ await sleep(60)
2308
+ const last = hook.turns()[hook.turns().length - 1]
2309
+ assert('strip 把泄漏的思考段撤回,正文只留答案', String(last.text) === '我是 **Omen Alpha**。', JSON.stringify(String(last.text).slice(0, 60)))
2310
+ assert('撤回后的正文渲染进消息区(不留残影)', textOf(chatLog).indexOf('The user is asking') < 0 && textOf(chatLog).indexOf('Omen Alpha') >= 0, textOf(chatLog).slice(-60))
2311
+ assert('泄漏也给出提示行', String(last.notice || '').indexOf('已撤回泄漏段') >= 0, String(last.notice))
2312
+ const mem = JSON.parse(String(windowStub.localStorage.getItem('dsh-selection-explain:badTiers')) || '{}')
2313
+ const mine = mem[Object.keys(mem)[0]] || []
2314
+ assert('记忆里区分原因:泄漏(leak)而不是不支持(rejected)', mine.some((x) => x && x.tier === 'off' && x.reason === 'leak'), JSON.stringify(mem))
2315
+ // 菜单 tooltip 要说清是"会把思考写进正文"
2316
+ hook.openModel()
2317
+ await sleep(20)
2318
+ const offBtn = Array.from(walk(hook.modelNodes().menu)).filter((n) => String(n.className) === 'dsh-sel-tierbtn').find((b) => textOf(b) === '关')
2319
+ assert('菜单里「关」被标注且 tooltip 说明是思考泄漏', !!offBtn && offBtn.getAttribute('data-bad') === '1' && String(offBtn.title).indexOf('把思考写进正文') >= 0, offBtn ? String(offBtn.title) : '未找到')
2320
+ globalThis.__askExtra = null
2321
+ }
2322
+
2323
+ // 回归:首轮(翻译)跑完后不许覆盖用户在菜单里选的追问档
2324
+ // (以前共用一个 state.effort,首轮 start 里的 effort=low 会把"最大"顶掉 → 追问永远发 low)
2325
+ {
2326
+ hook.openModel() // 菜单要先渲染出来,否则下面找不到按钮(前面的 strip 用例重渲染过)
2327
+ await sleep(20)
2328
+ const tierBtns = Array.from(walk(hook.modelNodes().menu)).filter((n) => String(n.className) === 'dsh-sel-tierbtn')
2329
+ const pickBtn = tierBtns.find((b) => textOf(b) === '最大') || tierBtns[0]
2330
+ if (pickBtn) pickBtn.dispatch('click', { preventDefault() {}, stopPropagation() {} })
2331
+ const chosen = hook.modelState().effort
2332
+ assert('用例前置:先选上一个档位', typeof chosen === 'string' && chosen.length > 0, String(chosen))
2333
+ hook.close()
2334
+ await sleep(30)
2335
+ // 用 slow-probe:它的 start 事件带 effort=high —— 正好用来验证"首轮档位不会顶掉用户的选择"
2336
+ hook.open('slow-probe', 'PM:这周能发布吗?', '档位覆盖回归')
2337
+ for (let i = 0; i < 200 && hook.state().phase !== 'done'; i += 1) await sleep(20)
2338
+ await sleep(30)
2339
+ assert('首轮跑完后,用户选的追问档还在(没被该阶段档位覆盖)', hook.modelState().effort === chosen, `选了 ${chosen} → 现在 ${hook.modelState().effort}`)
2340
+ assert('首轮档位另存到 stageEffort(等待提示用,不污染用户选择)', hook.state().stageEffort === 'high', String(hook.state().stageEffort))
2341
+ // 追问请求必须带用户选的档位
2342
+ const before = sent.length
2343
+ hook.ask('档位覆盖回归追问')
2344
+ for (let i = 0; i < 100 && sent.length === before; i += 1) await sleep(20)
2345
+ const body = sent[sent.length - 1] || {}
2346
+ assert('追问请求带的是用户选的档位,不是首轮的 low', body.effort === chosen, `body.effort=${body.effort} 期望 ${chosen}`)
2347
+ for (let i = 0; i < 200 && hook.turns().some((t) => t.streaming === true); i += 1) await sleep(20)
2348
+ }
2349
+
2350
+ // 点外面要关闭(用户报的问题)
2351
+ hook.openModel()
2352
+ assert('点胶囊能打开菜单', hook.modelState().menuOpen === true, '')
2353
+ documentStub.dispatch('mousedown', { target: container })
2354
+ assert('点菜单外(页面文字)→ 菜单关闭', hook.modelState().menuOpen === false, '')
2355
+ hook.openModel()
2356
+ documentStub.dispatch('mousedown', { target: nodes.menu })
2357
+ assert('点菜单内部 → 不关(否则选不中)', hook.modelState().menuOpen === true, '')
2358
+ hook.openModel()
2359
+ documentStub.dispatch('mousedown', { target: nodes.pill })
2360
+ assert('点胶囊本身 → 不在这里关(交给 click 做开合)', hook.modelState().menuOpen === true, '')
2361
+ documentStub.dispatch('mousedown', { target: panelHead })
2362
+ assert('点面板其它地方(消息区)→ 菜单关闭', hook.modelState().menuOpen === false, '')
2363
+ hook.openModel()
2364
+ hook.close()
2365
+ await sleep(30)
2366
+ assert('收起面板时一并收起菜单', hook.modelState().menuOpen === false, '')
2367
+ hook.open('delta 是什么', '解释一下', '模型用例复位')
2368
+ await sleep(30)
2369
+
2370
+ }
2371
+
2372
+ // ───────────────────────── 档位被拒 / 思考内联:客户端行为 ─────────────────────────
2373
+ {
2374
+ const css = readFileSync(BUNDLE, 'utf8')
2375
+ assert('CSS 契约:被拒档位划斜线 + 提示行样式', /\.dsh-sel-tierbtn\[data-bad="1"\]\{opacity:\.5;text-decoration:line-through\}/.test(css) && /\.dsh-sel-notice\{/.test(css), '')
2376
+
2377
+ // 先选一个模型,再让 host 回一条"档位被拒"的 notice
2378
+ globalThis.__modelCatalogFixture = {
2379
+ ok: true, current: { provider: 'p1', model: 'm-fast' },
2380
+ stages: { chat: 'high', translation: 'low', detail: 'off' },
2381
+ models: [{ provider: 'p1', providerName: 'P1', model: 'm-fast', name: 'Fast Model', efforts: [{ id: 'off', name: 'Off' }, { id: 'high', name: 'High' }, { id: 'max', name: 'Max' }], defaultEffort: 'high' }],
2382
+ }
2383
+ await hook.loadModels(true)
2384
+ const rows = () => Array.from(walk(hook.modelNodes().menu)).filter((n) => String(n.className) === 'dsh-sel-pickerrow')
2385
+ rows()[0].dispatch('click', { preventDefault() {}, stopPropagation() {} })
2386
+ const tiers = () => Array.from(walk(hook.modelNodes().menu)).filter((n) => String(n.className) === 'dsh-sel-tierbtn')
2387
+ const maxBtn = tiers().find((b) => textOf(b) === '最大')
2388
+ maxBtn.dispatch('click', { preventDefault() {}, stopPropagation() {} })
2389
+ assert('选到「最大」档后胶囊显示它', hook.modelState().effort === 'max', String(hook.modelState().effort))
2390
+
2391
+ // 让这一轮流式里含一条 effort-rejected notice(模拟 host 的回退)
2392
+ hook.close()
2393
+ await sleep(30)
2394
+ globalThis.__askExtra = { notice: { code: 'effort-rejected', tier: 'max', text: '「max」档位该模型不支持,已按默认档位重试' } }
2395
+ hook.ask('档位被拒的用例')
2396
+ for (let i = 0; i < 200 && hook.turns().some((t) => t.streaming === true); i += 1) await sleep(20)
2397
+ await sleep(50)
2398
+ const turns = hook.turns()
2399
+ const last = turns[turns.length - 1]
2400
+ assert('轮次上记下了提示文案', String(last.notice || '').indexOf('已按默认档位重试') >= 0, String(last.notice))
2401
+ assert('提示行渲染进了气泡(不是只记在状态里)', Array.from(walk(chatLog)).some((n) => String(n.className).indexOf('dsh-sel-notice') >= 0 && textOf(n).indexOf('已按默认档位重试') >= 0), textOf(chatLog).slice(-60))
2402
+ assert('被拒后自动回退:档位选择清空(不再拿同一个组合撞墙)', hook.modelState().effort === null, String(hook.modelState().effort))
2403
+ assert('回退同时清掉持久化', String(windowStub.localStorage.getItem('dsh-selection-explain:effort')) === '', String(windowStub.localStorage.getItem('dsh-selection-explain:effort')))
2404
+ // 菜单里该档位被标成"实测不支持"
2405
+ hook.openModel()
2406
+ const bad = Array.from(walk(hook.modelNodes().menu)).filter((n) => n.getAttribute && n.getAttribute('data-bad') === '1')
2407
+ assert('菜单里把被拒的档位标出来(划斜线 + tooltip)', bad.length === 1 && textOf(bad[0]) === '最大', bad.map((b) => textOf(b)).join(','))
2408
+ globalThis.__askExtra = null
2409
+ }
2410
+
2411
+ // ───────────────────────── 浮标浮现动效(pop)的行为 ─────────────────────────
2412
+ {
2413
+ const hasPop = () => button.getAttribute('data-pop') === '1'
2414
+ // 前面"划词 → 浮标"那一步已经浮现过一次,动画在桩里不会自己结束,这里手动收尾
2415
+ button.dispatch('animationend', {})
2416
+ assert('浮现动效标记在 animationend 后被摘掉(否则 :active 的按压缩放会失效)', !hasPop(), String(button.getAttribute('data-pop')))
2417
+
2418
+ hook.close()
2419
+ await sleep(30)
2420
+ documentStub.dispatch('mouseup', { target: body })
2421
+ await sleep(20)
2422
+ assert('隐藏后重新浮现会再挂上动效标记', hasPop(), String(button.getAttribute('data-pop')))
2423
+ button.dispatch('animationend', {})
2424
+ assert('已可见时再次定位不再重播(标记不会被挂回)', !hasPop(), String(button.getAttribute('data-pop')))
2425
+ documentStub.dispatch('mouseup', { target: body })
2426
+ await sleep(20)
2427
+ assert('已可见时只平移、不重播动效', button.style.display === 'inline-flex' && !hasPop(), String(button.getAttribute('data-pop')))
2428
+ }
2429
+
2430
+ // ───────────────────────── 历史条目:行内删除 + 撤销 ─────────────────────────
2431
+ {
2432
+ const css = readFileSync(BUNDLE, 'utf8')
2433
+ assert('CSS 契约:删除键默认隐形、悬浮才出现(防误触)',
2434
+ /\.dsh-sel-historydel\{[^}]*opacity:0/.test(css) &&
2435
+ /\.dsh-sel-historyrow:hover \.dsh-sel-historydel/.test(css), '')
2436
+ assert('CSS 契约:撤销条样式在', /\.dsh-sel-undo\{/.test(css), '')
2437
+
2438
+ // 打开"最近"列表(history 走内存桩,前面用例已经写过几条)
2439
+ hook.close()
2440
+ await sleep(30)
2441
+ hook.open('history-del-probe', '上下文', '删除用例')
2442
+ for (let i = 0; i < 120 && hook.state().phase !== 'done'; i += 1) await sleep(20)
2443
+ await sleep(30)
2444
+ const histBtn = Array.from(walk(panelHead)).find((n) => String(n.className).indexOf('dsh-sel-action') >= 0 && textOf(n).indexOf('最近') >= 0)
2445
+ histBtn.dispatch('click', { preventDefault() {}, stopPropagation() {} })
2446
+ await sleep(120)
2447
+ const histRows = () => Array.from(walk(histList)).filter((n) => String(n.className) === 'dsh-sel-historyrow')
2448
+ assert('列表里每行都有删除键', histRows().length > 0 && histRows().every((r) => !!Array.from(walk(r)).find((n) => String(n.className) === 'dsh-sel-historydel')), String(histRows().length))
2449
+
2450
+ // 点第一行的 ✕ → 该行消失 + 出现撤销条
2451
+ const before = histRows().length
2452
+ const firstDel = Array.from(walk(histRows()[0])).find((n) => String(n.className) === 'dsh-sel-historydel')
2453
+ firstDel.dispatch('click', { preventDefault() {}, stopPropagation() {} })
2454
+ for (let i = 0; i < 100 && histRows().length === before; i += 1) await sleep(20)
2455
+ assert('点 ✕ 后该行从列表里消失', histRows().length === before - 1, `${before} → ${histRows().length}`)
2456
+ const undoBar = Array.from(walk(histList)).find((n) => String(n.className) === 'dsh-sel-undo')
2457
+ assert('出现「已删除 · 撤销」条', !!undoBar && textOf(undoBar).indexOf('撤销') >= 0, undoBar ? textOf(undoBar) : '未找到')
2458
+
2459
+ // 点撤销 → 条目回来
2460
+ const undoBtn = undoBar ? Array.from(walk(undoBar)).find((n) => String(n.tagName) === 'BUTTON') : null
2461
+ if (undoBtn) {
2462
+ undoBtn.dispatch('click', { preventDefault() {}, stopPropagation() {} })
2463
+ for (let i = 0; i < 150 && histRows().length !== before; i += 1) await sleep(20)
2464
+ assert('撤销后条目回到列表', histRows().length === before, `${histRows().length} vs ${before}`)
2465
+ } else {
2466
+ assert('撤销按钮存在', false, '未找到')
2467
+ }
2468
+
2469
+ // 删除键不能触发"回放这一条":回放会去拉 ?key= 并重开面板 —— 用一个计数器盯住
2470
+ const replayHits = historySaved.filter((e) => e.key && e.key.indexOf('history-del-probe') >= 0)
2471
+ assert('删除键不会误触发回放(回放会重新写历史,这里应为 0 次)', replayHits.length === 0, String(replayHits.length))
2472
+ }
2473
+
2474
+ // ───────────────────────── 关闭 / 清理 ─────────────────────────
2475
+ hook.close()
2476
+ assert('Esc/关闭后隐藏', panel.style.display === 'none')
2477
+ for (const dispose of disposers.reverse()) {
2478
+ try {
2479
+ dispose()
2480
+ } catch (error) {
2481
+ console.log('cleanup 抛错:', error && error.message)
2482
+ }
2483
+ }
2484
+ assert('清理后 DOM 归零', mount.children.length === 0 && body.children.indexOf(container) >= 0)
2485
+ assert('清理后钩子移除', windowStub.__dshSelectionExplain === undefined)
2486
+ console.log('\n=== 客户端集成测试结束 ===')