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,671 @@
1
+ /**
2
+ * client bundle 形态测试(v0.4.1)。
3
+ *
4
+ * ## 为什么需要它
5
+ *
6
+ * v0.4.0 曾把 `lib/client.js` 写成**裸 ESM**(`export function apply`)却声明
7
+ * `dsh.client.platform=web`。宿主把 client 产物当**经典脚本**执行,要求它自行注册
8
+ * `window.__ModuleLoader__.load({id, factory})`(实测:dsh-sidebar-footer-stack 的
9
+ * 3KB 手写 client.js 也是这个包裹;dsh-cost-meter 的构建产物同款)。
10
+ * 结果是浏览器端 SyntaxError → `client-modules: could not load`。
11
+ *
12
+ * 独立审查在提交前拦下了这个错误。本测试用来**防止它再犯**:
13
+ * 断言 client.js 的第一条有效语句就是 `window.__ModuleLoader__.load({...})`,
14
+ * 且 factory 里正确导出 `apply` / `inject` / `name`。
15
+ *
16
+ * 这些断言不依赖宿主:直接把文件当文本读 + 用一个假的 `window.__ModuleLoader__`
17
+ * 在 Node 里**真实执行**一次,确认它调用了 load 且 factory 能跑出 apply。
18
+ */
19
+ import { test } from 'node:test';
20
+ import assert from 'node:assert/strict';
21
+ import { readFileSync } from 'node:fs';
22
+
23
+ const CLIENT = new URL('../lib/client.js', import.meta.url);
24
+ const PKG = new URL('../package.json', import.meta.url);
25
+
26
+ function readClient() {
27
+ return readFileSync(CLIENT, 'utf8');
28
+ }
29
+
30
+ // ── C1: 必须是 __ModuleLoader__ 包裹,不能是裸 ESM ──────────────────────────
31
+ test('C1: client.js 使用 window.__ModuleLoader__.load 包裹(不是裸 ESM)', () => {
32
+ const src = readClient();
33
+ assert.match(src, /window\.__ModuleLoader__\.load\s*\(\s*\{/,
34
+ 'client 产物必须自行注册 window.__ModuleLoader__.load({id, factory})');
35
+ // 反面断言:不能出现顶层 export(会被当经典脚本执行而报 SyntaxError)
36
+ assert.doesNotMatch(src, /^\s*export\s+(function|const|default|\{)/m,
37
+ 'client.js 不得含顶层 export —— 它不是 ESM 模块,是经典脚本');
38
+ // 也不能有顶层 import
39
+ assert.doesNotMatch(src, /^\s*import\s+/m,
40
+ 'client.js 不得含顶层 import');
41
+ });
42
+
43
+ // ── C2: id 必须与包名一致 ───────────────────────────────────────────────────
44
+ test('C2: 注册的 id 与 package.json 的 name 一致', () => {
45
+ const src = readClient();
46
+ const pkg = JSON.parse(readFileSync(PKG, 'utf8'));
47
+ const m = src.match(/__ModuleLoader__\.load\s*\(\s*\{[\s\S]*?id:\s*['"]([^'"]+)['"]/);
48
+ assert.ok(m, '应能解析出 id');
49
+ assert.equal(m[1], pkg.name, '注册 id 必须等于包名,否则 loaded without registering');
50
+ });
51
+
52
+ /** 极简假 React:只要 createElement 能把 type/props/children 记下来即可。 */
53
+ /**
54
+ * 够用的假 React:支持 createElement + useState/useEffect。
55
+ *
56
+ * useState 用「外部可变槽」实现——测试里只渲染一次,不需要真正的重渲染,
57
+ * 但组件初始化时会读 state,所以必须给初值。
58
+ * useEffect 立刻执行一次(组件里用它来 loadConfig)。
59
+ */
60
+ function fakeReact() {
61
+ const created = [];
62
+ const createElement = (type, props, ...children) => {
63
+ // 注意:属性名必须是 $$typeof(两个美元符号)。曾因 PowerShell 里 $$ 被转义
64
+ // 吃成一个 $,变成 typeof,导致所有元素被判定为「非 React 元素」而误报。
65
+ const node = { $$typeof: Symbol.for('react.element'), type, props: { ...(props || {}), children } };
66
+ created.push(node);
67
+ return node;
68
+ };
69
+ // 支持重渲染的假 useState:状态槽**跨多次渲染持久**(模拟真实 React 的 hook 记忆)。
70
+ // 每个 fakeReact() 实例对应一次「组件生命周期」,所以槽放在实例闭包里。
71
+ const hooks = { slots: [], index: 0, rerender: null };
72
+ const useState = (init) => {
73
+ const i = hooks.index++;
74
+ if (hooks.slots[i] === undefined) {
75
+ hooks.slots[i] = { value: typeof init === 'function' ? init() : init };
76
+ }
77
+ const slot = hooks.slots[i];
78
+ const set = (updater) => {
79
+ slot.value = typeof updater === 'function' ? updater(slot.value) : updater;
80
+ if (typeof hooks.rerender === 'function') hooks.rerender();
81
+ };
82
+ return [slot.value, set];
83
+ };
84
+ const useEffect = (fn) => { try { const d = fn(); if (typeof d === 'function') d(); } catch { /* ignore */ } };
85
+ const resetIndex = () => { hooks.index = 0; };
86
+ return { createElement, useState, useEffect, __created: created, __hooks: hooks, __resetIndex: resetIndex };
87
+ }
88
+
89
+ /** 带 react 的 require 桩(factory 只允许 require('react'))。 */
90
+ function requireWithReact(r) {
91
+ return (id) => {
92
+ if (id === 'react') return r;
93
+ throw new Error('client 不应 require: ' + id);
94
+ };
95
+ }
96
+
97
+ // ── C3: 真实执行一次 —— 假 window,确认 load 被调用且 factory 可跑 ──────────
98
+ test('C3: 在假 window 环境下真实执行,load 被调用且导出 apply/inject/name', () => {
99
+ const src = readClient();
100
+
101
+ let captured = null;
102
+ const fakeWindow = {
103
+ __ModuleLoader__: {
104
+ load(def) { captured = def; },
105
+ },
106
+ addEventListener() {},
107
+ removeEventListener() {},
108
+ };
109
+ // eslint-disable-next-line no-new-func
110
+ const run = new Function('window', 'document', src);
111
+ run(fakeWindow, undefined);
112
+
113
+ assert.ok(captured, '__ModuleLoader__.load 必须被调用');
114
+ assert.equal(typeof captured.id, 'string');
115
+ assert.equal(typeof captured.factory, 'function');
116
+
117
+ // factory 要能执行并返回带 apply 的 exports
118
+ const out = captured.factory(requireWithReact(fakeReact()));
119
+ assert.equal(typeof out.apply, 'function', 'factory 必须导出 apply');
120
+ assert.ok(Array.isArray(out.inject), 'factory 必须导出 inject(数组)');
121
+ assert.equal(out.name, captured.id, 'name 应与 id 一致');
122
+ });
123
+
124
+ // ── C4: apply 在「什么都没有」的 ctx 下不崩(降级)──────────────────────────
125
+ test('C4: apply 在无 slots / 无 uiWorkspace 的 ctx 下安全执行', () => {
126
+ const src = readClient();
127
+ let captured = null;
128
+ const fakeWindow = {
129
+ __ModuleLoader__: { load(def) { captured = def; } },
130
+ addEventListener() {},
131
+ removeEventListener() {},
132
+ };
133
+ // eslint-disable-next-line no-new-func
134
+ new Function('window', 'document', src)(fakeWindow, undefined);
135
+ const mod = captured.factory(requireWithReact(fakeReact()));
136
+
137
+ const ctx = { get() { return undefined; } }; // 什么都拿不到
138
+ let dispose;
139
+ assert.doesNotThrow(() => { dispose = mod.apply(ctx); });
140
+ assert.equal(typeof dispose, 'function', 'apply 应返回清理函数');
141
+ assert.doesNotThrow(() => dispose());
142
+ });
143
+
144
+ // ── C5: package.json 的 client 声明与文件存在性一致 ─────────────────────────
145
+ test('C5: package.json 声明 dsh.client 且 exports["./client"] 指向真实文件', () => {
146
+ const pkg = JSON.parse(readFileSync(PKG, 'utf8'));
147
+ assert.ok(pkg.dsh.client, '必须声明 dsh.client');
148
+ assert.equal(pkg.dsh.client.platform, 'web');
149
+ // package.json 的 dsh.client.inject 声明「需要宿主预注入什么」。
150
+ // 我们不需要宿主注入任何东西(API_PREFIX 是常量、没有外部模块),所以这里为空;
151
+ // 而 **代码里** 的 `exports.inject = ['slots']` 是 Cordis 的服务依赖,
152
+ // 由 C13 单独断言。两者语义不同,不要混。
153
+ assert.deepEqual(pkg.dsh.client.inject, [],
154
+ 'dsh.client.inject 应为空:本 client 不需要宿主预注入模块');
155
+ assert.ok(pkg.exports['./client'], 'exports 必须暴露 ./client');
156
+ // 指向的文件必须存在且有内容
157
+ const target = pkg.exports['./client'].replace(/^\.\//, '');
158
+ const src = readFileSync(new URL('../' + target, import.meta.url), 'utf8');
159
+ assert.ok(src.length > 500, 'client.js 不应为空');
160
+ });
161
+
162
+ // ── C6: route 前缀在 host 与 client 两侧一致(防拼错导致 404)───────────────
163
+ test('C6: host 与 client 的 route 前缀一致', async () => {
164
+ const bridge = await import('../lib/session-bridge.js');
165
+ const src = readClient();
166
+ assert.ok(bridge.ROUTE_PREFIX.startsWith('/api/'), 'host 前缀应以 /api/ 开头');
167
+ const seg = bridge.ROUTE_PREFIX.replace('/api/', '');
168
+ assert.ok(src.includes("API_PREFIX = '" + seg + "'"),
169
+ `client 的 API_PREFIX 必须等于 host 的路径段 "${seg}"`);
170
+ });
171
+
172
+ // ── C7: 设置分区必须返回 React 元素,不能返回原生 DOM ──────────────────────
173
+ // v0.4.1 首版用 document.createElement 造节点 → 设置页点进去**一片空白**
174
+ // (React 不认原生 DOM 节点,不会挂载)。本测试锁住这个回归。
175
+ test('C7: 设置分区返回 React 元素(不是原生 DOM 节点)', () => {
176
+ const src = readClient();
177
+ const r = fakeReact();
178
+ let captured = null;
179
+ const fakeWindow = {
180
+ __ModuleLoader__: { load(def) { captured = def; } },
181
+ addEventListener() {}, removeEventListener() {},
182
+ };
183
+ new Function('window', 'document', src)(fakeWindow, undefined);
184
+ const mod = captured.factory(requireWithReact(r));
185
+
186
+ // 造一个能捕获 slots.register 注册项的 ctx
187
+ let registered = null;
188
+ const ctx = {
189
+ get(id) {
190
+ if (id === 'slots') {
191
+ return {
192
+ inject(_name, fn) { fn(); },
193
+ register(meta, component) { registered = { meta, component }; },
194
+ };
195
+ }
196
+ return undefined;
197
+ },
198
+ };
199
+ mod.apply(ctx);
200
+
201
+ assert.ok(registered, '应在 settings.section 上注册一项');
202
+ assert.equal(registered.meta.name, 'settings.section');
203
+ assert.equal(typeof registered.component, 'function', '注册的应是组件函数');
204
+
205
+ // 渲染它 —— 必须产出 React 元素
206
+ const tree = registered.component({});
207
+ assert.ok(tree, '组件应返回内容(不能返回 null 当有了 React)');
208
+ assert.equal(tree.$$typeof, Symbol.for('react.element'),
209
+ '必须返回 React 元素;返回原生 DOM 会导致设置页空白');
210
+ assert.equal(tree.type, 'div', '根节点应是 div');
211
+ });
212
+
213
+ // ── C8: React 拿不到时返回 null 而不是抛(宁可无卡,不可崩页)──────────────
214
+ test('C8: 没有 React 时组件返回 null,且不抛异常', () => {
215
+ const src = readClient();
216
+ let captured = null;
217
+ const fakeWindow = {
218
+ __ModuleLoader__: { load(def) { captured = def; } },
219
+ addEventListener() {}, removeEventListener() {},
220
+ };
221
+ new Function('window', 'document', src)(fakeWindow, undefined);
222
+ // require 抛错 → 模块内 React 应为 null
223
+ const mod = captured.factory(() => { throw new Error('no react'); });
224
+ assert.ok(mod, 'factory 仍应返回 exports(不能因缺 React 就失败)');
225
+ assert.equal(typeof mod.apply, 'function');
226
+
227
+ let registered = null;
228
+ const ctx = {
229
+ get(id) {
230
+ if (id === 'slots') {
231
+ return {
232
+ inject(_n, fn) { fn(); },
233
+ register(_m, c) { registered = c; },
234
+ };
235
+ }
236
+ return undefined;
237
+ },
238
+ };
239
+ assert.doesNotThrow(() => mod.apply(ctx));
240
+ assert.equal(typeof registered, 'function');
241
+ let out;
242
+ assert.doesNotThrow(() => { out = registered({}); });
243
+ assert.equal(out, null, '没有 React 时应返回 null(不渲染),而不是抛');
244
+ });
245
+
246
+ // ── C9: 设置分区不得再用 document.createElement 造节点 ─────────────────────
247
+ // 注意:必须**剥掉注释**再检查,否则文件头解释"为什么不能用 createElement"
248
+ // 的那段说明文字会误伤本测试(自测时踩到过)。
249
+ test('C9: 不用 document.createElement 造 UI(但允许非 UI 的辅助元素)', () => {
250
+ const src = readClient();
251
+ const code = src
252
+ .split('\n')
253
+ .filter((line) => !/^\s*\/\//.test(line)) // 去掉纯注释行
254
+ .join('\n');
255
+
256
+ // 唯一允许的用法:创建一个**不可见**的 <input type=file> 来触发系统文件对话框。
257
+ // 它不会被 React 渲染、也不进入组件树,与"用原生 DOM 冒充 UI"是两回事
258
+ // (后者才会导致设置页空白,见 v0.4.1 事故)。
259
+ const allowed = /document\.createElement\(\s*'input'\s*\)/g;
260
+ const stripped = code.replace(allowed, '/* allowed: file picker */');
261
+
262
+ assert.doesNotMatch(stripped, /document\.createElement/,
263
+ '不得用原生 DOM 造设置页 UI —— React 不会挂载它(v0.4.1 实测空白)。'
264
+ + '唯一例外是不可见的 file input(触发系统文件对话框)。');
265
+ });
266
+
267
+ // ── C10: 设置卡必须有真控件(不能只有说明文字)────────────────────────────
268
+ // 用户实测反馈:「只有说明卡,没有可编辑或者可以操作的地方」。
269
+ // 这条锁住它:渲染出来的树里必须能找到 checkbox / range / select。
270
+ test('C10: 设置卡内含真控件(checkbox + range + select)', async () => {
271
+ const src = readClient();
272
+ const r = fakeReact();
273
+ let captured = null;
274
+ const fakeWindow = {
275
+ __ModuleLoader__: { load(def) { captured = def; } },
276
+ addEventListener() {}, removeEventListener() {},
277
+ };
278
+ new Function('window', 'document', src)(fakeWindow, undefined);
279
+ const mod = captured.factory(requireWithReact(r));
280
+
281
+ let component = null;
282
+ const ctx = {
283
+ get(id) {
284
+ if (id === 'slots') {
285
+ return { inject(_n, fn) { fn(); }, register(_m, c) { component = c; } };
286
+ }
287
+ return undefined;
288
+ },
289
+ };
290
+ mod.apply(ctx);
291
+ assert.ok(component, '应注册设置卡');
292
+
293
+ // fetch 桩:返回一份已有配置,让组件走到"有控件"分支
294
+ globalThis.fetch = () => Promise.resolve({
295
+ ok: true,
296
+ status: 200,
297
+ json: () => Promise.resolve({ ok: true, values: { volume: 55, voice: true, voiceMode: 'fixed', voiceName: 'zundamon-normal' } }),
298
+ });
299
+
300
+ // 首次渲染是「读取配置中…」(loadConfig 是异步的)。
301
+ // 让假 React 挂上 rerender,等微任务跑完 loadConfig 后再渲染一次。
302
+ let tree = component({});
303
+ r.__hooks.rerender = () => { r.__resetIndex(); tree = component({}); };
304
+ // 等两轮微任务,确保 fetch 的 Promise 链走完
305
+ await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); await Promise.resolve();
306
+ // 再主动渲染一次(此时 state 已 loaded)
307
+ r.__resetIndex();
308
+ tree = component({});
309
+ assert.ok(tree, '卡应有内容');
310
+
311
+ // 深度遍历树,收集所有元素的 type
312
+ const types = [];
313
+ const walk = (node) => {
314
+ if (!node || typeof node !== 'object') return;
315
+ if (Array.isArray(node)) return node.forEach(walk);
316
+ if (node.type) types.push(node.type);
317
+ const kids = node.props && node.props.children;
318
+ if (kids) walk(kids);
319
+ };
320
+ walk(tree);
321
+
322
+ assert.ok(types.includes('input'), '必须有 input 控件(开关/音量)');
323
+ assert.ok(types.includes('select'), '必须有 select 控件(音色/模式)');
324
+
325
+ // 更具体:应同时出现 checkbox 与 range 两种 input
326
+ const inputKinds = [];
327
+ const walkInputs = (node) => {
328
+ if (!node || typeof node !== 'object') return;
329
+ if (Array.isArray(node)) return node.forEach(walkInputs);
330
+ if (node.type === 'input' && node.props && node.props.type) inputKinds.push(node.props.type);
331
+ const kids = node.props && node.props.children;
332
+ if (kids) walkInputs(kids);
333
+ };
334
+ walkInputs(tree);
335
+ assert.ok(inputKinds.includes('checkbox'), '应有 checkbox(语音开关 / 事件开关)');
336
+ assert.ok(inputKinds.includes('range'), '应有 range(音量滑杆)');
337
+ // 收紧:个数也要够。语音开关 1 个 + 事件开关 8 个 = 至少 9 个 checkbox;
338
+ // 少一个都说明某个控件被删掉了(早期断言只看"存在",去掉一个仍然绿,是空断言)。
339
+ const checkboxCount = inputKinds.filter((k) => k === 'checkbox').length;
340
+ // 语音开关 1 + 弹窗开关 1 + 事件开关 8 = 至少 10 个(v0.4.4 加了弹窗开关)
341
+ assert.ok(checkboxCount >= 10, `checkbox 至少 10 个(语音 + 弹窗 + 8 个事件),实际 ${checkboxCount}`);
342
+ // range / select 的数量**随音源而变**(tts 时多一个语速滑杆;bundled 才显示音色下拉),
343
+ // 所以断言下限而不是精确值 —— 精确值会把"以后再加一个控件"变成假失败。
344
+ assert.ok(inputKinds.filter((k) => k === 'range').length >= 1, '至少有音量滑杆');
345
+ assert.ok(types.filter((x) => x === 'select').length >= 1, '至少有音源选择器');
346
+ });
347
+
348
+ // ── C11: 每个事件开关都必须有面向用户的说明文字 ────────────────────────────
349
+ // 用户反馈:「空闲门不是所有人都知道什么意思」。内部术语必须在界面上解释。
350
+ // 这条锁住:TOGGLES 里每一项的第 3 个元素(说明)都必须是非空字符串,
351
+ // 且不能只是复述键名。
352
+ test('C11: 每个事件开关都带人话说明(尤其 idleGate)', () => {
353
+ const src = readClient();
354
+
355
+ // 解析 TOGGLES 数组里的每一项
356
+ const m = src.match(/var TOGGLES = \[([\s\S]*?)\n \]/);
357
+ assert.ok(m, '应能定位 TOGGLES 定义');
358
+ const body = m[1];
359
+
360
+ // 每个 [ 'key', '名称', '说明' ] 元组
361
+ const tuples = [...body.matchAll(/\[\s*'([^']+)'\s*,\s*'([^']+)'\s*,\s*([\s\S]*?)\]/g)];
362
+ assert.ok(tuples.length >= 8, `应有至少 8 个开关,实际 ${tuples.length}`);
363
+
364
+ const problems = [];
365
+ for (const t of tuples) {
366
+ const key = t[1];
367
+ const label = t[2];
368
+ const descRaw = t[3].trim();
369
+ if (!descRaw) { problems.push(`${key} 缺少说明`); continue; }
370
+ // 去掉字符串拼接与引号,得到实际文案
371
+ const text = descRaw.replace(/'/g, '').replace(/\+/g, '').replace(/\s+/g, '');
372
+ if (text.length < 8) problems.push(`${key} 的说明过短(${text})`);
373
+ if (text === label.replace(/\s+/g, '')) problems.push(`${key} 的说明只是复述名称`);
374
+ }
375
+ assert.deepEqual(problems, [], '所有开关都应有足够清楚的人话说明');
376
+
377
+ // idleGate 必须有解释"它在防什么"的实质内容
378
+ const idle = tuples.find((t) => t[1] === 'idleGate');
379
+ assert.ok(idle, '应有 idleGate 项');
380
+ const idleText = idle[3].replace(/'/g, '').replace(/\+/g, '');
381
+ assert.match(idleText, /子任务|子代理/, 'idleGate 说明应提到它和子任务/子代理有关');
382
+ assert.match(idleText, /等|先不响|结束/, 'idleGate 说明应讲清"等结束再响"这个行为');
383
+ // 不应把内部术语直接当标题
384
+ assert.doesNotMatch(idle[2], /空闲门/, '标题不应直接用"空闲门"这种内部术语');
385
+ });
386
+
387
+ // ── C12: client 内联的跳转判定必须与 lib/session-jump.js 行为一致 ──────────
388
+ // client 是经典脚本(不能 import ESM),所以把 decideJump 内联了一份。
389
+ // 两份逻辑漂移的话,测试全绿但线上行为错误 —— 这条锁住一致性。
390
+ test('C12: client 内联 shouldJump 与 lib decideJump 行为等价', async () => {
391
+ const src = readClient();
392
+
393
+ // 提取 client 里的 shouldJump 函数体
394
+ const m = src.match(/function shouldJump\(inp\)\s*\{([\s\S]*?)\n \}/);
395
+ assert.ok(m, '应能定位 client 内联的 shouldJump');
396
+ // eslint-disable-next-line no-new-func
397
+ const inlineShouldJump = new Function('inp', m[1]);
398
+
399
+ const { decideJump } = await import('../lib/session-jump.js');
400
+
401
+ // 穷举开关 × 前后台 × 边界,逐一比对
402
+ const cases = [];
403
+ for (const bg of [true, false, undefined]) {
404
+ for (const fg of [true, false, undefined]) {
405
+ for (const phase of ['background', 'foreground']) {
406
+ cases.push({
407
+ jumpWhenBackground: bg,
408
+ jumpWhenForeground: fg,
409
+ consumed: false,
410
+ sessionId: 'sess-1',
411
+ notifiedAt: phase === 'background' ? 1000 : 500,
412
+ leftAt: phase === 'background' ? 500 : 1000,
413
+ });
414
+ }
415
+ }
416
+ }
417
+ // 再加几条边界
418
+ cases.push({ sessionId: '', notifiedAt: 1000, leftAt: 500 });
419
+ cases.push({ sessionId: 's', notifiedAt: 1000, leftAt: 0 });
420
+ cases.push({ sessionId: 's', notifiedAt: 0, leftAt: 500 });
421
+ cases.push({ sessionId: 's', consumed: true, notifiedAt: 1000, leftAt: 500 });
422
+
423
+ // returnedAt 的三种情形(第一版缺这个参数,导致"离开过一次后前台判定永久失效"):
424
+ // undefined → 还没回来(应算后台)
425
+ // > notifiedAt → 通知在回来之前到达(应算后台)
426
+ // <= notifiedAt → 通知在回来之后到达(应算前台)
427
+ for (const bg of [true, false]) {
428
+ for (const fg of [true, false]) {
429
+ cases.push({ jumpWhenBackground: bg, jumpWhenForeground: fg, sessionId: 's', notifiedAt: 5000, leftAt: 1000, returnedAt: undefined });
430
+ cases.push({ jumpWhenBackground: bg, jumpWhenForeground: fg, sessionId: 's', notifiedAt: 5000, leftAt: 1000, returnedAt: 9000 });
431
+ cases.push({ jumpWhenBackground: bg, jumpWhenForeground: fg, sessionId: 's', notifiedAt: 9000, leftAt: 1000, returnedAt: 5000 });
432
+ cases.push({ jumpWhenBackground: bg, jumpWhenForeground: fg, sessionId: 's', notifiedAt: 5000, leftAt: 1000, returnedAt: NaN });
433
+ }
434
+ }
435
+
436
+ const diffs = [];
437
+ for (const c of cases) {
438
+ const a = inlineShouldJump(c);
439
+ const b = decideJump(c).jump;
440
+ if (a !== b) {
441
+ diffs.push({ input: c, client: a, lib: b });
442
+ }
443
+ }
444
+ assert.deepEqual(diffs, [],
445
+ 'client 内联实现与 lib/session-jump.js 必须行为一致,否则线上会误跳/漏跳');
446
+ });
447
+
448
+ // ── C13: exports.inject 必须声明 slots(否则设置分区可能永久不出现)────────
449
+ // 独立审查 2026-10-17 指出:slots 由 dsh-client-ui-renderer 提供,
450
+ // 若本插件激活早于该服务生效,`ctx.get('slots', false)` 会拿到 undefined;
451
+ // 原实现没有重试 → 设置页分区永久缺失且不留日志。
452
+ // 声明为 inject 后宿主会等依赖就绪再激活,从根上避免竞态。
453
+ test('C13: exports.inject 声明了 slots 依赖', () => {
454
+ const src = readClient();
455
+ let captured = null;
456
+ const fakeWindow = {
457
+ __ModuleLoader__: { load(def) { captured = def; } },
458
+ addEventListener() {}, removeEventListener() {},
459
+ };
460
+ new Function('window', 'document', src)(fakeWindow, undefined);
461
+ const mod = captured.factory(requireWithReact(fakeReact()));
462
+ assert.ok(Array.isArray(mod.inject), 'inject 应是数组');
463
+ assert.ok(mod.inject.includes('slots'),
464
+ "inject 必须包含 'slots',否则设置页分区可能因激活顺序而永久不出现");
465
+ });
466
+
467
+ // ── C14: 音源选择器必须存在且五选项齐全 ────────────────────────────────────
468
+ // 用户 2026-10-17 的核心需求:"怎么自由怎么来"—— 有人要语音包、有人要系统音、
469
+ // 有人只想听自己的文件、有人干脆不要声音。四种人都要能选。
470
+ test('C14: 设置卡有音源选择器,含全部五种音源', () => {
471
+ const src = readClient();
472
+ // 选择性读取 SOURCES 定义
473
+ const m = src.match(/var SOURCES = \[([\s\S]*?)\n \]/);
474
+ assert.ok(m, '应能定位 SOURCES 定义');
475
+ for (const id of ['tts', 'bundled', 'system', 'custom', 'none']) {
476
+ assert.ok(m[1].includes("'" + id + "'"), `音源选择器应包含 ${id}`);
477
+ }
478
+ // 必须有下拉选择控件
479
+ assert.match(src, /apply\(\{ audioSource: e\.target\.value \}/, '选择后应保存 audioSource');
480
+ });
481
+
482
+ // ── C15: Toast 开关必须存在 ───────────────────────────────────────────────
483
+ test('C15: 设置卡有「显示通知弹窗」开关', () => {
484
+ const src = readClient();
485
+ assert.match(src, /apply\(\{ showToast: !!e\.target\.checked \}/, '应有 showToast 保存逻辑');
486
+ assert.match(src, /显示通知弹窗/, '应有对应文案');
487
+ });
488
+
489
+ // ── C16: 音源专属设置按当前音源条件显示 ────────────────────────────────────
490
+ // 避免界面上堆一堆无关选项:选 tts 时不该看到音色包下拉,反之亦然。
491
+ test('C16: 音源专属设置按当前音源条件渲染', () => {
492
+ const src = readClient();
493
+ assert.match(src, /if \(curSource === 'bundled'\)/, '语音包设置应受条件控制');
494
+ assert.match(src, /if \(curSource === 'tts'\)/, 'TTS 设置应受条件控制');
495
+ assert.match(src, /if \(curSource === 'custom'\)/, '自定义音频设置应受条件控制');
496
+ });
497
+
498
+ // ── C17: 自定义音频按事件分别选择 ──────────────────────────────────────────
499
+ test('C17: 自定义音频支持按事件分别选文件', () => {
500
+ const src = readClient();
501
+ const m = src.match(/var CUSTOM_KINDS = \[([\s\S]*?)\n \]/);
502
+ assert.ok(m, '应能定位 CUSTOM_KINDS');
503
+ for (const k of ['Done', 'Fail', 'Question', 'Approval']) {
504
+ assert.ok(m[1].includes("'" + k + "'"), `应支持为 ${k} 单独选文件`);
505
+ }
506
+ // 保存键名必须与 config-store 白名单一致(customFileDone 等)
507
+ assert.match(src, /var key = 'customFile' \+ suffix/, '保存键应为 customFile<Kind>');
508
+ });
509
+
510
+ // ── C18: 拿不到真实路径时必须提示,不能静默 ────────────────────────────────
511
+ test('C18: 文件选择失败时给出明确提示而非静默', () => {
512
+ const src = readClient();
513
+ assert.match(src, /没有取到文件路径/, '应提示用户');
514
+ // 必须尝试官方 __DSH_HOST_PATHS__ 拿真实路径
515
+ assert.match(src, /__DSH_HOST_PATHS__/, '应使用宿主暴露的 pathFor 拿真实路径');
516
+ assert.match(src, /pathFor/, '应调用 pathFor');
517
+ });
518
+
519
+ // ── C19: 高级区必须有「按事件覆盖音源」 ────────────────────────────────────
520
+ // 用户 2026-10-17 明确要求:"完成想听日语、失败想听系统警告音"。
521
+ // 这必须是可配置的,且放在折叠区(多数人不需要,铺开只会劝退)。
522
+ test('C19: 高级折叠区含五种事件的音源覆盖', () => {
523
+ const src = readClient();
524
+ // 折叠加展开按钮
525
+ assert.match(src, /高级:给每种提醒单独设置/, '应有高级折叠区标题');
526
+ assert.match(src, /advOpen/, '应有展开/收起状态');
527
+
528
+ // 五个事件都要有覆盖下拉
529
+ const m = src.match(/var KIND_ROWS = \[([\s\S]*?)\]\s*\n/);
530
+ assert.ok(m, '应能定位 KIND_ROWS');
531
+ for (const k of ['Done', 'Fail', 'Question', 'Approval', 'Blocked']) {
532
+ assert.ok(m[1].includes("'" + k + "'"), `应支持 ${k} 的事件级覆盖`);
533
+ }
534
+
535
+ // 写入键名必须与 config-store 白名单一致(kindSourceFail 等)。
536
+ //
537
+ // v0.4.10 起**读写字段名不同**,这是必须的:
538
+ // 写 → 扁平键 kindSourceFail='system'(白名单只认标量)
539
+ // 读 → 字典 v.audioSourceByKind.fail(backend 会把扁平键聚合成字典,
540
+ // 生效值里没有扁平键)
541
+ // 第一版读写都用扁平键,导致界面永远显示"跟随默认"(其实已存进文件)。
542
+ assert.match(src, /var writeKey = 'kindSource' \+ suffix/, '写入键应为 kindSource<Kind>');
543
+ assert.match(src, /v\.audioSourceByKind/, '读取应查 audioSourceByKind 字典');
544
+
545
+ // 必须有「跟随默认」选项,否则用户无法把某项改回继承
546
+ assert.match(src, /__follow__/, '应有「跟随默认」选项');
547
+ assert.match(src, /跟随默认(/, '「跟随默认」应显示当前全局音源名');
548
+ });
549
+
550
+ // ── C20: 热重载开关必须在 GUI,且默认关 ───────────────────────────────────
551
+ // 用户 2026-10-17 要求:"UI 里面没有放上热重载吗?让他选支不支持"
552
+ test('C20: GUI 有热重载开关,且说明其风险', () => {
553
+ const src = readClient();
554
+ assert.match(src, /apply\(\{ devReload: !!e\.target\.checked \}/, '应有 devReload 保存逻辑');
555
+ assert.match(src, /开发:改代码后自动热重载/, '应有开关文案');
556
+ // 必须说明风险:依赖宿主内部结构、失效不影响播报
557
+ assert.match(src, /依赖宿主内部结构/, '应说明它依赖内部结构');
558
+ assert.match(src, /失效时只报错/, '应说明失效时的行为');
559
+ });
560
+
561
+ // ── C21: 状态重建必须保留 advOpen(防"点一下就自己收起")──────────────────
562
+ test('C21: 所有状态重建都保留 advOpen,折叠区不会自己收起', () => {
563
+ const src = readClient();
564
+ // 找所有 return { ... loaded: ... } 形式的状态重建
565
+ const rebuilds = [...src.matchAll(/return \{\s*\n?\s*loaded:/g)];
566
+ assert.ok(rebuilds.length >= 4, `应找到多处状态重建,实际 ${rebuilds.length}`);
567
+
568
+ // 逐个检查每个重建块(从 return { 到下一个 })是否含 advOpen。
569
+ // 不能用全局计数:注释和"切换折叠"那处也会出现 advOpen,会把漏掉的真问题掩盖。
570
+ const missing = [];
571
+ const re = /return \{\n([\s\S]*?)\n\s{0,20}\}/g;
572
+ let mm;
573
+ while ((mm = re.exec(src)) !== null) {
574
+ const body = mm[1];
575
+ if (!/loaded:/.test(body)) continue; // 不是状态重建
576
+ if (!/advOpen:/.test(body)) {
577
+ missing.push(body.split('\n')[0].trim().slice(0, 60));
578
+ }
579
+ }
580
+ assert.deepEqual(missing, [],
581
+ '以下状态重建漏了 advOpen,会导致保存后折叠区自己收起:' + JSON.stringify(missing));
582
+ });
583
+
584
+ // ── C22: 跳转开关必须在 GUI,且不在折叠区里 ────────────────────────────────
585
+ // 用户 2026-10-17 指出:"是否跳回会话好像也还没做,应该也做到 GUI 里面。"
586
+ // 此前只有跳转**逻辑**在读这两个配置键,界面上根本没有开关。
587
+ //
588
+ // 而且要放在**常规区**,不是高级折叠区 —— 它属于大多数人都该看到的设置。
589
+ test('C22: 跳转开关有真控件,且位于高级折叠区之外', () => {
590
+ const src = readClient();
591
+
592
+ // 1) 必须有保存逻辑(真控件,而不是仅读配置)
593
+ assert.match(src, /apply\(\{ jumpWhenBackground: !!e\.target\.checked \}/,
594
+ '后台跳转应有保存逻辑');
595
+ assert.match(src, /apply\(\{ jumpWhenForeground: !!e\.target\.checked \}/,
596
+ '前台跳转应有保存逻辑');
597
+
598
+ // 2) 必须有两个独立开关的文案
599
+ assert.match(src, /在后台完成时跳转/, '应有后台跳转开关文案');
600
+ assert.match(src, /窗口就在眼前时也跳转/, '应有前台跳转开关文案');
601
+
602
+ // 3) 位置:必须在 `if (advOpen)` 之前(常规区),不能被折叠遮住
603
+ const jumpIdx = src.indexOf("key: 'jumpTitle'");
604
+ const advIdx = src.indexOf('if (advOpen) {');
605
+ assert.ok(jumpIdx > 0, '应能找到跳转块');
606
+ assert.ok(advIdx > 0, '应能找到高级区');
607
+ assert.ok(jumpIdx < advIdx,
608
+ '跳转开关必须在高级折叠区**之前**(它属于常规设置,不该被折叠藏起来)');
609
+
610
+ // 4) 说明实测限制,免得用户以为是 bug
611
+ assert.match(src, /Toast 的点击只能唤起窗口/, '应说明"点通知才跳"做不到的原因');
612
+ });
613
+
614
+ // ── C23: 两个开关都关时给出明确提示 ────────────────────────────────────────
615
+ test('C23: 两个跳转开关都关时提示"不会自动跳转"', () => {
616
+ const src = readClient();
617
+ assert.match(src, /两个都关,不会自动跳转/, '应明确告知当前不跳转');
618
+ });
619
+
620
+ // ── C24: TTS 台词输入框 ────────────────────────────────────────────────────
621
+ // 用户 2026-10-17 要求:"既然是这个 TTS 台词,是不是可以用户去输一段话呀?"
622
+ test('C24: TTS 设置区有自定义台词输入框(五种事件)', () => {
623
+ const src = readClient();
624
+ // 五行的定义
625
+ const m = src.match(/var TTS_TEXT_ROWS = \[([\s\S]*?)\n \]/);
626
+ assert.ok(m, '应能定位 TTS_TEXT_ROWS');
627
+ for (const k of ['Done', 'Fail', 'Question', 'Approval', 'Blocked']) {
628
+ assert.ok(m[1].includes("'" + k + "'"), `应支持为 ${k} 自定义台词`);
629
+ }
630
+ // 保存键名必须与 config-store 白名单一致
631
+ assert.match(src, /var key = 'ttsText' \+ suffix/, '保存键应为 ttsText<Kind>');
632
+ // 必须是文本输入而不是别的控件
633
+ assert.match(src, /type: 'text'/, '应为文本输入框');
634
+ // 必须限长(防止用户写几千字拖垮合成)
635
+ assert.match(src, /maxLength: 60/, '输入框应限长 60');
636
+ // 必须说明留空的含义,否则用户不知道该不该填
637
+ assert.match(src, /留空则每次从内置的几句里随机/, '应说明留空 = 内置随机');
638
+ });
639
+
640
+ // ── C25: 高级区每行能展开「该音源的子设置」 ────────────────────────────────
641
+ // 用户 2026-10-17 指出:只选了音源类型,但"念什么话、什么声音、什么音效"
642
+ // 还得回上面用全局的,不合逻辑。而且不只 TTS —— 语音包、系统音效各有参数。
643
+ test('C25: 高级区覆盖音源后展开对应子设置', () => {
644
+ const src = readClient();
645
+
646
+ // 1) 子设置只在"覆盖了音源"时展开(跟随时不展开,避免界面爆炸)
647
+ assert.match(src, /if \(cur === FOLLOW\) return/, '「跟随默认」不应展开子设置');
648
+ assert.match(src, /var effSrc = \(cur === FOLLOW\) \? curSource : cur/, '应能算出实际生效的音源');
649
+
650
+ // 2) 四种音源各有自己的子参数
651
+ assert.match(src, /kindTtsVoice/, 'tts 子设置应有发音人');
652
+ assert.match(src, /kindTtsRate/, 'tts 子设置应有语速');
653
+ assert.match(src, /kindVoiceMode/, 'bundled 子设置应有音色模式');
654
+ assert.match(src, /kindVoiceName/, 'bundled 子设置应有音色名');
655
+ assert.match(src, /kindSystemSound/, 'system 子设置应有音效选择');
656
+
657
+ // 3) 子设置容器存在(有缩进的视觉从属关系)
658
+ assert.match(src, /S\.subBox/, '应有子设置容器样式');
659
+ assert.match(src, /borderLeft/, '子设置应有左边框以表明从属');
660
+ });
661
+
662
+ // ── C26: 子设置保存的键名与 config-store 白名单一致 ────────────────────────
663
+ test('C26: 子设置用 kind<Param><Kind> 命名,与白名单一致', () => {
664
+ const src = readClient();
665
+ // 键名通过 suffix 拼接,检查拼接基准
666
+ for (const base of ['kindTtsVoice', 'kindTtsRate', 'kindVoiceMode', 'kindVoiceName', 'kindSystemSound']) {
667
+ assert.ok(src.includes("'" + base + "'"), `应使用 ${base} 作为键名前缀`);
668
+ }
669
+ // 必须用 suffix 拼,而不是写死某个事件
670
+ assert.match(src, /var kKey = function \(base\) \{ return base \+ suffix \}/, '键名应由前缀 + 事件后缀拼成');
671
+ });