@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.
- package/README.md +146 -0
- package/cordis.patch.yml +13 -0
- package/package.json +50 -0
- package/src/client.js +1857 -0
- package/src/core.mjs +583 -0
- package/src/index.js +660 -0
- package/test/client-id.test.mjs +16 -0
- package/test/core.test.mjs +736 -0
- package/test/event-wiring.test.mjs +585 -0
- package/test/flow.test.mjs +563 -0
- package/test/hmr-heal.test.mjs +31 -0
- package/test/im-targets.test.mjs +76 -0
- package/test/parity.test.mjs +312 -0
- package/test/rename.route.test.mjs +235 -0
- package/test/route.test.mjs +377 -0
- package/test/sounds.route.test.mjs +257 -0
- package/test/switch-guard.test.mjs +32 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// parity 测试:client.js LOGIC 标记段与 src/core.mjs 同源逻辑对照(think-expand 模式)。
|
|
2
|
+
// 覆盖认领状态机、音效解析、存储不可用退化三条主干。
|
|
3
|
+
import test from 'node:test'
|
|
4
|
+
import assert from 'node:assert/strict'
|
|
5
|
+
import { readFileSync } from 'node:fs'
|
|
6
|
+
import { execFileSync } from 'node:child_process'
|
|
7
|
+
import { fileURLToPath } from 'node:url'
|
|
8
|
+
import { dirname, join } from 'node:path'
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
decideClaim as coreDecideClaim,
|
|
12
|
+
resolveSound as coreResolveSound,
|
|
13
|
+
mergeMapping as coreMergeMapping,
|
|
14
|
+
deadCustomIds as coreDeadCustomIds,
|
|
15
|
+
chooseChannels as coreChooseChannels,
|
|
16
|
+
parseVolume as coreParseVolume,
|
|
17
|
+
imTargetKeyOf as coreImTargetKeyOf,
|
|
18
|
+
toggleImTargetList as coreToggleImTargetList,
|
|
19
|
+
removeImTargetFromList as coreRemoveImTargetFromList,
|
|
20
|
+
unregisterImBotList as coreUnregisterImBotList,
|
|
21
|
+
imBoundBotIds as coreImBoundBotIds,
|
|
22
|
+
DEFAULT_VOLUME,
|
|
23
|
+
CLAIM_LOCK_TTL_MS,
|
|
24
|
+
USER_IDLE_AWAY_MS,
|
|
25
|
+
CATEGORIES as coreCategories,
|
|
26
|
+
CATEGORY_LABELS as coreCategoryLabels,
|
|
27
|
+
DEFAULT_TONES as coreDefaultTones,
|
|
28
|
+
BUILTIN_TONES as coreBuiltinTones,
|
|
29
|
+
AUDIO_EXTS as coreAudioExts,
|
|
30
|
+
MIME_BY_EXT as coreMimeByExt,
|
|
31
|
+
} from '../src/core.mjs'
|
|
32
|
+
|
|
33
|
+
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
34
|
+
|
|
35
|
+
// 从 client.js 提取标记段,构造同接口的纯逻辑实现。
|
|
36
|
+
function clientLogic() {
|
|
37
|
+
const source = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
|
|
38
|
+
const begin = source.indexOf('/* LOGIC-BEGIN */')
|
|
39
|
+
const end = source.indexOf('/* LOGIC-END */')
|
|
40
|
+
assert.ok(begin >= 0 && end > begin, 'client.js 缺少逻辑标记段')
|
|
41
|
+
const section = source.slice(begin + '/* LOGIC-BEGIN */'.length, end)
|
|
42
|
+
const factory = new Function(
|
|
43
|
+
section
|
|
44
|
+
+ '; return { decideClaim, resolveSound, mergeMapping, deadCustomIds, chooseChannels, parseVolume, claimEvent, markDone, windowId, localGet, localSet, localDel, storageState, CLAIM_LOCK_TTL_MS, IDLE_AWAY_MS, KEY_DND, KEY_TOAST, KEY_SOUND, KEY_SYSTEM, KEY_PAGE_SOUND, KEY_PAGE_SOUND_CATEGORIES, imTargetKey, toggleImTargetList, removeImTargetFromList, unregisterImBotList, imBoundBotIds, CATEGORIES, CATEGORY_LABELS, DEFAULT_TONES, TONE_LABELS, AUDIO_EXTS, MIME_BY_EXT };',
|
|
45
|
+
)
|
|
46
|
+
return factory()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const client = clientLogic()
|
|
50
|
+
|
|
51
|
+
// 数据镜像常量对照:client 与 core 任一侧漂移即失败,防改文案或增删分类时静默失同步
|
|
52
|
+
test('[parity 数据镜像] 分类清单与标签对照', () => {
|
|
53
|
+
assert.deepEqual(client.CATEGORIES, coreCategories)
|
|
54
|
+
assert.deepEqual(client.CATEGORY_LABELS, coreCategoryLabels)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('[parity 数据镜像] 默认音效与内置音名标签对照', () => {
|
|
58
|
+
assert.deepEqual(client.DEFAULT_TONES, coreDefaultTones)
|
|
59
|
+
assert.deepEqual(client.TONE_LABELS, coreBuiltinTones)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('[parity 数据镜像] 音频扩展名与 MIME 映射对照', () => {
|
|
63
|
+
assert.deepEqual(client.AUDIO_EXTS, coreAudioExts)
|
|
64
|
+
assert.deepEqual(client.MIME_BY_EXT, coreMimeByExt)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
// client 段为位置参数签名,core 为对象签名,此处适配后逐场景对照。
|
|
68
|
+
function clientDecideClaim({ stored, done, now, windowId, lockTtlMs }) {
|
|
69
|
+
assert.equal(client.CLAIM_LOCK_TTL_MS, lockTtlMs)
|
|
70
|
+
return client.decideClaim(stored, done, now, windowId)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function clientResolveSound({ category, mapping, uploadedIds }) {
|
|
74
|
+
return client.resolveSound(category, mapping, uploadedIds)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function defineClaimScenarios(prefix, decide) {
|
|
78
|
+
const t0 = 1000
|
|
79
|
+
test(prefix + '认领状态机四态对照', () => {
|
|
80
|
+
assert.equal(decide({ stored: null, done: null, now: t0, windowId: 'w1', lockTtlMs: CLAIM_LOCK_TTL_MS }), 'claim')
|
|
81
|
+
assert.equal(decide({ stored: JSON.stringify({ wid: 'w2', at: t0 }), done: null, now: t0 + CLAIM_LOCK_TTL_MS - 1, windowId: 'w1', lockTtlMs: CLAIM_LOCK_TTL_MS }), 'skip')
|
|
82
|
+
assert.equal(decide({ stored: JSON.stringify({ wid: 'w1', at: t0 }), done: null, now: t0 + CLAIM_LOCK_TTL_MS - 1, windowId: 'w1', lockTtlMs: CLAIM_LOCK_TTL_MS }), 'claim')
|
|
83
|
+
assert.equal(decide({ stored: JSON.stringify({ wid: 'w2', at: t0 }), done: null, now: t0 + CLAIM_LOCK_TTL_MS + 1, windowId: 'w1', lockTtlMs: CLAIM_LOCK_TTL_MS }), 'takeover')
|
|
84
|
+
assert.equal(decide({ stored: null, done: '1', now: t0, windowId: 'w1', lockTtlMs: CLAIM_LOCK_TTL_MS }), 'done')
|
|
85
|
+
assert.equal(decide({ stored: 'not-json', done: null, now: t0, windowId: 'w1', lockTtlMs: CLAIM_LOCK_TTL_MS }), 'takeover')
|
|
86
|
+
// undefined 域双实现同形:视为无记录而非终态
|
|
87
|
+
assert.equal(decide({ stored: null, done: undefined, now: t0, windowId: 'w1', lockTtlMs: CLAIM_LOCK_TTL_MS }), 'claim')
|
|
88
|
+
assert.equal(decide({ stored: undefined, done: null, now: t0, windowId: 'w1', lockTtlMs: CLAIM_LOCK_TTL_MS }), 'claim')
|
|
89
|
+
assert.equal(decide({ stored: 'not-json', done: undefined, now: t0, windowId: 'w1', lockTtlMs: CLAIM_LOCK_TTL_MS }), 'takeover')
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
defineClaimScenarios('[core.mjs decideClaim] ', coreDecideClaim)
|
|
94
|
+
defineClaimScenarios('[client.js decideClaim] ', clientDecideClaim)
|
|
95
|
+
|
|
96
|
+
function defineSoundScenarios(prefix, resolve) {
|
|
97
|
+
test(prefix + '音效解析对照:自定义命中 / 内置音名命中 / 失效与未配置回落', () => {
|
|
98
|
+
const mapping = { completed: 'snd-9', error: 'gone', interrupted: 'bell' }
|
|
99
|
+
assert.deepEqual(resolve({ category: 'completed', mapping, uploadedIds: ['snd-9'] }), { kind: 'custom', id: 'snd-9' })
|
|
100
|
+
// 映射值为内置音名时必须播放该内置音,而非回落分类默认
|
|
101
|
+
assert.deepEqual(resolve({ category: 'interrupted', mapping, uploadedIds: [] }), { kind: 'builtin', name: 'bell' })
|
|
102
|
+
assert.deepEqual(resolve({ category: 'completed', mapping, uploadedIds: [] }), { kind: 'builtin', name: 'up-arpeggio' })
|
|
103
|
+
const fallback = resolve({ category: 'error', mapping, uploadedIds: ['snd-9'] })
|
|
104
|
+
assert.equal(fallback.kind, 'builtin')
|
|
105
|
+
assert.notEqual(fallback.name, 'gone')
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
defineSoundScenarios('[core.mjs resolveSound] ', coreResolveSound)
|
|
110
|
+
defineSoundScenarios('[client.js resolveSound] ', clientResolveSound)
|
|
111
|
+
|
|
112
|
+
// 双作用域映射合并对照:覆盖语义、缺键回落、空串覆盖与空入参容错。
|
|
113
|
+
function defineMergeScenarios(prefix, merge) {
|
|
114
|
+
test(prefix + '映射合并:本地覆盖 / 缺键回落 / 空串覆盖 / 空入参容错', () => {
|
|
115
|
+
assert.deepEqual(merge({ completed: 'a', error: 'b' }, { completed: 'c' }), { completed: 'c', error: 'b' })
|
|
116
|
+
assert.deepEqual(merge({ completed: 'a' }, {}), { completed: 'a' })
|
|
117
|
+
assert.deepEqual(merge({ completed: 'a' }, { completed: '' }), { completed: '' })
|
|
118
|
+
assert.deepEqual(merge(null, { completed: 'a' }), { completed: 'a' })
|
|
119
|
+
assert.deepEqual(merge({ completed: 'a' }, null), { completed: 'a' })
|
|
120
|
+
assert.deepEqual(merge(undefined, undefined), {})
|
|
121
|
+
const base = { completed: 'a' }
|
|
122
|
+
assert.deepEqual(merge(base, { completed: 'b' }), { completed: 'b' })
|
|
123
|
+
assert.deepEqual(base, { completed: 'a' })
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
defineMergeScenarios('[core.mjs mergeMapping] ', coreMergeMapping)
|
|
128
|
+
defineMergeScenarios('[client.js mergeMapping] ', client.mergeMapping)
|
|
129
|
+
|
|
130
|
+
// 死链识别对照:非内置音名且非已上传 id 即死链,去重按首次出现,空值与非字符串跳过。
|
|
131
|
+
function defineDeadScenarios(prefix, dead) {
|
|
132
|
+
test(prefix + '死链识别:死链收集 / 内置与已上传豁免 / 去重保序 / 空值与非字符串跳过 / 列表容错', () => {
|
|
133
|
+
const mapping = { completed: 'gone-2', error: 'bell', interrupted: 'gone-1', approval: '', ask: 'gone-2', 'max-tokens': 'snd-1' }
|
|
134
|
+
assert.deepEqual(dead(mapping, ['snd-1']), ['gone-2', 'gone-1'])
|
|
135
|
+
assert.deepEqual(dead({ completed: 'bell' }, []), [])
|
|
136
|
+
assert.deepEqual(dead({}, ['snd-1']), [])
|
|
137
|
+
assert.deepEqual(dead(null, []), [])
|
|
138
|
+
assert.deepEqual(dead({ completed: 7, error: null, ask: { id: 'x' } }, ['snd-1']), [])
|
|
139
|
+
assert.deepEqual(dead({ completed: 'gone-1' }, null), ['gone-1'])
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
defineDeadScenarios('[core.mjs deadCustomIds] ', coreDeadCustomIds)
|
|
144
|
+
defineDeadScenarios('[client.js deadCustomIds] ', client.deadCustomIds)
|
|
145
|
+
|
|
146
|
+
// 四通道矩阵对照:client 版开关取自 localStorage,经 stub 注入后与 core 参数化版本逐场景比对。
|
|
147
|
+
function clientChooseChannels({ hasFocus, permission, focusQuiet, toastEnabled, soundEnabled, soundCategories, category, systemEnabled, idleMs, idleThresholdMs, pageSoundEnabled, pageSoundCategories }) {
|
|
148
|
+
const backing = new Map()
|
|
149
|
+
if (focusQuiet === false) backing.set(client.KEY_DND, '0')
|
|
150
|
+
if (systemEnabled === false) backing.set(client.KEY_SYSTEM, '0')
|
|
151
|
+
if (toastEnabled === false) backing.set(client.KEY_TOAST, '0')
|
|
152
|
+
if (soundEnabled === false) backing.set(client.KEY_SOUND, '0')
|
|
153
|
+
if (pageSoundEnabled === true) backing.set(client.KEY_PAGE_SOUND, '1')
|
|
154
|
+
if (pageSoundCategories != null) backing.set(client.KEY_PAGE_SOUND_CATEGORIES, JSON.stringify(pageSoundCategories))
|
|
155
|
+
globalThis.window = { localStorage: { getItem: (key) => (backing.has(key) ? backing.get(key) : null) } }
|
|
156
|
+
try {
|
|
157
|
+
if (idleThresholdMs !== undefined) assert.equal(client.IDLE_AWAY_MS, idleThresholdMs)
|
|
158
|
+
return client.chooseChannels(hasFocus, permission, idleMs ?? undefined, soundCategories ?? null, category ?? null)
|
|
159
|
+
} finally {
|
|
160
|
+
delete globalThis.window
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function defineChannelScenarios(prefix, channels) {
|
|
165
|
+
test(prefix + '五通道矩阵对照:聚焦静默 / 双开关 / 授权与降级 / 提示音分类静音', () => {
|
|
166
|
+
const base = { hasFocus: false, permission: 'granted' }
|
|
167
|
+
assert.deepEqual(channels(base), { toast: true, sound: true, system: true, blink: false, pageSound: false })
|
|
168
|
+
assert.deepEqual(channels({ ...base, hasFocus: true }), { toast: true, sound: false, system: false, blink: false, pageSound: false })
|
|
169
|
+
assert.deepEqual(channels({ ...base, hasFocus: true, focusQuiet: false }).sound, true)
|
|
170
|
+
assert.deepEqual(channels({ ...base, toastEnabled: false }).toast, false)
|
|
171
|
+
assert.deepEqual(channels({ ...base, systemEnabled: false }), { toast: true, sound: true, system: false, blink: false, pageSound: false })
|
|
172
|
+
assert.deepEqual(channels({ ...base, permission: 'default' }), { toast: true, sound: true, system: false, blink: true, pageSound: false })
|
|
173
|
+
assert.deepEqual(channels({ ...base, permission: 'denied', systemEnabled: false }).blink, false)
|
|
174
|
+
assert.deepEqual(channels({ ...base, soundEnabled: false }), { toast: true, sound: false, system: true, blink: false, pageSound: false })
|
|
175
|
+
assert.equal(channels({ ...base, soundCategories: { ask: false }, category: 'ask' }).sound, false)
|
|
176
|
+
assert.equal(channels({ ...base, soundCategories: { ask: false }, category: 'completed' }).sound, true)
|
|
177
|
+
assert.equal(channels({ ...base, soundCategories: { ask: false } }).sound, true)
|
|
178
|
+
assert.equal(channels({ ...base, soundCategories: { ask: false }, category: null }).sound, true)
|
|
179
|
+
})
|
|
180
|
+
test(prefix + '页内提示音对照:聚焦补位 / 失焦让位 / 页内分类独立 / 缺省关闭', () => {
|
|
181
|
+
const base = { hasFocus: false, permission: 'granted', pageSoundEnabled: true }
|
|
182
|
+
assert.equal(channels({ ...base, hasFocus: true }).pageSound, true)
|
|
183
|
+
assert.equal(channels(base).pageSound, false)
|
|
184
|
+
assert.equal(channels({ ...base, soundEnabled: false }).pageSound, true)
|
|
185
|
+
assert.equal(channels({ ...base, hasFocus: true, toastEnabled: false }).pageSound, false)
|
|
186
|
+
assert.equal(channels({ ...base, hasFocus: true, pageSoundCategories: { ask: false }, category: 'ask' }).pageSound, false)
|
|
187
|
+
assert.equal(channels({ ...base, hasFocus: true, pageSoundCategories: { ask: false }, category: 'completed' }).pageSound, true)
|
|
188
|
+
assert.equal(channels({ ...base, hasFocus: true, soundCategories: { ask: false }, category: 'ask' }).pageSound, true)
|
|
189
|
+
assert.equal(channels({ ...base, hasFocus: true, pageSoundCategories: { ask: false } }).pageSound, true)
|
|
190
|
+
assert.equal(channels({ hasFocus: true, permission: 'granted' }).pageSound, false)
|
|
191
|
+
})
|
|
192
|
+
test(prefix + '用户空闲对照:满阈值离开全通道,活跃聚焦静默', () => {
|
|
193
|
+
const base = { hasFocus: true, permission: 'granted', idleThresholdMs: USER_IDLE_AWAY_MS }
|
|
194
|
+
assert.deepEqual(channels({ ...base, idleMs: USER_IDLE_AWAY_MS }), { toast: true, sound: true, system: true, blink: false, pageSound: false })
|
|
195
|
+
assert.equal(channels({ ...base, idleMs: USER_IDLE_AWAY_MS - 1 }).sound, false)
|
|
196
|
+
assert.equal(channels({ ...base, idleMs: 0 }).sound, false)
|
|
197
|
+
assert.deepEqual(channels(base), { toast: true, sound: false, system: false, blink: false, pageSound: false })
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
defineChannelScenarios('[core.mjs chooseChannels] ', coreChooseChannels)
|
|
202
|
+
defineChannelScenarios('[client.js chooseChannels] ', clientChooseChannels)
|
|
203
|
+
|
|
204
|
+
function defineVolumeScenarios(prefix, parse) {
|
|
205
|
+
test(prefix + '音量解析对照:未设置回默认 / 显式零保留 / 非法回落', () => {
|
|
206
|
+
assert.equal(parse(null), DEFAULT_VOLUME)
|
|
207
|
+
assert.equal(parse(undefined), DEFAULT_VOLUME)
|
|
208
|
+
assert.equal(parse('0'), 0)
|
|
209
|
+
assert.equal(parse('0.5'), 0.5)
|
|
210
|
+
assert.equal(parse('1'), 1)
|
|
211
|
+
assert.equal(parse('-1'), DEFAULT_VOLUME)
|
|
212
|
+
assert.equal(parse('abc'), DEFAULT_VOLUME)
|
|
213
|
+
assert.equal(parse('3'), DEFAULT_VOLUME)
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
defineVolumeScenarios('[core.mjs parseVolume] ', coreParseVolume)
|
|
218
|
+
defineVolumeScenarios('[client.js parseVolume] ', client.parseVolume)
|
|
219
|
+
|
|
220
|
+
// IM 投递目标列表操作对照:client 版 key 为 imTargetKey,其余签名一致。
|
|
221
|
+
const A = { botId: 'wx_aaa', targetId: 'tgt_1' }
|
|
222
|
+
const B = { botId: 'wx_aaa', targetId: 'tgt_2' }
|
|
223
|
+
const C = { botId: 'wx_bbb', targetId: 'tgt_1' }
|
|
224
|
+
|
|
225
|
+
function clientToggle(list, botId, targetId, checked) {
|
|
226
|
+
return client.toggleImTargetList(list, botId, targetId, checked)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function defineImTargetScenarios(prefix, keyOf, toggle, removeItem, unregister, boundBots) {
|
|
230
|
+
test(prefix + '键拼接与勾选幂等对照', () => {
|
|
231
|
+
assert.equal(keyOf(A), 'wx_aaa/tgt_1')
|
|
232
|
+
const once = toggle([], 'wx_aaa', 'tgt_1', true)
|
|
233
|
+
assert.deepEqual(once, [A])
|
|
234
|
+
assert.deepEqual(toggle(once, 'wx_aaa', 'tgt_1', true), [A])
|
|
235
|
+
assert.deepEqual(toggle(once, 'wx_bbb', 'tgt_1', true), [A, C])
|
|
236
|
+
})
|
|
237
|
+
test(prefix + '取消勾选/移除/取消注册对照', () => {
|
|
238
|
+
const list = [A, B, C]
|
|
239
|
+
assert.deepEqual(toggle(list, 'wx_aaa', 'tgt_2', false), [A, C])
|
|
240
|
+
assert.deepEqual(removeItem(list, 'wx_aaa', 'tgt_2'), [A, C])
|
|
241
|
+
assert.deepEqual(unregister(list, 'wx_aaa'), [C])
|
|
242
|
+
assert.deepEqual(unregister(list, 'wx_nnn'), list)
|
|
243
|
+
})
|
|
244
|
+
test(prefix + '已绑 bot 去重保序对照', () => {
|
|
245
|
+
assert.deepEqual(boundBots([A, B, C]), ['wx_aaa', 'wx_bbb'])
|
|
246
|
+
assert.deepEqual(boundBots([]), [])
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
defineImTargetScenarios(
|
|
251
|
+
'[core.mjs imTargets] ',
|
|
252
|
+
coreImTargetKeyOf,
|
|
253
|
+
coreToggleImTargetList,
|
|
254
|
+
coreRemoveImTargetFromList,
|
|
255
|
+
coreUnregisterImBotList,
|
|
256
|
+
coreImBoundBotIds,
|
|
257
|
+
)
|
|
258
|
+
defineImTargetScenarios(
|
|
259
|
+
'[client.js imTargets] ',
|
|
260
|
+
client.imTargetKey,
|
|
261
|
+
clientToggle,
|
|
262
|
+
client.removeImTargetFromList,
|
|
263
|
+
client.unregisterImBotList,
|
|
264
|
+
client.imBoundBotIds,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
test('[client.js] localStorage 抛错:认领退化为直接发声且提示位可用', () => {
|
|
268
|
+
const throwing = {
|
|
269
|
+
getItem: () => { throw new Error('blocked') },
|
|
270
|
+
setItem: () => { throw new Error('blocked') },
|
|
271
|
+
removeItem: () => { throw new Error('blocked') },
|
|
272
|
+
}
|
|
273
|
+
const original = globalThis.window
|
|
274
|
+
globalThis.window = { localStorage: throwing }
|
|
275
|
+
try {
|
|
276
|
+
client.storageState.broken = false
|
|
277
|
+
assert.equal(client.claimEvent('u1'), true)
|
|
278
|
+
assert.equal(client.storageState.broken, true)
|
|
279
|
+
assert.equal(client.localGet('k'), null)
|
|
280
|
+
client.markDone('u1')
|
|
281
|
+
} finally {
|
|
282
|
+
client.storageState.broken = false
|
|
283
|
+
if (original === undefined) delete globalThis.window
|
|
284
|
+
else globalThis.window = original
|
|
285
|
+
}
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
test('[client.js] localStorage 正常:写后读回唯一发声', () => {
|
|
289
|
+
const backing = new Map()
|
|
290
|
+
const store = {
|
|
291
|
+
getItem: (key) => (backing.has(key) ? backing.get(key) : null),
|
|
292
|
+
setItem: (key, value) => { backing.set(key, value) },
|
|
293
|
+
removeItem: (key) => { backing.delete(key) },
|
|
294
|
+
}
|
|
295
|
+
const original = globalThis.window
|
|
296
|
+
globalThis.window = { localStorage: store }
|
|
297
|
+
try {
|
|
298
|
+
client.storageState.broken = false
|
|
299
|
+
assert.equal(client.claimEvent('u2'), true)
|
|
300
|
+
client.markDone('u2')
|
|
301
|
+
// 完成标记后不再认领
|
|
302
|
+
assert.equal(client.claimEvent('u2'), false)
|
|
303
|
+
} finally {
|
|
304
|
+
client.storageState.broken = false
|
|
305
|
+
if (original === undefined) delete globalThis.window
|
|
306
|
+
else globalThis.window = original
|
|
307
|
+
}
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
test('client.js 语法可被 node 解析', () => {
|
|
311
|
+
execFileSync(process.execPath, ['--check', join(PKG_ROOT, 'src', 'client.js')])
|
|
312
|
+
})
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// 音效重命名路由测试(展示名语义):USERPROFILE 重定向到临时目录后动态装载 host 路由,
|
|
2
|
+
// 隔离真实 ~/.dsh 音效库;覆盖展示名落索引 / 文件名不变 / 映射引用不动 / 幂等 /
|
|
3
|
+
// 非法名 / 404 / 并发收敛 / 删除清索引 / 守卫。
|
|
4
|
+
import test from 'node:test'
|
|
5
|
+
import assert from 'node:assert/strict'
|
|
6
|
+
import { EventEmitter } from 'node:events'
|
|
7
|
+
import { mkdtemp, mkdir, writeFile, readFile, readdir, rm } from 'node:fs/promises'
|
|
8
|
+
import { tmpdir } from 'node:os'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
import { SOUND_NAME_MAX_CHARS } from '../src/core.mjs'
|
|
11
|
+
|
|
12
|
+
const homeRoot = await mkdtemp(join(tmpdir(), 'tn-rename-'))
|
|
13
|
+
process.env.USERPROFILE = homeRoot
|
|
14
|
+
process.env.HOME = homeRoot
|
|
15
|
+
const { apply } = await import('../src/index.js')
|
|
16
|
+
|
|
17
|
+
test.after(async () => { await rm(homeRoot, { recursive: true, force: true }) })
|
|
18
|
+
|
|
19
|
+
const soundsDir = join(homeRoot, '.dsh', 'dsh-turn-notify', 'sounds')
|
|
20
|
+
const JSON_HEADERS = { 'content-type': 'application/json' }
|
|
21
|
+
|
|
22
|
+
function makeRes() {
|
|
23
|
+
return {
|
|
24
|
+
status: null,
|
|
25
|
+
body: null,
|
|
26
|
+
raw: null,
|
|
27
|
+
headers: null,
|
|
28
|
+
writeHead(status, headers) { this.status = status; this.headers = headers },
|
|
29
|
+
end(body) {
|
|
30
|
+
if (Buffer.isBuffer(body)) this.raw = body
|
|
31
|
+
else if (body !== undefined && (this.headers ? String(this.headers['content-type']) : '').includes('json')) this.body = JSON.parse(body)
|
|
32
|
+
else this.raw = body
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function makeReq(method, payload, headers, url) {
|
|
38
|
+
const req = new EventEmitter()
|
|
39
|
+
req.method = method
|
|
40
|
+
req.url = url || '/api/turn-notify/sound'
|
|
41
|
+
req.headers = { host: '127.0.0.1:3080', ...(headers || {}) }
|
|
42
|
+
const data = payload === undefined ? null : Buffer.from(JSON.stringify(payload))
|
|
43
|
+
process.nextTick(() => {
|
|
44
|
+
if (data !== null) req.emit('data', data)
|
|
45
|
+
req.readableEnded = true
|
|
46
|
+
req.emit('end')
|
|
47
|
+
})
|
|
48
|
+
return req
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function makeSettings() {
|
|
52
|
+
const doc = new Map()
|
|
53
|
+
const merge = (under, over) => {
|
|
54
|
+
const out = { ...under }
|
|
55
|
+
for (const [key, value] of Object.entries(over)) {
|
|
56
|
+
const underValue = out[key]
|
|
57
|
+
const bothPlain = (value !== null && typeof value === 'object' && !Array.isArray(value))
|
|
58
|
+
&& (underValue !== null && typeof underValue === 'object' && !Array.isArray(underValue))
|
|
59
|
+
out[key] = bothPlain ? merge(underValue, value) : value
|
|
60
|
+
}
|
|
61
|
+
return out
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
register(ns) {
|
|
65
|
+
if (!doc.has(ns)) doc.set(ns, {})
|
|
66
|
+
return {
|
|
67
|
+
get: () => doc.get(ns),
|
|
68
|
+
watch: () => () => {},
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
get: (ns) => doc.get(ns),
|
|
72
|
+
update: async (ns, patch) => { doc.set(ns, merge(doc.get(ns) ?? {}, patch)) },
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function makeCtx() {
|
|
77
|
+
const routes = new Map()
|
|
78
|
+
const settingsService = makeSettings()
|
|
79
|
+
const ctx = {
|
|
80
|
+
on() {},
|
|
81
|
+
get(key) { return key === 'settings' ? settingsService : undefined },
|
|
82
|
+
inject(deps, fn) { fn({ settings: settingsService }) },
|
|
83
|
+
effect(thunk) { thunk() },
|
|
84
|
+
webServer: { register(route) { routes.set(route.path, route.handler) } },
|
|
85
|
+
}
|
|
86
|
+
return { ctx, routes, settingsService }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 目录为各测试共享:每个测试先重置,再种自己需要的文件,保证互不残留
|
|
90
|
+
async function resetSounds() {
|
|
91
|
+
await rm(soundsDir, { recursive: true, force: true })
|
|
92
|
+
await mkdir(soundsDir, { recursive: true })
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function seedSound(id, ext) {
|
|
96
|
+
await writeFile(join(soundsDir, id + '.' + ext), Buffer.from([1, 2, 3]))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function listNames() {
|
|
100
|
+
return readdir(soundsDir)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function readIndex() {
|
|
104
|
+
try {
|
|
105
|
+
return JSON.parse(await readFile(join(soundsDir, 'index.json'), 'utf8'))
|
|
106
|
+
} catch {
|
|
107
|
+
return {}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
test('重命名主路径:文件名不变,展示名落索引,映射引用不动,原 id 仍可读取', async () => {
|
|
112
|
+
await resetSounds()
|
|
113
|
+
await seedSound('snd-abc', 'wav')
|
|
114
|
+
const { ctx, routes, settingsService } = makeCtx()
|
|
115
|
+
apply(ctx)
|
|
116
|
+
await settingsService.update('turn-notify', { soundMapping: { completed: 'snd-abc', error: 'snd-other' } })
|
|
117
|
+
const res = makeRes()
|
|
118
|
+
await routes.get('/api/turn-notify/sound')(makeReq('PUT', { id: 'snd-abc', name: ' 提示音甲 ' }, JSON_HEADERS), res)
|
|
119
|
+
assert.equal(res.status, 200)
|
|
120
|
+
assert.deepEqual(res.body, { ok: true, id: 'snd-abc', name: '提示音甲' })
|
|
121
|
+
assert.deepEqual((await listNames()).sort(), ['index.json', 'snd-abc.wav'])
|
|
122
|
+
assert.deepEqual(await readIndex(), { 'snd-abc': '提示音甲' })
|
|
123
|
+
const stored = settingsService.get('turn-notify')
|
|
124
|
+
assert.equal(stored.soundMapping.completed, 'snd-abc')
|
|
125
|
+
assert.equal(stored.soundMapping.error, 'snd-other')
|
|
126
|
+
const got = makeRes()
|
|
127
|
+
await routes.get('/api/turn-notify/sound')(makeReq('GET', undefined, undefined, '/api/turn-notify/sound?id=snd-abc'), got)
|
|
128
|
+
assert.equal(got.status, 200)
|
|
129
|
+
assert.deepEqual(got.raw, Buffer.from([1, 2, 3]))
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('重命名为当前展示名:幂等成功,索引与文件不动', async () => {
|
|
133
|
+
await resetSounds()
|
|
134
|
+
await seedSound('snd-keep', 'mp3')
|
|
135
|
+
const { ctx, routes } = makeCtx()
|
|
136
|
+
apply(ctx)
|
|
137
|
+
const first = makeRes()
|
|
138
|
+
await routes.get('/api/turn-notify/sound')(makeReq('PUT', { id: 'snd-keep', name: '名称甲' }, JSON_HEADERS), first)
|
|
139
|
+
assert.equal(first.status, 200)
|
|
140
|
+
const again = makeRes()
|
|
141
|
+
await routes.get('/api/turn-notify/sound')(makeReq('PUT', { id: 'snd-keep', name: ' 名称甲 ' }, JSON_HEADERS), again)
|
|
142
|
+
assert.equal(again.status, 200)
|
|
143
|
+
assert.deepEqual(again.body, { ok: true, id: 'snd-keep', name: '名称甲' })
|
|
144
|
+
assert.deepEqual(await readIndex(), { 'snd-keep': '名称甲' })
|
|
145
|
+
assert.deepEqual((await listNames()).sort(), ['index.json', 'snd-keep.mp3'])
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('重命名为非法名:400 拒绝,索引与文件不动', async () => {
|
|
149
|
+
await resetSounds()
|
|
150
|
+
await seedSound('snd-x', 'ogg')
|
|
151
|
+
const { ctx, routes } = makeCtx()
|
|
152
|
+
apply(ctx)
|
|
153
|
+
for (const name of ['a.wav', 'a/b', 'bell', '', '长'.repeat(SOUND_NAME_MAX_CHARS + 1), 123]) {
|
|
154
|
+
const res = makeRes()
|
|
155
|
+
await routes.get('/api/turn-notify/sound')(makeReq('PUT', { id: 'snd-x', name }, JSON_HEADERS), res)
|
|
156
|
+
assert.equal(res.status, 400, '非法名应 400: ' + name)
|
|
157
|
+
}
|
|
158
|
+
assert.deepEqual(await listNames(), ['snd-x.ogg'])
|
|
159
|
+
assert.deepEqual(await readIndex(), {})
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test('重命名不存在的音效:404', async () => {
|
|
163
|
+
await resetSounds()
|
|
164
|
+
const { ctx, routes } = makeCtx()
|
|
165
|
+
apply(ctx)
|
|
166
|
+
const res = makeRes()
|
|
167
|
+
await routes.get('/api/turn-notify/sound')(makeReq('PUT', { id: 'snd-missing', name: '任意' }, JSON_HEADERS), res)
|
|
168
|
+
assert.equal(res.status, 404)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('并发展示名重命名:互斥串行,双双成功,文件不变且索引收敛于单值', async () => {
|
|
172
|
+
await resetSounds()
|
|
173
|
+
await seedSound('snd-dup', 'wav')
|
|
174
|
+
const { ctx, routes } = makeCtx()
|
|
175
|
+
apply(ctx)
|
|
176
|
+
const handler = routes.get('/api/turn-notify/sound')
|
|
177
|
+
const results = await Promise.all(['并发名一', '并发名二'].map((name) => {
|
|
178
|
+
const res = makeRes()
|
|
179
|
+
return handler(makeReq('PUT', { id: 'snd-dup', name }, JSON_HEADERS), res).then(() => res.status)
|
|
180
|
+
}))
|
|
181
|
+
assert.deepEqual(results, [200, 200])
|
|
182
|
+
assert.deepEqual((await listNames()).sort(), ['index.json', 'snd-dup.wav'])
|
|
183
|
+
const index = await readIndex()
|
|
184
|
+
assert.deepEqual(Object.keys(index), ['snd-dup'])
|
|
185
|
+
assert.ok(index['snd-dup'] === '并发名一' || index['snd-dup'] === '并发名二')
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
test('删除清理展示名索引:条目随文件移除,映射引用同步清理', async () => {
|
|
189
|
+
await resetSounds()
|
|
190
|
+
await seedSound('gone', 'wav')
|
|
191
|
+
const { ctx, routes, settingsService } = makeCtx()
|
|
192
|
+
apply(ctx)
|
|
193
|
+
const renamed = makeRes()
|
|
194
|
+
await routes.get('/api/turn-notify/sound')(makeReq('PUT', { id: 'gone', name: '旧名' }, JSON_HEADERS), renamed)
|
|
195
|
+
assert.equal(renamed.status, 200)
|
|
196
|
+
await settingsService.update('turn-notify', { soundMapping: { completed: 'gone' } })
|
|
197
|
+
const res = makeRes()
|
|
198
|
+
await routes.get('/api/turn-notify/sound')(makeReq('DELETE', undefined, undefined, '/api/turn-notify/sound?id=gone'), res)
|
|
199
|
+
assert.equal(res.status, 200)
|
|
200
|
+
assert.deepEqual(await listNames(), ['index.json'])
|
|
201
|
+
assert.deepEqual(await readIndex(), {})
|
|
202
|
+
const stored = settingsService.get('turn-notify')
|
|
203
|
+
assert.equal(stored.soundMapping.completed, null)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
test('删除不存在的音效:404,不产生索引文件', async () => {
|
|
207
|
+
await resetSounds()
|
|
208
|
+
const { ctx, routes } = makeCtx()
|
|
209
|
+
apply(ctx)
|
|
210
|
+
const res = makeRes()
|
|
211
|
+
await routes.get('/api/turn-notify/sound')(makeReq('DELETE', undefined, undefined, '/api/turn-notify/sound?id=missing'), res)
|
|
212
|
+
assert.equal(res.status, 404)
|
|
213
|
+
assert.deepEqual(await listNames(), [])
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
test('重命名路由守卫:跨源 403,非 JSON 400,畸形体 400,不支持的方法 405', async () => {
|
|
217
|
+
await resetSounds()
|
|
218
|
+
await seedSound('snd-guard', 'wav')
|
|
219
|
+
const { ctx, routes } = makeCtx()
|
|
220
|
+
apply(ctx)
|
|
221
|
+
const cross = makeRes()
|
|
222
|
+
await routes.get('/api/turn-notify/sound')(
|
|
223
|
+
makeReq('PUT', { id: 'snd-guard', name: '甲' }, { ...JSON_HEADERS, origin: 'https://evil.example' }), cross)
|
|
224
|
+
assert.equal(cross.status, 403)
|
|
225
|
+
const wrongType = makeRes()
|
|
226
|
+
await routes.get('/api/turn-notify/sound')(
|
|
227
|
+
makeReq('PUT', { id: 'snd-guard', name: '甲' }, { 'content-type': 'text/plain' }), wrongType)
|
|
228
|
+
assert.equal(wrongType.status, 400)
|
|
229
|
+
const malformed = makeRes()
|
|
230
|
+
await routes.get('/api/turn-notify/sound')(makeReq('PUT', undefined, JSON_HEADERS), malformed)
|
|
231
|
+
assert.equal(malformed.status, 400)
|
|
232
|
+
const badMethod = makeRes()
|
|
233
|
+
await routes.get('/api/turn-notify/sound')(makeReq('POST', {}, JSON_HEADERS), badMethod)
|
|
234
|
+
assert.equal(badMethod.status, 405)
|
|
235
|
+
})
|