@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 +3 -3
- package/package.json +1 -1
- package/src/client.js +31 -5
- package/test/flow.test.mjs +143 -2
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
|
-
-
|
|
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
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)
|
|
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
|
-
|
|
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)
|
package/test/flow.test.mjs
CHANGED
|
@@ -26,8 +26,15 @@ class BrokenStorage {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
// 以注入的 stub 加载真实 src/client.js,返回捕获的模块对象、页内通知捕获表与 window 桩。
|
|
29
|
-
|
|
30
|
-
|
|
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)
|