@mzzsfy/dsh-turn-notify 0.7.2 → 0.7.4

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.
package/README.md CHANGED
@@ -44,7 +44,7 @@ dsh plugin --profile web add file:./packages/dsh-turn-notify
44
44
  - **8 种内置音色**:上行琶音、铃铛、清脆双音、警报方波、低鸣、双音提示、嘀嗒、低音下滑。由 Web Audio 实时合成,零下载、零系统依赖。浏览器自动播放策略要求先与页面交互一次(任意点击),首次交互前声音静默,其余通道不受影响。
45
45
  - **自定义音效上传**:支持 wav / mp3 / ogg,可一次多选,单文件上限 2MB、总库上限 10MB。上传前可逐个试听,确认后才落盘保存。支持重命名;同一文件重复上传自动识别,不产生重复文件。
46
46
  - **按事件分类指定音效**:六类事件各自映射一款音效(内置或上传均可),例如"任务完成"用上行琶音、"等待审批"用双音提示。删除音效时引用它的映射自动清空、回落内置默认;仅当映射指向不存在的音效(如手改 yaml 产生死链)时,面板才显示失效项,试听时给出归因提示。
47
- - **按分类静音**:总开关之外,可单独让某一类事件不出声,例如只对"AI 提问""等待审批"出声。被静音分类的系统弹窗与标题闪烁随之静默,页内提示卡片与会话高亮不受影响;页内提示音有独立的分类开关,不随此处变化。
47
+ - **按分类静音**:总开关之外,可单独让某一类事件不出声,例如只对"AI 提问""等待审批"出声。静音只关闭声音通道本身,系统弹窗与标题闪烁照常;页内提示卡片与会话高亮不受影响;页内提示音有独立的分类开关,不随此处变化。
48
48
  - **音量**:按 5% 步进 0-100% 可调,本机记忆。
49
49
  - **映射双作用域**:音效映射默认全局共用(存 settings.yaml,所有浏览器一致);打开"当前域名独立"后,映射改动只写本浏览器(按域名隔离),公司/家里各配各的,本地优先于全局。关闭开关即恢复全局,本地已存映射休眠保留,重新开启即恢复。
50
50
  - **试听**:音效库与分类映射的每一项都能即时试听,试听的就是实际生效的音效。
@@ -56,7 +56,7 @@ dsh plugin --profile web add file:./packages/dsh-turn-notify
56
56
  - **系统弹窗**:浏览器 Notification 授权后,窗口失焦时弹系统级通知。HTTP 非回环地址下浏览器不提供该能力,声音开启时自动降级为标题闪烁;HTTPS 下自动恢复。Windows 下还受系统通知设置与专注助手约束。
57
57
  - **页内提示**:页面角落浮出的卡片提示(dsh-toast 栈式展示,6 秒),聚焦窗口内唯一常开的提醒形态,可关。
58
58
  - **页内提示音**:聚焦场景的独立声音,总开关、按事件分类独立开关与音色映射全套自成一档,独立于提示音的分类配置。开启后页内提示弹出且通知声音未播时补一声提示——聚焦窗口内通知声音被聚焦静默压制,靠它保留听觉提醒;与通知声音互斥,同一通知至多一声;音量共用,音色在音效页的「页内提示音映射」单独指定。
59
- - **降级标题闪烁**:声音通道开启(总开关开且该类未静音)的前提下,系统弹窗开关开启但未获得授权或不可用(如 HTTP 非回环地址、曾被拒绝)且窗口未聚焦时,标签页标题以 ⏳ 前缀闪烁替代弹窗;可在偏好中关闭该提示。
59
+ - **降级标题闪烁**:系统弹窗开关开启但未获得授权或不可用(如 HTTP 非回环地址、曾被拒绝)且窗口未聚焦时,标签页标题以 ⏳ 前缀闪烁替代弹窗;单条闪烁 6 秒自停,连环通知逐条续命但总时长硬顶 30 秒必停,返回窗口立即停止;可在偏好中关闭该提示。
60
60
  - **聚焦静默**:窗口聚焦时只保留页内提示,声音、系统弹窗与标题闪烁全部静默(页内提示音开启且该分类未在页内静音时仍有补位一声);你离开键盘满 5 分钟视为不在电脑前,聚焦也全通道提醒。
61
61
 
62
62
  ## webhook 推送
@@ -73,7 +73,7 @@ dsh plugin --profile web add file:./packages/dsh-turn-notify
73
73
 
74
74
  ## 多窗口只响一次
75
75
 
76
- 同一地址(同源)打开的多个窗口共享一次呈现:各窗口以长轮询挂起在 host 的通知队列上(事件产生或配置变更即时返回,空闲期单次挂起至多数十秒,失败按指数退避重连),经 localStorage 协调,仅一个窗口抢先呈现——发声并弹出页内提示、系统弹窗、高亮会话行,其余窗口全部跳过,不止声音。通知在产生后的下一次唤醒即送达,通常亚秒级;不同浏览器或不同地址打开的窗口各自发声互不感知。
76
+ 同一地址(同源)打开的多个窗口共享一次呈现:各窗口以长轮询挂起在 host 的通知队列上(事件产生或配置变更即时返回,空闲期单次挂起至多数十秒,失败按指数退避重连),经 localStorage 协调,仅一个窗口抢先呈现——发声并弹出页内提示、系统弹窗、高亮会话行,其余窗口全部跳过,不止声音。通知在产生后的下一次唤醒即送达,通常亚秒级;不同浏览器或不同地址打开的窗口各自发声互不感知。窗口关闭/刷新期间错过的通知不做重开回放:启动首轮仅静默对齐游标与完成标记,不逐条补发提示与声音(等待用户动作的审批与提问例外,照常呈现)。
77
77
 
78
78
  ## 设置面板指南
79
79
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-turn-notify",
3
3
  "description": "消息通知:六类回合事件送达声音、系统弹窗、页内提示、webhook 与 IM;内置八种合成音色,自定义音效,按事件分类指定;多窗口只响一次",
4
- "version": "0.7.2",
4
+ "version": "0.7.4",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/client.js CHANGED
@@ -28,6 +28,8 @@ window.__ModuleLoader__.load({
28
28
  // 页内通知展示期,经公共依赖 holdMs 传入
29
29
  const TOAST_MS = 6 * 1000
30
30
  const BLINK_MS = 1 * 1000
31
+ // 连环通知下标题闪烁的总时长硬顶
32
+ const BLINK_MAX_MS = 30 * 1000
31
33
  // 系统通知测试延迟:浏览器对聚焦窗口抑制系统弹窗,倒计时供用户切出窗口
32
34
  const SYSTEM_TEST_DELAY_MS = 5 * 1000
33
35
  // 显示回执等待上限:超时未回执按环境层拦截给出诊断
@@ -475,17 +477,28 @@ window.__ModuleLoader__.load({
475
477
  // 本包只保留标题闪烁通道
476
478
 
477
479
  let blinkTimer = null
480
+ let blinkStopTimer = null
478
481
  const baseTitle = () => document.title.replace(/^⏳ /, '')
479
482
 
483
+ // 连环通知(并行会话批量报错)间隔小于 TOAST_MS 时,闪烁会被逐条续命;
484
+ // 硬顶 BLINK_MAX_MS 保证任何情况下标题必停,焦点返回时由可见性同步立即清除
480
485
  function startTitleBlink() {
481
- if (blinkTimer !== null) return
486
+ if (blinkTimer !== null) {
487
+ clearTimeout(blinkStopTimer)
488
+ blinkStopTimer = setTimeout(stopTitleBlink, BLINK_MAX_MS - BLINK_MS)
489
+ return
490
+ }
482
491
  blinkTimer = setInterval(() => {
483
492
  document.title = document.title.startsWith('⏳ ') ? baseTitle() : '⏳ ' + baseTitle()
484
493
  }, BLINK_MS)
485
- setTimeout(stopTitleBlink, TOAST_MS)
494
+ blinkStopTimer = setTimeout(stopTitleBlink, TOAST_MS)
486
495
  }
487
496
 
488
497
  function stopTitleBlink() {
498
+ if (blinkStopTimer !== null) {
499
+ clearTimeout(blinkStopTimer)
500
+ blinkStopTimer = null
501
+ }
489
502
  if (blinkTimer === null) return
490
503
  clearInterval(blinkTimer)
491
504
  blinkTimer = null
@@ -537,6 +550,9 @@ window.__ModuleLoader__.load({
537
550
  }
538
551
  // 版本缺失即异常响应:按失败退避,防游标停滞退化成紧密首拉循环
539
552
  if (typeof payload.version !== 'number') return false
553
+ // 冷启动对齐:页面关闭/刷新期间错过的存量通知只补游标与完成标记,不轰炸呈现;
554
+ // 错过即过期的投影设计下,重开逐条回放只剩连环提示与声音,信息价值为零
555
+ const coldStart = projectionCursor === null
540
556
  projectionCursor = payload.version
541
557
  soundMapping = payload.soundMapping || {}
542
558
  sessionHighlightEnabled = readSessionHighlightEnabled()
@@ -559,6 +575,12 @@ window.__ModuleLoader__.load({
559
575
  storageState.broken = true
560
576
  }
561
577
  for (const unit of units) {
578
+ // 冷启动对齐:错过窗口期的存量通知不回放轰炸;审批与提问例外——
579
+ // 它们在等用户动作,静默会把待办吞掉
580
+ if (coldStart && unit.category !== 'approval' && unit.category !== 'ask') {
581
+ try { markDone(unit.id) } catch { /* 存储不可用:标记缺失由内存去重兜底,冷启动本就不发声 */ }
582
+ continue
583
+ }
562
584
  if (!claimEvent(unit.id)) continue
563
585
  markDone(unit.id)
564
586
  const channels = chooseChannels(document.hasFocus(), notificationPermission(), Date.now() - lastActionAt(), readSoundCategories(), unit.category)
@@ -577,8 +599,8 @@ window.__ModuleLoader__.load({
577
599
  if (channels.pageSound && toast) {
578
600
  playSound(resolveSound(unit.category, mergeMapping(effectiveMapping(), readPageMapping()), uploadedIds)).catch(() => {})
579
601
  }
580
- if (!channels.sound) continue
581
- playSound(sound).catch(() => {})
602
+ // 声音关闭只静音自身:system 弹窗与 blink 降级通道独立判断,不因无声 continue 连坐
603
+ if (channels.sound) playSound(sound).catch(() => {})
582
604
  if (channels.system) notifySystem(unit)
583
605
  else if (channels.blink && localGet(KEY_DEGRADE_HINT) !== '0') startTitleBlink()
584
606
  }
@@ -818,8 +840,12 @@ window.__ModuleLoader__.load({
818
840
  const onVisible = () => {
819
841
  if (document.hidden) return
820
842
  clearCurrentSessionHighlights()
843
+ stopTitleBlink()
844
+ }
845
+ const onFocus = () => {
846
+ clearCurrentSessionHighlights()
847
+ stopTitleBlink()
821
848
  }
822
- const onFocus = () => clearCurrentSessionHighlights()
823
849
  const dispose = () => {
824
850
  if (typeof window.removeEventListener === 'function') window.removeEventListener('focus', onFocus)
825
851
  document.removeEventListener('visibilitychange', onVisible)
@@ -26,8 +26,15 @@ class BrokenStorage {
26
26
  }
27
27
 
28
28
  // 以注入的 stub 加载真实 src/client.js,返回捕获的模块对象、页内通知捕获表与 window 桩。
29
- function loadClient({ storage, payload, onFetch, fetchImpl, document: documentOverride }) {
30
- const source = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
29
+ // shortTimers:闪烁常量替换为毫秒级,配合真实定时器验证硬顶与重入(node:test mock.timers
30
+ // 拦截不到 new Function 沙箱里的 timer,不能用于本 loader)
31
+ function loadClient({ storage, payload, onFetch, fetchImpl, document: documentOverride, shortTimers }) {
32
+ const raw = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
33
+ const source = shortTimers
34
+ ? raw.replace('const TOAST_MS = 6 * 1000', 'const TOAST_MS = 60')
35
+ .replace('const BLINK_MS = 1 * 1000', 'const BLINK_MS = 20')
36
+ .replace('const BLINK_MAX_MS = 30 * 1000', 'const BLINK_MAX_MS = 200')
37
+ : raw
31
38
  const modules = []
32
39
  const shown = []
33
40
  const windowListeners = {}
@@ -57,12 +64,16 @@ function loadClient({ storage, payload, onFetch, fetchImpl, document: documentOv
57
64
  }
58
65
  const defaultFetch = async (path) => {
59
66
  if (onFetch) onFetch(path)
67
+ // 首拉返回空投影:真实客户端启动即冷启动对齐,既有"正常消费"语义从第二次轮询起成立
68
+ calls += 1
69
+ if (calls === 1) return { ok: true, json: async () => ({ units: [], soundMapping: {}, version: 0 }) }
60
70
  return { ok: true, json: async () => payload }
61
71
  }
62
72
  const factory = new Function(
63
73
  'window', 'require', 'document', 'MutationObserver', 'fetch', 'Notification',
64
74
  source + '\n;return null',
65
75
  )
76
+ let calls = 0
66
77
  factory(
67
78
  windowStub,
68
79
  requireStub,
@@ -81,14 +92,84 @@ const units = [
81
92
  { id: 'u2', category: 'error', text: '[dsh] 任务出错: t2' },
82
93
  ]
83
94
 
95
+ // 标题闪烁:错误类通知在未授权系统通知时走 blink 降级通道,焦点回返与硬顶都必须复位标题
96
+ test('Given 声音总开关关闭 When 错误通知到达 Then 降级闪烁照常触发', async () => {
97
+ // 声音关闭只应静音,不得连坐 system 弹窗与 blink 降级通道
98
+ const dom = { hasFocus: () => false, title: 'dsh', hidden: true }
99
+ const { mod, document: doc } = loadClient({
100
+ storage: new FakeStorage({ 'turn-notify:sound': '0' }),
101
+ payload: { units: [units[1]], soundMapping: {}, version: 1 },
102
+ document: dom,
103
+ })
104
+ const { poll } = mod.__test
105
+ await poll()
106
+ await poll()
107
+ assert.ok(await scanTitleBlink(doc, 1400), '声音关闭时 blink 不应被 continue 连坐跳过')
108
+ })
109
+
110
+ test('Given 标题闪烁进行中 When 窗口重获焦点 Then 标题立即复位', async () => {
111
+ const docListeners = {}
112
+ const dom = {
113
+ hasFocus: () => false, title: 'dsh', hidden: true,
114
+ addEventListener: (type, fn) => { (docListeners[type] = docListeners[type] || []).push(fn) },
115
+ removeEventListener: (type, fn) => { docListeners[type] = (docListeners[type] || []).filter((f) => f !== fn) },
116
+ }
117
+ const { mod, window: winStub, document: doc } = loadClient({
118
+ storage: new FakeStorage(),
119
+ payload: { units: [units[1]], soundMapping: {}, version: 1 },
120
+ document: dom,
121
+ })
122
+ const { poll } = mod.__test
123
+ await poll()
124
+ await poll()
125
+ mod.__test.start()
126
+ const focusHandlers = (winStub.listeners.focus || []).slice()
127
+ assert.ok(focusHandlers.length > 0, 'window focus 未监听')
128
+ doc.title = '⏳ dsh'
129
+ for (const fn of focusHandlers) fn()
130
+ assert.equal(doc.title, 'dsh', '焦点返回后标题应复位')
131
+ winStub['turn-notify:polling'].abort()
132
+ })
133
+
134
+ test('Given 连环错误通知重入闪烁 When 硬顶到期 Then 标题必停', async () => {
135
+ const dom = { hasFocus: () => false, title: 'dsh', hidden: true }
136
+ const { mod, document: doc } = loadClient({
137
+ storage: new FakeStorage(),
138
+ payload: { units: [{ id: 'e1', category: 'error', text: '[dsh] 任务出错: e1' }, { id: 'e2', category: 'error', text: '[dsh] 任务出错: e2' }], soundMapping: {}, version: 1 },
139
+ document: dom,
140
+ shortTimers: true,
141
+ })
142
+ const { poll } = mod.__test
143
+ await poll()
144
+ await poll()
145
+ // 同批两事件:第一条启动闪烁(单条 60ms 即停),第二条重入重排至硬顶 200ms
146
+ const sawBlink = await scanTitleBlink(doc, 150)
147
+ assert.ok(sawBlink, '重入应延长闪烁,不得按单条时长提前复位')
148
+ await sleep(200)
149
+ assert.equal(doc.title.indexOf('⏳'), -1, '硬顶到期标题必复位,不允许无限续闪')
150
+ })
151
+
152
+ async function scanTitleBlink(doc, ms) {
153
+ const deadline = Date.now() + ms
154
+ while (Date.now() < deadline) {
155
+ if (doc.title.indexOf('⏳') >= 0) return true
156
+ await sleep(5)
157
+ }
158
+ return false
159
+ }
160
+
161
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
162
+
84
163
  test('broken localStorage:清理段不抛,降级发声恰好一次,第二轮不再发声', async () => {
85
164
  const { mod, shown } = loadClient({ storage: new BrokenStorage(), payload: { units, soundMapping: {}, version: 1 } })
86
165
  const { poll, storageState } = mod.__test
87
166
  await poll()
167
+ await poll()
88
168
  assert.equal(storageState.broken, true)
89
169
  // 两事件各发声一次,清理段未抛出控制流到达 claimEvent
90
170
  assert.equal(shown.length, units.length)
91
171
  await poll()
172
+ await poll()
92
173
  // 投影窗口内第二轮 poll 同事件不再发声
93
174
  assert.equal(shown.length, units.length)
94
175
  })
@@ -98,11 +179,13 @@ test('正常 localStorage:唯一发声,完成标记写入,残留锁被清理', a
98
179
  const { mod, shown } = loadClient({ storage, payload: { units, soundMapping: {}, version: 1 } })
99
180
  const { poll } = mod.__test
100
181
  await poll()
182
+ await poll()
101
183
  assert.equal(shown.length, units.length)
102
184
  assert.equal(storage.getItem('turn-notify:lock:stale'), null)
103
185
  assert.notEqual(storage.getItem('turn-notify:done:u1'), null)
104
186
  assert.notEqual(storage.getItem('turn-notify:done:u2'), null)
105
187
  await poll()
188
+ await poll()
106
189
  // 完成标记生效,第二轮不重复发声
107
190
  assert.equal(shown.length, units.length)
108
191
  })
@@ -110,10 +193,55 @@ test('正常 localStorage:唯一发声,完成标记写入,残留锁被清理', a
110
193
  test('页内通知经公共组件:文案与展示期随事件传入', async () => {
111
194
  const { mod, shown } = loadClient({ storage: new FakeStorage(), payload: { units, soundMapping: {}, version: 1 } })
112
195
  await mod.__test.poll()
196
+ await mod.__test.poll()
113
197
  assert.deepEqual(shown.map((call) => call.text), units.map((unit) => unit.text))
114
198
  assert.ok(shown.every((call) => call.opts && call.opts.holdMs === 6 * 1000))
115
199
  })
116
200
 
201
+ test('Given 冷启动首拉带存量通知 When poll Then 静默对齐不呈现且完成标记写入', async () => {
202
+ const storage = new FakeStorage()
203
+ const { mod, shown } = loadClient({
204
+ storage,
205
+ fetchImpl: async () => ({ ok: true, json: async () => ({ units, soundMapping: {}, version: 1 }) }),
206
+ })
207
+ await mod.__test.poll()
208
+ await mod.__test.poll()
209
+ assert.equal(shown.length, 0, '冷启动对齐不应轰炸呈现')
210
+ assert.notEqual(storage.getItem('turn-notify:done:u1'), null, '静默对齐仍需写完成标记防其他窗口回放')
211
+ })
212
+
213
+ test('Given 冷启动存量含审批与提问 When poll Then 照常呈现待办', async () => {
214
+ const todoUnits = [
215
+ { id: 'u-ap', category: 'approval', text: '[dsh] 等待审批: t1' },
216
+ { id: 'u-ask', category: 'ask', text: '[dsh] AI 提问: t2' },
217
+ ]
218
+ const { mod, shown } = loadClient({
219
+ storage: new FakeStorage(),
220
+ fetchImpl: async () => ({ ok: true, json: async () => ({ units: todoUnits, soundMapping: {}, version: 1 }) }),
221
+ })
222
+ await mod.__test.poll()
223
+ await mod.__test.poll()
224
+ assert.deepEqual(shown.map((call) => call.text), todoUnits.map((unit) => unit.text), '等待用户动作的通知不因冷启动静默')
225
+ })
226
+
227
+ test('Given 冷启动已对齐 When 新通知到达 Then 正常呈现', async () => {
228
+ let version = 1
229
+ let payloadUnits = units
230
+ const storage = new FakeStorage()
231
+ const { mod, shown } = loadClient({
232
+ storage,
233
+ fetchImpl: async () => ({ ok: true, json: async () => ({ units: payloadUnits, soundMapping: {}, version }) }),
234
+ })
235
+ await mod.__test.poll()
236
+ await mod.__test.poll()
237
+ assert.equal(shown.length, 0)
238
+ version = 2
239
+ payloadUnits = [...units, { id: 'u3', category: 'completed', text: '[dsh] 任务完成: t3' }]
240
+ await mod.__test.poll()
241
+ await mod.__test.poll()
242
+ assert.deepEqual(shown.map((call) => call.text), ['[dsh] 任务完成: t3'])
243
+ })
244
+
117
245
  // ---- 会话行高亮:认领后按投影 session 字段在侧边栏定位行并挂类 ----
118
246
 
119
247
  // 最小侧边栏 DOM:列表容器(多标题)→ 多会话行(各单标题),classList 记录变更,click listener 捕获;
@@ -174,6 +302,7 @@ test('Given 通知带 session 标题 When 认领 Then 会话行挂上高亮类',
174
302
  document: { title: '会话乙 — DeepSeek Harness', ...dom },
175
303
  })
176
304
  await mod.__test.poll()
305
+ await mod.__test.poll()
177
306
  assert.ok(dom.rows[0].classList.set.has('tn-sess-hl'), '会话行未挂高亮类')
178
307
  assert.ok(dom.rows[0].classList.set.has('tn-sess-hl--completed'), '高亮类缺少分类色')
179
308
  })
@@ -189,6 +318,7 @@ test('Given 通知的是当前查看的会话 When 认领 Then 不挂高亮', as
189
318
  document: { title: '会话甲 — DeepSeek Harness', ...dom },
190
319
  })
191
320
  await mod.__test.poll()
321
+ await mod.__test.poll()
192
322
  assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), false, '当前查看的会话不应闪烁')
193
323
  })
194
324
 
@@ -203,6 +333,7 @@ test('Given 当前会话判定不受标题闪烁前缀干扰 When 认领 Then
203
333
  document: { title: '⏳ 会话甲 — DeepSeek Harness', ...dom },
204
334
  })
205
335
  await mod.__test.poll()
336
+ await mod.__test.poll()
206
337
  assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), false, '闪烁前缀不应破坏当前会话判定')
207
338
  })
208
339
 
@@ -217,6 +348,7 @@ test('Given 失焦且通知的是本标签页会话 When 认领 Then 挂高亮',
217
348
  document: { title: '会话甲 — DeepSeek Harness', hasFocus: () => false, ...dom },
218
349
  })
219
350
  await mod.__test.poll()
351
+ await mod.__test.poll()
220
352
  assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), true, '失焦期间的本会话通知应闪烁提醒')
221
353
  })
222
354
 
@@ -232,6 +364,7 @@ test('Given 失焦期间挂上的当前会话高亮 When 页面重新可见 Then
232
364
  document: { title: '会话甲 — DeepSeek Harness', hasFocus: () => false, ...dom },
233
365
  })
234
366
  await mod.__test.poll()
367
+ await mod.__test.poll()
235
368
  assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), true)
236
369
  assert.equal(dom.rows[1].classList.set.has('tn-sess-hl'), true)
237
370
  mod.__test.start()
@@ -257,6 +390,7 @@ test('Given 失焦挂上的当前会话高亮 When 窗口重获焦点 Then 提
257
390
  document: { title: '会话甲 — DeepSeek Harness', hasFocus: () => false, ...dom },
258
391
  })
259
392
  await mod.__test.poll()
393
+ await mod.__test.poll()
260
394
  assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), true)
261
395
  mod.__test.start()
262
396
  const focusHandlers = (winStub.listeners.focus || []).slice()
@@ -278,6 +412,7 @@ test('Given 行处于运行状态 When 通知认领 Then 不挂高亮', async ()
278
412
  document: dom,
279
413
  })
280
414
  await mod.__test.poll()
415
+ await mod.__test.poll()
281
416
  assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), false, '运行中的会话不应闪烁')
282
417
  })
283
418
 
@@ -292,10 +427,12 @@ test('Given 已挂高亮 When 行进入运行状态 Then 清除闪烁', async ()
292
427
  document: dom,
293
428
  })
294
429
  await mod.__test.poll()
430
+ await mod.__test.poll()
295
431
  assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), true)
296
432
  // 会话又开跑:状态点文案回到行内
297
433
  dom.rows[0].textContent = '进行中会话甲 5分钟'
298
434
  await mod.__test.poll()
435
+ await mod.__test.poll()
299
436
  assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), false, '会话运行后应停止闪烁')
300
437
  })
301
438
 
@@ -310,6 +447,7 @@ test('Given 通知标题字段缺失 When 认领 Then 不挂高亮类且链路
310
447
  document: dom,
311
448
  })
312
449
  await mod.__test.poll()
450
+ await mod.__test.poll()
313
451
  assert.equal(shown.length, 1)
314
452
  assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), false)
315
453
  })
@@ -330,6 +468,7 @@ test('Given 短标题先于长前缀行匹配 When 挂类 Then 短标题精确
330
468
  document: dom,
331
469
  })
332
470
  await mod.__test.poll()
471
+ await mod.__test.poll()
333
472
  assert.ok(dom.rows[0].classList.set.has('tn-sess-hl--completed'), '长标题行未挂类')
334
473
  assert.ok(dom.rows[1].classList.set.has('tn-sess-hl--ask'), '短标题行未挂到自身行')
335
474
  assert.equal(dom.rows[1].classList.set.has('tn-sess-hl--completed'), false, '长标题类误挂到短标题行')
@@ -351,6 +490,7 @@ test('Given 两行各自高亮 When 点击其中一行 Then 仅该行清除且
351
490
  document: dom,
352
491
  })
353
492
  await mod.__test.poll()
493
+ await mod.__test.poll()
354
494
  mod.__test.start()
355
495
  const clickListeners = dom.listeners.click || []
356
496
  assert.ok(clickListeners.length > 0, 'click listener 未挂载')
@@ -438,6 +578,7 @@ test('页内提示通道独立开关:关闭后投影事件不再弹页内提示'
438
578
  const { mod, shown } = loadClient({ storage, payload: { units, soundMapping: {}, version: 1 } })
439
579
  const { poll } = mod.__test
440
580
  await poll()
581
+ await poll()
441
582
  assert.equal(shown.length, 0)
442
583
  // 完成标记已写:事件被认领消费,仅通道被关
443
584
  assert.notEqual(storage.getItem('turn-notify:done:u1'), null)