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,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 客户端代码的静态契约测试(v0.4.23)。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么需要这一类测试
|
|
5
|
+
*
|
|
6
|
+
* 2026-10-18 外部代码审阅发现:`consumeLastSession()` 返回**字符串**,
|
|
7
|
+
* 调用方却按 `doc.sessionId` 访问 —— 类型不匹配,导致**会话跳转功能
|
|
8
|
+
* 从未生效过**,而且服务端信号已被 consume 清除,连重试机会都没有。
|
|
9
|
+
*
|
|
10
|
+
* 这类 bug 的特点:
|
|
11
|
+
* · 两端各自"看起来都对"
|
|
12
|
+
* · 跑起来不报错(只是静默不生效)
|
|
13
|
+
* · 没有端到端测试就发现不了
|
|
14
|
+
*
|
|
15
|
+
* 所以这里用**源码静态断言**锁住契约。不是替代运行时测试,
|
|
16
|
+
* 而是防止"改一处忘另一处"的回归。
|
|
17
|
+
*/
|
|
18
|
+
import { test } from 'node:test';
|
|
19
|
+
import assert from 'node:assert/strict';
|
|
20
|
+
import { readFileSync } from 'node:fs';
|
|
21
|
+
import { join, resolve, dirname } from 'node:path';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
|
|
24
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
25
|
+
const CLIENT = join(ROOT, 'lib', 'client.js');
|
|
26
|
+
const NOTIFY_CONFIG = join(ROOT, 'tools', 'notify-config.ps1');
|
|
27
|
+
|
|
28
|
+
const client = readFileSync(CLIENT, 'utf8');
|
|
29
|
+
|
|
30
|
+
// ── C27: consumeLastSession 必须返回对象(不是裸字符串)──────────────────
|
|
31
|
+
test('C27: consumeLastSession 返回 {sessionId, at} 对象而非裸字符串', () => {
|
|
32
|
+
const fn = client.match(/function consumeLastSession\(\)[\s\S]*?\n \}/);
|
|
33
|
+
assert.ok(fn, '应能找到 consumeLastSession 定义');
|
|
34
|
+
const body = fn[0];
|
|
35
|
+
|
|
36
|
+
// 必须返回对象字面量
|
|
37
|
+
assert.match(body, /return\s*\{[\s\S]*sessionId:/,
|
|
38
|
+
'consumeLastSession 必须返回含 sessionId 的对象;'
|
|
39
|
+
+ '返回裸字符串会让调用方的 doc.sessionId 恒为 undefined(2026-10-18 实际 bug)');
|
|
40
|
+
|
|
41
|
+
// 不得再有 `return doc.sessionId`(裸字符串返回)
|
|
42
|
+
assert.doesNotMatch(body, /return\s+doc\.sessionId\s*$/m,
|
|
43
|
+
'不得返回裸字符串 doc.sessionId');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// ── C28: 调用方与返回形状必须一致(防单边修改)──────────────────────────
|
|
47
|
+
test('C28: 调用方按对象访问 sessionId(与 C27 形状一致)', () => {
|
|
48
|
+
const call = client.match(/consumeLastSession\(\)\.then\(function \(doc\) \{[\s\S]{0,400}/);
|
|
49
|
+
assert.ok(call, '应能找到调用点');
|
|
50
|
+
assert.match(call[0], /doc\.sessionId/,
|
|
51
|
+
'调用方应访问 doc.sessionId —— 每边都应跟着另一边改');
|
|
52
|
+
assert.match(call[0], /!doc \|\| !doc\.sessionId/,
|
|
53
|
+
'应保留空值短路判断');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// ── C29: jumpEnabled 必须"读不到配置就不跳" ─────────────────────────────
|
|
57
|
+
test('C29: jumpEnabled 读不到配置时两个开关都为 false', () => {
|
|
58
|
+
// 用「从函数名到下一个 function/】」的宽松切片,避免依赖缩进宽度
|
|
59
|
+
const start = client.indexOf('function jumpEnabled()');
|
|
60
|
+
assert.ok(start >= 0, '应能找到 jumpEnabled 定义');
|
|
61
|
+
const rest = client.slice(start);
|
|
62
|
+
const endRel = rest.search(/\n\s*var onVisibility/);
|
|
63
|
+
const body = endRel > 0 ? rest.slice(0, endRel) : rest.slice(0, 2000);
|
|
64
|
+
|
|
65
|
+
// 原 bug:cfg.jumpWhenBackground !== false 在 {} 上求值为 true
|
|
66
|
+
//
|
|
67
|
+
// 注意:必须**剥掉注释行**再判断 —— 我们的修复注释里就写了
|
|
68
|
+
// `{}.jumpWhenBackground !== false` 来解释这个 bug,
|
|
69
|
+
// 直接搜全文会误报(第一次跑就是这么挂的)。
|
|
70
|
+
const codeOnly = body
|
|
71
|
+
.split('\n')
|
|
72
|
+
.filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line))
|
|
73
|
+
.join('\n');
|
|
74
|
+
assert.doesNotMatch(codeOnly, /jumpWhenBackground\s*!==\s*false/,
|
|
75
|
+
'不得用 `!== false` 判定 —— {} 会得到 true,与"拿不到就不跳"相反');
|
|
76
|
+
|
|
77
|
+
// 必须是严格等于 true
|
|
78
|
+
assert.match(body, /jumpWhenBackground:\s*v\.jumpWhenBackground === true/,
|
|
79
|
+
'必须严格判断 === true,缺失/undefined 都不跳');
|
|
80
|
+
|
|
81
|
+
// 必须显式处理空配置
|
|
82
|
+
assert.match(body, /Object\.keys\(v\)\.length === 0/,
|
|
83
|
+
'必须显式识别空配置(loadConfig 失败返回 {})');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// ── C30: VOICE_KEYS 不得含许可被禁的音色 ────────────────────────────────
|
|
87
|
+
test('C30: GUI 音色列表不含 kasukabe-tsumugi / mochiko', () => {
|
|
88
|
+
const m = client.match(/var VOICE_KEYS = \[[\s\S]*?\]/);
|
|
89
|
+
assert.ok(m, '应能找到 VOICE_KEYS');
|
|
90
|
+
const list = m[0];
|
|
91
|
+
|
|
92
|
+
for (const banned of ['kasukabe-tsumugi', 'mochiko']) {
|
|
93
|
+
// 只检查列表本体,注释里提到是可以的(那是解释为什么移除)
|
|
94
|
+
const inList = new RegExp(`'${banned}'`).test(list);
|
|
95
|
+
assert.equal(inList, false,
|
|
96
|
+
`${banned} 不得出现在 GUI 音色列表里 —— `
|
|
97
|
+
+ '官方规约禁止再分发(见 docs/audio-audit/README.md)');
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// ── C31: 安装提示不得指向不存在的脚本 ───────────────────────────────────
|
|
102
|
+
test('C31: notify-config.ps1 不指向仓库里不存在的脚本', async () => {
|
|
103
|
+
const { existsSync } = await import('node:fs');
|
|
104
|
+
const src = readFileSync(NOTIFY_CONFIG, 'utf8');
|
|
105
|
+
|
|
106
|
+
// 找出所有 "powershell ... -File <某 .ps1>" 的提示
|
|
107
|
+
const refs = [...src.matchAll(/-File\s+\S*?([\w-]+\.ps1)/g)].map((m) => m[1]);
|
|
108
|
+
for (const script of new Set(refs)) {
|
|
109
|
+
const candidates = [
|
|
110
|
+
join(ROOT, 'tools', script),
|
|
111
|
+
join(ROOT, script),
|
|
112
|
+
];
|
|
113
|
+
const found = candidates.some((p) => existsSync(p));
|
|
114
|
+
assert.ok(found,
|
|
115
|
+
`notify-config.ps1 提示用户运行 ${script},但仓库里找不到它 —— `
|
|
116
|
+
+ '这是"照着做就失败"的文档 bug');
|
|
117
|
+
}
|
|
118
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 配置键覆盖检查(v0.4.18)。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么需要这条"元测试"
|
|
5
|
+
*
|
|
6
|
+
* 本项目反复栽在同一类错误上:**新加一个配置项,但某个环节忘了接**。
|
|
7
|
+
*
|
|
8
|
+
* 已发生 6 次:
|
|
9
|
+
* v0.4.4 customFiles 写了但 store 白名单没有
|
|
10
|
+
* v0.4.5 audioSourceByKind 同上
|
|
11
|
+
* v0.4.10 debounceSeconds normalizeConfig 算了,effectiveForClient 没映射
|
|
12
|
+
* v0.4.11 devReload GUI 有开关,白名单没有
|
|
13
|
+
* v0.4.16 systemSound 同上
|
|
14
|
+
* v0.4.18 voiceGain 又漏了 effectiveForClient(用户实测发现)
|
|
15
|
+
*
|
|
16
|
+
* 每次症状都一模一样:**设置能改,但保存后回读是旧值/默认值**,
|
|
17
|
+
* 用户以为"功能不存在",我要花好几轮才定位。
|
|
18
|
+
*
|
|
19
|
+
* 人工"记得每次都改全"是不可靠的(已经证明漏了 6 次)。
|
|
20
|
+
* 所以改成**机器检查**:每个 CONFIG_KEYS 里的键,
|
|
21
|
+
* 都必须能在 effectiveForClient 的输出里找到。
|
|
22
|
+
*
|
|
23
|
+
* ## 为什么用"造一个全值 cfg"的方式测
|
|
24
|
+
*
|
|
25
|
+
* 直接读源码字符串太脆(改名/重构就误报)。
|
|
26
|
+
* 这里构造一个所有字段都有值的内部 cfg,跑一遍映射,
|
|
27
|
+
* 然后检查输出里有没对应键 —— 这是**行为级**检查,抗重构。
|
|
28
|
+
*/
|
|
29
|
+
import { test } from 'node:test';
|
|
30
|
+
import assert from 'node:assert/strict';
|
|
31
|
+
|
|
32
|
+
import { CONFIG_KEYS } from '../lib/config-store.js';
|
|
33
|
+
import { effectiveForClient } from '../lib/index.js';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 造一个**每个已知字段都有值**的内部 cfg。
|
|
37
|
+
*
|
|
38
|
+
* 键名是 normalizeConfig 的输出形状(内部名,如 debounceMs),
|
|
39
|
+
* 不是 schema 键名。新增字段时这里也要补 —— 但即使忘了补,
|
|
40
|
+
* 只要 effectiveForClient 里有映射,本测试仍会通过(因为测试只查输出)。
|
|
41
|
+
*/
|
|
42
|
+
function mkFullCfg() {
|
|
43
|
+
const KINDS = ['done', 'fail', 'question', 'approval', 'blocked'];
|
|
44
|
+
const cap = (k) => k.charAt(0).toUpperCase() + k.slice(1);
|
|
45
|
+
const dict = (v) => Object.fromEntries(KINDS.map((k) => [k, v]));
|
|
46
|
+
return {
|
|
47
|
+
// 基础
|
|
48
|
+
voice: true, voiceMode: 'fixed', voiceName: 'zundamon-normal', volume: 80,
|
|
49
|
+
notifyOnComplete: true, notifyOnError: true, notifyOnBlocked: true,
|
|
50
|
+
notifyOnAborted: true, notifyOnApproval: true, notifyOnQuestion: true,
|
|
51
|
+
subagents: true, idleGate: true,
|
|
52
|
+
// 音源
|
|
53
|
+
audioSource: 'tts', showToast: true, ttsVoice: 'X', ttsRate: 1,
|
|
54
|
+
jumpWhenBackground: true, jumpWhenForeground: true, devReload: true,
|
|
55
|
+
// 冷却
|
|
56
|
+
debounceMs: 1000, globalCooldownMs: 2000, idleGateTimeoutMs: 3000,
|
|
57
|
+
// v0.4.16 增益
|
|
58
|
+
voiceGain: 2.5, normalizeCustomAudio: true,
|
|
59
|
+
// 按事件字典
|
|
60
|
+
audioSourceByKind: dict('tts'),
|
|
61
|
+
ttsTexts: dict('台词'),
|
|
62
|
+
customFiles: dict('D:\\a.wav'),
|
|
63
|
+
kindTtsVoice: dict('V'),
|
|
64
|
+
kindTtsRate: dict(1),
|
|
65
|
+
kindVoiceMode: dict('fixed'),
|
|
66
|
+
kindVoiceName: dict('N'),
|
|
67
|
+
kindSystemSound: dict('Beep'),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── K1: 核心保证 —— 每个配置键都能从 effectiveForClient 读回来 ───────────
|
|
72
|
+
test('K1: CONFIG_KEYS 里的每个键都能从 effectiveForClient 读回', () => {
|
|
73
|
+
const out = effectiveForClient(mkFullCfg());
|
|
74
|
+
|
|
75
|
+
// 这两个是"内部字段"而非配置键,不参与比较
|
|
76
|
+
const INTERNAL = new Set(['debounceMs', 'globalCooldownMs', 'idleGateTimeoutMs']);
|
|
77
|
+
|
|
78
|
+
const missing = CONFIG_KEYS.filter((k) => {
|
|
79
|
+
if (INTERNAL.has(k)) return false;
|
|
80
|
+
// 出现即为映射成功(值可能 undefined 但键应存在)
|
|
81
|
+
return !(k in out);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
assert.deepEqual(missing, [],
|
|
85
|
+
'以下配置键在 effectiveForClient 里没有映射 —— '
|
|
86
|
+
+ 'GUI 会读不到值、保存后显示默认值(本项目已因此栽过 6 次):\n '
|
|
87
|
+
+ missing.join('\n '));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ── K2: 映射出的值必须与输入一致(防"键在但值错")───────────────────────
|
|
91
|
+
test('K2: 映射后的值与内部 cfg 一致', () => {
|
|
92
|
+
const out = effectiveForClient(mkFullCfg());
|
|
93
|
+
assert.equal(out.voiceGain, 2.5);
|
|
94
|
+
assert.equal(out.normalizeCustomAudio, true);
|
|
95
|
+
assert.equal(out.volume, 80);
|
|
96
|
+
assert.equal(out.audioSource, 'tts');
|
|
97
|
+
assert.equal(out.debounceSeconds, 1, '1000ms 应映射成 1 秒');
|
|
98
|
+
assert.equal(out.globalCooldownSeconds, 2);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// ── K3: 默认值也必须能读回(不只是"设过才有")───────────────────────────
|
|
102
|
+
test('K3: 空配置时关键默认值仍可读(前端要显示默认、不能一片 undefined)', () => {
|
|
103
|
+
const out = effectiveForClient({});
|
|
104
|
+
// 增益必须有合理默认,否则滑杆位置无从确定
|
|
105
|
+
assert.equal(typeof out.voiceGain, 'number',
|
|
106
|
+
'voiceGain 必须给出数值,否则滑杆无法定位');
|
|
107
|
+
assert.ok(out.voiceGain >= 1 && out.voiceGain <= 4);
|
|
108
|
+
assert.equal(typeof out.normalizeCustomAudio, 'boolean');
|
|
109
|
+
});
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config-route 测试:HTTP 配置读写的完整验证。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么必须有
|
|
5
|
+
*
|
|
6
|
+
* 这是**唯一会写用户配置文件的代码路径**。它写错的表现是静默的:
|
|
7
|
+
* 用户的音量/音色被改掉而不知道,或者配置被写坏后插件行为异常。
|
|
8
|
+
*
|
|
9
|
+
* ## 覆盖
|
|
10
|
+
*
|
|
11
|
+
* - R1 GET 返回**生效值**,不是文件层(关键:patch 的 volume=100 不能被显示成 80)
|
|
12
|
+
* - R2 保存后响应仍返回生效值(防止其它控件回弹)
|
|
13
|
+
* - R3 保存单个开关只写那一个键,不动其它
|
|
14
|
+
* - R4 非法输入被拒绝,不写盘
|
|
15
|
+
* - R5 只认 /config/set 为写,且必须 POST
|
|
16
|
+
* - R6 clear 后返回生效值(回到 patch/schema 层)
|
|
17
|
+
* - R7 路径分流辅助:/config vs /consume 不会互相误判
|
|
18
|
+
* - R8 文件损坏时不覆盖(fail-closed)
|
|
19
|
+
*/
|
|
20
|
+
import { test } from 'node:test';
|
|
21
|
+
import assert from 'node:assert/strict';
|
|
22
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
23
|
+
import { tmpdir } from 'node:os';
|
|
24
|
+
import { dirname, join } from 'node:path';
|
|
25
|
+
|
|
26
|
+
// ⚠️ 必须在 import 前隔离 DSH_HOME
|
|
27
|
+
const HOME = mkdtempSync(join(tmpdir(), 'notify-cfgroute-'));
|
|
28
|
+
process.env.DSH_HOME = HOME;
|
|
29
|
+
process.on('exit', () => { try { rmSync(HOME, { recursive: true, force: true }); } catch { /* ignore */ } });
|
|
30
|
+
|
|
31
|
+
const { createConfigRoute } = await import('../lib/config-route.js');
|
|
32
|
+
const { configPath } = await import('../lib/config-store.js');
|
|
33
|
+
|
|
34
|
+
/** 造一个记录 code/body 的假 res。 */
|
|
35
|
+
function mkRes() {
|
|
36
|
+
const o = {
|
|
37
|
+
code: 0, body: null,
|
|
38
|
+
writeHead(c) { o.code = c; },
|
|
39
|
+
end(t) { o.body = t == null ? '' : String(t); },
|
|
40
|
+
};
|
|
41
|
+
return o;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 调一次 handler 并解析 JSON。 */
|
|
45
|
+
async function call(route, { method = 'GET', url, body } = {}) {
|
|
46
|
+
const res = mkRes();
|
|
47
|
+
await route.handler({ method, url, body }, res);
|
|
48
|
+
let doc = null;
|
|
49
|
+
try { doc = res.body ? JSON.parse(res.body) : null; } catch { doc = null; }
|
|
50
|
+
return { code: res.code, doc };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 每次测试用全新 route,并**清掉文件层**,避免用例之间串味。
|
|
55
|
+
*
|
|
56
|
+
* ⚠️ 所有用例共享同一个 $DSH_HOME(同一个 config.json),
|
|
57
|
+
* 不清的话前一个用例写的 volume=55 会留到后一个,导致假失败(实测踩到)。
|
|
58
|
+
*/
|
|
59
|
+
function mkRoute(effective) {
|
|
60
|
+
const p = configPath();
|
|
61
|
+
try { if (p && existsSync(p)) unlinkSync(p); } catch { /* ignore */ }
|
|
62
|
+
return createConfigRoute({
|
|
63
|
+
onChanged: () => {},
|
|
64
|
+
getEffective: () => effective,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const GET_URL = '/api/dsh-notify-say/config';
|
|
69
|
+
const SET_URL = '/api/dsh-notify-say/config/set';
|
|
70
|
+
|
|
71
|
+
// ── R1: GET 返回生效值 ─────────────────────────────────────────────────────
|
|
72
|
+
test('R1: GET 返回生效值,而不是文件层覆盖值', async () => {
|
|
73
|
+
// patch 里 volume=100,文件层什么都没存 —— 这正是真实的默认状态
|
|
74
|
+
const effective = { voice: true, volume: 100, voiceMode: 'fixed', voiceName: 'zundamon-normal', idleGate: true };
|
|
75
|
+
const r = mkRoute(effective);
|
|
76
|
+
const { code, doc } = await call(r, { url: GET_URL });
|
|
77
|
+
|
|
78
|
+
assert.equal(code, 200);
|
|
79
|
+
assert.equal(doc.ok, true);
|
|
80
|
+
assert.equal(doc.values.volume, 100,
|
|
81
|
+
'必须显示生效值 100;显示 80 会让用户误拖滑块把 100 覆盖成 80');
|
|
82
|
+
assert.equal(doc.values.voiceMode, 'fixed');
|
|
83
|
+
// 文件层覆盖值单独给出,便于判断"哪些是用户显式设过的"
|
|
84
|
+
assert.ok(doc.overrides && typeof doc.overrides === 'object');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// ── R2: 保存后响应仍是生效值 ───────────────────────────────────────────────
|
|
88
|
+
test('R2: 保存一个开关后,响应里其它键仍是生效值(不回弹)', async () => {
|
|
89
|
+
// 用一个可变的"生效配置"模拟 host:保存后 onChanged 会刷新它
|
|
90
|
+
let effective = { voice: true, volume: 100, voiceMode: 'fixed', voiceName: 'zundamon-normal', idleGate: true };
|
|
91
|
+
const r = createConfigRoute({
|
|
92
|
+
onChanged: () => {
|
|
93
|
+
// 模拟 refreshConfig:把文件层合并进生效值
|
|
94
|
+
effective = { ...effective, idleGate: false };
|
|
95
|
+
},
|
|
96
|
+
getEffective: () => effective,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const { code, doc } = await call(r, {
|
|
100
|
+
method: 'POST', url: SET_URL, body: { values: { idleGate: false } },
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
assert.equal(code, 200);
|
|
104
|
+
assert.equal(doc.ok, true);
|
|
105
|
+
assert.equal(doc.values.idleGate, false, '改动应生效');
|
|
106
|
+
assert.equal(doc.values.volume, 100,
|
|
107
|
+
'volume 不在这次写入里,响应必须仍返回 100;否则 client 会把它覆盖成硬编码 80');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ── R3: 只写指定的键 ──────────────────────────────────────────────────────
|
|
111
|
+
test('R3: 保存只写指定键,不污染其它配置', async () => {
|
|
112
|
+
const r = mkRoute({ voice: true, volume: 100, voiceMode: 'fixed' });
|
|
113
|
+
await call(r, { method: 'POST', url: SET_URL, body: { values: { volume: 55 } } });
|
|
114
|
+
|
|
115
|
+
// 直接读文件层,确认只写了 volume
|
|
116
|
+
const raw = JSON.parse(readFileSync(configPath(), 'utf8'));
|
|
117
|
+
assert.equal(raw.values.volume, 55);
|
|
118
|
+
assert.equal(raw.values.voice, undefined, '不该写入未提交的键');
|
|
119
|
+
assert.equal(raw.values.voiceMode, undefined);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ── R4: 非法输入被拒绝 ────────────────────────────────────────────────────
|
|
123
|
+
test('R4: 非法输入被拒绝且不写盘', async () => {
|
|
124
|
+
const r = mkRoute({ volume: 100 });
|
|
125
|
+
|
|
126
|
+
// 未知键
|
|
127
|
+
let res = await call(r, { method: 'POST', url: SET_URL, body: { values: { notAKey: 1 } } });
|
|
128
|
+
assert.equal(res.code, 400);
|
|
129
|
+
assert.equal(res.doc.ok, false);
|
|
130
|
+
|
|
131
|
+
// 越界值
|
|
132
|
+
res = await call(r, { method: 'POST', url: SET_URL, body: { values: { volume: 9999 } } });
|
|
133
|
+
assert.equal(res.code, 400);
|
|
134
|
+
|
|
135
|
+
// 空 body
|
|
136
|
+
res = await call(r, { method: 'POST', url: SET_URL, body: {} });
|
|
137
|
+
assert.equal(res.code, 400);
|
|
138
|
+
|
|
139
|
+
// 全部拒绝后,文件层应仍是空的
|
|
140
|
+
const p = configPath();
|
|
141
|
+
if (existsSync(p)) {
|
|
142
|
+
const raw = JSON.parse(readFileSync(p, 'utf8'));
|
|
143
|
+
assert.deepEqual(raw.values || {}, {}, '非法写入不应留下任何键');
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ── R5: 方法与路径 ────────────────────────────────────────────────────────
|
|
148
|
+
test('R5: 只认 /config/set 为写,且必须 POST', async () => {
|
|
149
|
+
const r = mkRoute({ volume: 100 });
|
|
150
|
+
|
|
151
|
+
// POST 到 /config(不是 /set)不应被当成写
|
|
152
|
+
const res1 = await call(r, { method: 'POST', url: GET_URL, body: { values: { volume: 7 } } });
|
|
153
|
+
assert.equal(res1.code, 200, 'POST /config 应按读处理');
|
|
154
|
+
assert.equal(res1.doc.values.volume, 100, '不应写入');
|
|
155
|
+
|
|
156
|
+
// GET 到 /config/set 应被拒(405)
|
|
157
|
+
const res2 = await call(r, { method: 'GET', url: SET_URL });
|
|
158
|
+
assert.equal(res2.code, 405);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ── R6: clear 返回生效值 ──────────────────────────────────────────────────
|
|
162
|
+
test('R6: clear 清掉文件层后返回生效值(回到 patch/schema)', async () => {
|
|
163
|
+
let effective = { volume: 100 };
|
|
164
|
+
const r = createConfigRoute({
|
|
165
|
+
onChanged: () => { effective = { volume: 100 }; }, // clear 后回到 patch 值
|
|
166
|
+
getEffective: () => effective,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// 先存一个覆盖
|
|
170
|
+
await call(r, { method: 'POST', url: SET_URL, body: { values: { volume: 30 } } });
|
|
171
|
+
|
|
172
|
+
// 再清空
|
|
173
|
+
const { code, doc } = await call(r, { method: 'POST', url: SET_URL, body: { clear: true } });
|
|
174
|
+
assert.equal(code, 200);
|
|
175
|
+
assert.equal(doc.values.volume, 100, '清空后应回到 patch 的 100,不是空对象');
|
|
176
|
+
|
|
177
|
+
assert.equal(existsSync(configPath()), false, 'clear 应删除文件层');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// ── R7: 路径分流 ──────────────────────────────────────────────────────────
|
|
181
|
+
test('R7: 路径按段分流,查询串里的 /config 不会误判', () => {
|
|
182
|
+
const PREFIX = '/api/dsh-notify-say';
|
|
183
|
+
function route(raw) {
|
|
184
|
+
const pathname = raw.split('?')[0].split('#')[0];
|
|
185
|
+
const rest = pathname.startsWith(PREFIX) ? pathname.slice(PREFIX.length) : pathname;
|
|
186
|
+
const head = rest.replace(/^\/+/, '').split('/')[0];
|
|
187
|
+
return head === 'config' ? 'config' : 'bridge';
|
|
188
|
+
}
|
|
189
|
+
assert.equal(route('/api/dsh-notify-say/config'), 'config');
|
|
190
|
+
assert.equal(route('/api/dsh-notify-say/config/set'), 'config');
|
|
191
|
+
assert.equal(route('/api/dsh-notify-say/consume'), 'bridge');
|
|
192
|
+
assert.equal(route('/api/dsh-notify-say/consume?next=/config'), 'bridge',
|
|
193
|
+
'查询串不应影响分流');
|
|
194
|
+
assert.equal(route('/api/dsh-notify-say/last-session'), 'bridge');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// ── R8: 文件损坏时不覆盖 ──────────────────────────────────────────────────
|
|
198
|
+
test('R8: 文件层损坏时拒绝写入(不覆盖用户数据)', async () => {
|
|
199
|
+
const p = configPath();
|
|
200
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
201
|
+
writeFileSync(p, '{ this is not valid json', 'utf8');
|
|
202
|
+
|
|
203
|
+
// ⚠️ 不能用 mkRoute —— 它会先删掉 config.json,把这个用例精心造的损坏文件清掉。
|
|
204
|
+
const r = createConfigRoute({ onChanged: () => {}, getEffective: () => ({ volume: 100 }) });
|
|
205
|
+
const { code, doc } = await call(r, { method: 'POST', url: SET_URL, body: { values: { volume: 42 } } });
|
|
206
|
+
|
|
207
|
+
assert.equal(code, 409, '损坏的文件不该被覆盖');
|
|
208
|
+
assert.equal(doc.ok, false);
|
|
209
|
+
|
|
210
|
+
// 原文件必须保持原样,供人工救
|
|
211
|
+
assert.equal(readFileSync(p, 'utf8'), '{ this is not valid json');
|
|
212
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config-schema 测试:官方 Config schema 的正确性与降级安全。
|
|
3
|
+
*
|
|
4
|
+
* 这是「设置页 GUI」的地基,错一处就整块报废(官方报错:
|
|
5
|
+
* `Plugin entry "<ns>" has no volatile fields` / `Config field "<path>" is not volatile`),
|
|
6
|
+
* 或更糟:schema 加载失败把插件一起拖挂。所以两类都测。
|
|
7
|
+
*
|
|
8
|
+
* 覆盖:
|
|
9
|
+
* - S1 schema 能从 $DSH_HOME 侧解析到 schemastery
|
|
10
|
+
* - S2 **每个字段**都有 volatile 标记(漏一个就是 has no volatile fields)
|
|
11
|
+
* - S3 默认值正确,且 readConfig 能从 ref 取值
|
|
12
|
+
* - S4 越界值被 schema 拒绝(volume 边界)
|
|
13
|
+
* - S5 降级:$DSH_HOME 缺失时 loadSchema 返回 null 且不抛
|
|
14
|
+
* - S6 降级:$DSH_HOME 指向不存在的目录时同样安全返回 null
|
|
15
|
+
* - S7 readConfig 兼容「普通对象」(Loader 已解析)与「缺失键」
|
|
16
|
+
* - S8 插件模块的 Config 导出与本 schema 一致(防两处各写一份漂移)
|
|
17
|
+
*/
|
|
18
|
+
import { test } from 'node:test';
|
|
19
|
+
import assert from 'node:assert/strict';
|
|
20
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
21
|
+
import { tmpdir } from 'node:os';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
safeHome, loadSchema, buildConfig, readConfig, FIELD_DEFAULTS,
|
|
26
|
+
} from '../lib/config-schema.js';
|
|
27
|
+
|
|
28
|
+
const HAS_HOME = typeof process.env.DSH_HOME === 'string' && process.env.DSH_HOME.length > 0;
|
|
29
|
+
|
|
30
|
+
// ── S1: 能解析到 schemastery ────────────────────────────────────────────────
|
|
31
|
+
test('S1: 从 $DSH_HOME 侧能解析 @deepseek-ai/schemastery',
|
|
32
|
+
{ skip: !HAS_HOME && '需要真实 $DSH_HOME(本机有;纯沙盒环境跳过)' }, () => {
|
|
33
|
+
const z = loadSchema(process.env);
|
|
34
|
+
assert.ok(z, '应解析到 schemastery');
|
|
35
|
+
assert.equal(typeof z.object, 'function', 'schemastery 应提供 z.object');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// ── S2: 每个字段都必须 volatile(官方硬契约)────────────────────────────────
|
|
39
|
+
test('S2: 每个字段都带 volatile 标记(漏一个设置页就报废)', () => {
|
|
40
|
+
const z = loadSchema(process.env);
|
|
41
|
+
if (!z) return; // 降级环境跳过(S5/S6 覆盖降级本身)
|
|
42
|
+
const Config = buildConfig(z);
|
|
43
|
+
const dict = Config.dict;
|
|
44
|
+
assert.ok(dict, 'schema 应有 dict');
|
|
45
|
+
|
|
46
|
+
const missing = [];
|
|
47
|
+
for (const key of Object.keys(FIELD_DEFAULTS)) {
|
|
48
|
+
const field = dict[key];
|
|
49
|
+
if (!field) { missing.push(key + '(字段不存在)'); continue; }
|
|
50
|
+
if (!field.meta || field.meta.volatile !== true) missing.push(key);
|
|
51
|
+
}
|
|
52
|
+
assert.deepEqual(missing, [],
|
|
53
|
+
`以下字段缺少 .volatile(),会导致 "Plugin entry has no volatile fields":${missing.join(', ')}`);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// ── S3: 默认值与取值 ────────────────────────────────────────────────────────
|
|
57
|
+
test('S3: 默认值正确,readConfig 能从 schemastery ref 取值', () => {
|
|
58
|
+
const z = loadSchema(process.env);
|
|
59
|
+
if (!z) return;
|
|
60
|
+
const Config = buildConfig(z);
|
|
61
|
+
const inst = Config({});
|
|
62
|
+
const vals = readConfig(inst);
|
|
63
|
+
|
|
64
|
+
assert.equal(vals.voice, FIELD_DEFAULTS.voice);
|
|
65
|
+
assert.equal(vals.voiceMode, FIELD_DEFAULTS.voiceMode);
|
|
66
|
+
assert.equal(vals.voiceName, FIELD_DEFAULTS.voiceName);
|
|
67
|
+
assert.equal(vals.volume, FIELD_DEFAULTS.volume);
|
|
68
|
+
assert.equal(vals.idleGate, FIELD_DEFAULTS.idleGate);
|
|
69
|
+
assert.equal(Object.keys(vals).length, Object.keys(FIELD_DEFAULTS).length,
|
|
70
|
+
'readConfig 应返回与 FIELD_DEFAULTS 同样多的键');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ── S4: 越界值被拒 ──────────────────────────────────────────────────────────
|
|
74
|
+
test('S4: volume 越界被 schema 拒绝(0-100)', () => {
|
|
75
|
+
const z = loadSchema(process.env);
|
|
76
|
+
if (!z) return;
|
|
77
|
+
const Config = buildConfig(z);
|
|
78
|
+
assert.throws(() => Config({ volume: 101 }), /volume|100/, 'volume=101 应被拒');
|
|
79
|
+
assert.throws(() => Config({ volume: -1 }), /volume|0/, 'volume=-1 应被拒');
|
|
80
|
+
// 边界值合法
|
|
81
|
+
assert.equal(readConfig(Config({ volume: 0 })).volume, 0);
|
|
82
|
+
assert.equal(readConfig(Config({ volume: 100 })).volume, 100);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ── S5: 降级 —— 没有 $DSH_HOME ──────────────────────────────────────────────
|
|
86
|
+
test('S5: $DSH_HOME 缺失时 loadSchema 安全返回 null(不抛)', () => {
|
|
87
|
+
assert.doesNotThrow(() => {
|
|
88
|
+
const z = loadSchema({});
|
|
89
|
+
assert.equal(z, null, '没有 $DSH_HOME 时应返回 null 而不是抛异常');
|
|
90
|
+
});
|
|
91
|
+
assert.equal(safeHome({}), '');
|
|
92
|
+
assert.equal(safeHome({ DSH_HOME: '' }), '');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ── S6: 降级 —— $DSH_HOME 指向不存在的地方 ──────────────────────────────────
|
|
96
|
+
test('S6: $DSH_HOME 指向不存在的目录时安全返回 null', () => {
|
|
97
|
+
const dir = mkdtempSync(join(tmpdir(), 'notify-schema-'));
|
|
98
|
+
try {
|
|
99
|
+
// 用一个空临时目录当 DSH_HOME:没有任何 package.json 锚点
|
|
100
|
+
assert.doesNotThrow(() => {
|
|
101
|
+
const z = loadSchema({ DSH_HOME: dir });
|
|
102
|
+
assert.equal(z, null, '解析不到 schemastery 应返回 null');
|
|
103
|
+
});
|
|
104
|
+
} finally {
|
|
105
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ── S7: readConfig 的兼容性 ─────────────────────────────────────────────────
|
|
110
|
+
test('S7: readConfig 兼容普通对象 / 缺失键 / null', () => {
|
|
111
|
+
// Loader 已解析成普通对象的情况
|
|
112
|
+
const plain = { volume: 42, voice: false, voiceMode: 'random' };
|
|
113
|
+
const got = readConfig(plain);
|
|
114
|
+
assert.equal(got.volume, 42);
|
|
115
|
+
assert.equal(got.voice, false);
|
|
116
|
+
assert.equal(got.voiceMode, 'random');
|
|
117
|
+
// 缺失的键用默认值补齐(避免下游拿到 undefined 后静默走错分支)
|
|
118
|
+
assert.equal(got.idleGate, FIELD_DEFAULTS.idleGate, '缺失键应回落到默认值');
|
|
119
|
+
|
|
120
|
+
// null / undefined 不能崩
|
|
121
|
+
assert.doesNotThrow(() => readConfig(null));
|
|
122
|
+
assert.doesNotThrow(() => readConfig(undefined));
|
|
123
|
+
assert.equal(readConfig(null).volume, FIELD_DEFAULTS.volume);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// ── S8: 插件导出的 Config 与本 schema 一致(防两处漂移)────────────────────
|
|
127
|
+
test('S8: lib/index.js 导出的 Config 与 buildConfig 结构一致', async () => {
|
|
128
|
+
const mod = await import('../lib/index.js');
|
|
129
|
+
assert.equal(typeof mod.apply, 'function', 'apply 必须导出');
|
|
130
|
+
const z = loadSchema(process.env);
|
|
131
|
+
if (!z) {
|
|
132
|
+
assert.equal(mod.Config, undefined, '降级环境下 Config 应为 undefined');
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
assert.equal(typeof mod.Config, 'function', 'Config 应导出且可实例化');
|
|
136
|
+
const mine = buildConfig(z);
|
|
137
|
+
// 字段集合必须一致(防止 index.js 里另写一份)
|
|
138
|
+
assert.deepEqual(
|
|
139
|
+
Object.keys(mod.Config.dict).sort(),
|
|
140
|
+
Object.keys(mine.dict).sort(),
|
|
141
|
+
'index.js 导出的 Config 字段集合与 config-schema.js 必须一致',
|
|
142
|
+
);
|
|
143
|
+
// volatile 契约同样成立
|
|
144
|
+
for (const key of Object.keys(FIELD_DEFAULTS)) {
|
|
145
|
+
assert.equal(mod.Config.dict[key].meta.volatile, true, `${key} 在导出的 Config 里必须 volatile`);
|
|
146
|
+
}
|
|
147
|
+
});
|