dsh-notify-say 0.4.55 → 0.4.56
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 +1 -1
- package/lib/client.js +55 -4
- package/package.json +1 -1
- package/test/ui-final.test.mjs +220 -0
- package/test/voice-pack-route.test.mjs +46 -0
package/README.md
CHANGED
|
@@ -330,7 +330,7 @@ npm run test:route # 需要 --import loader 的接线测试
|
|
|
330
330
|
npm run test:audio-wire
|
|
331
331
|
```
|
|
332
332
|
|
|
333
|
-
**
|
|
333
|
+
**418 条测试通过。** 绝大多数是纯逻辑测试,克隆仓库后 `npm test` 即可跑。
|
|
334
334
|
|
|
335
335
|
少数用例需要真实的 DSH 宿主(用于验证 Cordis 上下文行为)。
|
|
336
336
|
找不到宿主时它们会**自动 skip** —— 注意 skip 不等于通过。
|
package/lib/client.js
CHANGED
|
@@ -194,7 +194,8 @@ window.__ModuleLoader__.load({
|
|
|
194
194
|
ok: { color: '#1e8449', fontSize: '12px', marginTop: '6px' },
|
|
195
195
|
foot: { opacity: 0.65, marginTop: '12px', fontSize: '12.5px', lineHeight: '1.6' },
|
|
196
196
|
// 事件开关:勾选框 + 可换行的说明文字
|
|
197
|
-
|
|
197
|
+
// 行间距略放大(v0.4.56):420px 下说明文字密集,靠间距而不是缩字号。
|
|
198
|
+
toggleRow: { display: 'flex', alignItems: 'flex-start', gap: '8px', margin: '10px 0' },
|
|
198
199
|
toggleBox: { marginTop: '3px', flex: '0 0 auto' },
|
|
199
200
|
toggleText: { flex: '1', lineHeight: '1.5' },
|
|
200
201
|
toggleLabel: { fontWeight: 500 },
|
|
@@ -598,6 +599,17 @@ window.__ModuleLoader__.load({
|
|
|
598
599
|
var hvset = hvst[1]
|
|
599
600
|
var advHover = hvst[0].id || ""
|
|
600
601
|
|
|
602
|
+
// 声音设置区的展开状态(v0.4.56)。
|
|
603
|
+
//
|
|
604
|
+
// `null` = 跟随默认:声音提醒开着就展开、关了就收起。
|
|
605
|
+
// 用户手动点过之后才固定成 true/false(不覆盖用户的显式选择)。
|
|
606
|
+
//
|
|
607
|
+
// ⚠️ 收起**只是隐藏**,配置一律保留;试听也在里面,
|
|
608
|
+
// 用户想看随时能展开 —— 不禁用任何操作。
|
|
609
|
+
var sndst = React.useState({ open: null })
|
|
610
|
+
var sndset = sndst[1]
|
|
611
|
+
var sndPref = sndst[0].open
|
|
612
|
+
|
|
601
613
|
var st = React.useState({
|
|
602
614
|
loaded: false,
|
|
603
615
|
values: {
|
|
@@ -1087,6 +1099,31 @@ window.__ModuleLoader__.load({
|
|
|
1087
1099
|
// ── 二、声音设置 ────────────────────────────────────────────────
|
|
1088
1100
|
rows.push(section('sound', '声音设置', '音量、音色与试听;语音包可按需下载。'))
|
|
1089
1101
|
|
|
1102
|
+
// 声音提醒关掉时,把音频控件收起来(v0.4.56)。
|
|
1103
|
+
//
|
|
1104
|
+
// 用户指出:两个提醒都关了,下面还铺着整套 TTS 设置,视觉噪音大。
|
|
1105
|
+
// 但**不能删**配置 —— 用户可能只是暂时静音,回头还要用。
|
|
1106
|
+
var voiceOff = v.voice === false
|
|
1107
|
+
var sndOpen = sndPref === null ? !voiceOff : sndPref === true
|
|
1108
|
+
if (voiceOff) {
|
|
1109
|
+
rows.push(h('div', { key: 'snd-off-head', style: S.foldHead,
|
|
1110
|
+
onClick: function () { sndset({ open: !sndOpen }) },
|
|
1111
|
+
role: 'button', tabIndex: 0,
|
|
1112
|
+
'aria-expanded': sndOpen ? 'true' : 'false',
|
|
1113
|
+
onKeyDown: function (e) {
|
|
1114
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
1115
|
+
e.preventDefault()
|
|
1116
|
+
sndset({ open: !sndOpen })
|
|
1117
|
+
}
|
|
1118
|
+
},
|
|
1119
|
+
},
|
|
1120
|
+
h('span', { style: { fontSize: '13px', opacity: 0.9 } },
|
|
1121
|
+
'声音提醒已关闭,原有配置已保留'),
|
|
1122
|
+
h('span', { style: S.foldArrow }, sndOpen ? '▾' : '▸'),
|
|
1123
|
+
))
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
if (sndOpen) {
|
|
1090
1127
|
pushIf(curSource !== 'none', h('div', { key: 'vol', style: S.row },
|
|
1091
1128
|
h('span', { style: S.label }, '音量'),
|
|
1092
1129
|
h('input', {
|
|
@@ -1312,10 +1349,21 @@ window.__ModuleLoader__.load({
|
|
|
1312
1349
|
},
|
|
1313
1350
|
}, p.installed ? '卸载' : (p.downloadable ? '下载' : '不可用')),
|
|
1314
1351
|
),
|
|
1315
|
-
//
|
|
1352
|
+
// 署名与条款:小字,不抢视觉但随时可查。
|
|
1353
|
+
//
|
|
1354
|
+
// ⚠️ 占位提示的判据是「**能下载却没有条款**」(v0.4.56):
|
|
1355
|
+
// 那才是真的数据问题,值得提示。
|
|
1356
|
+
//
|
|
1357
|
+
// 原来判据是「没有 termsUrl 就提示」,于是**离线**时
|
|
1358
|
+
// (索引取不到 → 已安装的包也没有 termsUrl)界面会显示
|
|
1359
|
+
// 「条款链接待补」,看起来像插件发布缺字段 —— 虚报。
|
|
1360
|
+
//
|
|
1361
|
+
// 许可安全本身不受影响:下载按钮要求 p.downloadable,
|
|
1362
|
+
// 而 downloadable 只可能来自带 terms 的远程索引。
|
|
1316
1363
|
h('div', { style: S.subHint },
|
|
1317
1364
|
(p.credit ? '署名:' + p.credit : '')
|
|
1318
|
-
|
|
1365
|
+
// 内联判断(这里是表达式上下文,不能写 var)
|
|
1366
|
+
+ ((p.downloadable && !p.termsUrl) ? ' (条款链接待补)' : ''),
|
|
1319
1367
|
p.termsUrl
|
|
1320
1368
|
? h('a', {
|
|
1321
1369
|
href: p.termsUrl,
|
|
@@ -1541,6 +1589,8 @@ window.__ModuleLoader__.load({
|
|
|
1541
1589
|
notifyOnAborted: false, notifyOnApproval: true, notifyOnQuestion: true,
|
|
1542
1590
|
subagents: false, idleGate: true,
|
|
1543
1591
|
}
|
|
1592
|
+
} // ← 声音设置区展开/收起(v0.4.56)
|
|
1593
|
+
|
|
1544
1594
|
// ── 三、提醒规则 ────────────────────────────────────────────────
|
|
1545
1595
|
rows.push(section('rules', '提醒规则', '哪些事件要提醒你。默认只提醒需要你关注的那些。'))
|
|
1546
1596
|
// 按「什么时候会用到」分三组渲染(v0.4.54)。
|
|
@@ -1803,7 +1853,8 @@ window.__ModuleLoader__.load({
|
|
|
1803
1853
|
var KIND_ROWS = [['Done', '任务完成'], ['Fail', '任务失败'],
|
|
1804
1854
|
['Question', '向你提问'], ['Approval', '待你批准'], ['Blocked', '被拦截']]
|
|
1805
1855
|
var FOLLOW = '__follow__'
|
|
1806
|
-
|
|
1856
|
+
// 内层不再重复标题 —— 二级分组已经叫「按事件定制声音」(v0.4.56)。
|
|
1857
|
+
// 只保留说明,直接接五个事件选择器。
|
|
1807
1858
|
rows.push(h('div', { key: 'advhint', style: S.subHint },
|
|
1808
1859
|
'默认跟随上面的「声音来源」。只有你改过的那几行才会单独生效。'))
|
|
1809
1860
|
KIND_ROWS.forEach(function (pair) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-notify-say",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.56",
|
|
4
4
|
"description": "Desktop notifications for DSH Desktop that actually speak: Windows TTS out of the box (zero download), VOICEVOX voice packs optional, a selectable system-sound fallback, and an idle gate that waits for running subagents before saying the turn is done. Includes a settings-page GUI (sound source, per-event overrides, jump-back-to-session, cooldowns) and a self hot-reload for development.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 定稿前的收尾行为测试(v0.4.56)。
|
|
3
|
+
*
|
|
4
|
+
* 这两条来自用户评审:
|
|
5
|
+
* ① 静音时音频设置应收起,但**不能禁用试听**
|
|
6
|
+
* ② 「条款链接待补」不能虚报
|
|
7
|
+
*/
|
|
8
|
+
import { test } from 'node:test';
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import { readFileSync } from 'node:fs';
|
|
11
|
+
import { resolve, dirname, join } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
|
|
14
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
15
|
+
const CLIENT = readFileSync(join(ROOT, 'lib', 'client.js'), 'utf8');
|
|
16
|
+
|
|
17
|
+
function fakeReact() {
|
|
18
|
+
const createElement = (type, props, ...children) => ({
|
|
19
|
+
$$typeof: Symbol.for('react.element'),
|
|
20
|
+
type,
|
|
21
|
+
props: { ...(props || {}), children },
|
|
22
|
+
});
|
|
23
|
+
const hooks = { slots: [], index: 0, eff: 0, ranEff: new Set(), rerender: null };
|
|
24
|
+
const useState = (init) => {
|
|
25
|
+
const i = hooks.index++;
|
|
26
|
+
if (hooks.slots[i] === undefined) {
|
|
27
|
+
hooks.slots[i] = { value: typeof init === 'function' ? init() : init };
|
|
28
|
+
}
|
|
29
|
+
const slot = hooks.slots[i];
|
|
30
|
+
const set = (updater) => {
|
|
31
|
+
slot.value = typeof updater === 'function' ? updater(slot.value) : updater;
|
|
32
|
+
if (typeof hooks.rerender === 'function') hooks.rerender();
|
|
33
|
+
};
|
|
34
|
+
return [slot.value, set];
|
|
35
|
+
};
|
|
36
|
+
// 只跑一次(模拟 [] 依赖);不调 cleanup(否则 alive=false,配置永远不合并)
|
|
37
|
+
const useEffect = (fn) => {
|
|
38
|
+
const i = hooks.eff++;
|
|
39
|
+
if (hooks.ranEff.has(i)) return;
|
|
40
|
+
hooks.ranEff.add(i);
|
|
41
|
+
try { fn(); } catch { /* ignore */ }
|
|
42
|
+
};
|
|
43
|
+
const resetIndex = () => { hooks.index = 0; hooks.eff = 0; };
|
|
44
|
+
return { createElement, useState, useEffect, __hooks: hooks, __resetIndex: resetIndex };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const FULL = {
|
|
48
|
+
voice: true, voiceMode: 'fixed', voiceName: 'zundamon-normal', volume: 80,
|
|
49
|
+
notifyOnComplete: true, notifyOnError: true, notifyOnBlocked: true,
|
|
50
|
+
notifyOnAborted: false, notifyOnApproval: true, notifyOnQuestion: true,
|
|
51
|
+
subagents: false, idleGate: true,
|
|
52
|
+
audioSource: 'tts', showToast: true, ttsVoice: '', ttsRate: 0, devReload: false,
|
|
53
|
+
logLevel: 'info', voiceGain: 2.0, normalizeCustomAudio: false,
|
|
54
|
+
jumpWhenBackground: true, jumpWhenForeground: false,
|
|
55
|
+
debounceSeconds: 60, globalCooldownSeconds: 0,
|
|
56
|
+
audioSourceByKind: {}, ttsTexts: {}, kindSystemSound: {}, kindVoiceName: {},
|
|
57
|
+
customFiles: {},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function texts(node, out) {
|
|
61
|
+
const acc = out || [];
|
|
62
|
+
if (node === null || node === undefined || node === false) return acc;
|
|
63
|
+
if (typeof node === 'string' || typeof node === 'number') { acc.push(String(node)); return acc; }
|
|
64
|
+
if (Array.isArray(node)) { node.forEach((n) => texts(n, acc)); return acc; }
|
|
65
|
+
texts(node.props && node.props.children, acc);
|
|
66
|
+
return acc;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function elements(node, out) {
|
|
70
|
+
const acc = out || [];
|
|
71
|
+
if (!node || typeof node !== 'object') return acc;
|
|
72
|
+
if (Array.isArray(node)) { node.forEach((n) => elements(n, acc)); return acc; }
|
|
73
|
+
if (node.type) acc.push(node);
|
|
74
|
+
elements(node.props && node.props.children, acc);
|
|
75
|
+
return acc;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 按形状找主 state 槽(组件有多个 useState,不能写死索引)。 */
|
|
79
|
+
function findMainSlot(card) {
|
|
80
|
+
const s = card.r.__hooks.slots.find(
|
|
81
|
+
(x) => x && x.value && typeof x.value === 'object'
|
|
82
|
+
&& 'values' in x.value && 'loaded' in x.value,
|
|
83
|
+
);
|
|
84
|
+
if (!s) throw new Error('找不到主 state 槽');
|
|
85
|
+
return s;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 按形状找「声音设置展开状态」槽({ open: ... })。 */
|
|
89
|
+
function findSoundSlot(card) {
|
|
90
|
+
const s = card.r.__hooks.slots.find(
|
|
91
|
+
(x) => x && x.value && typeof x.value === 'object'
|
|
92
|
+
&& 'open' in x.value && Object.keys(x.value).length === 1,
|
|
93
|
+
);
|
|
94
|
+
if (!s) throw new Error('找不到声音区 state 槽');
|
|
95
|
+
return s;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function loadCard() {
|
|
99
|
+
let captured = null;
|
|
100
|
+
new Function('window', 'document', CLIENT)(
|
|
101
|
+
{ __ModuleLoader__: { load(def) { captured = def; } }, addEventListener() {}, removeEventListener() {} },
|
|
102
|
+
undefined,
|
|
103
|
+
);
|
|
104
|
+
if (!captured) throw new Error('client.js 没有注册');
|
|
105
|
+
|
|
106
|
+
const r = fakeReact();
|
|
107
|
+
const mod = captured.factory((id) => {
|
|
108
|
+
if (id === 'react') return r;
|
|
109
|
+
throw new Error('client 不应 require: ' + id);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
let component = null;
|
|
113
|
+
mod.apply({
|
|
114
|
+
get(id) {
|
|
115
|
+
if (id === 'slots') return { inject(_n, fn) { fn(); }, register(_m, c) { component = c; } };
|
|
116
|
+
return undefined;
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
if (!component) throw new Error('没有注册设置卡');
|
|
120
|
+
|
|
121
|
+
globalThis.fetch = (url) => Promise.resolve({
|
|
122
|
+
ok: true, status: 200,
|
|
123
|
+
json: () => Promise.resolve(
|
|
124
|
+
String(url).includes('version')
|
|
125
|
+
? {
|
|
126
|
+
ok: true, running: '0.4.56', installed: '0.4.56', latest: '0.4.56',
|
|
127
|
+
hasUpdate: false, needsRestart: false, npmLatest: '0.4.56',
|
|
128
|
+
githubLatest: '0.4.56', installMode: 'managed', changelogUrl: 'x',
|
|
129
|
+
}
|
|
130
|
+
: { ok: true, values: FULL, packs: [] },
|
|
131
|
+
),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const render = () => { r.__resetIndex(); return component({}); };
|
|
135
|
+
render();
|
|
136
|
+
r.__hooks.rerender = render;
|
|
137
|
+
for (let i = 0; i < 8; i++) await Promise.resolve();
|
|
138
|
+
return { r, render };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const has = (text, s) => text.includes(s);
|
|
142
|
+
|
|
143
|
+
/** 设 state 后渲染。 */
|
|
144
|
+
function renderWith(card, main, sound) {
|
|
145
|
+
findMainSlot(card).value = { ...findMainSlot(card).value, ...main };
|
|
146
|
+
if (sound !== undefined) findSoundSlot(card).value = { open: sound };
|
|
147
|
+
const tree = card.render();
|
|
148
|
+
return { text: texts(tree).join(' '), els: elements(tree) };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── N6: 静音时收起声音设置,但不禁用试听 ────────────────────────────────
|
|
152
|
+
test('N6: 声音提醒关闭时收起音频设置,试听仍可用', async () => {
|
|
153
|
+
const card = await loadCard();
|
|
154
|
+
|
|
155
|
+
// 声音开着 → 正常展开;手动把 open 置 null(跟随默认)
|
|
156
|
+
{
|
|
157
|
+
const { text } = renderWith(card,
|
|
158
|
+
{ values: { ...FULL, audioSource: 'tts', voice: true } }, null);
|
|
159
|
+
assert.ok(has(text, '朗读声音'), '声音开着时应正常显示音频控件');
|
|
160
|
+
assert.ok(!has(text, '声音提醒已关闭'), '声音开着时不该出现收起提示');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 声音关掉 → 收起,但说明"配置还在"
|
|
164
|
+
{
|
|
165
|
+
const { text } = renderWith(card,
|
|
166
|
+
{ values: { ...FULL, audioSource: 'tts', voice: false } }, null);
|
|
167
|
+
assert.ok(has(text, '声音提醒已关闭,原有配置已保留'),
|
|
168
|
+
'静音时应说明「配置还在」,否则用户以为被清掉了');
|
|
169
|
+
assert.ok(!has(text, '朗读声音'), '静音时应收起音频控件');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 用户手动展开 → 控件回来,且试听**没有**被禁用
|
|
173
|
+
{
|
|
174
|
+
const { text, els } = renderWith(card,
|
|
175
|
+
{ values: { ...FULL, audioSource: 'tts', voice: false } }, true);
|
|
176
|
+
assert.ok(has(text, '朗读声音'), '手动展开后应能看到音频控件');
|
|
177
|
+
|
|
178
|
+
const pvButtons = els.filter((e) => {
|
|
179
|
+
if (e.type !== 'button') return false;
|
|
180
|
+
const kids = e.props.children || [];
|
|
181
|
+
return ['完成', '失败', '提问', '批准', '拦截'].includes(kids[0]);
|
|
182
|
+
});
|
|
183
|
+
assert.ok(pvButtons.length >= 5, `五个试听按钮都应存在(实际 ${pvButtons.length})`);
|
|
184
|
+
assert.ok(pvButtons.every((b) => !b.props.disabled),
|
|
185
|
+
'静音**不得禁用试听** —— 试听是测试配置的行为,与通知开关无关');
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// ── N7: 条款占位不虚报 ──────────────────────────────────────────────────
|
|
190
|
+
test('N7: 条款占位只在「能下载却没条款」时出现', () => {
|
|
191
|
+
// 用户评审时看到「条款链接待补」,要求核实是不是发布缺字段。
|
|
192
|
+
//
|
|
193
|
+
// 核实结论:真实 packs.json 的 13 个音色都有 terms,服务端也转成 termsUrl
|
|
194
|
+
// (R7 用真实请求钉住)。但**离线**时索引取不到,已安装的包没有 termsUrl ——
|
|
195
|
+
// 原来会显示占位,看起来像发布缺字段。
|
|
196
|
+
//
|
|
197
|
+
// 现在判据收窄为「能下载 && 没条款」。
|
|
198
|
+
assert.match(
|
|
199
|
+
CLIENT,
|
|
200
|
+
/\(p\.downloadable && !p\.termsUrl\)/,
|
|
201
|
+
'占位提示必须同时要求 downloadable —— 否则离线时会虚报',
|
|
202
|
+
);
|
|
203
|
+
// 反面断言:不能退回「没有 termsUrl 就报」
|
|
204
|
+
assert.doesNotMatch(
|
|
205
|
+
CLIENT,
|
|
206
|
+
/\+ \(p\.termsUrl \? '' : ' (条款链接待补)'\)/,
|
|
207
|
+
'不应退回旧的「没 termsUrl 就提示」判据',
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// ── N8: 高级区不再有重复标题 ────────────────────────────────────────────
|
|
212
|
+
test('N8: 高级区四个子组没有重复的内部标题', async () => {
|
|
213
|
+
const card = await loadCard();
|
|
214
|
+
const { text } = renderWith(card, { advOpen: true }, undefined);
|
|
215
|
+
// 子组标题「按事件定制声音」只应出现一次(内部不再重复「按事件覆盖声音来源」)
|
|
216
|
+
const occurrences = text.split('按事件定制声音').length - 1;
|
|
217
|
+
assert.equal(occurrences, 1, `「按事件定制声音」应只出现一次(实际 ${occurrences})`);
|
|
218
|
+
assert.doesNotMatch(CLIENT, /'按事件覆盖声音来源'/,
|
|
219
|
+
'内部标题应已删除 —— 与二级分组标题重复');
|
|
220
|
+
});
|
|
@@ -182,3 +182,49 @@ test('R7: 安装索引里没有的音色返回 404', async () => {
|
|
|
182
182
|
`应是 404 或 502,实际 ${res.statusCode}`);
|
|
183
183
|
});
|
|
184
184
|
});
|
|
185
|
+
|
|
186
|
+
// ── R8: 索引里的 terms → termsUrl(前端读的是这个字段名)─────────────────
|
|
187
|
+
test('R8: 路由把 packs.json 的 terms 映射成前端要的 termsUrl', async () => {
|
|
188
|
+
// ⚠️ 用户评审时看到界面显示「条款链接待补」,要求核实是不是发布缺字段。
|
|
189
|
+
//
|
|
190
|
+
// 核实结论:真实 packs.json 的 13 个音色**都有** terms,
|
|
191
|
+
// 但字段名是 `terms`,而前端读的是 `termsUrl` ——
|
|
192
|
+
// 转名这一步在 voice-pack-route.js 里(`termsUrl: p.terms`)。
|
|
193
|
+
//
|
|
194
|
+
// 这步一旦被删,界面会对**所有**音色显示「条款链接待补」。
|
|
195
|
+
// 用真实请求 + 打桩 fetch 把这条跨文件契约钉住。
|
|
196
|
+
await withDataDir(async () => {
|
|
197
|
+
const realFetch = globalThis.fetch;
|
|
198
|
+
const FAKE_TERMS = 'https://example.invalid/voice-terms';
|
|
199
|
+
globalThis.fetch = async () => ({
|
|
200
|
+
ok: true,
|
|
201
|
+
status: 200,
|
|
202
|
+
headers: { get: () => null },
|
|
203
|
+
json: async () => ({
|
|
204
|
+
packs: [{
|
|
205
|
+
key: 'zundamon-normal',
|
|
206
|
+
name: 'ずんだもん(ノーマル)',
|
|
207
|
+
file: 'zundamon-normal.zip',
|
|
208
|
+
bytes: 100,
|
|
209
|
+
sha256: 'a'.repeat(64),
|
|
210
|
+
wavs: 1,
|
|
211
|
+
credit: 'VOICEVOX:ずんだもん',
|
|
212
|
+
terms: FAKE_TERMS,
|
|
213
|
+
}],
|
|
214
|
+
}),
|
|
215
|
+
});
|
|
216
|
+
try {
|
|
217
|
+
const route = createVoicePackRoute({ pluginRoot: '.', logger: { info() {}, warn() {} } });
|
|
218
|
+
const res = await call(route, 'GET', 'index');
|
|
219
|
+
assert.equal(res.statusCode, 200);
|
|
220
|
+
const doc = res.json();
|
|
221
|
+
assert.equal(doc.indexOk, true, '索引应可用(fetch 已打桩)');
|
|
222
|
+
const p = (doc.packs || []).find((x) => x.key === 'zundamon-normal');
|
|
223
|
+
assert.ok(p, '应返回该音色');
|
|
224
|
+
assert.equal(p.termsUrl, FAKE_TERMS,
|
|
225
|
+
'packs.json 里叫 terms,前端读的是 termsUrl —— 名字不对会让界面显示「条款链接待补」');
|
|
226
|
+
} finally {
|
|
227
|
+
globalThis.fetch = realFetch;
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
});
|