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
@@ -0,0 +1,287 @@
1
+ /**
2
+ * 音源接线测试:`notify()` 到底给哪个脚本传了什么参数。
3
+ *
4
+ * ## 为什么需要
5
+ *
6
+ * `audio-source.js` 的 17 条测试只验"该用哪个音源"这个**判定**,
7
+ * 但判定正确 ≠ 真的按判定执行。中间隔着 `notify()` 的分流:
8
+ *
9
+ * - `audioSource=tts` 时该调 `tts-speak.ps1`,且**不该**让 notify.ps1 出声
10
+ * - `showToast=false` 时不该弹窗
11
+ * - `audioSource=none` / `voice=false` 时不该有任何声音脚本
12
+ *
13
+ * 这些接线写错的表现是**静默**的:用户选了 TTS 却听到语音包,或什么都没听到。
14
+ *
15
+ * ## 隔离
16
+ *
17
+ * 不真实 spawn:用测试自身的桩收集参数(与 fail-route 的 loader stub 同思路,
18
+ * 但这里更简单——只验"组出来的参数对不对",不验进程行为)。
19
+ */
20
+ import { test } from 'node:test';
21
+ import assert from 'node:assert/strict';
22
+ import { mkdtempSync, rmSync } from 'node:fs';
23
+ import { tmpdir } from 'node:os';
24
+ import { join } from 'node:path';
25
+
26
+ const HOME = mkdtempSync(join(tmpdir(), 'notify-wiring-audio-'));
27
+ process.env.DSH_HOME = HOME;
28
+ process.on('exit', () => { try { rmSync(HOME, { recursive: true, force: true }); } catch { /* ignore */ } });
29
+
30
+ const { apply } = await import('../lib/index.js');
31
+
32
+ /**
33
+ * 造一个最小 ctx,收集所有 spawn 出去的 argv。
34
+ *
35
+ * 插件用 `spawn(POWERSHELL_EXE, args, ...)`;这里没法替换已 import 的 spawn,
36
+ * 所以改为**拦截 tts/notify 脚本路径**:构造一个会触发 notify 的事件流,
37
+ * 然后读 `globalThis.__SPAWN_CALLS`(由 test/route-loader-register.mjs 的 stub 提供)。
38
+ *
39
+ * 本文件必须用 `--import ./test/route-loader-register.mjs` 跑,
40
+ * 否则 `globalThis.__SPAWN_CALLS` 不存在,首个用例会 skip。
41
+ */
42
+ function stubActive() {
43
+ return globalThis.__STUB_CHILD_PROCESS__ === true;
44
+ }
45
+
46
+ function mkCtx() {
47
+ const handlers = new Map();
48
+ return {
49
+ on(name, fn) { handlers.set(name, fn); return { dispose() { handlers.delete(name); } }; },
50
+ effect(fn) { try { const d = fn(); return { dispose() { try { if (typeof d === 'function') d(); } catch { /* ignore */ } } }; } catch { return { dispose() { /* ignore */ } }; } },
51
+ inject(_names, fn) { try { fn({ webServer: null }); } catch { /* ignore */ } return { dispose() { /* ignore */ } }; },
52
+ get() { return undefined; },
53
+ logger: { info() { /* silent */ }, warn() { /* silent */ }, error() { /* silent */ } },
54
+ emit(name, ...args) { const h = handlers.get(name); if (h) h(...args); },
55
+ };
56
+ }
57
+
58
+ /** 跑一次完整"完成"事件流,返回 spawn 的 argv 列表。 */
59
+ function runDone(pluginCfg) {
60
+ globalThis.__SPAWN_CALLS = [];
61
+ const ctx = mkCtx();
62
+ apply(ctx, { idleGate: false, subagents: false, ...pluginCfg });
63
+
64
+ const agent = { id: 'agent-1', session: { id: 'sess-1' }, status: 'running' };
65
+ // 触发完成:turn/end(completed) → agent/status idle
66
+ ctx.emit('session/event', agent.session, { type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } });
67
+ agent.status = 'idle';
68
+ ctx.emit('agent/status', { agent, status: 'running' });
69
+ ctx.emit('agent/status', { agent, status: 'idle' });
70
+
71
+ return (globalThis.__SPAWN_CALLS || []).map((c) => (c && (c.args || c.argv)) || []);
72
+ }
73
+
74
+ /** 从 argv 里取出 `-File` 后面的脚本名。 */
75
+ function scriptOf(argv) {
76
+ const arr = Array.isArray(argv) ? argv : [];
77
+ const i = arr.indexOf('-File');
78
+ return i >= 0 && arr[i + 1] ? String(arr[i + 1]).replace(/\\/g, '/').split('/').pop() : '';
79
+ }
80
+
81
+ /** 取 `-Flag value`。 */
82
+ function argOf(argv, flag) {
83
+ const arr = Array.isArray(argv) ? argv : [];
84
+ const i = arr.indexOf(flag);
85
+ return i >= 0 ? arr[i + 1] : undefined;
86
+ }
87
+
88
+ // ⚠️ skip 必须在**用例执行时**求值,不能在模块加载时算好:const SKIP = ...`n// 会在 import 那一刻定死,而那时代码还没机会说它生效。故写成函数。
89
+ function skipReason() {
90
+ if (!process.env.DSH_ROUTE_LOADER && !stubActive()) return '需 npm run test:route(带 --import loader)';
91
+ if (!stubActive()) return 'loader stub 未生效';
92
+ return false;
93
+ }
94
+
95
+ // ── A1: audioSource=tts 应调 tts-speak.ps1 ────────────────────────────────
96
+ test('A1: audioSource=tts 时调用 tts-speak.ps1', { skip: skipReason() }, () => {
97
+ const calls = runDone({ audioSource: 'tts', voice: true, showToast: true });
98
+ const scripts = calls.map(scriptOf);
99
+ assert.ok(scripts.includes('tts-speak.ps1'),
100
+ `应调 TTS,实际脚本: ${JSON.stringify(scripts)}`);
101
+
102
+ const tts = calls.find((a) => scriptOf(a) === 'tts-speak.ps1');
103
+ // v0.4.8 起台词从台词库随机取(不再固定一句),所以断言:非空、不含"主人"。
104
+ const ttsText = argOf(tts, '-Text');
105
+ assert.ok(typeof ttsText === 'string' && ttsText.length > 0, 'TTS 台词不应为空');
106
+ assert.doesNotMatch(ttsText, /主人/, 'TTS 台词不应含"主人"');
107
+ });
108
+
109
+ // ── A2: TTS 台词不含"主人" ────────────────────────────────────────────────
110
+ test('A2: TTS 台词不含「主人」(用户明确要求去掉)', { skip: skipReason() }, () => {
111
+ const cases = [
112
+ ['completed', '任务完成了'],
113
+ ['error', '任务失败了,抱歉'],
114
+ ];
115
+ for (const [turnKind] of cases) {
116
+ globalThis.__SPAWN_CALLS = [];
117
+ const ctx = mkCtx();
118
+ apply(ctx, { audioSource: 'tts', voice: true, showToast: false, idleGate: false });
119
+ const agent = { id: 'a', session: { id: 's' }, status: 'running' };
120
+ ctx.emit('session/event', agent.session, { type: 'turn/end', data: { turn: 1, reason: { kind: turnKind } } });
121
+ agent.status = 'idle';
122
+ ctx.emit('agent/status', { agent, status: 'running' });
123
+ ctx.emit('agent/status', { agent, status: 'idle' });
124
+
125
+ for (const a of (globalThis.__SPAWN_CALLS || []).map((c) => c.args || c.argv || [])) {
126
+ const text = argOf(a, '-Text');
127
+ if (text !== undefined) {
128
+ assert.doesNotMatch(text, /主人/, `台词不该含"主人",实际: ${text}`);
129
+ assert.ok(text.length > 0, '台词不应为空');
130
+ }
131
+ }
132
+ }
133
+ });
134
+
135
+ // ── A3: showToast=false 时不弹窗 ──────────────────────────────────────────
136
+ test('A3: showToast=false 时只出声不弹窗', { skip: skipReason() }, () => {
137
+ const calls = runDone({ audioSource: 'tts', voice: true, showToast: false });
138
+ const notifyCalls = calls.filter((a) => scriptOf(a) === 'dsh-notify.ps1');
139
+ assert.equal(notifyCalls.length, 0,
140
+ `showToast=false 时不该调 dsh-notify.ps1(会弹窗),实际 ${notifyCalls.length} 次`);
141
+ // 但仍应有声音
142
+ assert.ok(calls.some((a) => scriptOf(a) === 'tts-speak.ps1'), '仍应有语音');
143
+ });
144
+
145
+ // ── A4: voice=false 时完全静音 ────────────────────────────────────────────
146
+ test('A4: voice=false 时不产生任何声音', { skip: skipReason() }, () => {
147
+ const calls = runDone({ audioSource: 'tts', voice: false, showToast: true });
148
+ assert.equal(calls.filter((a) => scriptOf(a) === 'tts-speak.ps1').length, 0,
149
+ 'voice=false 不该调 TTS');
150
+ const notify = calls.find((a) => scriptOf(a) === 'dsh-notify.ps1');
151
+ assert.ok(notify, '仍应弹窗');
152
+ assert.ok(notify.includes('-NoSound'), '弹窗应带 -NoSound');
153
+ });
154
+
155
+ // ── A5: audioSource=none 时不出声但可弹窗 ─────────────────────────────────
156
+ test('A5: audioSource=none 时不出声(但可弹窗)', { skip: skipReason() }, () => {
157
+ const calls = runDone({ audioSource: 'none', voice: true, showToast: true });
158
+ assert.equal(calls.filter((a) => scriptOf(a) === 'tts-speak.ps1').length, 0, '不该调 TTS');
159
+ const notify = calls.find((a) => scriptOf(a) === 'dsh-notify.ps1');
160
+ assert.ok(notify, '仍应弹窗');
161
+ assert.ok(notify.includes('-NoSound'), 'none 应静音');
162
+ });
163
+
164
+ // ── A6: audioSource=bundled 时走 notify.ps1 且不出错 ──────────────────────
165
+ test('A6: audioSource=bundled 时走 dsh-notify.ps1(语音包)', { skip: skipReason() }, () => {
166
+ const calls = runDone({ audioSource: 'bundled', voice: true, showToast: true });
167
+ assert.equal(calls.filter((a) => scriptOf(a) === 'tts-speak.ps1').length, 0,
168
+ 'bundled 不该走 TTS');
169
+ const notify = calls.find((a) => scriptOf(a) === 'dsh-notify.ps1');
170
+ assert.ok(notify, '应调 dsh-notify.ps1');
171
+ assert.ok(!notify.includes('-NoSound'), 'bundled 应出声,不该带 -NoSound');
172
+ });
173
+
174
+ // ── A7: 音量与语速透传到 TTS ─────────────────────────────────────────────
175
+ test('A7: 音量与语速透传给 TTS', { skip: skipReason() }, () => {
176
+ const calls = runDone({ audioSource: 'tts', voice: true, showToast: false, volume: 42, ttsRate: -3 });
177
+ const tts = calls.find((a) => scriptOf(a) === 'tts-speak.ps1');
178
+ assert.ok(tts, '应调 TTS');
179
+ assert.equal(argOf(tts, '-Volume'), '42', '音量应透传');
180
+ assert.equal(argOf(tts, '-Rate'), '-3', '语速应透传');
181
+ });
182
+
183
+ // ── A8: 兜底 —— 未配置 audioSource 时默认 TTS ────────────────────────────
184
+ test('A8: 未指定 audioSource 时默认走 TTS(零下载也能说话)', { skip: skipReason() }, () => {
185
+ const calls = runDone({ voice: true, showToast: false }); // 不传 audioSource
186
+ assert.ok(calls.some((a) => scriptOf(a) === 'tts-speak.ps1'),
187
+ '默认音源应是 tts');
188
+ });
189
+
190
+ // ── A9-A12: 冷却(v0.4.7)─────────────────────────────────────────────────
191
+ //
192
+ // 用户 2026-10-17 明确要求:"同一会话的冷却我们可以给它关了,
193
+ // 甚至可以把不同会话的冷却给它加上。"
194
+ //
195
+ // 两级冷却语义不同,必须分别验证:
196
+ // 同一会话 → 连响多次只播最后一次
197
+ // 所有会话 → 响过一次后,别的会话也不播
198
+ //
199
+ // 注意:这里不假装能测"时间流逝",用的是"同一毫秒内连续触发"
200
+ // —— 只要冷却 > 0,第二次就必然落在窗口内,这正是我们要的行为。
201
+
202
+ /**
203
+ * 触发两个**不同会话**各完成一次,返回实际发出的播报次数。
204
+ * @param {object} pluginCfg
205
+ */
206
+ function runTwoSessions(pluginCfg) {
207
+ globalThis.__SPAWN_CALLS = [];
208
+ const ctx = mkCtx();
209
+ apply(ctx, { idleGate: false, subagents: false, ...pluginCfg });
210
+
211
+ const fire = (id, n) => {
212
+ const agent = { id: 'agent-' + n, session: { id }, status: 'running' };
213
+ ctx.emit('session/event', agent.session, { type: 'turn/end', data: { turn: n, reason: { kind: 'completed' } } });
214
+ agent.status = 'idle';
215
+ ctx.emit('agent/status', { agent, status: 'running' });
216
+ ctx.emit('agent/status', { agent, status: 'idle' });
217
+ };
218
+ fire('sess-A', 1);
219
+ fire('sess-B', 2);
220
+
221
+ return (globalThis.__SPAWN_CALLS || []).map((c) => (c && (c.args || c.argv)) || []);
222
+ }
223
+
224
+ test('A9: 跨会话冷却关闭(默认 0)时,两个会话都播报', { skip: skipReason() }, () => {
225
+ const calls = runTwoSessions({
226
+ audioSource: 'tts', voice: true, showToast: false,
227
+ debounceSeconds: 0, globalCooldownSeconds: 0,
228
+ });
229
+ const tts = calls.filter((a) => scriptOf(a) === 'tts-speak.ps1');
230
+ assert.equal(tts.length, 2, `两个不同会话都应播报,实际 ${tts.length}`);
231
+ });
232
+
233
+ test('A10: 跨会话冷却开启后,第二个会话被抑制', { skip: skipReason() }, () => {
234
+ const calls = runTwoSessions({
235
+ audioSource: 'tts', voice: true, showToast: false,
236
+ debounceSeconds: 0, // 先排除同会话冷却的干扰
237
+ globalCooldownSeconds: 60,
238
+ });
239
+ const tts = calls.filter((a) => scriptOf(a) === 'tts-speak.ps1');
240
+ assert.equal(tts.length, 1,
241
+ `跨会话冷却应只放行第一次,实际 ${tts.length} 次(第二个会话该被抑制)`);
242
+ });
243
+
244
+ test('A11: 同会话冷却关闭(0)时,同一会话连续完成都播报', { skip: skipReason() }, () => {
245
+ globalThis.__SPAWN_CALLS = [];
246
+ const ctx = mkCtx();
247
+ apply(ctx, {
248
+ idleGate: false, subagents: false,
249
+ audioSource: 'tts', voice: true, showToast: false,
250
+ debounceSeconds: 0, globalCooldownSeconds: 0,
251
+ });
252
+
253
+ // 同一会话连触发两次(不同轮次)
254
+ for (const turn of [1, 2]) {
255
+ const agent = { id: 'agent-same', session: { id: 'sess-SAME' }, status: 'running' };
256
+ ctx.emit('session/event', agent.session, { type: 'turn/end', data: { turn, reason: { kind: 'completed' } } });
257
+ agent.status = 'idle';
258
+ ctx.emit('agent/status', { agent, status: 'running' });
259
+ ctx.emit('agent/status', { agent, status: 'idle' });
260
+ }
261
+
262
+ const tts = (globalThis.__SPAWN_CALLS || []).map((c) => c.args || c.argv || [])
263
+ .filter((a) => scriptOf(a) === 'tts-speak.ps1');
264
+ assert.equal(tts.length, 2, `debounceSeconds=0 时两次都应播,实际 ${tts.length}`);
265
+ });
266
+
267
+ test('A12: 同会话冷却 > 0 时,连响两次只播一次', { skip: skipReason() }, () => {
268
+ globalThis.__SPAWN_CALLS = [];
269
+ const ctx = mkCtx();
270
+ apply(ctx, {
271
+ idleGate: false, subagents: false,
272
+ audioSource: 'tts', voice: true, showToast: false,
273
+ debounceSeconds: 60, globalCooldownSeconds: 0,
274
+ });
275
+
276
+ for (const turn of [1, 2]) {
277
+ const agent = { id: 'agent-same', session: { id: 'sess-SAME2' }, status: 'running' };
278
+ ctx.emit('session/event', agent.session, { type: 'turn/end', data: { turn, reason: { kind: 'completed' } } });
279
+ agent.status = 'idle';
280
+ ctx.emit('agent/status', { agent, status: 'running' });
281
+ ctx.emit('agent/status', { agent, status: 'idle' });
282
+ }
283
+
284
+ const tts = (globalThis.__SPAWN_CALLS || []).map((c) => c.args || c.argv || [])
285
+ .filter((a) => scriptOf(a) === 'tts-speak.ps1');
286
+ assert.equal(tts.length, 1, `同会话 60 秒冷却应只播一次,实际 ${tts.length}`);
287
+ });