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.
- package/README.md +216 -176
- package/docs/audio-audit/README.md +164 -0
- package/docs/audio-audit/fetch-snapshots.ps1 +76 -0
- package/docs/audio-audit/snapshots/ameharu-hau.txt +89 -0
- package/docs/audio-audit/snapshots/goki-nuigurumi.txt +73 -0
- package/docs/audio-audit/snapshots/kasukabe-tsumugi.txt +197 -0
- package/docs/audio-audit/snapshots/manbetsu-hanamaru.txt +27 -0
- package/docs/audio-audit/snapshots/meimei-meria.txt +84 -0
- package/docs/audio-audit/snapshots/mochiko.txt +27 -0
- package/docs/audio-audit/snapshots/namiine-ritsu.txt +76 -0
- package/docs/audio-audit/snapshots/shirakami-kotaro.txt +158 -0
- package/docs/audio-audit/snapshots/sss-anonymous-common.txt +142 -0
- package/docs/audio-audit/snapshots/sss-zundamon.txt +142 -0
- package/docs/audio-audit/snapshots/voicevox-software.txt +41 -0
- package/docs/audio-audit/snapshots/voicevox-vvm-terms.txt +385 -0
- package/docs/audio-audit/snapshots/yurei-chan.txt +112 -0
- package/docs//345/276/205/345/201/232/346/270/205/345/215/225.md +218 -0
- 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
- package/lib/client.js +50 -8
- package/lib/config-route.js +251 -209
- package/lib/http-guard.js +222 -0
- package/lib/index.js +1822 -1768
- package/lib/session-bridge.js +138 -117
- package/package.json +11 -2
- package/test/audio-source.test.mjs +184 -0
- package/test/audio-wiring.test.mjs +287 -0
- package/test/client-bundle.test.mjs +671 -0
- package/test/client-contract.test.mjs +118 -0
- package/test/config-coverage.test.mjs +109 -0
- package/test/config-route.test.mjs +212 -0
- package/test/config-schema.test.mjs +147 -0
- package/test/config-store.test.mjs +256 -0
- package/test/dev-reload.test.mjs +132 -0
- package/test/effective-for-client.test.mjs +142 -0
- package/test/fail-route.test.mjs +128 -0
- package/test/fail-voice.test.mjs +193 -0
- package/test/http-guard.test.mjs +143 -0
- package/test/http-integration.test.mjs +284 -0
- package/test/idle-gate.test.mjs +167 -0
- package/test/preview.test.mjs +92 -0
- package/test/real-cordis.test.mjs +204 -0
- package/test/release-voice-guard.test.mjs +185 -0
- package/test/reload-live.test.mjs +261 -0
- package/test/reload-verification.test.mjs +218 -0
- package/test/route-loader-register.mjs +22 -0
- package/test/scalar-guard.test.mjs +125 -0
- package/test/session-jump.test.mjs +266 -0
- package/test/stub-child-process.mjs +43 -0
- package/test/tts-lines.test.mjs +112 -0
- package/test/version-consistency.test.mjs +98 -0
- package/test/voice-availability.test.mjs +90 -0
- package/test/wav-gain.test.mjs +138 -0
- package/test/wiring.test.mjs +287 -0
- package/tools/notify-config.ps1 +472 -471
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config-store 测试:可持久配置层的正确性与 fail-closed 契约。
|
|
3
|
+
*
|
|
4
|
+
* 全部用合成 `$DSH_HOME`(临时目录),**不碰真实 profile / storages**。
|
|
5
|
+
*
|
|
6
|
+
* 覆盖重点(都是真会出事的路径):
|
|
7
|
+
* - 三层优先级:文件值覆盖 patch 值,patch 值覆盖 DEFAULTS
|
|
8
|
+
* - fail-closed:坏 JSON 不被覆盖、不被当成"没有偏好"
|
|
9
|
+
* - 白名单:未知键丢弃,不会污染配置文件
|
|
10
|
+
* - 原子写:不留半截文件、不留 .tmp
|
|
11
|
+
* - 越界值:volume 101 / -1 / NaN 一律拒绝
|
|
12
|
+
*/
|
|
13
|
+
import { test } from 'node:test';
|
|
14
|
+
import assert from 'node:assert/strict';
|
|
15
|
+
import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync, readdirSync } from 'node:fs';
|
|
16
|
+
import { tmpdir } from 'node:os';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
CONFIG_VERSION,
|
|
21
|
+
CONFIG_RELATIVE_PATH,
|
|
22
|
+
CONFIG_KEYS,
|
|
23
|
+
safeHome,
|
|
24
|
+
configPath,
|
|
25
|
+
sanitize,
|
|
26
|
+
readConfig,
|
|
27
|
+
writeConfig,
|
|
28
|
+
mergeConfig,
|
|
29
|
+
} from '../lib/config-store.js';
|
|
30
|
+
|
|
31
|
+
/** 造一个隔离的 $DSH_HOME。 */
|
|
32
|
+
function mkHome() {
|
|
33
|
+
const dir = mkdtempSync(join(tmpdir(), 'notify-cfg-'));
|
|
34
|
+
return { dir, env: { DSH_HOME: dir } };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function cleanup(h) {
|
|
38
|
+
try { rmSync(h.dir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── C1: 路径解析 ────────────────────────────────────────────────────────────
|
|
42
|
+
test('C1: safeHome / configPath 正确解析 $DSH_HOME(含尾部斜杠)', () => {
|
|
43
|
+
assert.equal(safeHome({ DSH_HOME: 'D:\\DSH' }), 'D:\\DSH');
|
|
44
|
+
assert.equal(safeHome({ DSH_HOME: 'D:\\DSH\\\\' }), 'D:\\DSH');
|
|
45
|
+
assert.equal(safeHome({}), '', '根本没有 DSH_HOME 键时应返回空串');
|
|
46
|
+
assert.equal(safeHome({ DSH_HOME: '' }), '', 'DSH_HOME 为空串时应返回空串');
|
|
47
|
+
// null/undefined 的语义是「未提供」,按设计回退到 process.env ——
|
|
48
|
+
// 所以这里不能断言 ''(ci 环境可能真有 DSH_HOME)。只断言类型与幂等。
|
|
49
|
+
assert.equal(typeof safeHome(null), 'string');
|
|
50
|
+
assert.equal(typeof safeHome(undefined), 'string');
|
|
51
|
+
assert.equal(safeHome(null), safeHome(process.env), 'null 应等价于 process.env');
|
|
52
|
+
|
|
53
|
+
assert.equal(configPath({}), '', '$DSH_HOME 缺失时应返回空串而不是拼出相对路径');
|
|
54
|
+
assert.match(configPath({ DSH_HOME: 'D:\\DSH' }), /config\.json$/);
|
|
55
|
+
// 路径构成必须是本插件自己的目录
|
|
56
|
+
assert.match(configPath({ DSH_HOME: 'D:\\DSH' }).replace(/\\/g, '/'), /storages\/dsh-notify-say\/config\.json$/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// ── C2: 文件不存在 = 没有偏好,不是错误 ─────────────────────────────────────
|
|
60
|
+
test('C2: 文件不存在时返回 ok:true + 空值(调用方用 patch 默认值)', () => {
|
|
61
|
+
const h = mkHome();
|
|
62
|
+
try {
|
|
63
|
+
const r = readConfig(h.env);
|
|
64
|
+
assert.equal(r.ok, true);
|
|
65
|
+
assert.deepEqual(r.value, {});
|
|
66
|
+
} finally { cleanup(h); }
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// ── C3: 写入后能读回;白名单外的键被丢弃 ────────────────────────────────────
|
|
70
|
+
test('C3: 写入白名单键并读回;未知键被丢弃且不落盘', () => {
|
|
71
|
+
const h = mkHome();
|
|
72
|
+
try {
|
|
73
|
+
const w = writeConfig({ volume: 42, voiceName: 'ankomon', evilKey: 'x', __proto__: 'y' }, h.env);
|
|
74
|
+
assert.equal(w.ok, true, '写入应成功');
|
|
75
|
+
assert.deepEqual(w.written.sort(), ['voiceName', 'volume'], '只应写入白名单键');
|
|
76
|
+
assert.ok(w.dropped === undefined || w.dropped.includes('evilKey'));
|
|
77
|
+
|
|
78
|
+
const r = readConfig(h.env);
|
|
79
|
+
assert.equal(r.ok, true);
|
|
80
|
+
assert.equal(r.value.volume, 42);
|
|
81
|
+
assert.equal(r.value.voiceName, 'ankomon');
|
|
82
|
+
assert.equal(r.value.evilKey, undefined, '未知键不得落盘');
|
|
83
|
+
|
|
84
|
+
// 磁盘文件里也不该有 evilKey
|
|
85
|
+
const raw = readFileSync(configPath(h.env), 'utf8');
|
|
86
|
+
assert.ok(!raw.includes('evilKey'), '未知键不得出现在文件里');
|
|
87
|
+
} finally { cleanup(h); }
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ── C4: 部分写入是合并,不是覆盖 ────────────────────────────────────────────
|
|
91
|
+
test('C4: 二次写入只改指定键,其余保留(合并语义)', () => {
|
|
92
|
+
const h = mkHome();
|
|
93
|
+
try {
|
|
94
|
+
writeConfig({ volume: 30, voiceName: 'ankomon' }, h.env);
|
|
95
|
+
writeConfig({ volume: 90 }, h.env);
|
|
96
|
+
const r = readConfig(h.env);
|
|
97
|
+
assert.equal(r.value.volume, 90, 'volume 应被更新');
|
|
98
|
+
assert.equal(r.value.voiceName, 'ankomon', '未指定的键必须保留');
|
|
99
|
+
} finally { cleanup(h); }
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// ── C5: fail-closed —— 坏文件不被覆盖 ───────────────────────────────────────
|
|
103
|
+
test('C5: 磁盘上是坏 JSON 时报告 corrupt,且**不覆盖**用户原文件', () => {
|
|
104
|
+
const h = mkHome();
|
|
105
|
+
try {
|
|
106
|
+
const p = configPath(h.env);
|
|
107
|
+
// 先建目录再写坏内容
|
|
108
|
+
writeConfig({ volume: 50 }, h.env);
|
|
109
|
+
const bad = '{ this is not json';
|
|
110
|
+
writeFileSync(p, bad, 'utf8');
|
|
111
|
+
|
|
112
|
+
const r = readConfig(h.env);
|
|
113
|
+
assert.equal(r.ok, false);
|
|
114
|
+
assert.equal(r.reason, 'corrupt');
|
|
115
|
+
|
|
116
|
+
// 关键:写入必须被拒绝,且原文件字节不变
|
|
117
|
+
const w = writeConfig({ volume: 10 }, h.env);
|
|
118
|
+
assert.equal(w.ok, false, 'corrupt 时写入必须被拒绝');
|
|
119
|
+
assert.equal(w.reason, 'corrupt');
|
|
120
|
+
assert.equal(readFileSync(p, 'utf8'), bad, '坏文件必须原样保留,便于人工救');
|
|
121
|
+
} finally { cleanup(h); }
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// ── C6: 版本不认识 → 也当 corrupt,不覆盖 ───────────────────────────────────
|
|
125
|
+
test('C6: version 不匹配时报告 corrupt,不覆盖', () => {
|
|
126
|
+
const h = mkHome();
|
|
127
|
+
try {
|
|
128
|
+
writeConfig({ volume: 50 }, h.env);
|
|
129
|
+
const p = configPath(h.env);
|
|
130
|
+
writeFileSync(p, JSON.stringify({ version: 999, values: { volume: 1 } }), 'utf8');
|
|
131
|
+
const r = readConfig(h.env);
|
|
132
|
+
assert.equal(r.ok, false);
|
|
133
|
+
assert.equal(r.reason, 'corrupt');
|
|
134
|
+
assert.equal(writeConfig({ volume: 2 }, h.env).ok, false, '未知版本不得被覆盖');
|
|
135
|
+
} finally { cleanup(h); }
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// ── C7: 越界值被拒 ──────────────────────────────────────────────────────────
|
|
139
|
+
test('C7: volume 越界/非法被拒,不写坏值', () => {
|
|
140
|
+
const h = mkHome();
|
|
141
|
+
try {
|
|
142
|
+
const bad = sanitize({ volume: 101 });
|
|
143
|
+
assert.equal(bad.value.volume, undefined, 'volume 101 必须被拒');
|
|
144
|
+
assert.equal(sanitize({ volume: -1 }).value.volume, undefined);
|
|
145
|
+
assert.equal(sanitize({ volume: 'abc' }).value.volume, undefined);
|
|
146
|
+
assert.equal(sanitize({ volume: NaN }).value.volume, undefined);
|
|
147
|
+
assert.equal(sanitize({ volume: 0 }).value.volume, 0, '0 是合法值(静音)');
|
|
148
|
+
assert.equal(sanitize({ volume: 100 }).value.volume, 100, '100 是合法值(上限)');
|
|
149
|
+
assert.equal(sanitize({ volume: 50.6 }).value.volume, 51, '小数应取整');
|
|
150
|
+
} finally { /* no home needed */ }
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ── C8: 原子写 —— 不留 .tmp / 半截文件 ──────────────────────────────────────
|
|
154
|
+
test('C8: 写入后目录里只有 config.json,没有 .tmp 残留', () => {
|
|
155
|
+
const h = mkHome();
|
|
156
|
+
try {
|
|
157
|
+
writeConfig({ volume: 60 }, h.env);
|
|
158
|
+
const dir = join(h.dir, 'storages', 'dsh-notify-say');
|
|
159
|
+
const files = readdirSync(dir);
|
|
160
|
+
assert.deepEqual(files, ['config.json'], '不应留下 .tmp');
|
|
161
|
+
// 文件必须是完整可解析 JSON
|
|
162
|
+
const doc = JSON.parse(readFileSync(join(dir, 'config.json'), 'utf8'));
|
|
163
|
+
assert.equal(doc.version, CONFIG_VERSION);
|
|
164
|
+
assert.equal(doc.values.volume, 60);
|
|
165
|
+
} finally { cleanup(h); }
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// ── C9: 三层优先级 ──────────────────────────────────────────────────────────
|
|
169
|
+
test('C9: mergeConfig —— 文件值覆盖 patch 值;两者都没有的键不出现', () => {
|
|
170
|
+
const patch = { volume: 80, voiceName: 'zundamon-normal', idleGate: true };
|
|
171
|
+
const file = { volume: 100, voice: false };
|
|
172
|
+
|
|
173
|
+
const merged = mergeConfig(patch, file);
|
|
174
|
+
assert.equal(merged.volume, 100, '文件值必须覆盖 patch');
|
|
175
|
+
assert.equal(merged.voice, false, '文件里的布尔也要覆盖');
|
|
176
|
+
assert.equal(merged.voiceName, 'zundamon-normal', '文件没写的键沿用 patch');
|
|
177
|
+
assert.equal(merged.idleGate, true, '文件没写的键沿用 patch');
|
|
178
|
+
|
|
179
|
+
// 空 patch / 空 file 不应崩
|
|
180
|
+
assert.deepEqual(mergeConfig(undefined, undefined), {});
|
|
181
|
+
assert.deepEqual(mergeConfig(null, null), {});
|
|
182
|
+
assert.deepEqual(mergeConfig({ a: 1 }, null), { a: 1 });
|
|
183
|
+
assert.deepEqual(mergeConfig({ a: 1 }, { a: 2 }), { a: 2 });
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// ── C10: 无 $DSH_HOME 时安全降级 ────────────────────────────────────────────
|
|
187
|
+
test('C10: 缺 $DSH_HOME 时读写都安全失败,不抛异常', () => {
|
|
188
|
+
const env = {};
|
|
189
|
+
const r = readConfig(env);
|
|
190
|
+
assert.equal(r.ok, false);
|
|
191
|
+
assert.equal(r.reason, 'no-home');
|
|
192
|
+
const w = writeConfig({ volume: 10 }, env);
|
|
193
|
+
assert.equal(w.ok, false);
|
|
194
|
+
assert.equal(w.reason, 'no-home');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// ── C11: 白名单键清单稳定(防有人悄悄删键导致 GUI 存的配置被丢)──────────
|
|
198
|
+
test('C11: CONFIG_KEYS 覆盖 GUI 需要的全部键', () => {
|
|
199
|
+
const need = ['voice', 'voiceMode', 'voiceName', 'volume',
|
|
200
|
+
'notifyOnComplete', 'notifyOnError', 'notifyOnBlocked',
|
|
201
|
+
'notifyOnApproval', 'notifyOnQuestion', 'subagents', 'idleGate'];
|
|
202
|
+
for (const k of need) {
|
|
203
|
+
assert.ok(CONFIG_KEYS.includes(k), `CONFIG_KEYS 缺少 ${k}`);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// ── S12-S15: v0.4.4 自定义音频路径校验 ────────────────────────────────────
|
|
208
|
+
// 自定义音频只记**绝对路径**,不复制文件。所以路径校验就是唯一的安全边界:
|
|
209
|
+
// 放行错了会让播放进程去读任意位置;放行相对路径则会随 cwd 漂移而随机失败。
|
|
210
|
+
|
|
211
|
+
test('S12: customFile* 接受绝对路径(盘符与 UNC)', () => {
|
|
212
|
+
const a = sanitize({ customFileDone: 'D:\\Music\\a.mp3' });
|
|
213
|
+
assert.equal(a.value.customFileDone, 'D:\\Music\\a.mp3');
|
|
214
|
+
|
|
215
|
+
const b = sanitize({ customFileFail: '\\\\server\\share\\b.wav' });
|
|
216
|
+
assert.equal(b.value.customFileFail, '\\\\server\\share\\b.wav');
|
|
217
|
+
|
|
218
|
+
// 支持的扩展名
|
|
219
|
+
for (const ext of ['wav', 'mp3', 'm4a', 'flac', 'ogg', 'WAV']) {
|
|
220
|
+
const r = sanitize({ customFileDone: `D:\\a.${ext}` });
|
|
221
|
+
assert.ok(r.value.customFileDone, `应接受 .${ext}`);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('S13: customFile* 拒绝相对路径(防随 cwd 随机失败)', () => {
|
|
226
|
+
for (const p of ['relative.mp3', './a.wav', 'sounds\\a.wav', '..\\a.wav']) {
|
|
227
|
+
const r = sanitize({ customFileDone: p });
|
|
228
|
+
assert.equal(r.value.customFileDone, undefined, `应拒绝: ${p}`);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('S14: customFile* 拒绝设备命名空间前缀与非法扩展名', () => {
|
|
233
|
+
// \\?\ 与 \\.\ 能绕开路径规范化,必须挡掉
|
|
234
|
+
for (const p of ['\\\\?\\C:\\a.wav', '\\\\.\\PhysicalDrive0']) {
|
|
235
|
+
const r = sanitize({ customFileDone: p });
|
|
236
|
+
assert.equal(r.value.customFileDone, undefined, `应拒绝设备前缀: ${p}`);
|
|
237
|
+
}
|
|
238
|
+
// 非法扩展名
|
|
239
|
+
for (const p of ['D:\\a.exe', 'D:\\a.txt', 'D:\\a.bat']) {
|
|
240
|
+
const r = sanitize({ customFileDone: p });
|
|
241
|
+
assert.equal(r.value.customFileDone, undefined, `应拒绝扩展名: ${p}`);
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('S15: 五个事件的 customFile 键都可写', () => {
|
|
246
|
+
const patch = {
|
|
247
|
+
customFileDone: 'D:\\a.wav',
|
|
248
|
+
customFileFail: 'D:\\b.wav',
|
|
249
|
+
customFileQuestion: 'D:\\c.wav',
|
|
250
|
+
customFileApproval: 'D:\\d.wav',
|
|
251
|
+
customFileBlocked: 'D:\\e.wav',
|
|
252
|
+
};
|
|
253
|
+
const r = sanitize(patch);
|
|
254
|
+
assert.deepEqual(Object.keys(r.value).sort(), Object.keys(patch).sort(),
|
|
255
|
+
'五个事件键都应被白名单接受(否则配置存不进去,用户选了文件也不生效)');
|
|
256
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dev-reload 测试:热重载的核心纯逻辑。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么这些能单独测
|
|
5
|
+
*
|
|
6
|
+
* 真正的 `selfReload()` 要操作活的 ctx / fiber / registry,只能在宿主里验。
|
|
7
|
+
* 但**最容易写错、也最致命**的部分是纯逻辑:
|
|
8
|
+
*
|
|
9
|
+
* - `packageCacheKeys` 该选哪些缓存键(选错 → 重载拿到旧代码,白折腾)
|
|
10
|
+
* - `purgeCaches` 是否真的删掉(用错 delete → 同上)
|
|
11
|
+
* - `restoreCaches` 能否完整回滚(回滚不全 → 插件半死)
|
|
12
|
+
*
|
|
13
|
+
* 这些都可以用**假 cache**(一个真 Map)穷举验证。
|
|
14
|
+
*
|
|
15
|
+
* ## 关键回归
|
|
16
|
+
*
|
|
17
|
+
* 上游 FreeModel 的注释明确记载:Node 24 的 `loadCache` 是 Map 子类,
|
|
18
|
+
* **它自己的 `.delete` 只清"类型槽"**,模块本体仍在。必须用
|
|
19
|
+
* `Map.prototype.delete.call(cache, key)`。本文件用"子类覆写 delete 但不清本体"
|
|
20
|
+
* 的假 cache 来锁住这条 —— 如果用 `cache.delete()`,模块不会被真正删除。
|
|
21
|
+
*/
|
|
22
|
+
import { test } from 'node:test';
|
|
23
|
+
import assert from 'node:assert/strict';
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
packageCacheKeys, purgeCaches, restoreCaches, packageUrlOf, entryUrlOf,
|
|
27
|
+
} from '../lib/dev-reload.js';
|
|
28
|
+
|
|
29
|
+
const PKG = 'file:///plugins/dsh-notify-say/';
|
|
30
|
+
|
|
31
|
+
// ── D1: 只选中本包的键 ────────────────────────────────────────────────────
|
|
32
|
+
test('D1: packageCacheKeys 只选本包路径下的键', () => {
|
|
33
|
+
const cache = new Map([
|
|
34
|
+
['file:///plugins/dsh-notify-say/lib/index.js', 'a'],
|
|
35
|
+
['file:///plugins/dsh-notify-say/lib/client.js', 'b'],
|
|
36
|
+
['file:///plugins/other-plugin/index.js', 'c'],
|
|
37
|
+
['file:///plugins/dsh-notify-say-extra/index.js', 'd'], // 前缀相似但不是本包
|
|
38
|
+
]);
|
|
39
|
+
const keys = packageCacheKeys(cache, PKG);
|
|
40
|
+
assert.deepEqual(keys.sort(), [
|
|
41
|
+
'file:///plugins/dsh-notify-say/lib/client.js',
|
|
42
|
+
'file:///plugins/dsh-notify-say/lib/index.js',
|
|
43
|
+
], '不该选中 other-plugin 或 dsh-notify-say-extra');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// ── D2: 大小写与百分号编码容错 ────────────────────────────────────────────
|
|
47
|
+
test('D2: 键比较容忍大小写与百分号编码差异', () => {
|
|
48
|
+
const cache = new Map([
|
|
49
|
+
['file:///PLUGINS/dsh-notify-say/lib/index.js', 'x'], // 大写
|
|
50
|
+
['file:///plugins/dsh-notify-say/lib/a%20b.js', 'y'], // 含编码
|
|
51
|
+
]);
|
|
52
|
+
const keys = packageCacheKeys(cache, PKG);
|
|
53
|
+
assert.equal(keys.length, 2, '大写路径与含编码的路径都应命中');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// ── D3: 非字符串键不崩 ────────────────────────────────────────────────────
|
|
57
|
+
test('D3: 非字符串键被跳过,不抛异常', () => {
|
|
58
|
+
const cache = new Map([[Symbol('s'), 'x'], [42, 'y'], ['file:///plugins/dsh-notify-say/a.js', 'z']]);
|
|
59
|
+
assert.doesNotThrow(() => packageCacheKeys(cache, PKG));
|
|
60
|
+
assert.equal(packageCacheKeys(cache, PKG).length, 1);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ── D4: purge 真的删掉(关键:必须清微模块本体)────────────────────────────
|
|
64
|
+
test('D4: purgeCaches 用 Map.prototype.delete 真正删掉模块本体', () => {
|
|
65
|
+
// 模拟 Node 24 的 LoadCache:是 Map 子类,自己的 delete 只清"类型槽"
|
|
66
|
+
class FakeLoadCache extends Map {
|
|
67
|
+
constructor(entries) {
|
|
68
|
+
super(entries);
|
|
69
|
+
this.types = new Map();
|
|
70
|
+
for (const k of this.keys()) this.types.set(k, 'esm');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ⚠️ 故意写坏:只清类型槽,不清本体 —— 这正是 Node 24 的行为
|
|
74
|
+
delete(key) {
|
|
75
|
+
this.types.delete(key);
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const pkgEntry = 'file:///plugins/dsh-notify-say/lib/index.js';
|
|
81
|
+
const other = 'file:///plugins/other/index.js';
|
|
82
|
+
const cache = new FakeLoadCache([[pkgEntry, { id: 'old-index' }], [other, { id: 'other' }]]);
|
|
83
|
+
|
|
84
|
+
const backup = purgeCaches({ loadCache: cache }, PKG);
|
|
85
|
+
|
|
86
|
+
assert.equal(backup.esm.size, 1, '应备份 1 个本包模块');
|
|
87
|
+
assert.equal(backup.esm.get(pkgEntry)?.id, 'old-index');
|
|
88
|
+
|
|
89
|
+
// 关键断言:本体必须被真正删除
|
|
90
|
+
assert.equal(Map.prototype.has.call(cache, pkgEntry), false,
|
|
91
|
+
'模块本体必须被删掉;若用 cache.delete(key) 只会清类型槽,本体仍在 → 重载拿到旧代码');
|
|
92
|
+
// 别人的模块不受影响
|
|
93
|
+
assert.equal(Map.prototype.has.call(cache, other), true, '不该动其它包的模块');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ── D5: 回滚能把缓存写回去 ────────────────────────────────────────────────
|
|
97
|
+
test('D5: restoreCaches 完整回滚', () => {
|
|
98
|
+
const pkgEntry = 'file:///plugins/dsh-notify-say/lib/index.js';
|
|
99
|
+
const cache = new Map([[pkgEntry, { id: 'old' }]]);
|
|
100
|
+
|
|
101
|
+
// 清空并快照(用原生 Map,行为等价)
|
|
102
|
+
const snap = new Map(cache);
|
|
103
|
+
cache.clear();
|
|
104
|
+
assert.equal(cache.size, 0);
|
|
105
|
+
|
|
106
|
+
restoreCaches({ loadCache: cache }, { esm: snap, cjs: new Map() });
|
|
107
|
+
assert.equal(cache.get(pkgEntry)?.id, 'old', '回滚后应恢复原模块');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ── D6: 结构缺失时不崩 ────────────────────────────────────────────────────
|
|
111
|
+
test('D6: loadCache 缺失/畸形时安全返回空备份', () => {
|
|
112
|
+
for (const internal of [undefined, null, {}, { loadCache: null }, { loadCache: {} }]) {
|
|
113
|
+
let backup;
|
|
114
|
+
assert.doesNotThrow(() => { backup = purgeCaches(internal, PKG); });
|
|
115
|
+
assert.equal(backup.esm.size, 0, '拿不到缓存应返回空备份,而不是抛');
|
|
116
|
+
assert.doesNotThrow(() => restoreCaches(internal, backup));
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ── D7: URL 推导 ──────────────────────────────────────────────────────────
|
|
121
|
+
test('D7: packageUrlOf / entryUrlOf 正确推导', () => {
|
|
122
|
+
const mod = 'file:///plugins/dsh-notify-say/lib/dev-reload.js';
|
|
123
|
+
assert.equal(packageUrlOf(mod), 'file:///plugins/dsh-notify-say/');
|
|
124
|
+
assert.equal(entryUrlOf(mod), 'file:///plugins/dsh-notify-say/lib/index.js');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ── D8: 空 cache 不产生假备份 ─────────────────────────────────────────────
|
|
128
|
+
test('D8: 本包不在缓存里时返回空备份(调用方据此放弃重载)', () => {
|
|
129
|
+
const cache = new Map([['file:///plugins/other/index.js', {}]]);
|
|
130
|
+
const backup = purgeCaches({ loadCache: cache }, PKG);
|
|
131
|
+
assert.equal(backup.esm.size, 0, '没清到任何东西 → 调用方应放弃,而不是当成成功');
|
|
132
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* effectiveForClient 测试:内部 cfg → 前端 schema 形状的映射。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么单独测这一层
|
|
5
|
+
*
|
|
6
|
+
* 2026-10-17 连续踩了两个坑,都是这一层的:
|
|
7
|
+
*
|
|
8
|
+
* ① `getEffective: () => effectiveForClient()` **忘了传 cfg** ——
|
|
9
|
+
* 返回全空对象,于是界面上所有设置都显示默认值。
|
|
10
|
+
* 用户看到的是"冷却调成 0 却显示 60 秒""选了音源却还是跟随默认"。
|
|
11
|
+
*
|
|
12
|
+
* ② 直接把内部 cfg 交给前端 —— cfg 用毫秒字段名(debounceMs),
|
|
13
|
+
* 前端读秒字段名(debounceSeconds),键名对不上,同样显示默认值。
|
|
14
|
+
*
|
|
15
|
+
* 这两类错误**都不会让任何既有测试失败**,因为它们只影响"读回来的形状"。
|
|
16
|
+
* 所以必须专门测映射函数本身。
|
|
17
|
+
*/
|
|
18
|
+
import { test } from 'node:test';
|
|
19
|
+
import assert from 'node:assert/strict';
|
|
20
|
+
|
|
21
|
+
import { effectiveForClient } from '../lib/index.js';
|
|
22
|
+
|
|
23
|
+
/** 造一个典型的内部 cfg(normalizeConfig 的产物形状)。 */
|
|
24
|
+
function mkCfg(over) {
|
|
25
|
+
return {
|
|
26
|
+
voice: true,
|
|
27
|
+
voiceMode: 'fixed',
|
|
28
|
+
voiceName: 'zundamon-normal',
|
|
29
|
+
volume: 80,
|
|
30
|
+
notifyOnComplete: true,
|
|
31
|
+
notifyOnError: true,
|
|
32
|
+
notifyOnBlocked: true,
|
|
33
|
+
notifyOnAborted: false,
|
|
34
|
+
notifyOnApproval: true,
|
|
35
|
+
notifyOnQuestion: true,
|
|
36
|
+
subagents: false,
|
|
37
|
+
idleGate: true,
|
|
38
|
+
audioSource: 'tts',
|
|
39
|
+
showToast: true,
|
|
40
|
+
ttsVoice: '',
|
|
41
|
+
ttsRate: 0,
|
|
42
|
+
jumpWhenBackground: true,
|
|
43
|
+
jumpWhenForeground: false,
|
|
44
|
+
devReload: false,
|
|
45
|
+
debounceMs: 60_000,
|
|
46
|
+
globalCooldownMs: 0,
|
|
47
|
+
audioSourceByKind: {},
|
|
48
|
+
ttsTexts: {},
|
|
49
|
+
kindTtsVoice: {},
|
|
50
|
+
kindTtsRate: {},
|
|
51
|
+
kindVoiceMode: {},
|
|
52
|
+
kindVoiceName: {},
|
|
53
|
+
kindSystemSound: {},
|
|
54
|
+
customFiles: {},
|
|
55
|
+
...over,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── E1: 基础字段直传 ──────────────────────────────────────────────────────
|
|
60
|
+
test('E1: 基础字段原样透传', () => {
|
|
61
|
+
const r = effectiveForClient(mkCfg({ volume: 55, voice: false }));
|
|
62
|
+
assert.equal(r.volume, 55);
|
|
63
|
+
assert.equal(r.voice, false);
|
|
64
|
+
assert.equal(r.audioSource, 'tts');
|
|
65
|
+
assert.equal(r.showToast, true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// ── E2: 毫秒 → 秒(关键:键名必须变)──────────────────────────────────────
|
|
69
|
+
test('E2: debounceMs 映射成 debounceSeconds(键名与单位都要变)', () => {
|
|
70
|
+
const r = effectiveForClient(mkCfg({ debounceMs: 0, globalCooldownMs: 120_000 }));
|
|
71
|
+
assert.equal(r.debounceSeconds, 0, '60 秒的默认值不能盖掉真实的 0');
|
|
72
|
+
assert.equal(r.globalCooldownSeconds, 120);
|
|
73
|
+
// 内部字段名不应泄漏到前端形状里
|
|
74
|
+
assert.equal(r.debounceMs, undefined, '前端不该看到毫秒字段名');
|
|
75
|
+
assert.equal(r.globalCooldownMs, undefined);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ── E3: 字典 → 扁平键 ────────────────────────────────────────────────────
|
|
79
|
+
test('E3: 按事件的字典映射回扁平键', () => {
|
|
80
|
+
const r = effectiveForClient(mkCfg({
|
|
81
|
+
audioSourceByKind: { done: 'tts', fail: 'system' },
|
|
82
|
+
ttsTexts: { done: '搞定啦' },
|
|
83
|
+
kindSystemSound: { fail: 'Hand' },
|
|
84
|
+
kindTtsVoice: { done: 'Microsoft Huihui Desktop' },
|
|
85
|
+
kindVoiceName: { question: 'ankomon' },
|
|
86
|
+
customFiles: { approval: 'D:\\a.wav' },
|
|
87
|
+
}));
|
|
88
|
+
// audioSourceByKind 两种形态都要有
|
|
89
|
+
assert.deepEqual(r.audioSourceByKind, { done: 'tts', fail: 'system' },
|
|
90
|
+
'字典形态要保留(GUI 的按事件覆盖读它)');
|
|
91
|
+
assert.equal(r.kindSourceFail, 'system', '扁平形态也要有(便于排查)');
|
|
92
|
+
assert.equal(r.kindSourceDone, 'tts');
|
|
93
|
+
assert.equal(r.ttsTextDone, '搞定啦');
|
|
94
|
+
assert.equal(r.kindSystemSoundFail, 'Hand');
|
|
95
|
+
assert.equal(r.kindTtsVoiceDone, 'Microsoft Huihui Desktop');
|
|
96
|
+
assert.equal(r.kindVoiceNameQuestion, 'ankomon');
|
|
97
|
+
assert.equal(r.customFileApproval, 'D:\\a.wav');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// ── E4: 空字典不产生多余的键 ──────────────────────────────────────────────
|
|
101
|
+
test('E4: 字典为空时不产生扁平键(保持响应精简)', () => {
|
|
102
|
+
const r = effectiveForClient(mkCfg());
|
|
103
|
+
assert.deepEqual(r.audioSourceByKind, {});
|
|
104
|
+
assert.equal(r.kindSourceFail, undefined);
|
|
105
|
+
assert.equal(r.ttsTextDone, undefined);
|
|
106
|
+
assert.equal(r.kindSystemSoundFail, undefined);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ── E5: 传 undefined / null 不崩(这就是那个忘传参数的 bug)───────────────
|
|
110
|
+
test('E5: 传 undefined/null 时返回空对象而不是抛(防"忘传 cfg")', () => {
|
|
111
|
+
for (const bad of [undefined, null, 0, '', 'x', []]) {
|
|
112
|
+
let r;
|
|
113
|
+
assert.doesNotThrow(() => { r = effectiveForClient(bad); },
|
|
114
|
+
`传 ${JSON.stringify(bad)} 不应抛`);
|
|
115
|
+
assert.equal(typeof r, 'object');
|
|
116
|
+
assert.ok(r !== null);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ── E6: 忘传 cfg 的后果可被识别 ───────────────────────────────────────────
|
|
121
|
+
test('E6: 忘传 cfg 时结果是"全空",不是"看起来正常"', () => {
|
|
122
|
+
const empty = effectiveForClient();
|
|
123
|
+
// 关键:全空意味着所有值都是 undefined,
|
|
124
|
+
// 前端会因此回落到默认值 —— 这正是用户看到"改了没生效"的原因。
|
|
125
|
+
assert.equal(empty.volume, undefined);
|
|
126
|
+
// debounceSeconds 会算成 0(Number(undefined)||0 → 0),不是 undefined。
|
|
127
|
+
// 这反而是好事:宁可显示 0(关闭),也不要 NaN。
|
|
128
|
+
assert.equal(empty.debounceSeconds, 0);
|
|
129
|
+
assert.deepEqual(empty.audioSourceByKind, {});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ── E7: 非法数值不产生 NaN ───────────────────────────────────────────────
|
|
133
|
+
test('E7: 冷却值非法时归零而不是 NaN', () => {
|
|
134
|
+
for (const bad of [undefined, null, NaN, 'abc', -1]) {
|
|
135
|
+
const r = effectiveForClient(mkCfg({ debounceMs: bad }));
|
|
136
|
+
// 用 `=== 0` 而不是 assert.equal:-1/1000 四舍五入得 -0,
|
|
137
|
+
// 而 Node 的严格断言把 -0 与 0 视为不同(Object.is 语义)。
|
|
138
|
+
assert.ok(r.debounceSeconds === 0,
|
|
139
|
+
`debounceMs=${String(bad)} 应归零,实际 ${String(r.debounceSeconds)}`);
|
|
140
|
+
assert.ok(Number.isFinite(r.debounceSeconds), '不该出现 NaN');
|
|
141
|
+
}
|
|
142
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-notify-desktop「运行时路由」测试 —— v0.3.1。
|
|
3
|
+
*
|
|
4
|
+
* 与另外两个测试文件的分工:
|
|
5
|
+
* - `fail-voice.test.mjs`:真跑 ps1,验**池选择**与语义护栏(但用的是自己传的 -Kind)。
|
|
6
|
+
* - 本文件:验 **lib/index.js 运行时到底给 ps1 传了什么 -Kind**
|
|
7
|
+
* ——即"error 回合真的会走 fail 池吗"这条**端到端接线**。
|
|
8
|
+
*
|
|
9
|
+
* 为什么必须单独验:`fail-voice.test.mjs` 的 A1 只读**源码文本**(`psKind = 'fail'`),
|
|
10
|
+
* 证明不了"事件进来后实际传出的参数就是 fail"。这两者之间隔着
|
|
11
|
+
* `handleDone` 的分支选择 + `emitDone`/`notify` 的参数透传,任一环写错都会让
|
|
12
|
+
* 失败语音静默失效,而纯文本断言**照样全绿**。
|
|
13
|
+
*
|
|
14
|
+
* 隔离手法:用 `--import` 前置的 loader stub 替换 `node:child_process`,
|
|
15
|
+
* 让 spawn 只记录参数、**不起进程**(不弹 Toast、不出声、不依赖 SystemRoot)。
|
|
16
|
+
* 见 `stub-child-process.mjs` 的注释;踩坑 #424 的教训是别在这里设 SystemRoot。
|
|
17
|
+
*
|
|
18
|
+
* ⚠️ 运行方式:本文件需要 `--import` loader,**不能用默认 `node --test`**。
|
|
19
|
+
* 用专用脚本 `npm run test:route`,或:
|
|
20
|
+
* node --import ./test/route-loader-register.mjs --test test/fail-route.test.mjs
|
|
21
|
+
* 为免误跑,默认的 `node --test` 会跳过它(本文件首个用例会检测并 skip)。
|
|
22
|
+
*
|
|
23
|
+
* 运行:npm run test:route
|
|
24
|
+
*/
|
|
25
|
+
import { test } from 'node:test';
|
|
26
|
+
import assert from 'node:assert/strict';
|
|
27
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
28
|
+
import { tmpdir } from 'node:os';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
import { dirname, join } from 'node:path';
|
|
31
|
+
|
|
32
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
33
|
+
const PLUGIN = join(HERE, '..');
|
|
34
|
+
|
|
35
|
+
// ⚠️ 必须在 import lib/index.js **之前**把 DSH_HOME 指到空目录。
|
|
36
|
+
//
|
|
37
|
+
// 为什么:插件启动时会读 `$DSH_HOME/storages/dsh-notify-desktop/config.json`
|
|
38
|
+
// 作为"运行时覆盖层",其优先级**高于** apply() 传入的 config。
|
|
39
|
+
// 如果测试用真实 DSH_HOME,用户只要在 GUI 里改过任何一项
|
|
40
|
+
// (实测:把 notifyOnAborted 打开),R4「aborted 默认不播」就会失败——
|
|
41
|
+
// 那是用户配置导致的,不是代码缺陷。测试必须与用户真实配置隔离。
|
|
42
|
+
const ISOLATED_HOME = mkdtempSync(join(tmpdir(), 'notify-route-home-'));
|
|
43
|
+
process.env.DSH_HOME = ISOLATED_HOME;
|
|
44
|
+
process.on('exit', () => {
|
|
45
|
+
try { rmSync(ISOLATED_HOME, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/** 只有 loader 生效时 `__SPAWN_CALLS` 才可能被写入;否则说明没用 --import 跑。 */
|
|
49
|
+
function stubActive() {
|
|
50
|
+
return globalThis.__STUB_CHILD_PROCESS__ === true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 造最小 ctx 替身。 */
|
|
54
|
+
function mkCtx() {
|
|
55
|
+
const handlers = new Map();
|
|
56
|
+
return {
|
|
57
|
+
handlers,
|
|
58
|
+
on(name, fn) { if (!handlers.has(name)) handlers.set(name, []); handlers.get(name).push(fn); },
|
|
59
|
+
get() { return undefined; },
|
|
60
|
+
effect(fn) { const d = fn(); return () => { if (typeof d === 'function') d(); }; },
|
|
61
|
+
emit(name, ...args) { for (const fn of handlers.get(name) || []) fn(...args); },
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function mkAgent(id, { origin, parentSession, status = 'idle' } = {}) {
|
|
66
|
+
return { id, status, session: { id, header: { id, origin, parentSession } } };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 从记录的 spawn 调用里取出所有传给 ps1 的 -Kind 值。 */
|
|
70
|
+
function kindsOf(calls) {
|
|
71
|
+
const out = [];
|
|
72
|
+
for (const c of calls || []) {
|
|
73
|
+
const args = Array.isArray(c.args) ? c.args : [];
|
|
74
|
+
const i = args.indexOf('-Kind');
|
|
75
|
+
if (i >= 0 && i + 1 < args.length) out.push(String(args[i + 1]));
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 跑一次「turn/end(kind) + agent 状态 running→idle」并返回本次 spawn 的 -Kind 列表。 */
|
|
81
|
+
async function runTurnAndCollectKind(turnKind, { idleGate = true, subagents = false } = {}) {
|
|
82
|
+
// 每次重新 import 会命中模块缓存,apply 可重复调用(插件本就是可多次 apply 的)
|
|
83
|
+
const { apply } = await import('../lib/index.js');
|
|
84
|
+
const agent = mkAgent('sess-1', { status: 'running' });
|
|
85
|
+
const ctx = mkCtx();
|
|
86
|
+
globalThis.__SPAWN_CALLS = [];
|
|
87
|
+
apply(ctx, { idleGate, subagents, voice: true, volume: 0 });
|
|
88
|
+
ctx.emit('session/event', agent.session, { type: 'turn/end', data: { turn: 1, reason: { kind: turnKind } } });
|
|
89
|
+
agent.status = 'idle';
|
|
90
|
+
ctx.emit('agent/status', { agent, status: 'running' });
|
|
91
|
+
ctx.emit('agent/status', { agent, status: 'idle' });
|
|
92
|
+
// spawn 是同步调用(fire-and-forget),无需等待
|
|
93
|
+
return kindsOf(globalThis.__SPAWN_CALLS);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
test('R0: loader stub 生效(否则本文件的断言无意义)', { skip: !process.env.DSH_ROUTE_LOADER && '需 npm run test:route(带 --import loader);默认 node --test 跳过' }, () => {
|
|
97
|
+
assert.equal(stubActive(), true, 'stub 未生效:必须用 npm run test:route 跑');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('R1: error 回合 → ps1 收到 -Kind fail(exact,不是 chime)', { skip: !process.env.DSH_ROUTE_LOADER && '需 npm run test:route' }, async () => {
|
|
101
|
+
const kinds = await runTurnAndCollectKind('error');
|
|
102
|
+
assert.deepEqual(kinds, ['fail'], `error 应传出 ['fail'],实际 ${JSON.stringify(kinds)}`);
|
|
103
|
+
// 负例:绝不能还是 chime(那正是本次要改掉的旧行为)
|
|
104
|
+
assert.ok(!kinds.includes('chime'), 'error 不应再传 chime');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('R2: blocked 回合 → ps1 收到 -Kind chime(维持蜂鸣,语义不同)', { skip: !process.env.DSH_ROUTE_LOADER && '需 npm run test:route' }, async () => {
|
|
108
|
+
const kinds = await runTurnAndCollectKind('blocked');
|
|
109
|
+
assert.deepEqual(kinds, ['chime'], `blocked 应传出 ['chime'],实际 ${JSON.stringify(kinds)}`);
|
|
110
|
+
assert.ok(!kinds.includes('fail'), 'blocked 不应传 fail(它是"被拦下"不是"失败")');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('R3: completed 回合(无在飞工作)→ ps1 收到 -Kind done', { skip: !process.env.DSH_ROUTE_LOADER && '需 npm run test:route' }, async () => {
|
|
114
|
+
const kinds = await runTurnAndCollectKind('completed');
|
|
115
|
+
assert.deepEqual(kinds, ['done'], `completed 应传出 ['done'],实际 ${JSON.stringify(kinds)}`);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('R4: aborted 回合 → 默认不播(无 spawn 调用)', { skip: !process.env.DSH_ROUTE_LOADER && '需 npm run test:route' }, async () => {
|
|
119
|
+
const kinds = await runTurnAndCollectKind('aborted');
|
|
120
|
+
assert.deepEqual(kinds, [], `aborted 默认静默,实际 ${JSON.stringify(kinds)}`);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('R5: error 在 idleGate=true 下**立即**播(不过空闲门)', { skip: !process.env.DSH_ROUTE_LOADER && '需 npm run test:route' }, async () => {
|
|
124
|
+
// 即便开着空闲门,error 也必须立刻出声(失败要马上知道)——本用例断言"有 spawn 发出",
|
|
125
|
+
// 即没有被挂起吞掉。挂起路径不会调 notify(),故 spawn 计数为 0 就说明被吞了。
|
|
126
|
+
const kinds = await runTurnAndCollectKind('error', { idleGate: true });
|
|
127
|
+
assert.deepEqual(kinds, ['fail'], `error 开着空闲门也应立即播出 ['fail'],实际 ${JSON.stringify(kinds)}`);
|
|
128
|
+
});
|