dsh-soul 0.7.0 → 0.7.2
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/LICENSE +21 -0
- package/README.md +32 -19
- package/README_EN.md +36 -19
- package/assets/personas/custom-placeholder.webp +0 -0
- package/assets/personas/placeholder.svg +16 -0
- package/assets/personas//344/270/245/346/240/274/344/273/243/347/240/201/345/256/241/351/230/205/350/200/205.webp +0 -0
- package/assets/personas//345/244/264/350/204/221/351/243/216/346/232/264/344/274/231/344/274/264.webp +0 -0
- package/assets/personas//346/225/231/345/255/246/345/236/213/350/256/262/350/247/243/350/200/205.webp +0 -0
- package/assets/personas//346/236/201/347/256/200/344/270/273/344/271/211/350/200/205.webp +0 -0
- package/assets/personas//350/207/252/351/251/261/345/236/213/345/215/217/344/275/234/350/200/205.webp +0 -0
- package/assets/personas//350/213/217/346/240/274/346/213/211/345/272/225/345/274/217/346/217/220/351/227/256/350/200/205.webp +0 -0
- package/assets/personas//350/265/204/346/267/261/346/236/266/346/236/204/345/270/210.webp +0 -0
- package/client/index.mjs +1169 -214
- package/index.mjs +732 -85
- package/lib/config.mjs +42 -6
- package/lib/injection.mjs +2 -2
- package/lib/personas.mjs +62 -25
- package/lib/store.mjs +125 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +13 -2
- package/scripts/lib/skip-report.mjs +73 -0
- package/scripts/verify-client.mjs +1319 -0
- package/scripts/verify-compat.mjs +58 -9
- package/scripts/verify-config.mjs +2116 -44
- package/scripts/verify-e2e-prompt.mjs +627 -0
- package/scripts/verify-live-prompt.mjs +445 -0
- package/scripts/verify-nav-icon.mjs +35 -7
- package/scripts/verify-store.mjs +310 -0
- package/scripts/verify-trail.mjs +21 -8
|
@@ -8,14 +8,19 @@
|
|
|
8
8
|
// 零依赖,直接 `node scripts/verify-config.mjs` 运行。
|
|
9
9
|
|
|
10
10
|
import assert from 'node:assert/strict'
|
|
11
|
-
import { readFileSync, realpathSync, statSync } from 'node:fs'
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
11
|
+
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'
|
|
12
|
+
import { createRequire } from 'node:module'
|
|
13
|
+
import { homedir } from 'node:os'
|
|
14
|
+
import { dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
15
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
14
16
|
import {
|
|
15
17
|
DEFAULT_CONFIG,
|
|
16
18
|
FIELD_LIMITS,
|
|
19
|
+
LANGUAGE_VALUES,
|
|
17
20
|
PERSONA_FIELDS,
|
|
18
21
|
PERSONA_NAME_MAX,
|
|
22
|
+
PROFILE_FIELDS,
|
|
23
|
+
PROMPT_INPUT_FIELDS,
|
|
19
24
|
REPLY_LENGTH_VALUES,
|
|
20
25
|
STYLE_VALUES,
|
|
21
26
|
TRAIT_VALUES,
|
|
@@ -33,12 +38,144 @@ import {
|
|
|
33
38
|
personaMatches,
|
|
34
39
|
resolvePersona
|
|
35
40
|
} from '../lib/personas.mjs'
|
|
41
|
+
import { assertCount, skipNote } from './lib/skip-report.mjs'
|
|
42
|
+
|
|
43
|
+
// 基线运行应跑出的断言数。跑完时校验,防止「脚本加/删用例」与文档口径悄悄脱节。
|
|
44
|
+
//
|
|
45
|
+
// 注意:本脚本没有环境相关的跳过分支(只读仓库内的文件与纯函数,不依赖浏览器 / DSH /
|
|
46
|
+
// 平台能力),所以链里的 `--strict` 对它是空操作 —— 这是**事实描述**,不是缺陷。
|
|
47
|
+
// 它的数字由这里的 assertCount 守住;需要 --strict 的是那些有跳过能力的脚本。
|
|
48
|
+
const EXPECTED_ASSERTIONS = 115
|
|
36
49
|
|
|
37
50
|
let passed = 0
|
|
51
|
+
const failures = []
|
|
38
52
|
function check(name, fn) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
53
|
+
try {
|
|
54
|
+
fn()
|
|
55
|
+
passed++
|
|
56
|
+
console.log(` ✓ ${name}`)
|
|
57
|
+
} catch (err) {
|
|
58
|
+
failures.push({ name, err })
|
|
59
|
+
console.log(` ✗ ${name}`)
|
|
60
|
+
console.log(` ${String(err && err.message || err).split('\n')[0]}`)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 按括号配平取出从 anchor 起的那一个对象字面量。
|
|
65
|
+
// 扫描时会跳过字符串与行注释 —— 文案里出现的 `{n}` 占位符、行尾的 `// {` 之类
|
|
66
|
+
// 都不该影响配平(否则「取出来的对象」会一路吃到文件末尾)。
|
|
67
|
+
function extractObject(source, anchor) {
|
|
68
|
+
const at = source.indexOf(anchor)
|
|
69
|
+
if (at === -1) return ''
|
|
70
|
+
let i = source.indexOf('{', at)
|
|
71
|
+
if (i === -1) return ''
|
|
72
|
+
const from = i
|
|
73
|
+
let depth = 0
|
|
74
|
+
let quote = null
|
|
75
|
+
for (; i < source.length; i++) {
|
|
76
|
+
const c = source[i]
|
|
77
|
+
if (quote) {
|
|
78
|
+
if (c === '\\') { i++; continue }
|
|
79
|
+
if (c === quote) quote = null
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
if (c === "'" || c === '"' || c === '`') { quote = c; continue }
|
|
83
|
+
if (c === '/' && source[i + 1] === '/') {
|
|
84
|
+
const nl = source.indexOf('\n', i)
|
|
85
|
+
i = nl === -1 ? source.length : nl
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
if (c === '{') depth++
|
|
89
|
+
else if (c === '}') { depth--; if (depth === 0) return source.slice(from, i + 1) }
|
|
90
|
+
}
|
|
91
|
+
return source.slice(from)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 键必须是「行首(允许缩进)的 key:」—— 只按 `\w+:` 扫会把文案里的冒号误判成键
|
|
95
|
+
// (例如 'Use /soul: show' 里的 soul)。
|
|
96
|
+
function objectKeys(text) {
|
|
97
|
+
const keys = new Set()
|
|
98
|
+
for (const m of text.matchAll(/^[ \t]*(?:'([^']+)'|"([^"]+)"|([A-Za-z_$][\w$]*))\s*:/gm)) {
|
|
99
|
+
keys.add(m[1] ?? m[2] ?? m[3])
|
|
100
|
+
}
|
|
101
|
+
return keys
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 剥离行注释 / 块注释(字符串内容保留——它们是真实代码的一部分)。
|
|
105
|
+
// **长度保持不变**:被剥掉的字符以空格占位。这样剥离结果与原串共享同一套下标,
|
|
106
|
+
// extractCall 才能先在剥离文本上定位锚点、再回原串切片。
|
|
107
|
+
// 所有「对源码做正则断言」的地方都应先过这一步:否则把真实调用注释掉、在注释里留下
|
|
108
|
+
// 同样的字样,断言就被 satisfies(M5 类漏洞,变异实验实测可绕过)。
|
|
109
|
+
function stripComments(source) {
|
|
110
|
+
const out = source.split('')
|
|
111
|
+
let i = 0
|
|
112
|
+
let quote = null
|
|
113
|
+
const blank = (from, to) => { for (let k = from; k < to && k < out.length; k++) if (out[k] !== '\n') out[k] = ' ' }
|
|
114
|
+
while (i < source.length) {
|
|
115
|
+
const c = source[i]
|
|
116
|
+
if (quote) {
|
|
117
|
+
if (c === '\\') { i += 2; continue }
|
|
118
|
+
if (c === quote) quote = null
|
|
119
|
+
i++
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
if (c === "'" || c === '"' || c === '`') { quote = c; i++; continue }
|
|
123
|
+
if (c === '/' && source[i + 1] === '/') {
|
|
124
|
+
const nl = source.indexOf('\n', i)
|
|
125
|
+
blank(i, nl === -1 ? source.length : nl)
|
|
126
|
+
i = nl === -1 ? source.length : nl
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
if (c === '/' && source[i + 1] === '*') {
|
|
130
|
+
const end = source.indexOf('*/', i + 2)
|
|
131
|
+
const to = end === -1 ? source.length : end + 2
|
|
132
|
+
blank(i, to)
|
|
133
|
+
i = to
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
i++
|
|
137
|
+
}
|
|
138
|
+
return out.join('')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 从 anchor 起按括号配平取出**完整调用**文本。定位与配平都在**去注释后**的文本上进行
|
|
142
|
+
// (注释里的 anchor 与不等宽括号都不该影响判定),再回**原串**切片 —— stripComments
|
|
143
|
+
// 刻意保持长度不变,两套文本共享同一套下标。
|
|
144
|
+
// anchor 可带可不带 `(`:`skipExit(` 与 `e('span', { ... }`(调用括号在 anchor 内)都支持,
|
|
145
|
+
// 一律取「anchor 起点之后的第一个 `(`」作为配平起点。
|
|
146
|
+
// 之所以不用缩进/固定窗口猜终点:后者会随代码重排而捕获越界或假失败(徽标断言曾用
|
|
147
|
+
// `\s{22}` 定界,实测把摘要列、操作区全部吞进捕获,断言失去判断力)。
|
|
148
|
+
function extractCall(source, anchor) {
|
|
149
|
+
const stripped = stripComments(source)
|
|
150
|
+
const at = stripped.indexOf(anchor)
|
|
151
|
+
if (at === -1) return ''
|
|
152
|
+
let open = -1
|
|
153
|
+
for (let i = at; i < stripped.length; i++) {
|
|
154
|
+
if (stripped[i] === '(') { open = i; break }
|
|
155
|
+
if (stripped[i] === '\n' && i > at + anchor.length) return ''
|
|
156
|
+
}
|
|
157
|
+
if (open === -1) return ''
|
|
158
|
+
let depth = 0
|
|
159
|
+
let quote = null
|
|
160
|
+
for (let i = open; i < stripped.length; i++) {
|
|
161
|
+
const c = stripped[i]
|
|
162
|
+
if (quote) {
|
|
163
|
+
if (c === '\\') { i++; continue }
|
|
164
|
+
if (c === quote) quote = null
|
|
165
|
+
continue
|
|
166
|
+
}
|
|
167
|
+
if (c === "'" || c === '"' || c === '`') { quote = c; continue }
|
|
168
|
+
if (c === '(') depth++
|
|
169
|
+
else if (c === ')') { depth--; if (depth === 0) return source.slice(at, i + 1) }
|
|
170
|
+
}
|
|
171
|
+
return ''
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// 取某个具名文案表内 zh / en 各自的键集(表名 → 在表内定位 `zh: {` / `en: {`)。
|
|
175
|
+
function tableLangKeys(source, tableName, lang) {
|
|
176
|
+
const at = source.indexOf(`const ${tableName} = {`)
|
|
177
|
+
if (at === -1) return new Set()
|
|
178
|
+
return objectKeys(extractObject(source.slice(at), lang === 'zh' ? 'zh: {' : 'en: {'))
|
|
42
179
|
}
|
|
43
180
|
|
|
44
181
|
console.log('migrateConfig')
|
|
@@ -199,12 +336,16 @@ check('migrateConfig:personas 归一化透传,缺省时保持缺席;确认
|
|
|
199
336
|
console.log('特质:表格 / 回复长度')
|
|
200
337
|
|
|
201
338
|
const indexSource = readFileSync(new URL('../index.mjs', import.meta.url), 'utf8')
|
|
339
|
+
// 断言一律对**去注释后**的源码做匹配:注释里写一句 // const VERSION = '0.0.0' 或
|
|
340
|
+
// // await writeConfigFile(...) 就能骗过裸正则(M5 类,变异实验实测可绕过)。
|
|
341
|
+
// stripComments 长度保持不变,因此位置与字符串内容都不受影响。
|
|
342
|
+
const indexCode = stripComments(indexSource)
|
|
202
343
|
|
|
203
344
|
// 从 index.mjs 的 PROMPT_TEXT 中按缩进切片取出某个文案子表(zh / en 各一处)。
|
|
204
345
|
// 用「块数量必须为 2」做结构断言:文案表若被重构或改名,这里会直接失败而不是静默放过。
|
|
205
346
|
function promptTextBlocks(key) {
|
|
206
347
|
const re = new RegExp(`\\n ${key}: \\{\\n([\\s\\S]*?)\\n \\\},`, 'g')
|
|
207
|
-
return [...
|
|
348
|
+
return [...indexCode.matchAll(re)].map((m) => m[1])
|
|
208
349
|
}
|
|
209
350
|
|
|
210
351
|
check('DEFAULT_CONFIG 含表格与回复长度的默认值', () => {
|
|
@@ -364,6 +505,96 @@ check('清单:icon 落在 files 白名单内(会随 npm 包一起发布)',
|
|
|
364
505
|
assert.ok(manifest.files.includes(topLevel), `files 白名单应包含 ${topLevel},否则图标不会进发布包`)
|
|
365
506
|
})
|
|
366
507
|
|
|
508
|
+
// 插件列表里显示的**名称**不等于包名:DSH 的 readPluginMeta 先读
|
|
509
|
+
// `<包>/locale/<lang>.json` 的 `meta.title` / `meta.description`,读不到才回退到
|
|
510
|
+
// package.json 的 name / description(见 dsh-app-boot 的 readPluginMeta / dictionariesOf)。
|
|
511
|
+
// 三件事任一漏掉都会**静默回退成包名**(不报错、只是显示成 dsh-soul):
|
|
512
|
+
// ① locale 目录没进 files 白名单 → 发布包里没有它
|
|
513
|
+
// ② exports 没声明 "./locale/*.json" → 子路径解析被 exports 挡住
|
|
514
|
+
// ③ JSON 结构不是 { meta: { title } } → dictionariesOf 读 meta?.title 取不到
|
|
515
|
+
check('清单:插件展示名走 locale/*.json(否则静默回退成包名)', () => {
|
|
516
|
+
const localeDir = resolve(packageRoot, 'locale')
|
|
517
|
+
assert.equal(existsSync(localeDir), true, '缺少 locale/ 目录:插件列表会显示包名 dsh-soul 而不是自定义名称')
|
|
518
|
+
const localeFiles = readdirSync(localeDir).filter((f) => f.endsWith('.json'))
|
|
519
|
+
assert.ok(localeFiles.length > 0, 'locale/ 下没有任何 .json')
|
|
520
|
+
|
|
521
|
+
// ① files 白名单必须带上 locale
|
|
522
|
+
assert.ok(
|
|
523
|
+
manifest.files.includes('locale'),
|
|
524
|
+
'files 白名单应包含 locale,否则发布包里没有它、展示名静默回退成包名'
|
|
525
|
+
)
|
|
526
|
+
// ② exports 必须放行子路径(本包有 exports 字段,未声明的子路径会被挡住)
|
|
527
|
+
const exportKeys = Object.keys(manifest.exports || {})
|
|
528
|
+
assert.ok(
|
|
529
|
+
exportKeys.includes('./locale/*.json'),
|
|
530
|
+
'exports 必须声明 "./locale/*.json",否则 resolvePluginResource 解析不到该子路径'
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
// ③ 结构必须是 { meta: { title, description } },且英文必须有(LocalizedText 要求 en 回退)
|
|
534
|
+
for (const file of localeFiles) {
|
|
535
|
+
const raw = JSON.parse(readFileSync(resolve(localeDir, file), 'utf8'))
|
|
536
|
+
const lang = file.slice(0, -5)
|
|
537
|
+
assert.equal(/^[a-z][a-z\d-]*$/i.test(lang), true, `${file} 的文件名必须是语言 id`)
|
|
538
|
+
assert.ok(raw.meta && typeof raw.meta === 'object', `${file} 必须把字段包在 meta 里(dictionariesOf 读 meta?.title)`)
|
|
539
|
+
assert.equal(typeof raw.meta.title, 'string', `${file}: meta.title 必须是字符串`)
|
|
540
|
+
assert.ok(raw.meta.title.trim().length > 0, `${file}: meta.title 不能为空`)
|
|
541
|
+
}
|
|
542
|
+
const en = JSON.parse(readFileSync(resolve(localeDir, 'en.json'), 'utf8'))
|
|
543
|
+
assert.ok(en.meta.title, '必须有 locale/en.json(宿主以它作为英文回退与目录锚点)')
|
|
544
|
+
assert.notEqual(
|
|
545
|
+
en.meta.title,
|
|
546
|
+
manifest.name,
|
|
547
|
+
'英文展示名不应等于包名:那样与「回退成包名」无法区分,等于这条契约没生效'
|
|
548
|
+
)
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
check('清单:展示名能被宿主真实解析(readPluginMeta 行为层)', () => {
|
|
552
|
+
// 上游所有结构检查都只是「形状对」;真正要紧的是宿主解析出来的 title 不是包名回退值。
|
|
553
|
+
// 本机能定位到 dsh-app-boot 时直接调它的 readPluginMeta(该函数是导出的),
|
|
554
|
+
// 拿不到就 skipNote —— 与其它环境相关断言同一口径。
|
|
555
|
+
// check() 回调是同步的,所以这里用 createRequire 加载(dsh-app-boot 是 CJS),
|
|
556
|
+
// 不用动态 import —— 否则会踩到「同步回调里出现 await」的语法错误。
|
|
557
|
+
//
|
|
558
|
+
// 解析不能只从包目录出发:dsh-app-boot 是 DSH 的嵌套依赖,不在本包 node_modules 里,
|
|
559
|
+
// 单点解析必然 MODULE_NOT_FOUND(与调用真实 dsh-tools 那条断言同一坑)。这里按
|
|
560
|
+
// verify-compat 的 anchors 思路显式发现:包目录 → cwd → DSH profiles 下各 profile。
|
|
561
|
+
let readPluginMeta = null
|
|
562
|
+
try {
|
|
563
|
+
const anchors = [packageRoot, process.cwd()]
|
|
564
|
+
const profiles = join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'profiles')
|
|
565
|
+
if (existsSync(profiles)) {
|
|
566
|
+
for (const p of readdirSync(profiles)) anchors.push(join(profiles, p))
|
|
567
|
+
}
|
|
568
|
+
for (const anchor of anchors) {
|
|
569
|
+
try {
|
|
570
|
+
const boot = createRequire(join(anchor, 'noop.js'))('@deepseek-ai/dsh-app-boot')
|
|
571
|
+
if (typeof boot?.readPluginMeta === 'function') { readPluginMeta = boot.readPluginMeta; break }
|
|
572
|
+
} catch { /* 换下一个 anchor */ }
|
|
573
|
+
}
|
|
574
|
+
} catch { /* 本机未装 DSH:交由 skipNote 报告 */ }
|
|
575
|
+
if (typeof readPluginMeta !== 'function') {
|
|
576
|
+
if (!skipNote('verify-config', 1, '本机未解析到 dsh-app-boot,展示名只做结构层校验(行为层需真实宿主)', '安装 DSH 后重跑即可覆盖行为层')) {
|
|
577
|
+
failures.push({ name: '展示名行为层(需真实 dsh-app-boot)', err: new Error('--strict 下跳过按失败处理') })
|
|
578
|
+
}
|
|
579
|
+
return
|
|
580
|
+
}
|
|
581
|
+
const meta = readPluginMeta(manifest.name, pathToFileURL(join(packageRoot, 'noop.js')).href)
|
|
582
|
+
assert.ok(meta, '宿主 readPluginMeta 返回 undefined:展示元数据完全没被读到')
|
|
583
|
+
assert.equal(meta.error, undefined, `宿主解析展示元数据时报错:${meta.error}`)
|
|
584
|
+
const title = meta.title
|
|
585
|
+
assert.ok(title, '宿主未解析出 title(会显示包名回退值)')
|
|
586
|
+
const resolved = typeof title === 'string' ? { en: title } : title
|
|
587
|
+
assert.notEqual(resolved.en, manifest.name, '宿主解析出的英文 title 仍是包名:locale 未被读取')
|
|
588
|
+
const en = JSON.parse(readFileSync(resolve(packageRoot, 'locale/en.json'), 'utf8'))
|
|
589
|
+
assert.equal(resolved.en, en.meta.title, '宿主解析出的 title 与 locale/en.json 不一致')
|
|
590
|
+
// 中文也应可用(dictionariesOf 会扫描 locale/ 下的所有 .json)
|
|
591
|
+
const zhPath = resolve(packageRoot, 'locale/zh.json')
|
|
592
|
+
if (existsSync(zhPath)) {
|
|
593
|
+
const zh = JSON.parse(readFileSync(zhPath, 'utf8'))
|
|
594
|
+
assert.equal(resolved.zh, zh.meta.title, '宿主解析出的中文 title 与 locale/zh.json 不一致')
|
|
595
|
+
}
|
|
596
|
+
})
|
|
597
|
+
|
|
367
598
|
const iconSvg = iconRef ? readFileSync(iconPath, 'utf8') : ''
|
|
368
599
|
|
|
369
600
|
check('图标:SVG 自带命名空间,且 viewBox 为正方形', () => {
|
|
@@ -408,25 +639,101 @@ check('内置人设非空,名称合法且不超上限', () => {
|
|
|
408
639
|
}
|
|
409
640
|
})
|
|
410
641
|
|
|
411
|
-
check('
|
|
642
|
+
check('内置人设(含默认)只声明 PERSONA_FIELDS 内的键', () => {
|
|
643
|
+
// 「默认」也是内置项(声明 6 维,language 豁免),用 mergePersonas 统一取视图,
|
|
644
|
+
// 避免直接索引 BUILTIN_PERSONAS(默认不在其中 ⇒ undefined 崩)。
|
|
645
|
+
const view = mergePersonas(null)
|
|
412
646
|
for (const name of builtinPersonaNames()) {
|
|
413
|
-
for (const key of Object.keys(
|
|
647
|
+
for (const key of Object.keys(view[name])) {
|
|
648
|
+
if (key === 'builtin') continue
|
|
414
649
|
assert.ok(PERSONA_FIELDS.includes(key), `${name} 含非人设字段:${key}`)
|
|
415
650
|
}
|
|
416
651
|
}
|
|
417
652
|
})
|
|
418
653
|
|
|
419
|
-
check('
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
// enabled
|
|
654
|
+
check('内置人设不声明「关于你」、输出语言与总开关', () => {
|
|
655
|
+
// 「关于你」的三个字段根本不在 PERSONA_FIELDS 里(见下面「预设范围」一组),这里
|
|
656
|
+
// 另外钉住内置数据本身也不写;language 在预设范围内,但内置是给任何语言用户共用的
|
|
657
|
+
// 通用人格,写死会让另一种语言的用户应用一次就被强行切回;enabled 是总开关,
|
|
658
|
+
// 预设不该能替你关掉个性化。
|
|
659
|
+
const view = mergePersonas(null)
|
|
423
660
|
for (const name of builtinPersonaNames()) {
|
|
424
|
-
for (const key of [
|
|
425
|
-
assert.equal(key in
|
|
661
|
+
for (const key of [...PROFILE_FIELDS, 'language', 'enabled']) {
|
|
662
|
+
assert.equal(key in view[name], false, `${name} 不应声明 ${key}`)
|
|
426
663
|
}
|
|
427
664
|
}
|
|
428
665
|
})
|
|
429
666
|
|
|
667
|
+
// ==================== 预设范围:「关于你」不属于预设 ====================
|
|
668
|
+
|
|
669
|
+
check('预设范围只含人设,与「关于你」无交集', () => {
|
|
670
|
+
// 总纲:三个身份字段不在 PERSONA_FIELDS 里,于是保存(快照)/ 应用(取键)/
|
|
671
|
+
// 匹配(判据)/ 磁盘归一化四条路径都会自然地把它们排除在外 —— 不止内置预设,
|
|
672
|
+
// 用户自建预设也一样。
|
|
673
|
+
assert.deepEqual(PROFILE_FIELDS, ['nickname', 'occupation', 'bio'])
|
|
674
|
+
for (const key of PROFILE_FIELDS) {
|
|
675
|
+
assert.equal(PERSONA_FIELDS.includes(key), false, `「关于你」的 ${key} 不应出现在 PERSONA_FIELDS`)
|
|
676
|
+
}
|
|
677
|
+
// 反向防空过:人设维度必须还在,否则「无交集」会因为 PERSONA_FIELDS 被误清空而假通过
|
|
678
|
+
assert.ok(PERSONA_FIELDS.includes('style'), 'style 应在预设范围内')
|
|
679
|
+
assert.ok(PERSONA_FIELDS.includes('customInstructions'), 'customInstructions 应在预设范围内')
|
|
680
|
+
// 输出语言属于人设(决定 Agent 用什么语言作答):自建预设会保存并在应用时还原它
|
|
681
|
+
assert.ok(PERSONA_FIELDS.includes('language'), 'language 应在预设范围内')
|
|
682
|
+
assert.equal(PERSONA_FIELDS.includes('enabled'), false, 'enabled 是全局开关,不进预设')
|
|
683
|
+
assert.deepEqual(
|
|
684
|
+
[...PROMPT_INPUT_FIELDS].sort(),
|
|
685
|
+
[...new Set(['enabled', ...PROFILE_FIELDS, ...PERSONA_FIELDS])].sort(),
|
|
686
|
+
'编译读取范围应恰好等于「总开关 + 关于你 + 人设」'
|
|
687
|
+
)
|
|
688
|
+
})
|
|
689
|
+
|
|
690
|
+
check('用户自建预设里残留的「关于你」一律不算数(匹配与取值都忽略)', () => {
|
|
691
|
+
// 历史数据或手改文件都可能残留这些键:既不该影响 ★ 匹配,也不该被应用。
|
|
692
|
+
const legacy = { nickname: '老昵称', occupation: '老职业', bio: '老简介', style: 'roast', updatedAt: 'x' }
|
|
693
|
+
assert.deepEqual(declaredPersonaKeys(legacy), ['style'], '「关于你」不应算作已声明字段')
|
|
694
|
+
assert.equal(
|
|
695
|
+
personaMatches(legacy, { ...DEFAULT_CONFIG, style: 'roast', nickname: '换了个人' }),
|
|
696
|
+
true,
|
|
697
|
+
'残留的「关于你」不应影响匹配'
|
|
698
|
+
)
|
|
699
|
+
})
|
|
700
|
+
|
|
701
|
+
check('normalizePersonas 剥离历史预设里的「关于你」,保留人设字段与元数据', () => {
|
|
702
|
+
const out = normalizePersonas({
|
|
703
|
+
历史预设: {
|
|
704
|
+
nickname: '老昵称',
|
|
705
|
+
occupation: '老职业',
|
|
706
|
+
bio: '老简介',
|
|
707
|
+
style: 'roast',
|
|
708
|
+
headingLists: 'less',
|
|
709
|
+
updatedAt: 'T'
|
|
710
|
+
},
|
|
711
|
+
脏条目: 'not-an-object'
|
|
712
|
+
})
|
|
713
|
+
assert.deepEqual(Object.keys(out), ['历史预设'], '非对象条目应被剔除')
|
|
714
|
+
assert.deepEqual(Object.keys(out['历史预设']).sort(), ['headingLists', 'style', 'updatedAt'])
|
|
715
|
+
assert.equal('nickname' in out['历史预设'], false, '历史遗留的昵称应被剥离')
|
|
716
|
+
// 走真实读取路径(migrateConfig → normalizePersonas)同样生效
|
|
717
|
+
const migrated = migrateConfig({ personas: { 历史预设: { nickname: '老昵称', style: 'roast' } } })
|
|
718
|
+
assert.deepEqual(migrated.personas['历史预设'], { style: 'roast' })
|
|
719
|
+
})
|
|
720
|
+
|
|
721
|
+
check('预设的保存与应用都只遍历 PERSONA_FIELDS 这一份白名单', () => {
|
|
722
|
+
// 宿主侧唯一的两处取键:数据源必须是常量而不是各自硬编码一份字段表,
|
|
723
|
+
// 否则「预设范围」的增删会出现两套互相漂移的语义。
|
|
724
|
+
const snapshot = indexCode.match(/function personaSnapshotOf\(config\) \{([\s\S]*?)\n\}/)
|
|
725
|
+
assert.ok(snapshot, '缺少 personaSnapshotOf')
|
|
726
|
+
assert.ok(/for \(const key of PERSONA_FIELDS\)/.test(snapshot[1]), '快照必须按 PERSONA_FIELDS 取键')
|
|
727
|
+
const pick = indexCode.match(/function pickPersonaValues\(persona\) \{([\s\S]*?)\n\}/)
|
|
728
|
+
assert.ok(pick, '缺少 pickPersonaValues')
|
|
729
|
+
assert.ok(/for \(const key of PERSONA_FIELDS\)/.test(pick[1]), '应用必须按 PERSONA_FIELDS 取键')
|
|
730
|
+
// 反向:两处都不得出现硬编码的身份字段(否则会绕过白名单)
|
|
731
|
+
for (const key of PROFILE_FIELDS) {
|
|
732
|
+
assert.equal(snapshot[1].includes(key), false, `快照不应涉及 ${key}`)
|
|
733
|
+
assert.equal(pick[1].includes(key), false, `应用不应涉及 ${key}`)
|
|
734
|
+
}
|
|
735
|
+
})
|
|
736
|
+
|
|
430
737
|
check('内置人设的枚举值与文本长度合法,且都有自定义指令', () => {
|
|
431
738
|
const enums = {
|
|
432
739
|
style: STYLE_VALUES,
|
|
@@ -435,16 +742,22 @@ check('内置人设的枚举值与文本长度合法,且都有自定义指令'
|
|
|
435
742
|
tables: TRAIT_VALUES,
|
|
436
743
|
replyLength: REPLY_LENGTH_VALUES
|
|
437
744
|
}
|
|
745
|
+
const view = mergePersonas(null)
|
|
438
746
|
for (const name of builtinPersonaNames()) {
|
|
439
|
-
const entry =
|
|
747
|
+
const entry = view[name]
|
|
440
748
|
for (const [field, allowed] of Object.entries(enums)) {
|
|
441
749
|
if (!(field in entry)) continue
|
|
442
750
|
assert.ok(allowed.includes(entry[field]), `${name}.${field} 非法取值:${entry[field]}`)
|
|
443
751
|
}
|
|
444
752
|
// customInstructions 是人格差异的主要载体:枚举字段只能表达「更啰嗦 / 更简洁」
|
|
445
753
|
// 这类粗粒度倾向,所以内置预设必须有它。
|
|
446
|
-
|
|
447
|
-
|
|
754
|
+
// 「默认」是唯一例外 —— 出厂状态没有附加指令,空串即其本义。
|
|
755
|
+
if (name !== '默认') {
|
|
756
|
+
assert.equal(typeof entry.customInstructions, 'string', `${name} 缺少 customInstructions`)
|
|
757
|
+
assert.ok(entry.customInstructions.length > 0, `${name} 的 customInstructions 为空`)
|
|
758
|
+
} else {
|
|
759
|
+
assert.equal(entry.customInstructions, '', '默认人设的 customInstructions 应为空串')
|
|
760
|
+
}
|
|
448
761
|
assert.ok(
|
|
449
762
|
entry.customInstructions.length <= FIELD_LIMITS.customInstructions,
|
|
450
763
|
`${name} 的 customInstructions 超长(${entry.customInstructions.length} > ${FIELD_LIMITS.customInstructions})`
|
|
@@ -479,9 +792,11 @@ check('isBuiltinPersona / resolvePersona 的判定与优先级', () => {
|
|
|
479
792
|
assert.equal(isBuiltinPersona('不存在的名字'), false)
|
|
480
793
|
assert.equal(isBuiltinPersona(123), false)
|
|
481
794
|
assert.equal(isBuiltinPersona(null), false)
|
|
482
|
-
|
|
483
|
-
//
|
|
484
|
-
|
|
795
|
+
// names[0] 是「默认」(不在 BUILTIN_PERSONAS 字面量里,经 personaEntry 解析)。
|
|
796
|
+
// resolvePersona 的历史语义是【纯净条目】(不含 builtin 标记),所以这里用
|
|
797
|
+
// resolve 自身对称对比:同名用户条目必须被内置内容遮蔽。
|
|
798
|
+
const direct = resolvePersona(names[0], {})
|
|
799
|
+
assert.deepEqual(resolvePersona(names[0], { [names[0]]: { style: 'roast' } }), direct)
|
|
485
800
|
assert.deepEqual(resolvePersona('我的预设', { '我的预设': { style: 'casual' } }), { style: 'casual' })
|
|
486
801
|
assert.equal(resolvePersona('不存在', { '我的预设': { style: 'casual' } }), null)
|
|
487
802
|
assert.equal(resolvePersona(undefined, {}), null)
|
|
@@ -524,9 +839,37 @@ check('未声明任何字段的条目不匹配(避免匹配一切)', () => {
|
|
|
524
839
|
console.log('\n前后端契约一致性')
|
|
525
840
|
|
|
526
841
|
const clientSource = readFileSync(new URL('../client/index.mjs', import.meta.url), 'utf8')
|
|
842
|
+
const clientCode = stripComments(clientSource)
|
|
843
|
+
|
|
844
|
+
// 真实 @deepseek-ai/dsh-tools(若本机可解析):用于行为层校验 set_persona 的 schema。
|
|
845
|
+
// ESM 顶层 await;解析不到(CI / 未装 DSH)时为 null,行为层以 skipNote 报告。
|
|
846
|
+
//
|
|
847
|
+
// 解析不能用 createRequire(插件 package.json) 单点:dsh-tools 是 **peer 依赖**,不在
|
|
848
|
+
// 插件 node_modules 里,从插件目录解析注定 MODULE_NOT_FOUND(首次实现即踩此坑:
|
|
849
|
+
// skipNote 提示「未安装」却依然显示 ✓,靠 --strict 才暴露)。这里按 verify-compat 的
|
|
850
|
+
// anchors 思路显式发现:插件目录 → cwd → DSH profiles 下各 profile → 逐级向上。
|
|
851
|
+
let defineTool = null
|
|
852
|
+
try {
|
|
853
|
+
const here = fileURLToPath(new URL('..', import.meta.url))
|
|
854
|
+
const anchors = [here, process.cwd()]
|
|
855
|
+
const profiles = join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'profiles')
|
|
856
|
+
if (existsSync(profiles)) {
|
|
857
|
+
for (const p of readdirSync(profiles)) anchors.push(join(profiles, p))
|
|
858
|
+
}
|
|
859
|
+
let resolved = null
|
|
860
|
+
for (const anchor of anchors) {
|
|
861
|
+
try {
|
|
862
|
+
resolved = createRequire(join(anchor, 'noop.js')).resolve('@deepseek-ai/dsh-tools')
|
|
863
|
+
break
|
|
864
|
+
} catch { /* 换下一个 anchor */ }
|
|
865
|
+
}
|
|
866
|
+
if (!resolved) throw new Error('not found')
|
|
867
|
+
;({ defineTool } = await import(pathToFileURL(resolved).href))
|
|
868
|
+
} catch { /* 未装 DSH:行为层跳过(skipNote 在用到它的 check 里报告) */ }
|
|
869
|
+
if (defineTool) console.log(' · 已定位真实 @deepseek-ai/dsh-tools,set_persona 将做行为层校验')
|
|
527
870
|
|
|
528
871
|
check('客户端摘要的默认值表与宿主 DEFAULT_CONFIG 逐项一致', () => {
|
|
529
|
-
const block =
|
|
872
|
+
const block = clientCode.match(/const META_FIELD_DEFAULTS = \{([\s\S]*?)\}/)
|
|
530
873
|
assert.ok(block, '客户端缺少 META_FIELD_DEFAULTS')
|
|
531
874
|
const pairs = [...block[1].matchAll(/(\w+):\s*'([^']*)'/g)].map((m) => [m[1], m[2]])
|
|
532
875
|
assert.ok(pairs.length >= 5, `默认值表条目过少:${pairs.length}`)
|
|
@@ -539,8 +882,31 @@ check('客户端摘要的默认值表与宿主 DEFAULT_CONFIG 逐项一致', ()
|
|
|
539
882
|
}
|
|
540
883
|
})
|
|
541
884
|
|
|
885
|
+
check('客户端轮询在页面不可见时暂停(不只在「变可见」时刷新)', () => {
|
|
886
|
+
// 此前 `visibilitychange` 只处理了「变可见」这一半(`if (visible) refresh()`),
|
|
887
|
+
// 而 `setInterval(refresh, 2000)` 无条件执行 ⇒ 后台标签页每 2s 仍发两个请求。
|
|
888
|
+
// 修法是把守卫放进 refresh 本身(interval 与 focus 都走它,一处覆盖两条路径)。
|
|
889
|
+
const effect = clientCode.match(/const refresh = \(\) => \{([\s\S]*?)\n\s{8}\}/)
|
|
890
|
+
assert.ok(effect, '找不到设置页的 refresh 函数')
|
|
891
|
+
const body = effect[1]
|
|
892
|
+
assert.ok(
|
|
893
|
+
/document\.visibilityState !== 'visible'/.test(body),
|
|
894
|
+
'refresh 必须自己检查页面可见性,否则 setInterval 在后台标签页仍会持续请求'
|
|
895
|
+
)
|
|
896
|
+
// 守卫必须在发请求之前
|
|
897
|
+
const guardAt = body.indexOf("document.visibilityState !== 'visible'")
|
|
898
|
+
const firstRequest = body.indexOf('controller.loadConfig()')
|
|
899
|
+
assert.ok(firstRequest > 0, 'refresh 应调用 controller.loadConfig()')
|
|
900
|
+
assert.ok(
|
|
901
|
+
guardAt < firstRequest,
|
|
902
|
+
'可见性守卫必须排在发起请求之前,否则等于没拦'
|
|
903
|
+
)
|
|
904
|
+
// 防回归:定时器仍应存在(别把轮询整个删掉当成"修好")
|
|
905
|
+
assert.ok(/setInterval\(refresh, 2000\)/.test(clientCode), '设置页应保留 2s 轮询(外部写入路径靠它补齐)')
|
|
906
|
+
})
|
|
907
|
+
|
|
542
908
|
check('客户端摘要覆盖的字段集合固定', () => {
|
|
543
|
-
const block =
|
|
909
|
+
const block = clientCode.match(/const META_FIELD_VALUES = \{([\s\S]*?)\}/)
|
|
544
910
|
assert.ok(block, '客户端缺少 META_FIELD_VALUES')
|
|
545
911
|
const fields = [...block[1].matchAll(/(\w+):/g)].map((m) => m[1]).sort()
|
|
546
912
|
assert.deepEqual(fields, ['emoji', 'headingLists', 'language', 'replyLength', 'tables'])
|
|
@@ -567,30 +933,679 @@ check('客户端为内置预设提供中英双语文案键', () => {
|
|
|
567
933
|
}
|
|
568
934
|
})
|
|
569
935
|
|
|
570
|
-
check('
|
|
936
|
+
check('「人设变更需确认」提示如实描述审批面板与完全权限下的建议', () => {
|
|
937
|
+
// 该提示曾经过时:主路径已从「/soul confirm 文本确认」改为**宿主审批面板**
|
|
938
|
+
// (composer takeover,Enter 允许 / Esc 拒绝),文案却仍只说文本流程。
|
|
939
|
+
//
|
|
940
|
+
// 三条必须同时出现,否则用户会被误导:
|
|
941
|
+
// ① 主路径是审批面板(而不是只有文本确认)
|
|
942
|
+
// ② 通道不可用(策略 never)时退化为文本确认
|
|
943
|
+
// ③ 完全权限下建议关闭本开关 —— 因为此时面板不会出现、每次都要手输命令
|
|
944
|
+
const all = [...clientSource.matchAll(/'hint\.toolConfirm':\s*'([^']*)'/g)].map((m) => m[1])
|
|
945
|
+
assert.equal(all.length, 2, `hint.toolConfirm 应中英各一份,实际 ${all.length} 份`)
|
|
946
|
+
const [zh, en] = all
|
|
947
|
+
// ① 审批面板
|
|
948
|
+
assert.match(zh, /审批面板/, '中文提示应说明主路径是审批面板(否则用户不知道会弹面板)')
|
|
949
|
+
assert.match(en, /approval panel/, '英文提示应说明主路径是审批面板')
|
|
950
|
+
// ② 退化路径
|
|
951
|
+
assert.match(zh, /\/soul confirm/, '中文提示应说明通道不可用时退化为 /soul confirm')
|
|
952
|
+
assert.match(en, /\/soul confirm/, '英文提示应说明退化路径')
|
|
953
|
+
assert.match(zh, /never/, '中文提示应点明触发退化的策略(never)')
|
|
954
|
+
assert.match(en, /never/, '英文提示应点明触发退化的策略(never)')
|
|
955
|
+
// ③ 完全权限下的建议
|
|
956
|
+
assert.match(zh, /建议关闭/, '中文提示应给出「完全权限下建议关闭」的建议')
|
|
957
|
+
assert.match(en, /turning this switch off is recommended/, '英文提示应给出同样的建议')
|
|
958
|
+
// 长度上限:tooltip 是向上弹出的浮层,过长会盖住大半个分组。
|
|
959
|
+
// 实测(chrome 渲染):124 字 ≈ 108px 高。这里按字符数设一条粗粒度上限,
|
|
960
|
+
// 防止后续追加内容把浮层撑到不可用。
|
|
961
|
+
assert.ok(zh.length <= 160, `中文提示过长(${zh.length} 字),实测每 19 字一行,超过 160 字会盖住大半个分组`)
|
|
962
|
+
assert.ok(en.length <= 400, `英文提示过长(${en.length} 字符)`)
|
|
963
|
+
})
|
|
964
|
+
|
|
965
|
+
check('客户端卡牌把「内置」徽标放在插画区,不进操作区', () => {
|
|
966
|
+
// 竖版卡牌(方案 B)的结构是:
|
|
967
|
+
// .soul-persona-art 插画区 —— 图 + 渐变遮罩 + 徽标 + 位置指示 + 名称/摘要
|
|
968
|
+
// .soul-persona-body 信息区 —— 效果栏 + 操作按钮
|
|
969
|
+
// 徽标属于「这个预设从哪来」的信息,应随名称在插画区;放操作区会占掉按钮列位。
|
|
970
|
+
// 定界必须用括号配平(extractCall),不能用缩进/固定窗口:曾用 `\s{22}` 猜终点,
|
|
971
|
+
// 而真实闭合是 24 空格 ⇒ 捕获吞掉后续内容,断言退化成「徽标在这一块里」(H4)。
|
|
972
|
+
const artCell = extractCall(clientSource, "e('div', { className: 'soul-persona-art' }")
|
|
973
|
+
assert.ok(artCell !== '', '找不到插画区渲染块')
|
|
974
|
+
assert.ok(/soul-persona-badge/.test(artCell), '内置标记未渲染在插画区(应随名称展示)')
|
|
975
|
+
assert.ok(
|
|
976
|
+
/soul-persona-name/.test(artCell) && /soul-persona-meta/.test(artCell),
|
|
977
|
+
'名称与摘要应压在插画上(方案 B 的卡面构图)'
|
|
978
|
+
)
|
|
979
|
+
// 徽标不得出现在操作区块里
|
|
980
|
+
const actionsCell = extractCall(clientSource, "e('div', { className: 'soul-persona-actions' }")
|
|
981
|
+
assert.ok(actionsCell !== '', '找不到操作区渲染块')
|
|
982
|
+
assert.equal(
|
|
983
|
+
/soul-persona-badge/.test(actionsCell),
|
|
984
|
+
false,
|
|
985
|
+
'徽标不应出现在操作区(会占掉「使用」的列位)'
|
|
986
|
+
)
|
|
987
|
+
// 效果栏必须在信息区内(放插画区会压在图上看不清)
|
|
988
|
+
const bodyCell = extractCall(clientSource, "e('div', { className: 'soul-persona-body' }")
|
|
989
|
+
assert.ok(/soul-persona-eff/.test(bodyCell), '效果栏应在信息区(.soul-persona-body)内')
|
|
990
|
+
// 「删除」已移到**卡背**(用户反馈正面三钮拥挤、43px 窄条夹在中间观感差)。
|
|
991
|
+
// 正面现在两类卡都是「使用 + 详情」两钮 —— 断言改为:
|
|
992
|
+
// ① 正面**不得**再有删除按钮或空占位(slot 是旧布局的产物)
|
|
993
|
+
// ② 正面「使用」必须在、且不依赖占位对齐(flex 布局天然同列)
|
|
994
|
+
const actionsBlock = actionsCell
|
|
995
|
+
assert.equal(
|
|
996
|
+
actionsBlock.includes('soul-persona-danger') || actionsBlock.includes('soul-persona-slot'),
|
|
997
|
+
false,
|
|
998
|
+
'正面操作区不应再有删除按钮或空占位(删除已移到卡背;正面两钮与内置卡一致)'
|
|
999
|
+
)
|
|
1000
|
+
assert.ok(
|
|
1001
|
+
actionsBlock.includes('handleUsePersona(name)') && actionsBlock.includes('soul-persona-details'),
|
|
1002
|
+
'正面操作区应有「使用」与「详情」'
|
|
1003
|
+
)
|
|
1004
|
+
})
|
|
1005
|
+
|
|
1006
|
+
// 解析宿主主题包(dsh-client-ui-theme),供「CSS 引用的 token 必须真实存在」这条断言使用。
|
|
1007
|
+
// 与 dsh-tools / dsh-app-boot 同样的 anchors 思路:单点解析必失败(peer/嵌套依赖)。
|
|
1008
|
+
const loadThemeCss = () => {
|
|
1009
|
+
const anchors = [packageRoot, process.cwd()]
|
|
1010
|
+
try {
|
|
1011
|
+
const profiles = join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'profiles')
|
|
1012
|
+
if (existsSync(profiles)) for (const p of readdirSync(profiles)) anchors.push(join(profiles, p))
|
|
1013
|
+
} catch { /* 忽略:下面按 anchor 逐个尝试 */ }
|
|
1014
|
+
for (const anchor of anchors) {
|
|
1015
|
+
try {
|
|
1016
|
+
const req = createRequire(join(anchor, 'noop.js'))
|
|
1017
|
+
const entry = req.resolve('@deepseek-ai/dsh-client-ui-theme')
|
|
1018
|
+
// 主题的 token 定义在 lib/client.js(入口可能指向别处,故顺带扫同目录)
|
|
1019
|
+
const dir = dirname(entry)
|
|
1020
|
+
for (const file of [entry, join(dir, 'client.js'), join(dir, 'index.js')]) {
|
|
1021
|
+
if (existsSync(file)) {
|
|
1022
|
+
const text = readFileSync(file, 'utf8')
|
|
1023
|
+
if (text.includes('--dsw-radius-xl')) return text
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
} catch { /* 换下一个 anchor */ }
|
|
1027
|
+
}
|
|
1028
|
+
return null
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
check('客户端 CSS 引用的每个 --dsw-* token 都在宿主主题里真实存在', () => {
|
|
1032
|
+
// 这是一类**静默失效**:CSS 里写了一个宿主不存在的变量名,var() 会回退成继承值
|
|
1033
|
+
// (实测:不存在的 token ⇒ color 变成 rgb(0,0,0),即黑色),不报错、不警告,
|
|
1034
|
+
// 只在视觉上「颜色不对」。本插件曾用 `--dsw-alias-label-error` 表达错误色,
|
|
1035
|
+
// 而宿主根本没有这个 token(宿主用 `--dsw-alias-state-error-primary`)⇒ 错误提示、
|
|
1036
|
+
// 危险按钮、快捷开关失败态**全部渲染成黑色**,且 200+ 项断言全绿。
|
|
1037
|
+
const cssBlock = clientCode.match(/const css = \[([\s\S]*?)\]\.join/)?.[1] ?? ''
|
|
1038
|
+
assert.ok(cssBlock !== '', '找不到客户端 CSS 块')
|
|
1039
|
+
const used = [...new Set([...cssBlock.matchAll(/var\((--dsw-[\w-]+)/g)].map((m) => m[1]))]
|
|
1040
|
+
assert.ok(used.length > 0, '客户端 CSS 未引用任何主题 token(应使用 var(--dsw-*) 而非硬编码颜色)')
|
|
1041
|
+
|
|
1042
|
+
const theme = loadThemeCss()
|
|
1043
|
+
if (theme === null) {
|
|
1044
|
+
if (!skipNote('verify-config', 1, '本机未解析到 @deepseek-ai/dsh-client-ui-theme,CSS token 未做存在性校验', '安装 DSH 后重跑即可覆盖')) {
|
|
1045
|
+
failures.push({ name: 'CSS token 存在性(需真实主题包)', err: new Error('--strict 下跳过按失败处理') })
|
|
1046
|
+
}
|
|
1047
|
+
return
|
|
1048
|
+
}
|
|
1049
|
+
const missing = used.filter((t) => !new RegExp(`${t}\\s*:`).test(theme))
|
|
1050
|
+
assert.deepEqual(
|
|
1051
|
+
missing,
|
|
1052
|
+
[],
|
|
1053
|
+
`CSS 引用了宿主主题里不存在的 token(var() 会静默回退成继承值):${missing.join(', ')}`
|
|
1054
|
+
)
|
|
1055
|
+
// 反向也钉一条:曾用错的那个 token 不得再出现(防止回退)
|
|
1056
|
+
assert.ok(
|
|
1057
|
+
!used.includes('--dsw-alias-label-error'),
|
|
1058
|
+
'--dsw-alias-label-error 在宿主主题里不存在(应为 --dsw-alias-state-error-primary)'
|
|
1059
|
+
)
|
|
1060
|
+
})
|
|
1061
|
+
|
|
1062
|
+
check('设置页样式对齐宿主栏目:无外层盒子,分组用卡片', () => {
|
|
1063
|
+
// 宿主其余设置栏目(models / plugins / plugin-inventory)都不用外框:
|
|
1064
|
+
// 内容直接落在 options 容器自带的 `padding:0 24px 24px` 里,分组靠「卡片」表达
|
|
1065
|
+
// (.5px 描边 + settings-card-* + radius-xl)。此前 `.soul-section` 自带
|
|
1066
|
+
// margin/padding/border/background,等于在宿主内边距里又套一层盒子 —— 这是观感
|
|
1067
|
+
// 与其它栏目不统一最直接的原因。
|
|
1068
|
+
const cssBlock = clientCode.match(/const css = \[([\s\S]*?)\]\.join/)?.[1] ?? ''
|
|
1069
|
+
const section = cssBlock.match(/\.soul-section\{([^}]*)\}/)?.[1] ?? ''
|
|
1070
|
+
assert.ok(section !== '', '找不到 .soul-section 规则')
|
|
1071
|
+
for (const prop of ['padding', 'border', 'background']) {
|
|
1072
|
+
assert.equal(
|
|
1073
|
+
new RegExp(`(^|;)${prop}:`).test(section),
|
|
1074
|
+
false,
|
|
1075
|
+
`.soul-section 不应自带 ${prop}(宿主的设置内容区已提供内边距,再套一层盒子就与其它栏目不一致)`
|
|
1076
|
+
)
|
|
1077
|
+
}
|
|
1078
|
+
assert.ok(/max-width:\s*\d+px/.test(section), '.soul-section 应限制最大宽度(宿主各栏目用 720~760px 限制行长)')
|
|
1079
|
+
assert.ok(/display:flex/.test(section), '.soul-section 应为纵向弹性列(宿主各栏目同构)')
|
|
1080
|
+
|
|
1081
|
+
// 分组(折叠区)必须用宿主卡片 token,而不是普通边框 + 小圆角
|
|
1082
|
+
const accordion = cssBlock.match(/\.soul-accordion\{([^}]*)\}/)?.[1] ?? ''
|
|
1083
|
+
assert.ok(accordion !== '', '找不到 .soul-accordion 规则')
|
|
1084
|
+
assert.ok(/settings-card-stroke/.test(accordion), '分组应使用 settings-card-stroke 描边(宿主卡片统一色)')
|
|
1085
|
+
assert.ok(/settings-card-fill/.test(accordion), '分组应使用 settings-card-fill 底色')
|
|
1086
|
+
assert.ok(/radius-xl/.test(accordion), '分组应使用 radius-xl 圆角(与宿主卡片一致)')
|
|
1087
|
+
|
|
1088
|
+
// 标题规格对齐:宿主栏目标题是 16px / 500 / lh24,不是 600
|
|
1089
|
+
const title = cssBlock.match(/\.soul-section h3\{([^}]*)\}/)?.[1] ?? ''
|
|
1090
|
+
assert.ok(/font-size:16px/.test(title), '栏目标题应为 16px(与宿主一致)')
|
|
1091
|
+
assert.ok(/font-weight:500/.test(title), '栏目标题字重应为 500(宿主用 500,600 会显得更重)')
|
|
1092
|
+
|
|
1093
|
+
// 字段标签规格对齐:宿主 fieldLabel 是 12px / 500 / lh18
|
|
1094
|
+
const fieldLabel = cssBlock.match(/\.soul-field label\{([^}]*)\}/)?.[1] ?? ''
|
|
1095
|
+
assert.ok(/font-size:12px/.test(fieldLabel), '字段标签应为 12px(宿主 fieldLabel 同规格)')
|
|
1096
|
+
assert.ok(/line-height:18px/.test(fieldLabel), '字段标签行高应为 18px(与宿主一致)')
|
|
1097
|
+
|
|
1098
|
+
// 语义色必须走宿主的状态色族,而不是硬编码。
|
|
1099
|
+
// 两条容易误报的路径都要排掉(本条初版就被它们误伤过):
|
|
1100
|
+
// ① 注释里提到的旧值(如「原先硬编码 #f6ffed」)—— 先剥注释;
|
|
1101
|
+
// ② var(--token, #fallback) 里的兜底值 —— 那是刻意保留的降级,不算硬编码。
|
|
1102
|
+
const stripped = stripComments(cssBlock)
|
|
1103
|
+
let withoutFallbacks = ''
|
|
1104
|
+
for (let i = 0; i < stripped.length; i++) {
|
|
1105
|
+
if (stripped.startsWith('var(', i)) {
|
|
1106
|
+
// 括号配平跳过整个 var(...)(内部可能嵌套 color-mix(...))
|
|
1107
|
+
let depth = 0
|
|
1108
|
+
let j = i + 3
|
|
1109
|
+
for (; j < stripped.length; j++) {
|
|
1110
|
+
if (stripped[j] === '(') depth++
|
|
1111
|
+
else if (stripped[j] === ')') { depth--; if (depth === 0) break }
|
|
1112
|
+
}
|
|
1113
|
+
withoutFallbacks += 'var()'
|
|
1114
|
+
i = j
|
|
1115
|
+
continue
|
|
1116
|
+
}
|
|
1117
|
+
withoutFallbacks += stripped[i]
|
|
1118
|
+
}
|
|
1119
|
+
const hardcoded = [...withoutFallbacks.matchAll(/#[0-9a-fA-F]{3,8}\b/g)].map((m) => m[0])
|
|
1120
|
+
assert.deepEqual(
|
|
1121
|
+
hardcoded,
|
|
1122
|
+
[],
|
|
1123
|
+
`CSS 不应硬编码颜色(深色主题下会失效):${[...new Set(hardcoded)].join(', ')}`
|
|
1124
|
+
)
|
|
1125
|
+
})
|
|
1126
|
+
|
|
1127
|
+
check('卡牌操作按钮:主操作突出(实心主色),删除为次要小按钮', () => {
|
|
1128
|
+
// 卡牌的主操作是「使用」——它需要足够的视觉权重(实心主按钮 + 较大高度),
|
|
1129
|
+
// 而不是列表行里那种 28px 透明底的文字按钮(那是**列表**的规格,
|
|
1130
|
+
// 卡牌上会显得太小、不够突出)。
|
|
1131
|
+
// 「删除」是次要的破坏性操作:定宽小按钮 + 描边危险色,形成主次对比。
|
|
1132
|
+
const cssBlock = clientCode.match(/const css = \[([\s\S]*?)\]\.join/)?.[1] ?? ''
|
|
1133
|
+
const btn = cssBlock.match(/\.soul-persona-action\{([^}]*)\}/)?.[1] ?? ''
|
|
1134
|
+
assert.ok(btn !== '', '找不到 .soul-persona-action 规则')
|
|
1135
|
+
for (const [pattern, why] of [
|
|
1136
|
+
[/background:var\(--dsw-alias-button-primary-fill\)/, '「使用」应为实心主按钮底(用主题主按钮 token)'],
|
|
1137
|
+
[/color:var\(--dsw-alias-label-primary-foreground\)/, '主按钮文字应为反色前景(保证对比度)'],
|
|
1138
|
+
[/flex:1 1 auto/, '主按钮应弹性占满可用宽度(卡牌主操作要显眼)'],
|
|
1139
|
+
[/font-weight:600/, '主按钮字重应为 600(突出主操作)']
|
|
1140
|
+
]) {
|
|
1141
|
+
assert.ok(pattern.test(btn), why)
|
|
1142
|
+
}
|
|
1143
|
+
// 高度必须足够大:按钮偏小是这一版明确修过的问题 ⇒ 校验量级而非「有 height」
|
|
1144
|
+
const h = Number((btn.match(/height:(\d+)px/) ?? [])[1] ?? 0)
|
|
1145
|
+
assert.ok(h >= 32, `主按钮高度应 ≥32px(当前 ${h}px,卡牌上过小会不突出且难点)`)
|
|
1146
|
+
const fs = Number((btn.match(/font-size:(\d+)px/) ?? [])[1] ?? 0)
|
|
1147
|
+
assert.ok(fs >= 13, `主按钮字号应 ≥13px(当前 ${fs}px)`)
|
|
1148
|
+
// hover 反馈必须存在
|
|
1149
|
+
assert.match(cssBlock, /\.soul-persona-action:hover:not\(:disabled\)\{[^}]*filter:brightness/, '主按钮应有 hover 反馈')
|
|
1150
|
+
// 删除:定宽(不占满)+ 描边危险色,明显小于主按钮
|
|
1151
|
+
const danger = cssBlock.match(/\.soul-persona-danger\{([^}]*)\}/)?.[1] ?? ''
|
|
1152
|
+
assert.ok(/state-error-primary/.test(danger), '删除按钮色应为 state-error-primary')
|
|
1153
|
+
assert.match(danger, /flex:0 0 \d+px/, '删除按钮应定宽(不占满,避免与主操作抢视觉权重)')
|
|
1154
|
+
assert.match(danger, /background:transparent/, '删除按钮应为透明底(次要操作)')
|
|
1155
|
+
assert.match(danger, /border:\.5px solid/, '删除按钮应有描边(次要按钮的视觉语言)')
|
|
1156
|
+
assert.ok(
|
|
1157
|
+
/\.soul-persona-danger:hover:not\(:disabled\)\{[^}]*interactive-bg-hover-danger/.test(cssBlock),
|
|
1158
|
+
'删除按钮 hover 应使用 interactive-bg-hover-danger'
|
|
1159
|
+
)
|
|
1160
|
+
// 操作区应为 flex(主按钮弹性 + 删除定宽),不再是两条固定轨道的网格
|
|
1161
|
+
const actions = cssBlock.match(/\.soul-persona-actions\{([^}]*)\}/)?.[1] ?? ''
|
|
1162
|
+
assert.match(actions, /display:flex/, '操作区应为弹性布局(主按钮占满、删除定宽)')
|
|
1163
|
+
// 内置预设不渲染删除按钮,「使用」自然占满
|
|
1164
|
+
assert.match(cssBlock, /\.soul-persona-slot\{display:none\}/, '内置预设的删除位应完全不占空间(.soul-persona-slot 隐藏)')
|
|
1165
|
+
|
|
1166
|
+
// 卡片必须是堆叠容器(一次只展示一张),而不是纵向列表
|
|
1167
|
+
const card = cssBlock.match(/\.soul-persona-card\{([^}]*)\}/)?.[1] ?? ''
|
|
1168
|
+
assert.ok(card !== '', '找不到 .soul-persona-card 规则')
|
|
1169
|
+
assert.ok(/position:absolute/.test(card), '卡片应绝对定位在堆叠容器里(否则高度会随张数跳动)')
|
|
1170
|
+
assert.ok(/settings-card-stroke/.test(card), '卡片描边应用 settings-card-stroke(与宿主卡片一致)')
|
|
1171
|
+
assert.match(card, /border-radius:var\(--dsw-radius-xl\)/, '卡片应用 radius-xl 圆角(卡牌观感,与宿主卡片一致)')
|
|
1172
|
+
const stack = cssBlock.match(/\.soul-persona-stack\{([^}]*)\}/)?.[1] ?? ''
|
|
1173
|
+
assert.ok(/position:relative/.test(stack), '堆叠容器应为相对定位(作为卡片的定位基准)')
|
|
1174
|
+
// 高度必须**足够容纳一张卡片**:只断言「有 min-height」不够 —— 改成 0 也能过(实测),
|
|
1175
|
+
// 而 0 会让内容溢出、切换时跳动,因此校验量级。
|
|
1176
|
+
const stackH = Number((stack.match(/min-height:\s*(\d+)px/) ?? [])[1] ?? 0)
|
|
1177
|
+
assert.ok(stackH >= 100, `堆叠容器 min-height 应 ≥100px(当前 ${stackH}px,过小会导致内容溢出/切换跳动)`)
|
|
1178
|
+
// 叠层必须真的错开:偏移量过小就只露一条缝、看不出「一叠卡片」。
|
|
1179
|
+
// 校验内联 transform 里的偏移系数,避免「改了数值而断言没跟上」(实测 0 偏移能骗过旧写法)。
|
|
1180
|
+
const offMatch = clientSource.match(/translateX\(\$\{offset \* (\d+)\}px\)/)
|
|
1181
|
+
assert.ok(offMatch, '找不到卡片叠层偏移的写法(内联 transform)')
|
|
1182
|
+
// 竖版卡牌宽 260px,偏移要更大才能露出完整的卡边 ⇒ 下限提高到 60px
|
|
1183
|
+
assert.ok(
|
|
1184
|
+
Number(offMatch[1]) >= 60,
|
|
1185
|
+
`叠层横向偏移应 ≥60px(当前 ${offMatch[1]}px,过小则叠层与当前卡重叠成一坨)`
|
|
1186
|
+
)
|
|
1187
|
+
// 叠层容器的竖直错位(让背景卡稍微下沉,形成卡堆纵深)
|
|
1188
|
+
assert.match(
|
|
1189
|
+
clientSource,
|
|
1190
|
+
/translateY\(\$\{Math\.abs\(offset\) \* \d+\}px\)/,
|
|
1191
|
+
'叠层应有竖直错位(背景卡下沉,形成卡堆纵深)'
|
|
1192
|
+
)
|
|
1193
|
+
// 滑动过渡必须存在(用户要的「滑动特效」),且要尊重 reduced-motion
|
|
1194
|
+
assert.match(card, /transition:[^;}]*transform/, '卡片应有 transform 过渡(滑动特效)')
|
|
1195
|
+
// reduced-motion 规则里有嵌套的 `{}`(多选择器),用 `[^}]*\}\}` 这类简易正则匹配不到,
|
|
1196
|
+
// 因此改为「取 media 规则起点后的整段,再逐条查」。
|
|
1197
|
+
const rmAt = cssBlock.indexOf('@media (prefers-reduced-motion:reduce)')
|
|
1198
|
+
assert.ok(rmAt > 0, '缺少 prefers-reduced-motion 规则')
|
|
1199
|
+
const rm = cssBlock.slice(rmAt)
|
|
1200
|
+
assert.ok(
|
|
1201
|
+
/\.soul-persona-card\{animation:none!important;transition:none\}/.test(rm),
|
|
1202
|
+
'卡片的滑动/入场动效必须在 prefers-reduced-motion 下关闭'
|
|
1203
|
+
)
|
|
1204
|
+
// 来源区分交给徽标,不给卡片加底色(宿主不给行容器加 hover 底色)。
|
|
1205
|
+
// 判定要**只看背景声明**:`[data-builtin]` 选择器还承担布局用途
|
|
1206
|
+
// (无插画的自定义预设改单列),若只按选择器存在与否判断会误报。
|
|
1207
|
+
const builtinRules = cssBlock.match(/\.soul-persona-card\[data-builtin[^{]*\{[^}]*\}/g) ?? []
|
|
1208
|
+
const builtinBackgrounds = builtinRules.filter((r) => /background/.test(r))
|
|
1209
|
+
assert.deepEqual(
|
|
1210
|
+
builtinBackgrounds,
|
|
1211
|
+
[],
|
|
1212
|
+
`不应给内置卡片加底色(宿主用 rowTag 区分来源;灰底在白色卡片群里会被误读成 hover 态):${builtinBackgrounds.join(' ')}`
|
|
1213
|
+
)
|
|
1214
|
+
})
|
|
1215
|
+
|
|
1216
|
+
check('当前生效的人设:按钮文字变「使用中」且样式区别于其它卡片', () => {
|
|
1217
|
+
// 起因:点「使用」后再次进入设置页,只有名称前的 ★ 能表明哪张卡是当前配置,
|
|
1218
|
+
// 而按钮一律写「使用」,不够醒目。现改为当前生效那张的按钮文字变「使用中」+
|
|
1219
|
+
// 描边式样式(透明底 + 品牌色),与其它卡片的实心主色按钮形成对比。
|
|
1220
|
+
// 三重标识:★(名称前)+「使用中」文字 + 描边样式。
|
|
1221
|
+
const cssBlock = clientCode.match(/const css = \[([\s\S]*?)\]\.join/)?.[1] ?? ''
|
|
1222
|
+
// ① 词典键必须中英各一份(双语对齐断言只保证键集一致,不保证此键存在)
|
|
1223
|
+
assert.match(clientSource, /'personas\.active': '使用中'/, '缺少中文「使用中」文案键')
|
|
1224
|
+
assert.match(clientSource, /'personas\.active': 'Active'/, '缺少英文「Active」文案键')
|
|
1225
|
+
// ② 按钮文字按 activePersona 切换
|
|
1226
|
+
assert.match(
|
|
1227
|
+
clientSource,
|
|
1228
|
+
/name === activePersona \? t\('personas\.active'\) : t\('personas\.use'\)/,
|
|
1229
|
+
'生效中的预设应把按钮文字换成「使用中」(否则与其它卡片无从区分)'
|
|
1230
|
+
)
|
|
1231
|
+
// ③ 生效中的按钮加独立类名,且该类名按 activePersona 切换
|
|
1232
|
+
assert.match(
|
|
1233
|
+
clientSource,
|
|
1234
|
+
/className: name === activePersona \? 'soul-persona-action soul-persona-active' : 'soul-persona-action'/,
|
|
1235
|
+
'生效中的按钮应带 soul-persona-active 类(供样式区分)'
|
|
1236
|
+
)
|
|
1237
|
+
// ④ 该类名的样式必须真的区分:透明底 + 品牌色文字(不是照抄实心主色)
|
|
1238
|
+
const active = cssBlock.match(/\.soul-persona-active\{([^}]*)\}/)?.[1] ?? ''
|
|
1239
|
+
assert.ok(active !== '', '找不到 .soul-persona-active 规则')
|
|
1240
|
+
assert.match(active, /background:transparent/, '「使用中」按钮应为透明底(区别于实心主色)')
|
|
1241
|
+
assert.match(active, /color:var\(--dsw-alias-brand-primary\)/, '「使用中」按钮文字应用品牌色')
|
|
1242
|
+
assert.match(active, /box-shadow:inset/, '用 inset box-shadow 画描边(基类 border:0,加 border 会让尺寸变化导致布局跳动)')
|
|
1243
|
+
assert.equal(
|
|
1244
|
+
/border:/.test(active),
|
|
1245
|
+
false,
|
|
1246
|
+
'不得用 border 画描边(会改变盒模型尺寸,「使用中」与其它按钮不等高)'
|
|
1247
|
+
)
|
|
1248
|
+
// ⑤ 仍然可点击(不是 disabled)—— 重复应用是无副作用的幂等操作
|
|
1249
|
+
assert.equal(
|
|
1250
|
+
/soul-persona-active[^}]*cursor:default/.test(cssBlock),
|
|
1251
|
+
false,
|
|
1252
|
+
'「使用中」按钮不应显示为不可点击(重复应用是幂等的,禁用会让人以为卡住)'
|
|
1253
|
+
)
|
|
1254
|
+
})
|
|
1255
|
+
|
|
1256
|
+
check('卡片堆叠轮播:一次一张 + 位置指示 + 可点翻页(补偿列表的可扫读性)', () => {
|
|
1257
|
+
// 卡片堆叠的代价是「一次只见一张」——8 个预设要翻 7 次才能看全,而列表本可一眼扫完。
|
|
1258
|
+
// 因此必须同时提供:① 位置指示(i / n)② 可点的前后翻页 ③ 圆点直达。
|
|
1259
|
+
// 这条断言就是钉住这三处补偿,以及「只渲染 3 张」的层叠上限。
|
|
1260
|
+
assert.match(clientSource, /for \(let offset = -1; offset <= 1; offset\+\+\)/, '应只渲染当前张与左右各一张(层叠上限 3)')
|
|
1261
|
+
assert.match(clientSource, /const index = Math\.min\(Math\.max\(personaIndex, 0\), total - 1\)/, '索引必须夹取(预设被删除后可能越界)')
|
|
1262
|
+
assert.match(clientSource, /'aria-label': t\('personas\.prev'\)/, '上一张按钮缺少 aria-label')
|
|
1263
|
+
assert.match(clientSource, /'aria-label': t\('personas\.next'\)/, '下一张按钮缺少 aria-label')
|
|
1264
|
+
assert.match(clientSource, /disabled: index === 0/, '首张时「上一张」应禁用')
|
|
1265
|
+
assert.match(clientSource, /disabled: index === total - 1/, '末张时「下一张」应禁用')
|
|
1266
|
+
assert.match(clientSource, /className: 'soul-persona-card-pos'/, '缺少位置指示(i / n)')
|
|
1267
|
+
assert.match(clientSource, /className: 'soul-persona-dot'/, '缺少圆点直达指示器')
|
|
1268
|
+
assert.match(clientSource, /'aria-selected': i === index/, '圆点应暴露 aria-selected(读屏可知当前项)')
|
|
1269
|
+
// 非当前卡片不得被读屏读到,也不该可点(避免念出重复内容 / 误触)
|
|
1270
|
+
assert.match(clientSource, /'aria-hidden': current \? undefined : 'true'/, '非当前卡片应 aria-hidden')
|
|
1271
|
+
// 词典键必须齐全(zh/en 有逐键对齐断言兜底,但这里先确认被引用)
|
|
1272
|
+
for (const key of ['personas.prev', 'personas.next']) {
|
|
1273
|
+
assert.ok(clientSource.includes(`'${key}'`), `缺少词典键 ${key}`)
|
|
1274
|
+
}
|
|
1275
|
+
})
|
|
1276
|
+
|
|
1277
|
+
check('人设插画:宿主路由存在、只服务内置人设名、缺图回退占位', () => {
|
|
1278
|
+
// 图放 assets/personas/<人设名>.<ext>,客户端按名请求 /api/soul/persona-art/<名>。
|
|
1279
|
+
// 三条安全/健壮性契约:
|
|
1280
|
+
// ① 路由必须是 prefix(路径里带名字),且只接受 GET;
|
|
1281
|
+
// ② 名字经 isBuiltinPersona 白名单校验 —— 这是防路径穿越的**结构手段**
|
|
1282
|
+
// (用户可控的名字若不校验,`../package.json` 就能读到包内任意文件);
|
|
1283
|
+
// ③ 缺图回退 placeholder.svg,而不是 404/破图。
|
|
1284
|
+
assert.ok(/path: '\/api\/soul\/persona-art'/.test(indexCode), 'index.mjs 未注册人设插画路由')
|
|
1285
|
+
// 用**剥过注释**的 indexCode 定位:原始源码的文件头注释里也写了同一路径用作说明,
|
|
1286
|
+
// 直接 indexOf/lastIndexOf 会命中注释、把断言判错(初版就这样误报过)。
|
|
1287
|
+
const regAt = indexCode.lastIndexOf("path: '/api/soul/persona-art'")
|
|
1288
|
+
assert.ok(regAt > 0, '找不到插画路由的注册处')
|
|
1289
|
+
const regStart = indexCode.lastIndexOf('register(', regAt)
|
|
1290
|
+
const artRoute = indexCode.slice(Math.max(0, regStart), regAt + 1400)
|
|
1291
|
+
assert.match(artRoute, /kind: 'prefix'/, '插画路由应为 prefix(路径含人设名)')
|
|
1292
|
+
assert.match(artRoute, /req\.method !== 'GET'/, '插画路由应校验方法')
|
|
571
1293
|
assert.ok(
|
|
572
|
-
|
|
573
|
-
'
|
|
1294
|
+
/!isBuiltinPersona\(name\)/.test(indexCode),
|
|
1295
|
+
'resolvePersonaArt 必须用 isBuiltinPersona 做白名单校验(防路径穿越的结构手段)'
|
|
1296
|
+
)
|
|
1297
|
+
assert.ok(/placeholder\.svg/.test(indexCode), '缺少缺图回退(placeholder.svg)')
|
|
1298
|
+
assert.ok(/ART_MIME/.test(indexCode), '应显式给出 MIME 表(否则浏览器可能按 octet-stream 下载)')
|
|
1299
|
+
assert.equal(
|
|
1300
|
+
/join\(dir, name\)/.test(indexCode),
|
|
1301
|
+
false,
|
|
1302
|
+
'不得用未经白名单校验的请求名直接拼路径'
|
|
1303
|
+
)
|
|
1304
|
+
})
|
|
1305
|
+
|
|
1306
|
+
check('人设插画:客户端对所有卡片都渲染 img(不再限定内置)', () => {
|
|
1307
|
+
// 此前渲染是 `builtin && e('img', ...)` —— 自建预设完全不出图。
|
|
1308
|
+
// 本版统一出图;若哪天又被加上条件,自建卡会退回空白渐变底。
|
|
1309
|
+
const artIdx = clientSource.indexOf("'soul-persona-art' }")
|
|
1310
|
+
assert.ok(artIdx > 0, '找不到插画区渲染处')
|
|
1311
|
+
const artSlice = clientSource.slice(artIdx, artIdx + 900)
|
|
1312
|
+
assert.equal(
|
|
1313
|
+
/builtin && e\('img', \{[\s\S]{0,60}className: 'soul-persona-art-img'/.test(artSlice),
|
|
1314
|
+
false,
|
|
1315
|
+
'不得再用 builtin 条件限制出图(自建预设也要显示默认贴图)'
|
|
1316
|
+
)
|
|
1317
|
+
assert.match(
|
|
1318
|
+
artSlice,
|
|
1319
|
+
/e\('img', \{[\s\S]{0,80}className: 'soul-persona-art-img'/,
|
|
1320
|
+
'所有卡片都应渲染插画 img'
|
|
1321
|
+
)
|
|
1322
|
+
// onError 兜底仍须保留(路由不可用时退化为渐变底而非破图)
|
|
1323
|
+
assert.match(artSlice, /onError:/, '插画应保留 onError 兜底隐藏')
|
|
1324
|
+
})
|
|
1325
|
+
|
|
1326
|
+
check('人设插画:自建预设走专用默认贴图,且名字不参与路径拼接', () => {
|
|
1327
|
+
// 本版只做样式调整:自建预设此前完全不出图(只有渐变底),现在统一出图,
|
|
1328
|
+
// 由宿主对**非内置名**返回固定贴图。用户上传本地图片是后续版本功能。
|
|
1329
|
+
//
|
|
1330
|
+
// 安全不变量:非内置名**一律返回固定文件**,名字不参与 join ⇒
|
|
1331
|
+
// 路径穿越从结构上不可能(不是靠字符串替换去猜)。
|
|
1332
|
+
// ⚠️ 按**函数定义**定位,不能按路由注册处切:resolvePersonaArt 定义在文件前部
|
|
1333
|
+
// (插画常量区),而路由注册在文件后部的 apply 里 —— 按注册处切片会截掉函数体,
|
|
1334
|
+
// 断言于是「找不到判据」而误报(初版正是如此)。
|
|
1335
|
+
const fnAt = indexCode.indexOf('function resolvePersonaArt(')
|
|
1336
|
+
assert.ok(fnAt > 0, '找不到 resolvePersonaArt 定义')
|
|
1337
|
+
const artRoute = indexCode.slice(fnAt, indexCode.indexOf('function personasWithArt('))
|
|
1338
|
+
// ① 常量存在
|
|
1339
|
+
assert.match(indexCode, /const CUSTOM_ART_FILE = 'custom-placeholder\.webp'/, '缺少自建预设默认贴图的文件名常量')
|
|
1340
|
+
// ② 非内置名分支:返回 CUSTOM_ART_FILE,且**不**用 name 拼路径
|
|
1341
|
+
const branchAt = artRoute.indexOf('!isBuiltinPersona(name)')
|
|
1342
|
+
assert.ok(branchAt > 0, '缺少「非内置名」判据')
|
|
1343
|
+
// 该分支的结束位置:按名拼路径的循环开始处
|
|
1344
|
+
const loopAt = artRoute.indexOf('for (const ext of ART_EXTENSIONS)')
|
|
1345
|
+
assert.ok(loopAt > branchAt, '「非内置名」分支应在按名查找之前')
|
|
1346
|
+
const customBranch = artRoute.slice(branchAt, loopAt)
|
|
1347
|
+
assert.match(customBranch, /CUSTOM_ART_FILE/, '非内置名分支应返回专用默认贴图')
|
|
1348
|
+
assert.equal(
|
|
1349
|
+
/join\([^)]*name/.test(customBranch),
|
|
1350
|
+
false,
|
|
1351
|
+
'非内置名分支不得用 name 拼路径(否则路径穿越可能)'
|
|
1352
|
+
)
|
|
1353
|
+
// ③ 文件真实存在且规格与内置插画一致(否则静默回退、卡片一片空白)
|
|
1354
|
+
const customFile = resolve(packageRoot, 'assets/personas/custom-placeholder.webp')
|
|
1355
|
+
assert.equal(existsSync(customFile), true, '缺少 assets/personas/custom-placeholder.webp')
|
|
1356
|
+
// WebP 文件头:RIFF....WEBP(不引入图像库解析,头部足够确认格式)
|
|
1357
|
+
const head = readFileSync(customFile).subarray(0, 12)
|
|
1358
|
+
assert.equal(head.subarray(0, 4).toString('ascii'), 'RIFF', 'custom-placeholder.webp 不是 RIFF 容器')
|
|
1359
|
+
assert.equal(head.subarray(8, 12).toString('ascii'), 'WEBP', 'custom-placeholder.webp 不是 WebP')
|
|
1360
|
+
// MIME 必须按扩展名取,不能写死(格式变过一次:svg → webp)
|
|
1361
|
+
assert.match(
|
|
1362
|
+
indexCode,
|
|
1363
|
+
/const ext = CUSTOM_ART_FILE\.slice\(CUSTOM_ART_FILE\.lastIndexOf\('\.'\)\)/,
|
|
1364
|
+
'自定义贴图的 MIME 应按扩展名取(写死会在换格式后让浏览器下载而非渲染)'
|
|
1365
|
+
)
|
|
1366
|
+
// ④ 与内置缺图占位必须区分(同图会让「官方提供」与「自己配的」无从分辨)
|
|
1367
|
+
assert.notEqual(
|
|
1368
|
+
readFileSync(customFile).length,
|
|
1369
|
+
readFileSync(resolve(packageRoot, 'assets/personas/placeholder.svg')).length,
|
|
1370
|
+
'自建预设贴图不得与内置缺图占位相同(两者语义不同:自己配的 vs 官方提供)'
|
|
1371
|
+
)
|
|
1372
|
+
})
|
|
1373
|
+
|
|
1374
|
+
check('所有破坏性端点都校验 HTTP 方法(GET 不得触发写入)', () => {
|
|
1375
|
+
// 发版前安全收口时实测发现的真实缺陷:/api/soul/config/reset **完全没有**方法
|
|
1376
|
+
// 校验,任何 HTTP 方法都会执行破坏性写入(重置配置、必要时丢弃并备份损坏文件)。
|
|
1377
|
+
//
|
|
1378
|
+
// 为什么这条风险等级不低:**GET 不是「安全」的同义词** —— 浏览器预取、爬虫、
|
|
1379
|
+
// <img src>、链接预览、代理探测都会发 GET。缺少校验意味着一个 GET 就能重置
|
|
1380
|
+
// 用户配置。同期的 personas/save、personas/delete 都有校验,此处属遗漏。
|
|
1381
|
+
//
|
|
1382
|
+
// 两类端点的判定方式不同,必须分开校验(用同一把尺子会误报):
|
|
1383
|
+
// ① 读写复用(/api/soul/config):显式 `if (method === 'GET') … else if (POST)'
|
|
1384
|
+
// —— 未覆盖的方法自然落空,不需前置 405;
|
|
1385
|
+
// ② 纯写(reset / save / use / delete):必须**前置**拦截非 POST 并回 405,
|
|
1386
|
+
// 否则任何方法都会执行写入。
|
|
1387
|
+
const bodyOf = (path) => {
|
|
1388
|
+
const at = indexCode.indexOf(`path: '${path}'`)
|
|
1389
|
+
assert.ok(at > 0, `找不到路由 ${path}`)
|
|
1390
|
+
const next = indexCode.indexOf("path: '/api/soul/", at + 10)
|
|
1391
|
+
return indexCode.slice(at, next > at ? next : at + 4000)
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
// ① 读写复用端点的契约:两条分支都在
|
|
1395
|
+
const cfgBody = bodyOf('/api/soul/config')
|
|
1396
|
+
assert.match(cfgBody, /if \(req\.method === 'GET'\) \{/, '/api/soul/config 应有显式 GET 分支')
|
|
1397
|
+
assert.match(cfgBody, /\} else if \(req\.method === 'POST'\) \{/, '/api/soul/config 应有显式 POST 分支')
|
|
1398
|
+
|
|
1399
|
+
// ② 纯写端点的契约:前置拦截非 POST 且回 405
|
|
1400
|
+
for (const path of ['/api/soul/config/reset', '/api/soul/personas/save', '/api/soul/personas/use', '/api/soul/personas/delete']) {
|
|
1401
|
+
const body = bodyOf(path)
|
|
1402
|
+
assert.match(
|
|
1403
|
+
body,
|
|
1404
|
+
/if \(req\.method !== 'POST'\) \{/,
|
|
1405
|
+
`${path} 是纯写端点,必须前置校验 req.method !== 'POST'(否则 GET 也能触发写入)`
|
|
1406
|
+
)
|
|
1407
|
+
assert.match(body, /405/, `${path} 的方法校验应返回 405`)
|
|
1408
|
+
}
|
|
1409
|
+
})
|
|
1410
|
+
|
|
1411
|
+
check('人设插画:文件规格达标(≤256KiB / 命名与人设一致 / 无孤儿文件)', () => {
|
|
1412
|
+
// 宿主对插件资源有 256KiB 上限;文件名必须与内置人设名完全一致,
|
|
1413
|
+
// 否则运行时查不到、静默回退占位图。
|
|
1414
|
+
const artDir = resolve(packageRoot, 'assets/personas')
|
|
1415
|
+
assert.equal(existsSync(artDir), true, '缺少 assets/personas 目录')
|
|
1416
|
+
assert.equal(existsSync(resolve(artDir, 'placeholder.svg')), true, '缺少 placeholder.svg(缺图时要回退到它)')
|
|
1417
|
+
const files = readdirSync(artDir)
|
|
1418
|
+
const names = builtinPersonaNames()
|
|
1419
|
+
const withArt = names.filter((n) => files.some((f) => f.startsWith(`${n}.`)))
|
|
1420
|
+
assert.ok(withArt.length > 0, '至少要有一个内置人设配了插画(否则这条契约没有样本)')
|
|
1421
|
+
for (const n of withArt) {
|
|
1422
|
+
const file = files.find((f) => f.startsWith(`${n}.`))
|
|
1423
|
+
const size = statSync(resolve(artDir, file)).size
|
|
1424
|
+
assert.ok(size <= 256 * 1024, `assets/personas/${file} 为 ${size} 字节,超过宿主上限 262144 字节`)
|
|
1425
|
+
assert.ok(size > 0, `assets/personas/${file} 是空文件`)
|
|
1426
|
+
const ext = extname(file).toLowerCase()
|
|
1427
|
+
assert.ok(
|
|
1428
|
+
['.webp', '.png', '.jpg', '.jpeg', '.svg'].includes(ext),
|
|
1429
|
+
`assets/personas/${file} 的扩展名不在宿主白名单内`
|
|
1430
|
+
)
|
|
1431
|
+
}
|
|
1432
|
+
// 目录里不得有多余文件(拼错的名字永远不会被用到,属于静默垃圾)
|
|
1433
|
+
const allowed = new Set([
|
|
1434
|
+
...names.flatMap((n) => ['.webp', '.png', '.jpg', '.jpeg', '.svg'].map((e) => `${n}${e}`)),
|
|
1435
|
+
// 两张兜底图:内置缺图 → 鲸鱼;自建预设 → 调色板(形态区分来源)
|
|
1436
|
+
'placeholder.svg',
|
|
1437
|
+
'custom-placeholder.webp'
|
|
1438
|
+
])
|
|
1439
|
+
const orphan = files.filter((f) => !allowed.has(f))
|
|
1440
|
+
assert.deepEqual(orphan, [], `assets/personas 下有未被使用到的文件(名字拼错?):${orphan.join(', ')}`)
|
|
1441
|
+
})
|
|
1442
|
+
|
|
1443
|
+
check('人设插画:竖版卡牌(插画为主视觉),自定义预设用专用默认贴图', () => {
|
|
1444
|
+
// 卡牌竖版 —— 插画占卡面顶部的正方形主视觉,名称/摘要压在其上,
|
|
1445
|
+
// 效果与按钮在下方的信息区。**所有卡片都出图**:内置按人设名查找,自建用宿主
|
|
1446
|
+
// 返回的专用默认贴图(调色板,与官方鲸鱼区分来源);图未就绪时露出渐变底。
|
|
1447
|
+
const cssBlock = clientCode.match(/const css = \[([\s\S]*?)\]\.join/)?.[1] ?? ''
|
|
1448
|
+
const card = cssBlock.match(/\.soul-persona-card\{([^}]*)\}/)?.[1] ?? ''
|
|
1449
|
+
assert.match(card, /flex-direction:column/, '卡牌应为纵向布局(竖版卡:上插画下信息)')
|
|
1450
|
+
// 卡片**不能**有 overflow:hidden —— CSS 规范规定 overflow != visible 会强制
|
|
1451
|
+
// transform-style: flat,真 3D 翻转会被压平。裁切职责已下沉到 face。
|
|
1452
|
+
assert.equal(
|
|
1453
|
+
/overflow/.test(card),
|
|
1454
|
+
false,
|
|
1455
|
+
'卡牌不得有 overflow(会强制 transform-style:flat,3D 翻转失效);裁切应交给 .soul-persona-face'
|
|
1456
|
+
)
|
|
1457
|
+
assert.match(
|
|
1458
|
+
cssBlock.match(/\.soul-persona-face\{([^}]*)\}/)?.[1] ?? '',
|
|
1459
|
+
/overflow:hidden/,
|
|
1460
|
+
'face 应承接裁切职责(overflow:hidden + border-radius:inherit)'
|
|
1461
|
+
)
|
|
1462
|
+
// 插画区:正方形 + 渐变兜底底
|
|
1463
|
+
const art = cssBlock.match(/\.soul-persona-art\{([^}]*)\}/)?.[1] ?? ''
|
|
1464
|
+
assert.ok(art !== '', '找不到 .soul-persona-art 规则')
|
|
1465
|
+
assert.match(art, /aspect-ratio:1\/1/, '插画区应为正方形(卡面主视觉)')
|
|
1466
|
+
assert.match(art, /width:100%/, '插画区应占满卡宽')
|
|
1467
|
+
assert.match(art, /background:linear-gradient/, '插画区应有渐变兜底底(图未就绪/加载失败时不留空白)')
|
|
1468
|
+
assert.match(art, /overflow:hidden/, '插画区应裁掉溢出部分')
|
|
1469
|
+
const img = cssBlock.match(/\.soul-persona-art-img\{([^}]*)\}/)?.[1] ?? ''
|
|
1470
|
+
assert.match(img, /object-fit:cover/, 'img 应用 object-fit:cover(否则非方形图会变形)')
|
|
1471
|
+
assert.match(img, /position:absolute/, 'img 应绝对定位铺满插画区')
|
|
1472
|
+
// 渐变遮罩:名称压在图上必须有对比度保证
|
|
1473
|
+
const veil = cssBlock.match(/\.soul-persona-art-veil\{([^}]*)\}/)?.[1] ?? ''
|
|
1474
|
+
assert.match(veil, /linear-gradient/, '插画底部应有渐变遮罩(否则浅色插画上白字看不清)')
|
|
1475
|
+
// 名称压在插画上,用主题的固定白(不是硬编码 #fff)
|
|
1476
|
+
const nameRule = cssBlock.match(/\.soul-persona-name\{([^}]*)\}/)?.[1] ?? ''
|
|
1477
|
+
assert.match(nameRule, /color:var\(--dsw-static-neutral-bluish-00/, '图上文字应用主题固定白 token(不得硬编码 #fff)')
|
|
1478
|
+
// 信息区:效果栏 + 操作区
|
|
1479
|
+
const body = cssBlock.match(/\.soul-persona-body\{([^}]*)\}/)?.[1] ?? ''
|
|
1480
|
+
assert.match(body, /flex-direction:column/, '信息区应纵向排列')
|
|
1481
|
+
const eff = cssBlock.match(/\.soul-persona-eff\{([^}]*)\}/)?.[1] ?? ''
|
|
1482
|
+
assert.ok(eff !== '', '找不到效果栏规则 .soul-persona-eff')
|
|
1483
|
+
assert.match(eff, /-webkit-line-clamp/, '效果栏应限制行数(卡面空间有限)')
|
|
1484
|
+
// 客户端侧:只给内置人设渲染 img,且带 onError 兜底与懒加载
|
|
1485
|
+
assert.match(
|
|
1486
|
+
clientSource,
|
|
1487
|
+
// 所有卡片都出图:内置走按名查找的插画,自建走宿主返回的专用默认贴图。
|
|
1488
|
+
// (此前只在 builtin 时才渲染 img —— 本版改为统一出图,仅样式调整。)
|
|
1489
|
+
/e\('img', \{[\s\S]{0,80}className: 'soul-persona-art-img'/,
|
|
1490
|
+
'所有预设都应渲染插画(自建预设由宿主返回专用默认贴图)'
|
|
1491
|
+
)
|
|
1492
|
+
assert.match(
|
|
1493
|
+
clientSource,
|
|
1494
|
+
/\/api\/soul\/persona-art\/\$\{encodeURIComponent\(name\)\}/,
|
|
1495
|
+
'插画 URL 应对人设名做 encodeURIComponent'
|
|
1496
|
+
)
|
|
1497
|
+
assert.match(clientSource, /onError: \(ev\) => \{ ev\.target\.style\.display = 'none' \}/, 'img 需要 onError 兜底(路由不可用时不显示破图)')
|
|
1498
|
+
assert.match(clientSource, /loading: 'lazy'/, '插画应懒加载(打开设置页时不必一次请求全部图)')
|
|
1499
|
+
// 效果栏文本来源:优先 customInstructions(人格差异的主要载体)
|
|
1500
|
+
assert.match(clientSource, /const personaEffectText = /, '缺少效果栏文本函数')
|
|
1501
|
+
})
|
|
1502
|
+
|
|
1503
|
+
check('操作区贴底对齐(摘要长短不一时按钮不错位)', () => {
|
|
1504
|
+
// 摘要(效果栏)行数可能 1~3 行,卡片高度固定 ⇒ 操作区必须贴底,
|
|
1505
|
+
// 否则效果栏短的那张卡按钮会浮在中间,与其它卡不齐。
|
|
1506
|
+
const cssBlock = clientCode.match(/const css = \[([\s\S]*?)\]\.join/)?.[1] ?? ''
|
|
1507
|
+
const actions = cssBlock.match(/\.soul-persona-actions\{([^}]*)\}/)?.[1] ?? ''
|
|
1508
|
+
assert.ok(/margin-top:auto/.test(actions), '操作区应 margin-top:auto 贴底,避免效果栏长短导致按钮错位')
|
|
1509
|
+
// 效果栏本身要能撑开剩余空间、且限制行数(保证卡片高度可预期)
|
|
1510
|
+
const eff = cssBlock.match(/\.soul-persona-eff\{([^}]*)\}/)?.[1] ?? ''
|
|
1511
|
+
assert.match(eff, /-webkit-line-clamp:(\d+)/, '效果栏应限制行数')
|
|
1512
|
+
const clamp = Number((eff.match(/-webkit-line-clamp:(\d+)/) ?? [])[1] ?? 0)
|
|
1513
|
+
assert.ok(clamp >= 2 && clamp <= 4, `效果栏行数应在 2~4 之间(当前 ${clamp})`)
|
|
1514
|
+
// 信息区应能纵向撑满卡片(否则 margin-top:auto 无参照)
|
|
1515
|
+
const body = cssBlock.match(/\.soul-persona-body\{([^}]*)\}/)?.[1] ?? ''
|
|
1516
|
+
assert.match(body, /flex:1/, '信息区应 flex:1 撑满卡片剩余高度')
|
|
1517
|
+
})
|
|
1518
|
+
|
|
1519
|
+
check('成功内联绿字 + 失败内联红字(宿主 savedNotice/error 同款),不用 toast', () => {
|
|
1520
|
+
// 查证(dsh-client-ui-settings-models/lib/client.js):宿主设置页**从不使用
|
|
1521
|
+
// toast**(Toast 引用 = 0)。成功与失败都是设置页内的一行内联文字,完全对称:
|
|
1522
|
+
// 成功 → .savedNotice(state-success-primary、12px/18px、role:status、
|
|
1523
|
+
// aria-live:polite),位于标题/简介之后、分组列表之前;
|
|
1524
|
+
// 失败 → .error(state-error-primary、12px/18px、role:alert),
|
|
1525
|
+
// 就地出现,共 11 处使用。
|
|
1526
|
+
// 因此本插件的成功(绿)与失败(红)也用同一形态承载,互斥显示。
|
|
1527
|
+
const cssBlock = clientCode.match(/const css = \[([\s\S]*?)\]\.join/)?.[1] ?? ''
|
|
1528
|
+
// ① 成功绿字
|
|
1529
|
+
assert.match(clientSource, /savedNotice && e\('p', \{/, '成功提示应为内联 <p>')
|
|
1530
|
+
assert.match(clientSource, /role: 'status'/, '成功绿字应有 role=status(宿主同款)')
|
|
1531
|
+
assert.match(clientSource, /'aria-live': 'polite'/, '成功绿字应有 aria-live=polite(宿主同款)')
|
|
1532
|
+
const notice = cssBlock.match(/\.soul-saved-notice\{([^}]*)\}/)?.[1] ?? ''
|
|
1533
|
+
assert.match(notice, /color:var\(--dsw-alias-state-success-primary\)/, '绿字颜色应为 state-success-primary(宿主同款)')
|
|
1534
|
+
assert.match(notice, /font-size:12px;line-height:18px/, '绿字规格应为 12px/18px(宿主同款)')
|
|
1535
|
+
// ② 失败红字(与绿字对称)
|
|
1536
|
+
assert.match(clientSource, /errorNotice && e\('p', \{/, '失败提示应为内联 <p>(宿主设置页不用 toast)')
|
|
1537
|
+
assert.match(clientSource, /role: 'alert'/, '失败红字应有 role=alert(宿主同款)')
|
|
1538
|
+
const errRule = cssBlock.match(/\.soul-error-notice\{([^}]*)\}/)?.[1] ?? ''
|
|
1539
|
+
assert.match(errRule, /color:var\(--dsw-alias-state-error-primary\)/, '红字颜色应为 state-error-primary(宿主同款)')
|
|
1540
|
+
assert.match(errRule, /font-size:12px;line-height:18px/, '红字规格应为 12px/18px(宿主同款)')
|
|
1541
|
+
// ③ 互斥:失败压过成功(用户要看最新一次操作的结果)
|
|
1542
|
+
assert.match(clientSource, /!errorNotice && savedNotice && e\('p', \{/, '失败时应只显示红字(成功绿字被压过)')
|
|
1543
|
+
assert.match(clientSource, /setSavedNotice\(null\)\s*\n\s*setErrorNotice\(text\)/, 'showError 应先清成功提示再写失败')
|
|
1544
|
+
// ④ 生命周期:绿字在开始编辑(dirty)时清除(宿主语义)
|
|
1545
|
+
assert.match(clientSource, /if \(dirty\) setSavedNotice\(null\)/, '绿字应在开始编辑(dirty)时清除(宿主语义)')
|
|
1546
|
+
// ⑤ 不得再有任何 toast(宿主设置页 Toast 引用 = 0)
|
|
1547
|
+
assert.equal(/ui\.Toast/.test(clientCode), false, '不应再引用 ui.Toast(宿主设置页从不使用 toast)')
|
|
1548
|
+
assert.equal(/\.soul-toast/.test(cssBlock), false, '不应残留自绘的 .soul-toast 样式')
|
|
1549
|
+
assert.equal(/showToast/.test(clientCode), false, '不应残留 showToast 调用(成功/失败都走内联文字)')
|
|
1550
|
+
// ⑥ 文案键:notice.*(成功)与 toast.*(失败)双语齐备
|
|
1551
|
+
for (const k of ['notice.saved', 'notice.noChanges', 'notice.reset', 'notice.resetRecovered', 'notice.personaSaved', 'notice.personaUsed', 'notice.personaDeleted']) {
|
|
1552
|
+
assert.ok(clientSource.includes(`'${k}':`), `缺少成功文案键 ${k}`)
|
|
1553
|
+
}
|
|
1554
|
+
for (const k of ['toast.saveFailed', 'toast.resetFailed', 'toast.personaPartial']) {
|
|
1555
|
+
assert.ok(clientSource.includes(`'${k}':`), `缺少失败文案键 ${k}`)
|
|
1556
|
+
}
|
|
1557
|
+
})
|
|
1558
|
+
|
|
1559
|
+
check('客户端的动效尊重 prefers-reduced-motion(光轨 / 圆点)', () => {
|
|
1560
|
+
const cssBlock = clientCode.match(/const css = \[([\s\S]*?)\]\.join/)?.[1] ?? ''
|
|
1561
|
+
const reduced = cssBlock.match(/@media \(prefers-reduced-motion:reduce\)\{([\s\S]*?)\}\}/g) ?? []
|
|
1562
|
+
assert.ok(reduced.length > 0, '客户端没有任何 prefers-reduced-motion 规则')
|
|
1563
|
+
const all = reduced.join('')
|
|
1564
|
+
// 自绘动效的元素必须自己停:光轨层、快捷开关圆点。
|
|
1565
|
+
// 提示条**不在此列** —— 已改用宿主的 ui.Toast,它的 Toast.module.css 自带
|
|
1566
|
+
// reduced-motion 分支(去掉滑入、保留延迟淡出)。所以这里反向断言:
|
|
1567
|
+
// 只要还存在 .soul-toast 规则,就说明有人在自绘提示条。
|
|
1568
|
+
for (const [needle, why] of [
|
|
1569
|
+
['.soul-trail-layer', '光轨流动'],
|
|
1570
|
+
['.soul-quick-toggle', '快捷开关圆点的脉冲']
|
|
1571
|
+
]) {
|
|
1572
|
+
assert.ok(all.includes(needle), `prefers-reduced-motion 未覆盖 ${needle}(${why})`)
|
|
1573
|
+
}
|
|
1574
|
+
assert.equal(
|
|
1575
|
+
/\.soul-toast/.test(cssBlock),
|
|
1576
|
+
false,
|
|
1577
|
+
'不应再自绘提示条(.soul-toast)—— 已改用宿主 ui.Toast,动效与 reduced-motion 由宿主负责'
|
|
574
1578
|
)
|
|
575
|
-
|
|
1579
|
+
})
|
|
1580
|
+
|
|
1581
|
+
check('客户端预设行摘要不读「关于你」', () => {
|
|
1582
|
+
// 行摘要显示「这个预设会改变什么」。身份字段已不属于预设,摘要再显示昵称就会
|
|
1583
|
+
// 与「应用它不会动昵称」自相矛盾。
|
|
1584
|
+
const block = clientCode.match(/const personaRowMeta = \(entry\) => \{([\s\S]*?)return parts\.join/)
|
|
1585
|
+
assert.ok(block, '缺少 personaRowMeta')
|
|
1586
|
+
for (const key of PROFILE_FIELDS) {
|
|
1587
|
+
assert.equal(block[1].includes(`row.${key}`), false, `预设行摘要不应读取 ${key}`)
|
|
1588
|
+
}
|
|
1589
|
+
// 反向防空过:摘要仍要读人设维度,否则「不读身份字段」会因为整个函数被掏空而假通过
|
|
1590
|
+
assert.ok(/row\.style/.test(block[1]), '预设行摘要仍应读 style')
|
|
576
1591
|
})
|
|
577
1592
|
|
|
578
1593
|
check('服务端四条写路径都拒绝内置名,读取路径都走合并库', () => {
|
|
579
1594
|
// HTTP 保存 / 删除各一处,/soul 命令的 save / del 各一处
|
|
580
|
-
const rejections =
|
|
1595
|
+
const rejections = indexCode.split('isBuiltinPersona(personaName)').length - 1
|
|
581
1596
|
assert.equal(rejections, 4, `内置名拒绝点应为 4 处(HTTP 与命令层各 2),实际 ${rejections} 处`)
|
|
582
|
-
assert.ok(
|
|
583
|
-
assert.ok(/resolvePersona\(personaName, config\.personas\)/.test(
|
|
584
|
-
assert.ok(/resolvePersona\(personaName, current\.personas\)/.test(
|
|
1597
|
+
assert.ok(indexCode.split('mergePersonas(').length - 1 >= 3, '预设库读取应统一走 mergePersonas')
|
|
1598
|
+
assert.ok(/resolvePersona\(personaName, config\.personas\)/.test(indexCode), '命令层 use 未走 resolvePersona')
|
|
1599
|
+
assert.ok(/resolvePersona\(personaName, current\.personas\)/.test(indexCode), 'HTTP use 未走 resolvePersona')
|
|
585
1600
|
})
|
|
586
1601
|
|
|
587
1602
|
check('匹配逻辑只在纯模块实现一份(不留本地重复实现)', () => {
|
|
588
1603
|
assert.equal(
|
|
589
|
-
|
|
1604
|
+
indexCode.includes('function declaredPersonaKeys'),
|
|
590
1605
|
false,
|
|
591
1606
|
'index.mjs 不应自带 declaredPersonaKeys——已下沉到 lib/personas.mjs,两份实现会漂移'
|
|
592
1607
|
)
|
|
593
|
-
assert.ok(/personaMatches\(/.test(
|
|
1608
|
+
assert.ok(/personaMatches\(/.test(indexCode), 'index.mjs 应调用 personaMatches 做 ★ 判定')
|
|
594
1609
|
})
|
|
595
1610
|
|
|
596
1611
|
// ---------- 提示词预览(草稿编译)----------
|
|
@@ -600,7 +1615,7 @@ check('匹配逻辑只在纯模块实现一份(不留本地重复实现)', (
|
|
|
600
1615
|
function promptCompilerSource() {
|
|
601
1616
|
const chunks = []
|
|
602
1617
|
for (const name of ['buildUserProfile', 'buildBehavior', 'compilePrompt']) {
|
|
603
|
-
const matched =
|
|
1618
|
+
const matched = indexCode.match(new RegExp(`function ${name}\\([\\s\\S]*?\\n\\}`))
|
|
604
1619
|
assert.ok(matched, `index.mjs 中找不到函数 ${name}`)
|
|
605
1620
|
chunks.push(matched[0])
|
|
606
1621
|
}
|
|
@@ -608,7 +1623,7 @@ function promptCompilerSource() {
|
|
|
608
1623
|
}
|
|
609
1624
|
|
|
610
1625
|
check('草稿预览端点复用保存路径的校验与编译,且不落盘、不注入', () => {
|
|
611
|
-
const matched =
|
|
1626
|
+
const matched = indexCode.match(/path: '\/api\/soul\/prompt\/preview'[\s\S]*?\n \}\)/)
|
|
612
1627
|
assert.ok(matched, '缺少 /api/soul/prompt/preview 端点')
|
|
613
1628
|
const body = matched[0]
|
|
614
1629
|
assert.ok(/sanitizeConfig\(parsed\.body\)/.test(body), '草稿预览未复用 sanitizeConfig(未校验的草稿会编出误导性提示词)')
|
|
@@ -619,11 +1634,14 @@ check('草稿预览端点复用保存路径的校验与编译,且不落盘、
|
|
|
619
1634
|
})
|
|
620
1635
|
|
|
621
1636
|
check('客户端「参与编译的字段」与宿主 compilePrompt 实际读取的字段一致', () => {
|
|
622
|
-
const block =
|
|
1637
|
+
const block = clientCode.match(/const PROMPT_FIELD_KEYS = \[([\s\S]*?)\]/)
|
|
623
1638
|
assert.ok(block, '客户端缺少 PROMPT_FIELD_KEYS')
|
|
624
1639
|
const clientKeys = [...block[1].matchAll(/'([^']+)'/g)].map((m) => m[1]).sort()
|
|
625
1640
|
|
|
626
|
-
|
|
1641
|
+
// 「参与编译的字段」由配置层单点定义(总开关 + 关于你 + 人设)。这里刻意**不**用
|
|
1642
|
+
// PERSONA_FIELDS —— 预设能覆盖的字段与编译读取的字段是两回事,混用会在改预设范围
|
|
1643
|
+
// 时把这条契约连带改错。
|
|
1644
|
+
const driven = new Set(PROMPT_INPUT_FIELDS)
|
|
627
1645
|
const hostKeys = [...new Set([...promptCompilerSource().matchAll(/config\.(\w+)/g)].map((m) => m[1]))]
|
|
628
1646
|
.filter((key) => driven.has(key))
|
|
629
1647
|
.sort()
|
|
@@ -636,23 +1654,162 @@ check('客户端「参与编译的字段」与宿主 compilePrompt 实际读取
|
|
|
636
1654
|
assert.ok(clientKeys.length >= 5, `参与编译的字段过少:${clientKeys.length}`)
|
|
637
1655
|
})
|
|
638
1656
|
|
|
1657
|
+
check('客户端复刻的枚举常量与宿主 lib/config.mjs 逐项一致', () => {
|
|
1658
|
+
// 浏览器模块表不允许 client/index.mjs import lib/config.mjs,所以客户端必须**复刻**
|
|
1659
|
+
// 一份常量;这些常量本身写着「与 lib/config.mjs 的合法值保持一致」,但那只是注释。
|
|
1660
|
+
// 漂移后果各不相同且都不报错:
|
|
1661
|
+
// - 少了取值:下拉框少一档,用户在界面上根本选不到该能力
|
|
1662
|
+
// - 多了取值:界面能选、宿主 sanitizeConfig 拒绝 ⇒ 保存报 400
|
|
1663
|
+
// - 换了取值:与宿主枚举错位,脏检查与保存载荷对不上
|
|
1664
|
+
// 因此这里从两端各取一次真相再比对:宿主用 import 到的真实值,客户端按源文本解析。
|
|
1665
|
+
const specs = [
|
|
1666
|
+
['STYLE_VALUES', STYLE_VALUES],
|
|
1667
|
+
['TRAIT_VALUES', TRAIT_VALUES],
|
|
1668
|
+
['REPLY_LENGTH_VALUES', REPLY_LENGTH_VALUES],
|
|
1669
|
+
['LANGUAGE_VALUES', LANGUAGE_VALUES]
|
|
1670
|
+
]
|
|
1671
|
+
for (const [name, hostValues] of specs) {
|
|
1672
|
+
const m = clientCode.match(new RegExp(`const ${name} = \\[([^\\]]*)\\]`))
|
|
1673
|
+
assert.ok(m, `客户端缺少 ${name}`)
|
|
1674
|
+
const clientValues = [...m[1].matchAll(/'([^']*)'/g)].map((x) => x[1])
|
|
1675
|
+
assert.ok(clientValues.length > 0, `客户端 ${name} 解析为空`)
|
|
1676
|
+
assert.deepEqual(
|
|
1677
|
+
clientValues,
|
|
1678
|
+
hostValues,
|
|
1679
|
+
`客户端 ${name} 与宿主不一致(客户端 ${JSON.stringify(clientValues)} / 宿主 ${JSON.stringify(hostValues)})`
|
|
1680
|
+
)
|
|
1681
|
+
}
|
|
1682
|
+
})
|
|
1683
|
+
|
|
1684
|
+
check('客户端 FIELD_KEYS 覆盖宿主全部配置字段(dirty 门控不漏字段)', () => {
|
|
1685
|
+
// FIELD_KEYS 驱动 dirty 判定与保存载荷。漏一个字段的后果是静默的:
|
|
1686
|
+
// 改那个字段后「保存」按钮可能仍被禁用(看起来像界面卡住),或保存结果被判成「无变化」。
|
|
1687
|
+
const m = clientCode.match(/const FIELD_KEYS = \[([^\]]*)\]/)
|
|
1688
|
+
assert.ok(m, '客户端缺少 FIELD_KEYS')
|
|
1689
|
+
const clientKeys = [...m[1].matchAll(/'([^']*)'/g)].map((x) => x[1])
|
|
1690
|
+
const hostFields = Object.keys(DEFAULT_CONFIG).sort()
|
|
1691
|
+
assert.deepEqual(
|
|
1692
|
+
[...clientKeys].sort(),
|
|
1693
|
+
hostFields,
|
|
1694
|
+
'FIELD_KEYS 必须与宿主 DEFAULT_CONFIG 的字段集合完全相同(漏字段会让 dirty 检测失效)'
|
|
1695
|
+
)
|
|
1696
|
+
// 顺序无关紧要但重复有害:同一字段出现两次会让 dirty 判定重复计算。
|
|
1697
|
+
assert.equal(new Set(clientKeys).size, clientKeys.length, 'FIELD_KEYS 含重复字段')
|
|
1698
|
+
})
|
|
1699
|
+
|
|
1700
|
+
check('预设应用只有一条实现路径,且非法字段必须回报(HTTP 与 /soul use 共用)', () => {
|
|
1701
|
+
// 两条应用路径(POST /api/soul/personas/use 与 /soul use)此前各自内联一份
|
|
1702
|
+
// `sanitizeConfig(pickPersonaValues(entry))`,且都只取 patch、把 errors 丢掉 ⇒
|
|
1703
|
+
// 「部分应用」被报成成功(实测:style / emoji 被静默丢弃,仍返回 ok:true)。
|
|
1704
|
+
// 现已收口到 applyPersonaEntry。这里钉三件事:
|
|
1705
|
+
// ① 该校验全仓只允许出现在收口函数里(再写一份必漏 errors);
|
|
1706
|
+
// ② 两条路径都真的走收口函数;
|
|
1707
|
+
// ③ 收口函数把 errors 换算成 invalid / applied 回报,且调用方真的消费。
|
|
1708
|
+
// 一律匹配**去注释后**的源码:注释里写着「applied 是…」就能让裸 includes 恒真
|
|
1709
|
+
// (本条初版正是这么写的,被自己的变异实验抓到 —— 与 M5 同一漏洞类别)。
|
|
1710
|
+
const bare = stripComments(indexSource)
|
|
1711
|
+
const inlined = [...bare.matchAll(/sanitizeConfig\(pickPersonaValues\(entry\)\)/g)].length
|
|
1712
|
+
assert.equal(inlined, 1, `预设取值校验只应有一处实现(收口在 applyPersonaEntry),实际 ${inlined} 处`)
|
|
1713
|
+
const uses = [...bare.matchAll(/await applyPersonaEntry\(ctx, entry\)/g)].length
|
|
1714
|
+
assert.equal(uses, 2, `HTTP 与 /soul use 都应走 applyPersonaEntry,实际 ${uses} 处`)
|
|
1715
|
+
const body = stripComments(extractObject(indexSource, 'async function applyPersonaEntry'))
|
|
1716
|
+
assert.ok(body !== '', '找不到 applyPersonaEntry')
|
|
1717
|
+
for (const token of ['invalid', 'applied', 'Object.keys(errors)']) {
|
|
1718
|
+
assert.ok(body.includes(token), `applyPersonaEntry 未回报 ${token}:非法字段会被静默丢弃`)
|
|
1719
|
+
}
|
|
1720
|
+
assert.ok(/invalid\.length > 0/.test(bare), '命令路径未按 invalid 分支提示(usePartial / useAllInvalid)')
|
|
1721
|
+
assert.ok(/invalid \}/.test(bare), 'HTTP 响应未带出 invalid')
|
|
1722
|
+
// 双语文案表必须提供这两个键,否则部分应用时界面上没有对应文案
|
|
1723
|
+
for (const lang of ['zh', 'en']) {
|
|
1724
|
+
const keys = tableLangKeys(indexSource, 'COMMAND_MESSAGES', lang)
|
|
1725
|
+
for (const key of ['usePartial', 'useAllInvalid']) {
|
|
1726
|
+
assert.ok(keys.has(key), `COMMAND_MESSAGES.${lang} 缺少 ${key}`)
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
})
|
|
1730
|
+
|
|
1731
|
+
check('命令层的三处行为契约(覆盖列 / 键名大小写 / 昵称兜底)', () => {
|
|
1732
|
+
// ① `/soul list` 不得再显示预设的「关于你」:v0.7.1 起预设不含 nickname,
|
|
1733
|
+
// normalizePersonas 也会剥离它 ⇒ 显示 `昵称=${entry.nickname || '-'}` 恒为
|
|
1734
|
+
// `昵称=-`,是一列永远没有信息的死数据。改为报告覆盖了几个维度。
|
|
1735
|
+
const listBody = extractObject(indexCode, "if (first === 'list')")
|
|
1736
|
+
assert.ok(listBody !== '', '找不到 /soul list 分支')
|
|
1737
|
+
assert.equal(/entry\.nickname/.test(listBody), false, '/soul list 不得再读 entry.nickname(预设已不含该字段,会恒显示「-」)')
|
|
1738
|
+
assert.ok(/listCovers/.test(listBody), '/soul list 应报告覆盖的维度数(listCovers)')
|
|
1739
|
+
// ② 键名忽略大小写,且必须**映射回真实字段名**:直接 toLowerCase 会把 headingLists
|
|
1740
|
+
// 压成 headinglists 反而匹配不上白名单,所以查表还原(STYLE → style)。
|
|
1741
|
+
assert.ok(/COMMAND_FIELD_BY_LOWER/.test(indexCode), '缺少字段名小写索引表')
|
|
1742
|
+
assert.ok(/normalizeSetKey\(rawKey\)/.test(indexCode), '/soul set 未对键名做大小写归一化')
|
|
1743
|
+
assert.ok(
|
|
1744
|
+
/new Map\(Object\.keys\(DEFAULT_CONFIG\)\.map/.test(indexCode),
|
|
1745
|
+
'字段名索引表应由 DEFAULT_CONFIG 生成(手写列表会漂移)'
|
|
1746
|
+
)
|
|
1747
|
+
// ③ 昵称兜底是**刻意的设计**:非关键字输入整体视为昵称(/soul help 会写昵称)。
|
|
1748
|
+
// 这条断言把设计钉住,避免有人顺手加 help/status 关键字而无声改变语义。
|
|
1749
|
+
assert.ok(
|
|
1750
|
+
/!COMMAND_KEYWORDS\.has\(first\)[\s\S]{0,400}?sanitizeConfig\(\{ nickname: raw \}\)/.test(indexCode),
|
|
1751
|
+
'非关键字输入应整体视为昵称(该行为是设计,改动需同步文档与断言)'
|
|
1752
|
+
)
|
|
1753
|
+
for (const key of ['help', 'status', 'version']) {
|
|
1754
|
+
assert.equal(
|
|
1755
|
+
new RegExp(`COMMAND_KEYWORDS = new Set\\(\\[[^\\]]*'${key}'`).test(indexCode),
|
|
1756
|
+
false,
|
|
1757
|
+
`COMMAND_KEYWORDS 不应包含 ${key}:那会改变「非关键字即昵称」的既有语义`
|
|
1758
|
+
)
|
|
1759
|
+
}
|
|
1760
|
+
})
|
|
1761
|
+
|
|
1762
|
+
check('枚举值 trim 与文本字段同规则', () => {
|
|
1763
|
+
// 文本字段本来就 trim(见上面的 TEXT_FIELDS 断言),枚举不 trim 会让
|
|
1764
|
+
// `/soul set style= humorous`(= 后带空格)被拒,而 `bio= 张三` 却能成功 ——
|
|
1765
|
+
// 同一命令里两套规则。这里直接对真实 sanitizeConfig 断言行为。
|
|
1766
|
+
assert.equal(sanitizeConfig({ style: ' humorous' }).patch.style, 'humorous')
|
|
1767
|
+
assert.equal(sanitizeConfig({ replyLength: ' concise ' }).patch.replyLength, 'concise')
|
|
1768
|
+
assert.equal(sanitizeConfig({ trailSpeed: ' fast' }).patch.trailSpeed, 'fast')
|
|
1769
|
+
// 防空过:trim 不能把非法值放进来
|
|
1770
|
+
assert.ok(sanitizeConfig({ style: ' humorous ' }).errors.style === undefined)
|
|
1771
|
+
assert.ok(sanitizeConfig({ style: 'bogus' }).errors.style !== undefined, '非法枚举仍须被拒')
|
|
1772
|
+
assert.ok(sanitizeConfig({ style: '' }).errors.style !== undefined, '空串仍须被拒')
|
|
1773
|
+
})
|
|
1774
|
+
|
|
1775
|
+
check('客户端标题徽标的版本号与 package.json 一致', () => {
|
|
1776
|
+
// 宿主端已由下一条断言钉住「唯一来源 package.json」;客户端因为拿不到该模块系统,
|
|
1777
|
+
// 只能复刻一个字面量显示在标题右上角。这处漂移此前**完全无人看管**(实测:把它改成
|
|
1778
|
+
// 9.9.9 后整套 verify 依然全绿),而症状是界面上写着错误的版本号。
|
|
1779
|
+
const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
|
|
1780
|
+
const m = clientCode.match(/const VERSION = '([^']+)'/)
|
|
1781
|
+
assert.ok(m, '客户端缺少 VERSION 常量')
|
|
1782
|
+
assert.equal(
|
|
1783
|
+
m[1],
|
|
1784
|
+
manifest.version,
|
|
1785
|
+
`客户端标题徽标显示 v${m[1]},而 package.json 是 ${manifest.version}(升版本时漏改这一处)`
|
|
1786
|
+
)
|
|
1787
|
+
})
|
|
1788
|
+
|
|
1789
|
+
check('客户端版本号是唯一需要手工同步的常量(不再新增同类副本)', () => {
|
|
1790
|
+
// VERSION 是历史遗留的复刻点(上面一条断言已保证它与 package.json 一致)。
|
|
1791
|
+
// 除此之外不应再出现别的版本常量,否则每加一处就多一处漏改风险。
|
|
1792
|
+
const hits = [...clientSource.matchAll(/const\s+(?:PLUGIN_)?VERSION\s*=/g)].length
|
|
1793
|
+
assert.equal(hits, 1, `客户端版本常量应恰好 1 处(VERSION),实际 ${hits} 处`)
|
|
1794
|
+
})
|
|
1795
|
+
|
|
639
1796
|
check('客户端在有未保存编辑时预览草稿,否则预览已生效提示词', () => {
|
|
640
|
-
assert.ok(/async previewPrompt\(draft\)/.test(
|
|
641
|
-
assert.ok(/await controller\.previewPrompt\(promptDraft\(\)\)/.test(
|
|
642
|
-
assert.ok(/const promptDirty = PROMPT_FIELD_KEYS\.some\(/.test(
|
|
1797
|
+
assert.ok(/async previewPrompt\(draft\)/.test(clientCode), '控制器缺少 previewPrompt')
|
|
1798
|
+
assert.ok(/await controller\.previewPrompt\(promptDraft\(\)\)/.test(clientCode), '未调用草稿预览端点')
|
|
1799
|
+
assert.ok(/const promptDirty = PROMPT_FIELD_KEYS\.some\(/.test(clientCode), 'promptDirty 应由 PROMPT_FIELD_KEYS 判定(改光轨不该让预览变成草稿)')
|
|
643
1800
|
// 只断言下面的三元语法不够:把 asDraft 写死成 false 也能让三元看起来「正确」,
|
|
644
1801
|
// 但预览就永远只看已生效内容了。必须把「草稿判定来源于 promptDirty」这一环钉住。
|
|
645
|
-
assert.ok(/const asDraft = promptDirty\b/.test(
|
|
646
|
-
assert.ok(/asDraft\s*\?\s*await controller\.previewPrompt/.test(
|
|
1802
|
+
assert.ok(/const asDraft = promptDirty\b/.test(clientCode), 'asDraft 必须取自 promptDirty,否则预览会永远停留在已生效内容')
|
|
1803
|
+
assert.ok(/asDraft\s*\?\s*await controller\.previewPrompt/.test(clientCode), '草稿分支未按 asDraft 选择端点')
|
|
647
1804
|
})
|
|
648
1805
|
|
|
649
1806
|
check('提示词预览的编译入口唯一(避免重复请求与过期闭包)', () => {
|
|
650
1807
|
assert.equal(
|
|
651
|
-
|
|
1808
|
+
clientCode.includes('if (showPrompt) await loadPrompt()'),
|
|
652
1809
|
false,
|
|
653
1810
|
'保存/重置/应用预设处理器内不应再直接编译预览——刷新统一由 effect 驱动,两处调用会重复请求并可能用到过期闭包'
|
|
654
1811
|
)
|
|
655
|
-
const effect =
|
|
1812
|
+
const effect = clientCode.match(
|
|
656
1813
|
/const promptSettled = React\.useRef\(false\)[\s\S]*?\}, \[showPrompt, promptDraftKey, promptDirty\]\)/
|
|
657
1814
|
)
|
|
658
1815
|
assert.ok(effect, '缺少预览驱动 effect')
|
|
@@ -670,4 +1827,919 @@ check('提示词预览新增文案键中英各一份', () => {
|
|
|
670
1827
|
}
|
|
671
1828
|
})
|
|
672
1829
|
|
|
1830
|
+
// ---------- 系统提示词插值 ----------
|
|
1831
|
+
|
|
1832
|
+
check('系统提示词 section 显式关闭宿主插值', () => {
|
|
1833
|
+
// 用括号配平取 section(...) 的**整个选项对象**,而不是「anchor 起 1500 字符」的
|
|
1834
|
+
// 固定窗口:窗口式取法在注册点与选项之间多出约 1100 字符(注释或代码)后就会
|
|
1835
|
+
// 把 interpolate 推出窗口而假失败,且附近再出现一个 section 注册时可能锚错对象。
|
|
1836
|
+
const opts = stripComments(extractObject(indexSource, 'spCtx.systemPrompt.section('))
|
|
1837
|
+
assert.ok(opts !== '', '找不到 spCtx.systemPrompt.section(...) 调用')
|
|
1838
|
+
// 先确认锚对了对象(否则下面的断言可能对着别的 section 通过)
|
|
1839
|
+
assert.ok(/name:\s*'soul:persona'/.test(opts), 'section 选项对象里没有 soul:persona(锚点异常)')
|
|
1840
|
+
assert.ok(
|
|
1841
|
+
/interpolate:\s*false/.test(opts),
|
|
1842
|
+
'section 必须显式写 interpolate: false —— 宿主默认开启且是严格模式,' +
|
|
1843
|
+
'用户只要在「自定义指令」里写出一对完整的 {{...}} 就会让 renderPrompt 抛错;' +
|
|
1844
|
+
'该错位于 agent.step() 开头且无 try/catch ⇒ 该会话每一轮都失败'
|
|
1845
|
+
)
|
|
1846
|
+
assert.equal(/interpolate:\s*true/.test(opts), false, 'interpolate 不得为 true')
|
|
1847
|
+
})
|
|
1848
|
+
|
|
1849
|
+
// ---------- 配置持久化(原子写 / 损坏不覆盖)----------
|
|
1850
|
+
|
|
1851
|
+
const storeSource = readFileSync(new URL('../lib/store.mjs', import.meta.url), 'utf8')
|
|
1852
|
+
const storeCode = stripComments(storeSource)
|
|
1853
|
+
|
|
1854
|
+
check('配置读取区分「文件不存在」与「文件损坏」,失败时不缓存', () => {
|
|
1855
|
+
const matched = indexCode.match(/async function loadConfig\(\)[\s\S]*?\n\}/)
|
|
1856
|
+
assert.ok(matched, 'index.mjs 中找不到 loadConfig')
|
|
1857
|
+
const body = matched[0]
|
|
1858
|
+
assert.ok(/readConfigFile\(/.test(body), 'loadConfig 应经由 lib/store.mjs 读取(失败类型的判定在那里)')
|
|
1859
|
+
assert.ok(/configLoadFailure = failure/.test(body), 'loadConfig 必须记录失败,供写入路径拒绝保存')
|
|
1860
|
+
assert.ok(/return \{ \.\.\.DEFAULT_CONFIG \}/.test(body), '失败时回退默认值(保证插件与设置页仍可用)')
|
|
1861
|
+
|
|
1862
|
+
const failBlock = body.match(/if \(failure\) \{[\s\S]*?\n \}/)
|
|
1863
|
+
assert.ok(failBlock, '找不到 loadConfig 的失败分支')
|
|
1864
|
+
assert.equal(
|
|
1865
|
+
/configCache\s*=/.test(failBlock[0]),
|
|
1866
|
+
false,
|
|
1867
|
+
'失败分支不得写入 configCache:缓存了默认值,用户修好文件后不重启就恢复不了'
|
|
1868
|
+
)
|
|
1869
|
+
})
|
|
1870
|
+
|
|
1871
|
+
check('「是否允许写入」的判定只在 lib/store.mjs 实现一份', () => {
|
|
1872
|
+
assert.equal(
|
|
1873
|
+
/function configWriteRefusal|function describeConfigFailure/.test(indexCode),
|
|
1874
|
+
false,
|
|
1875
|
+
'index.mjs 不应自带失败说明 / 拒绝判定的实现——写入路径有四条,本地再写一份必然漂移'
|
|
1876
|
+
)
|
|
1877
|
+
assert.ok(
|
|
1878
|
+
/configWriteRefusal\(configLoadFailure, configPath\(\)\)/.test(indexCode),
|
|
1879
|
+
'saveConfig 应调用 configWriteRefusal 决定是否拒绝写入'
|
|
1880
|
+
)
|
|
1881
|
+
})
|
|
1882
|
+
|
|
1883
|
+
check('配置写入只有一条路径:lib/store.mjs 的「临时文件 + rename」', () => {
|
|
1884
|
+
assert.equal(
|
|
1885
|
+
/from 'node:fs\/promises'/.test(indexCode),
|
|
1886
|
+
false,
|
|
1887
|
+
'index.mjs 不应直接操作文件系统——原子写必须收口在 lib/store.mjs,否则会被绕过'
|
|
1888
|
+
)
|
|
1889
|
+
assert.ok(/await writeConfigFile\(configPath\(\), clean\)/.test(stripComments(indexSource)), 'saveConfig 应经由 writeConfigFile 写入(匹配去注释后的源码:注释里留字样不算)')
|
|
1890
|
+
assert.ok(
|
|
1891
|
+
/writeFile\(tmpPath[\s\S]*?rename\(tmpPath, filePath\)/.test(storeCode),
|
|
1892
|
+
'lib/store.mjs 的写入必须是「先写 .tmp、再 rename 覆盖」;直接 writeFile 会在中断时留下截断的 JSON'
|
|
1893
|
+
)
|
|
1894
|
+
})
|
|
1895
|
+
|
|
1896
|
+
check('set_persona 的输出 schema 能被真实 dsh-tools 的 DSL 接受(注册不失败)', () => {
|
|
1897
|
+
// 背景:DSH 的 schema DSL 与 JSON Schema 不同 —— 根级不支持 required(作者错误
|
|
1898
|
+
// "schema.required is not supported by the value schema DSL")、object 属性必须显式
|
|
1899
|
+
// 声明 additionalProperties。两者都曾让 defineTool 抛错,再被 .catch() 吞成一条 warn
|
|
1900
|
+
// ⇒ set_persona 静默不注册(Agent 无法改人设),而 196 项断言全绿 —— 因为全链
|
|
1901
|
+
// 只有 dsh-llm 桩、从未用真实 DSL 校验过 schema。本条就是那个缺口。
|
|
1902
|
+
const toolCall = extractCall(indexSource, 'defineTool(')
|
|
1903
|
+
assert.ok(toolCall !== '', 'index.mjs 应调用 defineTool 注册 set_persona')
|
|
1904
|
+
// 结构层(去注释后):不得出现根级 required;对象属性必须带 additionalProperties
|
|
1905
|
+
const outputSchema = toolCall.slice(toolCall.indexOf('output:'))
|
|
1906
|
+
assert.equal(
|
|
1907
|
+
/required:\s*\[/.test(outputSchema),
|
|
1908
|
+
false,
|
|
1909
|
+
'set_persona 输出 schema 不得使用根级 required(DSH DSL 不支持,defineTool 会抛错且被静默吞掉)'
|
|
1910
|
+
)
|
|
1911
|
+
assert.ok(
|
|
1912
|
+
/ok:\s*\{[^}]*required:\s*true/.test(outputSchema),
|
|
1913
|
+
'set_persona 输出的 ok 必须用属性级 required: true 声明必填(DSL 的唯一写法)'
|
|
1914
|
+
)
|
|
1915
|
+
assert.ok(
|
|
1916
|
+
/changes:\s*\{[^{}]*additionalProperties:\s*false/.test(outputSchema.replace(/\s+/g, ' ')),
|
|
1917
|
+
'set_persona 输出的 changes(object)必须显式声明 additionalProperties: false'
|
|
1918
|
+
)
|
|
1919
|
+
// 行为层:本机能解析到真实 @deepseek-ai/dsh-tools 时,用**真实 defineTool** 校验
|
|
1920
|
+
// schema —— 这是「结构对了但 DSL 仍拒收」这类回归的唯一可靠判据。
|
|
1921
|
+
// (顶层 await 已解析;本机未装 DSH 时为 null,CI 上走结构层即可。)
|
|
1922
|
+
if (defineTool) {
|
|
1923
|
+
// 从真实源码整体抽出 defineTool({...}) 的**定义对象**并求值 —— 不用文本切片去抠
|
|
1924
|
+
// render 的函数体(那种写法对括号/箭头/注释极脆弱,初版就在这里求值失败)。
|
|
1925
|
+
// 抽出的对象含 parameters / output(schema + render)/ execute,求值后即为宿主
|
|
1926
|
+
// 实际会拿到的形状。
|
|
1927
|
+
const defText = extractObject(indexSource, 'defineTool(')
|
|
1928
|
+
assert.ok(defText !== '', '无法从 index.mjs 抽出 defineTool 的定义对象')
|
|
1929
|
+
let definition
|
|
1930
|
+
try {
|
|
1931
|
+
// 定义对象的 enum 引用了宿主常量(STYLE_VALUES / TRAIT_VALUES / …),求值时注入
|
|
1932
|
+
// 真实 import 值 —— 它们本身已由上面「客户端复刻常量 == 宿主定义」那条断言覆盖。
|
|
1933
|
+
// eslint-disable-next-line no-new-func
|
|
1934
|
+
definition = new Function(
|
|
1935
|
+
'STYLE_VALUES', 'TRAIT_VALUES', 'REPLY_LENGTH_VALUES', 'LANGUAGE_VALUES',
|
|
1936
|
+
`return (${defText})`
|
|
1937
|
+
)(STYLE_VALUES, TRAIT_VALUES, REPLY_LENGTH_VALUES, LANGUAGE_VALUES)
|
|
1938
|
+
} catch (err) {
|
|
1939
|
+
assert.fail(`defineTool 的定义对象求值失败:${err.message}`)
|
|
1940
|
+
}
|
|
1941
|
+
assert.ok(definition?.output?.schema, '定义对象缺少 output.schema')
|
|
1942
|
+
assert.equal(
|
|
1943
|
+
typeof definition.output.render,
|
|
1944
|
+
'function',
|
|
1945
|
+
'set_persona 的 output 必须提供 render(宿主每次工具成功执行后都会无条件调用它)'
|
|
1946
|
+
)
|
|
1947
|
+
|
|
1948
|
+
let tool = null
|
|
1949
|
+
assert.doesNotThrow(
|
|
1950
|
+
() => {
|
|
1951
|
+
tool = defineTool({
|
|
1952
|
+
name: 'set_persona',
|
|
1953
|
+
description: 'verify-config schema 契约探针',
|
|
1954
|
+
parameters: definition.parameters,
|
|
1955
|
+
output: definition.output,
|
|
1956
|
+
async execute() { return { ok: true } }
|
|
1957
|
+
})
|
|
1958
|
+
},
|
|
1959
|
+
'真实 dsh-tools 的 defineTool 拒绝了 set_persona 的输出 schema(工具将静默注册失败)'
|
|
1960
|
+
)
|
|
1961
|
+
|
|
1962
|
+
// 行为层第二段:逐种返回值走一遍 render —— 复刻宿主 createSuccessResult 的必经一步。
|
|
1963
|
+
// 缺 render 时 defineTool 仍会把 undefined 包成函数 ⇒ 注册照样通过,但每次成功
|
|
1964
|
+
// 调用都在渲染阶段抛 `TypeError: userRender is not a function`(被包成
|
|
1965
|
+
// projectionError("render"))—— 这正是「断言只到注册、没到能用」漏掉的那一步。
|
|
1966
|
+
for (const value of [
|
|
1967
|
+
{ ok: true, message: '已更新' },
|
|
1968
|
+
{ ok: true, message: '提议待确认', pending: true, changes: { style: 'casual' } },
|
|
1969
|
+
{ ok: false, message: '操作失败:字段非法' },
|
|
1970
|
+
{ ok: true, message: '配置无变化,未做修改' }
|
|
1971
|
+
]) {
|
|
1972
|
+
let rendered
|
|
1973
|
+
assert.doesNotThrow(
|
|
1974
|
+
() => { rendered = tool.output.render({}, value) },
|
|
1975
|
+
`工具结果渲染失败(值:${JSON.stringify(value)})—— 宿主每次成功执行都会走这里`
|
|
1976
|
+
)
|
|
1977
|
+
assert.ok(Array.isArray(rendered) && rendered.length > 0, 'output.render 应返回非空数组(DSH 内容块约定)')
|
|
1978
|
+
assert.equal(rendered[0].type, 'text', 'output.render 的首个内容块应为 text')
|
|
1979
|
+
assert.equal(typeof rendered[0].text, 'string', 'output.render 的 text 必须是字符串')
|
|
1980
|
+
assert.ok(rendered[0].text.length > 0, 'output.render 不得返回空文本(模型会看不到任何反馈)')
|
|
1981
|
+
}
|
|
1982
|
+
return
|
|
1983
|
+
}
|
|
1984
|
+
if (!skipNote('verify-config', 1, '本机未安装 @deepseek-ai/dsh-tools,set_persona 仅做结构层校验(行为层需真实 DSL)', '安装 DSH 后重跑即可覆盖行为层')) {
|
|
1985
|
+
failures.push({ name: 'set_persona schema 行为层(需真实 dsh-tools)', err: new Error('--strict 下跳过按失败处理') })
|
|
1986
|
+
}
|
|
1987
|
+
})
|
|
1988
|
+
|
|
1989
|
+
check('set_persona 的模型侧描述必须中英并列', () => {
|
|
1990
|
+
// 工具 schema 只在 apply 时注册一次,而宿主 registry 对同名重注册会抛
|
|
1991
|
+
// `tool "set_persona" is already registered`(实测 dsh-tools 源码)⇒ 语言切换时
|
|
1992
|
+
// **无法**原地换描述。所以描述必须中英并列,覆盖 zh / en 两种界面语言。
|
|
1993
|
+
//
|
|
1994
|
+
// 只检查 **parameters** 块里的描述:output.schema 的 description 是给宿主/渲染层
|
|
1995
|
+
// 用的结构化标注(如「实际发生变化的字段」),不是模型读的自然语言说明 —— 把它
|
|
1996
|
+
// 一并纳入会误报(本条初版就这么写的,被断言自己抓出来了)。
|
|
1997
|
+
const toolCall = stripComments(extractCall(indexSource, 'defineTool('))
|
|
1998
|
+
assert.ok(toolCall !== '', '找不到 defineTool 调用')
|
|
1999
|
+
const paramsStart = toolCall.indexOf('parameters:')
|
|
2000
|
+
const paramsEnd = toolCall.indexOf('output:', paramsStart)
|
|
2001
|
+
assert.ok(paramsStart > 0 && paramsEnd > paramsStart, '无法定位 parameters 块')
|
|
2002
|
+
const params = toolCall.slice(paramsStart, paramsEnd)
|
|
2003
|
+
const descriptions = [...params.matchAll(/description:\s*\n?\s*((?:'[^']*'\s*\+?\s*)+)/g)]
|
|
2004
|
+
.map((m) => [...m[1].matchAll(/'([^']*)'/g)].map((x) => x[1]).join(''))
|
|
2005
|
+
.filter((text) => text.length > 0)
|
|
2006
|
+
assert.ok(descriptions.length >= 8, `模型侧描述条目过少:${descriptions.length}`)
|
|
2007
|
+
for (const text of descriptions) {
|
|
2008
|
+
assert.ok(/[\u4e00-\u9fff]/.test(text), `工具描述缺少中文:${text.slice(0, 40)}`)
|
|
2009
|
+
assert.ok(/[A-Za-z]{3,}/.test(text), `工具描述缺少英文(zh/en 两种界面都要能读):${text.slice(0, 40)}`)
|
|
2010
|
+
}
|
|
2011
|
+
})
|
|
2012
|
+
|
|
2013
|
+
check('配置损坏时上报原因(设置页不必等一次失败的保存才知道)', () => {
|
|
2014
|
+
assert.ok(
|
|
2015
|
+
/configError: configLoadFailure \? describeConfigFailure\(configLoadFailure\) : null/.test(indexCode),
|
|
2016
|
+
'GET /api/soul/config 应带回 configError,否则用户只会看到「人设无故被重置」'
|
|
2017
|
+
)
|
|
2018
|
+
assert.ok(
|
|
2019
|
+
/s\.error = typeof payload\.configError === 'string'[\s\S]{0,120}?typeof payload\.deliveryWarning === 'string'/.test(clientCode),
|
|
2020
|
+
'客户端应同时展示 configError(读不出来)与 deliveryWarning(送不到会话)——两者症状相同、处理方式不同'
|
|
2021
|
+
)
|
|
2022
|
+
})
|
|
2023
|
+
|
|
2024
|
+
// ---------- 损坏配置的逃生口(重置)----------
|
|
2025
|
+
|
|
2026
|
+
check('移除损坏配置只有一条实现:lib/store.mjs 的 moveAsideConfigFile', () => {
|
|
2027
|
+
assert.ok(
|
|
2028
|
+
/export async function moveAsideConfigFile\(filePath\)[\s\S]*?await rename\(filePath, target\)/.test(storeCode),
|
|
2029
|
+
'必须用 rename 把损坏文件移开——只 copyFile 保留原文件的话,读取仍判定为损坏,重置依然被拒'
|
|
2030
|
+
)
|
|
2031
|
+
assert.equal(
|
|
2032
|
+
/rename\(configPath\(\)|unlink\(configPath\(\)/.test(indexCode),
|
|
2033
|
+
false,
|
|
2034
|
+
'index.mjs 不应自己搬移/删除配置文件,收口在 lib/store.mjs'
|
|
2035
|
+
)
|
|
2036
|
+
})
|
|
2037
|
+
|
|
2038
|
+
check('重置是配置损坏时的逃生口:先移开损坏文件,再复用同一条写入路径', () => {
|
|
2039
|
+
const matched = indexCode.match(/async function resetConfigWithEscape\(\)[\s\S]*?\n\}/)
|
|
2040
|
+
assert.ok(matched, 'index.mjs 中找不到 resetConfigWithEscape')
|
|
2041
|
+
const body = matched[0]
|
|
2042
|
+
assert.ok(/moveAsideConfigFile\(configPath\(\)\)/.test(body), '重置应先移开损坏文件(=备份到 .corrupt)')
|
|
2043
|
+
assert.ok(/configLoadFailure = null/.test(body), '移开之后必须清掉失败标记,否则 saveConfig 仍会拒绝')
|
|
2044
|
+
assert.ok(/commitConfig\(/.test(body), '重置应复用 commitConfig,而不是另写一份「读—改—写」')
|
|
2045
|
+
assert.ok(
|
|
2046
|
+
/catch[\s\S]*?configLoadFailure = failure[\s\S]*?throw/.test(body),
|
|
2047
|
+
'写入失败必须恢复失败标记,否则会把「文件仍然不可用」误报成正常'
|
|
2048
|
+
)
|
|
2049
|
+
})
|
|
2050
|
+
|
|
2051
|
+
check('两个重置入口都走逃生口(HTTP 与斜杠命令)', () => {
|
|
2052
|
+
const route = indexCode.match(/path: '\/api\/soul\/config\/reset'[\s\S]*?\n \}\)/)
|
|
2053
|
+
assert.ok(route, '找不到 /api/soul/config/reset 端点')
|
|
2054
|
+
assert.ok(/resetConfigWithEscape\(\)/.test(route[0]), 'HTTP 重置必须走逃生口')
|
|
2055
|
+
assert.ok(/backupPath/.test(route[0]), 'HTTP 重置应回传备份路径,供界面如实说明数据去哪了')
|
|
2056
|
+
|
|
2057
|
+
const resetBranch = indexCode.match(/if \(first === 'reset'\)[\s\S]*?\n \}/)
|
|
2058
|
+
assert.ok(resetBranch, "找不到 /soul reset 分支")
|
|
2059
|
+
assert.ok(/resetConfigWithEscape\(\)/.test(resetBranch[0]), '/soul reset 必须走逃生口')
|
|
2060
|
+
assert.ok(/backupPath/.test(resetBranch[0]), '/soul reset 应告知备份位置')
|
|
2061
|
+
|
|
2062
|
+
assert.equal(
|
|
2063
|
+
/commitConfig\(current => defaultConfigPreservingPersonas\(current\)\)/.test(indexCode),
|
|
2064
|
+
false,
|
|
2065
|
+
'不得再有绕过逃生口的重置路径——否则损坏状态下会走到一条必然失败的写'
|
|
2066
|
+
)
|
|
2067
|
+
})
|
|
2068
|
+
|
|
2069
|
+
check('重置失败不得被报成成功', () => {
|
|
2070
|
+
const handler = clientCode.match(/const handleReset = async \(\) => \{[\s\S]*?\n \}/)
|
|
2071
|
+
assert.ok(handler, '找不到客户端的 handleReset')
|
|
2072
|
+
assert.ok(/if \(!payload\)/.test(handler[0]), 'handleReset 必须区分失败(resetConfig 失败时返回 undefined)')
|
|
2073
|
+
assert.ok(/toast\.resetFailed/.test(handler[0]), '失败时应用失败提示,而不是无条件报「已重置」')
|
|
2074
|
+
assert.ok(/payload\.backupPath/.test(handler[0]), '有备份时应提示「已备份」,避免用户以为数据凭空消失')
|
|
2075
|
+
})
|
|
2076
|
+
|
|
2077
|
+
// ---------- 配置生效链路的可观测性 ----------
|
|
2078
|
+
|
|
2079
|
+
check('注入失败必须留下可观测记录,不得静默返回', () => {
|
|
2080
|
+
const matched = indexCode.match(/function injectPromptToAllAgents\(ctx, config\)[\s\S]*?\n\}/)
|
|
2081
|
+
assert.ok(matched, 'index.mjs 中找不到 injectPromptToAllAgents')
|
|
2082
|
+
const body = matched[0]
|
|
2083
|
+
assert.ok(/deliveryState\.injectionProblem = reason/.test(body), 'agents 不可用时必须记录原因')
|
|
2084
|
+
assert.equal(
|
|
2085
|
+
/if \(!agents \|\| typeof agents\.list !== 'function'\) \{\s*return\s*\}/.test(body),
|
|
2086
|
+
false,
|
|
2087
|
+
'agents 不可用不得直接裸 return——这正是「改了人设不生效却毫无线索」的来源'
|
|
2088
|
+
)
|
|
2089
|
+
assert.ok(/warnOnce\(/.test(body), '应经 logger 告警(同因只告一次,避免刷屏)')
|
|
2090
|
+
assert.ok(/delivered\+\+/.test(body) && /failed\+\+/.test(body), '应如实统计成功与失败数')
|
|
2091
|
+
assert.equal(/console\.(warn|error)\(/.test(body), false, '不要用被注释掉的 console——统一走 ctx.logger')
|
|
2092
|
+
})
|
|
2093
|
+
|
|
2094
|
+
check('诊断端点 /api/soul/status 暴露两条通道', () => {
|
|
2095
|
+
const matched = indexCode.match(/path: '\/api\/soul\/status'[\s\S]*?\n \}\)/)
|
|
2096
|
+
assert.ok(matched, '应注册 GET /api/soul/status')
|
|
2097
|
+
const body = matched[0]
|
|
2098
|
+
assert.ok(/channels:/.test(body), '应分别报告两条通道')
|
|
2099
|
+
assert.ok(/section:/.test(body) && /injection/.test(body), 'section 与 injection 都要有')
|
|
2100
|
+
assert.ok(/sectionRegisteredAt/.test(indexCode), 'section 的注册状态必须被记录')
|
|
2101
|
+
})
|
|
2102
|
+
|
|
2103
|
+
check('「送不到会话」与「读不出来」是两种问题,分开上报', () => {
|
|
2104
|
+
assert.ok(
|
|
2105
|
+
/deliveryWarning: deliveryProblemText\(config\)/.test(indexCode),
|
|
2106
|
+
'GET /api/soul/config 应带回 deliveryWarning'
|
|
2107
|
+
)
|
|
2108
|
+
assert.ok(
|
|
2109
|
+
/deliveryWarning: deliveryProblemText\(updated\)/.test(indexCode),
|
|
2110
|
+
'POST /api/soul/config 也应带回——保存成功但送不到会话时要让用户看见'
|
|
2111
|
+
)
|
|
2112
|
+
assert.ok(/deliveryProblemText\(config\)/.test(indexCode), '/soul show 应展示送达状态')
|
|
2113
|
+
assert.ok(/t\.deliveryLabel/.test(indexCode), '/soul show 应带「送达」标签行')
|
|
2114
|
+
// delivery* 文案键**只定义在 COMMAND_MESSAGES**(PROMPT_TEXT 里没有)。
|
|
2115
|
+
// deliveryProblemText 必须从 commandMessages 取表:此前误用 promptTextOf,
|
|
2116
|
+
// 'agents-service-unavailable' 分支返回 undefined(JSON 序列化丢字段,故障不可见)、
|
|
2117
|
+
// 'inject-failed' 分支抛 TypeError(保存已落盘却报 HTTP 500)—— 变异实验均实测复现。
|
|
2118
|
+
const dpt = stripComments(extractObject(indexSource, 'function deliveryProblemText'))
|
|
2119
|
+
assert.ok(dpt !== '', '找不到 deliveryProblemText 函数体')
|
|
2120
|
+
assert.ok(
|
|
2121
|
+
/commandMessages\(config\)/.test(dpt),
|
|
2122
|
+
'deliveryProblemText 必须用 commandMessages 取文案(delivery* 键只在 COMMAND_MESSAGES;用 promptTextOf 会拿到 undefined 并在 inject-failed 时抛 TypeError)'
|
|
2123
|
+
)
|
|
2124
|
+
assert.equal(
|
|
2125
|
+
/promptTextOf\(config\)/.test(dpt),
|
|
2126
|
+
false,
|
|
2127
|
+
'deliveryProblemText 不得再从 PROMPT_TEXT 取 delivery 文案(该表没有这些键)'
|
|
2128
|
+
)
|
|
2129
|
+
// 两张表必须真的提供这两个键(防「改了取表方向、键又缺失」的二次回归)
|
|
2130
|
+
for (const table of ['COMMAND_MESSAGES']) {
|
|
2131
|
+
for (const lang of ['zh', 'en']) {
|
|
2132
|
+
const keys = tableLangKeys(indexSource, table, lang)
|
|
2133
|
+
for (const key of ['deliveryNoAgentsService', 'deliveryInjectFailed']) {
|
|
2134
|
+
assert.ok(
|
|
2135
|
+
keys.has(key),
|
|
2136
|
+
`${table}.${lang} 缺少 ${key}:deliveryProblemText 的两个分支都会拿不到文案`
|
|
2137
|
+
)
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
})
|
|
2142
|
+
|
|
2143
|
+
check('版本号不新增需手工同步的常量(唯一来源 package.json)', () => {
|
|
2144
|
+
assert.ok(
|
|
2145
|
+
/createRequire\(import\.meta\.url\)\('\.\/package\.json'\)\.version/.test(indexCode),
|
|
2146
|
+
'host 端版本应直接取自 package.json'
|
|
2147
|
+
)
|
|
2148
|
+
assert.equal(
|
|
2149
|
+
/const\s+(PLUGIN_)?VERSION\s*=\s*'0\./.test(indexCode),
|
|
2150
|
+
false,
|
|
2151
|
+
'不要再写一个版本常量——需要手工同步的地方已经有三处'
|
|
2152
|
+
)
|
|
2153
|
+
})
|
|
2154
|
+
|
|
2155
|
+
// ---------- 双语文案与校验链完整性 ----------
|
|
2156
|
+
|
|
2157
|
+
check('host 双语文案表逐键对齐(PROMPT_TEXT / COMMAND_MESSAGES)', () => {
|
|
2158
|
+
for (const table of ['PROMPT_TEXT', 'COMMAND_MESSAGES']) {
|
|
2159
|
+
const zh = tableLangKeys(indexSource, table, 'zh')
|
|
2160
|
+
const en = tableLangKeys(indexSource, table, 'en')
|
|
2161
|
+
assert.ok(zh.size > 10, `${table} 的 zh 键数异常:${zh.size}`)
|
|
2162
|
+
assert.deepEqual(
|
|
2163
|
+
[...zh].sort(),
|
|
2164
|
+
[...en].sort(),
|
|
2165
|
+
`${table} 的 zh / en 必须逐键对齐(少一个键就是另一种语言下少一句话)`
|
|
2166
|
+
)
|
|
2167
|
+
}
|
|
2168
|
+
})
|
|
2169
|
+
|
|
2170
|
+
check('client 双语文案表逐键对齐', () => {
|
|
2171
|
+
const zh = objectKeys(extractObject(clientSource, 'const zh = {'))
|
|
2172
|
+
const en = objectKeys(extractObject(clientSource, 'const en = {'))
|
|
2173
|
+
assert.ok(zh.size > 50, `client zh 键数异常:${zh.size}`)
|
|
2174
|
+
assert.deepEqual([...zh].sort(), [...en].sort(), 'client i18n 的 zh / en 必须逐键对齐')
|
|
2175
|
+
})
|
|
2176
|
+
|
|
2177
|
+
check('校验链与 --strict 链覆盖同一组脚本,且严格语义真在各脚本里实现', () => {
|
|
2178
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
|
|
2179
|
+
const entries = (cmd) => [...String(cmd).matchAll(/scripts\/([\w.-]+\.mjs)(\s+--strict)?/g)]
|
|
2180
|
+
.map((m) => ({ file: m[1], strict: Boolean(m[2]) }))
|
|
2181
|
+
const chain = entries(pkg.scripts['verify'])
|
|
2182
|
+
const strict = entries(pkg.scripts['verify:strict'])
|
|
2183
|
+
assert.ok(chain.length >= 5, `verify 链应覆盖全部脚本,实际 ${chain.length}`)
|
|
2184
|
+
assert.deepEqual(
|
|
2185
|
+
strict.map((e) => e.file),
|
|
2186
|
+
chain.map((e) => e.file),
|
|
2187
|
+
'verify:strict 必须与 verify 逐项同序,否则 --strict 会漏掉某些脚本'
|
|
2188
|
+
)
|
|
2189
|
+
assert.ok(
|
|
2190
|
+
strict.every((e) => e.strict),
|
|
2191
|
+
'verify:strict 的每一项都必须带 --strict,否则那一项仍会「跳过即通过」'
|
|
2192
|
+
)
|
|
2193
|
+
// 链覆盖必须有下限且与 scripts 目录对账:只比对「两条链互相同序」时,对称地删掉
|
|
2194
|
+
// 一个脚本(两条链一起删)两条链依然相等、CI 照样绿 —— 覆盖声明没有任何机器保证。
|
|
2195
|
+
const onDisk = readdirSync(new URL('../scripts', import.meta.url))
|
|
2196
|
+
.filter((f) => /^verify-[\w-]+\.mjs$/.test(f))
|
|
2197
|
+
.sort()
|
|
2198
|
+
assert.deepEqual(
|
|
2199
|
+
[...new Set(chain.map((e) => e.file))].sort(),
|
|
2200
|
+
onDisk,
|
|
2201
|
+
'verify 链必须覆盖 scripts 目录下的全部 verify-*.mjs(对称删脚本不应被静默放过)'
|
|
2202
|
+
)
|
|
2203
|
+
for (const { file } of [...chain, ...strict]) {
|
|
2204
|
+
const url = new URL(`../scripts/${file}`, import.meta.url)
|
|
2205
|
+
assert.ok(existsSync(url), `package.json 引用了不存在的脚本:${file}`)
|
|
2206
|
+
// 去注释后再匹配:把真实调用注释掉、在注释里留下同样字样,不应被视为满足。
|
|
2207
|
+
// (M5 类漏洞:正断言只查「字样出现」时,注释里的字样同样命中。)
|
|
2208
|
+
const src = stripComments(readFileSync(url, 'utf8'))
|
|
2209
|
+
// 光在 package.json 里写上 `--strict` 是不够的 —— 脚本必须真的实现那套语义:
|
|
2210
|
+
// ① 引入 skip-report(跳过与断言数的唯一实现);
|
|
2211
|
+
// ② 把「本脚本有多少断言」交给它,并**消费其结果**:跑完时
|
|
2212
|
+
// `if (!assertCount(...))`,或像 verify-compat 那样把 nominal(随环境浮动的
|
|
2213
|
+
// 检查数)交给 `skipExit(..., NOMINAL_ASSERTIONS, ...)`。两者取其一 ——
|
|
2214
|
+
// compat 的检查数取决于本机装了什么,精确相等不适用。
|
|
2215
|
+
// 只检测「字样出现」是不够的:调用被注释掉、或 assertCount 返回值被丢弃
|
|
2216
|
+
// (打印了不一致却仍 return 0),都曾实测绕过 —— 所以必须匹配**消费形态**。
|
|
2217
|
+
assert.ok(
|
|
2218
|
+
// 必须是一条**真正的 import 语句**,不能只在注释里出现这段路径 —— 否则把 import
|
|
2219
|
+
// 注掉、留下一行注释,这条断言照样通过(判断力对照实测过这个漏法)。
|
|
2220
|
+
/^\s*import\b[^\n]*from '\.\/lib\/skip-report\.mjs'/m.test(src),
|
|
2221
|
+
`${file} 必须真正 import ./lib/skip-report.mjs,否则跳过语义与断言数都无人守`
|
|
2222
|
+
)
|
|
2223
|
+
const consumesCount = /!\s*assertCount\(/.test(src)
|
|
2224
|
+
const skipExitCall = extractCall(src, 'skipExit(')
|
|
2225
|
+
const reportsNominal = skipExitCall !== '' && /NOMINAL_ASSERTIONS/.test(skipExitCall)
|
|
2226
|
+
assert.ok(
|
|
2227
|
+
consumesCount || reportsNominal,
|
|
2228
|
+
`${file} 必须把断言数交给 skip-report 并消费其结果(if (!assertCount(...)) 或 skipExit(..., NOMINAL_ASSERTIONS, ...));返回值被丢弃或只剩注释均不算`
|
|
2229
|
+
)
|
|
2230
|
+
if (reportsNominal && !consumesCount) {
|
|
2231
|
+
assert.ok(
|
|
2232
|
+
/const\s+NOMINAL_ASSERTIONS\s*=/.test(src),
|
|
2233
|
+
`${file} 引用 NOMINAL_ASSERTIONS 但未声明该常量`
|
|
2234
|
+
)
|
|
2235
|
+
}
|
|
2236
|
+
assert.equal(
|
|
2237
|
+
/process\.argv\.includes\('--strict'\)/.test(src),
|
|
2238
|
+
false,
|
|
2239
|
+
`${file} 不得自己解析 --strict(必须走 skip-report 的 strictMode(),否则两处判定会漂移)`
|
|
2240
|
+
)
|
|
2241
|
+
}
|
|
2242
|
+
})
|
|
2243
|
+
|
|
2244
|
+
check('删除预设用内联二次确认,不得用原生 confirm', () => {
|
|
2245
|
+
// 原生 window.confirm 在 webview 里可能把文档焦点留在异常状态 —— 症状是
|
|
2246
|
+
// 「页面看得见却点不动、下拉框与输入框都失效」,最小化再恢复才好(用户实测)。
|
|
2247
|
+
// 宿主自己的设置页也不用原生 confirm。故改为内联二次确认。
|
|
2248
|
+
assert.equal(
|
|
2249
|
+
/globalThis\.confirm\s*\(|window\.confirm\s*\(/.test(clientCode),
|
|
2250
|
+
false,
|
|
2251
|
+
'不得调用原生 confirm(webview 里会留下焦点异常,改用内联二次确认)'
|
|
2252
|
+
)
|
|
2253
|
+
// 必须存在待确认状态,且第一次点击只进入待确认、不真删
|
|
2254
|
+
assert.match(clientSource, /const \[pendingDelete, setPendingDelete\] = React\.useState\(null\)/, '缺少 pendingDelete 状态')
|
|
2255
|
+
assert.match(
|
|
2256
|
+
clientSource,
|
|
2257
|
+
/if \(pendingDelete !== name\) \{[\s\S]{0,80}?setPendingDelete\(name\)[\s\S]{0,40}?return/,
|
|
2258
|
+
'首次点击应只进入待确认态并返回(不得直接删除)'
|
|
2259
|
+
)
|
|
2260
|
+
// 必须有超时复位兜底:否则「确认删除?」会一直挂着,用户过一会儿随手一点就真删了
|
|
2261
|
+
assert.match(
|
|
2262
|
+
clientSource,
|
|
2263
|
+
/setTimeout\(\(\) => setPendingDelete\(null\), \d+\)/,
|
|
2264
|
+
'二次确认需要超时复位(否则待确认态会一直挂着,容易误删)'
|
|
2265
|
+
)
|
|
2266
|
+
// 按钮要有可见的待确认反馈(文案 + 独立类名)
|
|
2267
|
+
assert.match(clientSource, /t\('personas\.confirmDeleteShort'\)/, '待确认态按钮缺少文案')
|
|
2268
|
+
const cssBlock = clientCode.match(/const css = \[([\s\S]*?)\]\.join/)?.[1] ?? ''
|
|
2269
|
+
assert.ok(/soul-persona-danger-armed/.test(cssBlock), '待确认态按钮应有独立样式(描边→实心危险色),否则与普通「删除」无从区分')
|
|
2270
|
+
const armed = cssBlock.match(/\.soul-persona-danger-armed\{([^}]*)\}/)?.[1] ?? ''
|
|
2271
|
+
assert.match(armed, /state-error-primary/, '待确认态应使用实心危险色')
|
|
2272
|
+
// 中英文案键各一份
|
|
2273
|
+
assert.match(clientSource, /'personas\.confirmDeleteShort': '确认删除?'/, '缺中文待确认文案')
|
|
2274
|
+
assert.match(clientSource, /'personas\.confirmDeleteShort': 'Confirm delete\?'/, '缺英文待确认文案')
|
|
2275
|
+
})
|
|
2276
|
+
|
|
2277
|
+
|
|
2278
|
+
check('set_persona 接入宿主原生审批(三态 + 文本退化)', () => {
|
|
2279
|
+
// 需求:确认模式不要再让用户手敲 /soul confirm,而是走宿主原生的审批面板
|
|
2280
|
+
//(输入框处的「允许一次 / 拒绝」)。
|
|
2281
|
+
//
|
|
2282
|
+
// 为什么复用宿主审批:面板、键盘(Enter 允许 / Esc 拒绝)、读屏、一次性授权
|
|
2283
|
+
// 语义都由宿主实现,且 ask/decided 会写入会话审计日志。自绘弹框要把这些全部
|
|
2284
|
+
// 重做一遍且必然有差异。
|
|
2285
|
+
//
|
|
2286
|
+
// 关键:**客户端无需注册 answerer** —— dsh-web-app 的默认依赖里已包含
|
|
2287
|
+
// @deepseek-ai/dsh-client-ui-approval,它自己注册了 approval/request 的应答者。
|
|
2288
|
+
// 本插件只作为 asker 发起请求。
|
|
2289
|
+
//
|
|
2290
|
+
// 三态:
|
|
2291
|
+
// allowed-once → 'allowed' ⇒ 继续落盘
|
|
2292
|
+
// rejected → 'rejected' ⇒ 不落盘并如实告知
|
|
2293
|
+
// cancelled / unavailable → 'unavailable' ⇒ 退化到 /soul confirm 文本流程
|
|
2294
|
+
|
|
2295
|
+
// ① 工具 execute 必须接住第二个参数 exec(agent / callId / signal 都在里面)
|
|
2296
|
+
assert.match(
|
|
2297
|
+
indexCode,
|
|
2298
|
+
/async execute\(args, exec\) \{/,
|
|
2299
|
+
'execute 必须接住 exec 参数(否则拿不到 agent,审批无法路由到会话)'
|
|
2300
|
+
)
|
|
2301
|
+
// ② 服务解析:多候选(agent 的 agent-scoped ctx 优先,注册工具的 ctx 兜底),
|
|
2302
|
+
// 任一命中即用;全部拿不到则退化。
|
|
2303
|
+
//
|
|
2304
|
+
// 实测教训:最初用 registerTools 的**根 ctx** 取服务,ctx.get('approval') 返回
|
|
2305
|
+
// undefined ⇒ 每次都退化成文本流程,看起来像「功能没生效」。
|
|
2306
|
+
// 权威写法见 dsh-tools 自己的 serviceAsk:用持有 tools 的 ctx 取审批服务。
|
|
2307
|
+
assert.match(indexCode, /\.get\('approval'\)/, '应通过 get(\'approval\') 取审批服务')
|
|
2308
|
+
assert.match(
|
|
2309
|
+
indexCode,
|
|
2310
|
+
/const candidates = \[\]/,
|
|
2311
|
+
'服务解析应逐候选尝试(单一 ctx 取不到时会静默退化)'
|
|
2312
|
+
)
|
|
2313
|
+
assert.match(
|
|
2314
|
+
indexCode,
|
|
2315
|
+
/if \(agent && typeof agent\.ctx === 'object'[^\n]*candidates\.push\(agent\.ctx\)/,
|
|
2316
|
+
'agent 的 agent-scoped ctx 应作为首选候选(审批需路由到该 agent 的会话)'
|
|
2317
|
+
)
|
|
2318
|
+
assert.match(
|
|
2319
|
+
indexCode,
|
|
2320
|
+
/if \(ctx && typeof ctx\.get === 'function'\) candidates\.push\(ctx\)/,
|
|
2321
|
+
'注册工具的 ctx 应作为兜底候选'
|
|
2322
|
+
)
|
|
2323
|
+
// 调用处必须传「注册工具的 ctx」而不是 apply 的根 ctx
|
|
2324
|
+
assert.match(
|
|
2325
|
+
indexCode,
|
|
2326
|
+
/requestToolApproval\(toolsCtx, exec, proposalChanges\)/,
|
|
2327
|
+
'审批调用应传 toolsCtx(注册工具的 ctx),传根 ctx 会取不到审批服务'
|
|
2328
|
+
)
|
|
2329
|
+
// ③ agent 缺失时必须退化而不是硬发请求 —— 宿主客户端靠 agent 解析会话归属,
|
|
2330
|
+
// 拿不到会放弃应答(dsh-client-ui-approval 里 scopeOf(owner) === undefined ⇒ next())
|
|
2331
|
+
assert.match(
|
|
2332
|
+
indexCode,
|
|
2333
|
+
/const agent = exec && exec\.agent\s*\n\s*if \(!agent\) return 'unavailable'/,
|
|
2334
|
+
'exec.agent 缺失时必须退化为文本流程(否则审批必然无人应答)'
|
|
2335
|
+
)
|
|
2336
|
+
// ④ 三态映射:只有 allowed-once 算批准;rejected 单独处理;其余(含 cancelled)退化
|
|
2337
|
+
// 逐字校验映射函数的**完整实现**(只钉片段会让「cancelled 被当作批准」这类
|
|
2338
|
+
// 严重语义错误漏网 —— 判断力验证实测)
|
|
2339
|
+
const mapAt = indexCode.indexOf('function approvalOutcomeOf(')
|
|
2340
|
+
assert.ok(mapAt > 0, '缺少 approvalOutcomeOf')
|
|
2341
|
+
const mapEnd = indexCode.indexOf('\n}', mapAt)
|
|
2342
|
+
const mapFn = indexCode.slice(mapAt, mapEnd > mapAt ? mapEnd : mapAt + 600)
|
|
2343
|
+
assert.match(mapFn, /if \(outcome === 'allowed-once'\) return 'allowed'/, 'allowed-once 应映射为 allowed')
|
|
2344
|
+
assert.match(mapFn, /if \(outcome === 'rejected'\) return 'rejected'/, 'rejected 应映射为 rejected')
|
|
2345
|
+
assert.match(mapFn, /return 'unavailable'/, '其余结果应归为 unavailable 以走退化')
|
|
2346
|
+
// 关键:只有 allowed-once 能返回 'allowed'(approvalOutcomeOf 内 'allowed' 只应出现一次)
|
|
2347
|
+
assert.equal(
|
|
2348
|
+
(mapFn.match(/return 'allowed'/g) || []).length,
|
|
2349
|
+
1,
|
|
2350
|
+
"approvalOutcomeOf 里只允许 allowed-once 返回 'allowed'(cancelled 若也返回 allowed,撤回的请求会被应用)"
|
|
2351
|
+
)
|
|
2352
|
+
assert.equal(
|
|
2353
|
+
/cancelled[^\n]*return 'allowed'/.test(mapFn),
|
|
2354
|
+
false,
|
|
2355
|
+
'cancelled 绝不能映射为 allowed'
|
|
2356
|
+
)
|
|
2357
|
+
// ⑤ 请求字段必须完整:agent 必传,callId / signal 有则传,reason + displayReason 齐备
|
|
2358
|
+
assert.match(indexCode, /toolName: 'set_persona'/, '审批请求应带上工具名')
|
|
2359
|
+
assert.match(indexCode, /displayReason: \{/, '应提供 displayReason(宿主按 UI 语言选取)')
|
|
2360
|
+
assert.ok(indexCode.includes("zh: '人设变更需要你的确认'"), '缺中文 displayReason')
|
|
2361
|
+
assert.ok(indexCode.includes("en: 'Persona change requires your confirmation'"), '缺英文 displayReason')
|
|
2362
|
+
// ⑥ 异常不得让人设变更彻底失败,必须退化
|
|
2363
|
+
// 用切片判断(catch 块内的注释行数不固定,跨行正则太脆)
|
|
2364
|
+
const fnAt = indexCode.indexOf('async function requestToolApproval')
|
|
2365
|
+
const fnEnd = indexCode.indexOf('\n}', fnAt)
|
|
2366
|
+
const approvalFn = indexCode.slice(fnAt, fnEnd > fnAt ? fnEnd : fnAt + 2000)
|
|
2367
|
+
const catchAt = approvalFn.indexOf('} catch {')
|
|
2368
|
+
assert.ok(catchAt > 0, 'requestToolApproval 应有 catch 兜底')
|
|
2369
|
+
assert.match(
|
|
2370
|
+
approvalFn.slice(catchAt),
|
|
2371
|
+
/return 'unavailable'/,
|
|
2372
|
+
'审批链路异常应退化为文本流程,而不是让变更失败'
|
|
2373
|
+
)
|
|
2374
|
+
// ⑦ 退化路径必须保留 /soul confirm 提议(纯 TUI / 无 Web UI 场景唯一的确认途径)
|
|
2375
|
+
assert.match(indexCode, /pendingPersonaProposal = \{ patch: proposalChanges/, '退化时必须保留 /soul confirm 提议')
|
|
2376
|
+
// ⑧ 拒绝与退化都要有对应文案,且中英各一份
|
|
2377
|
+
assert.match(indexCode, /return \{ ok: false, message: T0\.toolRejected \}/, 'rejected 应返回失败并说明原因')
|
|
2378
|
+
assert.equal((indexCode.match(/toolRejected:/g) || []).length, 2, 'toolRejected 文案应中英各一份')
|
|
2379
|
+
// ⑧b 策略预判:'rejected' 把两件事混在一起(用户真的拒绝 / 通道被策略禁用而无人被询问),
|
|
2380
|
+
// 必须在发起请求前先读策略,否则会向用户报告一件从未发生的事
|
|
2381
|
+
// (实测踩过:policy=never 时提示「你在审批面板中拒绝了」,而用户根本没看到面板)。
|
|
2382
|
+
assert.match(
|
|
2383
|
+
indexCode,
|
|
2384
|
+
/function resolveApprovalPolicy\(approval, agent\)/,
|
|
2385
|
+
'应能读取当前生效的审批策略(用于区分「通道禁用」与「用户拒绝」)'
|
|
2386
|
+
)
|
|
2387
|
+
// 算法必须与宿主 effectivePolicy 一致:overrideOf(session) ?? config.policy ?? 'ask'
|
|
2388
|
+
const policyFnAt = indexCode.indexOf('function resolveApprovalPolicy(')
|
|
2389
|
+
const policyFn = indexCode.slice(policyFnAt, policyFnAt + 900)
|
|
2390
|
+
assert.match(policyFn, /approval\.overrideOf\(session\)/, '策略算法应先读会话级覆盖')
|
|
2391
|
+
assert.match(policyFn, /approval\.config && approval\.config\.policy/, '再读部署默认策略')
|
|
2392
|
+
assert.match(policyFn, /return 'ask'/, "不可读时应保守回落到 'ask'(失败方向是「多问一次」而非「静默改配置」)")
|
|
2393
|
+
assert.match(
|
|
2394
|
+
indexCode,
|
|
2395
|
+
/if \(resolveApprovalPolicy\(approval, agent\) === 'never'\) return 'policy-disabled'/,
|
|
2396
|
+
"策略为 never 时应返回 policy-disabled(走文本确认),而不是让宿主的 'rejected' 冒充用户拒绝"
|
|
2397
|
+
)
|
|
2398
|
+
assert.equal((indexCode.match(/toolApprovalDisabled:/g) || []).length, 2, 'toolApprovalDisabled 文案应中英各一份')
|
|
2399
|
+
// ⑨ 确认模式关闭时不得调用审批
|
|
2400
|
+
const decideAt = indexCode.indexOf('const verdict = await requestToolApproval')
|
|
2401
|
+
const guardAt = indexCode.lastIndexOf('if (snapshot.requireToolConfirmation) {', decideAt)
|
|
2402
|
+
assert.ok(guardAt > 0 && guardAt < decideAt, '审批调用必须在 requireToolConfirmation 分支内')
|
|
2403
|
+
// 退化分支必须**真实可达**:判据是非 allowed/rejected 的结果,而不是恒假条件
|
|
2404
|
+
//(判断力验证实测:把判据改成 if (false) 能悄悄删掉退化路径,纯 TUI 场景
|
|
2405
|
+
// 就再也没有确认途径了)
|
|
2406
|
+
const branchSlice = indexCode.slice(decideAt, decideAt + 1400)
|
|
2407
|
+
assert.match(
|
|
2408
|
+
branchSlice,
|
|
2409
|
+
/if \(verdict === 'rejected'\)/,
|
|
2410
|
+
'应有 rejected 分支(用户拒绝 ⇒ 不落盘并告知)'
|
|
2411
|
+
)
|
|
2412
|
+
assert.match(
|
|
2413
|
+
branchSlice,
|
|
2414
|
+
/if \(verdict === 'unavailable' \|\| verdict === 'policy-disabled'\) \{/,
|
|
2415
|
+
'应有 unavailable / policy-disabled 分支(退化到 /soul confirm 文本流程,纯 TUI 场景唯一途径)'
|
|
2416
|
+
)
|
|
2417
|
+
// 通道被禁用时必须给出准确说明,不得谎称用户拒绝
|
|
2418
|
+
assert.match(
|
|
2419
|
+
branchSlice,
|
|
2420
|
+
/verdict === 'policy-disabled' \? `\$\{T0\.toolApprovalDisabled\}/,
|
|
2421
|
+
'policy-disabled 时应追加「通道被禁用」说明(此前会误报为「用户拒绝」)'
|
|
2422
|
+
)
|
|
2423
|
+
assert.equal(
|
|
2424
|
+
/if \(false\)/.test(branchSlice),
|
|
2425
|
+
false,
|
|
2426
|
+
'退化分支不得是恒假条件'
|
|
2427
|
+
)
|
|
2428
|
+
})
|
|
2429
|
+
|
|
2430
|
+
check('每个内置预设都声明全 6 个人格维度(language 豁免)', () => {
|
|
2431
|
+
// 声明列表同时驱动应用与匹配,两者需求相反:应用希望「少声明不越权」,
|
|
2432
|
+
// 匹配希望「全声明才严格」。早期只声明要改的维度 ⇒ 匹配退化成宽松比较:
|
|
2433
|
+
// 改一个未声明的特质后 ★ 仍挂着,且同一动作结果随预设而变(实测:改 emoji
|
|
2434
|
+
// 在「极简主义者」上丢 ★、在「苏格拉底式提问者」上不丢)⇒ 用户无法形成稳定预期。
|
|
2435
|
+
// 现统一为全量声明,语义单一:**改任何特质 ⇒ 不再属于该预设**。
|
|
2436
|
+
const DIMS = ['style', 'headingLists', 'emoji', 'tables', 'replyLength', 'customInstructions']
|
|
2437
|
+
const view = mergePersonas(null)
|
|
2438
|
+
for (const name of builtinPersonaNames()) {
|
|
2439
|
+
const declared = declaredPersonaKeys(view[name]).filter((k) => k !== 'language')
|
|
2440
|
+
for (const dim of DIMS) {
|
|
2441
|
+
assert.ok(declared.includes(dim), `${name} 未声明 ${dim}(全量声明才能保证「改任何特质都丢 ★」)`)
|
|
2442
|
+
}
|
|
2443
|
+
// language 仍必须豁免(跨语言共用的通用人格)
|
|
2444
|
+
assert.equal('language' in view[name], false, `${name} 不应声明 language(会把另一种语言的用户切走)`)
|
|
2445
|
+
}
|
|
2446
|
+
// 行为闭环:应用后匹配自己,改任何一个特质维度都失去匹配
|
|
2447
|
+
for (const name of builtinPersonaNames()) {
|
|
2448
|
+
const entry = view[name]
|
|
2449
|
+
const applied = { ...DEFAULT_CONFIG }
|
|
2450
|
+
for (const dim of DIMS) applied[dim] = entry[dim]
|
|
2451
|
+
assert.equal(personaMatches(entry, applied), true, `${name} 应用后应匹配自己`)
|
|
2452
|
+
for (const dim of DIMS) {
|
|
2453
|
+
if (dim === 'customInstructions') continue
|
|
2454
|
+
const edited = { ...applied, [dim]: applied[dim] === 'default' ? 'more' : 'default' }
|
|
2455
|
+
assert.equal(
|
|
2456
|
+
personaMatches(entry, edited),
|
|
2457
|
+
false,
|
|
2458
|
+
`${name}:改 ${dim} 后应失去匹配(★ 必须消失)`
|
|
2459
|
+
)
|
|
2460
|
+
}
|
|
2461
|
+
// language 改了不该丢 ★(豁免)
|
|
2462
|
+
assert.equal(
|
|
2463
|
+
personaMatches(entry, { ...applied, language: applied.language === 'zh' ? 'en' : 'zh' }),
|
|
2464
|
+
true,
|
|
2465
|
+
`${name}:改 language 不应失去匹配(内置预设不声明它)`
|
|
2466
|
+
)
|
|
2467
|
+
}
|
|
2468
|
+
})
|
|
2469
|
+
|
|
2470
|
+
check('手风琴不得裁剪向上弹出的提示(几何验证,非字符串检查)', () => {
|
|
2471
|
+
// 用户实测:Agent 工具分组「人设变更需确认」右侧的 hover 提示被上方遮住一部分。
|
|
2472
|
+
//
|
|
2473
|
+
// 根因:.soul-accordion{overflow:hidden}(本为把触发器的方形 hover 背景裁进圆角)。
|
|
2474
|
+
// 而该分组里提示是**第一个元素**,上方只有 14px 内边距,提示高约 72px 必然越界,
|
|
2475
|
+
// 越界部分被裁掉。实测数据(真实 Chrome):旧实现裁掉 19px,修复后 0px。
|
|
2476
|
+
//
|
|
2477
|
+
// 为什么不用字符串断言:有没有 overflow:hidden 不等于会不会被裁(取决于提示高度、
|
|
2478
|
+
// 上方留白、祖先链)。这类「视觉被遮挡」必须量几何。
|
|
2479
|
+
const cssBlock = clientCode.match(/const css = \[([\s\S]*?)\]\.join/)?.[1] ?? ''
|
|
2480
|
+
// ① 容器不得用 overflow:hidden 裁剪
|
|
2481
|
+
assert.equal(
|
|
2482
|
+
/\.soul-accordion\{[^}]*overflow:hidden/.test(cssBlock),
|
|
2483
|
+
false,
|
|
2484
|
+
'手风琴容器不得 overflow:hidden(会裁掉向上弹出的 .soul-hint-tip)'
|
|
2485
|
+
)
|
|
2486
|
+
// ② 不裁剪后,触发器必须自己补圆角(否则 hover 背景外溢成直角)
|
|
2487
|
+
assert.match(
|
|
2488
|
+
cssBlock.match(/\.soul-accordion-trigger\{([^}]*)\}/)?.[1] ?? '',
|
|
2489
|
+
/border-radius:calc\(var\(--dsw-radius-xl\) - \.5px\)/,
|
|
2490
|
+
'触发器须补顶圆角(替代容器的裁剪)'
|
|
2491
|
+
)
|
|
2492
|
+
// ③ 收起态只有一个触发器 ⇒ 四角都要圆
|
|
2493
|
+
assert.match(
|
|
2494
|
+
cssBlock,
|
|
2495
|
+
/\.soul-accordion:not\(\.soul-accordion-open\) \.soul-accordion-trigger\{border-radius:calc\(var\(--dsw-radius-xl\) - \.5px\)\}/,
|
|
2496
|
+
'收起态触发器四角都要圆'
|
|
2497
|
+
)
|
|
2498
|
+
})
|
|
2499
|
+
|
|
2500
|
+
check('底栏开关始终展示当前人设(未匹配时显示「自定义」)', () => {
|
|
2501
|
+
// 需求:底栏「个性化已启用」后面必须给出「现在用的是哪套人设」。
|
|
2502
|
+
// 此前只有匹配到预设才缀名字,未匹配(手改过特质)就留空 —— 等于没回答;
|
|
2503
|
+
// 而「未匹配预设」这种说法听着像报错。现在:有匹配显示预设名,否则「自定义」。
|
|
2504
|
+
assert.match(clientSource, /enabled && e\('span', \{ className: 'soul-quick-toggle-name' \}/, '底栏应始终(启用时)渲染人设名')
|
|
2505
|
+
assert.match(
|
|
2506
|
+
clientSource,
|
|
2507
|
+
/activePersona \|\| t\('quick\.custom'\)/,
|
|
2508
|
+
'未匹配到预设时应回退显示「自定义」(不能留空)'
|
|
2509
|
+
)
|
|
2510
|
+
// 关闭个性化时不显示(人设此刻不生效,挂名字会误导)
|
|
2511
|
+
assert.match(clientSource, /enabled && e\('span', \{ className: 'soul-quick-toggle-name' \}/, '关闭个性化时不得显示人设名')
|
|
2512
|
+
// 名字限宽(自建预设上限 30 字,不限宽会撑开底栏)
|
|
2513
|
+
const cssBlock = clientCode.match(/const css = \[([\s\S]*?)\]\.join/)?.[1] ?? ''
|
|
2514
|
+
assert.match(
|
|
2515
|
+
cssBlock.match(/\.soul-quick-toggle-name\{([^}]*)\}/)?.[1] ?? '',
|
|
2516
|
+
/max-width:9em/,
|
|
2517
|
+
'底栏人设名必须限宽(否则长名会撑开底栏)'
|
|
2518
|
+
)
|
|
2519
|
+
// 文案键双语齐备
|
|
2520
|
+
assert.ok(clientSource.includes("'quick.custom': '自定义'"), '缺中文「自定义」文案')
|
|
2521
|
+
assert.ok(clientSource.includes("'quick.custom': 'Custom'"), '缺英文「Custom」文案')
|
|
2522
|
+
})
|
|
2523
|
+
|
|
2524
|
+
check('配置偏离任何预设时,卡片区给出「存为新预设」出口', () => {
|
|
2525
|
+
// 全量声明后「改任何特质都丢 ★」⇒ activeName=null 成为常见状态。
|
|
2526
|
+
// 它诚实但没有出路:用户刚调出来的配置无处安放。故在预设分组里给
|
|
2527
|
+
// 一句话说明 + 直接打开命名弹层的入口。
|
|
2528
|
+
assert.match(clientSource, /activePersona === null && personaNames !== null && !personasError/, '偏离判据:无匹配且列表已就绪(加载中/失败不算)')
|
|
2529
|
+
assert.match(clientSource, /className: 'soul-drift'/, '缺少偏离提示块')
|
|
2530
|
+
assert.match(clientSource, /'personas\.drift'/, '缺少偏离说明文案')
|
|
2531
|
+
assert.match(clientSource, /t\('personas\.driftAction'\)/, '缺少「存为新预设」按钮')
|
|
2532
|
+
// 按钮必须复用已有的命名弹层(不是另造一套保存流程)
|
|
2533
|
+
assert.match(
|
|
2534
|
+
clientSource,
|
|
2535
|
+
/onClick: \(\) => \{ setPersonaName\(''\); setShowSavePersona\(true\) \}/,
|
|
2536
|
+
'出口按钮应打开已有的命名弹层(复用 setShowSavePersona)'
|
|
2537
|
+
)
|
|
2538
|
+
// 文案键双语齐备
|
|
2539
|
+
assert.ok(clientSource.includes("'personas.drift': '当前配置与任何预设都不一致。'"), '缺中文偏离文案')
|
|
2540
|
+
assert.ok(clientSource.includes("'personas.driftAction': '存为新预设'"), '缺中文按钮文案')
|
|
2541
|
+
assert.ok(clientSource.includes("'personas.drift': 'The current config matches no preset.'"), '缺英文偏离文案')
|
|
2542
|
+
assert.ok(clientSource.includes("'personas.driftAction': 'Save as new preset'"), '缺英文按钮文案')
|
|
2543
|
+
// 样式:弱化提示条 + 描边式次要按钮(不与「使用」抢视觉)
|
|
2544
|
+
const cssBlock = clientCode.match(/const css = \[([\s\S]*?)\]\.join/)?.[1] ?? ''
|
|
2545
|
+
assert.match(cssBlock, /\.soul-drift\{[^}]*background:var\(--dsw-alias-bg-module-platform\)/, '偏离提示应用内嵌底(与宿主只读区同款)')
|
|
2546
|
+
assert.match(cssBlock, /\.soul-drift-action\{[^}]*border:\.5px solid var\(--dsw-alias-brand-primary\)/, '出口按钮应为描边式次要按钮')
|
|
2547
|
+
})
|
|
2548
|
+
|
|
2549
|
+
check('卡背(详情面)展示全部维度与全文,且不动卡片 transform', () => {
|
|
2550
|
+
// 需求:正面效果栏是截断的(首句 + 60 字 + 3 行 clamp),用户要「全部展示」,
|
|
2551
|
+
// 故加卡背展示 6 个维度 + customInstructions 全文。
|
|
2552
|
+
//
|
|
2553
|
+
// 最关键的架构约束:**卡背不得是轮播的第 N+1 张**,而是当前卡的另一种「面」——
|
|
2554
|
+
// 否则 total 要变 2N,位置指示/圆点/go() 全要改。
|
|
2555
|
+
const cssBlock = clientCode.match(/const css = \[([\s\S]*?)\]\.join/)?.[1] ?? ''
|
|
2556
|
+
const frontIdx = clientSource.indexOf("className: 'soul-persona-face soul-persona-face-front'")
|
|
2557
|
+
const backIdx = clientSource.indexOf("className: 'soul-persona-face soul-persona-face-back'")
|
|
2558
|
+
assert.ok(frontIdx > 0, '缺少正面 face')
|
|
2559
|
+
assert.ok(backIdx > frontIdx, '缺少背面 face')
|
|
2560
|
+
// 切片边界不能用固定字符数:卡背内容一长(比如加了删除按钮)就被截断,
|
|
2561
|
+
// 断言会「看不到」新增元素而误报(实测踩过:2600 字符截掉了删除按钮)。
|
|
2562
|
+
// 改到下一个稳定锚点(侧边卡渲染循环)为界。
|
|
2563
|
+
const backEnd = clientSource.indexOf('const cards = []', backIdx)
|
|
2564
|
+
const backBlock = clientSource.slice(backIdx, backEnd > backIdx ? backEnd : backIdx + 4000)
|
|
2565
|
+
// ① 翻面状态绑人设名(不是索引):删除/刷新后同名卡仍是同一张,绑索引会翻错面
|
|
2566
|
+
assert.match(clientSource, /const \[flippedName, setFlippedName\] = React\.useState\(null\)/, '翻面状态应绑人设名')
|
|
2567
|
+
// ② 卡片自身 transform 必须保持叠层偏移原样,且**不得**出现 rotateY
|
|
2568
|
+
// (翻转交给中间的 .soul-persona-flipper,见下 —— 写进卡片就会与叠层偏移
|
|
2569
|
+
// 抢同一个 transform,偏移几何会变脆)
|
|
2570
|
+
assert.match(clientSource, /transform: `translateX\(\$\{offset \* 92\}px\) translateY/, '叠层偏移 transform 应保持原样')
|
|
2571
|
+
const cardRule = cssBlock.match(/\.soul-persona-card\{([^}]*)\}/)?.[1] ?? ''
|
|
2572
|
+
assert.equal(/rotateY/.test(cardRule), false, '卡片规则不得含 rotateY(会与叠层偏移抢同一个 transform)')
|
|
2573
|
+
// 真 3D 翻转:翻转发生在中间的 .soul-persona-flipper 上(职责分离),
|
|
2574
|
+
// 卡片管叠层位置、flipper 管翻到哪一面。
|
|
2575
|
+
assert.match(
|
|
2576
|
+
cssBlock.match(/\.soul-persona-flipper\{([^}]*)\}/)?.[1] ?? '',
|
|
2577
|
+
/transform-style:preserve-3d/,
|
|
2578
|
+
'翻转容器必须 preserve-3d(否则两个面无法构成 3D 空间)'
|
|
2579
|
+
)
|
|
2580
|
+
assert.match(
|
|
2581
|
+
cssBlock,
|
|
2582
|
+
/\[data-flipped="true"\] \.soul-persona-flipper\{transform:rotateY\(180deg\)\}/,
|
|
2583
|
+
'翻面应把 flipper 转到 rotateY(180deg)'
|
|
2584
|
+
)
|
|
2585
|
+
// perspective 必须在翻转元素的父级(卡片)上才生效
|
|
2586
|
+
assert.match(cardRule, /perspective:/, '卡片应设 perspective(3D 景深必须挂在翻转元素的父级)')
|
|
2587
|
+
// 背面要预先转 180°,两个面才各朝一边
|
|
2588
|
+
assert.match(
|
|
2589
|
+
cssBlock.match(/\.soul-persona-face-back\{([^}]*)\}/)?.[1] ?? '',
|
|
2590
|
+
/transform:rotateY\(180deg\)/,
|
|
2591
|
+
'背面应预先 rotateY(180deg)(否则两面同向、翻转后看到镜像)'
|
|
2592
|
+
)
|
|
2593
|
+
// 两个面都要隐藏背面,否则会看到反面内容的镜像
|
|
2594
|
+
assert.match(cssBlock.match(/\.soul-persona-face\{([^}]*)\}/)?.[1] ?? '', /backface-visibility:hidden/, '面须 backface-visibility:hidden')
|
|
2595
|
+
// ③ 背面必须由维度 key 列表驱动渲染(写死易漏项)
|
|
2596
|
+
assert.match(clientSource, /const PERSONA_DIM_KEYS = \['style', 'headingLists', 'emoji', 'tables', 'replyLength', 'language'\]/, '缺少维度 key 列表')
|
|
2597
|
+
assert.match(backBlock, /PERSONA_DIM_KEYS\.map/, '背面应由 PERSONA_DIM_KEYS 驱动渲染')
|
|
2598
|
+
// ④ 全文**不截断**(正面才截断)
|
|
2599
|
+
const fullFn = clientSource.match(/const personaFullText = \(entry\) => \{[\s\S]*?\n \}/)?.[0] ?? ''
|
|
2600
|
+
assert.ok(fullFn !== '', '找不到 personaFullText 实现')
|
|
2601
|
+
assert.equal(/slice\(/.test(fullFn), false, '全文函数不得做 slice 截断(截断是正面效果栏的行为)')
|
|
2602
|
+
// ⑤ 滚动必须落在全文块自己身上,不能放 face 上
|
|
2603
|
+
// (放 face 上会让「返回」按钮跟着滚出视野 —— 实测确认)。
|
|
2604
|
+
// face 的裁切/背面隐藏由公共 .soul-persona-face 规则提供,背面专属规则
|
|
2605
|
+
// 只管 padding/排布,不能把两者混为一谈(以前误把 overflow:hidden 断言
|
|
2606
|
+
// 钉在 back 专属规则,真 3D 改造后裁切移回公共 face 规则就误报了)。
|
|
2607
|
+
const faceRule = cssBlock.match(/\.soul-persona-face\{([^}]*)\}/)?.[1] ?? ''
|
|
2608
|
+
assert.match(faceRule, /overflow:hidden/, '每一面自身应裁切内容(圆角图片/卡背滚动边界)')
|
|
2609
|
+
assert.match(faceRule, /backface-visibility:hidden/, '每一面应隐藏自己的背面(避免镜像内容漏出)')
|
|
2610
|
+
const backFace = cssBlock.match(/\.soul-persona-face-back\{([^}]*)\}/)?.[1] ?? ''
|
|
2611
|
+
assert.ok(backFace !== '', '找不到背面专属规则')
|
|
2612
|
+
assert.equal(/overflow-y:auto|overflow:auto/.test(backFace), false, '滚动不得放在 face 上(会让「返回」一起滚出视野)')
|
|
2613
|
+
assert.match(
|
|
2614
|
+
cssBlock.match(/\.soul-persona-fulltext\{([^}]*)\}/)?.[1] ?? '',
|
|
2615
|
+
/overflow-y:auto/,
|
|
2616
|
+
'全文块自己应可滚动(339 字占 272px,卡片仅 372px)'
|
|
2617
|
+
)
|
|
2618
|
+
// ⑥ 背面只阅读:不得有使用/删除,但必须有「返回」
|
|
2619
|
+
// ⑥ 背面是「查看 / 管理」面:**不得有「使用」**(高频操作留正面),
|
|
2620
|
+
// 但**必须有「删除」**(仅自建卡;从正面移到这里 —— 用户反馈正面三钮拥挤)
|
|
2621
|
+
// 与「返回」并排在底部按钮行。
|
|
2622
|
+
assert.equal(/handleUsePersona/.test(backBlock), false, '背面不得放「使用」按钮(高频操作留正面)')
|
|
2623
|
+
assert.ok(
|
|
2624
|
+
/handleDeletePersona\(name\)/.test(backBlock) && backBlock.includes('soul-persona-back-del'),
|
|
2625
|
+
'背面应有「删除」按钮(仅自建卡渲染,二次确认逻辑不变)'
|
|
2626
|
+
)
|
|
2627
|
+
assert.ok(backBlock.includes('setFlippedName(null)'), '背面缺少「返回」')
|
|
2628
|
+
// 卡背整面点击=返回 ⇒ 「删除」必须 stopPropagation(否则确认删除会被返回打断)
|
|
2629
|
+
assert.match(
|
|
2630
|
+
backBlock,
|
|
2631
|
+
/ev\?\.stopPropagation\(\)\s*\n\s*void handleDeletePersona\(name\)/,
|
|
2632
|
+
'卡背「删除」应 stopPropagation(卡背整面点击=返回,不挡住会打断删除确认)'
|
|
2633
|
+
)
|
|
2634
|
+
// 整面点击返回:必须排除「拖选文字」(卡背有可滚动全文,用户可能拖选复制)
|
|
2635
|
+
assert.match(
|
|
2636
|
+
backBlock,
|
|
2637
|
+
/globalThis\.getSelection\?\.\(\)/,
|
|
2638
|
+
'卡背整面点击返回前必须检查文字选区(否则拖选文字会误触发返回)'
|
|
2639
|
+
)
|
|
2640
|
+
assert.match(backBlock, /sel\.isCollapsed/, '选区判据应看 isCollapsed')
|
|
2641
|
+
// 底部按钮行容器:返回弹性占满 + 删除定宽
|
|
2642
|
+
assert.match(
|
|
2643
|
+
cssBlock.match(/\.soul-persona-back-actions\{([^}]*)\}/)?.[1] ?? '',
|
|
2644
|
+
/margin-top:auto/,
|
|
2645
|
+
'卡背按钮行应 margin-top:auto 贴底'
|
|
2646
|
+
)
|
|
2647
|
+
assert.match(
|
|
2648
|
+
cssBlock.match(/\.soul-persona-back-del\{([^}]*)\}/)?.[1] ?? '',
|
|
2649
|
+
/flex:0 0 \d+px/,
|
|
2650
|
+
'卡背删除应定宽(次要管理操作,不与返回抢宽)'
|
|
2651
|
+
)
|
|
2652
|
+
assert.ok(backBlock.includes('setFlippedName(null)'), '背面缺少「返回」')
|
|
2653
|
+
// ⑦ 卡背徽标必须重置为 static:.soul-persona-badge 是 absolute(为压插画而设),
|
|
2654
|
+
// 在卡背 flex 行里会脱离流与名称重叠(实测徽标 [44,77] 压住名称)
|
|
2655
|
+
assert.match(
|
|
2656
|
+
cssBlock,
|
|
2657
|
+
/\.soul-persona-back-head \.soul-persona-badge\{position:static/,
|
|
2658
|
+
'卡背徽标必须重置 position:static(否则与名称重叠)'
|
|
2659
|
+
)
|
|
2660
|
+
// ⑧ 名称宽度自适应:flex:1 会撑满整行并把徽标挤到同一位置
|
|
2661
|
+
assert.match(
|
|
2662
|
+
cssBlock.match(/\.soul-persona-back-name\{([^}]*)\}/)?.[1] ?? '',
|
|
2663
|
+
/flex:0 1 auto/,
|
|
2664
|
+
'卡背名称应 flex:0 1 auto(flex:1 会撑满整行导致徽标重叠)'
|
|
2665
|
+
)
|
|
2666
|
+
// ⑨ 详情入口只在当前卡上出现(叠层两张不给点)
|
|
2667
|
+
assert.match(clientSource, /current && e\('button', \{[\s\S]{0,200}?soul-persona-details/, '「详情」入口应只在当前卡上渲染')
|
|
2668
|
+
// ⑩ 「返回」按钮的选择器必须带祖先前缀(提高权重),不能只写单类。
|
|
2669
|
+
// 原因:基类 .soul-persona-action 有 flex:1 1 auto,而单类选择器权重相同 ⇒
|
|
2670
|
+
// 后声明者胜;本规则排在基类之前,于是 flex:none 被覆盖,按钮变成弹性元素。
|
|
2671
|
+
// 后果(实测):自定义预设指令较短时剩余空间把按钮**拉伸到 98px**(正常 35px);
|
|
2672
|
+
// 长文本时又被压缩到 21px。加前缀后与声明顺序无关。
|
|
2673
|
+
// 「返回」现在在卡背按钮行里弹性占满(flex:1 1 auto),删除定宽并行。
|
|
2674
|
+
// 权重教训仍在:返回按钮的 flex:1 与基类 .soul-persona-action 语义一致,不再冲突;
|
|
2675
|
+
// 行容器 .soul-persona-back-actions 必须存在且贴底(margin-top:auto)。
|
|
2676
|
+
assert.match(
|
|
2677
|
+
cssBlock.match(/\.soul-persona-back-btn\{([^}]*)\}/)?.[1] ?? '',
|
|
2678
|
+
/flex:1 1 auto/,
|
|
2679
|
+
'「返回」应在卡背按钮行里弹性占满(删除定宽并行)'
|
|
2680
|
+
)
|
|
2681
|
+
// ⑪ 整面点击翻面:正面 face 自带 onClick(当前卡任意处点击翻到卡背,用户需求);
|
|
2682
|
+
// 「使用」按钮必须 stopPropagation(点了就该用,不能顺带翻面)。
|
|
2683
|
+
// ⚠️ onClick 必须限定 current —— 否则点叠层侧边卡时,点击先冒泡到它 face 的
|
|
2684
|
+
// setFlippedName(侧边卡名),再冒泡到卡片 go() 翻页 ⇒ 翻页后它成了当前卡 ⇒
|
|
2685
|
+
// 直接以背面姿态出现(用户实测:切下一个人设时自动翻面了)。
|
|
2686
|
+
const frontFace = clientSource.slice(
|
|
2687
|
+
clientSource.indexOf("className: 'soul-persona-face soul-persona-face-front'"),
|
|
2688
|
+
clientSource.indexOf("className: 'soul-persona-face soul-persona-face-back'")
|
|
2689
|
+
)
|
|
2690
|
+
assert.match(frontFace, /onClick: \(current && !flipped\)/, '整面点击必须限定当前卡(否则点侧边卡翻页后会以背面出现)')
|
|
2691
|
+
assert.match(frontFace, /ev\?\.stopPropagation\(\)/, '「使用」按钮应 stopPropagation(点使用不能顺带翻面)')
|
|
2692
|
+
// cursor:pointer 只提示当前卡(叠层卡点击是翻页,不是翻面)
|
|
2693
|
+
assert.match(
|
|
2694
|
+
cssBlock,
|
|
2695
|
+
/\.soul-persona-card\[data-current="true"\]:not\(\[data-flipped="true"\]\) \.soul-persona-face-front\{cursor:pointer\}/,
|
|
2696
|
+
'cursor:pointer 应只给当前卡的正面(叠层卡的点击是翻页)'
|
|
2697
|
+
)
|
|
2698
|
+
})
|
|
2699
|
+
|
|
2700
|
+
check('「存为预设」入口与「保存设置」并列,且点开才问名字', () => {
|
|
2701
|
+
// 用户反馈原位置「比较迷茫」:它被塞在预设列表最下面,而用户此时的心理模型是
|
|
2702
|
+
// 「我在看别人的预设」;真正保存的却是「关于你/特质」里的字段(在别的分组里)。
|
|
2703
|
+
// 方案 A:移到「保存设置」旁做同级动作,点开才问名字,并明确列出保存范围。
|
|
2704
|
+
const cssBlock = clientCode.match(/const css = \[([\s\S]*?)\]\.join/)?.[1] ?? ''
|
|
2705
|
+
// ① 预设列表里不得再有那个输入框(旧位置已移除)
|
|
2706
|
+
assert.equal(/soul-persona-save/.test(cssBlock), false, '预设列表里的「保存为预设」输入框样式应已移除(入口已移到保存按钮旁)')
|
|
2707
|
+
// ② 与「保存设置」并排出现
|
|
2708
|
+
const buttons = extractCall(clientSource, "e('div', { className: 'soul-buttons' }")
|
|
2709
|
+
assert.ok(buttons !== '', '找不到保存按钮区')
|
|
2710
|
+
assert.match(buttons, /t\('button\.save'\)/, '保存按钮区应有「保存设置」')
|
|
2711
|
+
assert.match(buttons, /t\('personas\.saveAs'\)/, '「存为预设…」应与「保存设置」并列(同一按钮区)')
|
|
2712
|
+
// ③ 点开才问名字:按钮点击只打开弹层,不直接保存
|
|
2713
|
+
assert.match(
|
|
2714
|
+
clientSource,
|
|
2715
|
+
/onClick: \(\) => \{ setPersonaName\(''\); setShowSavePersona\(true\) \}/,
|
|
2716
|
+
'「存为预设…」应只打开命名弹层(不是先填名字才能点)'
|
|
2717
|
+
)
|
|
2718
|
+
assert.match(clientSource, /showSavePersona && e\('div', \{/, '缺少命名弹层渲染分支')
|
|
2719
|
+
// ④ 弹层必须说明「会保存什么 / 不会保存什么」—— 这是消除困惑的关键
|
|
2720
|
+
assert.match(clientSource, /t\('personas\.saveCovers'\)/, '弹层应说明会保存哪些字段')
|
|
2721
|
+
assert.match(clientSource, /t\('personas\.saveExcludes'\)/, '弹层应说明不含「关于你」(这正是困惑来源)')
|
|
2722
|
+
// ⑤ 弹层要能关闭:遮罩点击 + 取消按钮 + Esc
|
|
2723
|
+
assert.match(clientSource, /onClick: \(\) => \{ setShowSavePersona\(false\); setPersonaName\(''\) \}/, '弹层应可关闭(遮罩/取消)')
|
|
2724
|
+
assert.match(clientSource, /ev\.key === 'Escape'/, '弹层应支持 Esc 关闭')
|
|
2725
|
+
// ⑥ 中英文案键齐备
|
|
2726
|
+
for (const [k, zh, en] of [
|
|
2727
|
+
['personas.saveAs', '存为预设…', 'Save as persona…'],
|
|
2728
|
+
['personas.saveConfirm', '保存', 'Save'],
|
|
2729
|
+
['button.cancel', '取消', 'Cancel']
|
|
2730
|
+
]) {
|
|
2731
|
+
assert.ok(clientSource.includes(`'${k}': '${zh}'`), `缺中文文案 ${k}`)
|
|
2732
|
+
assert.ok(clientSource.includes(`'${k}': '${en}'`), `缺英文文案 ${k}`)
|
|
2733
|
+
}
|
|
2734
|
+
})
|
|
2735
|
+
|
|
2736
|
+
// 「断言数一致」与「每一项真的通过」是两回事:前者只防脚本与声明漂移,
|
|
2737
|
+
// 后者才是校验本身。check() 里捕获的任何异常都必须让整体失败 —— 否则失败的
|
|
2738
|
+
// check 只打印一行 ✗ 却仍退出 0,这正是「看起来绿、实际没守住」的形态。
|
|
2739
|
+
if (failures.length > 0) {
|
|
2740
|
+
console.log(`\n结果:失败(${failures.length} 项未通过)。`)
|
|
2741
|
+
process.exit(1)
|
|
2742
|
+
}
|
|
2743
|
+
if (!assertCount('verify-config', passed, EXPECTED_ASSERTIONS)) process.exit(1)
|
|
2744
|
+
|
|
673
2745
|
console.log(`\n全部通过:${passed} 项检查`)
|