@mzzsfy/dsh-turn-notify 0.10.0 → 0.11.1

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,405 @@
1
+ // 工作区文件夹运行标记:纯函数投影切片 + effect 扫描接线测试。
2
+ // 扫描逻辑闭包在 apply 的 effect 内,经捕获 effect 函数 + DOM 桩驱动,
3
+ // 锁定数量不变量守卫、下标对位、开关兜底与卸载清理。
4
+ import test from 'node:test'
5
+ import assert from 'node:assert/strict'
6
+ import { readFileSync } from 'node:fs'
7
+ import { fileURLToPath } from 'node:url'
8
+ import { dirname, join } from 'node:path'
9
+ import { mock } from 'node:test'
10
+
11
+ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
12
+ const CLIENT_SRC = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
13
+
14
+ // ── 纯函数切片:常量与 folderRunningState(无外部依赖) ──
15
+
16
+ const PURE_START = CLIENT_SRC.indexOf('const FOLD_SECTION_SELECTOR')
17
+ const PURE_END = CLIENT_SRC.indexOf('// ---- 点击直达会话')
18
+ assert.ok(PURE_START >= 0 && PURE_END > PURE_START, 'client.js 文件夹运行标记纯函数切片定位失败')
19
+ const pure = new Function(
20
+ CLIENT_SRC.slice(PURE_START, PURE_END)
21
+ + '; return { folderRunningState: folderRunningState, sectionSelector: FOLD_SECTION_SELECTOR, runningFlag: FOLD_RUNNING_FLAG, scanDelayMs: FOLD_SCAN_DELAY_MS }',
22
+ )()
23
+
24
+ const WS = (id, sessionIds) => ({ workspaceId: id, sessionIds })
25
+ const ROW = (id, extra) => ({ id, ...extra })
26
+
27
+ test('folderRunningState 主路径:运行中会话所在工作区入集合,非运行中不入', () => {
28
+ const sessions = {
29
+ ids: ['a', 'b'],
30
+ byId: { a: ROW('a', { running: true }), b: ROW('b', { running: false }) },
31
+ }
32
+ const workspaces = { items: [WS('w1', ['a']), WS('w2', ['b'])], archivedSessionIds: [] }
33
+ const state = pure.folderRunningState(sessions, workspaces)
34
+ assert.deepEqual(state.ids, ['w1', 'w2'])
35
+ assert.deepEqual([...state.running], ['w1'])
36
+ assert.equal(state.ungrouped, false)
37
+ })
38
+
39
+ test('folderRunningState 未分组:游离运行中会话使末位未分组桶标记', () => {
40
+ const sessions = {
41
+ ids: ['a', 'stray'],
42
+ byId: { a: ROW('a', { running: false }), stray: ROW('stray', { running: true }) },
43
+ }
44
+ const workspaces = { items: [WS('w1', ['a'])], archivedSessionIds: [] }
45
+ const state = pure.folderRunningState(sessions, workspaces)
46
+ assert.equal(state.running.has('w1'), false)
47
+ assert.equal(state.ungrouped, true)
48
+ })
49
+
50
+ test('folderRunningState 排除项:subagent、已归档、空白非当前不标记,空白当前例外', () => {
51
+ const sessions = {
52
+ ids: ['sub', 'arch', 'blank', 'cur'],
53
+ byId: {
54
+ sub: ROW('sub', { running: true, origin: 'subagent' }),
55
+ arch: ROW('arch', { running: true }),
56
+ blank: ROW('blank', { running: true, blank: true }),
57
+ cur: ROW('cur', { running: true, blank: true }),
58
+ },
59
+ current: 'cur',
60
+ }
61
+ const workspaces = {
62
+ items: [WS('w1', ['sub']), WS('w2', ['arch']), WS('w3', ['blank']), WS('w4', ['cur'])],
63
+ archivedSessionIds: ['arch'],
64
+ }
65
+ const state = pure.folderRunningState(sessions, workspaces)
66
+ assert.equal(state.running.has('w1'), false, 'subagent 运行不标记')
67
+ assert.equal(state.running.has('w2'), false, '已归档运行不标记')
68
+ assert.equal(state.running.has('w3'), false, '空白非当前运行不标记')
69
+ assert.equal(state.running.has('w4'), true, '空白且为当前会话标记')
70
+ })
71
+
72
+ test('folderRunningState 缺省守卫:快照缺失或账本悬空不抛错', () => {
73
+ assert.deepEqual(pure.folderRunningState(undefined, undefined), { ids: [], running: new Set(), ungrouped: false })
74
+ const state = pure.folderRunningState({ ids: ['gone'], byId: {} }, { items: [WS('w1', ['gone'])] })
75
+ assert.deepEqual([...state.running], [])
76
+ assert.equal(state.ungrouped, false)
77
+ })
78
+
79
+ // ── effect 扫描接线:DOM 桩 + 捕获 effect 函数驱动 ──
80
+
81
+ const SCAN_SETTLE_MS = pure.scanDelayMs * 4
82
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
83
+
84
+ // 组容器桩:属性集合语义与 Element 同构
85
+ function sectionStub() {
86
+ const attrs = new Set()
87
+ return {
88
+ attrs,
89
+ setAttribute: (name) => attrs.add(name),
90
+ removeAttribute: (name) => attrs.delete(name),
91
+ hasAttribute: (name) => attrs.has(name),
92
+ }
93
+ }
94
+
95
+ // 加载 client.js 并执行 apply(mock 最小服务面),effect 函数捕获不执行;
96
+ // MutationObserver 经工厂形参注入(源码自由变量),document 桩承载组容器查询;
97
+ // 通知长轮询经挂起 fetch 静默(测试只驱动 fold effect,不消费投影)
98
+ function loadClient() {
99
+ const modules = []
100
+ const effects = []
101
+ const windowStub = {
102
+ __ModuleLoader__: { load: (module) => modules.push(module) },
103
+ addEventListener: () => {},
104
+ removeEventListener: () => {},
105
+ }
106
+ const reactStub = {
107
+ useState: (value) => [value, () => {}],
108
+ useEffect: () => {},
109
+ useSyncExternalStore: () => [],
110
+ useRef: (value) => ({ current: value }),
111
+ createElement: () => null,
112
+ }
113
+ const requireStub = (name) => {
114
+ if (name === '@mzzsfy/dsh-toast/client') return { show: () => 0 }
115
+ return reactStub
116
+ }
117
+ const sections = []
118
+ const documentStub = {
119
+ documentElement: {},
120
+ createElement: () => ({ style: {}, remove() {} }),
121
+ head: { appendChild: () => {} },
122
+ body: { appendChild: () => {} },
123
+ addEventListener: () => {},
124
+ removeEventListener: () => {},
125
+ querySelectorAll: (selector) => {
126
+ if (selector === pure.sectionSelector) return [...sections]
127
+ if (selector === '[' + pure.runningFlag + ']') return sections.filter((section) => section.hasAttribute(pure.runningFlag))
128
+ return []
129
+ },
130
+ }
131
+ class MutationObserverStub {
132
+ observe() {}
133
+ disconnect() {}
134
+ }
135
+ // 不传 fetch 工厂参数:源码内裸 fetch 解析到 globalThis.fetch,由 withFetch 显式 mock;
136
+ // 传参数会把 fetch 绑定进沙箱,mock 永远打不到
137
+ const factory = new Function('window', 'require', 'document', 'MutationObserver', CLIENT_SRC + '\n;return null')
138
+ factory(windowStub, requireStub, documentStub, MutationObserverStub)
139
+ assert.equal(modules.length, 1, 'client.js 模块未被捕获')
140
+ const mod = modules[0].factory(requireStub)
141
+
142
+ // 快照源桩:监听器捕获 + emit,供测试模拟服务变更触发的重扫(生产由订阅驱动)
143
+ let sessionsState = { ids: [], byId: {} }
144
+ let workspacesState = undefined
145
+ function sourceStub(getSnapshot) {
146
+ const listeners = new Set()
147
+ return {
148
+ list: {
149
+ subscribe: (cb) => {
150
+ listeners.add(cb)
151
+ return () => listeners.delete(cb)
152
+ },
153
+ getSnapshot,
154
+ },
155
+ emit: () => {
156
+ for (const cb of [...listeners]) cb()
157
+ },
158
+ }
159
+ }
160
+ const sessions = { binding: () => undefined, ...sourceStub(() => sessionsState) }
161
+ const workspaces = sourceStub(() => workspacesState)
162
+ const ctx = {
163
+ get: (name) => (name === 'sessions' ? sessions : name === 'workspaces' ? workspaces : {}),
164
+ effect: (fn, tag) => effects.push({ fn, tag }),
165
+ slots: { inject: () => {}, register: () => {} },
166
+ }
167
+ mod.apply(ctx)
168
+ const entry = effects.find((item) => item.tag === 'turn-notify folder running mark')
169
+ assert.ok(entry, 'apply 未挂文件夹运行标记 effect')
170
+ return {
171
+ sections,
172
+ runEffect: entry.fn,
173
+ emitSessions: sessions.emit,
174
+ setSessions: (next) => { sessionsState = next },
175
+ setWorkspaces: (next) => { workspacesState = next },
176
+ }
177
+ }
178
+
179
+ // 两工作区一运行一的基准快照
180
+ function baseSnapshots(client) {
181
+ client.setSessions({
182
+ ids: ['a', 'b'],
183
+ byId: { a: ROW('a', { running: true }), b: ROW('b', { running: false }) },
184
+ })
185
+ client.setWorkspaces({ items: [WS('w1', ['a']), WS('w2', ['b'])], archivedSessionIds: [] })
186
+ }
187
+
188
+ async function withFetch(implOrPayload, run) {
189
+ // mock.method 返回被 mock 的函数,还原须经 .mock.restore(),不可直接调用返回值;
190
+ // 传函数即显式 fetch 实现(构造拉取失败),传对象即固定响应,undefined 即固定
191
+ // 成功响应 {folderRunningEnabled:true}——默认路径显式 mock,不依赖运行环境对相对
192
+ // URL 的 fetch 行为(隐式环境耦合会随 Node 升级漂移);
193
+ // 通知长轮询路径恒返回挂起 Promise,测试期间不产生投影循环
194
+ const impl = typeof implOrPayload === 'function'
195
+ ? async (path) => (String(path).indexOf('/api/turn-notify/projection') === 0 ? new Promise(() => {}) : implOrPayload(path))
196
+ : async (path) => {
197
+ if (String(path).indexOf('/api/turn-notify/projection') === 0) return new Promise(() => {})
198
+ return { ok: true, json: async () => (implOrPayload === undefined ? { folderRunningEnabled: true } : implOrPayload) }
199
+ }
200
+ const mocked = mock.method(globalThis, 'fetch', impl)
201
+ try {
202
+ await run()
203
+ } finally {
204
+ mocked.mock.restore()
205
+ }
206
+ }
207
+
208
+ test('Given 两工作区组容器与快照同序, When 扫描, Then 运行中工作区组容器带标记', async () => {
209
+ await withFetch(undefined, async () => {
210
+ const client = loadClient()
211
+ baseSnapshots(client)
212
+ const [s1, s2] = [sectionStub(), sectionStub()]
213
+ client.sections.push(s1, s2)
214
+ client.runEffect()
215
+ await sleep(SCAN_SETTLE_MS)
216
+ assert.ok(s1.hasAttribute(pure.runningFlag), '运行中工作区组容器应带标记')
217
+ assert.equal(s2.hasAttribute(pure.runningFlag), false, '空闲工作区组容器不带标记')
218
+ })
219
+ })
220
+
221
+ test('Given 末位未分组桶存在, When 扫描, Then 组容器数 = 工作区数+1 且游离运行中落末位', async () => {
222
+ await withFetch(undefined, async () => {
223
+ const client = loadClient()
224
+ baseSnapshots(client)
225
+ client.setSessions({
226
+ ids: ['a', 'b', 'stray'],
227
+ byId: { a: ROW('a', { running: true }), b: ROW('b', { running: false }), stray: ROW('stray', { running: true }) },
228
+ })
229
+ const [s1, s2, s3] = [sectionStub(), sectionStub(), sectionStub()]
230
+ client.sections.push(s1, s2, s3)
231
+ client.runEffect()
232
+ await sleep(SCAN_SETTLE_MS)
233
+ assert.ok(s1.hasAttribute(pure.runningFlag), '首工作区(运行中)带标记')
234
+ assert.equal(s2.hasAttribute(pure.runningFlag), false, '次工作区(空闲)不带标记')
235
+ assert.ok(s3.hasAttribute(pure.runningFlag), '末位未分组桶(游离运行中)带标记')
236
+ })
237
+ })
238
+
239
+ test('Given 组容器数与不变量漂移(单列表/搜索态/结构变更), When 扫描, Then 清除全部标记不误标', async () => {
240
+ await withFetch(undefined, async () => {
241
+ const client = loadClient()
242
+ baseSnapshots(client)
243
+ const [s1, s2] = [sectionStub(), sectionStub()]
244
+ client.sections.push(s1, s2)
245
+ client.runEffect()
246
+ await sleep(SCAN_SETTLE_MS)
247
+ assert.ok(s1.hasAttribute(pure.runningFlag), '前置:正常对位时首组带标记')
248
+ // 漂移:两工作区只剩一个组容器(或多余两个)均不匹配不变量
249
+ client.sections.length = 0
250
+ client.sections.push(s1)
251
+ client.emitSessions()
252
+ await sleep(SCAN_SETTLE_MS)
253
+ assert.equal(s1.hasAttribute(pure.runningFlag), false, '数量漂移后标记被清除')
254
+ })
255
+ })
256
+
257
+ test('Given 开关关闭(拉取返回 folderRunningEnabled:false), When 扫描, Then 不标记且既有标记清除', async () => {
258
+ await withFetch({ folderRunningEnabled: false }, async () => {
259
+ const client = loadClient()
260
+ baseSnapshots(client)
261
+ const s1 = sectionStub()
262
+ client.sections.push(s1)
263
+ client.runEffect()
264
+ await sleep(SCAN_SETTLE_MS)
265
+ assert.equal(s1.hasAttribute(pure.runningFlag), false, '停用后不新增标记')
266
+ })
267
+ })
268
+
269
+ test('Given 开关拉取失败, When 扫描, Then 按启用兜底照常标记', async () => {
270
+ await withFetch(async (path) => {
271
+ if (String(path).indexOf('/api/turn-notify/projection') === 0) return new Promise(() => {})
272
+ throw new Error('network down')
273
+ }, async () => {
274
+ const client = loadClient()
275
+ baseSnapshots(client)
276
+ const [s1, s2] = [sectionStub(), sectionStub()]
277
+ client.sections.push(s1, s2)
278
+ client.runEffect()
279
+ await sleep(SCAN_SETTLE_MS)
280
+ assert.ok(s1.hasAttribute(pure.runningFlag), '拉取失败按启用兜底(默认启用)')
281
+ assert.equal(s2.hasAttribute(pure.runningFlag), false, '兜底不改变运行态判定')
282
+ })
283
+ })
284
+
285
+ test('Given 运行态变化经服务订阅到达(折叠组无 DOM 变更), When 快照更新, Then 标记跟随', async () => {
286
+ await withFetch(undefined, async () => {
287
+ const client = loadClient()
288
+ baseSnapshots(client)
289
+ const [s1, s2] = [sectionStub(), sectionStub()]
290
+ client.sections.push(s1, s2)
291
+ client.runEffect()
292
+ await sleep(SCAN_SETTLE_MS)
293
+ assert.ok(s1.hasAttribute(pure.runningFlag), '前置:首工作区运行中')
294
+ // 唯一运行会话转入另一工作区(模拟会话被归档/挂到他处后的快照差)
295
+ client.setSessions({
296
+ ids: ['a', 'b'],
297
+ byId: { a: ROW('a', { running: false }), b: ROW('b', { running: true }) },
298
+ })
299
+ client.emitSessions()
300
+ await sleep(SCAN_SETTLE_MS)
301
+ assert.equal(s1.hasAttribute(pure.runningFlag), false, '原运行工作区标记消除')
302
+ assert.ok(s2.hasAttribute(pure.runningFlag), '新运行工作区获得标记')
303
+ })
304
+ })
305
+
306
+ test('Given effect 卸载, When 清理执行, Then 标记移除且资源退订', async () => {
307
+ await withFetch(undefined, async () => {
308
+ const client = loadClient()
309
+ baseSnapshots(client)
310
+ const [s1, s2] = [sectionStub(), sectionStub()]
311
+ client.sections.push(s1, s2)
312
+ const cleanup = client.runEffect()
313
+ await sleep(SCAN_SETTLE_MS)
314
+ assert.ok(s1.hasAttribute(pure.runningFlag), '前置:标记已应用')
315
+ cleanup()
316
+ assert.equal(s1.hasAttribute(pure.runningFlag), false, '卸载清除运行中工作区的标记')
317
+ assert.equal(s2.hasAttribute(pure.runningFlag), false, '卸载不残留其他标记')
318
+ })
319
+ })
320
+
321
+ test('Given 无工作区且游离会话运行, When 扫描, Then 末位未分组桶正确标记', async () => {
322
+ await withFetch(undefined, async () => {
323
+ const client = loadClient()
324
+ client.setSessions({ ids: ['stray'], byId: { stray: ROW('stray', { running: true }) } })
325
+ client.setWorkspaces({ items: [], archivedSessionIds: [] })
326
+ const s1 = sectionStub()
327
+ client.sections.push(s1)
328
+ client.runEffect()
329
+ await sleep(SCAN_SETTLE_MS)
330
+ assert.ok(s1.hasAttribute(pure.runningFlag), '零工作区时组容器数 1 = 工作区数 0 + 1,末位按未分组桶标记')
331
+ })
332
+ })
333
+
334
+ test('Given 组容器数多于不变量上界, When 扫描, Then 清除全部标记不误标', async () => {
335
+ await withFetch(undefined, async () => {
336
+ const client = loadClient()
337
+ baseSnapshots(client)
338
+ // 数量漂移「变多」:两工作区出现四个组容器(> 工作区数+1)
339
+ const sections = [sectionStub(), sectionStub(), sectionStub(), sectionStub()]
340
+ client.sections.push(...sections)
341
+ client.runEffect()
342
+ await sleep(SCAN_SETTLE_MS)
343
+ for (const [index, section] of sections.entries()) {
344
+ assert.equal(section.hasAttribute(pure.runningFlag), false, '容器 ' + index + ' 数量漂移时不带标记')
345
+ }
346
+ })
347
+ })
348
+
349
+ test('Given 拉取未完成即卸载, When 后续拉取完成并触发调度, Then 标记不复活', async () => {
350
+ // 拉取挂起(永不resolve),标记先经拉取完成前的唯一路径产生:
351
+ // 未决期 scan 仅清除,故先注入「已启用 + 已标记」状态再卸载
352
+ let release
353
+ await withFetch((path) => {
354
+ if (String(path).indexOf('/api/turn-notify/projection') === 0) return new Promise(() => {})
355
+ return new Promise((resolve) => { release = resolve })
356
+ }, async () => {
357
+ const client = loadClient()
358
+ baseSnapshots(client)
359
+ const s1 = sectionStub()
360
+ client.sections.push(s1)
361
+ const cleanup = client.runEffect()
362
+ await sleep(SCAN_SETTLE_MS)
363
+ assert.equal(s1.hasAttribute(pure.runningFlag), false, '拉取未决期间不产生标记')
364
+ // 模拟标记已在(如卸载前最后一次 scan 的残留):卸载后拉取完成不得复活
365
+ s1.setAttribute(pure.runningFlag, '')
366
+ cleanup()
367
+ release({ ok: true, json: async () => ({ folderRunningEnabled: true }) })
368
+ await sleep(SCAN_SETTLE_MS)
369
+ assert.equal(s1.hasAttribute(pure.runningFlag), false, '卸载后拉取完成不复活标记')
370
+ })
371
+ })
372
+
373
+ test('Given 开关停用, When 拉取完成前的首次扫描, Then 不产生标记(未决期仅清除)', async () => {
374
+ // 拉取延迟释放(3 个扫描窗口后返回停用),未决期内的扫描不得标记
375
+ await withFetch((path) => new Promise((resolve) => {
376
+ if (String(path).indexOf('/api/turn-notify/projection') === 0) return
377
+ setTimeout(() => resolve({ ok: true, json: async () => ({ folderRunningEnabled: false }) }), pure.scanDelayMs * 3)
378
+ }), async () => {
379
+ const client = loadClient()
380
+ baseSnapshots(client)
381
+ const s1 = sectionStub()
382
+ client.sections.push(s1)
383
+ client.runEffect()
384
+ await sleep(SCAN_SETTLE_MS)
385
+ assert.equal(s1.hasAttribute(pure.runningFlag), false, '拉取未决期首次扫描不标记')
386
+ await sleep(SCAN_SETTLE_MS * 2)
387
+ assert.equal(s1.hasAttribute(pure.runningFlag), false, '拉取返回停用后维持无标记')
388
+ })
389
+ })
390
+
391
+ // ── 源码契约:CSS 规则、面板开关挂载、选择器与标记属性 ──
392
+
393
+ test('源码契约:运行点与图标着色规则、官方组容器锚点、面板开关挂载齐备', () => {
394
+ assert.ok(CLIENT_SRC.includes('div[class$="_groupSection"][data-tn-folder-running]'), '缺组容器标记 CSS 锚点')
395
+ assert.ok(CLIENT_SRC.includes('var(--dsw-static-deepseek-450)'), '运行点色源应为官方 ongoing 实际色值(--dsh-state-ongoing 仅存在于官方状态点组件局部,组容器读不到)')
396
+ assert.ok(!CLIENT_SRC.includes('--tn-fold-running-color: var(--dsh-state-ongoing'), '死变量引用:组容器上下文读不到 --dsh-state-ongoing,恒走回退源,不得引入永不生效的引用')
397
+ assert.ok(CLIENT_SRC.includes('[class$="_folderActive"]'), '图标着色应让位官方 folderActive 态')
398
+ assert.ok(CLIENT_SRC.includes('tn-fold-running-pulse'), '缺运行点脉冲动画')
399
+ assert.ok(CLIENT_SRC.includes('prefers-reduced-motion'), '动画应尊重减少动态偏好')
400
+ assert.ok(CLIENT_SRC.includes('toggleFolderRunning'), '面板缺运行标记开关处理函数')
401
+ assert.ok(CLIENT_SRC.includes('title: FOLD_SWITCH_TITLE'), '开关行缺悬停说明挂载')
402
+ assert.ok(CLIENT_SRC.includes('const FOLD_SWITCH_TITLE'), '缺悬停说明常量')
403
+ // 开关经既有 config 路由读写:不新增路由,host 与 client 字面量一致
404
+ assert.ok(!CLIENT_SRC.includes("'/api/turn-notify/folder-running"), '运行标记开关不得另开路由(经 config 路由读写)')
405
+ })
@@ -661,6 +661,7 @@ test('配置解析:enabled 缺省键按开补全,字段类型回退默认', () =
661
661
  suppressSubagentWake: true,
662
662
  hostNotify: false,
663
663
  hostNotifyFallback: false,
664
+ folderRunningEnabled: true,
664
665
  enabled: { completed: false, error: true, interrupted: true, approval: true, ask: true, 'max-tokens': true },
665
666
  soundMapping: { completed: 'snd-1' },
666
667
  imTargets: [],
@@ -674,6 +675,7 @@ test('配置解析:enabled 缺省键按开补全,字段类型回退默认', () =
674
675
  suppressSubagentWake: true,
675
676
  hostNotify: false,
676
677
  hostNotifyFallback: false,
678
+ folderRunningEnabled: true,
677
679
  enabled: Object.fromEntries(CATEGORIES.map((name) => [name, true])),
678
680
  soundMapping: {},
679
681
  imTargets: [],
@@ -692,6 +694,7 @@ test('面板可见配置:webhookUrl 原文回显', () => {
692
694
  suppressSubagentWake: true,
693
695
  hostNotify: false,
694
696
  hostNotifyFallback: false,
697
+ folderRunningEnabled: true,
695
698
  enabled: { completed: false, error: true, interrupted: true, approval: true, ask: true, 'max-tokens': true },
696
699
  soundMapping: { completed: 'snd-1' },
697
700
  imTargets: [],
@@ -45,14 +45,15 @@ function loadClient({ storage, payload, onFetch, fetchImpl, document: documentOv
45
45
  listeners: windowListeners,
46
46
  localStorage: storage,
47
47
  }
48
+ const styleTags = []
48
49
  const documentStub = {
49
50
  hasFocus: () => true,
50
51
  // 默认桩:聚焦窗口恒可见(浏览器不变式);聚焦静默判定为可见+焦点双条件
51
52
  hidden: false,
52
53
  title: 'dsh',
53
- createElement: () => ({ style: {}, remove() {} }),
54
+ createElement: () => ({ style: {}, attrs: {}, setAttribute(name, value) { this.attrs[name] = value }, remove() {} }),
54
55
  body: { appendChild: () => {} },
55
- head: { appendChild: () => {} },
56
+ head: { appendChild: (node) => { styleTags.push(node) } },
56
57
  ...documentOverride,
57
58
  }
58
59
  const reactStub = { useState: (value) => [value, () => {}], useEffect: () => {}, useSyncExternalStore: () => [] }
@@ -85,7 +86,7 @@ function loadClient({ storage, payload, onFetch, fetchImpl, document: documentOv
85
86
  )
86
87
  assert.equal(modules.length, 1, 'client.js 模块未被捕获')
87
88
  // load({id, factory}) 结构:再调 factory(require) 得到真正的模块对象
88
- return { mod: modules[0].factory(requireStub), shown, window: windowStub, document: documentStub }
89
+ return { mod: modules[0].factory(requireStub), shown, window: windowStub, document: documentStub, styleTags }
89
90
  }
90
91
 
91
92
  const units = [
@@ -718,18 +719,23 @@ test('Given 页内映射存储损坏或死链 When 解析 Then 容错回落且
718
719
 
719
720
  test('激活即启动轮询:apply 注册设置分区且 start 已执行', async () => {
720
721
  const fetched = []
721
- const { mod } = loadClient({
722
+ const { mod, styleTags } = loadClient({
722
723
  storage: new FakeStorage(),
723
724
  payload: { units: [], soundMapping: {}, version: 1 },
724
725
  onFetch: (path) => { fetched.push(path) },
725
726
  })
726
727
  const injected = []
727
728
  const effects = []
729
+ // sessions/workspaces 服务桩:文件夹运行标记的注入面(订阅桩不做任何事;
730
+ // 本测试 document 桩无 querySelectorAll,fold effect 装配即干净退出)
731
+ const serviceStub = { list: { subscribe: () => () => {}, getSnapshot: () => ({}) } }
728
732
  mod.apply({
733
+ get: (name) => (name === 'sessions' || name === 'workspaces' ? serviceStub : undefined),
729
734
  slots: { inject: (name, fn) => { injected.push([name, fn]) } },
730
735
  effect: (fn) => { effects.push(fn()) },
731
736
  })
732
- assert.equal(effects.length, 1, '文档级样式未挂载')
737
+ assert.equal(effects.length, 2, '文档级样式与文件夹运行标记两个 effect 均已挂载')
738
+ assert.equal(styleTags[0].attrs['data-plugin'], '@mzzsfy/dsh-turn-notify', '样式自带 data-plugin,防宿主 claimStyles 误归属后随他插件 HMR 整批误删')
733
739
  assert.deepEqual(injected.map(([name]) => name), ['settings.section'], '页内通知展示已移交 dsh-toast,不再注入 shell.overlay')
734
740
  // start 已执行:音效清单被首拉;轮询定时器 unref,不阻止测试进程退出
735
741
  await new Promise((resolve) => { setTimeout(resolve, 0) })
@@ -799,7 +805,11 @@ test('分类通知开关提交失败:报错并以权威配置纠偏', async () =
799
805
  })
800
806
 
801
807
  function activate(mod) {
808
+ // sessions/workspaces 桩:apply 的注入面声明;fold effect 在无 querySelectorAll
809
+ // 的 document 桩上装配即退出,不触碰订阅
810
+ const serviceStub = { list: { subscribe: () => () => {}, getSnapshot: () => ({}) } }
802
811
  mod.apply({
812
+ get: (name) => (name === 'sessions' || name === 'workspaces' ? serviceStub : undefined),
803
813
  slots: { inject: () => {} },
804
814
  effect: (fn) => { fn() },
805
815
  })