@yfwu2020/dsh-selection-explain 0.4.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,7 +13,7 @@
13
13
  * 退出码:全部 PASS = 0;找不到 Chrome 打印 SKIP 后退出 0(与 smoke-bridge 一致)。
14
14
  */
15
15
  import { createServer } from 'node:http'
16
- import { readFileSync, existsSync } from 'node:fs'
16
+ import { readFileSync, existsSync, mkdirSync } from 'node:fs'
17
17
  import { dirname, resolve } from 'node:path'
18
18
  import { fileURLToPath } from 'node:url'
19
19
  import { execFile } from 'node:child_process'
@@ -91,6 +91,7 @@ function sse(events) {
91
91
  var body = events.map(function (e) { return 'data: ' + JSON.stringify(e) + '\\n\\n' }).join('')
92
92
  return new Response(body, { headers: { 'content-type': 'text/event-stream' } })
93
93
  }
94
+ var pillPosStore = null // 内存版 host 存储(/api/pill)
94
95
  window.fetch = function (input, init) {
95
96
  var url = String(input)
96
97
  var body = {}
@@ -104,6 +105,19 @@ window.fetch = function (input, init) {
104
105
  if (url.indexOf('/selection-explain/api/history') >= 0) {
105
106
  return Promise.resolve(new Response(JSON.stringify({ ok: true, entry: null, entries: [] }), { headers: { 'content-type': 'application/json' } }))
106
107
  }
108
+ if (url.indexOf('/selection-explain/api/pill') >= 0) {
109
+ var isGet = !init || !init.method || String(init.method).toUpperCase() === 'GET'
110
+ if (isGet) return Promise.resolve(new Response(JSON.stringify({ ok: true, position: pillPosStore }), { headers: { 'content-type': 'application/json' } }))
111
+ if (body.clear === true) {
112
+ pillPosStore = null
113
+ return Promise.resolve(new Response(JSON.stringify({ ok: true, position: null }), { headers: { 'content-type': 'application/json' } }))
114
+ }
115
+ if (typeof body.right === 'number' && typeof body.y === 'number') {
116
+ pillPosStore = { right: body.right, y: body.y }
117
+ return Promise.resolve(new Response(JSON.stringify({ ok: true, position: pillPosStore }), { headers: { 'content-type': 'application/json' } }))
118
+ }
119
+ return Promise.resolve(new Response(JSON.stringify({ ok: false, error: 'position 需要 { right, y }' }), { status: 400, headers: { 'content-type': 'application/json' } }))
120
+ }
107
121
  if (url.indexOf('/selection-explain/api/models') >= 0) {
108
122
  return Promise.resolve(new Response(JSON.stringify({ ok: true, current: null, stages: null, models: [] }), { headers: { 'content-type': 'application/json' } }))
109
123
  }
@@ -161,6 +175,46 @@ function mouseUpOn(node) {
161
175
  function clickOn(node) {
162
176
  node.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }))
163
177
  }
178
+ /**
179
+ * 像用户那样点一下:mousedown → mouseup → click。
180
+ * 只派发 click 是"程序化点击",浏览器不会走聚焦/选区那套默认行为 ——
181
+ * 而浮标靠 mousedown 的 preventDefault 保住选区,正是那条路要验。
182
+ */
183
+ function realClick(node) {
184
+ var opts = { bubbles: true, cancelable: true, view: window, detail: 1 }
185
+ node.dispatchEvent(new MouseEvent("mousedown", opts))
186
+ node.dispatchEvent(new MouseEvent("mouseup", opts))
187
+ node.dispatchEvent(new MouseEvent("click", opts))
188
+ }
189
+ /**
190
+ * 量几何之前先把过渡/动画冻住。
191
+ *
192
+ * 为什么必须冻:这个冒烟脚本跑在 --virtual-time-budget 下,定时器是虚拟时间(秒级飞快),
193
+ * 但 **CSS 过渡的动画时钟不跟着走** —— 胶囊的宽度/位置过渡(.32s)会在"中间某一帧"被量到,
194
+ * 于是"等了 420ms 还是量到起点值"。冻住之后量到的是终值,断言才是确定的;
195
+ * "到底有没有动画"另外用 getComputedStyle 断言(transition-property 里有没有 max-width)。
196
+ */
197
+ function freezeMotion() {
198
+ if (document.getElementById('freeze-motion')) return
199
+ var style = document.createElement('style')
200
+ style.id = 'freeze-motion'
201
+ style.textContent = '*{transition:none!important;animation:none!important}'
202
+ document.head.appendChild(style)
203
+ }
204
+ /** 按**顶层**逗号切分 CSS 列表值:cubic-bezier(0.4, 0, 0.6, 1) 里面也有逗号,直接 split 会切碎。 */
205
+ function splitCssList(value) {
206
+ var out = []
207
+ var depth = 0
208
+ var current = ''
209
+ for (var i = 0; i < String(value).length; i += 1) {
210
+ var ch = String(value)[i]
211
+ if (ch === '(') depth += 1
212
+ if (ch === ')') depth -= 1
213
+ if (ch === ',' && depth === 0) { out.push(current.trim()); current = '' } else { current += ch }
214
+ }
215
+ if (current.trim()) out.push(current.trim())
216
+ return out
217
+ }
164
218
  /** 浮标是不是**最上层**(z-index 低于面板时 elementFromPoint 会返回面板)。 */
165
219
  function onTop(node) {
166
220
  var rect = node.getBoundingClientRect()
@@ -345,6 +399,680 @@ async function main() {
345
399
  check('主界面引用标成会话里的', !!picked8 && picked8.session === true, picked8 && String(picked8.session))
346
400
  }
347
401
 
402
+ // ⑨ 点「✦ 解读」后立刻收掉本文档选区(用户报的:小窗亮起时旧选区又触发「❝ 引用」浮标)
403
+ {
404
+ hook.close()
405
+ await sleep(80)
406
+ var planBtn = document.querySelector('.dsh-sel-btn:not(.dsh-sel-quotebtn)')
407
+ var docp2 = document.getElementById('docp')
408
+ selectIn(docp2, 'the migration ran long')
409
+ mouseUpOn(docp2)
410
+ var planShown = await waitFor(function () {
411
+ return planBtn && planBtn.style.display !== 'none' && planBtn.getBoundingClientRect().width > 0
412
+ }, 1500)
413
+ check('主界面划词:浮出「✦ 解读」', planShown === true, planBtn ? planBtn.style.display : '未找到按钮')
414
+ if (planShown) {
415
+ realClick(planBtn)
416
+ // ⚠️ 这里**同步**检查,一下都不 await:开窗自动聚焦那一下是异步的(要等输入框可见),
417
+ // 所以"选区这会儿已经没了"只可能是我们自己在点的时候清的 —— 它骗不过这一条。
418
+ var live = window.getSelection()
419
+ check(
420
+ '点「解读」后选区立刻收掉(同步检查,不等聚焦那一下)',
421
+ !!live && (live.rangeCount === 0 || live.isCollapsed === true),
422
+ live ? 'rangeCount=' + live.rangeCount + ' collapsed=' + live.isCollapsed : 'null',
423
+ )
424
+ check('选区记录也一并作废(不再拿它当"当前选区")', hook.selection() === null, JSON.stringify(hook.selection()))
425
+ await sleep(120)
426
+ check('旧选区没有让「❝ 引用」浮标复活', !onTop(quoteBtn), String(quoteBtn.style.display))
427
+ // 用户报症状的那一步:小窗亮着时在输入框里按方向键(keyup 会触发一次选区检查)
428
+ var askBox = document.querySelector('.dsh-sel-askbox')
429
+ if (askBox) {
430
+ askBox.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowLeft', bubbles: true }))
431
+ await sleep(120)
432
+ check('开窗后按方向键也不再冒出引用浮标', !onTop(quoteBtn), String(quoteBtn.style.display))
433
+ }
434
+ hook.close()
435
+ await sleep(60)
436
+ }
437
+ }
438
+
439
+ // ⑩ 浮标图标的**墨迹**要在画布里居中(用户报的:"✦ 和 解读 好像没对齐,也没在按钮里居中")
440
+ // 为什么量 getBBox 而不是截图:浮动按钮是 align-items:center 的 flex,居中的是**画布盒子**,
441
+ // 图形在画布里偏一点,图标就整体偏一点 —— 老星形路径 y 占 1.2~12.4(中心 6.8,不是 8),
442
+ // 于是比右边「解读」两个字高 1.1px(8x 截图量出来的)。文字墨迹没法用 DOM 量,
443
+ // 但"图标墨迹居中"这一条就足以拦住这类回归。
444
+ {
445
+ var icons = [
446
+ ['解读浮标 ✦ 图标', document.querySelector('.dsh-sel-btn:not(.dsh-sel-quotebtn) svg')],
447
+ ['引用浮标 ❝ 图标', quoteBtn.querySelector('svg')],
448
+ ]
449
+ for (var k = 0; k < icons.length; k += 1) {
450
+ var svg = icons[k][1]
451
+ // 浮标这会儿多半是隐藏的(display:none)—— 隐藏元素的 getBBox() 一律返回 0,
452
+ // 所以量之前先临时摆出来,量完还原(不改它此刻的真实状态)。
453
+ var btn = svg ? svg.closest('.dsh-sel-btn') : null
454
+ var prev = btn ? btn.style.display : ''
455
+ if (btn) btn.style.display = 'inline-flex'
456
+ var box = svg ? svg.getBBox() : null
457
+ if (btn) btn.style.display = prev
458
+ var cx = box ? box.x + box.width / 2 : 0
459
+ var cy = box ? box.y + box.height / 2 : 0
460
+ check(
461
+ icons[k][0] + '墨迹在 16×16 画布里居中',
462
+ !!box && Math.abs(cx - 8) <= 0.25 && Math.abs(cy - 8) <= 0.25,
463
+ box ? 'cx=' + cx.toFixed(2) + ' cy=' + cy.toFixed(2) : '未找到 svg',
464
+ )
465
+ }
466
+ }
467
+
468
+ // ⑪ 胶囊宽度:**有**费用胶囊就跟随它,**没有**就收到 PILL_FALLBACK_MAX = 200px(用户定的规则)
469
+ // 这条只有真浏览器能量:要真布局(getBoundingClientRect)、真 CSS(max-width 生效),
470
+ // 还要能挂一个假的 dsh-spend 挂载点。
471
+ {
472
+ var pillNode = hook.pill()
473
+ var pillName = pillNode.querySelector('.dsh-sel-pillname')
474
+ var pillMeta = pillNode.querySelector('.dsh-sel-pillmeta')
475
+ var savedName = pillName.textContent
476
+ var savedMeta = pillMeta.textContent
477
+ // 先确认"动画是声明过的"(冻住之后就量不出来了):收球/吸附靠 max-width / padding / gap / 位置的过渡
478
+ var pillTrans = getComputedStyle(pillNode).transitionProperty
479
+ check('胶囊声明了过渡(收球/吸附是动画,不是跳变)', /max-width/.test(pillTrans) && /padding/.test(pillTrans) && /left/.test(pillTrans), pillTrans)
480
+ check('过渡只在"第一帧已经贴好位置"之后才打开(data-ready)', hook.pillState().ready === true, String(hook.pillState().ready))
481
+
482
+ // 收 / 放**严格互逆**的结构前提:所有会变的属性共用同一条时长 + 同一条曲线,
483
+ // 而且那条曲线必须时间对称(x1+x2=1 且 y1+y2=1 → 把动画倒过来放就是另一个方向)。
484
+ // 之前用 easeOutQuint(.22,1,.36,1):.22+.36≠1,两个方向前后半程不镜像 —— 用户报的"不对称"就是它。
485
+ var durList = splitCssList(getComputedStyle(pillNode).transitionDuration)
486
+ var easeList = splitCssList(getComputedStyle(pillNode).transitionTimingFunction)
487
+ var morphDur = durList.slice(1) // 第 0 条是 box-shadow 的 .15s,不属于形变
488
+ var morphEase = easeList.slice(1)
489
+ check('收/放所有属性共用一条时长', morphDur.length > 0 && morphDur.every(function (x) { return x === morphDur[0] }), durList.join(' | '))
490
+ check('收/放所有属性共用一条曲线', morphEase.length > 0 && morphEase.every(function (x) { return x === morphEase[0] }), easeList.join(' | '))
491
+ // 不用正则:这段代码在模板字符串里,\( 这种转义会被吃掉(踩过)
492
+ var easeText = morphEase[0] || ''
493
+ var easeAt = easeText.indexOf('cubic-bezier(')
494
+ var easeArgs = easeAt >= 0
495
+ ? easeText.slice(easeAt + 'cubic-bezier('.length, -1).split(',').map(function (x) { return Number(x.trim()) })
496
+ : []
497
+ check(
498
+ '曲线时间对称(x1+x2=1 且 y1+y2=1 → 倒放即另一个方向)',
499
+ easeArgs.length === 4 && Math.abs(easeArgs[0] + easeArgs[2] - 1) < 1e-6 && Math.abs(easeArgs[1] + easeArgs[3] - 1) < 1e-6,
500
+ easeText + ' → ' + JSON.stringify(easeArgs),
501
+ )
502
+ var nameDur = splitCssList(getComputedStyle(pillName).transitionDuration)[0]
503
+ var starEl = pillNode.querySelector('.dsh-sel-pillstar')
504
+ var starTrans = getComputedStyle(starEl)
505
+ check('文字那格的时长和胶囊一致(不一致 → 中间帧宽度由另一条曲线决定)', nameDur === morphDur[0], nameDur + ' vs ' + morphDur[0])
506
+
507
+ // ── 球心图形的形变轨迹(这一组是重点:光看两端点会漏掉中间帧的形状) ──
508
+ // 曾经踩过的坑:用 CSS 的 transition:d 在两条静态 path 之间插值 —— CSS 只能线性插值,
509
+ // 于是中途会经过一个正菱形(谷跟着尖一起动),读起来像方块;而且尖在 160ms 就停住、
510
+ // 胶囊还要走到 280ms,"配合"就散了。所以现在路径由 JS 按 p 现算(两段 smoothstep)。
511
+ check('球心图形不走 CSS 的 d 过渡(线性插值会经过正菱形)', starTrans.transitionProperty.trim() !== 'd', starTrans.transitionProperty.trim() || '(无过渡)')
512
+ var radiiAt = function (p) {
513
+ // 不写正则:模板字符串里反斜杠会被吃掉。按 M/L 切段再取两个数。
514
+ var parts = String(hook.pillStarAt(p)).split('L')
515
+ parts[0] = parts[0].charAt(0) === 'M' ? parts[0].slice(1) : parts[0]
516
+ return parts.map(function (seg) {
517
+ var z = seg.indexOf('Z')
518
+ if (z >= 0) seg = seg.slice(0, z)
519
+ var xy = seg.split(' ')
520
+ return Math.sqrt(parseFloat(xy[0]) * parseFloat(xy[0]) + parseFloat(xy[1]) * parseFloat(xy[1]))
521
+ })
522
+ }
523
+ var r0 = radiiAt(0)
524
+ var r25 = radiiAt(0.25)
525
+ var r50 = radiiAt(0.5)
526
+ var r80 = radiiAt(0.8)
527
+ var r100 = radiiAt(1)
528
+ 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))
529
+ check('p=0.25:谷仍停在 4.00("两段"的签名 —— 线性插值会把它拉到 3.80,那就是菱形相)', Math.abs(r25[4] - 4) < 0.02, '谷 ' + r25[4].toFixed(3))
530
+ check('p=0.25:尖已经在长(> 4 但远未到顶,尖先长)', r25[0] > 4.3 && r25[0] < r100[0] - 0.5, '尖 ' + r25[0].toFixed(3) + '(顶 ' + r100[0].toFixed(2) + ')')
531
+ 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))
532
+ // 同一个 p 下,从尖到谷的半径必须单调递减(尖 > 11.25° > 22.5° > 33.75° > 谷),
533
+ // 且左右对称 —— 这就是"不出现波浪边"的可测形式。
534
+ var sectorOk = function (r) {
535
+ var k
536
+ for (k = 0; k < 4; k += 1) if (r[k] < r[k + 1] - 0.02) return false
537
+ for (k = 1; k < 16; k += 1) if (Math.abs(r[k] - r[32 - k]) > 0.02) return false
538
+ return true
539
+ }
540
+ check('同一进度下:尖→谷单调递减且左右对称(没有波浪边)', sectorOk(r25) && sectorOk(r50) && sectorOk(radiiAt(0.8)), JSON.stringify(r50.map(function (r) { return Number(r.toFixed(2)) }).slice(0, 5)))
541
+ // 形状空间的过冲:尖在 p≈0.8 处冲过终点一点,到 p=1 精确回到终点
542
+ check('尖有轻微过冲(p=0.8 处高出终点一点,再收回)', r80[0] > r100[0] + 0.05, 'p=.8 尖 ' + r80[0].toFixed(3) + ' / p=1 尖 ' + r100[0].toFixed(3))
543
+ check('p=1 的路径与客户端常量逐字相同(和「✦ 解读」那颗星同一条几何)', hook.pillStarAt(1) === hook.pillStarD(), hook.pillStarAt(1) === hook.pillStarD() ? '逐字一致' : '不一致')
544
+ check('p=0 的路径与客户端常量逐字相同(展开态就是那枚 8px 圆点)', hook.pillStarAt(0) === hook.pillStarDotD(), hook.pillStarAt(0) === hook.pillStarDotD() ? '逐字一致' : '不一致')
545
+ // 曲线:时间对称(倒放即另一个方向)+ 轻微蓄势/过冲(不直来直去)
546
+ var xs = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]
547
+ var ys = xs.map(function (x) { return hook.pillStarEaseAt(x) })
548
+ var symOk = xs.every(function (x) { return Math.abs(hook.pillStarEaseAt(x) + hook.pillStarEaseAt(1 - x) - 1) < 1e-6 })
549
+ check('球心图形的曲线时间对称(收/放互为倒放)', symOk, ys.map(function (y) { return y.toFixed(3) }).join(' '))
550
+ // 曲线本身要"干净"(单调 S,不能是波浪):过冲放在形状空间,不放在时间轴上
551
+ var monotone = true
552
+ for (var mi = 1; mi < xs.length; mi += 1) if (hook.pillStarEaseAt(xs[mi]) <= hook.pillStarEaseAt(xs[mi - 1])) monotone = false
553
+ check('时间轴上的曲线是干净单调的 S(过冲不放在这里 —— 对称曲线的过冲必成波浪)', monotone && ys[0] > 0 && ys[ys.length - 1] < 1, ys.map(function (y) { return y.toFixed(3) }).join(' '))
554
+ check('JS 那条时长与 CSS 那条同拍(280ms ↔ .28s)', hook.pillMorphMs() === 280 && morphDur[0] === '0.28s', hook.pillMorphMs() + 'ms vs ' + morphDur[0])
555
+ freezeMotion()
556
+ // 最坏内容(选中文字 12 字 + 最长状态):自然宽度 301px,最容易被上限拦到
557
+ pillName.textContent = '迁移作业耗时超出了原计划…'
558
+ pillMeta.textContent = '· 追问中 12.3s'
559
+
560
+ // ① 没有费用胶囊 → 兜底上限 200
561
+ hook.place()
562
+ check('没有费用胶囊:上限收到 200px', pillNode.style.maxWidth === '200px', pillNode.style.maxWidth || '(空 = 回落 CSS 280)')
563
+ check('没有费用胶囊:不写死宽度(内容自适应)', pillNode.style.width === '', pillNode.style.width || '(空)')
564
+ var cappedWidth = Math.round(pillNode.getBoundingClientRect().width)
565
+ check('没有费用胶囊:最坏内容也真的被拦在 200px', cappedWidth === 200, cappedWidth + 'px')
566
+ check('没有费用胶囊:拦到上限后选中文字走省略号(不撑破)', pillName.scrollWidth > pillName.clientWidth, pillName.clientWidth + 'px 可见 / ' + pillName.scrollWidth + 'px 全长')
567
+
568
+ // ② 挂一个假的费用胶囊(宽 240)→ 跟随它的实测宽度,并清掉兜底上限
569
+ var fakeSpend = document.createElement('div')
570
+ fakeSpend.id = 'dsh-spend-widget'
571
+ fakeSpend.innerHTML = '<div class="dsu-widget" style="position:fixed;right:20px;bottom:20px">'
572
+ + '<div class="dsu-pill" style="box-sizing:border-box;width:240px;height:32px"></div></div>'
573
+ document.body.appendChild(fakeSpend)
574
+ hook.place()
575
+ check('有费用胶囊:宽度写死成它的实测宽度', pillNode.style.width === '240px', pillNode.style.width || '(空)')
576
+ check('有费用胶囊:清掉兜底上限(回到 CSS 280,和它同宽)', pillNode.style.maxWidth === '', pillNode.style.maxWidth || '(空)')
577
+ // ⚠️ 宽度现在有过渡(.32s):改完立刻量会量到**起点**,得等它走完
578
+ await sleep(420)
579
+ var followedWidth = Math.round(pillNode.getBoundingClientRect().width)
580
+ check('有费用胶囊:最坏内容跟着它到 240px(上限确实放开了)', followedWidth === 240, followedWidth + 'px')
581
+
582
+ // ③ 费用胶囊消失 → 立刻回到兜底
583
+ fakeSpend.remove()
584
+ hook.place()
585
+ check(
586
+ '费用胶囊消失后又回到 200px 兜底',
587
+ pillNode.style.maxWidth === '200px' && pillNode.style.width === '',
588
+ pillNode.style.maxWidth + ' / ' + (pillNode.style.width || '宽=空'),
589
+ )
590
+ // ⚠️ 这里必须把"最坏内容"重新写一遍再量:paintPill() 会在异步里把文案换回**真实内容**
591
+ // (真实内容自然宽 184px < 200,于是量到 184 而不是 200)—— 这条断言想验的是
592
+ // "上限回到 200",不是"内容恰好 301px",写回最坏内容才与内容变化解耦(否则偶发红)。
593
+ pillName.textContent = '迁移作业耗时超出了原计划…'
594
+ pillMeta.textContent = '· 追问中 12.3s'
595
+ await sleep(420)
596
+ check('回到兜底后宽度也收回来了', Math.round(pillNode.getBoundingClientRect().width) === 200, Math.round(pillNode.getBoundingClientRect().width) + 'px')
597
+
598
+ // 还原内容(后面的断言不受影响)
599
+ pillName.textContent = savedName
600
+ pillMeta.textContent = savedMeta
601
+ hook.place()
602
+ }
603
+
604
+ // ⑫ 胶囊可以拖:拖到别处就留在那儿;拖回右下角附近自动吸附回去;拖完那一下不算点击。
605
+ // 全用真鼠标事件(mousedown 打在胶囊上,mousemove/mouseup 打在 window —— 和面板拖动同一套)。
606
+ // 过渡已在 ⑪ 冻住(见 freezeMotion),所以每一步都能**同步**断言,不用等动画。
607
+ {
608
+ var pillEl = hook.pill()
609
+ var panelOpen = function () {
610
+ return hook.quoteState().panelOpen === true
611
+ }
612
+ function fire(type, x, y, onWindow) {
613
+ var event = new MouseEvent(type, { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y, button: 0 })
614
+ if (onWindow) window.dispatchEvent(event)
615
+ else pillEl.dispatchEvent(event)
616
+ }
617
+ function dragTo(x, y) {
618
+ var rect = pillEl.getBoundingClientRect()
619
+ fire('mousedown', Math.round(rect.left) + 20, Math.round(rect.top) + 10, false)
620
+ fire('mousemove', x, y, true)
621
+ fire('mousemove', x + 4, y + 2, true) // 第二下:确保"位移超过阈值"这条判定走完
622
+ fire('mouseup', x + 4, y + 2, true)
623
+ }
624
+ /** 拖到右下角那一带(吸附区)—— 用视口右下角算,和"贴角位置"差得不多,必落在吸附半径里。 */
625
+ function dragToCorner() {
626
+ dragTo(Math.round(window.innerWidth - 30), Math.round(window.innerHeight - 30))
627
+ }
628
+
629
+ hook.place()
630
+ var home = hook.pillState()
631
+ 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)
632
+
633
+ // ① 拖到屏幕中间 → 留在那儿(自由摆放)
634
+ var midX = Math.round(window.innerWidth / 2)
635
+ var midY = Math.round(window.innerHeight / 2)
636
+ dragTo(midX, midY)
637
+ var free = hook.pillState()
638
+ check('拖到屏幕中间:位置跟着走了(dock=free)', free.dock === 'free', free.dock)
639
+ check('拖到屏幕中间:落在松手的地方(±40px)', Math.abs(free.rect.left + 20 - midX) < 40 && Math.abs(free.rect.top + 10 - midY) < 40, free.rect.left + ',' + free.rect.top)
640
+
641
+ // 自由摆放时,贴角轮询不许把它拽回去
642
+ hook.place()
643
+ var stillFree = hook.pillState()
644
+ check('自由摆放时轮询不把它拽回去', stillFree.dock === 'free' && Math.abs(stillFree.rect.left - free.rect.left) <= 2, stillFree.dock + ' ' + stillFree.rect.left)
645
+
646
+ // ② 拖回右下角附近 → 自动吸附回贴角位置(没有费用胶囊就是 20/20)
647
+ dragToCorner()
648
+ var snapped = hook.pillState()
649
+ check('拖回右下角附近:自动吸附(dock=anchor)', snapped.dock === 'anchor', snapped.dock)
650
+ check('吸附回右下角 20/20', snapped.rect.right === 20 && snapped.rect.bottom === 20, snapped.rect.right + '/' + snapped.rect.bottom)
651
+
652
+ // ③ 拖完浏览器会补一个 click —— 不能被当成"点了一下"(否则会顺手开关小窗)
653
+ if (panelOpen()) hook.close()
654
+ dragTo(midX, midY)
655
+ fire('click', midX, midY, false) // 模拟浏览器在拖完之后补的那一下
656
+ check('拖完那一下不算点击(小窗没被打开)', panelOpen() === false, String(panelOpen()))
657
+ check('拖完那一下也不算"点开"(胶囊还在自由摆放位)', hook.pillState().dock === 'free', hook.pillState().dock)
658
+
659
+ // ④ 有费用胶囊时:拖到右下角附近 → 吸附到"跟随它的位置"(在那枚胶囊上方、同宽)
660
+ var fakeSpend2 = document.createElement('div')
661
+ fakeSpend2.id = 'dsh-spend-widget'
662
+ fakeSpend2.innerHTML = '<div class="dsu-widget" style="position:fixed;right:20px;bottom:20px">'
663
+ + '<div class="dsu-pill" style="box-sizing:border-box;width:240px;height:32px"></div></div>'
664
+ document.body.appendChild(fakeSpend2)
665
+ hook.place()
666
+ dragToCorner()
667
+ var withSpend = hook.pillState()
668
+ check('有费用胶囊时拖到右下角:吸附到跟随它的位置', withSpend.dock === 'anchor' && withSpend.rect.right === 20, withSpend.dock + ' right=' + withSpend.rect.right)
669
+ check('吸附后宽度是跟随它的 240px', withSpend.rect.width === 240, withSpend.rect.width + 'px')
670
+ check('吸附位置在费用胶囊**上方**(不压住它)', withSpend.rect.bottom >= 42, 'bottom=' + withSpend.rect.bottom)
671
+
672
+ // 再拖走 → 留在那儿;再拖回 → 又吸附回去
673
+ dragTo(midX, midY)
674
+ check('有费用胶囊时也能拖走(dock=free)', hook.pillState().dock === 'free', hook.pillState().dock)
675
+ dragToCorner()
676
+ var backHome = hook.pillState()
677
+ check('再拖回右下角:又吸附回去', backHome.dock === 'anchor' && backHome.rect.right === 20, backHome.dock + ' right=' + backHome.rect.right)
678
+ fakeSpend2.remove()
679
+ hook.place()
680
+
681
+ // ⑤ 没动的那一下仍然是"点击"(拖动不能把点击吃掉)
682
+ if (panelOpen()) hook.close()
683
+ var clickRect = pillEl.getBoundingClientRect()
684
+ fire('mousedown', Math.round(clickRect.left) + 20, Math.round(clickRect.top) + 10, false)
685
+ fire('mouseup', Math.round(clickRect.left) + 20, Math.round(clickRect.top) + 10, true)
686
+ fire('click', Math.round(clickRect.left) + 20, Math.round(clickRect.top) + 10, false)
687
+ await sleep(160)
688
+ check('没位移的那一下照旧是点击(小窗打开了)', panelOpen() === true, String(panelOpen()))
689
+ hook.close()
690
+ // 关小窗会让面板尺寸变 0 → 面板那个 ResizeObserver 会补一次 placePanel→paintPill(= 一次"活动")。
691
+ // 生产上无所谓(只是把静置计时重新拨满),测试里得先把这拍异步排掉,否则会和下面的收球抢时序。
692
+ await sleep(200)
693
+ }
694
+
695
+ // ⑬ 静置收球:**没有费用胶囊**时收成小球;有费用胶囊 / 小窗开着 / 鼠标停着都不收。
696
+ // 过渡已冻住,所以"收成 32×32"是同步可断言的;"到底有没有过渡"在 ⑪ 用 computed style 断言过。
697
+ {
698
+ var pillBall = hook.pill()
699
+ var panelIsOpen = function () {
700
+ return hook.quoteState().panelOpen === true
701
+ }
702
+ if (panelIsOpen()) hook.close()
703
+ await sleep(200)
704
+ var before = hook.pillState()
705
+ check('收球前是展开的胶囊', before.ball === false && before.rect.width > 100, before.rect.width + 'px')
706
+
707
+ var anchoredRightBefore = window.innerWidth - before.rect.right
708
+ var idleCollapsed = hook.pillIdle()
709
+ var balled = hook.pillState()
710
+ check('没有费用胶囊 → 静置收球', idleCollapsed === true && balled.ball === true, JSON.stringify(balled.style))
711
+ check('收成小球:32×32 的圆', balled.rect.width === balled.ballSize && balled.rect.height === balled.ballSize, balled.rect.width + '×' + balled.rect.height)
712
+ check('小球仍然贴着右下角(位置没跑)', balled.rect.right === 20 && balled.rect.bottom === 20, balled.rect.right + '/' + balled.rect.bottom)
713
+ check(
714
+ '锚定态收球:以胶囊自己的右边缘为锚(右边缘不动,往左收成球)',
715
+ Math.abs((window.innerWidth - balled.rect.right) - anchoredRightBefore) <= 1,
716
+ '右边缘 ' + anchoredRightBefore.toFixed(1) + ' → ' + (window.innerWidth - balled.rect.right).toFixed(1),
717
+ )
718
+
719
+ // ── 球心里的星芒:收球时 d 换成星、展开时换回圆点,几何都得对得上 ──
720
+ // 球心图形是 JS 按时间推进的(胶囊那条是 CSS 过渡,被 freezeMotion 冻成瞬时),
721
+ // 所以分三件事验:① 目标对不对(同步)② 计时器真的在推进(短等几拍)③ 终态几何(同步画到终点再量)。
722
+ // ⚠️ 不能靠"等 280ms 再看":这段时间里任何一次活动(比如首轮解读流式结束后的 paintPill)
723
+ // 都会按设计把球展开回胶囊,等出来的就不是球态了。
724
+ var starCanvas = pillBall.querySelector('.dsh-sel-pillicon')
725
+ var starPath = pillBall.querySelector('.dsh-sel-pillstar')
726
+ var starWant = hook.pillStarD()
727
+ var dotWant = hook.pillStarDotD()
728
+ check('收球时球心图形的目标是"星芒"(1)', hook.pillStarTarget() === 1, String(hook.pillStarTarget()))
729
+ // "计时器真的在推进"用解耦的钩子验(不依赖胶囊此刻是不是球态 ——
730
+ // 首轮解读收尾时 paintPill→notePillActivity 会按设计把球展开,和这条无关)
731
+ hook.pillStarAnimate(0)
732
+ var pAtStart = hook.pillStarProgress()
733
+ hook.pillStarAnimate(1)
734
+ var grew = false
735
+ for (var tick = 0; tick < 12 && !grew; tick += 1) {
736
+ await sleep(24)
737
+ if (hook.pillStarProgress() > pAtStart + 0.05) grew = true
738
+ }
739
+ check('球心图形的动画真的在推进(计时器在跑,不是画一帧就停)', grew, pAtStart.toFixed(3) + ' → ' + hook.pillStarProgress().toFixed(3))
740
+ // 球态的画布居中:这是 CSS 那边的事(margin 过渡),冻住运动后是瞬时终值,可以同步量。
741
+ // ⚠️ 先显式确保还在球态:上面的等待里任何一次活动都会按设计把球展开。
742
+ if (hook.pillState().ball !== true) {
743
+ hook.pillIdle()
744
+ await sleep(80)
745
+ }
746
+ check('(前置)此刻确实是球态,下面量的是球心几何', hook.pillState().ball === true, String(hook.pillState().ball))
747
+ var ballRect = pillBall.getBoundingClientRect()
748
+ var canvasRect = starCanvas.getBoundingClientRect()
749
+ check(
750
+ '星芒在球里居中(图形中心 = 球的中心,±0.6px)',
751
+ Math.abs(canvasRect.left + canvasRect.width / 2 - (ballRect.left + ballRect.width / 2)) <= 0.6,
752
+ '画布中心 ' + (canvasRect.left + canvasRect.width / 2).toFixed(1) + ' vs 球中心 ' + (ballRect.left + ballRect.width / 2).toFixed(1),
753
+ )
754
+ check('球态画布用 -1px 负边距摆正(内宽 32-2 边框 = 30)', getComputedStyle(starCanvas).marginLeft === '-1px', getComputedStyle(starCanvas).marginLeft)
755
+ // 同步画到终点,再量终态几何(不受计时器与异步活动影响)
756
+ hook.pillStarTo(1)
757
+ var starD = starPath.getAttribute('d')
758
+ var starBox = starPath.getBBox()
759
+ check('收球终态:d 与客户端那条常量逐字相同', starD === starWant, starD === starWant ? '32 顶点一致' : starD.slice(0, 18) + '… ≠ ' + starWant.slice(0, 18))
760
+ var pillFontSize = parseFloat(getComputedStyle(pillBall).fontSize)
761
+ check(
762
+ '星芒和胶囊里的文字一样高(墨迹 ≈ 字号,±0.8px)',
763
+ Math.abs(starBox.height - pillFontSize) <= 1.0 && Math.abs(starBox.width - starBox.height) < 0.15,
764
+ '星芒 ' + starBox.width.toFixed(2) + '×' + starBox.height.toFixed(2) + ' vs 字号 ' + pillFontSize + 'px',
765
+ )
766
+ check(
767
+ '星芒的内外半径比 = .3788(和「✦ 解读」浮标那颗星同一条几何,只是缩小了)+ 顶点数 32',
768
+ Math.abs(r100[0] / r100[4] - 1 / 0.3788) < 0.05 && r100.length === 32,
769
+ '尖 ' + r100[0].toFixed(2) + ' / 谷 ' + r100[4].toFixed(2) + ' = ' + (r100[0] / r100[4]).toFixed(3) + '(应为 2.640)',
770
+ )
771
+
772
+ // 鼠标一放上去就展开(若已被异步活动展开过,先收回球态,好让 mouseenter 真的走一次展开)
773
+ if (hook.pillState().ball !== true) {
774
+ hook.pillIdle()
775
+ await sleep(80)
776
+ }
777
+ pillBall.dispatchEvent(new MouseEvent('mouseenter', { view: window }))
778
+ var expanded = hook.pillState()
779
+ check('鼠标移上去:小球展开回胶囊', expanded.ball === false && expanded.rect.width > 100, expanded.rect.width + 'px')
780
+ check(
781
+ '锚定态展开:右边缘同样不动(往右长回去)',
782
+ Math.abs((window.innerWidth - expanded.rect.right) - anchoredRightBefore) <= 1,
783
+ '右边缘 ' + anchoredRightBefore.toFixed(1) + ' → ' + (window.innerWidth - expanded.rect.right).toFixed(1),
784
+ )
785
+ // 展开态:图形回到 8px 圆点,且画布**布局占位**仍是 8px(两侧 -12px)——
786
+ // 这样胶囊的排版与宽度和以前那枚 8px 圆点完全一样,不会因为换了个 SVG 就把胶囊撑宽。
787
+ check('展开时球心图形的目标是"圆点"(0)', hook.pillStarTarget() === 0, String(hook.pillStarTarget()))
788
+ // 真实悬停路径:展开会重置静置计时,不能因此取消球心图形的动画。
789
+ pillBall.dispatchEvent(new MouseEvent('mouseleave', { view: window }))
790
+ if (hook.pillState().ball !== true) hook.pillIdle()
791
+ hook.pillStarTo(1)
792
+ pillBall.dispatchEvent(new MouseEvent('mouseenter', { view: window }))
793
+ await sleep(350)
794
+ check(
795
+ '悬停展开后星芒自行回到圆点',
796
+ hook.pillState().ball === false && hook.pillStarProgress() === 0 && starPath.getAttribute('d') === dotWant,
797
+ 'ball=' + hook.pillState().ball + ' progress=' + hook.pillStarProgress(),
798
+ )
799
+ hook.pillStarTo(0) // 同步画回圆点,量终态
800
+ var dotD = pillBall.querySelector('.dsh-sel-pillstar').getAttribute('d')
801
+ var dotBox = pillBall.querySelector('.dsh-sel-pillstar').getBBox()
802
+ var iconCanvas = pillBall.querySelector('.dsh-sel-pillicon')
803
+ var iconStyle = getComputedStyle(iconCanvas)
804
+ var pillRectForDot = pillBall.getBoundingClientRect()
805
+ var canvasForDot = iconCanvas.getBoundingClientRect()
806
+ var dotRect = starPath.getBoundingClientRect()
807
+ var dotCx = dotRect.left + dotRect.width / 2
808
+ var dotCy = dotRect.top + dotRect.height / 2
809
+ var canvasCx = canvasForDot.left + canvasForDot.width / 2
810
+ var pillCy = pillRectForDot.top + pillRectForDot.height / 2
811
+ check(
812
+ '展开后圆点墨迹居中:横向在 SVG 画布中心,纵向在胶囊中心',
813
+ Math.abs(dotCx - canvasCx) < 0.25 && Math.abs(dotCy - pillCy) < 0.25,
814
+ '偏移 x=' + (dotCx - canvasCx).toFixed(2) + ' y=' + (dotCy - pillCy).toFixed(2),
815
+ )
816
+ check('展开后回到圆点态:d 与客户端那条常量**逐字相同**', dotD === dotWant, dotD === dotWant ? '32 顶点一致' : dotD.slice(0, 14) + '… ≠ ' + dotWant.slice(0, 14))
817
+ // 这条是补覆盖:上面用 pillStarTo(0) 同步画到终点,验的是"终点长什么样",
818
+ // 但"展开这条动画**自己**会不会走到终点"没验过 —— 用户报的"展开后没恢复成圆点"正是这里。
819
+ hook.pillStarAnimate(1)
820
+ await sleep(60)
821
+ hook.pillStarAnimate(0)
822
+ var settled = false
823
+ for (var settleTick = 0; settleTick < 24 && !settled; settleTick += 1) {
824
+ await sleep(24)
825
+ if (hook.pillStarProgress() === 0) settled = true
826
+ }
827
+ check('展开动画自己会走到圆点(不是停在星芒上)', settled, 'progress=' + hook.pillStarProgress())
828
+
829
+ // 用户报的"展开后没恢复成圆点",根因是**打断**:来回打断时每一段都跑满 280ms,
830
+ // 于是图形永远追不上目标(实测序列 10101010、进度卡在 0.158)。
831
+ // 这里用高频交替打断复现它,然后断言"停手之后能很快落到目标上"。
832
+ // (比"等悬停那一下走完"稳:冒烟跑在虚拟时钟下,10 秒静置会瞬间到期、球态随时可能翻。)
833
+ hook.pillStarAnimate(0)
834
+ for (var ham = 0; ham < 8; ham += 1) {
835
+ hook.pillStarAnimate(1)
836
+ await sleep(16)
837
+ hook.pillStarAnimate(0)
838
+ await sleep(16)
839
+ }
840
+ var settleTicks = -1
841
+ for (var st = 0; st < 40 && settleTicks < 0; st += 1) {
842
+ await sleep(24)
843
+ if (hook.pillStarProgress() === 0) settleTicks = st + 1
844
+ }
845
+ check(
846
+ '来回打断之后图形仍会落到目标上,且很快(不再卡在半路)',
847
+ settleTicks > 0 && settleTicks <= 6,
848
+ '停手后第 ' + settleTicks + ' 拍到位(每拍 24ms)· progress=' + hook.pillStarProgress() + ' · 起跑=' + hook.pillStarStarts(),
849
+ )
850
+ pillBall.dispatchEvent(new MouseEvent('mouseleave', { view: window }))
851
+ 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))
852
+ check('展开态画布布局占位 = 8px(两侧 -12px 负边距)', iconStyle.marginLeft === '-12px' && iconStyle.marginRight === '-12px', iconStyle.marginLeft + '/' + iconStyle.marginRight)
853
+ check('展开态画布本身仍是 32px(1 单位 = 1px,几何数字照抄设计稿)', iconStyle.width === '32px' && iconStyle.height === '32px', iconStyle.width + '×' + iconStyle.height)
854
+ check('展开后还是贴右下角', expanded.rect.right === 20 && expanded.rect.bottom === 20, expanded.rect.right + '/' + expanded.rect.bottom)
855
+
856
+ // 鼠标离开 → 又能收
857
+ pillBall.dispatchEvent(new MouseEvent('mouseleave', { view: window }))
858
+ check('鼠标离开后静置又能收球', hook.pillIdle() === true && hook.pillState().ball === true, String(hook.pillState().ball))
859
+
860
+ // 鼠标停在上面时不许收("点得到"优先于"藏起来")
861
+ pillBall.dispatchEvent(new MouseEvent('mouseenter', { view: window }))
862
+ check('鼠标停在小球上时不收球', hook.pillIdle() === false && hook.pillState().ball === false, String(hook.pillState().ball))
863
+ pillBall.dispatchEvent(new MouseEvent('mouseleave', { view: window }))
864
+
865
+ // 自由摆放时收球/展开都以**右边缘**为锚(往左收成球、往右展开回原样,右边缘一个像素都不动)
866
+ // 这条只有真浏览器能验:要真布局量矩形、真过渡(这里已冻住 → 量到的是终值)。
867
+ {
868
+ var homeRect = hook.pillState().rect
869
+ hook.pillIdle() // 先收起来(锚定态)
870
+ var anchored = hook.pillState()
871
+ var midX2 = Math.round(window.innerWidth / 2)
872
+ var midY2 = Math.round(window.innerHeight / 2)
873
+ pillBall.dispatchEvent(new MouseEvent('mouseenter', { view: window })) // 展开再拖(拖的是展开态)
874
+ pillBall.dispatchEvent(new MouseEvent('mouseleave', { view: window }))
875
+ var r0 = pillBall.getBoundingClientRect()
876
+ var fireOn = function (type, x, y, onWindow) {
877
+ var event = new MouseEvent(type, { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y, button: 0 })
878
+ if (onWindow) window.dispatchEvent(event)
879
+ else pillBall.dispatchEvent(event)
880
+ }
881
+ fireOn('mousedown', Math.round(r0.left) + 20, Math.round(r0.top) + 10, false)
882
+ fireOn('mousemove', midX2, midY2, true)
883
+ fireOn('mousemove', midX2 + 4, midY2 + 2, true)
884
+ fireOn('mouseup', midX2 + 4, midY2 + 2, true)
885
+ var wideFree = hook.pillState()
886
+ check('自由摆放(拖到中间)', wideFree.dock === 'free' && wideFree.rect.width > 100, wideFree.dock + ' ' + wideFree.rect.width)
887
+ check(
888
+ '自由摆放使用固定的 CSS right 定位,使过渡每一帧都能守住右边缘',
889
+ wideFree.style.left === 'auto' && wideFree.style.right !== 'auto' && wideFree.style.right !== '',
890
+ 'left=' + wideFree.style.left + ' right=' + wideFree.style.right,
891
+ )
892
+ var rightBefore = wideFree.rect.right
893
+ hook.pillIdle()
894
+ var ballFree = hook.pillState()
895
+ check('自由摆放时收球:右边缘不动(±1px,往左收成球)', ballFree.ball === true && Math.abs(ballFree.rect.right - rightBefore) <= 1, '右边缘 ' + rightBefore.toFixed(1) + ' → ' + ballFree.rect.right.toFixed(1))
896
+ var geoRightBefore = window.innerWidth - rightBefore
897
+ var geoRightBall = window.innerWidth - ballFree.rect.right
898
+ check('自由摆放时收球:球确实贴到了右边缘(球右边 = 原胶囊右边,球宽 32)', Math.abs(geoRightBall - geoRightBefore) <= 1 && ballFree.rect.width === ballFree.ballSize, '几何右边 ' + geoRightBefore.toFixed(1) + ' → ' + geoRightBall.toFixed(1) + ',球宽 ' + ballFree.rect.width)
899
+ pillBall.dispatchEvent(new MouseEvent('mouseenter', { view: window }))
900
+ var wideAgain = hook.pillState()
901
+ check('自由摆放时展开:右边缘也不动(±1px)', wideAgain.ball === false && Math.abs(wideAgain.rect.right - rightBefore) <= 1, '右边缘 ' + rightBefore.toFixed(1) + ' → ' + wideAgain.rect.right.toFixed(1))
902
+
903
+ await sleep(30) // 存档是异步 POST
904
+ check(
905
+ '拖到别处后把位置存到了 host 上(右边缘 + 垂直中心)',
906
+ pillPosStore && Math.abs(pillPosStore.right - (window.innerWidth - wideFree.rect.right)) <= 2,
907
+ JSON.stringify(pillPosStore) + ' vs 几何右边 ' + (window.innerWidth - wideFree.rect.right).toFixed(1),
908
+ )
909
+ // 读回来:预置一个位置,调钩子,胶囊应当切成自由摆放并摆到那儿(右边缘为锚)
910
+ pillPosStore = { right: 300, y: 220 }
911
+ hook.loadPillPos()
912
+ await sleep(60)
913
+ var restored = hook.pillState()
914
+ check(
915
+ '重挂载读回 host 上的位置:切成自由摆放并摆回去(右边缘 = 存档)',
916
+ restored.dock === 'free' && Math.abs(window.innerWidth - restored.rect.right - 300) <= 2,
917
+ 'dock=' + restored.dock + ' 几何右边 ' + (window.innerWidth - restored.rect.right).toFixed(1) + '(存档 300)',
918
+ )
919
+ // 再拖回中间,接着跑下面的"收球右边缘不动"
920
+ pillPosStore = null
921
+ hook.loadPillPos()
922
+ await sleep(0)
923
+ pillBall.dispatchEvent(new MouseEvent('mouseleave', { view: window }))
924
+ // 拖回右下角,后面的断言接着用锚定态
925
+ var frozenStyle = document.getElementById('freeze-motion')
926
+ fireOn('mousedown', Math.round(pillBall.getBoundingClientRect().left) + 20, Math.round(pillBall.getBoundingClientRect().top) + 10, false)
927
+ fireOn('mousemove', Math.round(window.innerWidth - 30), Math.round(window.innerHeight - 30), true)
928
+ frozenStyle.remove() // 拖动中 data-drag 会关过渡,此时解除全局冻结,不会让胶囊跳
929
+ fireOn('mouseup', Math.round(window.innerWidth - 30), Math.round(window.innerHeight - 30), true)
930
+ var snapAnimated = pillBall.getAnimations().some(function (animation) { return animation.transitionProperty === 'left' })
931
+ check('自由摆放从 CSS right 切回吸附时仍有滑行动画', snapAnimated, String(snapAnimated))
932
+ document.head.appendChild(frozenStyle)
933
+ check('拖回右下角后又吸附(回到 anchor)', hook.pillState().dock === 'anchor', JSON.stringify(hook.pillState()))
934
+ await sleep(30)
935
+ check('吸附回右下角后清掉 host 上的存档(那是默认位置,不用记)', pillPosStore === null, JSON.stringify(pillPosStore))
936
+ check('锚定位置和最初一致', Math.abs(hook.pillState().rect.left - homeRect.left) <= 2, hook.pillState().rect.left + ' vs ' + homeRect.left)
937
+ void anchored
938
+ }
939
+
940
+ // 有费用胶囊时不收球(收球的前提是"没有可跟随的胶囊")
941
+ var fakeSpend3 = document.createElement('div')
942
+ fakeSpend3.id = 'dsh-spend-widget'
943
+ fakeSpend3.innerHTML = '<div class="dsu-widget" style="position:fixed;right:20px;bottom:20px">'
944
+ + '<div class="dsu-pill" style="box-sizing:border-box;width:240px;height:32px"></div></div>'
945
+ document.body.appendChild(fakeSpend3)
946
+ hook.place()
947
+ check('有费用胶囊时静置也不收球', hook.pillIdle() === false, String(hook.pillState().ball))
948
+ check('有费用胶囊时保持展开', hook.pillState().ball === false, String(hook.pillState().ball))
949
+
950
+ // 收着球时费用胶囊出现 → 立刻展开(球没有存在的意义了)
951
+ fakeSpend3.remove()
952
+ hook.place()
953
+ check('静置收球后费用胶囊出现 → 立刻展开', hook.pillIdle() === true && hook.pillState().ball === true, String(hook.pillState().ball))
954
+ document.body.appendChild(fakeSpend3)
955
+ hook.place()
956
+ check('费用胶囊一出现,小球立刻展开', hook.pillState().ball === false && hook.pillState().rect.width === 240, hook.pillState().ball + ' ' + hook.pillState().rect.width)
957
+ fakeSpend3.remove()
958
+ hook.place()
959
+
960
+ // 小窗开着时不收球("小窗活动"也是活动)
961
+ hook.open('收球冒烟:小窗开着', '上下文', '冒烟')
962
+ await sleep(200)
963
+ check('小窗开着时不收球', hook.pillIdle() === false, String(hook.pillState().ball))
964
+ hook.close()
965
+ await sleep(200)
966
+ check('小窗关掉后又能收球', hook.pillIdle() === true && hook.pillState().ball === true, String(hook.pillState().ball))
967
+
968
+ // 收着球也能点开(小球仍然是个按钮)
969
+ var ballRect = pillBall.getBoundingClientRect()
970
+ var bx = Math.round(ballRect.left) + 16
971
+ var by = Math.round(ballRect.top) + 16
972
+ pillBall.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, view: window, clientX: bx, clientY: by, button: 0 }))
973
+ window.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, view: window, clientX: bx, clientY: by, button: 0 }))
974
+ pillBall.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window, clientX: bx, clientY: by }))
975
+ await sleep(200)
976
+ check('小球状态点一下照样打开小窗', panelIsOpen() === true, String(panelIsOpen()))
977
+ hook.close()
978
+ await sleep(120)
979
+ }
980
+
981
+ // 可选截图模式(?shot=1):把两个**终态**摆出来(展开=圆点 / 收球=星芒),
982
+ // 真实大小各一份 + 几何各一份放大,给人工核对,也给以后换图标留一张回归图。
983
+ // 用克隆而不是改原胶囊:克隆同样吃到那套 CSS(选择器都是 .dsh-sel-pill[data-ball="1"] …)。
984
+ if (location.search.indexOf('shot=1') >= 0) {
985
+ freezeMotion()
986
+ if (panelIsOpen()) hook.close()
987
+ await sleep(200)
988
+ hook.pillIdle()
989
+ await sleep(400)
990
+ var src = hook.pill()
991
+ var stage = document.createElement('div')
992
+ stage.setAttribute('id', 'shotstage')
993
+ stage.setAttribute('style',
994
+ 'position:fixed;left:0;top:0;right:0;bottom:0;z-index:2147483600;background:#f4f6f8;' +
995
+ 'display:flex;flex-direction:column;gap:22px;align-items:center;justify-content:center;color:#57606a;' +
996
+ 'font:12px -apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif')
997
+ var row1 = document.createElement('div')
998
+ row1.setAttribute('style', 'display:flex;gap:26px;align-items:center')
999
+ /**
1000
+ * 造一格:clone 是胶囊的克隆;ball 决定它摆成球还是展开;scale 是放大倍数;
1001
+ * dotOnly 时把球态画布里的 d 换成"圆点"那条 —— 用来单独看圆点的几何。
1002
+ * 盒子尺寸按**放大后的视觉尺寸**给(球 32×scale),克隆的布局盒仍是被 scale 前的尺寸,
1003
+ * 所以放大后的图形会对称溢出布局盒、正好落在盒子里。
1004
+ */
1005
+ function cell(ball, dotOnly, scale, label) {
1006
+ var wrap = document.createElement('div')
1007
+ wrap.setAttribute('style', 'display:flex;flex-direction:column;align-items:center;gap:9px')
1008
+ var boxW = ball ? Math.round(48 * scale) + 24 : 232
1009
+ var boxH = ball ? Math.round(48 * scale) + 24 : 92
1010
+ var box = document.createElement('div')
1011
+ box.setAttribute('style', 'display:flex;align-items:center;justify-content:center;' +
1012
+ 'width:' + boxW + 'px;height:' + boxH + 'px;background:#fff;border-radius:14px;' +
1013
+ 'box-shadow:0 6px 20px rgba(0,0,0,.10)')
1014
+ var clone = src.cloneNode(true)
1015
+ clone.style.position = 'static'
1016
+ clone.style.right = 'auto'
1017
+ clone.style.bottom = 'auto'
1018
+ clone.style.transition = 'none'
1019
+ clone.style.transform = scale === 1 ? 'none' : 'scale(' + scale + ')'
1020
+ if (ball) clone.setAttribute('data-ball', '1')
1021
+ else clone.removeAttribute('data-ball')
1022
+ clone.setAttribute('data-ready', '1')
1023
+ var starClone = clone.querySelector('.dsh-sel-pillstar')
1024
+ if (starClone) {
1025
+ starClone.setAttribute('d', ball && !dotOnly
1026
+ ? window.__dshSelectionExplain.pillStarD()
1027
+ : window.__dshSelectionExplain.pillStarDotD())
1028
+ }
1029
+ box.appendChild(clone)
1030
+ wrap.appendChild(box)
1031
+ var cap = document.createElement('div')
1032
+ cap.setAttribute('style', 'max-width:240px;text-align:center;line-height:1.6')
1033
+ cap.textContent = label
1034
+ wrap.appendChild(cap)
1035
+ return wrap
1036
+ }
1037
+ row1.appendChild(cell(false, false, 1, '展开态(真实大小):球心里是 8px 圆点'))
1038
+ row1.appendChild(cell(true, false, 1, '收球态(真实大小):8px 圆点长成星芒 16.8px'))
1039
+ row1.appendChild(cell(true, true, 4, '圆点 8px(4×)—— 32 边形,半径恒 4'))
1040
+ row1.appendChild(cell(true, false, 4, '星芒 16.8px(4×)—— 与「✦ 解读」那颗星逐点同形'))
1041
+ stage.appendChild(row1)
1042
+
1043
+ // 下行:形变逐帧(直接用 hook.pillStarAt(p) 画真实轨迹)——
1044
+ // 这一条就是"中间帧形状不对"的可视回归图:曾经用 CSS 的 d 过渡做线性插值,
1045
+ // 25%~38% 那几帧会是一个正菱形(谷跟着尖一起动),在这里一眼就能看出来。
1046
+ var row2 = document.createElement('div')
1047
+ row2.setAttribute('style', 'display:flex;gap:9px;align-items:flex-end')
1048
+ for (var fi = 0; fi <= 8; fi += 1) {
1049
+ var fp = fi / 8
1050
+ var fcell = document.createElement('div')
1051
+ fcell.setAttribute('style', 'display:flex;flex-direction:column;align-items:center;gap:5px')
1052
+ var fclone = src.cloneNode(true)
1053
+ fclone.style.position = 'static'
1054
+ fclone.style.right = 'auto'
1055
+ fclone.style.bottom = 'auto'
1056
+ fclone.style.transition = 'none'
1057
+ fclone.setAttribute('data-ball', '1')
1058
+ fclone.setAttribute('data-ready', '1')
1059
+ var fstar = fclone.querySelector('.dsh-sel-pillstar')
1060
+ if (fstar) {
1061
+ fstar.setAttribute('d', window.__dshSelectionExplain.pillStarAt(fp))
1062
+ fstar.style.transform = 'rotate(' + Math.round(8 * (1 - fp) * 100) / 100 + 'deg)'
1063
+ }
1064
+ fcell.appendChild(fclone)
1065
+ var fcap = document.createElement('div')
1066
+ fcap.setAttribute('style', 'font-size:10px;color:#8c959f')
1067
+ fcap.textContent = Math.round(fp * 100) + '%'
1068
+ fcell.appendChild(fcap)
1069
+ row2.appendChild(fcell)
1070
+ }
1071
+ stage.appendChild(row2)
1072
+ document.body.appendChild(stage)
1073
+ await sleep(400)
1074
+ }
1075
+
348
1076
  finish()
349
1077
  }
350
1078
 
@@ -413,6 +1141,34 @@ try {
413
1141
  dump = out
414
1142
  results = parseDump(dump)
415
1143
  }
1144
+
1145
+ // 截图(可选,失败不影响判定):把两个终态并排拍一张 —— 换图标时人工核对用。
1146
+ // 走同一个页面,加 ?shot=1 让页面在跑完检查后把"展开态 / 收球态"克隆出来并排摆好。
1147
+ try {
1148
+ const shotDir = resolve(ROOT, 'docs')
1149
+ if (!existsSync(shotDir)) mkdirSync(shotDir, { recursive: true })
1150
+ const shotPath = resolve(shotDir, 'pill-star-states.png')
1151
+ await new Promise((done) => {
1152
+ execFile(
1153
+ chrome,
1154
+ [
1155
+ '--headless',
1156
+ '--disable-gpu',
1157
+ '--no-first-run',
1158
+ '--no-default-browser-check',
1159
+ '--window-size=1180,560',
1160
+ '--virtual-time-budget=20000',
1161
+ `--screenshot=${shotPath}`,
1162
+ `http://127.0.0.1:${port}/smoke.html?shot=1`,
1163
+ ],
1164
+ { encoding: 'utf8', timeout: 90000 },
1165
+ () => done(),
1166
+ )
1167
+ })
1168
+ console.log(`=== 截图(圆点 / 星芒 两个终态):${shotPath} ===`)
1169
+ } catch (error) {
1170
+ console.log('(截图跳过:' + ((error && error.message) || String(error)) + ')')
1171
+ }
416
1172
  } finally {
417
1173
  if (typeof server.closeAllConnections === 'function') server.closeAllConnections()
418
1174
  server.close()