dsh-notify-say 0.4.54 → 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 CHANGED
@@ -330,7 +330,7 @@ npm run test:route # 需要 --import loader 的接线测试
330
330
  npm run test:audio-wire
331
331
  ```
332
332
 
333
- **410 条测试通过。** 绝大多数是纯逻辑测试,克隆仓库后 `npm test` 即可跑。
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
- toggleRow: { display: 'flex', alignItems: 'flex-start', gap: '8px', margin: '8px 0' },
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 },
@@ -266,6 +267,56 @@ window.__ModuleLoader__.load({
266
267
  opacity: 0.78, fontSize: '12.5px',
267
268
  },
268
269
 
270
+ // ── 试听按钮组(v0.4.55)────────────────────────────────────────
271
+ //
272
+ // 用户反馈:420px 下五个按钮挤成 4+1,最后一个「拦截」孤零零一行。
273
+ //
274
+ // 改法:整组**独占一行**(flex-basis 100%),用 grid 自动定列数。
275
+ // · 420px:内容宽约 364 → 排得下 5 列(每列约 63px)→ 一行五个
276
+ // · 更窄时自动降列 → 自然换行,行距列距一致(都是 6px)
277
+ // · maxWidth 340:720px 下不让按钮被拉成大块头
278
+ //
279
+ // 不缩小按钮:minmax 下限 56px 比原来的约 51px 还宽一点。
280
+ pvGroup: {
281
+ display: 'grid',
282
+ gridTemplateColumns: 'repeat(auto-fit, minmax(56px, 1fr))',
283
+ gap: '6px',
284
+ flex: '1 1 100%',
285
+ maxWidth: '340px',
286
+ marginTop: '2px',
287
+ },
288
+
289
+ // ── 中性提示框(v0.4.55)────────────────────────────────────────
290
+ //
291
+ // 用于「不是错误、但用户应当知道」的状态(如弹窗与声音都关掉)。
292
+ // 刻意**不用红/黄**:这不是故障,是用户自己的选择,只需告知结果。
293
+ noticeBox: {
294
+ margin: '8px 0 2px',
295
+ padding: '8px 10px',
296
+ borderLeft: '3px solid rgba(128,128,128,.5)',
297
+ background: 'rgba(128,128,128,.08)',
298
+ fontSize: '12.5px',
299
+ lineHeight: '1.6',
300
+ opacity: 0.92,
301
+ },
302
+
303
+ // 可点击的折叠标题(v0.4.55):整行可点,箭头固定在最右。
304
+ foldHead: {
305
+ display: 'flex',
306
+ alignItems: 'center',
307
+ justifyContent: 'space-between',
308
+ gap: '8px',
309
+ cursor: 'pointer',
310
+ userSelect: 'none',
311
+ margin: '16px 0 2px',
312
+ padding: '2px 4px',
313
+ marginLeft: '-4px',
314
+ borderRadius: '4px',
315
+ },
316
+ // 悬停/键盘焦点时的反馈(同一套,键盘用户也要看得见)。
317
+ foldHeadActive: { background: 'rgba(128,128,128,.14)' },
318
+ foldArrow: { flex: '0 0 auto', opacity: 0.6, fontSize: '11px' },
319
+
269
320
  // 说明文字:弱化但保持可读对比度(不要低到看不清)。
270
321
  fieldHint: { fontSize: '12.5px', opacity: 0.72, lineHeight: '1.6', flex: '1 1 100%', margin: '0 0 7px' },
271
322
 
@@ -416,6 +467,54 @@ window.__ModuleLoader__.load({
416
467
  'shikoku-metang', 'ameharu-hau', 'namiine-ritsu', 'shirakami-kotaro',
417
468
  ]
418
469
 
470
+ /**
471
+ * 音色友好显示名(v0.4.55)。
472
+ *
473
+ * 用户反馈:下拉里显示 `zundamon-normal` 是**开发者内部标识**,
474
+ * 普通用户看不懂。改为「角色名 · 风格名」;原 key 只在 title 悬停提示
475
+ * 与诊断日志里出现。
476
+ *
477
+ * ⚠️ 只影响**显示**:保存提交的仍是原 key(voiceName / kindVoiceName),
478
+ * 配置格式、默认值、后端一字未动。
479
+ *
480
+ * ⚠️ 名字与 `tools/build-voice-packs.ps1` 的 $Approved.Name 同源,
481
+ * 由测试 cross-check 防漂移(见 test/voice-label.test.mjs)。
482
+ */
483
+ var VOICE_LABELS = {
484
+ 'zundamon-normal': 'ずんだもん · ノーマル',
485
+ 'zundamon-amama': 'ずんだもん · あまあま',
486
+ 'ankomon': 'あんこもん',
487
+ 'goki-nuigurumi': '後鬼 · ぬいぐるみ',
488
+ 'kyushu-sora': '九州そら',
489
+ 'manbetsu-hanamaru-genki': '満別花丸 · 元気',
490
+ 'meimei-meria': '冥鳴ひまり',
491
+ 'shikoku-metang': '四国めたん',
492
+ 'ameharu-hau': '雨晴はう',
493
+ 'namiine-ritsu': '波音リツ',
494
+ 'shirakami-kotaro': '白上虎太郎',
495
+ }
496
+
497
+ /**
498
+ * key → 友好名。未知 key **原样返回**(不猜、不美化)——
499
+ * 用户手填的自定义 key 显示成它自己,比显示一个错的别名安全。
500
+ */
501
+ function voiceLabel(key) {
502
+ if (!key) return ''
503
+ return VOICE_LABELS[key] || key
504
+ }
505
+
506
+ /**
507
+ * 语音包元数据里的官方名(`ずんだもん(ノーマル)`)
508
+ * → 统一成「角色名 · 风格名」,与 VOICE_LABELS 风格一致。
509
+ *
510
+ * 否则「语音包列表」用括号、「音色下拉」用点,同一件事两种叫法。
511
+ */
512
+ function prettyPackName(name) {
513
+ var s = String(name || '')
514
+ var m = /^(.+?)((.+?))$/.exec(s)
515
+ return m ? (m[1] + ' · ' + m[2]) : s
516
+ }
517
+
419
518
  /** 事件开关定义(键 → 中文名)。 */
420
519
  // 事件开关:[键, 名称, 一句话说明]。
421
520
  //
@@ -493,6 +592,24 @@ window.__ModuleLoader__.load({
493
592
  var avset = avst[1]
494
593
  var advSec = avst[0]
495
594
 
595
+ // 高级子组标题的悬停/焦点高亮(v0.4.55)。
596
+ // 独立 state:它纯粹是瞬时交互反馈,与配置无关,
597
+ // 也不该因为保存配置(25 处 set 重建主 state)被重置。
598
+ var hvst = React.useState({ id: "" })
599
+ var hvset = hvst[1]
600
+ var advHover = hvst[0].id || ""
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
+
496
613
  var st = React.useState({
497
614
  loaded: false,
498
615
  values: {
@@ -935,6 +1052,17 @@ window.__ModuleLoader__.load({
935
1052
  h('span', { style: S.hint }, '关掉只弹 Toast'),
936
1053
  ))
937
1054
 
1055
+ // ⚠️ 两个都关掉 = 完全没有任何提醒(v0.4.55)。
1056
+ //
1057
+ // 用户指出这个歧义:不禁止这样设置(那是用户的选择),
1058
+ // 但必须**如实告知结果**,否则用户以为"我配过了怎么没反应"。
1059
+ // 用中性样式,不用红/黄 —— 这不是故障。
1060
+ if (v.showToast === false && v.voice === false) {
1061
+ rows.push(h('div', { key: 'all-silent', style: S.noticeBox },
1062
+ '弹窗与声音都已关闭 —— 插件不会产生任何可见或可听的提醒。'
1063
+ + '(提醒规则里的事件仍会照常触发,只是没有任何表现。)'))
1064
+ }
1065
+
938
1066
  // ── 音源选择(v0.4.4)─────────────────────────────────────────────────
939
1067
  //
940
1068
  // 这是本插件最核心的可配置项:用户想听系统语音、想听语音包、想要系统音效、
@@ -971,6 +1099,31 @@ window.__ModuleLoader__.load({
971
1099
  // ── 二、声音设置 ────────────────────────────────────────────────
972
1100
  rows.push(section('sound', '声音设置', '音量、音色与试听;语音包可按需下载。'))
973
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) {
974
1127
  pushIf(curSource !== 'none', h('div', { key: 'vol', style: S.row },
975
1128
  h('span', { style: S.label }, '音量'),
976
1129
  h('input', {
@@ -1083,7 +1236,7 @@ window.__ModuleLoader__.load({
1083
1236
  ['done', '完成'], ['fail', '失败'], ['question', '提问'],
1084
1237
  ['approval', '批准'], ['blocked', '拦截'],
1085
1238
  ]
1086
- return h('div', { style: { display: 'flex', gap: '6px', flexWrap: 'wrap', flex: '1' } },
1239
+ return h('div', { style: S.pvGroup },
1087
1240
  KINDS.map(function (pair) {
1088
1241
  var isBusy = pvState.kind === pair[0]
1089
1242
  return h('button', {
@@ -1175,7 +1328,8 @@ window.__ModuleLoader__.load({
1175
1328
  // 条款要**下载前**可见 —— 不能只靠 zip 里的 TERMS.md(那要下载后才看得到)。
1176
1329
  return h('div', { key: 'vp-item-' + p.key, style: { display: 'flex', flexDirection: 'column', gap: '2px', padding: '3px 0' } },
1177
1330
  h('div', { style: { display: 'flex', alignItems: 'center', gap: '8px' } },
1178
- h('span', { style: { flex: '1' } }, (p.name || p.key) + (mb ? '(' + mb + ')' : '')),
1331
+ h('span', { style: { flex: '1' }, title: p.key },
1332
+ (prettyPackName(p.name) || p.key) + (mb ? '(' + mb + ')' : '')),
1179
1333
  p.installed ? h('span', { style: { color: '#2a7' } }, '已安装') : null,
1180
1334
  h('button', {
1181
1335
  type: 'button',
@@ -1195,10 +1349,21 @@ window.__ModuleLoader__.load({
1195
1349
  },
1196
1350
  }, p.installed ? '卸载' : (p.downloadable ? '下载' : '不可用')),
1197
1351
  ),
1198
- // 署名与条款:小字,不抢视觉但随时可查
1352
+ // 署名与条款:小字,不抢视觉但随时可查。
1353
+ //
1354
+ // ⚠️ 占位提示的判据是「**能下载却没有条款**」(v0.4.56):
1355
+ // 那才是真的数据问题,值得提示。
1356
+ //
1357
+ // 原来判据是「没有 termsUrl 就提示」,于是**离线**时
1358
+ // (索引取不到 → 已安装的包也没有 termsUrl)界面会显示
1359
+ // 「条款链接待补」,看起来像插件发布缺字段 —— 虚报。
1360
+ //
1361
+ // 许可安全本身不受影响:下载按钮要求 p.downloadable,
1362
+ // 而 downloadable 只可能来自带 terms 的远程索引。
1199
1363
  h('div', { style: S.subHint },
1200
1364
  (p.credit ? '署名:' + p.credit : '')
1201
- + (p.termsUrl ? '' : ' (条款链接待补)'),
1365
+ // 内联判断(这里是表达式上下文,不能写 var)
1366
+ + ((p.downloadable && !p.termsUrl) ? ' (条款链接待补)' : ''),
1202
1367
  p.termsUrl
1203
1368
  ? h('a', {
1204
1369
  href: p.termsUrl,
@@ -1242,7 +1407,10 @@ window.__ModuleLoader__.load({
1242
1407
  value: v.voiceName || 'zundamon-normal',
1243
1408
  disabled: s.busy || !fixedMode,
1244
1409
  onChange: function (e) { apply({ voiceName: e.target.value }, '音色已更新') },
1245
- }, keyOpts.map(function (k) { return h('option', { key: k, value: k }, k) })),
1410
+ }, keyOpts.map(function (k) {
1411
+ // value 仍是原 key(保存用它),只改显示文本;原 key 放 title
1412
+ return h('option', { key: k, value: k, title: k }, voiceLabel(k))
1413
+ })),
1246
1414
  fixedMode ? null : h('span', { style: S.hint }, '随机模式下不生效'),
1247
1415
  ))
1248
1416
  }
@@ -1421,6 +1589,8 @@ window.__ModuleLoader__.load({
1421
1589
  notifyOnAborted: false, notifyOnApproval: true, notifyOnQuestion: true,
1422
1590
  subagents: false, idleGate: true,
1423
1591
  }
1592
+ } // ← 声音设置区展开/收起(v0.4.56)
1593
+
1424
1594
  // ── 三、提醒规则 ────────────────────────────────────────────────
1425
1595
  rows.push(section('rules', '提醒规则', '哪些事件要提醒你。默认只提醒需要你关注的那些。'))
1426
1596
  // 按「什么时候会用到」分三组渲染(v0.4.54)。
@@ -1647,13 +1817,33 @@ window.__ModuleLoader__.load({
1647
1817
  ADV_SECTIONS.forEach(function (sec) {
1648
1818
  var id = sec[0]
1649
1819
  var isOpen = advSec[id] === true
1820
+ // 整行可点 + 箭头固定最右 + 悬停/焦点反馈 + 键盘可操作(v0.4.55)。
1821
+ //
1822
+ // 用户反馈:原来箭头跟在文字后面(`高级设置 ▸`),不明显能点。
1823
+ // 现在整行是点击区、箭头靠右、Enter/Space 也能展开 ——
1824
+ // 键盘用户与鼠标用户看到同一套反馈(foldHeadActive)。
1825
+ var headActive = advHover === id
1650
1826
  rows.push(h('div', {
1651
1827
  key: 'adv-sec-' + id,
1652
- style: Object.assign({}, S.groupLabel, {
1653
- cursor: 'pointer', userSelect: 'none', margin: '16px 0 2px',
1654
- }),
1828
+ role: 'button',
1829
+ tabIndex: 0,
1830
+ 'aria-expanded': isOpen ? 'true' : 'false',
1831
+ style: Object.assign({}, S.foldHead, headActive ? S.foldHeadActive : null),
1655
1832
  onClick: function () { toggleAdvSec(id) },
1656
- }, sec[1] + (isOpen ? ' ▾' : ' ▸')))
1833
+ onMouseEnter: function () { hvset({ id: id }) },
1834
+ onMouseLeave: function () { hvset({ id: advHover === id ? '' : advHover }) },
1835
+ onFocus: function () { hvset({ id: id }) },
1836
+ onBlur: function () { hvset({ id: advHover === id ? '' : advHover }) },
1837
+ onKeyDown: function (e) {
1838
+ if (e.key === 'Enter' || e.key === ' ') {
1839
+ e.preventDefault()
1840
+ toggleAdvSec(id)
1841
+ }
1842
+ },
1843
+ },
1844
+ h('span', { style: { fontWeight: 600, fontSize: '13px' } }, sec[1]),
1845
+ h('span', { style: S.foldArrow }, isOpen ? '▾' : '▸'),
1846
+ ))
1657
1847
  rows.push(h('div', { key: 'adv-sec-desc-' + id, style: S.subHint }, sec[2]))
1658
1848
 
1659
1849
  if (isOpen) {
@@ -1663,7 +1853,8 @@ window.__ModuleLoader__.load({
1663
1853
  var KIND_ROWS = [['Done', '任务完成'], ['Fail', '任务失败'],
1664
1854
  ['Question', '向你提问'], ['Approval', '待你批准'], ['Blocked', '被拦截']]
1665
1855
  var FOLLOW = '__follow__'
1666
- rows.push(h('div', { key: 'adv-group', style: S.groupLabel }, '按事件覆盖声音来源'))
1856
+ // 内层不再重复标题 —— 二级分组已经叫「按事件定制声音」(v0.4.56)。
1857
+ // 只保留说明,直接接五个事件选择器。
1667
1858
  rows.push(h('div', { key: 'advhint', style: S.subHint },
1668
1859
  '默认跟随上面的「声音来源」。只有你改过的那几行才会单独生效。'))
1669
1860
  KIND_ROWS.forEach(function (pair) {
@@ -1871,7 +2062,9 @@ window.__ModuleLoader__.load({
1871
2062
  return function (e) { var o = {}; o[k] = e.target.value; apply(o, '已更新') }
1872
2063
  })(nKey),
1873
2064
  }, [h('option', { key: '', value: '' }, '跟随全局')].concat(
1874
- kOpts2.map(function (x) { return h('option', { key: x, value: x }, x) }),
2065
+ kOpts2.map(function (x) {
2066
+ return h('option', { key: x, value: x, title: x }, voiceLabel(x))
2067
+ }),
1875
2068
  )),
1876
2069
  mCur === 'random' ? h('span', { style: S.hint }, '随机模式下不生效') : null,
1877
2070
  ))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-notify-say",
3
- "version": "0.4.54",
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
+ });
@@ -0,0 +1,123 @@
1
+ /**
2
+ * 音色友好显示名的一致性测试(v0.4.55)。
3
+ *
4
+ * ## 为什么要这些测试
5
+ *
6
+ * 用户反馈:下拉里显示 `zundamon-normal` 是开发者内部标识,应显示「角色名 · 风格名」。
7
+ *
8
+ * 这个改动本身很小,但它引入了**第二份音色名字**:
9
+ * ① `tools/build-voice-packs.ps1` 的 `$Approved[].Name`(打包用,权威)
10
+ * ② `lib/client.js` 的 `VOICE_LABELS`(显示用)
11
+ *
12
+ * 两份名字一旦漂移,用户会在「语音包列表」看到一个名字、
13
+ * 在「音色下拉」看到另一个名字 —— 同一件事两种叫法。
14
+ * 所以必须用测试把它们钉在一起。
15
+ *
16
+ * ## 关键的"不能改"约束
17
+ *
18
+ * 只改显示。**保存提交的仍是原 key** —— 如果哪天有人把 option 的 value
19
+ * 改成友好名,用户的配置就会写入一个后端不认识的字符串。
20
+ * Q3 专门盯这一条。
21
+ */
22
+ import { test } from 'node:test';
23
+ import assert from 'node:assert/strict';
24
+ import { readFileSync, existsSync } from 'node:fs';
25
+ import { resolve, dirname, join } from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+
28
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
29
+ const CLIENT = readFileSync(join(ROOT, 'lib', 'client.js'), 'utf8');
30
+ const BUILD_PS1 = join(ROOT, 'tools', 'build-voice-packs.ps1');
31
+ const hasBuild = existsSync(BUILD_PS1);
32
+
33
+ /** 解析 client.js 里的 VOICE_KEYS。 */
34
+ function voiceKeys() {
35
+ const m = CLIENT.match(/var VOICE_KEYS = \[([\s\S]*?)\n \]/);
36
+ assert.ok(m, '应能定位 VOICE_KEYS');
37
+ return [...m[1].matchAll(/'([a-z0-9-]+)'/g)].map((x) => x[1]);
38
+ }
39
+
40
+ /** 解析 client.js 里的 VOICE_LABELS。 */
41
+ function voiceLabels() {
42
+ const m = CLIENT.match(/var VOICE_LABELS = \{([\s\S]*?)\n \}/);
43
+ assert.ok(m, '应能定位 VOICE_LABELS');
44
+ const out = {};
45
+ for (const mm of m[1].matchAll(/'([a-z0-9-]+)':\s*'([^']+)'/g)) out[mm[1]] = mm[2];
46
+ return out;
47
+ }
48
+
49
+ /** 解析打包脚本的 $Approved(key → 官方 Name)。 */
50
+ function approvedNames() {
51
+ const src = readFileSync(BUILD_PS1, 'utf8');
52
+ const out = {};
53
+ for (const m of src.matchAll(/'([a-z0-9-]+)'\s*=\s*@\{\s*Name\s*=\s*'([^']+)'/g)) {
54
+ out[m[1]] = m[2];
55
+ }
56
+ return out;
57
+ }
58
+
59
+ /** `ずんだもん(ノーマル)` → `ずんだもん`;`四国めたん` → `四国めたん`。 */
60
+ function characterOf(official) {
61
+ const m = /^(.+?)((.+?))$/.exec(official);
62
+ return m ? m[1] : official;
63
+ }
64
+
65
+ // ── Q1: VOICE_KEYS 每一项都有友好名 ─────────────────────────────────────
66
+ test('Q1: 每个可选音色都有友好显示名', () => {
67
+ const keys = voiceKeys();
68
+ const labels = voiceLabels();
69
+ assert.ok(keys.length >= 8, `音色数量偏少(${keys.length})`);
70
+
71
+ const missing = keys.filter((k) => !labels[k]);
72
+ assert.deepEqual(missing, [],
73
+ '这些音色还显示着英文 key(用户看不懂),应补进 VOICE_LABELS:\n' + missing.join('\n'));
74
+ });
75
+
76
+ // ── Q2: 友好名与打包脚本的官方名同源 ────────────────────────────────────
77
+ test('Q2: 友好名的角色部分与 build-voice-packs.ps1 一致', () => {
78
+ if (!hasBuild) {
79
+ // 发布包里没有维护者工具,跳过(与 D6/D7 同款处理)
80
+ return;
81
+ }
82
+ const labels = voiceLabels();
83
+ const approved = approvedNames();
84
+ assert.ok(Object.keys(approved).length >= 8, '应能解析出打包脚本的音色表');
85
+
86
+ const problems = [];
87
+ for (const [key, official] of Object.entries(approved)) {
88
+ const label = labels[key];
89
+ if (!label) continue; // Q1 管缺失
90
+ const wantChar = characterOf(official);
91
+ const gotChar = label.split(' · ')[0];
92
+ if (gotChar !== wantChar) {
93
+ problems.push(`${key}: 显示「${label}」,但打包脚本的角色名是「${wantChar}」`);
94
+ }
95
+ }
96
+ assert.deepEqual(problems, [],
97
+ '两份音色名字漂移了 —— 用户会在不同位置看到不同叫法:\n' + problems.join('\n'));
98
+ });
99
+
100
+ // ── Q3: 保存的仍是原 key,不是友好名 ───────────────────────────────────
101
+ test('Q3: 音色下拉的 value 仍是原 key(只改显示)', () => {
102
+ // 如果 value 被改成友好名,用户的配置会写入后端不认识的字符串。
103
+ assert.match(CLIENT, /h\('option', \{ key: k, value: k, title: k \}, voiceLabel\(k\)\)/,
104
+ '固定音色的 option 必须 value=原key、显示=友好名');
105
+ assert.match(CLIENT, /h\('option', \{ key: x, value: x, title: x \}, voiceLabel\(x\)\)/,
106
+ '按事件覆盖的音色 option 同理');
107
+ // 原 key 要能在悬停提示里看到(用户排查问题时需要)
108
+ assert.match(CLIENT, /title: k/, '原 key 应放在 title 里(悬停可见)');
109
+ });
110
+
111
+ // ── Q4: option 显示文本不再直接是 key ──────────────────────────────────
112
+ test('Q4: 音色 option 的显示文本走 voiceLabel,不是裸 key', () => {
113
+ // 反面断言:不能再出现 `h('option', { key: k, value: k }, k)` 这种裸 key 显示
114
+ assert.doesNotMatch(CLIENT, /h\('option', \{ key: k, value: k \}, k\)/,
115
+ '固定音色不应再直接显示英文 key');
116
+ assert.doesNotMatch(CLIENT, /h\('option', \{ key: x, value: x \}, x\)/,
117
+ '按事件覆盖的音色不应再直接显示英文 key');
118
+
119
+ // 语音包列表也要用统一风格(括号 → 点)
120
+ assert.match(CLIENT, /prettyPackName\(p\.name\)/,
121
+ '语音包列表的官方名应统一成「角色 · 风格」风格');
122
+ assert.match(CLIENT, /function prettyPackName/, '应有 prettyPackName 工具函数');
123
+ });
@@ -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
+ });