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,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-notify-desktop 空闲门(idle gate)单元测试。
|
|
3
|
+
*
|
|
4
|
+
* 覆盖 v0.3.0 引入的三个纯函数与关键判定:
|
|
5
|
+
* - reasonKindOf:turn/end 的 reason.kind 取值(对象 / 字符串 / 缺失)
|
|
6
|
+
* - isDescendantOf:子代理后代判定(含 sessions.fork 分叉会话**不得**误判为后代)
|
|
7
|
+
* - collectInFlight:两路在飞工作汇总(jobs + agents),缺一不可
|
|
8
|
+
*
|
|
9
|
+
* 为什么这些断言是 exact 而非宽松:判定协议(空闲门)的语义漂移会让"误报完成"
|
|
10
|
+
* 这个原始缺陷复发,而宽松断言会把漂移伪装成绿灯。故每条都断言精确结果。
|
|
11
|
+
*
|
|
12
|
+
* 运行:node --test tools/plugins/dsh-notify-desktop/test/idle-gate.test.mjs
|
|
13
|
+
*/
|
|
14
|
+
import { test } from 'node:test';
|
|
15
|
+
import assert from 'node:assert/strict';
|
|
16
|
+
|
|
17
|
+
import { reasonKindOf, isDescendantOf, collectInFlight } from '../lib/index.js';
|
|
18
|
+
|
|
19
|
+
/** 造一个最小 agent 替身(只含判定用到的字段)。 */
|
|
20
|
+
function mkAgent(id, { origin, parentSession, status = 'idle' } = {}) {
|
|
21
|
+
return {
|
|
22
|
+
id,
|
|
23
|
+
status,
|
|
24
|
+
session: { id, header: { id, origin, parentSession } },
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ── reasonKindOf ─────────────────────────────────────────────────────────
|
|
29
|
+
test('reasonKindOf: 对象形态取 kind', () => {
|
|
30
|
+
assert.equal(reasonKindOf({ reason: { kind: 'completed' } }), 'completed');
|
|
31
|
+
assert.equal(reasonKindOf({ reason: { kind: 'error' } }), 'error');
|
|
32
|
+
assert.equal(reasonKindOf({ reason: { kind: 'aborted' } }), 'aborted');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('reasonKindOf: 字符串形态向前兼容', () => {
|
|
36
|
+
assert.equal(reasonKindOf({ reason: 'completed' }), 'completed');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('reasonKindOf: 缺失/畸形一律空串(不抛)', () => {
|
|
40
|
+
assert.equal(reasonKindOf(undefined), '');
|
|
41
|
+
assert.equal(reasonKindOf({}), '');
|
|
42
|
+
assert.equal(reasonKindOf({ reason: {} }), '');
|
|
43
|
+
assert.equal(reasonKindOf({ reason: null }), '');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// ── isDescendantOf:子代理后代判定 ────────────────────────────────────────
|
|
47
|
+
test('isDescendantOf: 直接子代理(origin=subagent, parentSession=root)→ true', () => {
|
|
48
|
+
const root = mkAgent('root-1');
|
|
49
|
+
const child = mkAgent('child-1', { origin: 'subagent', parentSession: 'root-1' });
|
|
50
|
+
const byId = new Map([['root-1', root], ['child-1', child]]);
|
|
51
|
+
assert.equal(isDescendantOf(child, 'root-1', byId), true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('isDescendantOf: 孙代子代理(多跳)→ true', () => {
|
|
55
|
+
const root = mkAgent('root-1');
|
|
56
|
+
const child = mkAgent('child-1', { origin: 'subagent', parentSession: 'root-1' });
|
|
57
|
+
const grand = mkAgent('grand-1', { origin: 'subagent', parentSession: 'child-1' });
|
|
58
|
+
const byId = new Map([['root-1', root], ['child-1', child], ['grand-1', grand]]);
|
|
59
|
+
assert.equal(isDescendantOf(grand, 'root-1', byId), true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('isDescendantOf: 分叉会话(有 parentSession 但 origin 为空)→ false', () => {
|
|
63
|
+
// 这是关键负例:sessions.fork 的分叉会话 header 也带 parentSession,
|
|
64
|
+
// 但它是用户自己的顶层会话。误判为后代会让主会话完成通知被拖住。
|
|
65
|
+
const root = mkAgent('root-1');
|
|
66
|
+
const forked = mkAgent('fork-1', { parentSession: 'root-1' });
|
|
67
|
+
const byId = new Map([['root-1', root], ['fork-1', forked]]);
|
|
68
|
+
assert.equal(isDescendantOf(forked, 'root-1', byId), false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('isDescendantOf: 无关顶层会话 → false', () => {
|
|
72
|
+
const other = mkAgent('other-1');
|
|
73
|
+
const byId = new Map([['other-1', other]]);
|
|
74
|
+
assert.equal(isDescendantOf(other, 'root-1', byId), false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('isDescendantOf: 链上祖先已不在注册表 → false(不臆断)', () => {
|
|
78
|
+
const child = mkAgent('child-1', { origin: 'subagent', parentSession: 'gone-1' });
|
|
79
|
+
const byId = new Map([['child-1', child]]);
|
|
80
|
+
assert.equal(isDescendantOf(child, 'root-1', byId), false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('isDescendantOf: 畸形输入不抛,返回 false', () => {
|
|
84
|
+
assert.equal(isDescendantOf(null, 'root-1', new Map()), false);
|
|
85
|
+
assert.equal(isDescendantOf({}, 'root-1', new Map()), false);
|
|
86
|
+
assert.equal(isDescendantOf(mkAgent('x'), 'root-1', null), false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ── collectInFlight:两路在飞汇总 ─────────────────────────────────────────
|
|
90
|
+
test('collectInFlight: 全部空闲 → 空数组(可播报)', () => {
|
|
91
|
+
const root = mkAgent('root-1');
|
|
92
|
+
assert.deepEqual(collectInFlight([], [root], 'root-1'), []);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('collectInFlight: running 的后台任务算在飞', () => {
|
|
96
|
+
const root = mkAgent('root-1');
|
|
97
|
+
const jobs = [{ id: 'bash-1', label: 'sleep 100', status: 'running' }];
|
|
98
|
+
const items = collectInFlight(jobs, [root], 'root-1');
|
|
99
|
+
assert.equal(items.length, 1);
|
|
100
|
+
assert.equal(items[0].kind, 'job');
|
|
101
|
+
assert.equal(items[0].id, 'bash-1');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('collectInFlight: stopping 也算在飞(尚未真正停止)', () => {
|
|
105
|
+
const root = mkAgent('root-1');
|
|
106
|
+
const jobs = [{ id: 'bash-2', label: 'x', status: 'stopping' }];
|
|
107
|
+
assert.equal(collectInFlight(jobs, [root], 'root-1').length, 1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('collectInFlight: 终态任务不算在飞', () => {
|
|
111
|
+
const root = mkAgent('root-1');
|
|
112
|
+
for (const status of ['completed', 'killed', 'failed']) {
|
|
113
|
+
const jobs = [{ id: 'bash-3', label: 'x', status }];
|
|
114
|
+
assert.equal(collectInFlight(jobs, [root], 'root-1').length, 0, `status=${status} 不应算在飞`);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('collectInFlight: continuable 子代理不注册 job,只能靠 agents 一路发现', () => {
|
|
119
|
+
// 关键正例:这是本缺陷最容易漏掉的一路。
|
|
120
|
+
// dsh-base/cordis.patch.yml:354 的 subagent 工具默认 backgroundMode=continuable,
|
|
121
|
+
// 走 ctx.agents.create({parentAgent}),**不经过 jobs.start**。
|
|
122
|
+
const root = mkAgent('root-1', { status: 'idle' });
|
|
123
|
+
const child = mkAgent('child-1', { origin: 'subagent', parentSession: 'root-1', status: 'running' });
|
|
124
|
+
const items = collectInFlight([], [root, child], 'root-1');
|
|
125
|
+
assert.equal(items.length, 1);
|
|
126
|
+
assert.equal(items[0].kind, 'subagent');
|
|
127
|
+
assert.equal(items[0].id, 'child-1');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('collectInFlight: 子代理已 idle 不算在飞', () => {
|
|
131
|
+
const root = mkAgent('root-1');
|
|
132
|
+
const child = mkAgent('child-1', { origin: 'subagent', parentSession: 'root-1', status: 'idle' });
|
|
133
|
+
assert.deepEqual(collectInFlight([], [root, child], 'root-1'), []);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('collectInFlight: 主会话自身 running 不算"在飞子工作"', () => {
|
|
137
|
+
// 主会话正在跑是正常状态,不该被当作"需要等待的子工作"(否则永不播报)。
|
|
138
|
+
const root = mkAgent('root-1', { status: 'running' });
|
|
139
|
+
assert.deepEqual(collectInFlight([], [root], 'root-1'), []);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('collectInFlight: 无关顶层会话 running 不影响本会话判定', () => {
|
|
143
|
+
const root = mkAgent('root-1');
|
|
144
|
+
const other = mkAgent('other-1', { status: 'running' });
|
|
145
|
+
assert.deepEqual(collectInFlight([], [root, other], 'root-1'), []);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('collectInFlight: 分叉会话 running 不影响本会话判定(负例)', () => {
|
|
149
|
+
const root = mkAgent('root-1');
|
|
150
|
+
const forked = mkAgent('fork-1', { parentSession: 'root-1', status: 'running' });
|
|
151
|
+
assert.deepEqual(collectInFlight([], [root, forked], 'root-1'), []);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('collectInFlight: 两路同时有在飞 → 都报出来', () => {
|
|
155
|
+
const root = mkAgent('root-1');
|
|
156
|
+
const child = mkAgent('child-1', { origin: 'subagent', parentSession: 'root-1', status: 'running' });
|
|
157
|
+
const jobs = [{ id: 'bash-9', label: 'y', status: 'running' }];
|
|
158
|
+
const items = collectInFlight(jobs, [root, child], 'root-1');
|
|
159
|
+
assert.equal(items.length, 2);
|
|
160
|
+
assert.deepEqual(items.map((i) => i.kind).sort(), ['job', 'subagent']);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('collectInFlight: 畸形输入退化为空(fail-visible:宁可播报不静默)', () => {
|
|
164
|
+
assert.deepEqual(collectInFlight(null, null, 'root-1'), []);
|
|
165
|
+
assert.deepEqual(collectInFlight(undefined, undefined, 'root-1'), []);
|
|
166
|
+
assert.deepEqual(collectInFlight([null, undefined], [null, undefined], 'root-1'), []);
|
|
167
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 试听功能测试(v0.4.17)。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么要有试听
|
|
5
|
+
*
|
|
6
|
+
* 用户 2026-10-18 指出:"是不是少了试听?"
|
|
7
|
+
*
|
|
8
|
+
* 对以下设置来说,试听是**必需品而不是锦上添花**:
|
|
9
|
+
* · 音量增益(1.0~4.0)—— 光看数字根本不知道多响
|
|
10
|
+
* · 发音人 / 音色 —— 名字看不出实际声音
|
|
11
|
+
* · 自定义音频 —— 确认选对了文件
|
|
12
|
+
* · 按事件覆盖 —— 确认每个事件的声音都对
|
|
13
|
+
*
|
|
14
|
+
* 没有试听就只能盲调,调一次听一次真实通知,效率极低。
|
|
15
|
+
*
|
|
16
|
+
* ## 与「测试通知」的区别(容易混淆,因此写死在这里)
|
|
17
|
+
*
|
|
18
|
+
* 试听 → 只出声,**不弹窗**(ps1 的 -Preview)
|
|
19
|
+
* 测试通知 → 完整 notify(),含 Toast 弹窗
|
|
20
|
+
*/
|
|
21
|
+
import { test } from 'node:test';
|
|
22
|
+
import assert from 'node:assert/strict';
|
|
23
|
+
import { readFileSync } from 'node:fs';
|
|
24
|
+
import { resolve, dirname, join } from 'node:path';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
|
|
27
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
28
|
+
const HOST = readFileSync(join(ROOT, 'lib', 'index.js'), 'utf8');
|
|
29
|
+
const CLIENT = readFileSync(join(ROOT, 'lib', 'client.js'), 'utf8');
|
|
30
|
+
const PS1 = readFileSync(join(ROOT, 'dsh-notify.ps1'), 'utf8');
|
|
31
|
+
|
|
32
|
+
// ── P1: host 必须有 preview 端点 ─────────────────────────────────────────
|
|
33
|
+
test('P1: host 注册了 /preview 试听端点', () => {
|
|
34
|
+
assert.match(HOST, /head === 'preview'/, 'host 应有 preview 分支');
|
|
35
|
+
// kind 参数用正则从查询串里解析(不写成字面量 '/preview?kind')
|
|
36
|
+
const start = HOST.indexOf("head === 'preview'");
|
|
37
|
+
const seg = HOST.slice(start, start + 6000); // 6KB:给限流与多音源分支留够窗口(v0.4.24 加限流后 3KB 不够)
|
|
38
|
+
assert.match(seg, /kind=\(\[a-z\]\+\)/, '应从查询串解析 kind 参数');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// ── P2: 试听必须走 -Preview(只出声不弹窗)───────────────────────────────
|
|
42
|
+
test('P2: 试听调用 ps1 时带 -Preview(不弹 Toast)', () => {
|
|
43
|
+
// 找到 preview 分支的代码段,确认里面 push 了 '-Preview'
|
|
44
|
+
const start = HOST.indexOf("head === 'preview'");
|
|
45
|
+
assert.ok(start > 0, '应存在 preview 分支');
|
|
46
|
+
const seg = HOST.slice(start, start + 6000); // 6KB:给限流与多音源分支留够窗口(v0.4.24 加限流后 3KB 不够)
|
|
47
|
+
assert.match(seg, /'-Preview'/, '试听必须传 -Preview,否则会弹窗');
|
|
48
|
+
// 而且不应在 preview 分支里调用 notify()(那会弹窗)
|
|
49
|
+
assert.doesNotMatch(seg, /\bnotify\(/, 'preview 分支不该调用 notify()(会弹 Toast)');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// ── P3: 试听要遵循当前音源(tts/bundled/system/custom)────────────────────
|
|
53
|
+
test('P3: 试听按当前音源分流(不是无脑播语音包)', () => {
|
|
54
|
+
const start = HOST.indexOf("head === 'preview'");
|
|
55
|
+
const seg = HOST.slice(start, start + 6000); // 6KB:给限流与多音源分支留够窗口(v0.4.24 加限流后 3KB 不够)
|
|
56
|
+
for (const src of ['tts', 'bundled', 'custom', 'system']) {
|
|
57
|
+
assert.ok(seg.includes(`'${src}'`), `试听应处理 ${src} 音源`);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// ── P4: 试听要带上增益(否则调增益时听不出差别)──────────────────────────
|
|
62
|
+
test('P4: 试听应用 voiceGain(调增益后能听出差别)', () => {
|
|
63
|
+
const start = HOST.indexOf("head === 'preview'");
|
|
64
|
+
const seg = HOST.slice(start, start + 6000); // 6KB:给限流与多音源分支留够窗口(v0.4.24 加限流后 3KB 不够)
|
|
65
|
+
assert.match(seg, /-Gain/, '试听必须带 -Gain,否则调增益时听不到变化');
|
|
66
|
+
assert.match(seg, /voiceGain/, '应从配置读 voiceGain');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// ── P5: client 必须有试听按钮 ───────────────────────────────────────────
|
|
70
|
+
test('P5: GUI 提供试听按钮', () => {
|
|
71
|
+
assert.match(CLIENT, /function previewSound\(/, 'client 应有 previewSound 函数');
|
|
72
|
+
assert.match(CLIENT, /'试听'|试听/, 'GUI 应有试听按钮文案');
|
|
73
|
+
assert.match(CLIENT, /preview\?kind=/, '应请求 preview 端点');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// ── P6: 五种事件都能单独试听 ────────────────────────────────────────────
|
|
77
|
+
test('P6: 五种事件都能单独试听', () => {
|
|
78
|
+
for (const k of ['done', 'fail', 'question', 'approval', 'blocked']) {
|
|
79
|
+
assert.ok(CLIENT.includes(`['${k}',`) || CLIENT.includes(`'${k}'`),
|
|
80
|
+
`应支持试听 ${k}`);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ── P7: ps1 的 -Preview 不弹窗(实现保证)────────────────────────────────
|
|
85
|
+
test('P7: ps1 在 -Preview 模式下不弹 Toast', () => {
|
|
86
|
+
// ps1 里 Preview 分支应只调 Play-VoiceLine,不调 Toast 展示
|
|
87
|
+
const idx = PS1.indexOf('if ($Preview)');
|
|
88
|
+
assert.ok(idx > 0, 'ps1 应有 $Preview 分支');
|
|
89
|
+
const seg = PS1.slice(idx, idx + 800);
|
|
90
|
+
assert.match(seg, /Play-VoiceLine|试听/, 'Preview 分支应播放声音');
|
|
91
|
+
assert.doesNotMatch(seg, /Show-Balloon|Toast\(/, 'Preview 分支不该弹窗');
|
|
92
|
+
});
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-notify-desktop 空闲门「真实 Cordis 接线」测试。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要这个文件(补 wiring.test.mjs 的洞):
|
|
5
|
+
* `wiring.test.mjs` 用的是**自造 ctx 替身**,它的 `get(name)` 忽略第二个参数——
|
|
6
|
+
* 所以那套测试**无法证明** `ctx.get(name, false)`(非 strict 取服务)在真实 Cordis
|
|
7
|
+
* 上真的能拿到服务。如果真实 Cordis 语义不同,空闲门会静默失效(永不挂起 =
|
|
8
|
+
* 本缺陷原样复发),而替身测试**照样全绿**——这是典型的"假绿"。
|
|
9
|
+
*
|
|
10
|
+
* 本文件用**宿主自带的真实 @deepseek-ai/cordis**(4.0.2)加载本插件:
|
|
11
|
+
* - 真实 Context/Service/effect/事件系统;
|
|
12
|
+
* - 真实 `ctx.get`(含 2 参非 strict)语义;
|
|
13
|
+
* - 真实 `ctx.on(..., { global: true })` 是否能注册成功。
|
|
14
|
+
* 只把**外部副作用**(spawn 子进程)隔离掉:SystemRoot 指向不存在路径。
|
|
15
|
+
*
|
|
16
|
+
* 运行:node --test tools/plugins/dsh-notify-desktop/test/
|
|
17
|
+
*/
|
|
18
|
+
import { test } from 'node:test';
|
|
19
|
+
import assert from 'node:assert/strict';
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
import { pathToFileURL } from 'node:url';
|
|
22
|
+
// ⚠️ 必须在 import lib/index.js **之前**把 DSH_HOME 指到空目录。
|
|
23
|
+
//
|
|
24
|
+
// 插件启动时会读 `$DSH_HOME/storages/dsh-notify-desktop/config.json` 作为
|
|
25
|
+
// 「运行时覆盖层」,其优先级**高于** apply() 传入的 config。若用真实 DSH_HOME,
|
|
26
|
+
// 用户只要在 GUI 里改过任何一项(实测:把 notifyOnAborted 打开),
|
|
27
|
+
// 那些假定默认值的用例就会红——那是用户配置导致的,不是代码缺陷。
|
|
28
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
29
|
+
import { tmpdir } from 'node:os';
|
|
30
|
+
import { join } from 'node:path';
|
|
31
|
+
const ISOLATED_HOME = mkdtempSync(join(tmpdir(), 'notify-test-home-'));
|
|
32
|
+
process.env.DSH_HOME = ISOLATED_HOME;
|
|
33
|
+
process.on('exit', () => { try { rmSync(ISOLATED_HOME, { recursive: true, force: true }); } catch { /* best effort */ } });
|
|
34
|
+
|
|
35
|
+
// 必须在 import 插件之前设置:插件模块顶层读 SystemRoot 算 POWERSHELL_EXE。
|
|
36
|
+
process.env.SystemRoot = 'Z:\\__no_such_root__';
|
|
37
|
+
|
|
38
|
+
const { apply } = await import('../lib/index.js');
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 定位宿主自带的真实 cordis。找不到就**跳过**(不静默通过):
|
|
42
|
+
* 该依赖是宿主的、不在插件包内,换机器可能不存在。
|
|
43
|
+
*
|
|
44
|
+
* ⚠️ Windows 上动态 import 绝对路径必须先转 `file://` URL,否则报
|
|
45
|
+
* `ERR_UNSUPPORTED_ESM_URL_SCHEME`(Received protocol 'd:')——实测踩过。
|
|
46
|
+
*/
|
|
47
|
+
function loadRealCordis() {
|
|
48
|
+
const candidates = [
|
|
49
|
+
process.env.DSH_CORDIS_ENTRY || '', // CI/其他机器可指定
|
|
50
|
+
'D:\\Program Files (x86)\\DSH\\.runtime\\node_modules\\@deepseek-ai\\cordis\\lib\\index.js',
|
|
51
|
+
];
|
|
52
|
+
const req = createRequire(import.meta.url);
|
|
53
|
+
for (const p of candidates) {
|
|
54
|
+
// 显式设了 DSH_CORDIS_ENTRY 就只用它 —— 便于模拟"没有宿主"的 CI 环境
|
|
55
|
+
if (process.env.DSH_CORDIS_ENTRY) {
|
|
56
|
+
const only = process.env.DSH_CORDIS_ENTRY;
|
|
57
|
+
try { req.resolve(only); return pathToFileURL(only).href; } catch { return undefined; }
|
|
58
|
+
}
|
|
59
|
+
if (!p) continue;
|
|
60
|
+
try {
|
|
61
|
+
req.resolve(p);
|
|
62
|
+
return pathToFileURL(p).href;
|
|
63
|
+
} catch { /* 试下一个 */ }
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const REAL_CORDIS = loadRealCordis();
|
|
69
|
+
|
|
70
|
+
/** 捕获插件日志。 */
|
|
71
|
+
function captureLogs() {
|
|
72
|
+
const lines = [];
|
|
73
|
+
const origLog = console.log;
|
|
74
|
+
const origWarn = console.warn;
|
|
75
|
+
console.log = (...a) => { lines.push(a.join(' ')); };
|
|
76
|
+
console.warn = (...a) => { lines.push(a.join(' ')); };
|
|
77
|
+
return {
|
|
78
|
+
lines,
|
|
79
|
+
restore() { console.log = origLog; console.warn = origWarn; },
|
|
80
|
+
get text() { return lines.join('\n'); },
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function mkAgent(id, { origin, parentSession, status = 'idle' } = {}) {
|
|
85
|
+
return { id, status, session: { id, header: { id, origin, parentSession } } };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
test('真实 Cordis:ctx.get(name, false) 确实能取到非 strict 提供的服务', { skip: !REAL_CORDIS && '未找到宿主 cordis(跳过,不算通过)' }, async () => {
|
|
89
|
+
const { Context, Service } = await import(REAL_CORDIS);
|
|
90
|
+
const root = new Context();
|
|
91
|
+
|
|
92
|
+
// 用真实 Service 子类注册一个 'jobs',模拟宿主提供方式。
|
|
93
|
+
class FakeJobs extends Service {
|
|
94
|
+
constructor(ctx) { super(ctx, 'jobs'); }
|
|
95
|
+
list() { return []; }
|
|
96
|
+
onJobsChanged() { return () => {}; }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 关键:在**已就绪**的 fiber 上提供,然后分别用 strict / non-strict 读。
|
|
100
|
+
let provided = false;
|
|
101
|
+
root.plugin((ctx) => {
|
|
102
|
+
new FakeJobs(ctx);
|
|
103
|
+
provided = true;
|
|
104
|
+
});
|
|
105
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
106
|
+
assert.equal(provided, true, '前置:服务应已提供');
|
|
107
|
+
|
|
108
|
+
const strictHit = root.get('jobs');
|
|
109
|
+
const looseHit = root.get('jobs', false);
|
|
110
|
+
assert.ok(looseHit, 'ctx.get("jobs", false) 必须能取到服务(空闲门依赖此语义)');
|
|
111
|
+
assert.ok(strictHit, 'ctx.get("jobs") strict 也应取到(fiber 已 active)');
|
|
112
|
+
assert.equal(typeof looseHit.list, 'function', '取到的服务应可用');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('真实 Cordis:未提供时 ctx.get(name, false) 返回 undefined 而不是抛错', { skip: !REAL_CORDIS && '未找到宿主 cordis(跳过,不算通过)' }, async () => {
|
|
116
|
+
const { Context } = await import(REAL_CORDIS);
|
|
117
|
+
const root = new Context();
|
|
118
|
+
let threw = false;
|
|
119
|
+
let val;
|
|
120
|
+
try { val = root.get('definitely-not-provided', false); } catch { threw = true; }
|
|
121
|
+
assert.equal(threw, false, '读未提供的服务不应抛(空闲门靠这个退化)');
|
|
122
|
+
assert.equal(val, undefined, '未提供应为 undefined');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('真实 Cordis:插件能在真实 ctx 上加载,注册三个事件处理器且不抛', { skip: !REAL_CORDIS && '未找到宿主 cordis(跳过,不算通过)' }, async () => {
|
|
126
|
+
const { Context } = await import(REAL_CORDIS);
|
|
127
|
+
const root = new Context();
|
|
128
|
+
const logs = captureLogs();
|
|
129
|
+
let loaded = false;
|
|
130
|
+
let failure;
|
|
131
|
+
try {
|
|
132
|
+
root.plugin((ctx) => { apply(ctx, { idleGate: true }); loaded = true; });
|
|
133
|
+
await new Promise((r) => setTimeout(r, 40));
|
|
134
|
+
} catch (e) {
|
|
135
|
+
failure = e;
|
|
136
|
+
} finally {
|
|
137
|
+
logs.restore();
|
|
138
|
+
}
|
|
139
|
+
assert.equal(failure, undefined, 'apply 不应抛:' + (failure && failure.message));
|
|
140
|
+
assert.equal(loaded, true, '插件应加载成功');
|
|
141
|
+
// 未提供 jobs/agents 时应走退化路径,而不是崩
|
|
142
|
+
assert.doesNotMatch(logs.text, /idle-gate: 挂起完成播报/, '无服务时不应挂起(退化为立即播报)');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('真实 Cordis:空闲门端到端——真实服务下,子代理 running 时挂起', { skip: !REAL_CORDIS && '未找到宿主 cordis(跳过,不算通过)' }, async () => {
|
|
146
|
+
const { Context, Service } = await import(REAL_CORDIS);
|
|
147
|
+
const root = new Context();
|
|
148
|
+
|
|
149
|
+
const main = mkAgent('main-1', { status: 'idle' });
|
|
150
|
+
const child = mkAgent('child-1', { origin: 'subagent', parentSession: 'main-1', status: 'running' });
|
|
151
|
+
const liveAgents = [main, child];
|
|
152
|
+
|
|
153
|
+
// 用真实 Service 注册 agents / jobs,验证插件通过 ctx.get 真能拿到。
|
|
154
|
+
class FakeAgents extends Service {
|
|
155
|
+
constructor(ctx) { super(ctx, 'agents'); }
|
|
156
|
+
list() { return liveAgents; }
|
|
157
|
+
get(id) { return liveAgents.find((a) => a.id === id); }
|
|
158
|
+
}
|
|
159
|
+
class FakeJobs2 extends Service {
|
|
160
|
+
constructor(ctx) { super(ctx, 'jobs'); }
|
|
161
|
+
list() { return []; }
|
|
162
|
+
onJobsChanged() { return () => {}; }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
let ctxRef;
|
|
166
|
+
root.plugin((ctx) => {
|
|
167
|
+
new FakeAgents(ctx);
|
|
168
|
+
new FakeJobs2(ctx);
|
|
169
|
+
ctxRef = ctx;
|
|
170
|
+
apply(ctx, { idleGate: true });
|
|
171
|
+
});
|
|
172
|
+
await new Promise((r) => setTimeout(r, 40));
|
|
173
|
+
assert.ok(ctxRef, '前置:插件应已加载');
|
|
174
|
+
|
|
175
|
+
const logs = captureLogs();
|
|
176
|
+
try {
|
|
177
|
+
// 真实事件分发:session/event + agent/status
|
|
178
|
+
ctxRef.emit('session/event', main.session, { type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } });
|
|
179
|
+
ctxRef.emit('agent/status', { agent: main, status: 'running' });
|
|
180
|
+
ctxRef.emit('agent/status', { agent: main, status: 'idle' });
|
|
181
|
+
assert.match(
|
|
182
|
+
logs.text,
|
|
183
|
+
/idle-gate: 挂起完成播报/,
|
|
184
|
+
'真实 Cordis 下,子代理 running 时**必须**挂起——若这里失败,说明 ctx.get(name,false) 语义不符,空闲门会失效',
|
|
185
|
+
);
|
|
186
|
+
} finally {
|
|
187
|
+
logs.restore();
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('真实 Cordis:ctx.effect 的 disposer 真能被调用(卸载清理路径)', { skip: !REAL_CORDIS && '未找到宿主 cordis(跳过,不算通过)' }, async () => {
|
|
192
|
+
const { Context } = await import(REAL_CORDIS);
|
|
193
|
+
const root = new Context();
|
|
194
|
+
let disposed = false;
|
|
195
|
+
// ctx.plugin 返回 fiber;卸载走 fiber.dispose()(不是 root.stop()——那不存在,实测踩过)。
|
|
196
|
+
const fiber = root.plugin((ctx) => {
|
|
197
|
+
ctx.effect(() => () => { disposed = true; }, 'test-effect');
|
|
198
|
+
});
|
|
199
|
+
await fiber;
|
|
200
|
+
assert.equal(disposed, false, '前置:未卸载前不应清理');
|
|
201
|
+
await fiber.dispose();
|
|
202
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
203
|
+
assert.equal(disposed, true, '卸载时应调用 effect disposer(清挂起计时器靠它)');
|
|
204
|
+
});
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 发布包「个人声音样本」防护测试(v0.4.21)。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么这条测试必须存在
|
|
5
|
+
*
|
|
6
|
+
* `sounds/voice-lines/my-voice-*.wav` 是作者本人的声音样本。
|
|
7
|
+
* 它们只用于本地自用。**一旦进发布包就等于把个人生物特征公开分发,
|
|
8
|
+
* 而且不可撤回**(推上 GitHub / npm 后无法真正删除)。
|
|
9
|
+
*
|
|
10
|
+
* 原有的筛选只靠 `-VoiceKey` 参数,而 `-KeepAllVoices` 会绕过它。
|
|
11
|
+
* 也就是说:任何人为了"打个全量包"加上那个开关,个人声音就会漏出去。
|
|
12
|
+
*
|
|
13
|
+
* 所以 `release-build.ps1` 里加了**前缀黑名单**,优先于一切参数。
|
|
14
|
+
* 这条测试锁住该行为。
|
|
15
|
+
*
|
|
16
|
+
* ## 测试策略
|
|
17
|
+
*
|
|
18
|
+
* 真跑 `release-build.ps1`(不是读源码字符串),
|
|
19
|
+
* 用两种最危险的参数组合验证输出目录里没有 my-voice 文件。
|
|
20
|
+
* 这样即使将来重写了筛选逻辑,只要行为对就通过;
|
|
21
|
+
* 行为一旦回退,立刻变红。
|
|
22
|
+
*/
|
|
23
|
+
import { test } from 'node:test';
|
|
24
|
+
import assert from 'node:assert/strict';
|
|
25
|
+
import { execFileSync } from 'node:child_process';
|
|
26
|
+
import { existsSync, mkdtempSync, readdirSync, statSync } from 'node:fs';
|
|
27
|
+
import { tmpdir } from 'node:os';
|
|
28
|
+
import { join, resolve, dirname } from 'node:path';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
|
|
31
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
32
|
+
const BUILDER = join(ROOT, 'tools', 'release-build.ps1');
|
|
33
|
+
|
|
34
|
+
/** 递归找匹配的文件(不引第三方库)。 */
|
|
35
|
+
function findFiles(dir, predicate, out = []) {
|
|
36
|
+
if (!existsSync(dir)) return out;
|
|
37
|
+
for (const name of readdirSync(dir)) {
|
|
38
|
+
const full = join(dir, name);
|
|
39
|
+
let st;
|
|
40
|
+
try { st = statSync(full); } catch { continue; }
|
|
41
|
+
if (st.isDirectory()) findFiles(full, predicate, out);
|
|
42
|
+
else if (predicate(name)) out.push(full);
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 只找**音频/语音文件**里的被禁音色。
|
|
49
|
+
*
|
|
50
|
+
* ⚠️ 不能对所有文件名做前缀匹配:审核文档快照(docs/audio-audit/snapshots/
|
|
51
|
+
* mochiko.txt)和测试文件本身**就应该**包含这些名字 —— 它们是"为什么禁"
|
|
52
|
+
* 的证据。原来的判据把它们也算成泄漏,属于假阳性。
|
|
53
|
+
*
|
|
54
|
+
* 真正要拦的是:这些音色的 **wav / mp3** 是否进包。
|
|
55
|
+
*/
|
|
56
|
+
function findBannedAudio(dir, voiceKey) {
|
|
57
|
+
return findFiles(dir, (n) => {
|
|
58
|
+
if (!/\.(wav|mp3|m4a|ogg|flac)$/i.test(n)) return false;
|
|
59
|
+
return n.toLowerCase().startsWith(voiceKey.toLowerCase());
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
/** 跑一次发布包构建,返回目标目录。 */
|
|
63
|
+
function buildRelease(extraArgs) {
|
|
64
|
+
const dest = mkdtempSync(join(tmpdir(), 'rel-guard-'));
|
|
65
|
+
execFileSync('powershell.exe', [
|
|
66
|
+
'-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', BUILDER,
|
|
67
|
+
'-Destination', dest, '-Force', ...extraArgs,
|
|
68
|
+
], { encoding: 'utf8', timeout: 300_000 });
|
|
69
|
+
return dest;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── P1: 默认参数下不包含个人声音 ────────────────────────────────────────
|
|
73
|
+
test('P1: 默认参数构建的发布包不含 my-voice 文件', () => {
|
|
74
|
+
if (!existsSync(BUILDER)) return;
|
|
75
|
+
const dest = buildRelease([]);
|
|
76
|
+
const leaked = findBannedAudio(dest, 'my-voice');
|
|
77
|
+
assert.deepEqual(leaked, [],
|
|
78
|
+
'个人声音样本绝不能进发布包 —— 这是不可撤回的隐私泄漏:\n '
|
|
79
|
+
+ leaked.join('\n '));
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ── P2: -KeepAllVoices 也必须挡住(关键:原实现会漏)────────────────────
|
|
83
|
+
test('P2: -KeepAllVoices 也无法带出 my-voice(黑名单优先于一切参数)', () => {
|
|
84
|
+
if (!existsSync(BUILDER)) return;
|
|
85
|
+
const dest = buildRelease(['-KeepAllVoices']);
|
|
86
|
+
const leaked = findBannedAudio(dest, 'my-voice');
|
|
87
|
+
assert.deepEqual(leaked, [],
|
|
88
|
+
'-KeepAllVoices 会绕过 -VoiceKey 筛选,黑名单必须优先于它才能挡住:\n '
|
|
89
|
+
+ leaked.join('\n '));
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// ── P3: 防护不能误伤其它音色 ────────────────────────────────────────────
|
|
93
|
+
test('P3: 防护只挡 my-voice,其它音色照常保留', () => {
|
|
94
|
+
if (!existsSync(BUILDER)) return;
|
|
95
|
+
const dest = buildRelease(['-KeepAllVoices']);
|
|
96
|
+
const wavs = findFiles(dest, (n) => n.toLowerCase().endsWith('.wav'));
|
|
97
|
+
const others = wavs.filter((f) => !/^my-voice/i.test(f.split(/[\\/]/).pop()));
|
|
98
|
+
assert.ok(others.length > 100,
|
|
99
|
+
`-KeepAllVoices 应保留其它全部音色,实际只有 ${others.length} 个 —— 防护可能误伤过广`);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// ── P4: 源码里必须有这道防护的声明 ──────────────────────────────────────
|
|
103
|
+
test('P4: release-build.ps1 里有明确的 NeverShip 黑名单', () => {
|
|
104
|
+
if (!existsSync(BUILDER)) return;
|
|
105
|
+
const src = execFileSync('powershell.exe', [
|
|
106
|
+
'-NoProfile', '-Command',
|
|
107
|
+
`Get-Content -LiteralPath '${BUILDER.replace(/'/g, "''")}' -Raw`,
|
|
108
|
+
], { encoding: 'utf8' });
|
|
109
|
+
assert.match(src, /NeverShipVoicePrefixes/,
|
|
110
|
+
'必须有显式黑名单变量,便于将来审计与修改');
|
|
111
|
+
assert.match(src, /Test-VoiceNeverShip/,
|
|
112
|
+
'必须有判断函数,且它要在 KeepAllVoices 之前生效');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// ── P5: mochiko 必须被阻断(许可明确禁止再分发)──────────────────────────
|
|
116
|
+
test('P5: mochiko 不进发布包(官方规约禁止无偿配发音声素材)', () => {
|
|
117
|
+
if (!existsSync(BUILDER)) return;
|
|
118
|
+
const dest = buildRelease(['-KeepAllVoices']);
|
|
119
|
+
const leaked = findBannedAudio(dest, 'mochiko');
|
|
120
|
+
assert.deepEqual(leaked, [],
|
|
121
|
+
'もち子さん 的官方规约第 1 项禁止「出力した合成音声を使用した音声素材を無償配布」,'
|
|
122
|
+
+ '我们的免费提醒包正落在该禁令内:\n ' + leaked.join('\n '));
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ── P6: Mixkit mp3 不得重新出现(许可禁止再分发素材本身)─────────────────
|
|
126
|
+
test('P6: 发布包不含任何 .mp3(Mixkit 已移除,且禁止再分发)', () => {
|
|
127
|
+
if (!existsSync(BUILDER)) return;
|
|
128
|
+
const dest = buildRelease(['-KeepAllVoices']);
|
|
129
|
+
const mp3 = findFiles(dest, (n) => n.toLowerCase().endsWith('.mp3'));
|
|
130
|
+
assert.deepEqual(mp3, [],
|
|
131
|
+
'Mixkit 许可禁止"redistribute the Item on its own... or with source files",'
|
|
132
|
+
+ '这些 mp3 不应再随包分发:\n ' + mp3.join('\n '));
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ── P7: npm files 白名单必须包含第三方声明 ──────────────────────────────
|
|
136
|
+
test('P7: 发布包 package.json 的 files 含 THIRD-PARTY-NOTICES.md', async () => {
|
|
137
|
+
if (!existsSync(BUILDER)) return;
|
|
138
|
+
const dest = buildRelease([]);
|
|
139
|
+
const pkgPath = join(dest, 'package.json');
|
|
140
|
+
if (!existsSync(pkgPath)) return;
|
|
141
|
+
const pkg = JSON.parse(execFileSync('powershell.exe', [
|
|
142
|
+
'-NoProfile', '-Command',
|
|
143
|
+
`Get-Content -LiteralPath '${pkgPath.replace(/'/g, "''")}' -Raw`,
|
|
144
|
+
], { encoding: 'utf8' }));
|
|
145
|
+
assert.ok(Array.isArray(pkg.files), '应有 files 白名单');
|
|
146
|
+
assert.ok(pkg.files.includes('THIRD-PARTY-NOTICES.md'),
|
|
147
|
+
'npm 只打包 files 白名单里的路径;漏了第三方声明等于用户拿不到它 —— '
|
|
148
|
+
+ '这正是 2026-10-18 外部审阅发现的问题');
|
|
149
|
+
});
|
|
150
|
+
// ── P8: kasukabe-tsumugi 必须阻断(规约禁止「二次配布」)────────────────
|
|
151
|
+
test('P8: kasukabe-tsumugi 不进发布包(官方规约禁止二次配布)', () => {
|
|
152
|
+
if (!existsSync(BUILDER)) return;
|
|
153
|
+
const dest = buildRelease(['-KeepAllVoices']);
|
|
154
|
+
const leaked = findBannedAudio(dest, 'kasukabe-tsumugi');
|
|
155
|
+
assert.deepEqual(leaked, [],
|
|
156
|
+
'春日部つむぎ 官方规约第 6 条禁止事项明确列出「二次配布、自作発言」,'
|
|
157
|
+
+ '我们的 ZIP 分发正属该禁令范围(2026-10-18 读原文发现,此前只当"条件保留"):\n '
|
|
158
|
+
+ leaked.join('\n '));
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ── P9: yurei-chan 必须阻断(身份未确认)────────────────────────────────
|
|
162
|
+
test('P9: yurei-chan 不进发布包(角色身份未确认)', () => {
|
|
163
|
+
if (!existsSync(BUILDER)) return;
|
|
164
|
+
const dest = buildRelease(['-KeepAllVoices']);
|
|
165
|
+
const leaked = findBannedAudio(dest, 'yurei-chan');
|
|
166
|
+
assert.deepEqual(leaked, [],
|
|
167
|
+
'ユーレイちゃん(U-Stella) 与 夜語トバリ 是不同权利人,'
|
|
168
|
+
+ '文件名无法证明实际用的是哪个;确认前不应分发:\n ' + leaked.join('\n '));
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// ── P10: 审核记录必须存在且列明结论 ─────────────────────────────────────
|
|
172
|
+
test('P10: docs/audio-audit/README.md 存在并包含三类判定', async () => {
|
|
173
|
+
const audit = join(ROOT, 'docs', 'audio-audit', 'README.md');
|
|
174
|
+
if (!existsSync(audit)) return;
|
|
175
|
+
const { readFileSync } = await import('node:fs');
|
|
176
|
+
const md = readFileSync(audit, 'utf8');
|
|
177
|
+
for (const marker of ['FORBIDDEN', 'CONDITIONAL', 'PENDING']) {
|
|
178
|
+
assert.match(md, new RegExp(marker),
|
|
179
|
+
`审核记录必须用 ${marker} 标注状态,便于机器与人工共同复核`);
|
|
180
|
+
}
|
|
181
|
+
assert.match(md, /kasukabe-tsumugi/,
|
|
182
|
+
'必须记录 kasukabe-tsumugi 的二次配布禁令——这是读原文才发现的');
|
|
183
|
+
assert.match(md, /二次配布/,
|
|
184
|
+
'必须引用官方原文关键词,而非只写结论');
|
|
185
|
+
});
|