@mzzsfy/dsh-turn-notify 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,563 @@
1
+ // 全链路集成测试:加载真实 client.js 模块,注入 broken / 正常 localStorage,
2
+ // 走 pollOnce 完整链路(清理段 → 认领 → 发声),验证降级接线与每事件去重。
3
+ import test from 'node:test'
4
+ import assert from 'node:assert/strict'
5
+ import { readFileSync } from 'node:fs'
6
+ import { fileURLToPath } from 'node:url'
7
+ import { dirname, join } from 'node:path'
8
+
9
+ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
10
+
11
+ class FakeStorage {
12
+ constructor(seed) { this.map = new Map(Object.entries(seed || {})) }
13
+ get length() { return this.map.size }
14
+ key(index) { return Array.from(this.map.keys())[index] ?? null }
15
+ getItem(key) { return this.map.has(key) ? this.map.get(key) : null }
16
+ setItem(key, value) { this.map.set(key, String(value)) }
17
+ removeItem(key) { this.map.delete(key) }
18
+ }
19
+
20
+ class BrokenStorage {
21
+ get length() { throw new Error('blocked') }
22
+ key() { throw new Error('blocked') }
23
+ getItem() { throw new Error('blocked') }
24
+ setItem() { throw new Error('blocked') }
25
+ removeItem() { throw new Error('blocked') }
26
+ }
27
+
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')
31
+ const modules = []
32
+ const shown = []
33
+ const windowListeners = {}
34
+ const windowStub = {
35
+ __ModuleLoader__: { load: (module) => { modules.push(module) } },
36
+ addEventListener: (type, fn) => { (windowListeners[type] = windowListeners[type] || []).push(fn) },
37
+ removeEventListener: (type, fn) => { windowListeners[type] = (windowListeners[type] || []).filter((f) => f !== fn) },
38
+ listeners: windowListeners,
39
+ localStorage: storage,
40
+ }
41
+ const documentStub = {
42
+ hasFocus: () => true,
43
+ title: 'dsh',
44
+ hidden: true,
45
+ createElement: () => ({ style: {}, remove() {} }),
46
+ body: { appendChild: () => {} },
47
+ head: { appendChild: () => {} },
48
+ ...documentOverride,
49
+ }
50
+ const reactStub = { useState: (value) => [value, () => {}], useEffect: () => {}, useSyncExternalStore: () => [] }
51
+ const requireStub = (name) => {
52
+ // 页内通知通道出口:公共依赖 @mzzsfy/dsh-toast 的捕获桩
53
+ if (name === '@mzzsfy/dsh-toast/client') {
54
+ return { show: (text, opts) => { shown.push({ text, opts }); return shown.length } }
55
+ }
56
+ return reactStub
57
+ }
58
+ const defaultFetch = async (path) => {
59
+ if (onFetch) onFetch(path)
60
+ return { ok: true, json: async () => payload }
61
+ }
62
+ const factory = new Function(
63
+ 'window', 'require', 'document', 'MutationObserver', 'fetch', 'Notification',
64
+ source + '\n;return null',
65
+ )
66
+ factory(
67
+ windowStub,
68
+ requireStub,
69
+ documentStub,
70
+ class { observe() {} disconnect() {} },
71
+ fetchImpl || defaultFetch,
72
+ undefined,
73
+ )
74
+ assert.equal(modules.length, 1, 'client.js 模块未被捕获')
75
+ // load({id, factory}) 结构:再调 factory(require) 得到真正的模块对象
76
+ return { mod: modules[0].factory(requireStub), shown, window: windowStub, document: documentStub }
77
+ }
78
+
79
+ const units = [
80
+ { id: 'u1', category: 'completed', text: '[dsh] 任务完成: t1' },
81
+ { id: 'u2', category: 'error', text: '[dsh] 任务出错: t2' },
82
+ ]
83
+
84
+ test('broken localStorage:清理段不抛,降级发声恰好一次,第二轮不再发声', async () => {
85
+ const { mod, shown } = loadClient({ storage: new BrokenStorage(), payload: { units, soundMapping: {}, version: 1 } })
86
+ const { poll, storageState } = mod.__test
87
+ await poll()
88
+ assert.equal(storageState.broken, true)
89
+ // 两事件各发声一次,清理段未抛出控制流到达 claimEvent
90
+ assert.equal(shown.length, units.length)
91
+ await poll()
92
+ // 投影窗口内第二轮 poll 同事件不再发声
93
+ assert.equal(shown.length, units.length)
94
+ })
95
+
96
+ test('正常 localStorage:唯一发声,完成标记写入,残留锁被清理', async () => {
97
+ const storage = new FakeStorage({ 'turn-notify:lock:stale': '{"wid":"w9","at":1}' })
98
+ const { mod, shown } = loadClient({ storage, payload: { units, soundMapping: {}, version: 1 } })
99
+ const { poll } = mod.__test
100
+ await poll()
101
+ assert.equal(shown.length, units.length)
102
+ assert.equal(storage.getItem('turn-notify:lock:stale'), null)
103
+ assert.notEqual(storage.getItem('turn-notify:done:u1'), null)
104
+ assert.notEqual(storage.getItem('turn-notify:done:u2'), null)
105
+ await poll()
106
+ // 完成标记生效,第二轮不重复发声
107
+ assert.equal(shown.length, units.length)
108
+ })
109
+
110
+ test('页内通知经公共组件:文案与展示期随事件传入', async () => {
111
+ const { mod, shown } = loadClient({ storage: new FakeStorage(), payload: { units, soundMapping: {}, version: 1 } })
112
+ await mod.__test.poll()
113
+ assert.deepEqual(shown.map((call) => call.text), units.map((unit) => unit.text))
114
+ assert.ok(shown.every((call) => call.opts && call.opts.holdMs === 6 * 1000))
115
+ })
116
+
117
+ // ---- 会话行高亮:认领后按投影 session 字段在侧边栏定位行并挂类 ----
118
+
119
+ // 最小侧边栏 DOM:列表容器(多标题)→ 多会话行(各单标题),classList 记录变更,click listener 捕获;
120
+ // className 与 classList 双向同步(对齐真 DOM 语义:任一写入对方可见)
121
+ function makeSidebarDom(rows) {
122
+ const listeners = {}
123
+ const rowEls = rows.map((spec) => {
124
+ const rowClassList = { set: new Set() }
125
+ rowClassList.contains = (c) => rowClassList.set.has(c)
126
+ rowClassList.add = (...cs) => cs.forEach((c) => rowClassList.set.add(c))
127
+ rowClassList.remove = (...cs) => cs.forEach((c) => rowClassList.set.delete(c))
128
+ const sync = function (value) {
129
+ rowClassList.set.clear()
130
+ String(value).split(' ').filter(Boolean).forEach((c) => rowClassList.set.add(c))
131
+ }
132
+ const leaf = { className: 'a_title', textContent: spec.leafText, querySelectorAll: () => [] }
133
+ const row = {
134
+ leafText: spec.leafText,
135
+ textContent: spec.rowText || spec.leafText,
136
+ querySelector: (sel) => (sel.indexOf('_title') >= 0 ? leaf : null),
137
+ querySelectorAll: (sel) => (sel.indexOf('_title') >= 0 ? [leaf] : []),
138
+ children: [], classList: rowClassList,
139
+ }
140
+ Object.defineProperty(row, 'className', {
141
+ get: () => [...rowClassList.set].join(' '),
142
+ set: sync,
143
+ })
144
+ sync('x_row')
145
+ return row
146
+ })
147
+ const leaves = rows.map((spec, index) => rowEls[index].querySelector('[class*="_title"]'))
148
+ const listEl = {
149
+ className: 'x_list',
150
+ // 容器探测要求子树含多个标题,单行场景补哑叶满足
151
+ querySelectorAll: (sel) => (sel.indexOf('_title') >= 0 ? leaves.concat([{ className: 'a_title', textContent: '哑叶', querySelectorAll: () => [] }]) : []),
152
+ children: rowEls,
153
+ }
154
+ const dom = {
155
+ querySelectorAll: (sel) => (sel.indexOf('_list') >= 0 ? [listEl] : []),
156
+ addEventListener: (type, fn) => { (listeners[type] = listeners[type] || []).push(fn) },
157
+ removeEventListener: (type, fn) => { listeners[type] = (listeners[type] || []).filter((f) => f !== fn) },
158
+ listeners,
159
+ rows: rowEls,
160
+ }
161
+ return dom
162
+ }
163
+
164
+ const clickEventOn = (row) => ({ target: { closest: (sel) => (sel === '.tn-sess-hl' ? row : null) } })
165
+
166
+ test('Given 通知带 session 标题 When 认领 Then 会话行挂上高亮类', async () => {
167
+ const dom = makeSidebarDom([{ leafText: '会话甲' }])
168
+ const hlUnits = [
169
+ { id: 'u-hl', category: 'completed', text: '[dsh] 任务完成: 会话甲', session: '会话甲' },
170
+ ]
171
+ const { mod } = loadClient({
172
+ storage: new FakeStorage(),
173
+ payload: { units: hlUnits, soundMapping: {}, version: 1 },
174
+ document: { title: '会话乙 — DeepSeek Harness', ...dom },
175
+ })
176
+ await mod.__test.poll()
177
+ assert.ok(dom.rows[0].classList.set.has('tn-sess-hl'), '会话行未挂高亮类')
178
+ assert.ok(dom.rows[0].classList.set.has('tn-sess-hl--completed'), '高亮类缺少分类色')
179
+ })
180
+
181
+ test('Given 通知的是当前查看的会话 When 认领 Then 不挂高亮', async () => {
182
+ const dom = makeSidebarDom([{ leafText: '会话甲' }])
183
+ const hlUnits = [
184
+ { id: 'u-cur', category: 'completed', text: '[dsh] 任务完成: 会话甲', session: '会话甲' },
185
+ ]
186
+ const { mod } = loadClient({
187
+ storage: new FakeStorage(),
188
+ payload: { units: hlUnits, soundMapping: {}, version: 1 },
189
+ document: { title: '会话甲 — DeepSeek Harness', ...dom },
190
+ })
191
+ await mod.__test.poll()
192
+ assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), false, '当前查看的会话不应闪烁')
193
+ })
194
+
195
+ test('Given 当前会话判定不受标题闪烁前缀干扰 When 认领 Then 仍不挂高亮', async () => {
196
+ const dom = makeSidebarDom([{ leafText: '会话甲' }])
197
+ const hlUnits = [
198
+ { id: 'u-blink', category: 'error', text: '[dsh] 任务出错: 会话甲', session: '会话甲' },
199
+ ]
200
+ const { mod } = loadClient({
201
+ storage: new FakeStorage(),
202
+ payload: { units: hlUnits, soundMapping: {}, version: 1 },
203
+ document: { title: '⏳ 会话甲 — DeepSeek Harness', ...dom },
204
+ })
205
+ await mod.__test.poll()
206
+ assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), false, '闪烁前缀不应破坏当前会话判定')
207
+ })
208
+
209
+ test('Given 失焦且通知的是本标签页会话 When 认领 Then 挂高亮', async () => {
210
+ const dom = makeSidebarDom([{ leafText: '会话甲' }])
211
+ const hlUnits = [
212
+ { id: 'u-away', category: 'completed', text: '[dsh] 任务完成: 会话甲', session: '会话甲' },
213
+ ]
214
+ const { mod } = loadClient({
215
+ storage: new FakeStorage(),
216
+ payload: { units: hlUnits, soundMapping: {}, version: 1 },
217
+ document: { title: '会话甲 — DeepSeek Harness', hasFocus: () => false, ...dom },
218
+ })
219
+ await mod.__test.poll()
220
+ assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), true, '失焦期间的本会话通知应闪烁提醒')
221
+ })
222
+
223
+ test('Given 失焦期间挂上的当前会话高亮 When 页面重新可见 Then 提醒消失且其他会话保留', async () => {
224
+ const dom = makeSidebarDom([{ leafText: '会话甲' }, { leafText: '会话乙' }])
225
+ const hlUnits = [
226
+ { id: 'u-away', category: 'completed', text: '[dsh] 任务完成: 会话甲', session: '会话甲' },
227
+ { id: 'u-other', category: 'ask', text: '[dsh] AI 提问: 会话乙', session: '会话乙' },
228
+ ]
229
+ const { mod, window: winStub, document: docStub } = loadClient({
230
+ storage: new FakeStorage(),
231
+ payload: { units: hlUnits, soundMapping: {}, version: 1 },
232
+ document: { title: '会话甲 — DeepSeek Harness', hasFocus: () => false, ...dom },
233
+ })
234
+ await mod.__test.poll()
235
+ assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), true)
236
+ assert.equal(dom.rows[1].classList.set.has('tn-sess-hl'), true)
237
+ mod.__test.start()
238
+ const visibleHandlers = (dom.listeners.visibilitychange || []).slice()
239
+ assert.ok(visibleHandlers.length > 0, 'visibilitychange 未监听')
240
+ docStub.hidden = false
241
+ for (const fn of visibleHandlers) fn()
242
+ assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), false, '返回后当前会话提醒应消失')
243
+ assert.equal(dom.rows[1].classList.set.has('tn-sess-hl'), true, '其他会话的提醒不应被清除')
244
+ winStub['turn-notify:polling'].abort()
245
+ })
246
+
247
+ test('Given 失焦挂上的当前会话高亮 When 窗口重获焦点 Then 提醒消失', async () => {
248
+ // 对齐 SessionStatusDots 的事实驱动语义:切到别的应用窗口不触发
249
+ // visibilitychange(标签页未切、未最小化),焦点通道负责这一场景
250
+ const dom = makeSidebarDom([{ leafText: '会话甲' }])
251
+ const hlUnits = [
252
+ { id: 'u-foc', category: 'completed', text: '[dsh] 任务完成: 会话甲', session: '会话甲' },
253
+ ]
254
+ const { mod, window: winStub } = loadClient({
255
+ storage: new FakeStorage(),
256
+ payload: { units: hlUnits, soundMapping: {}, version: 1 },
257
+ document: { title: '会话甲 — DeepSeek Harness', hasFocus: () => false, ...dom },
258
+ })
259
+ await mod.__test.poll()
260
+ assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), true)
261
+ mod.__test.start()
262
+ const focusHandlers = (winStub.listeners.focus || []).slice()
263
+ assert.ok(focusHandlers.length > 0, 'window focus 未监听')
264
+ for (const fn of focusHandlers) fn()
265
+ assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), false, '重获焦点后当前会话提醒应消失')
266
+ winStub['turn-notify:polling'].abort()
267
+ })
268
+
269
+ test('Given 行处于运行状态 When 通知认领 Then 不挂高亮', async () => {
270
+ // 对齐 SessionStatusDots:运行中的会话由原生状态点表达,闪烁只表达"状态已更新"
271
+ const dom = makeSidebarDom([{ leafText: '会话甲', rowText: '进行中会话甲 3分钟' }])
272
+ const hlUnits = [
273
+ { id: 'u-run', category: 'completed', text: '[dsh] 任务完成: 会话甲', session: '会话甲' },
274
+ ]
275
+ const { mod } = loadClient({
276
+ storage: new FakeStorage(),
277
+ payload: { units: hlUnits, soundMapping: {}, version: 1 },
278
+ document: dom,
279
+ })
280
+ await mod.__test.poll()
281
+ assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), false, '运行中的会话不应闪烁')
282
+ })
283
+
284
+ test('Given 已挂高亮 When 行进入运行状态 Then 清除闪烁', async () => {
285
+ const dom = makeSidebarDom([{ leafText: '会话甲' }])
286
+ const hlUnits = [
287
+ { id: 'u-done', category: 'completed', text: '[dsh] 任务完成: 会话甲', session: '会话甲' },
288
+ ]
289
+ const { mod } = loadClient({
290
+ storage: new FakeStorage(),
291
+ payload: { units: hlUnits, soundMapping: {}, version: 1 },
292
+ document: dom,
293
+ })
294
+ await mod.__test.poll()
295
+ assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), true)
296
+ // 会话又开跑:状态点文案回到行内
297
+ dom.rows[0].textContent = '进行中会话甲 5分钟'
298
+ await mod.__test.poll()
299
+ assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), false, '会话运行后应停止闪烁')
300
+ })
301
+
302
+ test('Given 通知标题字段缺失 When 认领 Then 不挂高亮类且链路不抛', async () => {
303
+ const dom = makeSidebarDom([{ leafText: '会话甲' }])
304
+ const bareUnits = [
305
+ { id: 'u-bare', category: 'completed', text: '[dsh] 任务完成: 无标题' },
306
+ ]
307
+ const { mod, shown } = loadClient({
308
+ storage: new FakeStorage(),
309
+ payload: { units: bareUnits, soundMapping: {}, version: 1 },
310
+ document: dom,
311
+ })
312
+ await mod.__test.poll()
313
+ assert.equal(shown.length, 1)
314
+ assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), false)
315
+ })
316
+
317
+ test('Given 短标题先于长前缀行匹配 When 挂类 Then 短标题精确命中自身行', async () => {
318
+ // 长标题行在前,短标题是长标题的子串:短标题通知必须挂到全等行,不得被子串误吸
319
+ const dom = makeSidebarDom([
320
+ { leafText: '修复通知插件长轮询问题' },
321
+ { leafText: '通知插件' },
322
+ ])
323
+ const hlUnits = [
324
+ { id: 'u-long', category: 'completed', text: '[dsh] 任务完成: 长标题', session: '修复通知插件长轮询问题' },
325
+ { id: 'u-short', category: 'ask', text: '[dsh] AI 提问: 短标题', session: '通知插件' },
326
+ ]
327
+ const { mod } = loadClient({
328
+ storage: new FakeStorage(),
329
+ payload: { units: hlUnits, soundMapping: {}, version: 1 },
330
+ document: dom,
331
+ })
332
+ await mod.__test.poll()
333
+ assert.ok(dom.rows[0].classList.set.has('tn-sess-hl--completed'), '长标题行未挂类')
334
+ assert.ok(dom.rows[1].classList.set.has('tn-sess-hl--ask'), '短标题行未挂到自身行')
335
+ assert.equal(dom.rows[1].classList.set.has('tn-sess-hl--completed'), false, '长标题类误挂到短标题行')
336
+ assert.equal(dom.rows[0].classList.set.has('tn-sess-hl--ask'), false, '短标题类误挂到长标题行')
337
+ })
338
+
339
+ test('Given 两行各自高亮 When 点击其中一行 Then 仅该行清除且另一行不受影响', async () => {
340
+ const dom = makeSidebarDom([
341
+ { leafText: '修复通知插件长轮询问题' },
342
+ { leafText: '通知插件' },
343
+ ])
344
+ const hlUnits = [
345
+ { id: 'u-long', category: 'completed', text: '[dsh] 任务完成: 长标题', session: '修复通知插件长轮询问题' },
346
+ { id: 'u-short', category: 'ask', text: '[dsh] AI 提问: 短标题', session: '通知插件' },
347
+ ]
348
+ const { mod, window: winStub } = loadClient({
349
+ storage: new FakeStorage(),
350
+ payload: { units: hlUnits, soundMapping: {}, version: 1 },
351
+ document: dom,
352
+ })
353
+ await mod.__test.poll()
354
+ mod.__test.start()
355
+ const clickListeners = dom.listeners.click || []
356
+ assert.ok(clickListeners.length > 0, 'click listener 未挂载')
357
+ for (const fn of clickListeners) fn(clickEventOn(dom.rows[0]))
358
+ assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), false, '点击行未清除')
359
+ assert.equal(dom.rows[1].classList.set.has('tn-sess-hl'), true, '另一行被误清除')
360
+ winStub['turn-notify:polling'].abort()
361
+ })
362
+
363
+ test('announcedIds 去重窗口按 TTL 过期清理', () => {
364
+ const { announcedOnce, announcedIds, ANNOUNCED_TTL_MS } = loadLogic({})
365
+ assert.equal(announcedOnce('a', 1000), true)
366
+ assert.equal(announcedOnce('a', 1000 + ANNOUNCED_TTL_MS - 1), false)
367
+ // 窗口过期后可再次发声,且过期条目被清理
368
+ assert.equal(announcedOnce('a', 1000 + ANNOUNCED_TTL_MS + 1), true)
369
+ assert.equal(announcedIds.size, 1)
370
+ })
371
+
372
+ // ---- 页内提示音场景映射:未配置沿用通知映射,显式覆盖独立生效 ----
373
+
374
+ function loadLogic(storageSeed) {
375
+ const source = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
376
+ const begin = source.indexOf('/* LOGIC-BEGIN */')
377
+ const end = source.indexOf('/* LOGIC-END */')
378
+ const section = source.slice(begin + '/* LOGIC-BEGIN */'.length, end)
379
+ const windowStub = { localStorage: new FakeStorage(storageSeed) }
380
+ const factory = new Function('window', section + '; return { readPageMapping, mergeMapping, resolveSound, DEFAULT_TONES, announcedOnce, announcedIds, ANNOUNCED_TTL_MS }')
381
+ return factory(windowStub)
382
+ }
383
+
384
+ test('Given 页内映射未配置 When 解析页内音色 Then 与通知映射解析一致', () => {
385
+ const logic = loadLogic({})
386
+ const notifyMapping = { completed: 'bell', ask: 'snd-x' }
387
+ const merged = logic.mergeMapping(notifyMapping, logic.readPageMapping())
388
+ assert.deepEqual(logic.resolveSound('completed', merged, ['snd-x']), { kind: 'builtin', name: 'bell' })
389
+ assert.deepEqual(logic.resolveSound('ask', merged, ['snd-x']), { kind: 'custom', id: 'snd-x' })
390
+ })
391
+
392
+ test('Given 页内映射显式覆盖某分类 When 解析页内音色 Then 页内用覆盖值且通知映射不受影响', () => {
393
+ const logic = loadLogic({ 'turn-notify:page-mapping': JSON.stringify({ completed: 'tick' }) })
394
+ const notifyMapping = { completed: 'bell' }
395
+ const merged = logic.mergeMapping(notifyMapping, logic.readPageMapping())
396
+ assert.deepEqual(logic.resolveSound('completed', merged, []), { kind: 'builtin', name: 'tick' })
397
+ // 通知场景解析仍用通知映射(不含页内覆盖)
398
+ assert.deepEqual(logic.resolveSound('completed', notifyMapping, []), { kind: 'builtin', name: 'bell' })
399
+ })
400
+
401
+ test('Given 页内映射存储残留空串值 When 读取 Then 视同未配置沿用通知映射', () => {
402
+ const logic = loadLogic({ 'turn-notify:page-mapping': JSON.stringify({ completed: '' }) })
403
+ assert.deepEqual(logic.readPageMapping(), {})
404
+ const merged = logic.mergeMapping({ completed: 'bell' }, logic.readPageMapping())
405
+ assert.deepEqual(logic.resolveSound('completed', merged, []), { kind: 'builtin', name: 'bell' })
406
+ })
407
+
408
+ test('Given 页内映射存储损坏或死链 When 解析 Then 容错回落且死链值回落内置默认', () => {
409
+ const logic = loadLogic({ 'turn-notify:page-mapping': '{broken' })
410
+ assert.deepEqual(logic.readPageMapping(), {})
411
+ const deadLogic = loadLogic({ 'turn-notify:page-mapping': JSON.stringify({ completed: 'snd-gone' }) })
412
+ const merged = deadLogic.mergeMapping({ completed: 'bell' }, deadLogic.readPageMapping())
413
+ assert.deepEqual(deadLogic.resolveSound('completed', merged, []), { kind: 'builtin', name: deadLogic.DEFAULT_TONES.completed })
414
+ })
415
+
416
+ test('激活即启动轮询:apply 注册设置分区且 start 已执行', async () => {
417
+ const fetched = []
418
+ const { mod } = loadClient({
419
+ storage: new FakeStorage(),
420
+ payload: { units: [], soundMapping: {}, version: 1 },
421
+ onFetch: (path) => { fetched.push(path) },
422
+ })
423
+ const injected = []
424
+ const effects = []
425
+ mod.apply({
426
+ slots: { inject: (name, fn) => { injected.push([name, fn]) } },
427
+ effect: (fn) => { effects.push(fn()) },
428
+ })
429
+ assert.equal(effects.length, 1, '文档级样式未挂载')
430
+ assert.deepEqual(injected.map(([name]) => name), ['settings.section'], '页内通知展示已移交 dsh-toast,不再注入 shell.overlay')
431
+ // start 已执行:音效清单被首拉;轮询定时器 unref,不阻止测试进程退出
432
+ await new Promise((resolve) => { setTimeout(resolve, 0) })
433
+ assert.ok(fetched.indexOf('/api/turn-notify/sounds') >= 0)
434
+ })
435
+
436
+ test('页内提示通道独立开关:关闭后投影事件不再弹页内提示', async () => {
437
+ const storage = new FakeStorage({ 'turn-notify:toast': '0' })
438
+ const { mod, shown } = loadClient({ storage, payload: { units, soundMapping: {}, version: 1 } })
439
+ const { poll } = mod.__test
440
+ await poll()
441
+ assert.equal(shown.length, 0)
442
+ // 完成标记已写:事件被认领消费,仅通道被关
443
+ assert.notEqual(storage.getItem('turn-notify:done:u1'), null)
444
+ })
445
+
446
+ test('分类通知开关串行提交:连点按序入队,host 终值为最后一次点击', async () => {
447
+ const calls = []
448
+ const { mod } = loadClient({ storage: new FakeStorage(), payload: { units: [], soundMapping: {}, version: 1 } })
449
+ const apiImpl = async (path, init) => {
450
+ if (init.method === 'POST') {
451
+ const checked = JSON.parse(init.body).enabled
452
+ await new Promise((resolve) => { setTimeout(resolve, calls.length === 0 ? 30 : 0) })
453
+ // 首个请求最慢:host 到达序即串行化证明,无队列时后发请求将先到
454
+ calls.push(checked)
455
+ return {}
456
+ }
457
+ return {}
458
+ }
459
+ const noop = () => {}
460
+ mod.__test.submitCategoryToggle('completed', false, { apiImpl, onConfig: noop, onError: noop })
461
+ mod.__test.submitCategoryToggle('completed', true, { apiImpl, onConfig: noop, onError: noop })
462
+ mod.__test.submitCategoryToggle('completed', false, { apiImpl, onConfig: noop, onError: noop })
463
+ await mod.__test.submitCategoryToggle('completed', true, { apiImpl, onConfig: noop, onError: noop })
464
+ assert.deepEqual(calls, [
465
+ { completed: false },
466
+ { completed: true },
467
+ { completed: false },
468
+ { completed: true },
469
+ ])
470
+ })
471
+
472
+ test('分类通知开关提交失败:报错并以权威配置纠偏', async () => {
473
+ let failing = true
474
+ const gets = []
475
+ const errors = []
476
+ const configs = []
477
+ const apiImpl = async (path, init) => {
478
+ if (init && init.method === 'POST') {
479
+ if (failing) throw new Error('boom')
480
+ return {}
481
+ }
482
+ gets.push(path)
483
+ return { enabled: { completed: true } }
484
+ }
485
+ const { mod } = loadClient({ storage: new FakeStorage(), payload: { units: [], soundMapping: {}, version: 1 } })
486
+ await mod.__test.submitCategoryToggle('completed', true, {
487
+ apiImpl,
488
+ onConfig: (res) => configs.push(res),
489
+ onError: (text) => errors.push(text),
490
+ })
491
+ assert.equal(errors.length, 1)
492
+ assert.ok(errors[0].indexOf('boom') >= 0)
493
+ assert.deepEqual(gets, ['/api/turn-notify/config'])
494
+ assert.deepEqual(configs, [{ enabled: { completed: true } }])
495
+ })
496
+
497
+ function activate(mod) {
498
+ mod.apply({
499
+ slots: { inject: () => {} },
500
+ effect: (fn) => { fn() },
501
+ })
502
+ }
503
+
504
+ test('长轮询代际中止:abort 后循环退出不再发起新请求', async () => {
505
+ let calls = 0
506
+ const { mod, window: windowStub } = loadClient({
507
+ storage: new FakeStorage(),
508
+ // 桩模拟真实 fetch 的中止语义:代际 signal 触发即 reject,释放循环;仅计投影请求
509
+ fetchImpl: (path, init) => new Promise((resolve, reject) => {
510
+ if (path.indexOf('/sounds') >= 0) {
511
+ resolve({ ok: true, json: async () => ({ sounds: [] }) })
512
+ return
513
+ }
514
+ calls += 1
515
+ init.signal.addEventListener('abort', () => reject(new Error('aborted')))
516
+ }),
517
+ })
518
+ activate(mod)
519
+ await new Promise((resolve) => { setTimeout(resolve, 0) })
520
+ assert.equal(calls, 1, '首拉已发出并挂起')
521
+ windowStub['turn-notify:polling'].abort()
522
+ await new Promise((resolve) => { setTimeout(resolve, 10) })
523
+ assert.equal(calls, 1, '中止后不应再发起新请求')
524
+ })
525
+
526
+ test('长轮询失败:降级通告一次,退避期内不重试不通告第二次', async () => {
527
+ const warns = []
528
+ const original = console.warn
529
+ console.warn = (message) => { warns.push(message) }
530
+ let calls = 0
531
+ try {
532
+ const { mod } = loadClient({
533
+ storage: new FakeStorage(),
534
+ fetchImpl: async (path) => {
535
+ if (path.indexOf('/sounds') >= 0) return { ok: true, json: async () => ({ sounds: [] }) }
536
+ calls += 1
537
+ throw new Error('down')
538
+ },
539
+ })
540
+ activate(mod)
541
+ // 退避起点远大于此等待窗:窗内恰好一次失败与一次通告
542
+ await new Promise((resolve) => { setTimeout(resolve, 100) })
543
+ } finally { console.warn = original }
544
+ assert.equal(calls, 1)
545
+ assert.equal(warns.length, 1)
546
+ assert.ok(warns[0].indexOf('通知降级') >= 0)
547
+ })
548
+
549
+ test('长轮询游标推进:带 version 响应推进 cursor,后续请求携带游标', async () => {
550
+ const paths = []
551
+ const { mod } = loadClient({
552
+ storage: new FakeStorage(),
553
+ fetchImpl: async (path) => {
554
+ paths.push(path)
555
+ return { ok: true, json: async () => ({ units: [], soundMapping: {}, version: 7 }) }
556
+ },
557
+ })
558
+ const { poll } = mod.__test
559
+ assert.equal(await poll(), true, '带 version 响应视为成功')
560
+ assert.equal(await poll(new AbortController().signal), true)
561
+ assert.ok(paths[0].indexOf('?cursor=') < 0, '首拉无游标')
562
+ assert.ok(paths[1].indexOf('?cursor=7') >= 0, '游标随响应推进')
563
+ })
@@ -0,0 +1,31 @@
1
+ // HMR 代际自愈 BDD:client 半区经 HMR/闭包重建重复装载时,新代首挂先释放
2
+ // 旧代资源(轮询长连接 / 高亮 observer / 高亮 listener)再挂新代——
3
+ // 旧代不滞留、不叠加、不跑旧闭包状态。资源段运行于 factory 闭包内(IO),
4
+ // 守卫以源码契约断言锁定形态。
5
+
6
+ import { test } from 'node:test'
7
+ import assert from 'node:assert/strict'
8
+ import { readFileSync } from 'node:fs'
9
+ import { join, dirname } from 'node:path'
10
+ import { fileURLToPath } from 'node:url'
11
+
12
+ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
13
+ const source = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
14
+
15
+ test('轮询代际令牌承载 AbortController:新代启动前中止旧代长轮询', () => {
16
+ assert.match(source, /window\[KEY_POLL_TOKEN\] instanceof AbortController\)\s*window\[KEY_POLL_TOKEN\]\.abort\(\)/, '缺少旧代长轮询中止')
17
+ assert.match(source, /window\[KEY_POLL_TOKEN\] = controller/, 'AbortController 未交接给令牌')
18
+ assert.ok(!source.includes('window[KEY_POLL_TOKEN] = true'), '残留布尔窗棂形态')
19
+ })
20
+
21
+ test('高亮 observer 代际令牌承载 observer:新代先断开旧代', () => {
22
+ assert.match(source, /window\[KEY_HL_OBSERVER\]\.disconnect\(\)/, '缺少旧代 observer 断开')
23
+ assert.match(source, /window\[KEY_HL_OBSERVER\] = observer/, 'observer 未交接给令牌')
24
+ assert.ok(!source.includes('window[KEY_HL_OBSERVER] = true'), '残留布尔窗棂形态')
25
+ })
26
+
27
+ test('高亮 listener 代际令牌承载函数引用:新代先移除旧代', () => {
28
+ assert.match(source, /removeEventListener\('click', window\[KEY_HL_LISTENER\], true\)/, '缺少旧代 listener 移除')
29
+ assert.match(source, /window\[KEY_HL_LISTENER\] = listener/, 'listener 未交接给令牌')
30
+ assert.ok(!source.includes('window[KEY_HL_LISTENER] = true'), '残留布尔窗棂形态')
31
+ })
@@ -0,0 +1,76 @@
1
+ // IM 投递目标列表操作:多 bot 绑定、取消注册、勾选幂等。
2
+ // core.mjs 为权威实现;client.js LOGIC 段镜像,parity.test.mjs 保证不漂移。
3
+ import test from 'node:test'
4
+ import assert from 'node:assert/strict'
5
+
6
+ import {
7
+ imTargetKeyOf,
8
+ toggleImTargetList,
9
+ removeImTargetFromList,
10
+ unregisterImBotList,
11
+ imBoundBotIds,
12
+ normalizeImTargets,
13
+ } from '../src/core.mjs'
14
+
15
+ const A = { botId: 'wx_aaa', targetId: 'tgt_1' }
16
+ const B = { botId: 'wx_aaa', targetId: 'tgt_2' }
17
+ const C = { botId: 'wx_bbb', targetId: 'tgt_1' }
18
+
19
+ test('imTargetKeyOf:botId+targetId 拼接,跨 bot 同 targetId 键不同', () => {
20
+ assert.equal(imTargetKeyOf(A), 'wx_aaa/tgt_1')
21
+ assert.notEqual(imTargetKeyOf(A), imTargetKeyOf(C))
22
+ })
23
+
24
+ test('toggleImTargetList:勾选追加,重复勾选幂等(单份,位置在尾)', () => {
25
+ assert.deepEqual(toggleImTargetList([], 'wx_aaa', 'tgt_1', true), [A])
26
+ const once = toggleImTargetList([], 'wx_aaa', 'tgt_1', true)
27
+ assert.deepEqual(toggleImTargetList(once, 'wx_aaa', 'tgt_1', true), [A])
28
+ const two = toggleImTargetList(once, 'wx_bbb', 'tgt_1', true)
29
+ assert.deepEqual(two, [A, C])
30
+ })
31
+
32
+ test('toggleImTargetList:取消勾选只移除目标项,其余保序', () => {
33
+ const list = [A, B, C]
34
+ assert.deepEqual(toggleImTargetList(list, 'wx_aaa', 'tgt_2', false), [A, C])
35
+ assert.deepEqual(toggleImTargetList(list, 'wx_nnn', 'tgt_9', false), list)
36
+ })
37
+
38
+ test('toggleImTargetList:入参列表不被修改(纯函数)', () => {
39
+ const list = [A]
40
+ toggleImTargetList(list, 'wx_bbb', 'tgt_1', true)
41
+ toggleImTargetList(list, 'wx_aaa', 'tgt_1', false)
42
+ assert.deepEqual(list, [A])
43
+ })
44
+
45
+ test('removeImTargetFromList:按键移除单项,其余保序', () => {
46
+ const list = [A, B, C]
47
+ assert.deepEqual(removeImTargetFromList(list, 'wx_aaa', 'tgt_2'), [A, C])
48
+ assert.deepEqual(removeImTargetFromList(list, 'wx_nnn', 'tgt_9'), list)
49
+ })
50
+
51
+ test('unregisterImBotList:移除该 bot 全部目标,其他 bot 保留', () => {
52
+ const list = [A, B, C]
53
+ assert.deepEqual(unregisterImBotList(list, 'wx_aaa'), [C])
54
+ assert.deepEqual(unregisterImBotList(list, 'wx_bbb'), [A, B])
55
+ assert.deepEqual(unregisterImBotList(list, 'wx_nnn'), list)
56
+ })
57
+
58
+ test('unregisterImBotList:唯一 bot 清空后列表为空(取消注册即全清)', () => {
59
+ assert.deepEqual(unregisterImBotList([A, B], 'wx_aaa'), [])
60
+ })
61
+
62
+ test('imBoundBotIds:首次绑定顺序去重', () => {
63
+ assert.deepEqual(imBoundBotIds([A, B, C]), ['wx_aaa', 'wx_bbb'])
64
+ assert.deepEqual(imBoundBotIds([C, A, B]), ['wx_bbb', 'wx_aaa'])
65
+ assert.deepEqual(imBoundBotIds([]), [])
66
+ })
67
+
68
+ test('imBoundBotIds:与取消注册组合,chip 随最后一项消失', () => {
69
+ const list = [A, B, C]
70
+ assert.deepEqual(imBoundBotIds(unregisterImBotList(list, 'wx_aaa')), ['wx_bbb'])
71
+ assert.deepEqual(imBoundBotIds(unregisterImBotList(list, 'wx_bbb')), ['wx_aaa'])
72
+ })
73
+
74
+ test('normalizeImTargets:跨 bot 同 targetId 均保留(合法,dsh-im 唯一性仅限单 bot)', () => {
75
+ assert.deepEqual(normalizeImTargets([A, C]), [A, C])
76
+ })