@yfwu2020/dsh-selection-explain 0.10.4 → 0.10.6

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.
Files changed (120) hide show
  1. package/README.md +13 -11
  2. package/cordis.patch.yml +1 -1
  3. package/lib/client.js +10698 -12424
  4. package/lib/client.js.map +7 -0
  5. package/lib/config-values.js +55 -0
  6. package/lib/config-values.js.map +1 -0
  7. package/lib/host/analyze.js +795 -0
  8. package/lib/host/analyze.js.map +1 -0
  9. package/lib/host/config.js +81 -0
  10. package/lib/host/config.js.map +1 -0
  11. package/lib/host/constants.js +25 -0
  12. package/lib/host/constants.js.map +1 -0
  13. package/lib/host/context.js +237 -0
  14. package/lib/host/context.js.map +1 -0
  15. package/lib/host/design-skill.js +42 -0
  16. package/lib/host/design-skill.js.map +1 -0
  17. package/lib/host/effort.js +239 -0
  18. package/lib/host/effort.js.map +1 -0
  19. package/lib/host/filters.js +132 -0
  20. package/lib/host/filters.js.map +1 -0
  21. package/lib/host/history-store.js +404 -0
  22. package/lib/host/history-store.js.map +1 -0
  23. package/lib/host/http.js +46 -0
  24. package/lib/host/http.js.map +1 -0
  25. package/lib/host/messages.js +14 -0
  26. package/lib/host/messages.js.map +1 -0
  27. package/lib/host/mic.js +246 -0
  28. package/lib/host/mic.js.map +1 -0
  29. package/lib/host/models.js +246 -0
  30. package/lib/host/models.js.map +1 -0
  31. package/lib/host/plugin.js +41 -0
  32. package/lib/host/plugin.js.map +1 -0
  33. package/lib/host/promote.js +219 -0
  34. package/lib/host/promote.js.map +1 -0
  35. package/lib/host/routes.js +237 -0
  36. package/lib/host/routes.js.map +1 -0
  37. package/lib/host/sessions.js +181 -0
  38. package/lib/host/sessions.js.map +1 -0
  39. package/lib/host/settings-store.js +140 -0
  40. package/lib/host/settings-store.js.map +1 -0
  41. package/lib/host/speech.js +320 -0
  42. package/lib/host/speech.js.map +1 -0
  43. package/lib/host/tools.js +179 -0
  44. package/lib/host/tools.js.map +1 -0
  45. package/lib/host/types.js +2 -0
  46. package/lib/host/types.js.map +1 -0
  47. package/lib/index.js +10 -3765
  48. package/lib/index.js.map +1 -1
  49. package/lib/prompts.js +1 -1
  50. package/lib/settings.js +17 -27
  51. package/lib/settings.js.map +1 -1
  52. package/lib/types/config-values.d.ts +175 -0
  53. package/lib/types/host/analyze.d.ts +10 -0
  54. package/lib/types/host/config.d.ts +147 -0
  55. package/lib/types/host/constants.d.ts +24 -0
  56. package/lib/types/host/context.d.ts +41 -0
  57. package/lib/types/host/design-skill.d.ts +1 -0
  58. package/lib/types/host/effort.d.ts +61 -0
  59. package/lib/types/host/filters.d.ts +20 -0
  60. package/lib/types/host/history-store.d.ts +13 -0
  61. package/lib/types/host/http.d.ts +8 -0
  62. package/lib/types/host/messages.d.ts +13 -0
  63. package/lib/types/host/mic.d.ts +17 -0
  64. package/lib/types/host/models.d.ts +57 -0
  65. package/lib/types/host/plugin.d.ts +3 -0
  66. package/lib/types/host/promote.d.ts +9 -0
  67. package/lib/types/host/routes.d.ts +14 -0
  68. package/lib/types/host/sessions.d.ts +14 -0
  69. package/lib/types/host/settings-store.d.ts +8 -0
  70. package/lib/types/host/speech.d.ts +23 -0
  71. package/lib/types/host/tools.d.ts +39 -0
  72. package/lib/types/host/types.d.ts +89 -0
  73. package/lib/types/index.d.ts +12 -441
  74. package/lib/types/prompts.d.ts +1 -1
  75. package/lib/types/settings.d.ts +1 -1
  76. package/package.json +31 -21
  77. package/assets/code.png +0 -0
  78. package/assets/detail.png +0 -0
  79. package/assets/quotes.png +0 -0
  80. package/assets/stage1.png +0 -0
  81. package/assets/stage2.png +0 -0
  82. package/assets/translation.png +0 -0
  83. package/assets/voice-card-light.png +0 -0
  84. package/assets/voice-inserted.png +0 -0
  85. package/assets/voice-settings.png +0 -0
  86. package/assets/voice.png +0 -0
  87. package/assets/web.png +0 -0
  88. package/scripts/build-demo-pages.mjs +0 -350
  89. package/scripts/build-vcard-lab.mjs +0 -834
  90. package/scripts/build.sh +0 -147
  91. package/scripts/crop-png.mjs +0 -142
  92. package/scripts/dump-prompt.mjs +0 -143
  93. package/scripts/fixtures/fake-mic-probe.sh +0 -9
  94. package/scripts/gen-star-path.mjs +0 -71
  95. package/scripts/measure-cost.mjs +0 -251
  96. package/scripts/smoke-bridge.mjs +0 -365
  97. package/scripts/smoke-panel.mjs +0 -1903
  98. package/scripts/smoke-voice.mjs +0 -774
  99. package/scripts/test-bridge.mjs +0 -412
  100. package/scripts/test-client.mjs +0 -6175
  101. package/scripts/test-effort-map.mjs +0 -129
  102. package/scripts/test-effort-retry.mjs +0 -109
  103. package/scripts/test-escape-focus.mjs +0 -101
  104. package/scripts/test-filters.mjs +0 -146
  105. package/scripts/test-format.mjs +0 -93
  106. package/scripts/test-guard.mjs +0 -48
  107. package/scripts/test-mic-build.mjs +0 -146
  108. package/scripts/test-mic-idle.mjs +0 -158
  109. package/scripts/test-model-follow.mjs +0 -193
  110. package/scripts/test-prompt.mjs +0 -193
  111. package/scripts/test-route-cache.mjs +0 -138
  112. package/scripts/test-settings-behavior.mjs +0 -188
  113. package/scripts/test-settings-host.mjs +0 -48
  114. package/scripts/test-settings.mjs +0 -329
  115. package/scripts/test-speech.mjs +0 -220
  116. package/scripts/test-transcript.mjs +0 -151
  117. package/src/client/index.js +0 -12580
  118. package/src/index.ts +0 -4254
  119. package/src/prompts.ts +0 -144
  120. package/src/settings.ts +0 -313
@@ -1,1903 +0,0 @@
1
- /**
2
- * 小窗面板的**真浏览器**冒烟测试(无头 Chrome)。
3
- *
4
- * 为什么需要它:`test-client.mjs` 用的是手搓 DOM 桩,桩上"什么都对"但真实浏览器里
5
- * 连着踩过三次(都是它测不出来的那类问题):
6
- * ① `mouseup` 对面板内的目标直接 return —— 小窗里划词毫无反应(桩里 target 用的是 document.body);
7
- * ② 引用浮标的 z-index 比面板低 —— 浮标画在面板**底下**,看不见也点不着(桩里不跑层叠);
8
- * ③ 翻译 / 详解两节里划词取不到(桩里没覆盖"节"这条路径)。
9
- * 所以这里用真 DOM、真选区、真 MouseEvent、真层叠(`elementFromPoint`)跑一遍主要交互。
10
- *
11
- * 用法:node scripts/smoke-panel.mjs [lib/client.js 路径]
12
- * CHROME=/path/to/chrome node scripts/smoke-panel.mjs
13
- * 退出码:全部 PASS = 0;找不到 Chrome 打印 SKIP 后退出 0(与 smoke-bridge 一致)。
14
- */
15
- import { createServer } from 'node:http'
16
- import { readFileSync, existsSync, mkdirSync } from 'node:fs'
17
- import { dirname, resolve } from 'node:path'
18
- import { fileURLToPath } from 'node:url'
19
- import { execFile } from 'node:child_process'
20
-
21
- const HERE = dirname(fileURLToPath(import.meta.url))
22
- const ROOT = resolve(HERE, '..')
23
- const BUNDLE = process.argv[2] || resolve(ROOT, 'lib', 'client.js')
24
-
25
- function findChrome() {
26
- const candidates = [
27
- process.env.CHROME,
28
- '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
29
- '/Applications/Chromium.app/Contents/MacOS/Chromium',
30
- '/usr/bin/google-chrome',
31
- '/usr/bin/chromium',
32
- '/usr/bin/chromium-browser',
33
- ].filter(Boolean)
34
- for (const item of candidates) if (existsSync(item)) return item
35
- return ''
36
- }
37
-
38
- const chrome = findChrome()
39
- if (!chrome) {
40
- console.log('SKIP 没找到 Chrome/Chromium(设 CHROME=/path/to/chrome 可指定)')
41
- process.exit(0)
42
- }
43
-
44
- const PAGE = `<!doctype html>
45
- <html lang="zh-CN"><head><meta charset="utf-8"><title>panel smoke</title>
46
- <style>html,body{margin:0;padding:0;background:#fff}
47
- /* 给主界面留一块"正文",用来验主界面划词那条路 */
48
- #doc{padding:40px;font:14px/1.7 -apple-system,"PingFang SC",sans-serif}
49
- </style></head>
50
- <body>
51
- <div id="doc"><p id="docp">Dev: the migration ran long, so we ship Wednesday.</p></div>
52
- <!-- "别处正在打字"的输入框:必须 fixed 定位 —— 放进文档流会挪动上面那段文字的坐标,
53
- 而这一页里有一族"依赖坐标的划词用例"(见下面 ① 的注释),挪一下就会撞红。 -->
54
- <textarea id="other" style="position:fixed;right:8px;bottom:8px;width:150px;height:34px">别处正在打字</textarea>
55
- <div id="mount"></div>
56
- <pre id="out">PENDING</pre>
57
- <script>
58
- // ── 自检结果收集 ──
59
- var RESULTS = []
60
- function check(name, ok, extra) { RESULTS.push({ name: name, ok: !!ok, extra: extra === undefined ? '' : String(extra).slice(0, 300) }) }
61
- function finish() { document.getElementById('out').textContent = JSON.stringify(RESULTS) }
62
- var sleep = function (ms) { return new Promise(function (r) { setTimeout(r, ms) }) }
63
- function waitFor(fn, ms) {
64
- var deadline = Date.now() + (ms || 3000)
65
- return new Promise(function (done) {
66
- ;(function tick() {
67
- var value
68
- try { value = fn() } catch (error) { value = false }
69
- if (value || Date.now() > deadline) return done(value)
70
- setTimeout(tick, 25)
71
- })()
72
- })
73
- }
74
-
75
- // ── 插件 bundle 需要的环境:ModuleLoader / React 垫片 / ctx ──
76
- var captured = null
77
- window.__ModuleLoader__ = { load: function (spec) { captured = spec } }
78
- var React = {
79
- createElement: function (type, props) {
80
- var children = Array.prototype.slice.call(arguments, 2)
81
- var out = { type: type, props: Object.assign({}, props || {}) }
82
- if (children.length === 1) out.props.children = children[0]
83
- else if (children.length > 1) out.props.children = children
84
- return out
85
- },
86
- }
87
-
88
- var fixture = {
89
- translation: '## 翻译\\n第一段译文:the migration ran long——迁移跑得比预期久,可以被划词引用。\\n\\n> 在本句中:进度延迟。\\n',
90
- detail: '## 详解\\n详解第二段:这句话在排期上意味着顺延,也可以被划词引用。\\n',
91
- chat: '这是对追问的回答:冒烟用例。',
92
- }
93
- function sse(events) {
94
- var body = events.map(function (e) { return 'data: ' + JSON.stringify(e) + '\\n\\n' }).join('')
95
- return new Response(body, { headers: { 'content-type': 'text/event-stream' } })
96
- }
97
- // 麦克风占用:默认"没人在录"。⑫ 交接用例要它报"豆包在录"才能把语音卡唤出来。
98
- var micState = { ok: true, available: true, capturing: false, processes: [], reason: '' }
99
- var pillPosStore = null // 内存版 host 存储(/api/pill)
100
- var panelSizeStore = null // 内存版 host 存储(/api/panel,只存尺寸)
101
- window.fetch = function (input, init) {
102
- var url = String(input)
103
- var body = {}
104
- try { body = init && init.body ? JSON.parse(String(init.body)) : {} } catch (error) { body = {} }
105
- if (url.indexOf('/selection-explain/api/mic') >= 0) {
106
- window.__micHits = (window.__micHits || 0) + 1
107
- return Promise.resolve(new Response(JSON.stringify(micState), { headers: { 'content-type': 'application/json' } }))
108
- }
109
- if (url.indexOf('/selection-explain/api/ping') >= 0) {
110
- return Promise.resolve(new Response(JSON.stringify({ ok: true, route: { provider: 'fixture', model: 'fixture' }, reasoningEffortByStage: { translation: 'low', detail: 'high', chat: 'high' }, limits: {} }), { headers: { 'content-type': 'application/json' } }))
111
- }
112
- if (url.indexOf('/selection-explain/api/quote-context') >= 0) {
113
- return Promise.resolve(new Response(JSON.stringify({ ok: true, matched: true, context: '用户:上一问\\n助手:上一答\\n用户:【' + (body.text || '') + '】\\n助手:下一答', rounds: 2 }), { headers: { 'content-type': 'application/json' } }))
114
- }
115
- if (url.indexOf('/selection-explain/api/history') >= 0) {
116
- return Promise.resolve(new Response(JSON.stringify({ ok: true, entry: null, entries: [] }), { headers: { 'content-type': 'application/json' } }))
117
- }
118
- if (url.indexOf('/selection-explain/api/panel') >= 0) {
119
- var isGetPanel = !init || !init.method || String(init.method).toUpperCase() === 'GET'
120
- if (isGetPanel) return Promise.resolve(new Response(JSON.stringify({ ok: true, size: panelSizeStore }), { headers: { 'content-type': 'application/json' } }))
121
- if (body.clear === true) {
122
- panelSizeStore = null
123
- return Promise.resolve(new Response(JSON.stringify({ ok: true, size: null }), { headers: { 'content-type': 'application/json' } }))
124
- }
125
- // 只认 w/h:位置不该出现在这个载荷里
126
- panelSizeStore = body.size ? { w: body.size.w, h: body.size.h, hadXY: ('x' in body.size || 'y' in body.size) } : null
127
- return Promise.resolve(new Response(JSON.stringify({ ok: true, size: panelSizeStore }), { headers: { 'content-type': 'application/json' } }))
128
- }
129
- if (url.indexOf('/selection-explain/api/pill') >= 0) {
130
- var isGet = !init || !init.method || String(init.method).toUpperCase() === 'GET'
131
- if (isGet) return Promise.resolve(new Response(JSON.stringify({ ok: true, position: pillPosStore }), { headers: { 'content-type': 'application/json' } }))
132
- if (body.clear === true) {
133
- pillPosStore = null
134
- return Promise.resolve(new Response(JSON.stringify({ ok: true, position: null }), { headers: { 'content-type': 'application/json' } }))
135
- }
136
- if (typeof body.right === 'number' && typeof body.y === 'number') {
137
- pillPosStore = { right: body.right, y: body.y }
138
- return Promise.resolve(new Response(JSON.stringify({ ok: true, position: pillPosStore }), { headers: { 'content-type': 'application/json' } }))
139
- }
140
- return Promise.resolve(new Response(JSON.stringify({ ok: false, error: 'position 需要 { right, y }' }), { status: 400, headers: { 'content-type': 'application/json' } }))
141
- }
142
- if (url.indexOf('/selection-explain/api/models') >= 0) {
143
- // 三个模型:一个长名字、一个同名 id 挂在另一个 provider 下 —— 给"菜单里给当前在用的
144
- // 那个打勾"那条用例用。current = 会话在用的那个(跟随态菜单要照它打勾)。
145
- return Promise.resolve(new Response(JSON.stringify({
146
- ok: true,
147
- current: { provider: 'p1', model: 'm-fast' },
148
- stages: { chat: 'high' },
149
- models: [
150
- { provider: 'p1', providerName: 'Provider One', model: 'm-fast', name: 'DeepSeek V4.1 Flash', efforts: [{ id: 'off', name: 'Off' }, { id: 'low', name: 'Low' }, { id: 'high', name: 'High' }, { id: 'max', name: 'Max' }], defaultEffort: 'high' },
151
- { provider: 'p1', providerName: 'Provider One', model: 'm-strong', name: 'Strong Model With A Very Long Display Name', efforts: [{ id: 'off', name: 'Off' }, { id: 'high', name: 'High' }], defaultEffort: 'high' },
152
- { provider: 'p2', providerName: 'DeepSeek Account', model: 'm-fast', name: 'Another Provider Same Id', efforts: [], defaultEffort: null },
153
- ],
154
- }), { headers: { 'content-type': 'application/json' } }))
155
- }
156
- if (url.indexOf('/selection-explain/api/analyze') >= 0) {
157
- if (body.question) return Promise.resolve(sse([{ type: 'start', provider: 'fixture', model: 'fixture', mode: 'chat' }, { type: 'delta', text: fixture.chat }, { type: 'done', chars: 8 }]))
158
- var fence = String.fromCharCode(96, 96, 96)
159
- var code = '## 注释\\n' + fence + 'js\\n// 注释:先声明常量\\nconst x = 1\\n' + fence + '\\n\\n> 小结:一句话说清。\\n'
160
- var text = body.kind === 'code' ? code : body.stage === 'detail' ? fixture.detail : fixture.translation
161
- return Promise.resolve(sse([{ type: 'start', provider: 'fixture', model: 'fixture', stage: body.stage || 'translation' }, { type: 'delta', text: text }, { type: 'done', chars: text.length }]))
162
- }
163
- return Promise.resolve(new Response('{}', { headers: { 'content-type': 'application/json' } }))
164
- }
165
-
166
- var disposers = []
167
- var registration = null
168
- var ctx = {
169
- logger: { info: function () {}, warn: function () {} },
170
- get: function () { return undefined },
171
- effect: function (fn) { var d = fn(); if (typeof d === 'function') disposers.push(d); return d },
172
- slots: {
173
- inject: function (key, callback) { var d = callback(); if (typeof d === 'function') disposers.push(d); return d },
174
- register: function (options, component) { registration = { options: options, component: component }; return function () {} },
175
- },
176
- }
177
-
178
- // ── 真选区 + 真事件 ──
179
- </script>
180
- <script src="/client.js"></script>
181
- <script>
182
- function selectIn(node, needle) {
183
- var text = node.textContent || ''
184
- var at = needle ? text.indexOf(needle) : 0
185
- if (at < 0) return null
186
- var len = needle ? needle.length : Math.min(6, text.length)
187
- var walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT, null)
188
- var acc = 0, startNode = null, startOffset = 0, endNode = null, endOffset = 0, current
189
- while ((current = walker.nextNode())) {
190
- var next = acc + current.nodeValue.length
191
- if (startNode === null && at < next) { startNode = current; startOffset = at - acc }
192
- if (startNode !== null && at + len <= next) { endNode = current; endOffset = at + len - acc; break }
193
- acc = next
194
- }
195
- if (!startNode || !endNode) return null
196
- var range = document.createRange()
197
- range.setStart(startNode, Math.max(0, startOffset))
198
- range.setEnd(endNode, Math.max(0, endOffset))
199
- var sel = window.getSelection()
200
- sel.removeAllRanges()
201
- sel.addRange(range)
202
- return range
203
- }
204
- function mouseUpOn(node) {
205
- node.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, view: window }))
206
- }
207
- function clickOn(node) {
208
- node.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }))
209
- }
210
- /**
211
- * 像用户那样点一下:mousedown → mouseup → click。
212
- * 只派发 click 是"程序化点击",浏览器不会走聚焦/选区那套默认行为 ——
213
- * 而浮标靠 mousedown 的 preventDefault 保住选区,正是那条路要验。
214
- */
215
- function realClick(node) {
216
- var opts = { bubbles: true, cancelable: true, view: window, detail: 1 }
217
- node.dispatchEvent(new MouseEvent("mousedown", opts))
218
- node.dispatchEvent(new MouseEvent("mouseup", opts))
219
- node.dispatchEvent(new MouseEvent("click", opts))
220
- }
221
- /**
222
- * 量几何之前先把过渡/动画冻住。
223
- *
224
- * 为什么必须冻:这个冒烟脚本跑在 --virtual-time-budget 下,定时器是虚拟时间(秒级飞快),
225
- * 但 **CSS 过渡的动画时钟不跟着走** —— 胶囊的宽度/位置过渡(.32s)会在"中间某一帧"被量到,
226
- * 于是"等了 420ms 还是量到起点值"。冻住之后量到的是终值,断言才是确定的;
227
- * "到底有没有动画"另外用 getComputedStyle 断言(transition-property 里有没有 max-width)。
228
- */
229
- function freezeMotion() {
230
- if (document.getElementById('freeze-motion')) return
231
- var style = document.createElement('style')
232
- style.id = 'freeze-motion'
233
- style.textContent = '*{transition:none!important;animation:none!important}'
234
- document.head.appendChild(style)
235
- }
236
- /** 按**顶层**逗号切分 CSS 列表值:cubic-bezier(0.4, 0, 0.6, 1) 里面也有逗号,直接 split 会切碎。 */
237
- function splitCssList(value) {
238
- var out = []
239
- var depth = 0
240
- var current = ''
241
- for (var i = 0; i < String(value).length; i += 1) {
242
- var ch = String(value)[i]
243
- if (ch === '(') depth += 1
244
- if (ch === ')') depth -= 1
245
- if (ch === ',' && depth === 0) { out.push(current.trim()); current = '' } else { current += ch }
246
- }
247
- if (current.trim()) out.push(current.trim())
248
- return out
249
- }
250
- /** 浮标是不是**最上层**(z-index 低于面板时 elementFromPoint 会返回面板)。 */
251
- function onTop(node) {
252
- var rect = node.getBoundingClientRect()
253
- if (!rect.width || !rect.height) return false
254
- var hit = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2)
255
- return hit === node || node.contains(hit)
256
- }
257
-
258
- async function main() {
259
- var mount = document.getElementById('mount')
260
- var hook = null
261
- try {
262
- var modules = captured.factory(function (name) { return name === 'react' ? React : {} })
263
- modules.apply(ctx)
264
- check('插件装载(槽注册 + apply)', !!registration && registration.options.id === '@yfwu2020/dsh-selection-explain', registration && registration.options.id)
265
- registration.component().props.ref(mount)
266
- hook = window.__dshSelectionExplain
267
- check('解读自有浮层已挂载', !!document.querySelector('.dsh-sel-layer'), '')
268
- var ownedLayer = document.querySelector('.dsh-sel-layer')
269
- check('解读使用 body 自有根,公开归属且空白穿透', ownedLayer.parentNode === document.body && ownedLayer.getAttribute('data-dsh-floating-owner') === 'explanation' && getComputedStyle(ownedLayer).pointerEvents === 'none', '')
270
- check('解读独立根越过完整视图工作面板', Number(getComputedStyle(ownedLayer).zIndex) > 60, getComputedStyle(ownedLayer).zIndex)
271
- var modeFrame = document.createElement('div')
272
- document.body.appendChild(modeFrame)
273
- modeFrame.setAttribute('data-dsh-full-view', '')
274
- await sleep(40)
275
- check('解读自身响应完整视图并隐藏胶囊', getComputedStyle(document.querySelector('.dsh-sel-pill')).display === 'none', '')
276
- modeFrame.removeAttribute('data-dsh-full-view')
277
- await sleep(40)
278
- check('返回分栏由解读自身恢复胶囊', getComputedStyle(document.querySelector('.dsh-sel-pill')).display !== 'none', '')
279
- modeFrame.remove()
280
- } catch (error) {
281
- check('插件装载(槽注册 + apply)', false, error && error.message)
282
- finish()
283
- return
284
- }
285
-
286
- var panel = document.querySelector('.dsh-sel-panel')
287
- var quoteBtn = hook.quoteNode()
288
- var quotesBox = document.querySelector('.dsh-sel-quotes')
289
-
290
- function chipCount() { return hook.quotes().length }
291
- function dropQuotes() {
292
- var xs = quotesBox.querySelectorAll('.dsh-sel-quotechip-x')
293
- for (var i = xs.length - 1; i >= 0; i -= 1) clickOn(xs[i])
294
- }
295
- /** 等面板"安顿下来"(开窗自动聚焦那一下会清掉选区,等它过去再划)。 */
296
- async function settlePanel() {
297
- var box = document.querySelector(".dsh-sel-askbox")
298
- await waitFor(function () { return box && document.activeElement === box }, 800)
299
- await sleep(30)
300
- }
301
- /** 划一段 → 点引用 → 返回那一段引用(并清场)。find() 返回 { node, needle }。 */
302
- async function quoteOnce(label, find) {
303
- await settlePanel()
304
- var target = find()
305
- if (!target || !target.node) { check(label + ':找到目标节点', false, '未找到'); return null }
306
- var range = selectIn(target.node, target.needle)
307
- check(label + ':能建出真选区', !!range, range ? range.toString().slice(0, 24) : '未找到目标文字')
308
- if (!range) return null
309
- // ⚠️ 合成选区 vs 面板渲染有竞态:面板晚一步重绘会把刚选中的节点换掉,插件据此判定
310
- // "那段文字没了"并收起浮标(这是对的),用例于是偶发红 —— 真浏览器冒烟在 CI 与本机
311
- // 都复现过(改动前的基线同样 192/2)。所以这里**重试合成选区**(最多 3 次,每次都重新
312
- // 取节点、重建 range),而不是放宽断言。
313
- var shown = false
314
- for (var attempt = 0; attempt < 3 && !shown; attempt += 1) {
315
- if (attempt > 0) {
316
- await settlePanel()
317
- var fresh = find()
318
- if (!fresh || !fresh.node) break
319
- target = fresh
320
- range = selectIn(target.node, target.needle)
321
- if (!range) break
322
- }
323
- mouseUpOn(target.node)
324
- shown = await waitFor(function () { return hook.quoteState().visible === true }, 1500)
325
- }
326
- check(label + ':浮出「❝ 引用」', shown === true, JSON.stringify(hook.quoteState()))
327
- if (!shown) return null
328
- check(label + ':浮标在最上层(不被面板盖住)', onTop(quoteBtn), '')
329
- clickOn(quoteBtn)
330
- await sleep(40)
331
- var quotes = hook.quotes()
332
- var picked = quotes.length ? quotes[quotes.length - 1] : null
333
- check(label + ':进了引用区', chipCount() === 1 && !!picked, JSON.stringify(quotes.map(function (q) { return q.label + ':' + q.text.slice(0, 12) })))
334
- dropQuotes()
335
- await sleep(30)
336
- return picked
337
- }
338
- function sectionContent(key) {
339
- return document.querySelector('.dsh-sel-sec[data-sec="' + key + '"] .dsh-sel-c')
340
- }
341
-
342
- // ── 打开面板(fetch 桩给固定两节内容)──
343
- hook.open('冒烟探针', '上下文片段 ABC', '冒烟')
344
- await waitFor(function () { return hook.state().phase === 'done' }, 4000)
345
- check('首轮渲染完成', hook.state().phase === 'done', hook.state().phase)
346
-
347
- // ── 面板 / 设置抽屉的真实尺寸 ──
348
- // 为什么要有这一段:单测的 DOM 桩**没有布局**。这里曾经有条按阶段收窄到 440px 的规则,
349
- // 因为 CSS 声明块漏了收尾花括号被整条丢弃(属性对、规则不生效,单测照样全绿);
350
- // 修好闭合后它又暴露了"追问一下宽度就从 440 跳到 540"的问题 —— 两件事都只有真浏览器量得出来。
351
- // 现在宽度**不随阶段变**(恒 540),下面同时钉住"首轮就是 540"和"追问之后仍是 540"。
352
- // ── 录音行不许把发送键顶到下一行(用户报:一开麦 ↑ 就掉到下面单独一行)──
353
- // 真浏览器量布局:按录音态把 DOM 摆成插件摆的样子(亮出录音行、让位那几样工具),
354
- // 再量发送键是否还与录音行同处一行。CSS 里那两条 flex-basis:0% + nowrap 就是为它服务的。
355
- {
356
- var toolsEl = document.querySelector('.dsh-sel-asktools')
357
- var captureEl = document.querySelector('.dsh-sel-capture')
358
- var sendEl = document.querySelector('.dsh-sel-asksend')
359
- var waveEl = document.querySelector('.dsh-sel-vwave')
360
- if (toolsEl && captureEl && sendEl) {
361
- var yielding = ['.dsh-sel-pref', '.dsh-sel-askspace', '.dsh-sel-picker', '.dsh-sel-mic']
362
- .map(function (sel) { return document.querySelector(sel) })
363
- .filter(Boolean)
364
- var restore = yielding.map(function (n) { return [n, n.style.display] })
365
- // 必须**窄一点**量:波形那个 svg 的固有宽度是 384px(viewBox 640x40 按 24px 高推出来的),
366
- // 540px 的面板刚好放得下、量不出问题;用户那个宽度才会露出来。面板本来就可调窄,这是真实场景。
367
- var panelWide = document.querySelector('.dsh-sel-panel')
368
- var widthBefore = panelWide ? panelWide.style.width : ''
369
- if (panelWide) panelWide.style.width = '420px'
370
- toolsEl.setAttribute('data-capturing', '1')
371
- captureEl.setAttribute('data-show', '1')
372
- captureEl.style.display = 'flex'
373
- if (waveEl) waveEl.style.display = ''
374
- yielding.forEach(function (n) { n.style.display = 'none' })
375
- var captureRect = captureEl.getBoundingClientRect()
376
- var sendRect = sendEl.getBoundingClientRect()
377
- check(
378
- '录音行出现时发送键仍在同一行(不被顶到下面)',
379
- sendRect.height > 0 && sendRect.top < captureRect.bottom && Math.abs(sendRect.top - captureRect.top) <= 8,
380
- JSON.stringify({
381
- capture: [Math.round(captureRect.top), Math.round(captureRect.bottom)],
382
- send: [Math.round(sendRect.top), Math.round(sendRect.bottom)],
383
- }),
384
- )
385
- restore.forEach(function (pair) { pair[0].style.display = pair[1] })
386
- if (panelWide) panelWide.style.width = widthBefore
387
- captureEl.style.display = 'none'
388
- captureEl.setAttribute('data-show', '0')
389
- toolsEl.setAttribute('data-capturing', '0')
390
- if (waveEl) waveEl.style.display = 'none'
391
- } else {
392
- check('录音行出现时发送键仍在同一行(不被顶到下面)', false, '没找到工具行/录音行/发送键')
393
- }
394
- }
395
-
396
- var WIDE_WIDTH = 540
397
- {
398
- var panelEl = document.querySelector('.dsh-sel-panel')
399
- var beforeRect = panelEl.getBoundingClientRect()
400
- check('首轮阶段宽度就是常规宽度(不再按阶段收窄到 440)',
401
- Math.abs(beforeRect.width - WIDE_WIDTH) <= 2,
402
- 'stage=' + panelEl.getAttribute('data-stage') + ' width=' + Math.round(beforeRect.width))
403
-
404
- var settingsBtn = Array.prototype.slice.call(panelEl.querySelectorAll('.dsh-sel-action'))
405
- .filter(function (b) { return b.textContent.indexOf('设置') >= 0 })[0]
406
- check('头部有「设置」键', !!settingsBtn, settingsBtn ? settingsBtn.textContent : '未找到')
407
- if (settingsBtn) {
408
- clickOn(settingsBtn)
409
- await waitFor(function () { return panelEl.getAttribute('data-settings') === '1' }, 1200)
410
- await sleep(60)
411
- var openRect = panelEl.getBoundingClientRect()
412
- var sheetEl = panelEl.querySelector('.dsh-sel-sheet')
413
- var sheetBody = sheetEl && sheetEl.querySelector('.dsh-sel-sheetbody')
414
- // 抽屉是 inset:0 贴在面板上的:面板多大多小,抽屉就多大多小。
415
- // 而面板高度是"解读内容有多长"撑出来的 —— 刚开窗时内容很短,
416
- // 不锁下限的话设置列表只剩十几个像素(实测 17px),等于没法用。
417
- // 设置窗口是 inset:0 贴在面板上的:**宽度必须与小窗一致**,
418
- // 开抽屉不该让面板自己"胖一圈"或"瘦一圈"(高度才需要补下限)。
419
- check('开设置抽屉后:设置窗口宽度与小窗完全一致(不额外加宽)',
420
- Math.abs(openRect.width - beforeRect.width) <= 0.5,
421
- '抽屉=' + openRect.width.toFixed(1) + ' 小窗=' + beforeRect.width.toFixed(1))
422
- check('开设置抽屉后:高度有下限(不再被内容长短绑架)',
423
- openRect.height >= 500,
424
- 'height=' + Math.round(openRect.height) + '(首轮未开抽屉时 ' + Math.round(beforeRect.height) + ')')
425
- var bodyH = sheetBody ? sheetBody.clientHeight : 0
426
- check('设置列表可视高度够用(不是一条缝)',
427
- bodyH >= 200,
428
- 'sheetBody.clientHeight=' + Math.round(bodyH))
429
- // 关掉,别影响后面的用例
430
- var backBtn = sheetEl && sheetEl.querySelector('.dsh-sel-sheetback')
431
- if (backBtn) clickOn(backBtn)
432
- await waitFor(function () { return panelEl.getAttribute('data-settings') !== '1' }, 1200)
433
- await sleep(40)
434
- var restored = panelEl.getBoundingClientRect()
435
- check('关掉抽屉后:小窗宽度不变(开合抽屉不动宽度)',
436
- Math.abs(restored.width - beforeRect.width) <= 0.5 && Math.abs(restored.width - WIDE_WIDTH) <= 2,
437
- 'width=' + Math.round(restored.width))
438
- }
439
- }
440
-
441
- // ── 拖动 / 改大小 ──
442
- // 规则:**尺寸**记住(用户拖角调过的),**位置不记**(每次开窗都按选区重新锚定)。
443
- function pointerOn(node, type, x, y) {
444
- node.dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y, pointerId: 1, button: 0, buttons: 1 }))
445
- }
446
- {
447
- var panelEl2 = document.querySelector('.dsh-sel-panel')
448
- var before = hook.panelGeom()
449
-
450
- // ① 拖右下角 → 变宽变高,并且只有尺寸被存下来
451
- var corner = panelEl2.querySelector('.dsh-sel-grip-corner')
452
- check('有右下角改大小手柄', !!corner, corner ? corner.className : '未找到')
453
- if (corner) {
454
- pointerOn(corner, 'pointerdown', 0, 0)
455
- pointerOn(window, 'pointermove', 140, 90)
456
- pointerOn(window, 'pointerup', 140, 90)
457
- await sleep(60)
458
- var grown = hook.panelGeom()
459
- check('拖角能让小窗变大',
460
- Math.round(grown.w) > Math.round(before.w) + 100 && Math.round(grown.h) > Math.round(before.h) + 60,
461
- 'w ' + Math.round(before.w) + '→' + Math.round(grown.w) + ' h ' + Math.round(before.h) + '→' + Math.round(grown.h))
462
- check('改过大小会打标记(设置页就不再插手高度)', grown.resized === true, String(grown.resized))
463
- await sleep(420) // 防抖 300ms
464
- check('尺寸存到了 host(下次开窗回到这个大小)',
465
- !!panelSizeStore && Math.abs(panelSizeStore.w - grown.w) <= 1 && Math.abs(panelSizeStore.h - grown.h) <= 1,
466
- JSON.stringify(panelSizeStore))
467
- check('存档里**没有位置**(载荷只有 w/h)', !!panelSizeStore && panelSizeStore.hadXY === false,
468
- JSON.stringify(panelSizeStore))
469
- }
470
-
471
- // ② 拖标题栏:这次能挪动,但**不该存位置**
472
- var headEl = panelEl2.querySelector('.dsh-sel-head')
473
- var posBefore = hook.panelGeom()
474
- pointerOn(headEl, 'mousedown', posBefore.x + 60, posBefore.y + 12)
475
- window.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: posBefore.x + 60 + 70, clientY: posBefore.y + 12 + 44 }))
476
- window.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }))
477
- await sleep(60)
478
- var moved = hook.panelGeom()
479
- check('拖标题栏能挪动小窗(本次有效)',
480
- Math.abs(moved.x - posBefore.x) > 40 && Math.abs(moved.y - posBefore.y) > 20,
481
- 'x ' + Math.round(posBefore.x) + '→' + Math.round(moved.x) + ' y ' + Math.round(posBefore.y) + '→' + Math.round(moved.y))
482
- await sleep(420)
483
- check('拖动**不会**把位置存进 host(位置记忆已按要求去掉)',
484
- !!panelSizeStore && panelSizeStore.hadXY === false,
485
- JSON.stringify(panelSizeStore))
486
-
487
- // ③ 尺寸能读回来(模拟刷新:清掉内联尺寸,再让客户端重读一次)
488
- panelEl2.style.width = ''
489
- panelEl2.style.height = ''
490
- panelEl2.removeAttribute('data-resized')
491
- hook.loadPanelSize()
492
- await sleep(80)
493
- var restored2 = hook.panelGeom()
494
- // 宽度按存档精确恢复;高度只作为天花板 —— 内容撑不到就自己短着(见 ⑥)
495
- check('宽度能按存档恢复',
496
- Math.abs(restored2.w - panelSizeStore.w) <= 1,
497
- 'w=' + Math.round(restored2.w) + ' / 存档 ' + Math.round(panelSizeStore.w))
498
- check('高度不超过存档的上限',
499
- restored2.h <= panelSizeStore.h + 1,
500
- 'h=' + Math.round(restored2.h) + ' / 上限 ' + Math.round(panelSizeStore.h))
501
-
502
- // ④ 重新开窗 → 必须重新按选区锚定(这是"位置不记"的核心保证)
503
- // 先把窗口挪到一个明显偏离锚点的位置,再关掉重开。
504
- panelEl2.style.left = '7px'
505
- panelEl2.style.top = '7px'
506
- hook.close()
507
- await sleep(40)
508
- hook.open('重新锚定探测', '上下文片段 ABC', '冒烟')
509
- await sleep(60)
510
- var reopened = hook.panelGeom()
511
- check('重新开窗会重新按选区锚定(位置没被记住)',
512
- reopened.x > 20 && reopened.y > 20,
513
- 'x=' + Math.round(reopened.x) + ' y=' + Math.round(reopened.y) + '(若被记住应是 7,7)')
514
-
515
- // ⑤ 双击角标 → 恢复默认尺寸
516
- if (corner) {
517
- corner.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true, view: window }))
518
- await sleep(80)
519
- var afterReset = hook.panelGeom()
520
- check('双击角标恢复默认尺寸(清掉存档)',
521
- afterReset.resized === false && panelSizeStore === null,
522
- 'resized=' + afterReset.resized + ' store=' + JSON.stringify(panelSizeStore))
523
- await sleep(420)
524
- }
525
-
526
- // ⑥ 记住的高度只能当**天花板**:新会话内容短时面板必须缩回去,不能留一大片空白。
527
- // 这条是用户报的回归 —— 高度被当成固定值写上去,新开一个会话(只有一小段翻译)
528
- // 面板硬撑到上次那么高,下面全是空的。
529
- panelSizeStore = { w: 620, h: 700, hadXY: false }
530
- panelEl2.style.width = ''
531
- panelEl2.style.height = ''
532
- panelEl2.style.maxHeight = ''
533
- panelEl2.removeAttribute('data-resized')
534
- hook.loadPanelSize()
535
- await sleep(80)
536
- hook.close()
537
- await sleep(40)
538
- hook.open('自适应高度探测', '上下文片段 ABC', '冒烟')
539
- await sleep(100)
540
- var fresh = hook.panelGeom()
541
- var blank = panelEl2.clientHeight - panelEl2.scrollHeight
542
- check('新会话高度自适应:面板里没有大片空白',
543
- blank <= 4,
544
- 'clientHeight=' + panelEl2.clientHeight + ' scrollHeight=' + panelEl2.scrollHeight + ' 空白=' + blank)
545
- check('存档的高度只当天花板(没被当成固定高度)',
546
- fresh.h < 680,
547
- 'h=' + Math.round(fresh.h) + '(存档上限 700,若被当固定值用就该是 700)')
548
- check('宽度仍沿用存档的值(宽度固定、高度自适应)',
549
- Math.abs(fresh.w - 620) <= 1,
550
- 'w=' + Math.round(fresh.w))
551
-
552
- // ⑦ 双击 = 自适应 = 恢复默认尺寸(同一个动作):把窗口**拉小**之后再双击,
553
- // 高度不该还被旧的上限卡着 —— 那是"只清了 width/height、漏了 maxHeight"的症状。
554
- panelSizeStore = { w: 340, h: 250, hadXY: false }
555
- panelEl2.style.width = ''
556
- panelEl2.style.height = ''
557
- panelEl2.style.maxHeight = ''
558
- panelEl2.removeAttribute('data-resized')
559
- hook.loadPanelSize()
560
- await sleep(80)
561
- hook.close()
562
- await sleep(40)
563
- hook.open('拉小后双击探测', '上下文片段 ABC', '冒烟')
564
- await sleep(120)
565
- var shrunk = hook.panelGeom()
566
- check('存档的小尺寸生效(宽度 340、高度被 250 卡住)',
567
- Math.abs(shrunk.w - 340) <= 1,
568
- 'w=' + Math.round(shrunk.w) + ' h=' + Math.round(shrunk.h))
569
- corner.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true, view: window }))
570
- await sleep(120)
571
- var back = hook.panelGeom()
572
- check('双击后宽度回到默认(不再被存档的 340 卡住)',
573
- Math.abs(back.w - 340) > 40,
574
- 'w ' + Math.round(shrunk.w) + '→' + Math.round(back.w))
575
- check('双击后高度上限也被清掉(不再被 250 卡住)',
576
- panelEl2.style.maxHeight === '' && back.h > 260,
577
- 'maxHeight=' + JSON.stringify(panelEl2.style.maxHeight) + ' h=' + Math.round(back.h))
578
- await sleep(420)
579
-
580
- // ⑦.5 刚拖完角标就双击还原:防抖里那次没落盘的保存**不能**把还原撤销掉。
581
- // savePanelSize 在定时器触发时才读当前尺寸 —— 不掐掉的话它 300ms 后会读到
582
- // "已还原的默认尺寸",反过来当成用户选的尺寸存下来,宽度被钉死。
583
- pointerOn(corner, 'pointerdown', 0, 0)
584
- pointerOn(window, 'pointermove', 90, 60)
585
- pointerOn(window, 'pointerup', 90, 60)
586
- await sleep(40) // 远小于 300ms 防抖:保存还没落盘
587
- corner.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true, view: window }))
588
- await sleep(500) // 跨过防抖窗口,看那次保存会不会"复活"
589
- check('刚拖完就双击还原:待落的保存不会把还原撤销掉',
590
- panelSizeStore === null && panelEl2.style.width === '' && panelEl2.getAttribute('data-resized') === null,
591
- 'store=' + JSON.stringify(panelSizeStore) + ' inline=' + JSON.stringify(panelEl2.style.width) +
592
- ' resized=' + panelEl2.getAttribute('data-resized'))
593
-
594
- // ⑧ 恢复默认尺寸**不等于没有上限**:上限从"用户调过的"换回"CSS 默认的" min(78vh,720px)。
595
- // 清掉的是用户那条自定义上限,不是限制本身。
596
- {
597
- var cap = Math.min(window.innerHeight * 0.78, 720)
598
- var computedMax = getComputedStyle(panelEl2).maxHeight
599
- check('恢复默认后高度仍有上限(回到 CSS 默认 min(78vh,720px))',
600
- panelEl2.style.maxHeight === '' && computedMax !== 'none' &&
601
- Math.abs(parseFloat(computedMax) - cap) <= 2,
602
- 'inline=' + JSON.stringify(panelEl2.style.maxHeight) + ' computed=' + computedMax +
603
- ' 期望=' + Math.round(cap) + 'px')
604
- // 塞一段超长内容:面板不该被撑破上限,多出来的部分在正文里滚动
605
- // 塞一段超长内容量一次 —— 量完必须**收拾干净**:这一段动的是真面板,
606
- // 面板高度会 423 → 634 → 423,滚动位置也会变;不还原就会影响后面那些
607
- // 依赖坐标的划词用例(跑多次能撞见)。
608
- var bodyEl = panelEl2.querySelector('.dsh-sel-body')
609
- var savedScroll = bodyEl.scrollTop
610
- var filler = document.createElement('div')
611
- filler.style.cssText = 'height:3000px;flex:0 0 auto'
612
- bodyEl.appendChild(filler)
613
- await sleep(80)
614
- var tall = panelEl2.getBoundingClientRect().height
615
- check('内容超长时面板不越过上限(超出部分内部滚动)',
616
- tall <= cap + 2,
617
- 'h=' + Math.round(tall) + ' 上限=' + Math.round(cap) + ' body 可滚=' + (bodyEl.scrollHeight > bodyEl.clientHeight))
618
- filler.remove()
619
- bodyEl.scrollTop = savedScroll
620
- void panelEl2.offsetHeight // 强制回流,别让后面的用例量到中间态
621
- await sleep(140)
622
- }
623
-
624
- // ⑨ 手柄不该有常驻的视觉标识(用户要求去掉右下角那道小斜纹)
625
- var cornerAfter = getComputedStyle(corner, '::after')
626
- check('右下角手柄没有常驻标识(不再画小斜纹)',
627
- !cornerAfter.content || cornerAfter.content === 'none' || cornerAfter.content === 'normal',
628
- 'content=' + cornerAfter.content)
629
- check('手柄仍在(只是不可见,鼠标移上去光标会变)',
630
- getComputedStyle(corner).cursor === 'nwse-resize',
631
- 'cursor=' + getComputedStyle(corner).cursor)
632
-
633
- // ⑩ 贴着屏幕边缘时双击恢复默认尺寸 → 面板不能被顶出屏幕
634
- // 复现:先拉宽到接近满屏并推到右边,再双击角标(宽度会跳回默认 540,位置是旧的)
635
- var vw = window.innerWidth
636
- var vh = window.innerHeight
637
- pointerOn(corner, 'pointerdown', 0, 0)
638
- pointerOn(window, 'pointermove', vw, vh) // 尽量拉大
639
- pointerOn(window, 'pointerup', vw, vh)
640
- await sleep(80)
641
- // 推到右下角
642
- var big = hook.panelGeom()
643
- var headEl2 = panelEl2.querySelector('.dsh-sel-head')
644
- pointerOn(headEl2, 'mousedown', big.x + 60, big.y + 12)
645
- window.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: vw + 500, clientY: vh + 500 }))
646
- window.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }))
647
- await sleep(80)
648
- var beforeReset = hook.panelGeom()
649
- corner.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true, view: window }))
650
- await sleep(120)
651
- var afterDbl = hook.panelGeom()
652
- check('贴边双击恢复默认尺寸后,面板仍在屏幕内',
653
- afterDbl.x >= 0 && afterDbl.y >= 0 &&
654
- afterDbl.x + afterDbl.w <= vw + 1 && afterDbl.y + afterDbl.h <= vh + 1,
655
- 'x=' + Math.round(afterDbl.x) + ' y=' + Math.round(afterDbl.y) +
656
- ' 右=' + Math.round(afterDbl.x + afterDbl.w) + '/' + vw +
657
- ' 下=' + Math.round(afterDbl.y + afterDbl.h) + '/' + vh +
658
- '(双击前 x=' + Math.round(beforeReset.x) + ' w=' + Math.round(beforeReset.w) + ')')
659
- }
660
-
661
- // ① 翻译节
662
- var picked1 = await quoteOnce('翻译节划词', function () {
663
- return { node: sectionContent('translation'), needle: '迁移跑得比预期久' }
664
- })
665
- var transTitle = document.querySelector('.dsh-sel-sec[data-sec="translation"] .dsh-sel-sh span')
666
- var transTitleText = transTitle ? transTitle.textContent : ''
667
- check(
668
- '翻译节的来源写明是哪一节(用该节自己的标题)',
669
- !!picked1 && !!transTitleText && picked1.label === '小窗「' + transTitleText + '」节',
670
- picked1 && picked1.label + ' vs ' + transTitleText,
671
- )
672
-
673
- // ② 结论条(.dsh-sel-callout,也在翻译节里)
674
- await quoteOnce('结论条划词', function () {
675
- return { node: document.querySelector('.dsh-sel-callout'), needle: '进度延迟' }
676
- })
677
-
678
- // ③ 展开详解 → 详解节
679
- hook.expand()
680
- await waitFor(function () { return (hook.parts().detail || '').length > 0 && hook.state().phase === 'done' }, 4000)
681
- check('详解节渲染出来了', !!sectionContent('detail'), '')
682
- var picked3 = await quoteOnce('详解节划词', function () {
683
- return { node: sectionContent('detail'), needle: '也可以被划词引用' }
684
- })
685
- check('详解节的来源写明是哪一节', !!picked3 && picked3.label.indexOf('详解') >= 0, picked3 && picked3.label)
686
-
687
- // ④ 跨节选择:从翻译节拖到详解节
688
- {
689
- var from = sectionContent('translation')
690
- var to = sectionContent('detail')
691
- var texts = []
692
- var walker = document.createTreeWalker(from, NodeFilter.SHOW_TEXT, null)
693
- var node
694
- while ((node = walker.nextNode())) if (node.nodeValue.trim()) texts.push(node)
695
- if (texts.length && to.firstChild) {
696
- // 与 quoteOnce 同一套重试(合成选区 vs 面板渲染的竞态)
697
- var shown = false
698
- for (var attempt2 = 0; attempt2 < 3 && !shown; attempt2 += 1) {
699
- if (attempt2 > 0) await settlePanel()
700
- var fromNow = sectionContent('translation')
701
- var toNow = sectionContent('detail')
702
- var textsNow = []
703
- var walker2 = document.createTreeWalker(fromNow, NodeFilter.SHOW_TEXT, null)
704
- var nodeNow
705
- while ((nodeNow = walker2.nextNode())) if (nodeNow.nodeValue.trim()) textsNow.push(nodeNow)
706
- if (!textsNow.length || !toNow.firstChild) break
707
- var range = document.createRange()
708
- range.setStart(textsNow[0], 0)
709
- var endNode = toNow.firstChild.nodeType === 3 ? toNow.firstChild : toNow
710
- range.setEnd(endNode, Math.min(4, (endNode.nodeValue || '').length))
711
- var sel = window.getSelection()
712
- sel.removeAllRanges()
713
- sel.addRange(range)
714
- mouseUpOn(toNow)
715
- shown = await waitFor(function () { return hook.quoteState().visible === true }, 1500)
716
- }
717
- check('跨节选择:也浮出「❝ 引用」', shown === true, JSON.stringify(hook.quoteState()))
718
- if (shown) {
719
- clickOn(quoteBtn)
720
- await sleep(40)
721
- check('跨节选择:进了引用区', chipCount() === 1, JSON.stringify(hook.quotes().map(function (q) { return q.text.length })))
722
- dropQuotes()
723
- await sleep(30)
724
- }
725
- } else {
726
- check('跨节选择:也浮出「❝ 引用」', false, '没找到可跨的文本节点')
727
- }
728
- }
729
-
730
- // ⑤ 面板**滚动之后**再划词(长内容 + 滚动是真实常态)
731
- {
732
- var body = document.querySelector('.dsh-sel-body')
733
- body.scrollTop = Math.min(120, Math.max(0, body.scrollHeight - body.clientHeight))
734
- await sleep(30)
735
- var picked5 = await quoteOnce('滚动后划翻译节', function () {
736
- return { node: sectionContent('translation'), needle: '迁移跑得比预期久' }
737
- })
738
- check('滚动之后划词照常能用', !!picked5, '')
739
- }
740
-
741
- // ⑥ 代码注释节(「## 注释」+ <pre>,划词落在 pre 里)
742
- {
743
- hook.close()
744
- hook.open('function add(a, b) { return a + b }', 'const x = 1', '代码块')
745
- await waitFor(function () { return hook.state().phase === 'done' }, 4000)
746
- var header = document.querySelector('.dsh-sel-sec[data-sec="translation"] .dsh-sel-sh')
747
- check('代码用例切到「注释」节', !!header && header.textContent.indexOf('注释') >= 0, header ? header.textContent : '未找到')
748
- await quoteOnce('代码块里划词', function () {
749
- return { node: document.querySelector('.dsh-sel-pre'), needle: 'const x' }
750
- })
751
- }
752
-
753
- // ⑦ 对话气泡(不带上下文,但要写清出处)
754
- {
755
- hook.close()
756
- hook.open('冒烟探针', '上下文片段 ABC', '冒烟')
757
- await waitFor(function () { return hook.state().phase === 'done' }, 4000)
758
- var widthBeforeAsk = Math.round(document.querySelector('.dsh-sel-panel').getBoundingClientRect().width)
759
- hook.ask('冒烟追问')
760
- await waitFor(function () { return hook.state().asking === false }, 4000)
761
- var widthAfterAsk = Math.round(document.querySelector('.dsh-sel-panel').getBoundingClientRect().width)
762
- check('追问前后宽度不变(不再从 440 跳到 540)',
763
- widthBeforeAsk === widthAfterAsk && Math.abs(widthAfterAsk - WIDE_WIDTH) <= 2,
764
- '追问前 ' + widthBeforeAsk + 'px → 追问后 ' + widthAfterAsk + 'px')
765
- var bubbles = document.querySelectorAll('.dsh-sel-chatlog .dsh-sel-bubble-bot')
766
- var bot = bubbles[bubbles.length - 1]
767
- check('追问气泡渲染出来了', !!bot && bot.textContent.indexOf('冒烟用例') >= 0, bot ? bot.textContent.slice(0, 24) : '未找到')
768
- var picked7 = await quoteOnce('对话气泡划词', function () { return { node: bot, needle: '冒烟用例' } })
769
- check('对话引用写清了出处(第 N 轮 + 角色)', !!picked7 && /第 [0-9]+ 轮/.test(picked7.label), picked7 && picked7.label)
770
- check('对话引用不带上下文(小窗对话本来就在历史里)', !!picked7 && !picked7.context, picked7 && JSON.stringify(picked7.context))
771
- var all = bot.querySelector('.dsh-sel-bubquote')
772
- check('气泡末尾有「引用整条」', !!all, '')
773
- if (all) {
774
- clickOn(all)
775
- await sleep(40)
776
- var quoted = hook.quotes()
777
- var last = quoted.length ? quoted[quoted.length - 1] : null
778
- check('「引用整条」也不带上下文', !!last && !last.context, last && JSON.stringify(last.context))
779
- check('「引用整条」也写清出处', !!last && /第 [0-9]+ 轮/.test(last.label), last && last.label)
780
- dropQuotes()
781
- await sleep(30)
782
- }
783
- }
784
-
785
- // ⑧ 主界面划词(小窗开着)→ 会话里的引用(带 host 上下文)
786
- {
787
- var docp = document.getElementById('docp')
788
- var picked8 = await quoteOnce('主界面划词', function () { return { node: docp, needle: 'the migration ran long' } })
789
- check('主界面引用标成会话里的', !!picked8 && picked8.session === true, picked8 && String(picked8.session))
790
- }
791
-
792
- // ⑨ 点「✦ 解读」后立刻收掉本文档选区(用户报的:小窗亮起时旧选区又触发「❝ 引用」浮标)
793
- {
794
- hook.close()
795
- await sleep(80)
796
- var planBtn = document.querySelector('.dsh-sel-btn:not(.dsh-sel-quotebtn)')
797
- var docp2 = document.getElementById('docp')
798
- selectIn(docp2, 'the migration ran long')
799
- mouseUpOn(docp2)
800
- var planShown = await waitFor(function () {
801
- return planBtn && planBtn.style.display !== 'none' && planBtn.getBoundingClientRect().width > 0
802
- }, 1500)
803
- check('主界面划词:浮出「✦ 解读」', planShown === true, planBtn ? planBtn.style.display : '未找到按钮')
804
- if (planShown) {
805
- realClick(planBtn)
806
- // ⚠️ 这里**同步**检查,一下都不 await:开窗自动聚焦那一下是异步的(要等输入框可见),
807
- // 所以"选区这会儿已经没了"只可能是我们自己在点的时候清的 —— 它骗不过这一条。
808
- var live = window.getSelection()
809
- check(
810
- '点「解读」后选区立刻收掉(同步检查,不等聚焦那一下)',
811
- !!live && (live.rangeCount === 0 || live.isCollapsed === true),
812
- live ? 'rangeCount=' + live.rangeCount + ' collapsed=' + live.isCollapsed : 'null',
813
- )
814
- check('选区记录也一并作废(不再拿它当"当前选区")', hook.selection() === null, JSON.stringify(hook.selection()))
815
- await sleep(120)
816
- check('旧选区没有让「❝ 引用」浮标复活', !onTop(quoteBtn), String(quoteBtn.style.display))
817
- // 用户报症状的那一步:小窗亮着时在输入框里按方向键(keyup 会触发一次选区检查)
818
- var askBox = document.querySelector('.dsh-sel-askbox')
819
- if (askBox) {
820
- askBox.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowLeft', bubbles: true }))
821
- await sleep(120)
822
- check('开窗后按方向键也不再冒出引用浮标', !onTop(quoteBtn), String(quoteBtn.style.display))
823
- }
824
- hook.close()
825
- await sleep(60)
826
- }
827
- }
828
-
829
- // ⑨-b 选中的文字被删掉 → 浮标跟着消失(用户报的:文字没了,浮标还赖在屏幕上)
830
- {
831
- hook.close()
832
- await sleep(80)
833
- var planBtn2 = document.querySelector('.dsh-sel-btn:not(.dsh-sel-quotebtn)')
834
- var docp3 = document.getElementById('docp')
835
- selectIn(docp3, 'the migration ran long')
836
- mouseUpOn(docp3)
837
- var lit = await waitFor(function () {
838
- return planBtn2 && planBtn2.style.display !== 'none' && planBtn2.getBoundingClientRect().width > 0
839
- }, 1500)
840
- check('(准备)主界面划词浮出「✦ 解读」', lit === true, planBtn2 ? planBtn2.style.display : '未找到按钮')
841
-
842
- // ① 选区塌陷(Backspace / Delete / 打字覆盖 / 剪切走的都是这条):浏览器会发 selectionchange
843
- window.getSelection().removeAllRanges()
844
- var gone = await waitFor(function () { return planBtn2.style.display === 'none' }, 800)
845
- check('选中的文字被删掉(选区塌陷)→ 浮标消失', gone === true, planBtn2 ? planBtn2.style.display : '未找到按钮')
846
-
847
- // ② 选中的节点被整个换掉(流式回答重渲染就是这条):那段文字没了,浮标也不该留着。
848
- // ⚠️ 先确认浮标**真的收掉了**再往下走:不然 ① 漏掉时浮标一直亮着,
849
- // 下面的"再次亮着"会白捡一个 PASS(这条用例就测不出东西了)。
850
- var settled = await waitFor(function () { return planBtn2.style.display === 'none' }, 800)
851
- check('(准备 ②)① 之后浮标确实是收着的', settled === true, planBtn2 ? planBtn2.style.display : '未找到按钮')
852
- selectIn(docp3, 'the migration ran long')
853
- mouseUpOn(docp3)
854
- var lit2 = await waitFor(function () {
855
- return planBtn2 && planBtn2.style.display !== 'none' && planBtn2.getBoundingClientRect().width > 0
856
- }, 1500)
857
- check('(准备 ②)浮标再次亮着', lit2 === true, planBtn2 ? planBtn2.style.display : '未找到按钮')
858
- docp3.textContent = '(这段文字已经被换掉了)'
859
- var gone2 = await waitFor(function () { return planBtn2.style.display === 'none' }, 800)
860
- check('选中的节点被摘掉(文字被换掉)→ 浮标也消失', gone2 === true, planBtn2 ? planBtn2.style.display : '未找到按钮')
861
- docp3.textContent = 'Dev: the migration ran long, so we ship Wednesday.'
862
- await sleep(30)
863
- }
864
-
865
- // ⑩ 浮标图标的**墨迹**要在画布里居中(用户报的:"✦ 和 解读 好像没对齐,也没在按钮里居中")
866
- // 为什么量 getBBox 而不是截图:浮动按钮是 align-items:center 的 flex,居中的是**画布盒子**,
867
- // 图形在画布里偏一点,图标就整体偏一点 —— 老星形路径 y 占 1.2~12.4(中心 6.8,不是 8),
868
- // 于是比右边「解读」两个字高 1.1px(8x 截图量出来的)。文字墨迹没法用 DOM 量,
869
- // 但"图标墨迹居中"这一条就足以拦住这类回归。
870
- {
871
- var icons = [
872
- ['解读浮标 ✦ 图标', document.querySelector('.dsh-sel-btn:not(.dsh-sel-quotebtn) svg')],
873
- ['引用浮标 ❝ 图标', quoteBtn.querySelector('svg')],
874
- ]
875
- for (var k = 0; k < icons.length; k += 1) {
876
- var svg = icons[k][1]
877
- // 浮标这会儿多半是隐藏的(display:none)—— 隐藏元素的 getBBox() 一律返回 0,
878
- // 所以量之前先临时摆出来,量完还原(不改它此刻的真实状态)。
879
- var btn = svg ? svg.closest('.dsh-sel-btn') : null
880
- var prev = btn ? btn.style.display : ''
881
- if (btn) btn.style.display = 'inline-flex'
882
- var box = svg ? svg.getBBox() : null
883
- if (btn) btn.style.display = prev
884
- var cx = box ? box.x + box.width / 2 : 0
885
- var cy = box ? box.y + box.height / 2 : 0
886
- check(
887
- icons[k][0] + '墨迹在 16×16 画布里居中',
888
- !!box && Math.abs(cx - 8) <= 0.25 && Math.abs(cy - 8) <= 0.25,
889
- box ? 'cx=' + cx.toFixed(2) + ' cy=' + cy.toFixed(2) : '未找到 svg',
890
- )
891
- }
892
- }
893
-
894
- // ⑪ 胶囊宽度:**有**费用胶囊就跟随它,**没有**就收到 PILL_FALLBACK_MAX = 200px(用户定的规则)
895
- // 这条只有真浏览器能量:要真布局(getBoundingClientRect)、真 CSS(max-width 生效),
896
- // 还要能挂一个假的 dsh-spend 挂载点。
897
- {
898
- var pillNode = hook.pill()
899
- var pillName = pillNode.querySelector('.dsh-sel-pillname')
900
- var pillMeta = pillNode.querySelector('.dsh-sel-pillmeta')
901
- var savedName = pillName.textContent
902
- var savedMeta = pillMeta.textContent
903
- // 先确认"动画是声明过的"(冻住之后就量不出来了):收球/吸附靠 max-width / padding / gap / 位置的过渡
904
- var pillTrans = getComputedStyle(pillNode).transitionProperty
905
- check('胶囊声明了过渡(收球/吸附是动画,不是跳变)', /max-width/.test(pillTrans) && /padding/.test(pillTrans) && /left/.test(pillTrans), pillTrans)
906
- check('过渡只在"第一帧已经贴好位置"之后才打开(data-ready)', hook.pillState().ready === true, String(hook.pillState().ready))
907
-
908
- // 收 / 放**严格互逆**的结构前提:所有会变的属性共用同一条时长 + 同一条曲线,
909
- // 而且那条曲线必须时间对称(x1+x2=1 且 y1+y2=1 → 把动画倒过来放就是另一个方向)。
910
- // 之前用 easeOutQuint(.22,1,.36,1):.22+.36≠1,两个方向前后半程不镜像 —— 用户报的"不对称"就是它。
911
- var durList = splitCssList(getComputedStyle(pillNode).transitionDuration)
912
- var easeList = splitCssList(getComputedStyle(pillNode).transitionTimingFunction)
913
- var morphDur = durList.slice(1) // 第 0 条是 box-shadow 的 .15s,不属于形变
914
- var morphEase = easeList.slice(1)
915
- check('收/放所有属性共用一条时长', morphDur.length > 0 && morphDur.every(function (x) { return x === morphDur[0] }), durList.join(' | '))
916
- check('收/放所有属性共用一条曲线', morphEase.length > 0 && morphEase.every(function (x) { return x === morphEase[0] }), easeList.join(' | '))
917
- // 不用正则:这段代码在模板字符串里,\( 这种转义会被吃掉(踩过)
918
- var easeText = morphEase[0] || ''
919
- var easeAt = easeText.indexOf('cubic-bezier(')
920
- var easeArgs = easeAt >= 0
921
- ? easeText.slice(easeAt + 'cubic-bezier('.length, -1).split(',').map(function (x) { return Number(x.trim()) })
922
- : []
923
- check(
924
- '曲线时间对称(x1+x2=1 且 y1+y2=1 → 倒放即另一个方向)',
925
- easeArgs.length === 4 && Math.abs(easeArgs[0] + easeArgs[2] - 1) < 1e-6 && Math.abs(easeArgs[1] + easeArgs[3] - 1) < 1e-6,
926
- easeText + ' → ' + JSON.stringify(easeArgs),
927
- )
928
- var nameDur = splitCssList(getComputedStyle(pillName).transitionDuration)[0]
929
- var starEl = pillNode.querySelector('.dsh-sel-pillstar')
930
- var starTrans = getComputedStyle(starEl)
931
- check('文字那格的时长和胶囊一致(不一致 → 中间帧宽度由另一条曲线决定)', nameDur === morphDur[0], nameDur + ' vs ' + morphDur[0])
932
-
933
- // ── 球心图形的形变轨迹(这一组是重点:光看两端点会漏掉中间帧的形状) ──
934
- // 曾经踩过的坑:用 CSS 的 transition:d 在两条静态 path 之间插值 —— CSS 只能线性插值,
935
- // 于是中途会经过一个正菱形(谷跟着尖一起动),读起来像方块;而且尖在 160ms 就停住、
936
- // 胶囊还要走到 280ms,"配合"就散了。所以现在路径由 JS 按 p 现算(两段 smoothstep)。
937
- check('球心图形不走 CSS 的 d 过渡(线性插值会经过正菱形)', starTrans.transitionProperty.trim() !== 'd', starTrans.transitionProperty.trim() || '(无过渡)')
938
- var radiiAt = function (p) {
939
- // 不写正则:模板字符串里反斜杠会被吃掉。按 M/L 切段再取两个数。
940
- var parts = String(hook.pillStarAt(p)).split('L')
941
- parts[0] = parts[0].charAt(0) === 'M' ? parts[0].slice(1) : parts[0]
942
- return parts.map(function (seg) {
943
- var z = seg.indexOf('Z')
944
- if (z >= 0) seg = seg.slice(0, z)
945
- var xy = seg.split(' ')
946
- return Math.sqrt(parseFloat(xy[0]) * parseFloat(xy[0]) + parseFloat(xy[1]) * parseFloat(xy[1]))
947
- })
948
- }
949
- var r0 = radiiAt(0)
950
- var r25 = radiiAt(0.25)
951
- var r50 = radiiAt(0.5)
952
- var r80 = radiiAt(0.8)
953
- var r100 = radiiAt(1)
954
- check('p=0:32 个顶点同半径 4.00(8px 圆点)', r0.length === 32 && r0.every(function (r) { return Math.abs(r - 4) < 0.02 }), r0.length + ' 顶点,半径 ' + r0[0].toFixed(3))
955
- check('p=0.25:谷仍停在 4.00("两段"的签名 —— 线性插值会把它拉到 3.80,那就是菱形相)', Math.abs(r25[4] - 4) < 0.02, '谷 ' + r25[4].toFixed(3))
956
- check('p=0.25:尖已经在长(> 4 但远未到顶,尖先长)', r25[0] > 4.3 && r25[0] < r100[0] - 0.5, '尖 ' + r25[0].toFixed(3) + '(顶 ' + r100[0].toFixed(2) + ')')
957
- check('p=0.5:尖接近满长(≥ 顶-0.6),身刚开始收(谷仍 > 3.6)', r50[0] >= r100[0] - 0.6 && r50[4] > 3.6, '尖 ' + r50[0].toFixed(2) + ' / 谷 ' + r50[4].toFixed(2))
958
- // 同一个 p 下,从尖到谷的半径必须单调递减(尖 > 11.25° > 22.5° > 33.75° > 谷),
959
- // 且左右对称 —— 这就是"不出现波浪边"的可测形式。
960
- var sectorOk = function (r) {
961
- var k
962
- for (k = 0; k < 4; k += 1) if (r[k] < r[k + 1] - 0.02) return false
963
- for (k = 1; k < 16; k += 1) if (Math.abs(r[k] - r[32 - k]) > 0.02) return false
964
- return true
965
- }
966
- check('同一进度下:尖→谷单调递减且左右对称(没有波浪边)', sectorOk(r25) && sectorOk(r50) && sectorOk(radiiAt(0.8)), JSON.stringify(r50.map(function (r) { return Number(r.toFixed(2)) }).slice(0, 5)))
967
- // 形状空间的过冲:尖在 p≈0.8 处冲过终点一点,到 p=1 精确回到终点
968
- check('尖有轻微过冲(p=0.8 处高出终点一点,再收回)', r80[0] > r100[0] + 0.05, 'p=.8 尖 ' + r80[0].toFixed(3) + ' / p=1 尖 ' + r100[0].toFixed(3))
969
- check('p=1 的路径与客户端常量逐字相同(和「✦ 解读」那颗星同一条几何)', hook.pillStarAt(1) === hook.pillStarD(), hook.pillStarAt(1) === hook.pillStarD() ? '逐字一致' : '不一致')
970
- check('p=0 的路径与客户端常量逐字相同(展开态就是那枚 8px 圆点)', hook.pillStarAt(0) === hook.pillStarDotD(), hook.pillStarAt(0) === hook.pillStarDotD() ? '逐字一致' : '不一致')
971
- // 曲线:时间对称(倒放即另一个方向)+ 轻微蓄势/过冲(不直来直去)
972
- var xs = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]
973
- var ys = xs.map(function (x) { return hook.pillStarEaseAt(x) })
974
- var symOk = xs.every(function (x) { return Math.abs(hook.pillStarEaseAt(x) + hook.pillStarEaseAt(1 - x) - 1) < 1e-6 })
975
- check('球心图形的曲线时间对称(收/放互为倒放)', symOk, ys.map(function (y) { return y.toFixed(3) }).join(' '))
976
- // 曲线本身要"干净"(单调 S,不能是波浪):过冲放在形状空间,不放在时间轴上
977
- var monotone = true
978
- for (var mi = 1; mi < xs.length; mi += 1) if (hook.pillStarEaseAt(xs[mi]) <= hook.pillStarEaseAt(xs[mi - 1])) monotone = false
979
- check('时间轴上的曲线是干净单调的 S(过冲不放在这里 —— 对称曲线的过冲必成波浪)', monotone && ys[0] > 0 && ys[ys.length - 1] < 1, ys.map(function (y) { return y.toFixed(3) }).join(' '))
980
- check('JS 那条时长与 CSS 那条同拍(280ms ↔ .28s)', hook.pillMorphMs() === 280 && morphDur[0] === '0.28s', hook.pillMorphMs() + 'ms vs ' + morphDur[0])
981
- freezeMotion()
982
- // 最坏内容(选中文字 12 字 + 最长状态):自然宽度 301px,最容易被上限拦到
983
- pillName.textContent = '迁移作业耗时超出了原计划…'
984
- pillMeta.textContent = '· 追问中 12.3s'
985
-
986
- // ① 没有费用胶囊 → 兜底上限 200
987
- hook.place()
988
- check('没有费用胶囊:上限收到 200px', pillNode.style.maxWidth === '200px', pillNode.style.maxWidth || '(空 = 回落 CSS 280)')
989
- check('没有费用胶囊:不写死宽度(内容自适应)', pillNode.style.width === '', pillNode.style.width || '(空)')
990
- var cappedWidth = Math.round(pillNode.getBoundingClientRect().width)
991
- check('没有费用胶囊:最坏内容也真的被拦在 200px', cappedWidth === 200, cappedWidth + 'px')
992
- check('没有费用胶囊:拦到上限后选中文字走省略号(不撑破)', pillName.scrollWidth > pillName.clientWidth, pillName.clientWidth + 'px 可见 / ' + pillName.scrollWidth + 'px 全长')
993
-
994
- // ② 挂一个假的费用胶囊(宽 240)→ 跟随它的实测宽度,并清掉兜底上限
995
- var fakeSpend = document.createElement('div')
996
- fakeSpend.id = 'dsh-spend-widget'
997
- fakeSpend.innerHTML = '<div class="dsu-widget" style="position:fixed;right:20px;bottom:20px">'
998
- + '<div class="dsu-pill" style="box-sizing:border-box;width:240px;height:32px"></div></div>'
999
- document.body.appendChild(fakeSpend)
1000
- hook.place()
1001
- check('有费用胶囊:宽度写死成它的实测宽度', pillNode.style.width === '240px', pillNode.style.width || '(空)')
1002
- check('有费用胶囊:清掉兜底上限(回到 CSS 280,和它同宽)', pillNode.style.maxWidth === '', pillNode.style.maxWidth || '(空)')
1003
- // ⚠️ 宽度现在有过渡(.32s):改完立刻量会量到**起点**,得等它走完
1004
- await sleep(420)
1005
- var followedWidth = Math.round(pillNode.getBoundingClientRect().width)
1006
- check('有费用胶囊:最坏内容跟着它到 240px(上限确实放开了)', followedWidth === 240, followedWidth + 'px')
1007
-
1008
- // ③ 费用胶囊消失 → 立刻回到兜底
1009
- fakeSpend.remove()
1010
- hook.place()
1011
- check(
1012
- '费用胶囊消失后又回到 200px 兜底',
1013
- pillNode.style.maxWidth === '200px' && pillNode.style.width === '',
1014
- pillNode.style.maxWidth + ' / ' + (pillNode.style.width || '宽=空'),
1015
- )
1016
- // ⚠️ 这里必须把"最坏内容"重新写一遍再量:paintPill() 会在异步里把文案换回**真实内容**
1017
- // (真实内容自然宽 184px < 200,于是量到 184 而不是 200)—— 这条断言想验的是
1018
- // "上限回到 200",不是"内容恰好 301px",写回最坏内容才与内容变化解耦(否则偶发红)。
1019
- pillName.textContent = '迁移作业耗时超出了原计划…'
1020
- pillMeta.textContent = '· 追问中 12.3s'
1021
- await sleep(420)
1022
- check('回到兜底后宽度也收回来了', Math.round(pillNode.getBoundingClientRect().width) === 200, Math.round(pillNode.getBoundingClientRect().width) + 'px')
1023
-
1024
- // 还原内容(后面的断言不受影响)
1025
- pillName.textContent = savedName
1026
- pillMeta.textContent = savedMeta
1027
- hook.place()
1028
- }
1029
-
1030
- // ⑫ 胶囊可以拖:拖到别处就留在那儿;拖回右下角附近自动吸附回去;拖完那一下不算点击。
1031
- // 全用真鼠标事件(mousedown 打在胶囊上,mousemove/mouseup 打在 window —— 和面板拖动同一套)。
1032
- // 过渡已在 ⑪ 冻住(见 freezeMotion),所以每一步都能**同步**断言,不用等动画。
1033
- {
1034
- var pillEl = hook.pill()
1035
- var panelOpen = function () {
1036
- return hook.quoteState().panelOpen === true
1037
- }
1038
- function fire(type, x, y, onWindow) {
1039
- var event = new MouseEvent(type, { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y, button: 0 })
1040
- if (onWindow) window.dispatchEvent(event)
1041
- else pillEl.dispatchEvent(event)
1042
- }
1043
- function dragTo(x, y) {
1044
- var rect = pillEl.getBoundingClientRect()
1045
- fire('mousedown', Math.round(rect.left) + 20, Math.round(rect.top) + 10, false)
1046
- fire('mousemove', x, y, true)
1047
- fire('mousemove', x + 4, y + 2, true) // 第二下:确保"位移超过阈值"这条判定走完
1048
- fire('mouseup', x + 4, y + 2, true)
1049
- }
1050
- /** 拖到右下角那一带(吸附区)—— 用视口右下角算,和"贴角位置"差得不多,必落在吸附半径里。 */
1051
- function dragToCorner() {
1052
- dragTo(Math.round(window.innerWidth - 30), Math.round(window.innerHeight - 30))
1053
- }
1054
-
1055
- hook.place()
1056
- var home = hook.pillState()
1057
- check('起始:贴着右下角(dock=anchor,right/bottom=20)', home.dock === 'anchor' && home.rect.right === 20 && home.rect.bottom === 20, home.dock + ' ' + home.rect.right + '/' + home.rect.bottom)
1058
-
1059
- // ① 拖到屏幕中间 → 留在那儿(自由摆放)
1060
- var midX = Math.round(window.innerWidth / 2)
1061
- var midY = Math.round(window.innerHeight / 2)
1062
- dragTo(midX, midY)
1063
- var free = hook.pillState()
1064
- check('拖到屏幕中间:位置跟着走了(dock=free)', free.dock === 'free', free.dock)
1065
- check('拖到屏幕中间:落在松手的地方(±40px)', Math.abs(free.rect.left + 20 - midX) < 40 && Math.abs(free.rect.top + 10 - midY) < 40, free.rect.left + ',' + free.rect.top)
1066
-
1067
- // 自由摆放时,贴角轮询不许把它拽回去
1068
- hook.place()
1069
- var stillFree = hook.pillState()
1070
- check('自由摆放时轮询不把它拽回去', stillFree.dock === 'free' && Math.abs(stillFree.rect.left - free.rect.left) <= 2, stillFree.dock + ' ' + stillFree.rect.left)
1071
-
1072
- // ② 拖回右下角附近 → 自动吸附回贴角位置(没有费用胶囊就是 20/20)
1073
- dragToCorner()
1074
- var snapped = hook.pillState()
1075
- check('拖回右下角附近:自动吸附(dock=anchor)', snapped.dock === 'anchor', snapped.dock)
1076
- check('吸附回右下角 20/20', snapped.rect.right === 20 && snapped.rect.bottom === 20, snapped.rect.right + '/' + snapped.rect.bottom)
1077
-
1078
- // ③ 拖完浏览器会补一个 click —— 不能被当成"点了一下"(否则会顺手开关小窗)
1079
- if (panelOpen()) hook.close()
1080
- dragTo(midX, midY)
1081
- fire('click', midX, midY, false) // 模拟浏览器在拖完之后补的那一下
1082
- check('拖完那一下不算点击(小窗没被打开)', panelOpen() === false, String(panelOpen()))
1083
- check('拖完那一下也不算"点开"(胶囊还在自由摆放位)', hook.pillState().dock === 'free', hook.pillState().dock)
1084
-
1085
- // ④ 有费用胶囊时:拖到右下角附近 → 吸附到"跟随它的位置"(在那枚胶囊上方、同宽)
1086
- var fakeSpend2 = document.createElement('div')
1087
- fakeSpend2.id = 'dsh-spend-widget'
1088
- fakeSpend2.innerHTML = '<div class="dsu-widget" style="position:fixed;right:20px;bottom:20px">'
1089
- + '<div class="dsu-pill" style="box-sizing:border-box;width:240px;height:32px"></div></div>'
1090
- document.body.appendChild(fakeSpend2)
1091
- hook.place()
1092
- dragToCorner()
1093
- var withSpend = hook.pillState()
1094
- check('有费用胶囊时拖到右下角:吸附到跟随它的位置', withSpend.dock === 'anchor' && withSpend.rect.right === 20, withSpend.dock + ' right=' + withSpend.rect.right)
1095
- check('吸附后宽度是跟随它的 240px', withSpend.rect.width === 240, withSpend.rect.width + 'px')
1096
- check('吸附位置在费用胶囊**上方**(不压住它)', withSpend.rect.bottom >= 42, 'bottom=' + withSpend.rect.bottom)
1097
-
1098
- // 再拖走 → 留在那儿;再拖回 → 又吸附回去
1099
- dragTo(midX, midY)
1100
- check('有费用胶囊时也能拖走(dock=free)', hook.pillState().dock === 'free', hook.pillState().dock)
1101
- dragToCorner()
1102
- var backHome = hook.pillState()
1103
- check('再拖回右下角:又吸附回去', backHome.dock === 'anchor' && backHome.rect.right === 20, backHome.dock + ' right=' + backHome.rect.right)
1104
- fakeSpend2.remove()
1105
- hook.place()
1106
-
1107
- // ⑤ 没动的那一下仍然是"点击"(拖动不能把点击吃掉)
1108
- if (panelOpen()) hook.close()
1109
- var clickRect = pillEl.getBoundingClientRect()
1110
- fire('mousedown', Math.round(clickRect.left) + 20, Math.round(clickRect.top) + 10, false)
1111
- fire('mouseup', Math.round(clickRect.left) + 20, Math.round(clickRect.top) + 10, true)
1112
- fire('click', Math.round(clickRect.left) + 20, Math.round(clickRect.top) + 10, false)
1113
- await sleep(160)
1114
- check('没位移的那一下照旧是点击(小窗打开了)', panelOpen() === true, String(panelOpen()))
1115
- hook.close()
1116
- // 关小窗会让面板尺寸变 0 → 面板那个 ResizeObserver 会补一次 placePanel→paintPill(= 一次"活动")。
1117
- // 生产上无所谓(只是把静置计时重新拨满),测试里得先把这拍异步排掉,否则会和下面的收球抢时序。
1118
- await sleep(200)
1119
- }
1120
-
1121
- // ⑬ 静置收球:**没有费用胶囊**时收成小球;有费用胶囊 / 小窗开着 / 鼠标停着都不收。
1122
- // 过渡已冻住,所以"收成 32×32"是同步可断言的;"到底有没有过渡"在 ⑪ 用 computed style 断言过。
1123
- {
1124
- var pillBall = hook.pill()
1125
- var panelIsOpen = function () {
1126
- return hook.quoteState().panelOpen === true
1127
- }
1128
- if (panelIsOpen()) hook.close()
1129
- await sleep(200)
1130
- var before = hook.pillState()
1131
- check('收球前是展开的胶囊', before.ball === false && before.rect.width > 100, before.rect.width + 'px')
1132
-
1133
- var anchoredRightBefore = window.innerWidth - before.rect.right
1134
- var idleCollapsed = hook.pillIdle()
1135
- var balled = hook.pillState()
1136
- check('没有费用胶囊 → 静置收球', idleCollapsed === true && balled.ball === true, JSON.stringify(balled.style))
1137
- check('收成小球:32×32 的圆', balled.rect.width === balled.ballSize && balled.rect.height === balled.ballSize, balled.rect.width + '×' + balled.rect.height)
1138
- check('小球仍然贴着右下角(位置没跑)', balled.rect.right === 20 && balled.rect.bottom === 20, balled.rect.right + '/' + balled.rect.bottom)
1139
- check(
1140
- '锚定态收球:以胶囊自己的右边缘为锚(右边缘不动,往左收成球)',
1141
- Math.abs((window.innerWidth - balled.rect.right) - anchoredRightBefore) <= 1,
1142
- '右边缘 ' + anchoredRightBefore.toFixed(1) + ' → ' + (window.innerWidth - balled.rect.right).toFixed(1),
1143
- )
1144
-
1145
- // ── 球心里的星芒:收球时 d 换成星、展开时换回圆点,几何都得对得上 ──
1146
- // 球心图形是 JS 按时间推进的(胶囊那条是 CSS 过渡,被 freezeMotion 冻成瞬时),
1147
- // 所以分三件事验:① 目标对不对(同步)② 计时器真的在推进(短等几拍)③ 终态几何(同步画到终点再量)。
1148
- // ⚠️ 不能靠"等 280ms 再看":这段时间里任何一次活动(比如首轮解读流式结束后的 paintPill)
1149
- // 都会按设计把球展开回胶囊,等出来的就不是球态了。
1150
- var starCanvas = pillBall.querySelector('.dsh-sel-pillicon')
1151
- var starPath = pillBall.querySelector('.dsh-sel-pillstar')
1152
- var starWant = hook.pillStarD()
1153
- var dotWant = hook.pillStarDotD()
1154
- check('收球时球心图形的目标是"星芒"(1)', hook.pillStarTarget() === 1, String(hook.pillStarTarget()))
1155
- // "计时器真的在推进"用解耦的钩子验(不依赖胶囊此刻是不是球态 ——
1156
- // 首轮解读收尾时 paintPill→notePillActivity 会按设计把球展开,和这条无关)
1157
- hook.pillStarAnimate(0)
1158
- var pAtStart = hook.pillStarProgress()
1159
- hook.pillStarAnimate(1)
1160
- var grew = false
1161
- for (var tick = 0; tick < 12 && !grew; tick += 1) {
1162
- await sleep(24)
1163
- if (hook.pillStarProgress() > pAtStart + 0.05) grew = true
1164
- }
1165
- check('球心图形的动画真的在推进(计时器在跑,不是画一帧就停)', grew, pAtStart.toFixed(3) + ' → ' + hook.pillStarProgress().toFixed(3))
1166
- // 球态的画布居中:这是 CSS 那边的事(margin 过渡),冻住运动后是瞬时终值,可以同步量。
1167
- // ⚠️ 先显式确保还在球态:上面的等待里任何一次活动都会按设计把球展开。
1168
- if (hook.pillState().ball !== true) {
1169
- hook.pillIdle()
1170
- await sleep(80)
1171
- }
1172
- check('(前置)此刻确实是球态,下面量的是球心几何', hook.pillState().ball === true, String(hook.pillState().ball))
1173
- var ballRect = pillBall.getBoundingClientRect()
1174
- var canvasRect = starCanvas.getBoundingClientRect()
1175
- check(
1176
- '星芒在球里居中(图形中心 = 球的中心,±0.6px)',
1177
- Math.abs(canvasRect.left + canvasRect.width / 2 - (ballRect.left + ballRect.width / 2)) <= 0.6,
1178
- '画布中心 ' + (canvasRect.left + canvasRect.width / 2).toFixed(1) + ' vs 球中心 ' + (ballRect.left + ballRect.width / 2).toFixed(1),
1179
- )
1180
- check('球态画布用 -1px 负边距摆正(内宽 32-2 边框 = 30)', getComputedStyle(starCanvas).marginLeft === '-1px', getComputedStyle(starCanvas).marginLeft)
1181
- // 同步画到终点,再量终态几何(不受计时器与异步活动影响)
1182
- hook.pillStarTo(1)
1183
- var starD = starPath.getAttribute('d')
1184
- var starBox = starPath.getBBox()
1185
- check('收球终态:d 与客户端那条常量逐字相同', starD === starWant, starD === starWant ? '32 顶点一致' : starD.slice(0, 18) + '… ≠ ' + starWant.slice(0, 18))
1186
- var pillFontSize = parseFloat(getComputedStyle(pillBall).fontSize)
1187
- check(
1188
- '星芒和胶囊里的文字一样高(墨迹 ≈ 字号,±0.8px)',
1189
- Math.abs(starBox.height - pillFontSize) <= 1.0 && Math.abs(starBox.width - starBox.height) < 0.15,
1190
- '星芒 ' + starBox.width.toFixed(2) + '×' + starBox.height.toFixed(2) + ' vs 字号 ' + pillFontSize + 'px',
1191
- )
1192
- check(
1193
- '星芒的内外半径比 = .3788(和「✦ 解读」浮标那颗星同一条几何,只是缩小了)+ 顶点数 32',
1194
- Math.abs(r100[0] / r100[4] - 1 / 0.3788) < 0.05 && r100.length === 32,
1195
- '尖 ' + r100[0].toFixed(2) + ' / 谷 ' + r100[4].toFixed(2) + ' = ' + (r100[0] / r100[4]).toFixed(3) + '(应为 2.640)',
1196
- )
1197
-
1198
- // 鼠标一放上去就展开(若已被异步活动展开过,先收回球态,好让 mouseenter 真的走一次展开)
1199
- if (hook.pillState().ball !== true) {
1200
- hook.pillIdle()
1201
- await sleep(80)
1202
- }
1203
- pillBall.dispatchEvent(new MouseEvent('mouseenter', { view: window }))
1204
- var expanded = hook.pillState()
1205
- check('鼠标移上去:小球展开回胶囊', expanded.ball === false && expanded.rect.width > 100, expanded.rect.width + 'px')
1206
- check(
1207
- '锚定态展开:右边缘同样不动(往右长回去)',
1208
- Math.abs((window.innerWidth - expanded.rect.right) - anchoredRightBefore) <= 1,
1209
- '右边缘 ' + anchoredRightBefore.toFixed(1) + ' → ' + (window.innerWidth - expanded.rect.right).toFixed(1),
1210
- )
1211
- // 展开态:图形回到 8px 圆点,且画布**布局占位**仍是 8px(两侧 -12px)——
1212
- // 这样胶囊的排版与宽度和以前那枚 8px 圆点完全一样,不会因为换了个 SVG 就把胶囊撑宽。
1213
- check('展开时球心图形的目标是"圆点"(0)', hook.pillStarTarget() === 0, String(hook.pillStarTarget()))
1214
- // 真实悬停路径:展开会重置静置计时,不能因此取消球心图形的动画。
1215
- pillBall.dispatchEvent(new MouseEvent('mouseleave', { view: window }))
1216
- if (hook.pillState().ball !== true) hook.pillIdle()
1217
- hook.pillStarTo(1)
1218
- pillBall.dispatchEvent(new MouseEvent('mouseenter', { view: window }))
1219
- await sleep(350)
1220
- check(
1221
- '悬停展开后星芒自行回到圆点',
1222
- hook.pillState().ball === false && hook.pillStarProgress() === 0 && starPath.getAttribute('d') === dotWant,
1223
- 'ball=' + hook.pillState().ball + ' progress=' + hook.pillStarProgress(),
1224
- )
1225
- hook.pillStarTo(0) // 同步画回圆点,量终态
1226
- var dotD = pillBall.querySelector('.dsh-sel-pillstar').getAttribute('d')
1227
- var dotBox = pillBall.querySelector('.dsh-sel-pillstar').getBBox()
1228
- var iconCanvas = pillBall.querySelector('.dsh-sel-pillicon')
1229
- var iconStyle = getComputedStyle(iconCanvas)
1230
- var pillRectForDot = pillBall.getBoundingClientRect()
1231
- var canvasForDot = iconCanvas.getBoundingClientRect()
1232
- var dotRect = starPath.getBoundingClientRect()
1233
- var dotCx = dotRect.left + dotRect.width / 2
1234
- var dotCy = dotRect.top + dotRect.height / 2
1235
- var canvasCx = canvasForDot.left + canvasForDot.width / 2
1236
- var pillCy = pillRectForDot.top + pillRectForDot.height / 2
1237
- check(
1238
- '展开后圆点墨迹居中:横向在 SVG 画布中心,纵向在胶囊中心',
1239
- Math.abs(dotCx - canvasCx) < 0.25 && Math.abs(dotCy - pillCy) < 0.25,
1240
- '偏移 x=' + (dotCx - canvasCx).toFixed(2) + ' y=' + (dotCy - pillCy).toFixed(2),
1241
- )
1242
- check('展开后回到圆点态:d 与客户端那条常量**逐字相同**', dotD === dotWant, dotD === dotWant ? '32 顶点一致' : dotD.slice(0, 14) + '… ≠ ' + dotWant.slice(0, 14))
1243
- // 这条是补覆盖:上面用 pillStarTo(0) 同步画到终点,验的是"终点长什么样",
1244
- // 但"展开这条动画**自己**会不会走到终点"没验过 —— 用户报的"展开后没恢复成圆点"正是这里。
1245
- hook.pillStarAnimate(1)
1246
- await sleep(60)
1247
- hook.pillStarAnimate(0)
1248
- var settled = false
1249
- for (var settleTick = 0; settleTick < 24 && !settled; settleTick += 1) {
1250
- await sleep(24)
1251
- if (hook.pillStarProgress() === 0) settled = true
1252
- }
1253
- check('展开动画自己会走到圆点(不是停在星芒上)', settled, 'progress=' + hook.pillStarProgress())
1254
-
1255
- // 用户报的"展开后没恢复成圆点",根因是**打断**:来回打断时每一段都跑满 280ms,
1256
- // 于是图形永远追不上目标(实测序列 10101010、进度卡在 0.158)。
1257
- // 这里用高频交替打断复现它,然后断言"停手之后能很快落到目标上"。
1258
- // (比"等悬停那一下走完"稳:冒烟跑在虚拟时钟下,10 秒静置会瞬间到期、球态随时可能翻。)
1259
- hook.pillStarAnimate(0)
1260
- for (var ham = 0; ham < 8; ham += 1) {
1261
- hook.pillStarAnimate(1)
1262
- await sleep(16)
1263
- hook.pillStarAnimate(0)
1264
- await sleep(16)
1265
- }
1266
- var settleTicks = -1
1267
- for (var st = 0; st < 40 && settleTicks < 0; st += 1) {
1268
- await sleep(24)
1269
- if (hook.pillStarProgress() === 0) settleTicks = st + 1
1270
- }
1271
- check(
1272
- '来回打断之后图形仍会落到目标上,且很快(不再卡在半路)',
1273
- settleTicks > 0 && settleTicks <= 6,
1274
- '停手后第 ' + settleTicks + ' 拍到位(每拍 24ms)· progress=' + hook.pillStarProgress() + ' · 起跑=' + hook.pillStarStarts(),
1275
- )
1276
- pillBall.dispatchEvent(new MouseEvent('mouseleave', { view: window }))
1277
- check('圆点态墨迹 8×8(和以前那枚 8px 圆点一致)', Math.abs(dotBox.width - 8) < 0.1 && Math.abs(dotBox.height - 8) < 0.1, dotBox.width.toFixed(2) + '×' + dotBox.height.toFixed(2))
1278
- check('展开态画布布局占位 = 8px(两侧 -12px 负边距)', iconStyle.marginLeft === '-12px' && iconStyle.marginRight === '-12px', iconStyle.marginLeft + '/' + iconStyle.marginRight)
1279
- check('展开态画布本身仍是 32px(1 单位 = 1px,几何数字照抄设计稿)', iconStyle.width === '32px' && iconStyle.height === '32px', iconStyle.width + '×' + iconStyle.height)
1280
- check('展开后还是贴右下角', expanded.rect.right === 20 && expanded.rect.bottom === 20, expanded.rect.right + '/' + expanded.rect.bottom)
1281
-
1282
- // 鼠标离开 → 又能收
1283
- pillBall.dispatchEvent(new MouseEvent('mouseleave', { view: window }))
1284
- check('鼠标离开后静置又能收球', hook.pillIdle() === true && hook.pillState().ball === true, String(hook.pillState().ball))
1285
-
1286
- // 鼠标停在上面时不许收("点得到"优先于"藏起来")
1287
- pillBall.dispatchEvent(new MouseEvent('mouseenter', { view: window }))
1288
- check('鼠标停在小球上时不收球', hook.pillIdle() === false && hook.pillState().ball === false, String(hook.pillState().ball))
1289
- pillBall.dispatchEvent(new MouseEvent('mouseleave', { view: window }))
1290
-
1291
- // 自由摆放时收球/展开都以**右边缘**为锚(往左收成球、往右展开回原样,右边缘一个像素都不动)
1292
- // 这条只有真浏览器能验:要真布局量矩形、真过渡(这里已冻住 → 量到的是终值)。
1293
- {
1294
- var homeRect = hook.pillState().rect
1295
- hook.pillIdle() // 先收起来(锚定态)
1296
- var anchored = hook.pillState()
1297
- var midX2 = Math.round(window.innerWidth / 2)
1298
- var midY2 = Math.round(window.innerHeight / 2)
1299
- pillBall.dispatchEvent(new MouseEvent('mouseenter', { view: window })) // 展开再拖(拖的是展开态)
1300
- pillBall.dispatchEvent(new MouseEvent('mouseleave', { view: window }))
1301
- var r0 = pillBall.getBoundingClientRect()
1302
- var fireOn = function (type, x, y, onWindow) {
1303
- var event = new MouseEvent(type, { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y, button: 0 })
1304
- if (onWindow) window.dispatchEvent(event)
1305
- else pillBall.dispatchEvent(event)
1306
- }
1307
- fireOn('mousedown', Math.round(r0.left) + 20, Math.round(r0.top) + 10, false)
1308
- fireOn('mousemove', midX2, midY2, true)
1309
- fireOn('mousemove', midX2 + 4, midY2 + 2, true)
1310
- fireOn('mouseup', midX2 + 4, midY2 + 2, true)
1311
- var wideFree = hook.pillState()
1312
- check('自由摆放(拖到中间)', wideFree.dock === 'free' && wideFree.rect.width > 100, wideFree.dock + ' ' + wideFree.rect.width)
1313
- check(
1314
- '自由摆放使用固定的 CSS right 定位,使过渡每一帧都能守住右边缘',
1315
- wideFree.style.left === 'auto' && wideFree.style.right !== 'auto' && wideFree.style.right !== '',
1316
- 'left=' + wideFree.style.left + ' right=' + wideFree.style.right,
1317
- )
1318
- var rightBefore = wideFree.rect.right
1319
- hook.pillIdle()
1320
- var ballFree = hook.pillState()
1321
- check('自由摆放时收球:右边缘不动(±1px,往左收成球)', ballFree.ball === true && Math.abs(ballFree.rect.right - rightBefore) <= 1, '右边缘 ' + rightBefore.toFixed(1) + ' → ' + ballFree.rect.right.toFixed(1))
1322
- var geoRightBefore = window.innerWidth - rightBefore
1323
- var geoRightBall = window.innerWidth - ballFree.rect.right
1324
- check('自由摆放时收球:球确实贴到了右边缘(球右边 = 原胶囊右边,球宽 32)', Math.abs(geoRightBall - geoRightBefore) <= 1 && ballFree.rect.width === ballFree.ballSize, '几何右边 ' + geoRightBefore.toFixed(1) + ' → ' + geoRightBall.toFixed(1) + ',球宽 ' + ballFree.rect.width)
1325
- pillBall.dispatchEvent(new MouseEvent('mouseenter', { view: window }))
1326
- var wideAgain = hook.pillState()
1327
- check('自由摆放时展开:右边缘也不动(±1px)', wideAgain.ball === false && Math.abs(wideAgain.rect.right - rightBefore) <= 1, '右边缘 ' + rightBefore.toFixed(1) + ' → ' + wideAgain.rect.right.toFixed(1))
1328
-
1329
- await sleep(30) // 存档是异步 POST
1330
- check(
1331
- '拖到别处后把位置存到了 host 上(右边缘 + 垂直中心)',
1332
- pillPosStore && Math.abs(pillPosStore.right - (window.innerWidth - wideFree.rect.right)) <= 2,
1333
- JSON.stringify(pillPosStore) + ' vs 几何右边 ' + (window.innerWidth - wideFree.rect.right).toFixed(1),
1334
- )
1335
- // 读回来:预置一个位置,调钩子,胶囊应当切成自由摆放并摆到那儿(右边缘为锚)
1336
- pillPosStore = { right: 300, y: 220 }
1337
- hook.loadPillPos()
1338
- await sleep(60)
1339
- var restored = hook.pillState()
1340
- check(
1341
- '重挂载读回 host 上的位置:切成自由摆放并摆回去(右边缘 = 存档)',
1342
- restored.dock === 'free' && Math.abs(window.innerWidth - restored.rect.right - 300) <= 2,
1343
- 'dock=' + restored.dock + ' 几何右边 ' + (window.innerWidth - restored.rect.right).toFixed(1) + '(存档 300)',
1344
- )
1345
- // 再拖回中间,接着跑下面的"收球右边缘不动"
1346
- pillPosStore = null
1347
- hook.loadPillPos()
1348
- await sleep(0)
1349
- pillBall.dispatchEvent(new MouseEvent('mouseleave', { view: window }))
1350
- // 拖回右下角,后面的断言接着用锚定态
1351
- var frozenStyle = document.getElementById('freeze-motion')
1352
- fireOn('mousedown', Math.round(pillBall.getBoundingClientRect().left) + 20, Math.round(pillBall.getBoundingClientRect().top) + 10, false)
1353
- fireOn('mousemove', Math.round(window.innerWidth - 30), Math.round(window.innerHeight - 30), true)
1354
- frozenStyle.remove() // 拖动中 data-drag 会关过渡,此时解除全局冻结,不会让胶囊跳
1355
- fireOn('mouseup', Math.round(window.innerWidth - 30), Math.round(window.innerHeight - 30), true)
1356
- var snapAnimated = pillBall.getAnimations().some(function (animation) { return animation.transitionProperty === 'left' })
1357
- check('自由摆放从 CSS right 切回吸附时仍有滑行动画', snapAnimated, String(snapAnimated))
1358
- document.head.appendChild(frozenStyle)
1359
- check('拖回右下角后又吸附(回到 anchor)', hook.pillState().dock === 'anchor', JSON.stringify(hook.pillState()))
1360
- await sleep(30)
1361
- check('吸附回右下角后清掉 host 上的存档(那是默认位置,不用记)', pillPosStore === null, JSON.stringify(pillPosStore))
1362
- check('锚定位置和最初一致', Math.abs(hook.pillState().rect.left - homeRect.left) <= 2, hook.pillState().rect.left + ' vs ' + homeRect.left)
1363
- void anchored
1364
- }
1365
-
1366
- // 有费用胶囊时不收球(收球的前提是"没有可跟随的胶囊")
1367
- var fakeSpend3 = document.createElement('div')
1368
- fakeSpend3.id = 'dsh-spend-widget'
1369
- fakeSpend3.innerHTML = '<div class="dsu-widget" style="position:fixed;right:20px;bottom:20px">'
1370
- + '<div class="dsu-pill" style="box-sizing:border-box;width:240px;height:32px"></div></div>'
1371
- document.body.appendChild(fakeSpend3)
1372
- hook.place()
1373
- check('有费用胶囊时静置也不收球', hook.pillIdle() === false, String(hook.pillState().ball))
1374
- check('有费用胶囊时保持展开', hook.pillState().ball === false, String(hook.pillState().ball))
1375
-
1376
- // 收着球时费用胶囊出现 → 立刻展开(球没有存在的意义了)
1377
- fakeSpend3.remove()
1378
- hook.place()
1379
- check('静置收球后费用胶囊出现 → 立刻展开', hook.pillIdle() === true && hook.pillState().ball === true, String(hook.pillState().ball))
1380
- document.body.appendChild(fakeSpend3)
1381
- hook.place()
1382
- check('费用胶囊一出现,小球立刻展开', hook.pillState().ball === false && hook.pillState().rect.width === 240, hook.pillState().ball + ' ' + hook.pillState().rect.width)
1383
- fakeSpend3.remove()
1384
- hook.place()
1385
-
1386
- // 小窗开着时不收球("小窗活动"也是活动)
1387
- hook.open('收球冒烟:小窗开着', '上下文', '冒烟')
1388
- await sleep(200)
1389
- check('小窗开着时不收球', hook.pillIdle() === false, String(hook.pillState().ball))
1390
- hook.close()
1391
- await sleep(200)
1392
- check('小窗关掉后又能收球', hook.pillIdle() === true && hook.pillState().ball === true, String(hook.pillState().ball))
1393
-
1394
- // 收着球也能点开(小球仍然是个按钮)
1395
- var ballRect = pillBall.getBoundingClientRect()
1396
- var bx = Math.round(ballRect.left) + 16
1397
- var by = Math.round(ballRect.top) + 16
1398
- pillBall.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, view: window, clientX: bx, clientY: by, button: 0 }))
1399
- window.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, view: window, clientX: bx, clientY: by, button: 0 }))
1400
- pillBall.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window, clientX: bx, clientY: by }))
1401
- await sleep(200)
1402
- check('小球状态点一下照样打开小窗', panelIsOpen() === true, String(panelIsOpen()))
1403
- hook.close()
1404
- await sleep(120)
1405
- }
1406
-
1407
- // 可选截图模式(?shot=1):把两个**终态**摆出来(展开=圆点 / 收球=星芒),
1408
- // 真实大小各一份 + 几何各一份放大,给人工核对,也给以后换图标留一张回归图。
1409
- // 用克隆而不是改原胶囊:克隆同样吃到那套 CSS(选择器都是 .dsh-sel-pill[data-ball="1"] …)。
1410
- if (location.search.indexOf('shot=1') >= 0) {
1411
- freezeMotion()
1412
- if (panelIsOpen()) hook.close()
1413
- await sleep(200)
1414
- hook.pillIdle()
1415
- await sleep(400)
1416
- var src = hook.pill()
1417
- var stage = document.createElement('div')
1418
- stage.setAttribute('id', 'shotstage')
1419
- stage.setAttribute('style',
1420
- 'position:fixed;left:0;top:0;right:0;bottom:0;z-index:2147483600;background:#f4f6f8;' +
1421
- 'display:flex;flex-direction:column;gap:22px;align-items:center;justify-content:center;color:#57606a;' +
1422
- 'font:12px -apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif')
1423
- var row1 = document.createElement('div')
1424
- row1.setAttribute('style', 'display:flex;gap:26px;align-items:center')
1425
- /**
1426
- * 造一格:clone 是胶囊的克隆;ball 决定它摆成球还是展开;scale 是放大倍数;
1427
- * dotOnly 时把球态画布里的 d 换成"圆点"那条 —— 用来单独看圆点的几何。
1428
- * 盒子尺寸按**放大后的视觉尺寸**给(球 32×scale),克隆的布局盒仍是被 scale 前的尺寸,
1429
- * 所以放大后的图形会对称溢出布局盒、正好落在盒子里。
1430
- */
1431
- function cell(ball, dotOnly, scale, label) {
1432
- var wrap = document.createElement('div')
1433
- wrap.setAttribute('style', 'display:flex;flex-direction:column;align-items:center;gap:9px')
1434
- var boxW = ball ? Math.round(48 * scale) + 24 : 232
1435
- var boxH = ball ? Math.round(48 * scale) + 24 : 92
1436
- var box = document.createElement('div')
1437
- box.setAttribute('style', 'display:flex;align-items:center;justify-content:center;' +
1438
- 'width:' + boxW + 'px;height:' + boxH + 'px;background:#fff;border-radius:14px;' +
1439
- 'box-shadow:0 6px 20px rgba(0,0,0,.10)')
1440
- var clone = src.cloneNode(true)
1441
- clone.style.position = 'static'
1442
- clone.style.right = 'auto'
1443
- clone.style.bottom = 'auto'
1444
- clone.style.transition = 'none'
1445
- clone.style.transform = scale === 1 ? 'none' : 'scale(' + scale + ')'
1446
- if (ball) clone.setAttribute('data-ball', '1')
1447
- else clone.removeAttribute('data-ball')
1448
- clone.setAttribute('data-ready', '1')
1449
- var starClone = clone.querySelector('.dsh-sel-pillstar')
1450
- if (starClone) {
1451
- starClone.setAttribute('d', ball && !dotOnly
1452
- ? window.__dshSelectionExplain.pillStarD()
1453
- : window.__dshSelectionExplain.pillStarDotD())
1454
- }
1455
- box.appendChild(clone)
1456
- wrap.appendChild(box)
1457
- var cap = document.createElement('div')
1458
- cap.setAttribute('style', 'max-width:240px;text-align:center;line-height:1.6')
1459
- cap.textContent = label
1460
- wrap.appendChild(cap)
1461
- return wrap
1462
- }
1463
- row1.appendChild(cell(false, false, 1, '展开态(真实大小):球心里是 8px 圆点'))
1464
- row1.appendChild(cell(true, false, 1, '收球态(真实大小):8px 圆点长成星芒 16.8px'))
1465
- row1.appendChild(cell(true, true, 4, '圆点 8px(4×)—— 32 边形,半径恒 4'))
1466
- row1.appendChild(cell(true, false, 4, '星芒 16.8px(4×)—— 与「✦ 解读」那颗星逐点同形'))
1467
- stage.appendChild(row1)
1468
-
1469
- // 下行:形变逐帧(直接用 hook.pillStarAt(p) 画真实轨迹)——
1470
- // 这一条就是"中间帧形状不对"的可视回归图:曾经用 CSS 的 d 过渡做线性插值,
1471
- // 25%~38% 那几帧会是一个正菱形(谷跟着尖一起动),在这里一眼就能看出来。
1472
- var row2 = document.createElement('div')
1473
- row2.setAttribute('style', 'display:flex;gap:9px;align-items:flex-end')
1474
- for (var fi = 0; fi <= 8; fi += 1) {
1475
- var fp = fi / 8
1476
- var fcell = document.createElement('div')
1477
- fcell.setAttribute('style', 'display:flex;flex-direction:column;align-items:center;gap:5px')
1478
- var fclone = src.cloneNode(true)
1479
- fclone.style.position = 'static'
1480
- fclone.style.right = 'auto'
1481
- fclone.style.bottom = 'auto'
1482
- fclone.style.transition = 'none'
1483
- fclone.setAttribute('data-ball', '1')
1484
- fclone.setAttribute('data-ready', '1')
1485
- var fstar = fclone.querySelector('.dsh-sel-pillstar')
1486
- if (fstar) {
1487
- fstar.setAttribute('d', window.__dshSelectionExplain.pillStarAt(fp))
1488
- fstar.style.transform = 'rotate(' + Math.round(8 * (1 - fp) * 100) / 100 + 'deg)'
1489
- }
1490
- fcell.appendChild(fclone)
1491
- var fcap = document.createElement('div')
1492
- fcap.setAttribute('style', 'font-size:10px;color:#8c959f')
1493
- fcap.textContent = Math.round(fp * 100) + '%'
1494
- fcell.appendChild(fcap)
1495
- row2.appendChild(fcell)
1496
- }
1497
- stage.appendChild(row2)
1498
- document.body.appendChild(stage)
1499
- await sleep(400)
1500
- }
1501
-
1502
- // ⑮ 模型菜单:跟随态也要给"当前在用"那个打勾(真浏览器里量几何 —— 桩里量不出换行/溢出)
1503
- {
1504
- hook.open('模型菜单勾选', '', '菜单冒烟')
1505
- await waitFor(function () { return hook.state().phase === 'done' }, 8000)
1506
- await hook.loadModels(true)
1507
- var modelNodes = hook.modelNodes()
1508
- // DSH 按属性判定弹层,不检查 CSS 可见性(Issue #1)。
1509
- var modalSelector = '[role="dialog"][aria-modal="true"], [role="menu"]'
1510
- function modelMenuOwnsShortcuts() {
1511
- return Array.from(document.querySelectorAll(modalSelector)).includes(modelNodes.menu)
1512
- }
1513
- function checkModelMenuClosed(reason) {
1514
- check(reason + ':模型菜单不再占用 DSH 快捷键',
1515
- !modelMenuOwnsShortcuts() && modelNodes.menu.getAttribute('data-open') !== '1' &&
1516
- modelNodes.pill.getAttribute('aria-expanded') === 'false')
1517
- }
1518
- checkModelMenuClosed('初始收起')
1519
- // 官方 useModalLayer 在 document 冒泡阶段处理 Esc;插件 capture 必须让行。
1520
- function checkHostEscapeFirst(target, label) {
1521
- var hostSettings = document.createElement('div')
1522
- hostSettings.setAttribute('role', 'dialog')
1523
- hostSettings.setAttribute('aria-modal', 'true')
1524
- hostSettings.setAttribute('data-shortcut-modal', 'settings')
1525
- var hostInput = document.createElement('input')
1526
- hostSettings.appendChild(hostInput)
1527
- document.body.appendChild(hostSettings)
1528
- function dismissHost(event) {
1529
- if (event.key !== 'Escape' || event.defaultPrevented) return
1530
- event.preventDefault()
1531
- hostSettings.remove()
1532
- }
1533
- document.addEventListener('keydown', dismissHost)
1534
- try {
1535
- var event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
1536
- ;(target || hostInput).dispatchEvent(event)
1537
- check(label + ':Esc 到达官方浮层并只关闭官方设置',
1538
- event.defaultPrevented && !hostSettings.isConnected && panel.style.display === 'flex')
1539
- } finally {
1540
- document.removeEventListener('keydown', dismissHost)
1541
- hostSettings.remove()
1542
- }
1543
- }
1544
- var escapeModeFrame = document.createElement('div')
1545
- escapeModeFrame.setAttribute('data-dsh-full-view', '')
1546
- escapeModeFrame.setAttribute('data-dsh-active-floating-window', 'explanation')
1547
- for (var fullViewMode of [false, true]) {
1548
- if (fullViewMode) document.body.appendChild(escapeModeFrame)
1549
- var modeLabel = fullViewMode ? '完整视图' : '普通分栏'
1550
- checkHostEscapeFirst(null, modeLabel + '/焦点在官方设置')
1551
- checkHostEscapeFirst(document.querySelector('.dsh-sel-askbox'), modeLabel + '/焦点仍在解读输入框')
1552
- }
1553
- escapeModeFrame.remove()
1554
- hook.openModel()
1555
- await sleep(120)
1556
- check('模型菜单展开时占用 DSH 快捷键',
1557
- modelMenuOwnsShortcuts() && modelNodes.pill.getAttribute('aria-expanded') === 'true')
1558
- var menu = hook.modelNodes().menu
1559
- var rows = Array.prototype.slice.call(menu.querySelectorAll('.dsh-sel-pickerrow'))
1560
- check('模型菜单列出 3 个模型(含同名 id 的另一 provider)', rows.length === 3, String(rows.length))
1561
- var onRows = rows.filter(function (r) { return r.getAttribute('data-on') === '1' })
1562
- check('跟随态:只有当前在用的那一行打勾',
1563
- onRows.length === 1 && onRows[0].textContent.indexOf('DeepSeek V4.1 Flash') >= 0,
1564
- onRows.map(function (r) { return r.textContent }).join(' | ') || '没有任何一行打勾')
1565
- var badge = onRows[0] && onRows[0].querySelector('.dsh-sel-pickerfollow')
1566
- check('跟随态那一行有「跟随」小标,且小标在菜单里可见(没被挤出边界)',
1567
- !!badge && badge.offsetWidth > 0 && badge.getBoundingClientRect().right <= menu.getBoundingClientRect().right + 1,
1568
- badge ? Math.round(badge.getBoundingClientRect().left) + '→' + Math.round(badge.getBoundingClientRect().right) + '(菜单右边界 ' + Math.round(menu.getBoundingClientRect().right) + ')' : '没有小标')
1569
- var nameSpan = onRows[0] && onRows[0].querySelector('.dsh-sel-pickername')
1570
- check('名字带省略号、不撑破一行(真实布局下 scrollWidth 不超)',
1571
- !!nameSpan && nameSpan.scrollWidth >= nameSpan.clientWidth && onRows[0].scrollWidth <= onRows[0].clientWidth + 1,
1572
- nameSpan ? nameSpan.clientWidth + ' 可见 / ' + nameSpan.scrollWidth + ' 全长;行 ' + onRows[0].clientWidth + '/' + onRows[0].scrollWidth : '没有名字节点')
1573
- // 长名字那行(没打勾、没小标)也不该被撑破
1574
- var longRow = rows.filter(function (r) { return r.textContent.indexOf('Strong Model With A Very Long') >= 0 })[0]
1575
- check('超长名字那一行同样不横向溢出(靠省略号收窄)',
1576
- !!longRow && longRow.scrollWidth <= longRow.clientWidth + 1,
1577
- longRow ? longRow.clientWidth + '/' + longRow.scrollWidth : '没找到那一行')
1578
- var rowBox = onRows[0] ? onRows[0].getBoundingClientRect() : null
1579
- check('打勾那一行仍是单行(高度不超过 30px,没折成两行)',
1580
- !!rowBox && rowBox.height <= 30, rowBox ? Math.round(rowBox.height) + 'px' : '没找到那一行')
1581
-
1582
- checkHostEscapeFirst(modelNodes.menu, '模型菜单仍展开')
1583
- check('官方设置关闭时保留插件模型菜单', modelNodes.menu.getAttribute('data-open') === '1')
1584
-
1585
- clickOn(modelNodes.pill)
1586
- checkModelMenuClosed('再次点击胶囊')
1587
- hook.openModel()
1588
- modelNodes.menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
1589
- checkModelMenuClosed('按 Esc')
1590
- hook.openModel()
1591
- document.getElementById('docp').dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
1592
- checkModelMenuClosed('点击菜单外部')
1593
- hook.openModel()
1594
- clickOn(modelNodes.menu.querySelector('.dsh-sel-pickerrow'))
1595
- checkModelMenuClosed('选择模型')
1596
- hook.openModel()
1597
- hook.close() // 关窗会顺手收起模型菜单
1598
- checkModelMenuClosed('关闭解读面板')
1599
- await sleep(30)
1600
- }
1601
-
1602
-
1603
- // 内容把面板撑出视口 → 必须自动收回(回复框不能被顶到屏幕外)
1604
- //
1605
- // 真实场景:开窗那一刻"放得下吗"是按当时的高度算的(常常只有一行翻译在流),
1606
- // 之后正文长到 max-height,面板从固定的 top 往下挂 —— 它是 position:fixed、页面不滚,
1607
- // 顶出去那截(回复框)就点不到了。
1608
- //
1609
- // ⚠️ 复核挂在**绘制之后**(内容这时才在 DOM 里)。所以这里灌完内容要再走一次真实绘制,
1610
- // 不能用 askValue 那类只改状态、不重绘的钩子(踩过:断言量到的一直是注入前的旧位置)。
1611
- {
1612
- hook.open('溢出探测', '上下文片段', '冒烟')
1613
- await sleep(140)
1614
- var panelEl3 = document.querySelector('.dsh-sel-panel')
1615
- var bodyEl3 = panelEl3.querySelector('.dsh-sel-body')
1616
- var filler = document.createElement('div')
1617
- filler.style.height = Math.round(window.innerHeight * 0.9) + 'px'
1618
- filler.style.flex = '0 0 auto'
1619
- bodyEl3.appendChild(filler)
1620
- // 再开一次 = 走一遍真实的 showPanel + paint(内容已经在 DOM 里,复核这帧就能看见)
1621
- hook.open('溢出探测', '上下文片段', '冒烟')
1622
- await sleep(180)
1623
- var overRect = panelEl3.getBoundingClientRect()
1624
- check('内容把面板撑出视口时自动收回可视区(回复框不会被顶到屏幕外)',
1625
- overRect.bottom <= window.innerHeight - 2,
1626
- 'bottom=' + Math.round(overRect.bottom) + ' vh=' + window.innerHeight)
1627
- filler.remove()
1628
- hook.open('溢出探测', '上下文片段', '冒烟')
1629
- await sleep(80)
1630
- hook.close()
1631
- await sleep(40)
1632
- }
1633
-
1634
- // ⑫ 卡片 → 小窗的交接(方案二「缩放长出」):真浏览器里量真矩形 + 真过渡。
1635
- // 桩 DOM 里只能验"写进去的 transform 字符串",这里补上"浏览器真的从那一格长出来"。
1636
- {
1637
- hook.close()
1638
- await sleep(60)
1639
- var docp4 = document.getElementById('docp')
1640
- selectIn(docp4, 'the migration ran long')
1641
- mouseUpOn(docp4)
1642
- // 让 host 报"豆包在录":插件轮询到就把卡片弹出来(这是真实的触发路径)
1643
- micState = { ok: true, available: true, capturing: true, processes: [{ pid: 513, bundleId: 'com.bytedance.inputmethod.doubaoime', name: 'DoubaoIme' }], reason: '' }
1644
- var cardEl = await waitFor(function () {
1645
- var el = document.querySelector('.dsh-sel-vcard')
1646
- return el && getComputedStyle(el).display !== 'none' && el.getBoundingClientRect().width > 0 ? el : null
1647
- }, 2500)
1648
- check('(准备)输入法开麦 → 语音卡自己弹出来', !!cardEl, cardEl ? '' : '没等到卡片')
1649
- if (cardEl) {
1650
- var vbox = cardEl.querySelector('.dsh-sel-vcard-box')
1651
- var vsend = cardEl.querySelector('.dsh-sel-vcard-send')
1652
- vbox.value = '这是 在推迟 还是提前'
1653
- vbox.dispatchEvent(new Event('input', { bubbles: true }))
1654
- await sleep(60)
1655
- var cardRect = cardEl.getBoundingClientRect()
1656
- var panelEl4 = document.querySelector('.dsh-sel-panel')
1657
- // ⚠️ 不能用"点完下一帧读计算值"来抓起点:这个冒烟页跑在 --virtual-time-budget 下,
1658
- // 虚拟时钟会往前跳,rAF 回来时过渡可能已经跑完了(实测读到 matrix=none)。
1659
- // 改成**记录 style 属性的每一次写入**(MutationObserver 的 attributeOldValue 给的是
1660
- // 写之前的值)—— 于是"起点把面板压到卡片那一格"这件事与时钟无关,直接可读可验。
1661
- var styleWrites = []
1662
- var mo = new MutationObserver(function (records) {
1663
- for (var i = 0; i < records.length; i += 1) styleWrites.push(records[i].oldValue || '')
1664
- })
1665
- mo.observe(panelEl4, { attributes: true, attributeFilter: ['style'], attributeOldValue: true })
1666
- clickOn(vsend)
1667
- // 期望值必须在**同一个任务里**读完:插件也是在这次点击里量的几何
1668
- // (openPanelWith 内层跑完、内容已换新 → 量高度;随后流式回答才会把面板撑高)。
1669
- // 面板的 offset* 是布局值,不受正在跑的 transform 影响。
1670
- var expW = panelEl4.offsetWidth
1671
- var expH = panelEl4.offsetHeight
1672
- var expLeft = panelEl4.offsetLeft
1673
- var expTop = panelEl4.offsetTop
1674
- await sleep(150)
1675
- mo.disconnect()
1676
- var startStyle = null
1677
- for (var wi = 0; wi < styleWrites.length; wi += 1) {
1678
- if (styleWrites[wi].indexOf('transform: translate(') >= 0) startStyle = styleWrites[wi]
1679
- }
1680
- // ⚠️ 页面脚本是**模板字符串**里的:正则里的 \d / \( 会被模板字面量吃掉反斜杠
1681
- // (写成 [\d.] 到了页面里变成 [d.],永远匹配不上 —— 实测就是这么红的)。
1682
- // 所以这里只用不带反斜杠的字符类 [0-9.] 取数,并且从 transform 那一段开始取,
1683
- // 免得把 z-index / left / top 上的数字也吃进来。
1684
- var seg = startStyle ? startStyle.slice(startStyle.indexOf('transform: translate(')) : ''
1685
- var nums = seg.match(/-?[0-9.]+/g) || []
1686
- var m = nums.length >= 4 ? [seg.slice(0, 60), nums[0], nums[1], nums[2], nums[3]] : null
1687
- // 期望值:面板的**布局**值(offset*,不受动画影响)与卡片矩形的比值/差值
1688
- var sx = cardRect.width / expW
1689
- var sy = cardRect.height / expH
1690
- var dx = cardRect.left - expLeft
1691
- var dy = cardRect.top - expTop
1692
- check('交接起点:面板被压到卡片那一格(缩放 + 位移,量的是真矩形)',
1693
- !!m && Math.abs(Number(m[3]) - sx) < 0.01 && Math.abs(Number(m[4]) - sy) < 0.01 &&
1694
- Math.abs(Number(m[1]) - dx) < 1 && Math.abs(Number(m[2]) - dy) < 1,
1695
- m ? '起点=' + m[0] + ' 期望≈translate(' + dx.toFixed(1) + 'px,' + dy.toFixed(1) + 'px) scale(' + sx.toFixed(4) + ',' + sy.toFixed(4) + ')'
1696
- : '没观察到起点写入;style 写入次数=' + styleWrites.length)
1697
- check('交接期间 transform-origin 在左上角(否则缩放会把面板甩出去)',
1698
- !!startStyle && startStyle.indexOf('transform-origin: 0px 0px') >= 0,
1699
- startStyle ? startStyle.slice(0, 120) : '(没抓到)')
1700
- var sawTransition = styleWrites.some(function (v) { return v.indexOf('transition: transform 260ms cubic-bezier(0.2, 0.8, 0.2, 1)') >= 0 })
1701
- check('交接只动 transform、260ms 同一族曲线(不动 width/left/top)',
1702
- sawTransition || String(panelEl4.style.transition).indexOf('transform 260ms') === 0,
1703
- '写入=' + JSON.stringify(styleWrites.map(function (v) { return (v.match(/transition:[^;]*/) || [''])[0] }).filter(Boolean).slice(0, 2)))
1704
- await sleep(520)
1705
- var settledMatrix = getComputedStyle(panelEl4).transform
1706
- check('交接结束:回到恒等(无 transform)',
1707
- settledMatrix === 'none' || settledMatrix === 'matrix(1, 0, 0, 1, 0, 0)', String(settledMatrix))
1708
- check('交接收尾:内联 transform / transition / transformOrigin 清干净',
1709
- !panelEl4.style.transform && !panelEl4.style.transition && !panelEl4.style.transformOrigin,
1710
- JSON.stringify({ tf: panelEl4.style.transform, tr: panelEl4.style.transition, og: panelEl4.style.transformOrigin }))
1711
- // 收尾:麦克风"松开",别影响后面的用例
1712
- micState = { ok: true, available: true, capturing: false, processes: [], reason: '' }
1713
- await sleep(80)
1714
- hook.close()
1715
- await sleep(40)
1716
- }
1717
- }
1718
-
1719
- // ⑬ 小窗开着时开麦 = 点一下「❝ 引用」(2026-10-09 定的行为)。
1720
- // 真浏览器里验的是**接线**:引用浮标亮着才轮到轮询、开麦后引用真的进了引用区、
1721
- // 焦点真的落到小窗输入框(IME 上屏要往那儿落)、而且**不弹语音卡**。
1722
- {
1723
- var recording = { ok: true, available: true, capturing: true, processes: [{ pid: 513, bundleId: 'com.bytedance.inputmethod.doubaoime', name: 'DoubaoIme' }], reason: '' }
1724
- var notRecording = { ok: true, available: true, capturing: false, processes: [], reason: '' }
1725
- micState = notRecording
1726
- hook.close()
1727
- await sleep(60)
1728
- hook.open('引用开麦用例', '开麦引用的上下文片段', '引用开麦')
1729
- await waitFor(function () { return hook.state().phase === 'done' }, 3000)
1730
- var quoteBtn = document.querySelector('.dsh-sel-quotebtn')
1731
- var quotesBox = document.querySelector('.dsh-sel-quotes')
1732
- var vcardEl = document.querySelector('.dsh-sel-vcard')
1733
- var askEl = document.querySelector('.dsh-sel-askbox')
1734
-
1735
- // ① 没有引用浮标时不轮询(用户定的:探针只跟着浮标活)
1736
- var hitsIdle = window.__micHits || 0
1737
- await sleep(500)
1738
- check('没有引用浮标时不轮询麦克风', (window.__micHits || 0) === hitsIdle, hitsIdle + ' → ' + (window.__micHits || 0))
1739
-
1740
- // ② 主界面划词 → 引用浮标亮 → 开始问
1741
- var docp5 = document.getElementById('docp')
1742
- selectIn(docp5, 'the migration ran long')
1743
- mouseUpOn(docp5)
1744
- var lit = await waitFor(function () {
1745
- return quoteBtn && quoteBtn.style.display !== 'none' && getComputedStyle(quoteBtn).display !== 'none'
1746
- }, 1500)
1747
- check('(前提)引用浮标亮着', lit === true, quoteBtn ? getComputedStyle(quoteBtn).display : '未找到浮标')
1748
- var hitsFloat = window.__micHits || 0
1749
- await sleep(500)
1750
- check('引用浮标亮着时才问麦克风', (window.__micHits || 0) > hitsFloat, hitsFloat + ' → ' + (window.__micHits || 0))
1751
-
1752
- // ③ 开麦 → 引用挂进来 + 焦点进小窗输入框 + 不弹语音卡
1753
- var quotesBefore = (hook.quotes() || []).length
1754
- micState = recording
1755
- var quoted = await waitFor(function () { return (hook.quotes() || []).length > quotesBefore }, 2000)
1756
- var got = hook.quotes() || []
1757
- check('开麦 → 选中的那段自动挂进引用', quoted === true && /migration ran long/.test(got[got.length - 1].text), JSON.stringify(got.map(function (q) { return q.text })))
1758
- check('开麦 → 光标进了小窗输入框(输入法上屏的字落在这儿)',
1759
- document.activeElement === askEl, document.activeElement ? (document.activeElement.className || document.activeElement.tagName) : 'null')
1760
- check('开麦 → **不**弹语音卡(面板开着时字直接落进小窗)',
1761
- !vcardEl || getComputedStyle(vcardEl).display === 'none', vcardEl ? getComputedStyle(vcardEl).display : '没有卡片元素')
1762
- await sleep(120)
1763
- check('开麦 → 引用浮标自己收了', quoteBtn.style.display === 'none' || getComputedStyle(quoteBtn).display === 'none', quoteBtn.style.display)
1764
- check('开麦 → 引用区真的画出来了', quotesBox && quotesBox.getAttribute('data-show') === '1', quotesBox ? quotesBox.getAttribute('data-show') : '未找到引用区')
1765
-
1766
- // ④ 别处正在打字 → 不加引用、不抢焦点(守卫,与语音卡那条完全一致)
1767
- micState = notRecording
1768
- await sleep(400)
1769
- var otherEl = document.getElementById('other')
1770
- otherEl.focus()
1771
- var docp6 = document.getElementById('docp')
1772
- selectIn(docp6, 'the migration ran long')
1773
- mouseUpOn(docp6)
1774
- await waitFor(function () { return quoteBtn && quoteBtn.style.display !== 'none' }, 1200)
1775
- var quotesGuard = (hook.quotes() || []).length
1776
- micState = recording
1777
- await sleep(600)
1778
- check('别处正在打字:不加引用', (hook.quotes() || []).length === quotesGuard, JSON.stringify((hook.quotes() || []).map(function (q) { return q.text })))
1779
- check('别处正在打字:不抢焦点(人家的听写不能被拽进小窗)',
1780
- document.activeElement === otherEl, document.activeElement ? (document.activeElement.id || document.activeElement.className || document.activeElement.tagName) : 'null')
1781
-
1782
- // 收尾
1783
- micState = notRecording
1784
- if (otherEl) otherEl.blur()
1785
- hook.close()
1786
- await sleep(60)
1787
- }
1788
-
1789
- finish()
1790
- }
1791
-
1792
- main().catch(function (error) {
1793
- check('冒烟脚本自身没抛错', false, (error && error.message) || String(error))
1794
-
1795
- finish()
1796
- })
1797
- </script>
1798
- </body></html>`
1799
-
1800
- const server = createServer((req, res) => {
1801
- const url = (req.url || '/').split('?')[0]
1802
- if (url === '/client.js') {
1803
- res.writeHead(200, { 'content-type': 'application/javascript; charset=utf-8' })
1804
- res.end(readFileSync(BUNDLE))
1805
- return
1806
- }
1807
- if (url === '/' || url === '/smoke.html') {
1808
- res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
1809
- res.end(PAGE)
1810
- return
1811
- }
1812
- res.writeHead(404, { 'content-type': 'text/plain' })
1813
- res.end('not found')
1814
- })
1815
-
1816
- await new Promise((done) => server.listen(0, '127.0.0.1', done))
1817
- const port = server.address().port
1818
-
1819
- function parseDump(dump) {
1820
- const matched = /<pre id="out">([\s\S]*?)<\/pre>/.exec(dump || '')
1821
- if (!matched) return null
1822
- const text = matched[1]
1823
- .replace(/&quot;/g, '"')
1824
- .replace(/&lt;/g, '<')
1825
- .replace(/&gt;/g, '>')
1826
- .replace(/&amp;/g, '&')
1827
- try {
1828
- return JSON.parse(text)
1829
- } catch (error) {
1830
- return null
1831
- }
1832
- }
1833
-
1834
- let results = null
1835
- let dump = ''
1836
- try {
1837
- for (let attempt = 1; attempt <= 2 && !results; attempt += 1) {
1838
- const out = await new Promise((done) => {
1839
- execFile(
1840
- chrome,
1841
- [
1842
- '--headless',
1843
- '--disable-gpu',
1844
- '--no-first-run',
1845
- '--no-default-browser-check',
1846
- '--window-size=1200,900',
1847
- '--virtual-time-budget=25000',
1848
- '--dump-dom',
1849
- `http://127.0.0.1:${port}/smoke.html`,
1850
- ],
1851
- { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, timeout: 90000 },
1852
- (error, stdout) => done(stdout || ''),
1853
- )
1854
- })
1855
- dump = out
1856
- results = parseDump(dump)
1857
- }
1858
-
1859
- // 截图(可选,失败不影响判定):把两个终态并排拍一张 —— 换图标时人工核对用。
1860
- // 走同一个页面,加 ?shot=1 让页面在跑完检查后把"展开态 / 收球态"克隆出来并排摆好。
1861
- try {
1862
- const shotDir = resolve(ROOT, 'docs')
1863
- if (!existsSync(shotDir)) mkdirSync(shotDir, { recursive: true })
1864
- const shotPath = resolve(shotDir, 'pill-star-states.png')
1865
- await new Promise((done) => {
1866
- execFile(
1867
- chrome,
1868
- [
1869
- '--headless',
1870
- '--disable-gpu',
1871
- '--no-first-run',
1872
- '--no-default-browser-check',
1873
- '--window-size=1180,560',
1874
- '--virtual-time-budget=20000',
1875
- `--screenshot=${shotPath}`,
1876
- `http://127.0.0.1:${port}/smoke.html?shot=1`,
1877
- ],
1878
- { encoding: 'utf8', timeout: 90000 },
1879
- () => done(),
1880
- )
1881
- })
1882
- console.log(`=== 截图(圆点 / 星芒 两个终态):${shotPath} ===`)
1883
- } catch (error) {
1884
- console.log('(截图跳过:' + ((error && error.message) || String(error)) + ')')
1885
- }
1886
- } finally {
1887
- if (typeof server.closeAllConnections === 'function') server.closeAllConnections()
1888
- server.close()
1889
- }
1890
-
1891
- let failed = 0
1892
- if (!results) {
1893
- console.log('FAIL 浏览器里没拿到结果(<pre id="out">)')
1894
- console.log(dump.slice(0, 400))
1895
- failed += 1
1896
- } else {
1897
- for (const item of results) {
1898
- console.log(`${item.ok ? 'PASS' : 'FAIL'} ${item.name}${item.extra ? ' — ' + item.extra : ''}`)
1899
- if (!item.ok) failed += 1
1900
- }
1901
- }
1902
- console.log(`\n=== 小窗面板冒烟结束:${results ? results.length - failed : 0} 通过 / ${failed} 失败 ===`)
1903
- process.exit(failed === 0 ? 0 : 1)