dsh-notify-say 0.4.21 → 0.4.25

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.
Files changed (54) hide show
  1. package/README.md +216 -176
  2. package/docs/audio-audit/README.md +164 -0
  3. package/docs/audio-audit/fetch-snapshots.ps1 +76 -0
  4. package/docs/audio-audit/snapshots/ameharu-hau.txt +89 -0
  5. package/docs/audio-audit/snapshots/goki-nuigurumi.txt +73 -0
  6. package/docs/audio-audit/snapshots/kasukabe-tsumugi.txt +197 -0
  7. package/docs/audio-audit/snapshots/manbetsu-hanamaru.txt +27 -0
  8. package/docs/audio-audit/snapshots/meimei-meria.txt +84 -0
  9. package/docs/audio-audit/snapshots/mochiko.txt +27 -0
  10. package/docs/audio-audit/snapshots/namiine-ritsu.txt +76 -0
  11. package/docs/audio-audit/snapshots/shirakami-kotaro.txt +158 -0
  12. package/docs/audio-audit/snapshots/sss-anonymous-common.txt +142 -0
  13. package/docs/audio-audit/snapshots/sss-zundamon.txt +142 -0
  14. package/docs/audio-audit/snapshots/voicevox-software.txt +41 -0
  15. package/docs/audio-audit/snapshots/voicevox-vvm-terms.txt +385 -0
  16. package/docs/audio-audit/snapshots/yurei-chan.txt +112 -0
  17. package/docs//345/276/205/345/201/232/346/270/205/345/215/225.md +218 -0
  18. package/docs//350/257/255/351/237/263/345/214/205/345/210/206/345/217/221/346/226/271/346/241/210-/345/276/205/345/256/241/351/230/205.md +378 -0
  19. package/lib/client.js +50 -8
  20. package/lib/config-route.js +251 -209
  21. package/lib/http-guard.js +222 -0
  22. package/lib/index.js +1822 -1768
  23. package/lib/session-bridge.js +138 -117
  24. package/package.json +11 -2
  25. package/test/audio-source.test.mjs +184 -0
  26. package/test/audio-wiring.test.mjs +287 -0
  27. package/test/client-bundle.test.mjs +671 -0
  28. package/test/client-contract.test.mjs +118 -0
  29. package/test/config-coverage.test.mjs +109 -0
  30. package/test/config-route.test.mjs +212 -0
  31. package/test/config-schema.test.mjs +147 -0
  32. package/test/config-store.test.mjs +256 -0
  33. package/test/dev-reload.test.mjs +132 -0
  34. package/test/effective-for-client.test.mjs +142 -0
  35. package/test/fail-route.test.mjs +128 -0
  36. package/test/fail-voice.test.mjs +193 -0
  37. package/test/http-guard.test.mjs +143 -0
  38. package/test/http-integration.test.mjs +284 -0
  39. package/test/idle-gate.test.mjs +167 -0
  40. package/test/preview.test.mjs +92 -0
  41. package/test/real-cordis.test.mjs +204 -0
  42. package/test/release-voice-guard.test.mjs +185 -0
  43. package/test/reload-live.test.mjs +261 -0
  44. package/test/reload-verification.test.mjs +218 -0
  45. package/test/route-loader-register.mjs +22 -0
  46. package/test/scalar-guard.test.mjs +125 -0
  47. package/test/session-jump.test.mjs +266 -0
  48. package/test/stub-child-process.mjs +43 -0
  49. package/test/tts-lines.test.mjs +112 -0
  50. package/test/version-consistency.test.mjs +98 -0
  51. package/test/voice-availability.test.mjs +90 -0
  52. package/test/wav-gain.test.mjs +138 -0
  53. package/test/wiring.test.mjs +287 -0
  54. package/tools/notify-config.ps1 +472 -471
@@ -1,117 +1,138 @@
1
- /**
2
- * 会话状态桥(host 半侧 HTTP route)。
3
- *
4
- * ## 为什么需要它
5
- *
6
- * client 半侧要「点 Toast 后跳到那条通知对应的会话」,但它拿不到会话 id:
7
- *
8
- * - 渲染进程是 `sandbox:true / contextIsolation:true / nodeIntegration:false`
9
- * (app.asar 实测)→ **没有 `process`**,读不了 `$DSH_HOME` 下的文件;
10
- * - 也不能直接访问 host 的 Cordis 服务(只有白名单里的 remote 事件)。
11
- *
12
- * 所以走官方给的正路:host 注册一个 `/api/...` route,client 用 `fetch` 取。
13
- * 本机先例:`dsh-our-free-model` 用 `webServer.register({ kind:'prefix' })` +
14
- * client 侧 `fetch('api/our-free-model')`(注释里提醒要用 `document.baseURI` 解析,
15
- * 不能写 origin-absolute 的 `/api/...`,否则反向代理下会 404)。
16
- *
17
- * ## 稳定性设计(本插件首要目标)
18
- *
19
- * `webServer` 是可选依赖,**拿不到就整体不注册**,插件其余功能(Toast + 语音)
20
- * 完全不受影响。这与「配置层失败就降级」是同一套思路:
21
- * 任何增强能力都不得连累主功能。
22
- *
23
- * route 只做两件事:
24
- * GET /api/dsh-notify-say/last-session → { sessionId, at } 或 204
25
- * POST /api/dsh-notify-say/consume → 读一次并清空(防重复跳转)
26
- */
27
-
28
- /** route 前缀(client 侧据此拼接)。 */
29
- export const ROUTE_PREFIX = '/api/dsh-notify-say';
30
-
31
- /** 取最近通知会话的路径。 */
32
- export const ROUTE_LAST = ROUTE_PREFIX + '/last-session';
33
-
34
- /** 消费(读取并清空)的路径。 */
35
- export const ROUTE_CONSUME = ROUTE_PREFIX + '/consume';
36
-
37
- /**
38
- * 创建 route handler。状态放在闭包里,不落盘 ——
39
- * 这是**进程内、短生命周期**的一次性信号,重启即失效,正好符合语义
40
- * (重启后没有"刚才那条通知"了)。
41
- *
42
- * @returns {{ handler: Function, setLast: Function, peek: Function }}
43
- */
44
- export function createSessionBridge() {
45
- /** @type {{ sessionId: string, at: number } | null} */
46
- let last = null;
47
-
48
- /** 记录一条通知对应的会话(供 client 取用)。 */
49
- function setLast(sessionId, now) {
50
- try {
51
- const sid = typeof sessionId === 'string' ? sessionId.trim() : '';
52
- if (!sid) return;
53
- last = { sessionId: sid, at: typeof now === 'number' ? now : Date.now() };
54
- } catch { /* 通知主流程不因此中断 */ }
55
- }
56
-
57
- /** 只看不取。 */
58
- function peek() {
59
- return last;
60
- }
61
-
62
- /**
63
- * HTTP handler。
64
- *
65
- * 支持两种形态的 register 回调(本机实测不同插件用法不同):
66
- * - `{ request, response }` 风格(Node http)
67
- * - `(req, res)` 位置参数风格
68
- * 统一从第一个参数上取 request/response,兼容两者。
69
- */
70
- function handler(...args) {
71
- const req = args[0] && args[0].request ? args[0].request
72
- : (args[0] && args[0].req ? args[0].req : args[0]);
73
- const res = args[0] && args[0].response ? args[0].response
74
- : (args[0] && args[0].res ? args[0].res : args[1]);
75
-
76
- const send = (code, body) => {
77
- try {
78
- if (res && typeof res.writeHead === 'function') {
79
- const text = body === undefined ? '' : JSON.stringify(body);
80
- res.writeHead(code, {
81
- 'content-type': 'application/json; charset=utf-8',
82
- 'cache-control': 'no-store',
83
- });
84
- res.end(text);
85
- return;
86
- }
87
- if (res && typeof res.setHeader === 'function' && typeof res.end === 'function') {
88
- res.statusCode = code;
89
- res.setHeader('content-type', 'application/json; charset=utf-8');
90
- res.setHeader('cache-control', 'no-store');
91
- res.end(body === undefined ? '' : JSON.stringify(body));
92
- }
93
- } catch { /* 响应写失败不影响后续 */ }
94
- };
95
-
96
- try {
97
- const url = String((req && (req.url || req.path)) || '');
98
- const isConsume = url.indexOf('/consume') >= 0;
99
-
100
- if (isConsume) {
101
- const hit = last;
102
- last = null; // 消费即清,避免重复跳转
103
- if (!hit) { send(204); return; }
104
- send(200, hit);
105
- return;
106
- }
107
-
108
- // 默认走 last-session
109
- if (!last) { send(204); return; }
110
- send(200, last);
111
- } catch {
112
- send(500, { error: 'internal' });
113
- }
114
- }
115
-
116
- return { handler, setLast, peek };
117
- }
1
+ /**
2
+ * 会话状态桥(host 半侧 HTTP route)。
3
+ *
4
+ * ## 为什么需要它
5
+ *
6
+ * client 半侧要「点 Toast 后跳到那条通知对应的会话」,但它拿不到会话 id:
7
+ *
8
+ * - 渲染进程是 `sandbox:true / contextIsolation:true / nodeIntegration:false`
9
+ * (app.asar 实测)→ **没有 `process`**,读不了 `$DSH_HOME` 下的文件;
10
+ * - 也不能直接访问 host 的 Cordis 服务(只有白名单里的 remote 事件)。
11
+ *
12
+ * 所以走官方给的正路:host 注册一个 `/api/...` route,client 用 `fetch` 取。
13
+ * 本机先例:`dsh-our-free-model` 用 `webServer.register({ kind:'prefix' })` +
14
+ * client 侧 `fetch('api/our-free-model')`(注释里提醒要用 `document.baseURI` 解析,
15
+ * 不能写 origin-absolute 的 `/api/...`,否则反向代理下会 404)。
16
+ *
17
+ * ## 稳定性设计(本插件首要目标)
18
+ *
19
+ * `webServer` 是可选依赖,**拿不到就整体不注册**,插件其余功能(Toast + 语音)
20
+ * 完全不受影响。这与「配置层失败就降级」是同一套思路:
21
+ * 任何增强能力都不得连累主功能。
22
+ *
23
+ * route 只做两件事:
24
+ * GET /api/dsh-notify-say/last-session → { sessionId, at } 或 204
25
+ * POST /api/dsh-notify-say/consume → 读一次并清空(防重复跳转)
26
+ */
27
+
28
+ /** route 前缀(client 侧据此拼接)。 */
29
+ export const ROUTE_PREFIX = '/api/dsh-notify-say';
30
+
31
+ /** 取最近通知会话的路径。 */
32
+ export const ROUTE_LAST = ROUTE_PREFIX + '/last-session';
33
+
34
+ /** 消费(读取并清空)的路径。 */
35
+ export const ROUTE_CONSUME = ROUTE_PREFIX + '/consume';
36
+
37
+ /**
38
+ * 创建 route handler。状态放在闭包里,不落盘 ——
39
+ * 这是**进程内、短生命周期**的一次性信号,重启即失效,正好符合语义
40
+ * (重启后没有"刚才那条通知"了)。
41
+ *
42
+ * @returns {{ handler: Function, setLast: Function, peek: Function }}
43
+ */
44
+ export function createSessionBridge() {
45
+ /** @type {{ sessionId: string, at: number } | null} */
46
+ let last = null;
47
+
48
+ /** 记录一条通知对应的会话(供 client 取用)。 */
49
+ function setLast(sessionId, now) {
50
+ try {
51
+ const sid = typeof sessionId === 'string' ? sessionId.trim() : '';
52
+ if (!sid) return;
53
+ last = { sessionId: sid, at: typeof now === 'number' ? now : Date.now() };
54
+ } catch { /* 通知主流程不因此中断 */ }
55
+ }
56
+
57
+ /** 只看不取。 */
58
+ function peek() {
59
+ return last;
60
+ }
61
+
62
+ /**
63
+ * HTTP handler。
64
+ *
65
+ * 支持两种形态的 register 回调(本机实测不同插件用法不同):
66
+ * - `{ request, response }` 风格(Node http)
67
+ * - `(req, res)` 位置参数风格
68
+ * 统一从第一个参数上取 request/response,兼容两者。
69
+ */
70
+ function handler(...args) {
71
+ const req = args[0] && args[0].request ? args[0].request
72
+ : (args[0] && args[0].req ? args[0].req : args[0]);
73
+ const res = args[0] && args[0].response ? args[0].response
74
+ : (args[0] && args[0].res ? args[0].res : args[1]);
75
+
76
+ const send = (code, body) => {
77
+ try {
78
+ if (res && typeof res.writeHead === 'function') {
79
+ const text = body === undefined ? '' : JSON.stringify(body);
80
+ res.writeHead(code, {
81
+ 'content-type': 'application/json; charset=utf-8',
82
+ 'cache-control': 'no-store',
83
+ });
84
+ res.end(text);
85
+ return;
86
+ }
87
+ if (res && typeof res.setHeader === 'function' && typeof res.end === 'function') {
88
+ res.statusCode = code;
89
+ res.setHeader('content-type', 'application/json; charset=utf-8');
90
+ res.setHeader('cache-control', 'no-store');
91
+ res.end(body === undefined ? '' : JSON.stringify(body));
92
+ }
93
+ } catch { /* 响应写失败不影响后续 */ }
94
+ };
95
+
96
+ try {
97
+ const url = String((req && (req.url || req.path)) || '');
98
+ // ⚠️ 必须按**路径段**判断,不能用 indexOf,
99
+ // 否则 `/last-session?next=/consume` 会被误判成 consume(子串匹配)。
100
+ const pathname = url.split('?')[0].split('#')[0];
101
+ // ⚠️ 必须先剥掉 route 前缀再取第一段。
102
+ // 否则 `/api/dsh-notify-say/consume` 的第一段是 `api`,
103
+ // 永远匹配不到 consume —— 消费不会清状态(v0.4.25 实测踩到)。
104
+ const rest = pathname.startsWith(ROUTE_PREFIX)
105
+ ? pathname.slice(ROUTE_PREFIX.length)
106
+ : pathname;
107
+ const head = rest.replace(/^\/+/, '').split('/')[0];
108
+
109
+ const isConsume = head === 'consume';
110
+
111
+ if (isConsume) {
112
+ // ⚠️ 只接受 POST:这个动作会**清除服务端状态**,用 GET 既语义不当,
113
+ // 也会被浏览器的跨站简单请求(<img>/<a>)触发。
114
+ // 客户端已改为 POST + JSON(见 client.js 的 consumeLastSession)。
115
+ const method = String((req && req.method) || 'GET').toUpperCase();
116
+ if (method !== 'POST') {
117
+ if (res && typeof res.setHeader === 'function') {
118
+ res.setHeader('allow', 'POST');
119
+ }
120
+ send(405, { error: 'method-not-allowed', allow: 'POST' });
121
+ return;
122
+ }
123
+ const hit = last;
124
+ last = null; // 消费即清,避免重复跳转
125
+ if (!hit) { send(204); return; }
126
+ send(200, hit);
127
+ return;
128
+ }
129
+ // 默认走 last-session(只读,不改状态)
130
+ if (!last) { send(204); return; }
131
+ send(200, last);
132
+ } catch {
133
+ send(500, { error: 'internal' });
134
+ }
135
+ }
136
+
137
+ return { handler, setLast, peek };
138
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-notify-say",
3
- "version": "0.4.21",
3
+ "version": "0.4.25",
4
4
  "description": "Desktop notifications for DSH Desktop that actually speak: Windows TTS out of the box (zero download), VOICEVOX voice packs optional, a selectable system-sound fallback, and an idle gate that waits for running subagents before saying the turn is done. Includes a settings-page GUI (sound source, per-event overrides, jump-back-to-session, cooldowns) and a self hot-reload for development.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -29,7 +29,9 @@
29
29
  "tools",
30
30
  "README.md",
31
31
  "LICENSE",
32
- "THIRD-PARTY-NOTICES.md"
32
+ "THIRD-PARTY-NOTICES.md",
33
+ "test",
34
+ "docs"
33
35
  ],
34
36
  "keywords": [
35
37
  "dsh",
@@ -47,6 +49,13 @@
47
49
  "node": "\u003e=20",
48
50
  "dsh": "\u003e=0.1.7-rc.2"
49
51
  },
52
+ "scripts": {
53
+ "test": "node --test",
54
+ "test:all": "node --test \u0026\u0026 node --import ./test/route-loader-register.mjs --test test/fail-route.test.mjs test/audio-wiring.test.mjs",
55
+ "test:route": "node --import ./test/route-loader-register.mjs --test test/fail-route.test.mjs",
56
+ "test:audio-wire": "node --import ./test/route-loader-register.mjs --test test/audio-wiring.test.mjs",
57
+ "check:client": "node ./test/client-bundle.test.mjs"
58
+ },
50
59
  "dsh": {
51
60
  "manifestVersion": 1,
52
61
  "bundle": {
@@ -0,0 +1,184 @@
1
+ /**
2
+ * audio-source 测试:音源解析的穷举验证。
3
+ *
4
+ * 这块决定"到底播什么",写错的表现往往是**静默**的(该说话的变成了静音、
5
+ * 该用警告音的用了完成音),用户很难察觉。所以穷举所有组合。
6
+ *
7
+ * 覆盖:
8
+ * - A1 全局默认解析
9
+ * - A2 按事件覆盖优先于全局
10
+ * - A3 与默认相同的覆盖项被丢弃(保持配置最小)
11
+ * - A4 非法值一律收敛到安全默认,不产生半截脏配置
12
+ * - A5 回退链:想要的拿不到时,保证仍有声音(除非显式 none)
13
+ * - A6 ps1 的 chime → blocked 映射
14
+ * - A7 系统音效的语义映射(失败用警告音,不是完成音)
15
+ * - A8 自定义文件只接受绝对路径与合法扩展名(防"随机失败")
16
+ * - A9 真实场景组合:完成用语音包、失败用系统音(用户举的例子)
17
+ */
18
+ import { test } from 'node:test';
19
+ import assert from 'node:assert/strict';
20
+
21
+ import {
22
+ AUDIO_SOURCES, AUDIO_KINDS, DEFAULT_SOURCE, SYSTEM_SOUNDS,
23
+ normalizeSource, normalizeByKind, resolveSource, audioKindOf,
24
+ effectiveSource, resolveSystemSound, resolveCustomFile,
25
+ } from '../lib/audio-source.js';
26
+
27
+ // ── A1: 全局默认 ────────────────────────────────────────────────────────────
28
+ test('A1: 没设置时所有事件都用 tts(零下载即可说话)', () => {
29
+ assert.equal(normalizeSource(undefined), 'tts');
30
+ for (const kind of AUDIO_KINDS) {
31
+ assert.equal(resolveSource(kind, {}), 'tts', `${kind} 应默认 tts`);
32
+ assert.equal(resolveSource(kind, { audioSource: undefined }), 'tts');
33
+ }
34
+ assert.equal(DEFAULT_SOURCE, 'tts');
35
+ });
36
+
37
+ // ── A2: 按事件覆盖 ──────────────────────────────────────────────────────────
38
+ test('A2: 按事件覆盖优先于全局默认', () => {
39
+ const cfg = { audioSource: 'tts', audioSourceByKind: { fail: 'system' } };
40
+ assert.equal(resolveSource('fail', cfg), 'system', 'fail 应被覆盖');
41
+ assert.equal(resolveSource('done', cfg), 'tts', '未覆盖的事件跟全局');
42
+ assert.equal(resolveSource('question', cfg), 'tts');
43
+ });
44
+
45
+ // ── A3: 配置最小化 ──────────────────────────────────────────────────────────
46
+ test('A3: 与全局默认相同的覆盖项被丢弃', () => {
47
+ const got = normalizeByKind({ done: 'tts', fail: 'system' }, 'tts');
48
+ assert.deepEqual(got, { fail: 'system' },
49
+ 'done 与默认相同应被丢掉,只留真正偏离的 fail');
50
+ });
51
+
52
+ // ── A4: 非法值收敛 ──────────────────────────────────────────────────────────
53
+ test('A4: 非法音源/事件一律丢弃,不产生脏配置', () => {
54
+ assert.equal(normalizeSource('nonsense'), 'tts');
55
+ assert.equal(normalizeSource(123), 'tts');
56
+ assert.equal(normalizeSource(null), 'tts');
57
+
58
+ const got = normalizeByKind({
59
+ done: 'nonsense', // 非法音源
60
+ fail: 'system', // 合法
61
+ notAKind: 'system', // 非法事件名
62
+ question: 123, // 非法类型
63
+ }, 'tts');
64
+ assert.deepEqual(got, { fail: 'system' }, '只保留唯一合法项');
65
+
66
+ // 非对象输入不应崩
67
+ assert.doesNotThrow(() => normalizeByKind(null));
68
+ assert.doesNotThrow(() => normalizeByKind([]));
69
+ assert.deepEqual(normalizeByKind([]), {});
70
+ });
71
+
72
+ // ── A5: 回退链保证"有声音" ─────────────────────────────────────────────────
73
+ test('A5: 想要的拿不到时按回退链降级,绝不无声(除非显式 none)', () => {
74
+ // 选了语音包但没下载 → 退到系统音
75
+ let r = effectiveSource('bundled', { hasBundledVoice: false });
76
+ assert.equal(r.source, 'system');
77
+ assert.equal(r.fellBack, true);
78
+ assert.equal(r.from, 'bundled');
79
+
80
+ // 有语音包就直接用
81
+ r = effectiveSource('bundled', { hasBundledVoice: true });
82
+ assert.equal(r.source, 'bundled');
83
+ assert.equal(r.fellBack, false);
84
+
85
+ // 选了自定义但没配文件 → 退到系统音
86
+ r = effectiveSource('custom', { hasCustomFile: false });
87
+ assert.equal(r.source, 'system');
88
+ assert.equal(r.fellBack, true);
89
+
90
+ // TTS 不可用 → 系统音
91
+ r = effectiveSource('tts', { hasTtsVoice: false });
92
+ assert.equal(r.source, 'system');
93
+
94
+ // 显式 none → 就是 none,不要自作聪明出声
95
+ r = effectiveSource('none', {});
96
+ assert.equal(r.source, 'none');
97
+ assert.equal(r.fellBack, false);
98
+
99
+ // 极端:系统音也被禁(不该发生,但要保证不返回空)
100
+ r = effectiveSource('bundled', { hasBundledVoice: false, hasTtsVoice: false });
101
+ assert.ok(AUDIO_SOURCES.includes(r.source), '必须返回合法音源');
102
+ });
103
+
104
+ // ── A6: chime → blocked 映射 ────────────────────────────────────────────────
105
+ test('A6: ps1 的 chime 映射到 blocked 事件', () => {
106
+ assert.equal(audioKindOf('chime'), 'blocked');
107
+ assert.equal(audioKindOf('done'), 'done');
108
+ assert.equal(audioKindOf('fail'), 'fail');
109
+ assert.equal(audioKindOf('approval'), 'approval');
110
+ assert.equal(audioKindOf('question'), 'question');
111
+ assert.equal(audioKindOf('unknown'), '');
112
+ assert.equal(audioKindOf(''), '');
113
+ });
114
+
115
+ // ── A7: 系统音效语义 ───────────────────────────────────────────────────────
116
+ test('A7: 系统音效按语义映射(失败用警告音,不用完成音)', () => {
117
+ assert.equal(resolveSystemSound('fail'), 'Hand');
118
+ assert.equal(resolveSystemSound('done'), 'Asterisk');
119
+ assert.equal(resolveSystemSound('question'), 'Question');
120
+ assert.equal(resolveSystemSound('approval'), 'Question');
121
+ assert.equal(resolveSystemSound('blocked'), 'Exclamation');
122
+
123
+ // fail 绝不能和 done 同一个音(那会让"失败"听起来像"成功")
124
+ assert.notEqual(resolveSystemSound('fail'), resolveSystemSound('done'));
125
+
126
+ // 用户可覆盖
127
+ assert.equal(resolveSystemSound('done', { systemSounds: { done: 'Beep' } }), 'Beep');
128
+ // 非法值回落到默认
129
+ assert.equal(resolveSystemSound('done', { systemSounds: { done: 'NotASound' } }), 'Asterisk');
130
+
131
+ // 所有返回值都必须是合法音效名
132
+ for (const k of AUDIO_KINDS) {
133
+ assert.ok(SYSTEM_SOUNDS.includes(resolveSystemSound(k)), `${k} 应返回合法音效名`);
134
+ }
135
+ });
136
+
137
+ // ── A8: 自定义文件路径校验 ─────────────────────────────────────────────────
138
+ test('A8: 自定义文件只接受绝对路径 + 合法扩展名(防随机失败)', () => {
139
+ const ok = { customFiles: { done: 'D:\\sounds\\done.wav' } };
140
+ assert.equal(resolveCustomFile('done', ok), 'D:\\sounds\\done.wav');
141
+
142
+ // 相对路径必须拒绝:播放进程 cwd 不确定,会随机失败
143
+ assert.equal(resolveCustomFile('done', { customFiles: { done: 'sounds\\done.wav' } }), '');
144
+ assert.equal(resolveCustomFile('done', { customFiles: { done: './done.wav' } }), '');
145
+
146
+ // UNC 路径可接受
147
+ assert.equal(
148
+ resolveCustomFile('done', { customFiles: { done: '\\\\server\\share\\a.wav' } }),
149
+ '\\\\server\\share\\a.wav',
150
+ );
151
+
152
+ // 非法扩展名拒绝
153
+ assert.equal(resolveCustomFile('done', { customFiles: { done: 'D:\\a.exe' } }), '');
154
+ assert.equal(resolveCustomFile('done', { customFiles: { done: 'D:\\a.txt' } }), '');
155
+
156
+ // 支持的扩展名
157
+ for (const ext of ['wav', 'mp3', 'm4a', 'flac', 'ogg']) {
158
+ assert.ok(resolveCustomFile('done', { customFiles: { done: `D:\\a.${ext}` } }), `应接受 ${ext}`);
159
+ }
160
+
161
+ // 大小写不敏感
162
+ assert.ok(resolveCustomFile('done', { customFiles: { done: 'D:\\A.WAV' } }));
163
+
164
+ // 缺失/空值不崩
165
+ assert.equal(resolveCustomFile('done', {}), '');
166
+ assert.equal(resolveCustomFile('done', null), '');
167
+ });
168
+
169
+ // ── A9: 用户举的真实场景 ───────────────────────────────────────────────────
170
+ test('A9: 用户场景 —— 完成听日语语音包、失败听系统警告音', () => {
171
+ const cfg = {
172
+ audioSource: 'bundled', // 默认用语音包
173
+ audioSourceByKind: { fail: 'system' }, // 但失败改成系统音
174
+ systemSounds: { fail: 'Hand' },
175
+ voiceName: 'zundamon-normal',
176
+ };
177
+ assert.equal(resolveSource('done', cfg), 'bundled', '完成用语音包');
178
+ assert.equal(resolveSource('fail', cfg), 'system', '失败用系统音');
179
+ assert.equal(resolveSystemSound('fail', cfg), 'Hand');
180
+
181
+ // 失败时不该去拿语音包文件
182
+ const eff = effectiveSource(resolveSource('fail', cfg), { hasBundledVoice: true });
183
+ assert.equal(eff.source, 'system', '即使语音包可用,fail 也走 system');
184
+ });