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,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-jump 测试:四种「跳转时机」组合的穷举验证。
|
|
3
|
+
*
|
|
4
|
+
* 这块的价值全在**不误跳**:跳错了会盖掉用户正在看的会话、抢走他自己点开的对话。
|
|
5
|
+
*
|
|
6
|
+
* 用户 2026-10-17 明确要求做成**两个独立开关**,因为他发现不同人诉求相反:
|
|
7
|
+
* - 有人想"后台完成→回来就跳"
|
|
8
|
+
* - 有人**故意不跳**,想自己回来按绿点逐个确认
|
|
9
|
+
* - 有人希望"前台完成也跳,让我看一眼"
|
|
10
|
+
* 所以实现不该替用户选,而要把四种组合都支持。
|
|
11
|
+
*/
|
|
12
|
+
import { test } from 'node:test';
|
|
13
|
+
import assert from 'node:assert/strict';
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
decideJump, JUMP_DECISIONS, DEFAULT_JUMP, isHidden, createLeaveTracker,
|
|
17
|
+
} from '../lib/session-jump.js';
|
|
18
|
+
|
|
19
|
+
const T0 = 1790000000000;
|
|
20
|
+
|
|
21
|
+
/** 后台完成:用户先离开(T0-1000),通知后到(T0)。 */
|
|
22
|
+
function bg(over) {
|
|
23
|
+
return {
|
|
24
|
+
jumpWhenBackground: true,
|
|
25
|
+
jumpWhenForeground: false,
|
|
26
|
+
consumed: false,
|
|
27
|
+
sessionId: 'sess-1',
|
|
28
|
+
notifiedAt: T0,
|
|
29
|
+
leftAt: T0 - 1000,
|
|
30
|
+
...over,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 前台完成:通知先到(T0-5000),用户后离开(T0)。 */
|
|
35
|
+
function fg(over) {
|
|
36
|
+
return {
|
|
37
|
+
jumpWhenBackground: true,
|
|
38
|
+
jumpWhenForeground: false,
|
|
39
|
+
consumed: false,
|
|
40
|
+
sessionId: 'sess-1',
|
|
41
|
+
notifiedAt: T0 - 5000,
|
|
42
|
+
leftAt: T0,
|
|
43
|
+
...over,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── J1: 组合①(默认)后台跳、前台不跳 ─────────────────────────────────────
|
|
48
|
+
test('J1: 组合① 后台→跳 / 前台→不跳(默认)', () => {
|
|
49
|
+
const b = decideJump(bg());
|
|
50
|
+
assert.equal(b.jump, true);
|
|
51
|
+
assert.equal(b.phase, 'background');
|
|
52
|
+
|
|
53
|
+
const f = decideJump(fg());
|
|
54
|
+
assert.equal(f.jump, false);
|
|
55
|
+
assert.equal(f.decision, JUMP_DECISIONS.SKIP_FOREGROUND);
|
|
56
|
+
assert.equal(f.phase, 'foreground');
|
|
57
|
+
|
|
58
|
+
// 默认值本身也要对
|
|
59
|
+
assert.equal(DEFAULT_JUMP.jumpWhenBackground, true);
|
|
60
|
+
assert.equal(DEFAULT_JUMP.jumpWhenForeground, false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ── J2: 组合② 都跳 ────────────────────────────────────────────────────────
|
|
64
|
+
test('J2: 组合② 后台和前台都跳', () => {
|
|
65
|
+
const o = { jumpWhenBackground: true, jumpWhenForeground: true };
|
|
66
|
+
assert.equal(decideJump(bg(o)).jump, true);
|
|
67
|
+
const f = decideJump(fg(o));
|
|
68
|
+
assert.equal(f.jump, true);
|
|
69
|
+
assert.equal(f.phase, 'foreground');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// ── J3: 组合③ 只前台跳 ────────────────────────────────────────────────────
|
|
73
|
+
test('J3: 组合③ 只前台跳(少见但允许)', () => {
|
|
74
|
+
const o = { jumpWhenBackground: false, jumpWhenForeground: true };
|
|
75
|
+
const b = decideJump(bg(o));
|
|
76
|
+
assert.equal(b.jump, false);
|
|
77
|
+
assert.equal(b.decision, JUMP_DECISIONS.SKIP_BACKGROUND);
|
|
78
|
+
|
|
79
|
+
assert.equal(decideJump(fg(o)).jump, true);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ── J4: 组合④ 都不跳 ──────────────────────────────────────────────────────
|
|
83
|
+
test('J4: 组合④ 都不跳(想自己按绿点确认的人)', () => {
|
|
84
|
+
const o = { jumpWhenBackground: false, jumpWhenForeground: false };
|
|
85
|
+
const b = decideJump(bg(o));
|
|
86
|
+
const f = decideJump(fg(o));
|
|
87
|
+
assert.equal(b.jump, false);
|
|
88
|
+
assert.equal(f.jump, false);
|
|
89
|
+
assert.equal(b.decision, JUMP_DECISIONS.SKIP_DISABLED);
|
|
90
|
+
assert.equal(f.decision, JUMP_DECISIONS.SKIP_DISABLED);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// ── J5: 10 分钟后回来仍然跳(不设时效)────────────────────────────────────
|
|
94
|
+
test('J5: 隔很久才回来看,仍然跳(结果还没看过)', () => {
|
|
95
|
+
const tenMin = 10 * 60 * 1000;
|
|
96
|
+
assert.equal(decideJump(bg({ leftAt: T0 - tenMin })).jump, true);
|
|
97
|
+
const oneHour = 60 * 60 * 1000;
|
|
98
|
+
assert.equal(decideJump(bg({ leftAt: T0 - oneHour })).jump, true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// ── J6: 缺省参数按默认语义 ────────────────────────────────────────────────
|
|
102
|
+
test('J6: 不传开关时按默认(后台跳、前台不跳)', () => {
|
|
103
|
+
const b = decideJump({ consumed: false, sessionId: 's', notifiedAt: T0, leftAt: T0 - 1 });
|
|
104
|
+
assert.equal(b.jump, true, '未指定开关时默认后台跳');
|
|
105
|
+
const f = decideJump({ consumed: false, sessionId: 's', notifiedAt: T0 - 10, leftAt: T0 });
|
|
106
|
+
assert.equal(f.jump, false, '未指定开关时默认前台不跳');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ── J7: 无待跳转 / 已消费 ─────────────────────────────────────────────────
|
|
110
|
+
test('J7: 没有待跳转会话、或已消费时都不跳(防重复)', () => {
|
|
111
|
+
assert.equal(decideJump(bg({ sessionId: '' })).decision, JUMP_DECISIONS.SKIP_NO_PENDING);
|
|
112
|
+
assert.equal(decideJump(bg({ sessionId: undefined })).jump, false);
|
|
113
|
+
assert.equal(decideJump(bg({ consumed: true })).decision, JUMP_DECISIONS.SKIP_CONSUMED);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ── J8: 不知道何时离开 → 保守不跳 ─────────────────────────────────────────
|
|
117
|
+
test('J8: 无法得知用户何时离开时不跳(宁可漏跳,不可乱跳)', () => {
|
|
118
|
+
assert.equal(decideJump(bg({ leftAt: 0 })).decision, JUMP_DECISIONS.SKIP_UNKNOWN_LEFT);
|
|
119
|
+
assert.equal(decideJump(bg({ leftAt: undefined })).jump, false);
|
|
120
|
+
assert.equal(decideJump(bg({ leftAt: NaN })).jump, false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ── J9: 边界 —— 通知与离开同一毫秒 ───────────────────────────────────────
|
|
124
|
+
test('J9: 通知时刻 == 离开时刻 视为后台(>=)', () => {
|
|
125
|
+
const r = decideJump(bg({ notifiedAt: T0, leftAt: T0 }));
|
|
126
|
+
assert.equal(r.jump, true);
|
|
127
|
+
assert.equal(r.phase, 'background');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// ── J10: 异常输入不崩 ────────────────────────────────────────────────────
|
|
131
|
+
test('J10: 空/异常输入一律安全返回不跳', () => {
|
|
132
|
+
assert.doesNotThrow(() => decideJump());
|
|
133
|
+
assert.doesNotThrow(() => decideJump(null));
|
|
134
|
+
assert.doesNotThrow(() => decideJump({}));
|
|
135
|
+
assert.equal(decideJump(undefined).jump, false);
|
|
136
|
+
assert.equal(decideJump({}).jump, false);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ── J11: isHidden 归一 ───────────────────────────────────────────────────
|
|
140
|
+
test('J11: isHidden 兼容多种文档形态', () => {
|
|
141
|
+
assert.equal(isHidden({ visibilityState: 'hidden' }), true);
|
|
142
|
+
assert.equal(isHidden({ visibilityState: 'visible' }), false);
|
|
143
|
+
assert.equal(isHidden({ hidden: true }), true);
|
|
144
|
+
assert.equal(isHidden({ hidden: false }), false);
|
|
145
|
+
assert.equal(isHidden(null), true);
|
|
146
|
+
assert.equal(isHidden({}), false);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// ── J12: leaveTracker 只在"变为隐藏"的边沿记录 ──────────────────────────
|
|
150
|
+
test('J12: leaveTracker 记录离开时刻,且不重复刷新', () => {
|
|
151
|
+
const tr = createLeaveTracker();
|
|
152
|
+
tr.onVisibility({ visibilityState: 'visible' }, T0);
|
|
153
|
+
assert.equal(tr.leftAt(), 0, '一直可见时没有离开时刻');
|
|
154
|
+
|
|
155
|
+
tr.onVisibility({ visibilityState: 'hidden' }, T0 + 1000);
|
|
156
|
+
assert.equal(tr.leftAt(), T0 + 1000);
|
|
157
|
+
|
|
158
|
+
tr.onVisibility({ visibilityState: 'hidden' }, T0 + 2000);
|
|
159
|
+
assert.equal(tr.leftAt(), T0 + 1000, '持续隐藏不应刷新');
|
|
160
|
+
|
|
161
|
+
tr.onVisibility({ visibilityState: 'visible' }, T0 + 3000);
|
|
162
|
+
assert.equal(tr.leftAt(), T0 + 1000, '回来后保留');
|
|
163
|
+
|
|
164
|
+
tr.onVisibility({ visibilityState: 'hidden' }, T0 + 9000);
|
|
165
|
+
assert.equal(tr.leftAt(), T0 + 9000, '再次离开要刷新');
|
|
166
|
+
|
|
167
|
+
tr.reset();
|
|
168
|
+
assert.equal(tr.leftAt(), 0, 'reset 后清零');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// ── J13: 端到端 —— 后台完成 → 回来 → 跳 ──────────────────────────────────
|
|
172
|
+
test('J13: 端到端时序:离开 → 通知 → 回来 → 跳', () => {
|
|
173
|
+
const tr = createLeaveTracker();
|
|
174
|
+
tr.onVisibility({ visibilityState: 'hidden' }, T0);
|
|
175
|
+
const notifiedAt = T0 + 5000;
|
|
176
|
+
tr.onVisibility({ visibilityState: 'visible' }, T0 + 6000);
|
|
177
|
+
|
|
178
|
+
const r = decideJump({
|
|
179
|
+
jumpWhenBackground: true, jumpWhenForeground: false,
|
|
180
|
+
consumed: false, sessionId: 'sess-x',
|
|
181
|
+
notifiedAt, leftAt: tr.leftAt(),
|
|
182
|
+
});
|
|
183
|
+
assert.equal(r.jump, true);
|
|
184
|
+
assert.equal(r.phase, 'background');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// ── J14: 端到端 —— 前台完成 → 看过后离开再回来 → 不跳 ──────────────────────
|
|
188
|
+
test('J14: 端到端时序:前台完成 → 离开再回来 → 默认不跳', () => {
|
|
189
|
+
const tr = createLeaveTracker();
|
|
190
|
+
tr.onVisibility({ visibilityState: 'visible' }, T0);
|
|
191
|
+
const notifiedAt = T0 + 1000;
|
|
192
|
+
tr.onVisibility({ visibilityState: 'hidden' }, T0 + 3000);
|
|
193
|
+
tr.onVisibility({ visibilityState: 'visible' }, T0 + 5000);
|
|
194
|
+
|
|
195
|
+
const r = decideJump({
|
|
196
|
+
jumpWhenBackground: true, jumpWhenForeground: false,
|
|
197
|
+
consumed: false, sessionId: 'sess-y',
|
|
198
|
+
notifiedAt, leftAt: tr.leftAt(),
|
|
199
|
+
});
|
|
200
|
+
assert.equal(r.jump, false);
|
|
201
|
+
assert.equal(r.phase, 'foreground');
|
|
202
|
+
assert.equal(r.decision, JUMP_DECISIONS.SKIP_FOREGROUND);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// ── J15: 回归 —— "离开过一次"不能让前台判定永久失效 ────────────────────────
|
|
206
|
+
// 独立审查 2026-10-17 指出的严重缺陷:第一版只记 leftAt(离开时刻),
|
|
207
|
+
// 于是用户离开过一次之后,leftAt 永远是旧值,此后任何通知都满足
|
|
208
|
+
// notifiedAt >= leftAt → 被判成"后台完成" → 在用户眼前强行跳转。
|
|
209
|
+
// 修法:同时记 returnedAt,用区间 [leftAt, returnedAt) 判定。
|
|
210
|
+
test('J15: 离开过一次后回来的通知,不应被判成后台(关键回归)', () => {
|
|
211
|
+
const tr = createLeaveTracker();
|
|
212
|
+
// 用户在 T0 离开
|
|
213
|
+
tr.onVisibility({ visibilityState: 'hidden' }, 1000);
|
|
214
|
+
// 在 T0+5000 回来
|
|
215
|
+
tr.onVisibility({ visibilityState: 'visible' }, 5000);
|
|
216
|
+
// 回来后、正看着时,T0+9000 来了新通知
|
|
217
|
+
const r = decideJump({
|
|
218
|
+
jumpWhenBackground: true,
|
|
219
|
+
jumpWhenForeground: false, // 默认:前台不跳
|
|
220
|
+
consumed: false,
|
|
221
|
+
sessionId: 's',
|
|
222
|
+
notifiedAt: 9000,
|
|
223
|
+
leftAt: tr.leftAt(), // = 1000
|
|
224
|
+
returnedAt: tr.returnedAt(), // = 5000
|
|
225
|
+
});
|
|
226
|
+
assert.equal(r.phase, 'foreground', '通知在回来之后到达,属于前台');
|
|
227
|
+
assert.equal(r.jump, false, '前台不该跳 —— 第一版在这里会误跳');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// ── J16: 离开期间到达的通知仍然算后台 ─────────────────────────────────────
|
|
231
|
+
test('J16: 离开期间到达的通知仍判为后台', () => {
|
|
232
|
+
const tr = createLeaveTracker();
|
|
233
|
+
tr.onVisibility({ visibilityState: 'hidden' }, 1000);
|
|
234
|
+
tr.onVisibility({ visibilityState: 'visible' }, 9000);
|
|
235
|
+
const r = decideJump({
|
|
236
|
+
jumpWhenBackground: true,
|
|
237
|
+
jumpWhenForeground: false,
|
|
238
|
+
consumed: false,
|
|
239
|
+
sessionId: 's',
|
|
240
|
+
notifiedAt: 5000, // 在 1000..9000 之间
|
|
241
|
+
leftAt: tr.leftAt(),
|
|
242
|
+
returnedAt: tr.returnedAt(),
|
|
243
|
+
});
|
|
244
|
+
assert.equal(r.phase, 'background');
|
|
245
|
+
assert.equal(r.jump, true);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// ── J17: tracker 正确记录回归时刻 ─────────────────────────────────────────
|
|
249
|
+
test('J17: leaveTracker 记录 returnedAt,并在再次离开时清掉', () => {
|
|
250
|
+
const tr = createLeaveTracker();
|
|
251
|
+
tr.onVisibility({ visibilityState: 'visible' }, 0);
|
|
252
|
+
assert.equal(tr.returnedAt(), 0, '从未离开过 → 没有回归时刻');
|
|
253
|
+
|
|
254
|
+
tr.onVisibility({ visibilityState: 'hidden' }, 1000);
|
|
255
|
+
assert.equal(tr.returnedAt(), 0, '离开后还没回来');
|
|
256
|
+
|
|
257
|
+
tr.onVisibility({ visibilityState: 'visible' }, 5000);
|
|
258
|
+
assert.equal(tr.returnedAt(), 5000, '回来时记录时刻');
|
|
259
|
+
|
|
260
|
+
tr.onVisibility({ visibilityState: 'hidden' }, 8000);
|
|
261
|
+
assert.equal(tr.returnedAt(), 0, '再次离开要把旧的回来说明作废');
|
|
262
|
+
|
|
263
|
+
tr.reset();
|
|
264
|
+
assert.equal(tr.leftAt(), 0);
|
|
265
|
+
assert.equal(tr.returnedAt(), 0);
|
|
266
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESM loader stub:把 `node:child_process` 换成记录型假实现。
|
|
3
|
+
*
|
|
4
|
+
* 用途:`test/fail-route.test.mjs` 要断言 **lib/index.js 运行时到底给 ps1 传了什么参数**
|
|
5
|
+
* (例如 error 回合是否真的传 `-Kind fail`)。若真去 spawn PowerShell:
|
|
6
|
+
* ① 会弹 Toast / 出声,污染用户桌面;② 需要真实 SystemRoot,与同目录其他测试的
|
|
7
|
+
* 隔离手法冲突(见踩坑 #424)。故在加载 lib 之前把这个模块注册为 loader,
|
|
8
|
+
* 让 `spawn()` 只把参数记进 `globalThis.__SPAWN_CALLS`,不起任何进程。
|
|
9
|
+
*
|
|
10
|
+
* 为什么不用 monkey-patch:lib 用的是 ESM 具名导入
|
|
11
|
+
* (`import { spawn } from 'node:child_process'`),导入绑定只读、不可重新赋值;
|
|
12
|
+
* 只有 loader 层能在模块解析阶段替换它。
|
|
13
|
+
*
|
|
14
|
+
* 该文件**不是测试文件**(不以 .test. 命名),不会被 `node --test` 当用例执行。
|
|
15
|
+
*/
|
|
16
|
+
export async function resolve(specifier, context, nextResolve) {
|
|
17
|
+
if (specifier === 'node:child_process') {
|
|
18
|
+
return { url: 'stub:child_process', shortCircuit: true };
|
|
19
|
+
}
|
|
20
|
+
return nextResolve(specifier, context);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function load(url, context, nextLoad) {
|
|
24
|
+
if (url === 'stub:child_process') {
|
|
25
|
+
const source = `
|
|
26
|
+
// 记录每一次 spawn 的 (file, args) 供断言;返回一个不会真的启动进程的假句柄。
|
|
27
|
+
export function spawn(file, args, options) {
|
|
28
|
+
globalThis.__SPAWN_CALLS = globalThis.__SPAWN_CALLS || [];
|
|
29
|
+
globalThis.__SPAWN_CALLS.push({ file, args: Array.isArray(args) ? args.slice() : args, options });
|
|
30
|
+
return {
|
|
31
|
+
pid: 424242,
|
|
32
|
+
once() { return this; },
|
|
33
|
+
on() { return this; },
|
|
34
|
+
kill() { return true; },
|
|
35
|
+
unref() { return this; },
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export default { spawn };
|
|
39
|
+
`;
|
|
40
|
+
return { format: 'module', shortCircuit: true, source };
|
|
41
|
+
}
|
|
42
|
+
return nextLoad(url, context);
|
|
43
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tts-lines 测试:台词选取与自定义文本校验。
|
|
3
|
+
*
|
|
4
|
+
* 覆盖用户 2026-10-17 的两个要求:
|
|
5
|
+
* 1. "台词随机" —— 不要把同一句念一天
|
|
6
|
+
* 2. "用户能不能自己输一段话" —— 可以,但有长度和格式限制
|
|
7
|
+
*
|
|
8
|
+
* 校验部分尤其重要:自定义文本会经 spawn argv 传给 PowerShell 再交给 SAPI。
|
|
9
|
+
* 虽然 argv 不经 shell 解释(无注入风险),但**超长文本**会让合成拖很久、
|
|
10
|
+
* **换行与控制字符**会让断句怪异,都必须挡掉。
|
|
11
|
+
*/
|
|
12
|
+
import { test } from 'node:test';
|
|
13
|
+
import assert from 'node:assert/strict';
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
DEFAULT_LINES, FALLBACK_LINE, MAX_TEXT_LEN,
|
|
17
|
+
sanitizeTtsText, pickTtsText,
|
|
18
|
+
} from '../lib/tts-lines.js';
|
|
19
|
+
|
|
20
|
+
// ── T1: 内置库覆盖五种事件且每种多条 ──────────────────────────────────────
|
|
21
|
+
test('T1: 内置台词库覆盖五种事件,每种至少 2 条', () => {
|
|
22
|
+
for (const kind of ['done', 'fail', 'question', 'approval', 'blocked']) {
|
|
23
|
+
const pool = DEFAULT_LINES[kind];
|
|
24
|
+
assert.ok(Array.isArray(pool), `${kind} 应有台词库`);
|
|
25
|
+
assert.ok(pool.length >= 2, `${kind} 至少 2 条才谈得上"随机"`);
|
|
26
|
+
for (const s of pool) {
|
|
27
|
+
assert.ok(typeof s === 'string' && s.length > 0, `${kind} 的台词不应为空`);
|
|
28
|
+
assert.doesNotMatch(s, /主人/, `${kind} 的台词不该含"主人"`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// ── T2: 随机确实会取到不同条目 ────────────────────────────────────────────
|
|
34
|
+
test('T2: 随机源不同则取到不同台词', () => {
|
|
35
|
+
const kind = 'done';
|
|
36
|
+
const pool = DEFAULT_LINES[kind];
|
|
37
|
+
// 用确定性的 rand 走遍所有下标
|
|
38
|
+
const seen = new Set();
|
|
39
|
+
for (let i = 0; i < pool.length; i++) {
|
|
40
|
+
const r = (i + 0.5) / pool.length; // 落在第 i 个区间
|
|
41
|
+
seen.add(pickTtsText(kind, {}, () => r));
|
|
42
|
+
}
|
|
43
|
+
assert.equal(seen.size, pool.length, '每个下标都应能取到不同台词');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// ── T3: 随机源异常时兜底到第一条,不崩 ────────────────────────────────────
|
|
47
|
+
test('T3: 随机源返回异常值时不崩、退回第一条', () => {
|
|
48
|
+
for (const bad of [() => NaN, () => -1, () => 99, () => { throw new Error('x'); }]) {
|
|
49
|
+
let got;
|
|
50
|
+
assert.doesNotThrow(() => { got = pickTtsText('done', {}, bad); });
|
|
51
|
+
assert.equal(got, DEFAULT_LINES.done[0]);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// ── T4: 未知事件类型用兜底句 ──────────────────────────────────────────────
|
|
56
|
+
test('T4: 未知事件类型返回兜底句', () => {
|
|
57
|
+
assert.equal(pickTtsText('nonsense', {}), FALLBACK_LINE);
|
|
58
|
+
assert.equal(pickTtsText('', {}), FALLBACK_LINE);
|
|
59
|
+
assert.equal(pickTtsText(null, {}), FALLBACK_LINE);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// ── T5: 自定义台词优先于内置库 ────────────────────────────────────────────
|
|
63
|
+
test('T5: 用户自定义台词优先', () => {
|
|
64
|
+
const cfg = { ttsTexts: { done: '我自己的台词' } };
|
|
65
|
+
assert.equal(pickTtsText('done', cfg), '我自己的台词');
|
|
66
|
+
// 未自定义的事件仍用内置库
|
|
67
|
+
assert.ok(DEFAULT_LINES.fail.includes(pickTtsText('fail', cfg)));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// ── T6: 空白自定义等于没自定义 ────────────────────────────────────────────
|
|
71
|
+
test('T6: 空白自定义退回内置库', () => {
|
|
72
|
+
for (const blank of ['', ' ', '\n\t', undefined, null, 123]) {
|
|
73
|
+
const got = pickTtsText('done', { ttsTexts: { done: blank } }, () => 0);
|
|
74
|
+
assert.equal(got, DEFAULT_LINES.done[0], `空白值 ${JSON.stringify(blank)} 应退回内置库`);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ── T7: 自定义文本的清洗 ──────────────────────────────────────────────────
|
|
79
|
+
test('T7: 自定义文本会去掉换行与控制字符并压空白', () => {
|
|
80
|
+
assert.equal(sanitizeTtsText(' 你好 '), '你好');
|
|
81
|
+
assert.equal(sanitizeTtsText('第一行\n第二行'), '第一行 第二行');
|
|
82
|
+
assert.equal(sanitizeTtsText('a\tb\r\nc'), 'a b c');
|
|
83
|
+
assert.equal(sanitizeTtsText('a\u0000b'), 'a b');
|
|
84
|
+
// 全角空格等不应被误删
|
|
85
|
+
assert.equal(sanitizeTtsText('你好 世界'), '你好 世界');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ── T8: 超长文本被截断 ────────────────────────────────────────────────────
|
|
89
|
+
test('T8: 超长自定义文本被截断到上限', () => {
|
|
90
|
+
const long = '很'.repeat(MAX_TEXT_LEN + 50);
|
|
91
|
+
const out = sanitizeTtsText(long);
|
|
92
|
+
assert.equal(out.length, MAX_TEXT_LEN, `应截断到 ${MAX_TEXT_LEN} 字`);
|
|
93
|
+
assert.ok(out.startsWith('很'), '截断后内容应保持');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ── T9: 非字符串输入一律当作"未设置" ──────────────────────────────────────
|
|
97
|
+
test('T9: 非字符串输入返回空串(表示未设置)', () => {
|
|
98
|
+
for (const v of [123, true, {}, [], null, undefined, () => {}]) {
|
|
99
|
+
assert.equal(sanitizeTtsText(v), '', `${typeof v} 应返回空串`);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// ── T10: 台词长度适合"一句话提醒" ─────────────────────────────────────────
|
|
104
|
+
test('T10: 内置台词都足够短(念完约 5 秒内)', () => {
|
|
105
|
+
for (const [kind, pool] of Object.entries(DEFAULT_LINES)) {
|
|
106
|
+
for (const s of pool) {
|
|
107
|
+
assert.ok(s.length <= MAX_TEXT_LEN, `${kind} 的台词过长:${s}`);
|
|
108
|
+
// 经验值:中文 TTS 约 4 字/秒,所以 20 字上下最舒服
|
|
109
|
+
assert.ok(s.length <= 30, `${kind} 的台词建议不超过 30 字:${s}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 版本号一致性检查(v0.4.19)。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么需要这条检查
|
|
5
|
+
*
|
|
6
|
+
* 用户 2026-10-18 第三次指出:"我感觉你的版本号经常会慢一点。"
|
|
7
|
+
*
|
|
8
|
+
* 确实如此。已发生 3 次:
|
|
9
|
+
* · v0.4.13~15 只在**提交说明**里写了版本号,没改 package.json
|
|
10
|
+
* · v0.4.17~18 又漏了两次,代码到 0.4.18 时文件还写 0.4.16
|
|
11
|
+
*
|
|
12
|
+
* 后果不是"难看",而是**实质性的误导**:
|
|
13
|
+
* · 插件管理页面显示 0.4.12,用户以为重启没生效
|
|
14
|
+
* · 我判断"宿主加载了哪一版"时被这个数字骗过
|
|
15
|
+
* · 排查时无法用版本号区分代码状态
|
|
16
|
+
*
|
|
17
|
+
* ## 为什么靠检查而不是靠"记得"
|
|
18
|
+
*
|
|
19
|
+
* 已经漏了 3 次,说明"提交前记得改"不可靠。
|
|
20
|
+
* 这条测试纳入 `npm run test:all`,改代码忘了升版本就会红。
|
|
21
|
+
*
|
|
22
|
+
* ## 检查什么
|
|
23
|
+
*
|
|
24
|
+
* 1. package.json 的 version 必须符合 semver
|
|
25
|
+
* 2. 源码里如果有版本标记(如注释里的 v0.4.x),不能高于 package.json
|
|
26
|
+
* 3. CHANGELOG(若有)的最新条目不能高于 package.json
|
|
27
|
+
*/
|
|
28
|
+
import { test } from 'node:test';
|
|
29
|
+
import assert from 'node:assert/strict';
|
|
30
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
31
|
+
import { resolve, dirname, join } from 'node:path';
|
|
32
|
+
import { fileURLToPath } from 'node:url';
|
|
33
|
+
|
|
34
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
35
|
+
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
|
|
36
|
+
|
|
37
|
+
/** 把 "0.4.18" 解析成可比较的数字数组。 */
|
|
38
|
+
function parseSemver(v) {
|
|
39
|
+
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(String(v || ''));
|
|
40
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** a > b 返回 true。 */
|
|
44
|
+
function gt(a, b) {
|
|
45
|
+
for (let i = 0; i < 3; i++) {
|
|
46
|
+
if (a[i] > b[i]) return true;
|
|
47
|
+
if (a[i] < b[i]) return false;
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── A1: version 必须是合法 semver ────────────────────────────────────────
|
|
53
|
+
test('A1: package.json 的 version 是合法 semver', () => {
|
|
54
|
+
assert.ok(pkg.version, 'package.json 必须有 version');
|
|
55
|
+
const v = parseSemver(pkg.version);
|
|
56
|
+
assert.ok(v !== null, `version 不合法: ${pkg.version}`);
|
|
57
|
+
assert.ok(v[0] >= 0 && v[1] >= 0 && v[2] >= 0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// ── A2: 源码里的版本标记不能超过 package.json ────────────────────────────
|
|
61
|
+
test('A2: 源码注释里的 v0.4.x 标记不高于 package.json 的 version', () => {
|
|
62
|
+
const vPkg = parseSemver(pkg.version);
|
|
63
|
+
assert.ok(vPkg, 'version 无法解析');
|
|
64
|
+
|
|
65
|
+
// 扫描主要源码文件里出现的 v0.4.N 标记
|
|
66
|
+
const files = ['lib/index.js', 'lib/client.js', 'lib/config-store.js',
|
|
67
|
+
'lib/config-schema.js', 'dsh-notify.ps1', 'lib/wav-gain.ps1'];
|
|
68
|
+
const tooHigh = [];
|
|
69
|
+
for (const rel of files) {
|
|
70
|
+
const p = join(ROOT, rel);
|
|
71
|
+
if (!existsSync(p)) continue;
|
|
72
|
+
const src = readFileSync(p, 'utf8');
|
|
73
|
+
// 只认 v0.4.N 这种明确的版本标记
|
|
74
|
+
const re = /\bv0\.4\.(\d+)\b/g;
|
|
75
|
+
let m;
|
|
76
|
+
while ((m = re.exec(src)) !== null) {
|
|
77
|
+
const n = Number(m[1]);
|
|
78
|
+
if (n > vPkg[2]) tooHigh.push(`${rel} 提到 v0.4.${n}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
assert.deepEqual(tooHigh, [],
|
|
82
|
+
`源码里出现了高于 package.json (${pkg.version}) 的版本标记 —— `
|
|
83
|
+
+ '说明改了代码但忘了升版本号:\n ' + tooHigh.join('\n '));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// ── A3: CHANGELOG 最新条目不能高于 package.json ──────────────────────────
|
|
87
|
+
test('A3: CHANGELOG(若有)最新版本不高于 package.json', () => {
|
|
88
|
+
const p = join(ROOT, 'CHANGELOG.md');
|
|
89
|
+
if (!existsSync(p)) return; // 还没建 CHANGELOG 时跳过
|
|
90
|
+
const text = readFileSync(p, 'utf8');
|
|
91
|
+
const m = /##\s*\[?v?(\d+\.\d+\.\d+)/.exec(text);
|
|
92
|
+
if (!m) return;
|
|
93
|
+
const vLog = parseSemver(m[1]);
|
|
94
|
+
const vPkg = parseSemver(pkg.version);
|
|
95
|
+
assert.ok(vLog && vPkg);
|
|
96
|
+
assert.ok(!gt(vLog, vPkg),
|
|
97
|
+
`CHANGELOG 最新是 ${m[1]},高于 package.json 的 ${pkg.version}`);
|
|
98
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 语音包可用性判定测试(v0.4.15)。
|
|
3
|
+
*
|
|
4
|
+
* ## 这里踩过的坑
|
|
5
|
+
*
|
|
6
|
+
* 用户选 `audioSource=bundled`,实际播放的却是系统蜂鸣。
|
|
7
|
+
* 诊断日志显示:
|
|
8
|
+
*
|
|
9
|
+
* `[voice-check] exists=true wav=-3 dir=E:\...\sounds\voice-lines`
|
|
10
|
+
*
|
|
11
|
+
* `existsSync` 为 true,但 `readdirSync` **抛异常**(-3 是 catch 标记)。
|
|
12
|
+
* 原因是**宿主进程运行在沙箱里,fs 读取受限**;而 `dsh-notify.ps1`
|
|
13
|
+
* 是独立子进程、不受此约束 —— 所以 ps1 明明能读到 103 个 wav 并正常播放,
|
|
14
|
+
* 宿主却判定"没有语音包",把用户主动选择降级成了蜂鸣。
|
|
15
|
+
*
|
|
16
|
+
* ## 核心原则
|
|
17
|
+
*
|
|
18
|
+
* **判断不了 ≠ 没有。** 只有"目录明确不存在"才是确定的坏状态;
|
|
19
|
+
* 读取失败必须乐观放行,由真正能读的 ps1 裁决。
|
|
20
|
+
*
|
|
21
|
+
* 这些用例直接测 `effectiveSource` 的回退链——它是"选语音包却听蜂鸣"的决策点。
|
|
22
|
+
*/
|
|
23
|
+
import { test } from 'node:test';
|
|
24
|
+
import assert from 'node:assert/strict';
|
|
25
|
+
|
|
26
|
+
import { effectiveSource, resolveSource } from '../lib/audio-source.js';
|
|
27
|
+
|
|
28
|
+
// ── V1: 判定为可用时必须保持 bundled,绝不回退 ────────────────────────────
|
|
29
|
+
test('V1: hasBundledVoice=true 时保持 bundled(不回退到 system)', () => {
|
|
30
|
+
const r = effectiveSource('bundled', {
|
|
31
|
+
hasTtsVoice: true, hasBundledVoice: true, hasCustomFile: false,
|
|
32
|
+
});
|
|
33
|
+
assert.equal(r.source, 'bundled', '用户选了语音包就该放语音包');
|
|
34
|
+
assert.equal(r.fellBack, false, '不该发生回退');
|
|
35
|
+
assert.equal(r.from, 'bundled');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// ── V2: 确实没有语音包时才回退(这是正确的降级)──────────────────────────
|
|
39
|
+
test('V2: hasBundledVoice=false 时回退到 system', () => {
|
|
40
|
+
const r = effectiveSource('bundled', {
|
|
41
|
+
hasTtsVoice: true, hasBundledVoice: false, hasCustomFile: false,
|
|
42
|
+
});
|
|
43
|
+
assert.equal(r.source, 'system');
|
|
44
|
+
assert.equal(r.fellBack, true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// ── V3: 关键——"读取失败"必须传 true,不能传 false ─────────────────────────
|
|
48
|
+
// hasVoiceLine 在 readdirSync 抛异常时应返回 true。这里锁住它的后果:
|
|
49
|
+
// 只要传 true,就不会降级成蜂鸣。
|
|
50
|
+
test('V3: 沙箱读不到文件时(应传 true)不会降级为蜂鸣', () => {
|
|
51
|
+
// 模拟 hasVoiceLine 修复后的返回值:读不了 -> true
|
|
52
|
+
const readFailedAssumeYes = true;
|
|
53
|
+
const r = effectiveSource('bundled', {
|
|
54
|
+
hasTtsVoice: true,
|
|
55
|
+
hasBundledVoice: readFailedAssumeYes,
|
|
56
|
+
hasCustomFile: false,
|
|
57
|
+
});
|
|
58
|
+
assert.equal(r.source, 'bundled',
|
|
59
|
+
'读不到不等于没有;降级会把用户选的语音包变成蜂鸣(实测事故)');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// ── V4: resolveSource 在无覆盖时返回全局 bundled ──────────────────────────
|
|
63
|
+
test('V4: 无按事件覆盖时 resolveSource 返回全局音源', () => {
|
|
64
|
+
assert.equal(resolveSource('done', { audioSource: 'bundled', audioSourceByKind: {} }), 'bundled');
|
|
65
|
+
assert.equal(resolveSource('fail', { audioSource: 'bundled' }), 'bundled');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// ── V5: 按事件覆盖优先于全局(且不影响其它事件)──────────────────────────
|
|
69
|
+
test('V5: 按事件覆盖只作用于该事件', () => {
|
|
70
|
+
const cfg = { audioSource: 'bundled', audioSourceByKind: { fail: 'system' } };
|
|
71
|
+
assert.equal(resolveSource('done', cfg), 'bundled', 'done 仍用语音包');
|
|
72
|
+
assert.equal(resolveSource('fail', cfg), 'system', 'fail 用系统音');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ── V6: 回退链最终不会返回空 ──────────────────────────────────────────────
|
|
76
|
+
test('V6: 任何组合下 effectiveSource 都返回合法音源', () => {
|
|
77
|
+
const sources = ['tts', 'bundled', 'system', 'custom', 'none'];
|
|
78
|
+
const avails = [
|
|
79
|
+
{ hasTtsVoice: true, hasBundledVoice: true, hasCustomFile: true },
|
|
80
|
+
{ hasTtsVoice: true, hasBundledVoice: false, hasCustomFile: false },
|
|
81
|
+
{ hasTtsVoice: false, hasBundledVoice: false, hasCustomFile: false },
|
|
82
|
+
];
|
|
83
|
+
for (const src of sources) {
|
|
84
|
+
for (const av of avails) {
|
|
85
|
+
const r = effectiveSource(src, av);
|
|
86
|
+
assert.ok(sources.includes(r.source),
|
|
87
|
+
`${src} + ${JSON.stringify(av)} 返回了非法音源: ${r.source}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
});
|