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
|
@@ -0,0 +1,1319 @@
|
|
|
1
|
+
// scripts/verify-client.mjs — 设置页客户端行为回归(离线,无浏览器)
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要它:`verify-trail` / `verify-nav-icon` 覆盖的是**渲染层**(DOM 里的 SVG),
|
|
4
|
+
// 而设置页的**交互行为**一直只有「源文本契约」——即断言客户端源码里出现了某段写法。
|
|
5
|
+
// 那种断言证明不了「运行时真的走了那条分支」:把 `if (!payload)` 改成常量、或让
|
|
6
|
+
// `resetConfig` 永不返回 undefined,源码看起来依然正确。
|
|
7
|
+
//
|
|
8
|
+
// 这里把**真实的 `client/index.mjs`** 原样载入(不复制、不裁剪),在纯 Node 里:
|
|
9
|
+
// 1. 用一小撮 React 垫片实现 hooks(useState / useEffect / useRef)与 jsx-runtime;
|
|
10
|
+
// 2. 用假 window / document / fetch / 定时器接管外部世界;
|
|
11
|
+
// 3. 跑真实的 `apply(ctx)`,拿到真实的 `soulController` 与设置页组件;
|
|
12
|
+
// 4. 像用户一样**点按钮**,断言**用户实际会看到的提示文案**。
|
|
13
|
+
//
|
|
14
|
+
// 覆盖的行为全部是 0.7.1 修过、而此前没有任何行为回归守着的那几处:
|
|
15
|
+
// - 两类问题分开上报:`configError`(配置读不出来)/ `deliveryWarning`(送不到会话)
|
|
16
|
+
// - 重置失败**不得**被报成成功(配置损坏时那唯一一条自救路径)
|
|
17
|
+
// - 重置成功且丢弃过损坏文件时要告知「已备份」
|
|
18
|
+
// - 保存失败**不得**误报成「配置无变化」
|
|
19
|
+
//
|
|
20
|
+
// 零依赖,直接 `node scripts/verify-client.mjs` 运行。
|
|
21
|
+
|
|
22
|
+
import assert from 'node:assert/strict'
|
|
23
|
+
import { readFileSync } from 'node:fs'
|
|
24
|
+
import { setImmediate as drainTick } from 'node:timers'
|
|
25
|
+
import { assertCount } from './lib/skip-report.mjs'
|
|
26
|
+
|
|
27
|
+
// 基线运行应跑出的断言数。本脚本没有环境相关跳过(客户端源码与 Node 都在),
|
|
28
|
+
// 所以链里的 `--strict` 对它无额外语义;数字由下面的 assertCount 守住。
|
|
29
|
+
const EXPECTED_ASSERTIONS = 23
|
|
30
|
+
|
|
31
|
+
const source = readFileSync(new URL('../client/index.mjs', import.meta.url), 'utf8')
|
|
32
|
+
|
|
33
|
+
// ==================== 一、React 垫片 ====================
|
|
34
|
+
//
|
|
35
|
+
// 只实现客户端真正用到的那几个 hook(`React.useState` × 25 / `useEffect` × 9 /
|
|
36
|
+
// `useRef` × 6,无 useCallback / useMemo / useSyncExternalStore)。hook 按「组件在
|
|
37
|
+
// 渲染树里的路径」分帧、按调用顺序取槽 —— 与 React 的规则一致。渲染是同步的:状态
|
|
38
|
+
// 变化只置脏标记,由 settle() 收敛。
|
|
39
|
+
|
|
40
|
+
const Fragment = Symbol('Fragment')
|
|
41
|
+
|
|
42
|
+
function createRuntime() {
|
|
43
|
+
const frames = new Map() // 路径 -> { key, values: [], deps: [], cursor }
|
|
44
|
+
const cleanups = new Map() // `${frame.key}#${slot}` -> 清理函数
|
|
45
|
+
let frameStack = []
|
|
46
|
+
let queue = []
|
|
47
|
+
let dirty = false
|
|
48
|
+
|
|
49
|
+
const frameOf = () => {
|
|
50
|
+
const frame = frameStack[frameStack.length - 1]
|
|
51
|
+
if (!frame) throw new Error('hook 在组件外被调用')
|
|
52
|
+
return frame
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const React = {
|
|
56
|
+
Fragment,
|
|
57
|
+
useState(initial) {
|
|
58
|
+
const frame = frameOf()
|
|
59
|
+
const slot = frame.cursor++
|
|
60
|
+
if (!(slot in frame.values)) {
|
|
61
|
+
frame.values[slot] = typeof initial === 'function' ? initial() : initial
|
|
62
|
+
}
|
|
63
|
+
const set = (next) => {
|
|
64
|
+
const current = frame.values[slot]
|
|
65
|
+
const value = typeof next === 'function' ? next(current) : next
|
|
66
|
+
if (Object.is(value, current)) return
|
|
67
|
+
frame.values[slot] = value
|
|
68
|
+
dirty = true
|
|
69
|
+
}
|
|
70
|
+
return [frame.values[slot], set]
|
|
71
|
+
},
|
|
72
|
+
useEffect(fn, deps) {
|
|
73
|
+
const frame = frameOf()
|
|
74
|
+
const slot = frame.cursor++
|
|
75
|
+
const prev = frame.deps[slot]
|
|
76
|
+
const same = prev !== undefined && deps !== undefined &&
|
|
77
|
+
prev.length === deps.length && deps.every((d, i) => Object.is(d, prev[i]))
|
|
78
|
+
if (same) return
|
|
79
|
+
frame.deps[slot] = deps
|
|
80
|
+
queue.push({ frame, slot, fn })
|
|
81
|
+
},
|
|
82
|
+
useRef(initial) {
|
|
83
|
+
const frame = frameOf()
|
|
84
|
+
const slot = frame.cursor++
|
|
85
|
+
if (!(slot in frame.values)) frame.values[slot] = { current: initial }
|
|
86
|
+
return frame.values[slot]
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const jsx = (type, props, key) => ({ type, props: props || {}, key })
|
|
91
|
+
|
|
92
|
+
function renderChildren(children, path) {
|
|
93
|
+
if (children === undefined || children === null || typeof children === 'boolean') return []
|
|
94
|
+
if (Array.isArray(children)) return children.flatMap((c, i) => renderChildren(c, `${path},${i}`))
|
|
95
|
+
if (typeof children === 'string' || typeof children === 'number') {
|
|
96
|
+
return [{ type: '#text', text: String(children) }]
|
|
97
|
+
}
|
|
98
|
+
const node = render(children, path)
|
|
99
|
+
return node ? [node] : []
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function render(node, path) {
|
|
103
|
+
if (typeof node === 'string' || typeof node === 'number') return { type: '#text', text: String(node) }
|
|
104
|
+
if (!node || typeof node !== 'object') return null
|
|
105
|
+
const { type, props } = node
|
|
106
|
+
if (type === Fragment) return { type: '#fragment', children: renderChildren(props.children, `${path}#`) }
|
|
107
|
+
if (typeof type === 'function') {
|
|
108
|
+
const frame = frames.get(path) || { key: path, values: [], deps: [], cursor: 0 }
|
|
109
|
+
frames.set(path, frame)
|
|
110
|
+
frame.cursor = 0
|
|
111
|
+
frameStack.push(frame)
|
|
112
|
+
let out
|
|
113
|
+
try {
|
|
114
|
+
out = type(props)
|
|
115
|
+
} finally {
|
|
116
|
+
frameStack.pop()
|
|
117
|
+
}
|
|
118
|
+
return render(out, `${path}/`)
|
|
119
|
+
}
|
|
120
|
+
// 宿主元素(原生标签,或 ui 垫片里的标记类型):props 原样保留 —— 断言要读它们
|
|
121
|
+
return { type, props, children: renderChildren(props.children, `${path}>`) }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const drain = async () => {
|
|
125
|
+
for (let i = 0; i < 8; i++) await new Promise((r) => drainTick(r))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
React,
|
|
130
|
+
jsx,
|
|
131
|
+
Fragment,
|
|
132
|
+
/**
|
|
133
|
+
* 卸载:执行所有已注册 effect 的清理函数。
|
|
134
|
+
*
|
|
135
|
+
* 为什么需要:垫片原先只在**依赖变化**时跑清理(见 settle 里的 previous()),
|
|
136
|
+
* 组件卸载时从不执行 —— 于是「卸载时必须复位某状态」这类契约在测试台里根本
|
|
137
|
+
* 无法触发(判断力验证实测:变异「卸载不复位 paused」漏网,底栏会永久停摆)。
|
|
138
|
+
* 这不是产品缺陷而是测试台能力缺口,故补在这里。
|
|
139
|
+
*/
|
|
140
|
+
unmount() {
|
|
141
|
+
for (const fn of cleanups.values()) if (typeof fn === 'function') fn()
|
|
142
|
+
cleanups.clear()
|
|
143
|
+
dirty = false
|
|
144
|
+
queue = []
|
|
145
|
+
},
|
|
146
|
+
/** 渲染 + 跑本轮 effect,直到状态不再变化(含 effect / handler 里已 resolve 的 promise)。 */
|
|
147
|
+
async settle(root) {
|
|
148
|
+
for (let pass = 0; pass < 40; pass++) {
|
|
149
|
+
dirty = false
|
|
150
|
+
queue = []
|
|
151
|
+
const tree = render(root, 'root')
|
|
152
|
+
for (const { frame, slot, fn } of queue) {
|
|
153
|
+
const key = `${frame.key}#${slot}`
|
|
154
|
+
const previous = cleanups.get(key)
|
|
155
|
+
if (typeof previous === 'function') previous()
|
|
156
|
+
const returned = fn()
|
|
157
|
+
cleanups.set(key, typeof returned === 'function' ? returned : null)
|
|
158
|
+
}
|
|
159
|
+
await drain()
|
|
160
|
+
if (!dirty) return tree
|
|
161
|
+
}
|
|
162
|
+
throw new Error('渲染未收敛(40 轮仍在变化)—— hook 依赖或 effect 里可能有自激循环')
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ==================== 二、遍历渲染树 ====================
|
|
168
|
+
|
|
169
|
+
function walk(node, visit) {
|
|
170
|
+
if (!node) return
|
|
171
|
+
visit(node)
|
|
172
|
+
for (const child of node.children || []) walk(child, visit)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function textOf(node) {
|
|
176
|
+
if (!node) return ''
|
|
177
|
+
if (node.type === '#text') return node.text
|
|
178
|
+
return (node.children || []).map(textOf).join('')
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function findAll(tree, predicate) {
|
|
182
|
+
const hits = []
|
|
183
|
+
walk(tree, (node) => {
|
|
184
|
+
if (predicate(node)) hits.push(node)
|
|
185
|
+
})
|
|
186
|
+
return hits
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* 按**类名 token 精确匹配**取元素。
|
|
191
|
+
*
|
|
192
|
+
* 不要用 `className.includes('soul-persona-card')` 这种子串匹配:它同时命中
|
|
193
|
+
* `soul-persona-card-head`、`soul-persona-card-pos` —— 实测导致「2 张卡片」被数成
|
|
194
|
+
* 6 个节点(每个卡片还带着两个同前缀的子元素),断言报出误导性的「渲染了 6 张」。
|
|
195
|
+
*/
|
|
196
|
+
function findAllByClass(tree, cls) {
|
|
197
|
+
return findAll(tree, (n) => {
|
|
198
|
+
const c = n.props?.className
|
|
199
|
+
return typeof c === 'string' && c.split(/\s+/).includes(cls)
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ==================== 三、沙箱:假 window / document / 定时器 / fetch ====================
|
|
204
|
+
|
|
205
|
+
const realTimers = {
|
|
206
|
+
setTimeout: globalThis.setTimeout,
|
|
207
|
+
clearTimeout: globalThis.clearTimeout,
|
|
208
|
+
setInterval: globalThis.setInterval,
|
|
209
|
+
clearInterval: globalThis.clearInterval
|
|
210
|
+
}
|
|
211
|
+
let timerQueue = []
|
|
212
|
+
let fetchCalls = []
|
|
213
|
+
|
|
214
|
+
const installFakeTimers = () => {
|
|
215
|
+
// 客户端有三处定时器,全部与断言直接相关,必须接管:
|
|
216
|
+
// - toast 的 2s 自动消失(绝不能在断言之前触发);
|
|
217
|
+
// - 「关于你」分组的 2s 轮询刷新(真实 setInterval 会让事件循环永不空 —— 脚本
|
|
218
|
+
// 跑完最后一行却不退出,看起来像挂住);
|
|
219
|
+
// - 提示词预览的 400ms 防抖。
|
|
220
|
+
// 接管后由用例决定何时推进,测试才是确定性的、也才跑得完。
|
|
221
|
+
globalThis.setTimeout = (fn, delay) => {
|
|
222
|
+
const handle = { fn, delay: delay || 0, repeat: false }
|
|
223
|
+
timerQueue.push(handle)
|
|
224
|
+
return handle
|
|
225
|
+
}
|
|
226
|
+
globalThis.setInterval = (fn, delay) => {
|
|
227
|
+
const handle = { fn, delay: delay || 0, repeat: true }
|
|
228
|
+
timerQueue.push(handle)
|
|
229
|
+
return handle
|
|
230
|
+
}
|
|
231
|
+
const drop = (handle) => {
|
|
232
|
+
if (handle) timerQueue = timerQueue.filter((t) => t !== handle)
|
|
233
|
+
}
|
|
234
|
+
globalThis.clearTimeout = drop
|
|
235
|
+
globalThis.clearInterval = drop
|
|
236
|
+
}
|
|
237
|
+
const restoreTimers = () => {
|
|
238
|
+
globalThis.setTimeout = realTimers.setTimeout
|
|
239
|
+
globalThis.clearTimeout = realTimers.clearTimeout
|
|
240
|
+
globalThis.setInterval = realTimers.setInterval
|
|
241
|
+
globalThis.clearInterval = realTimers.clearInterval
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const interpolate = (text, params) =>
|
|
245
|
+
String(text).replace(/\{(\w+)\}/g, (all, key) => (params && key in params ? String(params[key]) : all))
|
|
246
|
+
|
|
247
|
+
/** 载入真实客户端 bundle 并装配插件(返回真实 controller 与设置页组件)。 */
|
|
248
|
+
function loadClient(routes) {
|
|
249
|
+
let factory = null
|
|
250
|
+
const domNode = () => ({
|
|
251
|
+
style: { setProperty() {}, removeProperty() {} },
|
|
252
|
+
classList: { add() {}, remove() {} },
|
|
253
|
+
dataset: {},
|
|
254
|
+
setAttribute() {},
|
|
255
|
+
removeAttribute() {},
|
|
256
|
+
appendChild() {},
|
|
257
|
+
remove() {},
|
|
258
|
+
addEventListener() {},
|
|
259
|
+
removeEventListener() {},
|
|
260
|
+
children: []
|
|
261
|
+
})
|
|
262
|
+
const windowStub = {
|
|
263
|
+
__ModuleLoader__: { load: (m) => { factory = m.factory } },
|
|
264
|
+
addEventListener: (type) => { captured.events.window.push(type) },
|
|
265
|
+
removeEventListener() {},
|
|
266
|
+
location: { origin: 'http://dsh.test' }
|
|
267
|
+
}
|
|
268
|
+
const documentStub = {
|
|
269
|
+
visibilityState: 'visible',
|
|
270
|
+
body: domNode(),
|
|
271
|
+
head: domNode(),
|
|
272
|
+
addEventListener: (type) => { captured.events.document.push(type) },
|
|
273
|
+
removeEventListener() {},
|
|
274
|
+
// 插件在装配时会把样式表插到 head(用 querySelector 判重),导航图标那一层还会
|
|
275
|
+
// 挂 MutationObserver —— 这里只需让这些调用成立;本脚本断言的是行为,不是 DOM。
|
|
276
|
+
querySelector: () => null,
|
|
277
|
+
querySelectorAll: () => [],
|
|
278
|
+
createElement: () => domNode(),
|
|
279
|
+
createElementNS: () => domNode()
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
globalThis.window = windowStub
|
|
283
|
+
globalThis.document = documentStub
|
|
284
|
+
globalThis.MutationObserver = class { observe() {} disconnect() {} takeRecords() { return [] } }
|
|
285
|
+
globalThis.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} }
|
|
286
|
+
globalThis.requestAnimationFrame = (fn) => drainTick(() => fn(0))
|
|
287
|
+
globalThis.cancelAnimationFrame = () => {}
|
|
288
|
+
globalThis.getComputedStyle = () => ({ position: 'absolute', getPropertyValue: () => '' })
|
|
289
|
+
globalThis.fetch = async (input, init = {}) => {
|
|
290
|
+
const url = new URL(String(input))
|
|
291
|
+
const method = (init.method || 'GET').toUpperCase()
|
|
292
|
+
const call = { path: url.pathname, method, body: init.body ? JSON.parse(init.body) : null }
|
|
293
|
+
fetchCalls.push(call)
|
|
294
|
+
const handler = routes[url.pathname]
|
|
295
|
+
const body = handler ? handler(call) : { ok: false, error: `no route: ${url.pathname}` }
|
|
296
|
+
if (body === false) return { ok: false, status: 500, json: async () => ({ ok: false, error: 'boom' }) }
|
|
297
|
+
return { ok: true, status: 200, json: async () => body }
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
installFakeTimers()
|
|
301
|
+
|
|
302
|
+
const runtime = createRuntime()
|
|
303
|
+
const requireStub = (spec) => {
|
|
304
|
+
if (spec === 'react') return runtime.React
|
|
305
|
+
if (spec === 'react/jsx-runtime') return { jsx: runtime.jsx, jsxs: runtime.jsx, Fragment }
|
|
306
|
+
if (spec === '@deepseek-ai/dsh-client-ui-primitives') {
|
|
307
|
+
// ui.Button / ui.Toast 单独换成宿主元素标记:这样它们连同 onClick / text /
|
|
308
|
+
// tone / holdMs / onDone 原样留在渲染树里,断言就能像用户一样
|
|
309
|
+
// 「找到那个按钮并点它」、「读出提示条的文案与色调」,而不是去猜某个闭包。
|
|
310
|
+
return { Button: 'ui-button', Toast: 'ui-toast' }
|
|
311
|
+
}
|
|
312
|
+
throw new Error(`未预料的 require:${spec}`)
|
|
313
|
+
}
|
|
314
|
+
// eslint-disable-next-line no-new-func -- 客户端是宿主加载的 bundle(无 ESM 导入),只能在受控作用域里求值
|
|
315
|
+
new Function('window', source)(windowStub)
|
|
316
|
+
const module = factory(requireStub)
|
|
317
|
+
|
|
318
|
+
// events:window/document 上注册过的事件名。用于断言「轮询与页面级监听挂在
|
|
319
|
+
// **装配层**」—— 此前它们全在 SoulSettings 组件内,设置页没挂载时全部失效
|
|
320
|
+
// (底栏人设名因此不刷新)。stub 原本是空实现,这里让它可观测。
|
|
321
|
+
const captured = { provide: {}, sections: [], dicts: null, runtime, events: { window: [], document: [] } }
|
|
322
|
+
const ctx = {
|
|
323
|
+
provide: (name, value) => { captured.provide[name] = value },
|
|
324
|
+
effect: (fn) => { const dispose = fn(); if (typeof dispose === 'function') captured.sections.push({ dispose }) },
|
|
325
|
+
locale: {
|
|
326
|
+
register: (_ns, dicts) => { captured.dicts = dicts },
|
|
327
|
+
bind: () => (key, params) => interpolate((captured.dicts?.zh || {})[key] ?? key, params)
|
|
328
|
+
},
|
|
329
|
+
slots: {
|
|
330
|
+
inject: (_name, cb) => cb(),
|
|
331
|
+
register: (options, component) => { captured.sections.push({ options, component }) }
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
module.apply(ctx)
|
|
335
|
+
|
|
336
|
+
const section = captured.sections.find((s) => s.options && s.options.name === 'settings.section')
|
|
337
|
+
const controller = captured.provide.soulController
|
|
338
|
+
const world = {
|
|
339
|
+
runtime,
|
|
340
|
+
captured,
|
|
341
|
+
section,
|
|
342
|
+
controller,
|
|
343
|
+
routes,
|
|
344
|
+
dicts: captured.dicts,
|
|
345
|
+
apply: module.apply,
|
|
346
|
+
module
|
|
347
|
+
}
|
|
348
|
+
return world
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ==================== 四、驱动器 ====================
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* 装配一个干净的客户端实例(含真实的初始 loadConfig)。
|
|
355
|
+
*
|
|
356
|
+
* **桩必须与宿主真实协议同形**:`GET /api/soul/personas` 返回的是
|
|
357
|
+
* `{ personas: <对象 map>, activeName: <名字|null> }`(见 index.mjs:835 personaLibrary),
|
|
358
|
+
* 其中 personas 由 mergePersonas 生成、**恒含 7 个内置人设**(已实测:mergePersonas(null)
|
|
359
|
+
* 也有 7 个键),所以真实环境里「空列表」分支不可达。
|
|
360
|
+
* 此前这个桩写的是 `{ personas: [], activePersona: null }` —— 字段名错(activePersona
|
|
361
|
+
* 而非 activeName)、类型也错(数组而非对象 map),于是人设行与 ★ 标记的渲染在行为层
|
|
362
|
+
* **从未被执行过**:把它改成真实形状,整套 C 系列依然全绿(实测)。这正是「测试替身
|
|
363
|
+
* 与真实协议漂移」的典型:桩喂了一个真实环境不可达的输入,测试永远看不到真问题。
|
|
364
|
+
*/
|
|
365
|
+
async function boot(configPayload = {}, personaPayload = null) {
|
|
366
|
+
fetchCalls = []
|
|
367
|
+
timerQueue = []
|
|
368
|
+
const routes = {
|
|
369
|
+
'/api/soul/config': (call) => (call.method === 'GET'
|
|
370
|
+
? { ok: true, config: { ...CONFIG }, ...configPayload }
|
|
371
|
+
: { ok: true, config: { ...CONFIG }, changed: [] }),
|
|
372
|
+
'/api/soul/config/reset': () => ({ ok: true, config: { ...CONFIG }, changed: [], backupPath: null }),
|
|
373
|
+
// 默认形状贴近宿主:对象 map + activeName(不是数组、不是 activePersona)
|
|
374
|
+
'/api/soul/personas': () => (personaPayload || {
|
|
375
|
+
ok: true,
|
|
376
|
+
personas: { 苏格拉底式提问者: { style: 'professional', builtin: true } },
|
|
377
|
+
activeName: null
|
|
378
|
+
}),
|
|
379
|
+
'/api/soul/prompt': () => ({ ok: true, prompt: 'P', enabled: true })
|
|
380
|
+
}
|
|
381
|
+
const world = loadClient(routes)
|
|
382
|
+
for (let i = 0; i < 8; i++) await new Promise((r) => drainTick(r))
|
|
383
|
+
return world
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* 渲染设置页。`useSoulController` 由宿主把槽位声明的 `hooks.soulController`
|
|
388
|
+
* (一个 subscribe / getSnapshot store)包成 hook 注入 —— 这里用垫片复刻同一语义。
|
|
389
|
+
*/
|
|
390
|
+
function rootElement(world) {
|
|
391
|
+
const props = world.section.options.inject()
|
|
392
|
+
const store = props.hooks.soulController
|
|
393
|
+
const useSoulController = (selector) => {
|
|
394
|
+
const [, force] = world.runtime.React.useState(0)
|
|
395
|
+
world.runtime.React.useEffect(() => store.subscribe(() => force((n) => n + 1)), [])
|
|
396
|
+
return selector(store.getSnapshot())
|
|
397
|
+
}
|
|
398
|
+
return { type: world.section.component, props: { ...props, useSoulController } }
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const renderSettings = (world) => world.runtime.settle(rootElement(world))
|
|
402
|
+
|
|
403
|
+
/** 输入框左侧的快捷开关(conversation.input.left 槽位),渲染方式与设置页同源。 */
|
|
404
|
+
function quickToggleElement(world) {
|
|
405
|
+
const slot = world.captured.sections.find((s) => s.options && s.options.name === 'conversation.input.left')
|
|
406
|
+
assert.ok(slot, '未注册 conversation.input.left 槽位')
|
|
407
|
+
const props = slot.options.inject()
|
|
408
|
+
const store = props.hooks.soulController
|
|
409
|
+
const useSoulController = (selector) => {
|
|
410
|
+
const [, force] = world.runtime.React.useState(0)
|
|
411
|
+
world.runtime.React.useEffect(() => store.subscribe(() => force((n) => n + 1)), [])
|
|
412
|
+
return selector(store.getSnapshot())
|
|
413
|
+
}
|
|
414
|
+
return { type: slot.component, props: { ...props, useSoulController } }
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** 像用户一样点快捷开关,返回之后的按钮节点(可读 props 上的反馈)。 */
|
|
418
|
+
async function clickQuickToggle(world) {
|
|
419
|
+
const before = await world.runtime.settle(quickToggleElement(world))
|
|
420
|
+
const buttons = findAll(before, (n) => n.type === 'button' && typeof n.props?.onClick === 'function')
|
|
421
|
+
assert.equal(buttons.length, 1, `快捷开关应恰好一个可点按钮,实际 ${buttons.length}`)
|
|
422
|
+
await buttons[0].props.onClick()
|
|
423
|
+
for (let i = 0; i < 6; i++) await new Promise((r) => drainTick(r))
|
|
424
|
+
const after = await world.runtime.settle(quickToggleElement(world))
|
|
425
|
+
return findAll(after, (n) => n.type === 'button')[0]
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** 像用户一样点下指定文案的按钮,返回之后的 toast({ text, kind })。 */
|
|
429
|
+
async function clickButton(world, labelKey) {
|
|
430
|
+
const tree = await renderSettings(world)
|
|
431
|
+
const label = world.dicts.zh[labelKey]
|
|
432
|
+
const buttons = findAll(tree, (n) => n.type === 'ui-button' && textOf(n) === label)
|
|
433
|
+
assert.equal(buttons.length, 1, `期望恰好一个「${label}」按钮,实际 ${buttons.length} 个`)
|
|
434
|
+
await buttons[0].props.onClick()
|
|
435
|
+
const after = await renderSettings(world)
|
|
436
|
+
// 提示形态对齐宿主模型设置页:成功走内联绿字(.soul-saved-notice,role=status)、
|
|
437
|
+
// 失败走内联红字(.soul-error-notice,role=alert)—— 宿主设置页从不使用 toast。
|
|
438
|
+
const notices = findAll(after, (n) => typeof n.props?.className === 'string' && n.props.className.includes('soul-saved-notice'))
|
|
439
|
+
if (notices.length > 0) {
|
|
440
|
+
assert.equal(notices.length, 1, `期望恰好一条成功绿字,实际 ${notices.length} 条`)
|
|
441
|
+
return { text: textOf(notices[0]), kind: 'success' }
|
|
442
|
+
}
|
|
443
|
+
const errs = findAll(after, (n) => typeof n.props?.className === 'string' && n.props.className.includes('soul-error-notice'))
|
|
444
|
+
assert.equal(errs.length, 1, `期望恰好一条失败红字,实际 ${errs.length} 条`)
|
|
445
|
+
return { text: textOf(errs[0]), kind: 'error' }
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ==================== 五、用例 ====================
|
|
449
|
+
|
|
450
|
+
const CONFIG = {
|
|
451
|
+
enabled: true,
|
|
452
|
+
nickname: '',
|
|
453
|
+
occupation: '',
|
|
454
|
+
bio: '',
|
|
455
|
+
style: 'professional',
|
|
456
|
+
headingLists: 'default',
|
|
457
|
+
emoji: 'default',
|
|
458
|
+
tables: 'default',
|
|
459
|
+
replyLength: 'normal',
|
|
460
|
+
language: 'zh',
|
|
461
|
+
customInstructions: '',
|
|
462
|
+
requireToolConfirmation: false,
|
|
463
|
+
trailEnabled: true,
|
|
464
|
+
trailColor: '#679EFE',
|
|
465
|
+
trailSpeed: 'slow',
|
|
466
|
+
trailWidth: 'thin'
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
let passed = 0
|
|
470
|
+
async function check(name, fn) {
|
|
471
|
+
try {
|
|
472
|
+
await fn()
|
|
473
|
+
} catch (error) {
|
|
474
|
+
// 失败时把用例名带进消息:否则只有一行 AssertionError,无法一眼看出是哪个用例塌了。
|
|
475
|
+
// 注意 `stack` 也要改 —— 未捕获错误打印的是 stack,它的首行在 AssertionError 构造时
|
|
476
|
+
// 就已经写死,只改 `message` 在终端上根本看不见(判断力对照实测过)。
|
|
477
|
+
error.message = `【${name}】${error.message}`
|
|
478
|
+
error.stack = `【${name}】${error.stack}`
|
|
479
|
+
throw error
|
|
480
|
+
}
|
|
481
|
+
passed++
|
|
482
|
+
console.log(` ✓ ${name}`)
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
try {
|
|
486
|
+
await check('C1 真实 client/index.mjs 可在纯 Node 里装配,并通过 settings.section 注册设置栏目', async () => {
|
|
487
|
+
const world = await boot()
|
|
488
|
+
assert.ok(world.section, '未注册 settings.section')
|
|
489
|
+
assert.equal(world.section.options.id, 'soul-settings')
|
|
490
|
+
assert.equal(typeof world.section.options.label, 'function', 'label 必须是函数(语言切换时实时解析)')
|
|
491
|
+
assert.ok(world.controller, '未 provide soulController')
|
|
492
|
+
assert.ok(world.dicts?.zh && world.dicts?.en, '双语文案表未注册')
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
await check('C2 装配后立即以 GET 拉取配置(而不是等用户打开设置页)', async () => {
|
|
496
|
+
await boot()
|
|
497
|
+
const got = fetchCalls.filter((c) => c.path === '/api/soul/config' && c.method === 'GET')
|
|
498
|
+
assert.equal(got.length, 1, `期望恰好一次 GET /api/soul/config,实际 ${got.length}`)
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
await check('C3 磁盘配置损坏时,configError 要显示在设置页上', async () => {
|
|
502
|
+
const world = await boot({ configError: 'soul-config.json 解析失败' })
|
|
503
|
+
const tree = await renderSettings(world)
|
|
504
|
+
assert.match(textOf(tree), /soul-config\.json 解析失败/, '设置页未显示 configError')
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
await check('C4 只有送达警告时,deliveryWarning 同样要显示出来', async () => {
|
|
508
|
+
const world = await boot({ deliveryWarning: '未送达任何活动会话' })
|
|
509
|
+
const tree = await renderSettings(world)
|
|
510
|
+
assert.match(textOf(tree), /未送达任何活动会话/, '设置页未显示 deliveryWarning')
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
await check('C5 两者同时存在时以 configError 为准(读不出来的问题更靠前)', async () => {
|
|
514
|
+
const world = await boot({ configError: '读不出来', deliveryWarning: '送不到' })
|
|
515
|
+
assert.equal(world.controller.store.getSnapshot().error, '读不出来')
|
|
516
|
+
})
|
|
517
|
+
|
|
518
|
+
await check('C6 保存成功但配置送不到会话时,回传的 deliveryWarning 要落到提示条上', async () => {
|
|
519
|
+
const world = await boot()
|
|
520
|
+
world.routes['/api/soul/config'] = (call) => (call.method === 'GET'
|
|
521
|
+
? { ok: true, config: { ...CONFIG } }
|
|
522
|
+
: { ok: true, config: { ...CONFIG }, changed: ['style'], deliveryWarning: '注入失败' })
|
|
523
|
+
const payload = await world.controller.saveConfig({ style: 'casual' })
|
|
524
|
+
assert.ok(payload, '保存成功应返回 payload')
|
|
525
|
+
assert.equal(world.controller.store.getSnapshot().error, '注入失败')
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
await check('C7 保存失败时 saveConfig 返回 falsy(调用方据此给失败提示,而不是误报「无变化」)', async () => {
|
|
529
|
+
const world = await boot()
|
|
530
|
+
world.routes['/api/soul/config'] = (call) => (call.method === 'GET'
|
|
531
|
+
? { ok: true, config: { ...CONFIG } }
|
|
532
|
+
: { ok: false, error: '写入被拒绝' })
|
|
533
|
+
const payload = await world.controller.saveConfig({ style: 'casual' })
|
|
534
|
+
assert.equal(payload, undefined, '保存失败不得返回 payload')
|
|
535
|
+
assert.match(String(world.controller.store.getSnapshot().error), /写入被拒绝/)
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
await check('C8 重置失败时弹「重置失败」,不得报成成功(0.7.1 修的就是这里)', async () => {
|
|
539
|
+
const world = await boot()
|
|
540
|
+
world.routes['/api/soul/config/reset'] = () => false // 失败响应
|
|
541
|
+
const toast = await clickButton(world, 'button.reset')
|
|
542
|
+
assert.equal(toast.kind, 'error', `重置失败的提示应为错误样式,实际 ${toast.kind}`)
|
|
543
|
+
assert.equal(toast.text, world.dicts.zh['toast.resetFailed'])
|
|
544
|
+
assert.notEqual(toast.text, world.dicts.zh['notice.reset'])
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
await check('C9 重置成功且丢弃过损坏文件时,提示「已备份」(而不是普通的「已重置」)', async () => {
|
|
548
|
+
const world = await boot()
|
|
549
|
+
world.routes['/api/soul/config/reset'] = () => ({
|
|
550
|
+
ok: true, config: { ...CONFIG }, changed: [], backupPath: '/x/soul-config.json.corrupt'
|
|
551
|
+
})
|
|
552
|
+
const toast = await clickButton(world, 'button.reset')
|
|
553
|
+
assert.equal(toast.kind, 'success')
|
|
554
|
+
assert.equal(toast.text, world.dicts.zh['notice.resetRecovered'])
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
await check('C10 普通重置只提示「已重置」,且不留残余的送达警告', async () => {
|
|
558
|
+
const world = await boot({ deliveryWarning: '旧的送达警告' })
|
|
559
|
+
world.routes['/api/soul/config/reset'] = () => ({ ok: true, config: { ...CONFIG }, changed: [], backupPath: null })
|
|
560
|
+
const toast = await clickButton(world, 'button.reset')
|
|
561
|
+
assert.equal(toast.text, world.dicts.zh['notice.reset'])
|
|
562
|
+
assert.equal(world.controller.store.getSnapshot().error, null, '重置后不应残留上一轮的送达警告')
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
await check('C11 保存失败弹「保存失败」,不得误报「配置无变化」', async () => {
|
|
566
|
+
const world = await boot()
|
|
567
|
+
world.routes['/api/soul/config'] = (call) => (call.method === 'GET'
|
|
568
|
+
? { ok: true, config: { ...CONFIG } }
|
|
569
|
+
: { ok: false, error: '非法颜色' })
|
|
570
|
+
const toast = await clickButton(world, 'button.save')
|
|
571
|
+
assert.equal(toast.kind, 'error')
|
|
572
|
+
assert.equal(toast.text, world.dicts.zh['toast.saveFailed'])
|
|
573
|
+
assert.notEqual(toast.text, world.dicts.zh['notice.noChanges'])
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
await check('C12 设置页真的渲染出可点的保存 / 重置按钮(断言不是对着空树跑)', async () => {
|
|
577
|
+
const world = await boot()
|
|
578
|
+
const tree = await renderSettings(world)
|
|
579
|
+
const buttons = findAll(tree, (n) => n.type === 'ui-button')
|
|
580
|
+
assert.ok(buttons.length >= 2, `应至少有保存与重置两个按钮,实际 ${buttons.length}`)
|
|
581
|
+
const labels = buttons.map(textOf)
|
|
582
|
+
assert.ok(labels.includes(world.dicts.zh['button.save']), `未找到保存按钮:${labels.join(' / ')}`)
|
|
583
|
+
assert.ok(labels.includes(world.dicts.zh['button.reset']), `未找到重置按钮:${labels.join(' / ')}`)
|
|
584
|
+
assert.ok(buttons.every((b) => typeof b.props.onClick === 'function'), '按钮缺少 onClick')
|
|
585
|
+
// 反「空树也通过」:不仅要有按钮,还要真的渲染出了栏目与字段
|
|
586
|
+
const text = textOf(tree)
|
|
587
|
+
for (const key of ['settings.title', 'field.nickname', 'button.save', 'button.reset']) {
|
|
588
|
+
assert.ok(
|
|
589
|
+
text.includes(world.dicts.zh[key]),
|
|
590
|
+
`设置页缺少「${key}」对应的文案(${world.dicts.zh[key]}):${text}`
|
|
591
|
+
)
|
|
592
|
+
}
|
|
593
|
+
})
|
|
594
|
+
|
|
595
|
+
await check('C13 快捷开关保存失败时必须给出反馈(此前静默:按钮外观与文案都不变)', async () => {
|
|
596
|
+
const world = await boot()
|
|
597
|
+
world.routes['/api/soul/config'] = (call) => (call.method === 'GET'
|
|
598
|
+
? { ok: true, config: { ...CONFIG } }
|
|
599
|
+
: false)
|
|
600
|
+
const button = await clickQuickToggle(world)
|
|
601
|
+
assert.equal(button.props['data-failed'], 'true', '失败后 data-failed 必须为 true')
|
|
602
|
+
assert.ok(
|
|
603
|
+
String(button.props.title || '').includes('boom'),
|
|
604
|
+
`失败原因要出现在 title 上,实际:${button.props.title}`
|
|
605
|
+
)
|
|
606
|
+
assert.equal(
|
|
607
|
+
button.props['aria-label'],
|
|
608
|
+
button.props.title,
|
|
609
|
+
'aria-label 需与 title 一致(读屏用户同样要拿到失败原因)'
|
|
610
|
+
)
|
|
611
|
+
// 防空过:成功时该标记必须是 false,否则上面的断言等于写死
|
|
612
|
+
const ok = await boot()
|
|
613
|
+
const good = await clickQuickToggle(ok)
|
|
614
|
+
assert.equal(good.props['data-failed'], 'false', '成功时不应残留失败标记')
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
await check('C14 预设列表拉取失败时显示失败原因,不得永远停在「加载中...」', async () => {
|
|
618
|
+
const world = await boot()
|
|
619
|
+
world.routes['/api/soul/personas'] = () => false
|
|
620
|
+
// 人设分组默认折叠(Accordion 折叠时不渲染子节点),先像用户一样展开它
|
|
621
|
+
const closed = await renderSettings(world)
|
|
622
|
+
const triggers = findAll(closed, (n) => n.type === 'button' && textOf(n).includes(world.dicts.zh['group.personas']))
|
|
623
|
+
assert.equal(triggers.length, 1, `应恰好一个「人设预设」折叠标题,实际 ${triggers.length}`)
|
|
624
|
+
await triggers[0].props.onClick()
|
|
625
|
+
const text = textOf(await renderSettings(world))
|
|
626
|
+
const failedLabel = world.dicts.zh['personas.loadFailed'].split('{')[0]
|
|
627
|
+
assert.ok(text.includes(failedLabel), `应显示加载失败提示,实际渲染:${text.slice(0, 240)}`)
|
|
628
|
+
assert.ok(text.includes('boom'), '失败原因要一并显示')
|
|
629
|
+
assert.ok(
|
|
630
|
+
!text.includes(world.dicts.zh['status.loading']),
|
|
631
|
+
'失败后不得继续显示「加载中...」(此前是空 catch,personas 永为 null)'
|
|
632
|
+
)
|
|
633
|
+
// 防空过:正常加载时不应误报失败
|
|
634
|
+
const ok = await boot()
|
|
635
|
+
const okClosed = await renderSettings(ok)
|
|
636
|
+
const okTrigger = findAll(okClosed, (n) => n.type === 'button' && textOf(n).includes(world.dicts.zh['group.personas']))[0]
|
|
637
|
+
await okTrigger.props.onClick()
|
|
638
|
+
const okText = textOf(await renderSettings(ok))
|
|
639
|
+
assert.ok(!okText.includes(failedLabel), '正常加载不应误报失败')
|
|
640
|
+
})
|
|
641
|
+
|
|
642
|
+
await check('C15 只改首尾空格不算「未保存的更改」(与服务端 trim 判据一致)', async () => {
|
|
643
|
+
// 服务端对字符串字段一律 trim 后再落盘,客户端若按原始串比较 dirty,就会出现
|
|
644
|
+
// 「敲了个空格 → 保存按钮亮起 → 保存成功却提示『配置无变化』」这种自相矛盾的状态。
|
|
645
|
+
const world = await boot()
|
|
646
|
+
world.routes['/api/soul/config'] = (call) => (call.method === 'GET'
|
|
647
|
+
? { ok: true, config: { ...CONFIG, nickname: 'NickLi6' } }
|
|
648
|
+
: { ok: true, config: { ...CONFIG, nickname: 'NickLi6' }, changed: [] })
|
|
649
|
+
await world.controller.loadConfig()
|
|
650
|
+
for (let i = 0; i < 6; i++) await new Promise((r) => drainTick(r))
|
|
651
|
+
|
|
652
|
+
const find = async () => findAll(await renderSettings(world), (n) => n.type === 'input' && n.props?.id === 'soul-nickname')
|
|
653
|
+
const inputs = await find()
|
|
654
|
+
assert.equal(inputs.length, 1, `应有一个昵称输入框,实际 ${inputs.length}`)
|
|
655
|
+
inputs[0].props.onChange({ target: { value: ' NickLi6 ' } })
|
|
656
|
+
for (let i = 0; i < 4; i++) await new Promise((r) => drainTick(r))
|
|
657
|
+
assert.ok(
|
|
658
|
+
!textOf(await renderSettings(world)).includes(world.dicts.zh['status.unsaved']),
|
|
659
|
+
'只加首尾空格不应被判成「未保存的更改」'
|
|
660
|
+
)
|
|
661
|
+
// 防空过:真有改动时必须提示,否则上面那条等于恒真
|
|
662
|
+
const inputs2 = await find()
|
|
663
|
+
inputs2[0].props.onChange({ target: { value: 'NickLi6-改过' } })
|
|
664
|
+
for (let i = 0; i < 4; i++) await new Promise((r) => drainTick(r))
|
|
665
|
+
assert.ok(
|
|
666
|
+
textOf(await renderSettings(world)).includes(world.dicts.zh['status.unsaved']),
|
|
667
|
+
'真有改动时必须提示「有未保存的更改」'
|
|
668
|
+
)
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
await check('C16 人设卡片按宿主协议渲染:★ 生效标记、内置徽标、卡片堆叠与翻页', async () => {
|
|
672
|
+
// 这条补的是一个**覆盖空洞**:此前桩返回 `{ personas: [], activePersona: null }`
|
|
673
|
+
// (字段名与类型都与宿主不符),于是人设行渲染与 ★ 标记从未被执行过 —— 把桩改成
|
|
674
|
+
// 真实形状后,整套 C 系列仍全绿(实测),说明没有任何断言吃过这份数据。
|
|
675
|
+
// 宿主真实协议:personas 是由 mergePersonas 生成的**对象 map**(恒含 7 个内置),
|
|
676
|
+
// activeName 是「与当前配置匹配」的预设名(client:1235 读的就是它)。
|
|
677
|
+
const world = await boot({}, {
|
|
678
|
+
ok: true,
|
|
679
|
+
personas: {
|
|
680
|
+
苏格拉底式提问者: { style: 'professional', builtin: true },
|
|
681
|
+
我的自建: { style: 'roast', emoji: 'more' }
|
|
682
|
+
},
|
|
683
|
+
activeName: '我的自建'
|
|
684
|
+
})
|
|
685
|
+
const closed = await renderSettings(world)
|
|
686
|
+
const triggers = findAll(closed, (n) => n.type === 'button' && textOf(n).includes(world.dicts.zh['group.personas']))
|
|
687
|
+
assert.equal(triggers.length, 1, '应有一个「人设预设」分组标题')
|
|
688
|
+
await triggers[0].props.onClick()
|
|
689
|
+
const tree = await renderSettings(world)
|
|
690
|
+
|
|
691
|
+
// ① activeName → store.activePersona 这条链路必须真的通(字段名写错就全灭)
|
|
692
|
+
assert.equal(
|
|
693
|
+
world.controller.store.getSnapshot().activePersona,
|
|
694
|
+
'我的自建',
|
|
695
|
+
'activeName 未被写进 store.activePersona —— 字段名与宿主协议不符'
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
// ② 卡片堆叠一次只渲染限定张数(当前 + 左右各一),当前张带 data-current="true"
|
|
699
|
+
const cards = findAllByClass(tree, 'soul-persona-card')
|
|
700
|
+
assert.equal(cards.length, 2, `两个预设应渲染 2 张卡片(左右无更多),实际 ${cards.length}`)
|
|
701
|
+
const current = cards.filter((c) => c.props['data-current'] === 'true')
|
|
702
|
+
assert.equal(current.length, 1, `应恰好一张当前卡片,实际 ${current.length}`)
|
|
703
|
+
|
|
704
|
+
const cardText = cards.map(textOf)
|
|
705
|
+
const activeCard = cardText.find((t2) => t2.includes('我的自建'))
|
|
706
|
+
const idleCard = cardText.find((t2) => t2.includes('苏格拉底式提问者'))
|
|
707
|
+
assert.ok(activeCard, `未渲染「我的自建」卡片:${cardText.join(' | ')}`)
|
|
708
|
+
assert.ok(idleCard, `未渲染「苏格拉底式提问者」卡片:${cardText.join(' | ')}`)
|
|
709
|
+
|
|
710
|
+
// ③ 生效预设带 ★,非生效的不带(防空过:两个方向都要断言,否则「恒加★」也会过)
|
|
711
|
+
assert.ok(activeCard.includes('★'), `生效预设应带 ★:${activeCard}`)
|
|
712
|
+
assert.ok(!idleCard.includes('★'), `非生效预设不应带 ★:${idleCard}`)
|
|
713
|
+
|
|
714
|
+
// ④ 内置卡片带「内置」标记、且不渲染删除按钮;自建卡片相反
|
|
715
|
+
assert.ok(idleCard.includes(world.dicts.zh['personas.builtin']), `内置卡片应带「内置」标记:${idleCard}`)
|
|
716
|
+
assert.ok(!activeCard.includes(world.dicts.zh['personas.builtin']), `自建卡片不应带「内置」标记:${activeCard}`)
|
|
717
|
+
const deleteButtons = findAll(tree, (n) => n.type === 'button' && textOf(n) === world.dicts.zh['personas.delete'])
|
|
718
|
+
assert.equal(deleteButtons.length, 1, `只应有一个删除按钮(内置卡片不得有),实际 ${deleteButtons.length}`)
|
|
719
|
+
|
|
720
|
+
// ⑤ 轮播交互:位置指示、圆点、翻页按钮与边界禁用(一次只见一张,这些是必需的补偿)
|
|
721
|
+
//
|
|
722
|
+
// 注意:**展开分组时轮播会自动定位到当前生效的那张**(新功能),因此这里一进来
|
|
723
|
+
// 就已经停在「我的自建」(数组第 2 项 ⇒ 索引 1),而不是首张。位置指示应为 2 / 2。
|
|
724
|
+
const nav = findAllByClass(tree, 'soul-persona-navbtn')
|
|
725
|
+
assert.equal(nav.length, 2, `应有前后两个翻页按钮,实际 ${nav.length}`)
|
|
726
|
+
const pos0 = findAllByClass(tree, 'soul-persona-card-pos')
|
|
727
|
+
assert.equal(pos0.length, 1, `当前卡片应显示位置指示(i / n),实际 ${pos0.length}`)
|
|
728
|
+
const [prev, next] = nav
|
|
729
|
+
assert.equal(textOf(pos0[0]), '2 / 2', `展开后应定位到生效项(末张),位置指示应为「2 / 2」,实际「${textOf(pos0[0])}」`)
|
|
730
|
+
assert.equal(prev.props.disabled, false, '已定位到末张时「上一张」应可用')
|
|
731
|
+
assert.equal(next.props.disabled, true, '已定位到末张时「下一张」应禁用')
|
|
732
|
+
const dots = findAllByClass(tree, 'soul-persona-dot')
|
|
733
|
+
assert.equal(dots.length, 2, `圆点数量应等于预设数,实际 ${dots.length}`)
|
|
734
|
+
assert.equal(dots.filter((d) => d.props['data-active'] === 'true').length, 1, '应恰好一个圆点为激活态')
|
|
735
|
+
// 激活的圆点必须是生效项那个索引(1),而不是恒为第 0 个
|
|
736
|
+
assert.equal(dots[1].props['data-active'], 'true', '激活圆点应落在生效项(索引 1)上')
|
|
737
|
+
assert.equal(dots[0].props['data-active'], 'false', '非生效项的圆点不应是激活态')
|
|
738
|
+
|
|
739
|
+
// ⑥ 点「上一张」→ 索引前移,位置指示与当前卡片内容同步变化
|
|
740
|
+
const posBefore = textOf(findAllByClass(tree, 'soul-persona-card-pos')[0])
|
|
741
|
+
const textBefore = textOf(current[0])
|
|
742
|
+
await prev.props.onClick()
|
|
743
|
+
const tree2 = await renderSettings(world)
|
|
744
|
+
const cur2 = findAllByClass(tree2, 'soul-persona-card').filter((c) => c.props['data-current'] === 'true')
|
|
745
|
+
assert.equal(cur2.length, 1, '翻页后仍应恰好一张当前卡片')
|
|
746
|
+
const posAfter = textOf(findAllByClass(tree2, 'soul-persona-card-pos')[0])
|
|
747
|
+
assert.notEqual(posAfter, posBefore, `位置指示应随翻页变化(翻页前后都是「${posAfter}」)`)
|
|
748
|
+
assert.notEqual(textOf(cur2[0]), textBefore, '当前卡片内容应随翻页变化')
|
|
749
|
+
// 位置指示与当前卡片必须是同一个预设(防止「指示变了但卡片没换」)。
|
|
750
|
+
// 不硬编码名字与序号的对应关系 —— 顺序由 store 决定,从「翻页前那张」推导即可:
|
|
751
|
+
// 翻页后指示从 i/2 变成 j/2,卡片内容必须是**另一张**,且两张合起来正好是两个预设。
|
|
752
|
+
const allNames = ['我的自建', '苏格拉底式提问者']
|
|
753
|
+
const shown = [textBefore, textOf(cur2[0])]
|
|
754
|
+
for (const n of allNames) {
|
|
755
|
+
assert.equal(
|
|
756
|
+
shown.filter((t2) => t2.includes(n)).length,
|
|
757
|
+
1,
|
|
758
|
+
`「${n}」应在翻页前后各出现恰好一次,实际出现在:${shown.map((s) => s.slice(0, 26)).join(' | ')}`
|
|
759
|
+
)
|
|
760
|
+
}
|
|
761
|
+
// 翻到首张后「上一张」应禁用(边界)
|
|
762
|
+
const nav2 = findAllByClass(tree2, 'soul-persona-navbtn')
|
|
763
|
+
assert.equal(nav2[0].props.disabled, true, '翻到首张后「上一张」应禁用')
|
|
764
|
+
})
|
|
765
|
+
|
|
766
|
+
await check('C19 应用预设后表单必须跟随 store,保存按钮不得误亮', async () => {
|
|
767
|
+
// 用户实测:点「使用」显示正常,但「保存设置」亮起;一点保存,刚应用的预设
|
|
768
|
+
// 被本地旧表单整体覆盖回旧配置(activeName 变回默认)。
|
|
769
|
+
//
|
|
770
|
+
// 根因:回填 effect 原是「dirty || saving 就整体不回填」。应用预设后 store 先变、
|
|
771
|
+
// 本地表单还是旧值 ⇒ 那一次渲染 dirty 恒为 true ⇒ 回填被挡 ⇒ 表单停在旧值 ⇒
|
|
772
|
+
// 「保存」把旧值整体 POST 上去覆盖预设。
|
|
773
|
+
//
|
|
774
|
+
// 修法:按字段判断(本地值 == 上次同步快照 ⇒ 用户没动过 ⇒ 回填)。
|
|
775
|
+
// 本断言验证行为:应用预设后(未手改任何字段)dirty 必须为 false。
|
|
776
|
+
const world = await boot({}, {
|
|
777
|
+
ok: true,
|
|
778
|
+
personas: {
|
|
779
|
+
苏格拉底式提问者: { style: 'professional', builtin: true },
|
|
780
|
+
资深架构师: { style: 'professional', headingLists: 'more', tables: 'more', replyLength: 'detailed', builtin: true }
|
|
781
|
+
},
|
|
782
|
+
activeName: '苏格拉底式提问者'
|
|
783
|
+
})
|
|
784
|
+
// 找到「使用」按钮所在卡(资深架构师)并应用它 —— 用卡片按钮而不是直接调 controller,
|
|
785
|
+
// 这样测的是用户真实路径(handleUsePersona → store → 回填 effect)。
|
|
786
|
+
// 注意:「人设预设」分组默认折叠,必须先展开(与 C16 同样的前置)。
|
|
787
|
+
//
|
|
788
|
+
// boot 默认桩没有 /personas/use 路由(点了会 404、被 catch 吞掉、store 不变),
|
|
789
|
+
// 那样本断言会得到「保存按钮不亮」的**假阳性** —— 所以这里覆盖该路由,
|
|
790
|
+
// 让它返回真实的「应用预设后的完整配置」(宿主行为:usePersona 会改活动配置)。
|
|
791
|
+
// 同时 GET /config 也必须跟随 —— 否则 2s 轮询会把 store 打回旧值;
|
|
792
|
+
// 真实宿主的 GET /config 本来就返回当前配置,测试桩此前没有模拟这一点。
|
|
793
|
+
let appliedPersona = false
|
|
794
|
+
const appliedConfig = () => ({ ...CONFIG, headingLists: 'more', tables: 'more', replyLength: 'detailed' })
|
|
795
|
+
world.routes['/api/soul/config'] = (call) => (call.method === 'GET'
|
|
796
|
+
? { ok: true, config: appliedPersona ? appliedConfig() : { ...CONFIG }, configError: null, deliveryWarning: null }
|
|
797
|
+
: { ok: true, config: { ...CONFIG }, changed: [] })
|
|
798
|
+
world.routes['/api/soul/personas/use'] = () => {
|
|
799
|
+
appliedPersona = true
|
|
800
|
+
return { ok: true, config: appliedConfig(), changed: ['headingLists', 'tables', 'replyLength'], invalid: [] }
|
|
801
|
+
}
|
|
802
|
+
const tree0 = await renderSettings(world)
|
|
803
|
+
const trigger0 = findAll(tree0, (n) => n.type === 'button' && textOf(n).includes(world.dicts.zh['group.personas']))
|
|
804
|
+
await trigger0[0].props.onClick()
|
|
805
|
+
const treeOpen = await renderSettings(world)
|
|
806
|
+
const useBtns = findAll(treeOpen, (n) => n.type === 'button' && textOf(n) === world.dicts.zh['personas.use'])
|
|
807
|
+
assert.ok(useBtns.length >= 1, '应存在「使用」按钮(前置条件)')
|
|
808
|
+
// 资深架构师与苏格拉底式提问者的 style 相同但 headingLists/tables/replyLength 不同,
|
|
809
|
+
// 应用后 store 的这些字段必然变化 —— 足以检验回填。
|
|
810
|
+
// 点按钮是 fire-and-forget(handleUsePersona 内部 await),必须排空微任务,
|
|
811
|
+
// store 才会被 usePersona 的响应写入(排空后还要再排空一次:写入触发的渲染链)。
|
|
812
|
+
await useBtns[useBtns.length - 1].props.onClick()
|
|
813
|
+
for (let i = 0; i < 10; i++) await new Promise((r) => drainTick(r))
|
|
814
|
+
const tree1 = await renderSettings(world)
|
|
815
|
+
// ① 保存按钮必须**不亮**(disabled)—— 用户什么都没改
|
|
816
|
+
const saveBtn = findAll(tree1, (n) => n.type === 'ui-button' && textOf(n) === world.dicts.zh['button.save'])
|
|
817
|
+
assert.equal(saveBtn.length, 1, `应恰好一个「保存设置」按钮,实际 ${saveBtn.length}`)
|
|
818
|
+
assert.equal(
|
|
819
|
+
saveBtn[0].props.disabled,
|
|
820
|
+
true,
|
|
821
|
+
'应用预设后(未手改字段)「保存设置」应禁用 —— 亮起说明表单没跟上 store(本 bug 的第一症状)'
|
|
822
|
+
)
|
|
823
|
+
// ② store 与「已保存配置」一致(usePersona 已写回)
|
|
824
|
+
const snap = world.controller.store.getSnapshot()
|
|
825
|
+
assert.equal(snap.headingLists, 'more', '应用后 store.headingLists 应为预设值 more')
|
|
826
|
+
assert.equal(snap.replyLength, 'detailed', '应用后 store.replyLength 应为预设值 detailed')
|
|
827
|
+
// ③ 关键回归点:本地表单必须与 store 一致(dirty 的两侧相等)
|
|
828
|
+
// 通过「不再有未保存提示」与保存按钮禁用间接验证(树里没有 dirty 标记)
|
|
829
|
+
const dirtyHint = findAll(tree1, (n) => typeof n.props?.className === 'string' && String(n.props.className).includes('soul-dirty'))
|
|
830
|
+
assert.equal(dirtyHint.length, 0, '不应出现「未保存的更改」提示')
|
|
831
|
+
// ④ 编辑某字段后 dirty 仍应正常亮起(确认按字段判断没有把保护一并去掉)。
|
|
832
|
+
// 折叠分组是**互斥**的(toggleSection 会关掉人设分组),先切回「特质」分组。
|
|
833
|
+
const triggerTraits = findAll(tree1, (n) => n.type === 'button' && textOf(n).includes(world.dicts.zh['group.traits']))
|
|
834
|
+
assert.ok(triggerTraits.length > 0, '应存在「特质」分组标题(前置条件)')
|
|
835
|
+
await triggerTraits[0].props.onClick()
|
|
836
|
+
const treeTraits = await renderSettings(world)
|
|
837
|
+
const styleSelects = findAll(treeTraits, (n) => n.type === 'select')
|
|
838
|
+
assert.ok(styleSelects.length > 0, '应存在风格下拉框(前置条件)')
|
|
839
|
+
await styleSelects[0].props.onChange({ target: { value: 'roast' } })
|
|
840
|
+
const tree2 = await renderSettings(world)
|
|
841
|
+
const saveBtn2 = findAll(tree2, (n) => n.type === 'ui-button' && textOf(n) === world.dicts.zh['button.save'])
|
|
842
|
+
assert.equal(
|
|
843
|
+
saveBtn2[0].props.disabled,
|
|
844
|
+
false,
|
|
845
|
+
'手动改动字段后「保存设置」应恢复可用(按字段保护不能连编辑检测一起丢掉)'
|
|
846
|
+
)
|
|
847
|
+
// ⑤ 按字段保护的**另一半**:用户正在编辑的字段,不能被外部写入(loadConfig 的
|
|
848
|
+
// applyConfig,等价于 2s 轮询干的事)覆盖。若去掉按字段判断(无条件跟随
|
|
849
|
+
// store),这里会失败 —— C19 前半段只测「跟随」,测不出这个方向的回归
|
|
850
|
+
// (判断力验证实测 ②③ 漏网)。
|
|
851
|
+
// 注意:不要指望 fake timer 的轮询自己跑 —— settle 不推进 timerQueue,
|
|
852
|
+
// 直接调 loadConfig 才是确定性的等价写入。
|
|
853
|
+
await styleSelects[0].props.onChange({ target: { value: 'casual' } }) // 用户正在编辑 style
|
|
854
|
+
// 外部写入(轮询的等价物)必须真的改变 store —— effect 的依赖是 store 值,
|
|
855
|
+
// 若 loadConfig 返回与 store 相同的值,effect 根本不跑,变异(无条件跟随)
|
|
856
|
+
// 就探测不到差异(判断力验证实测 ②③ 漏网的原因)。这里模拟「别处把风格
|
|
857
|
+
// 改成了 humorous」:store 即将变为 humorous,而用户手里还捏着 casual。
|
|
858
|
+
world.routes['/api/soul/config'] = (call) => (call.method === 'GET'
|
|
859
|
+
? { ok: true, config: { ...CONFIG, style: 'humorous' }, configError: null, deliveryWarning: null }
|
|
860
|
+
: { ok: true, config: { ...CONFIG }, changed: [] })
|
|
861
|
+
await world.controller.loadConfig()
|
|
862
|
+
// 渲染两轮:effect 按字段判断后的 setState 要到下一轮渲染才反映到树里
|
|
863
|
+
// (测试台的 set 只标记 dirty,下一帧 render() 才提交;单帧树读到的是旧值)
|
|
864
|
+
await renderSettings(world)
|
|
865
|
+
const treePolled = await renderSettings(world)
|
|
866
|
+
const polledSelects = findAll(treePolled, (n) => n.type === 'select')
|
|
867
|
+
assert.ok(polledSelects.length > 0, '轮询后仍应有风格下拉框(前置条件)')
|
|
868
|
+
assert.equal(
|
|
869
|
+
polledSelects[0].props.value,
|
|
870
|
+
'casual',
|
|
871
|
+
'用户正在编辑的字段不得被外部写入覆盖(无条件跟随 store 会把它改回 store 值)'
|
|
872
|
+
)
|
|
873
|
+
})
|
|
874
|
+
|
|
875
|
+
await check('C22 翻页会复位翻面态:切走再切回不得自动是背面', async () => {
|
|
876
|
+
// 用户实测:翻开 A 卡背面 → 切到 B → 再切回 A,A 仍以背面出现。
|
|
877
|
+
//
|
|
878
|
+
// 根因:flippedName 记住了「哪张卡被翻开过」,而 go() 只改索引、不清它 ⇒
|
|
879
|
+
// 切回来时 current && flippedName === name 又成立,于是「记得」上次的面。
|
|
880
|
+
//
|
|
881
|
+
// 语义:翻面是「看这张卡背面」的**临时阅读动作**,翻页是「换一张看」——
|
|
882
|
+
// 注意力已重置。记住它会让用户以为这张卡「默认就是背面」。
|
|
883
|
+
// 故三处换卡入口都要清:go() / 展开自动定位 / 折叠分组。
|
|
884
|
+
const lib = {
|
|
885
|
+
ok: true,
|
|
886
|
+
personas: {
|
|
887
|
+
苏格拉底式提问者: { style: 'professional', builtin: true, headingLists: 'default', emoji: 'default', tables: 'default', replyLength: 'normal', customInstructions: '' },
|
|
888
|
+
极简主义者: { style: 'efficient', builtin: true, headingLists: 'default', emoji: 'default', tables: 'default', replyLength: 'normal', customInstructions: '' },
|
|
889
|
+
资深架构师: { style: 'professional', builtin: true, headingLists: 'more', emoji: 'default', tables: 'more', replyLength: 'detailed', customInstructions: 'x' }
|
|
890
|
+
},
|
|
891
|
+
activeName: '苏格拉底式提问者'
|
|
892
|
+
}
|
|
893
|
+
const world = await boot({}, lib)
|
|
894
|
+
const closed = await renderSettings(world)
|
|
895
|
+
await findAll(closed, (n) => n.type === 'button' && textOf(n).includes(world.dicts.zh['group.personas']))[0].props.onClick()
|
|
896
|
+
const tree0 = await renderSettings(world)
|
|
897
|
+
|
|
898
|
+
const flippedCount = (tree) => findAllByClass(tree, 'soul-persona-card').filter((c) => c.props['data-flipped'] === 'true').length
|
|
899
|
+
const currentCard = (tree) => findAllByClass(tree, 'soul-persona-card').find((c) => c.props['data-current'] === 'true')
|
|
900
|
+
|
|
901
|
+
// ① 翻开当前卡背面
|
|
902
|
+
const front = findAll(currentCard(tree0), (n) => typeof n.props?.className === 'string' && String(n.props.className).includes('soul-persona-face-front'))
|
|
903
|
+
assert.ok(front[0] && typeof front[0].props.onClick === 'function', '前置条件:当前卡正面应可点翻面')
|
|
904
|
+
await front[0].props.onClick()
|
|
905
|
+
for (let i = 0; i < 6; i++) await new Promise((r) => drainTick(r))
|
|
906
|
+
const tree1 = await renderSettings(world)
|
|
907
|
+
assert.equal(flippedCount(tree1), 1, '前置条件:点整面后应有 1 张处于背面')
|
|
908
|
+
|
|
909
|
+
// ② 点侧边卡切到下一张 ⇒ 无卡处于背面
|
|
910
|
+
const side = findAllByClass(tree1, 'soul-persona-card').find((c) => c.props['data-current'] === 'false' && typeof c.props.onClick === 'function')
|
|
911
|
+
assert.ok(side, '前置条件:应存在可点的叠层侧边卡')
|
|
912
|
+
await side.props.onClick()
|
|
913
|
+
for (let i = 0; i < 8; i++) await new Promise((r) => drainTick(r))
|
|
914
|
+
const tree2 = await renderSettings(world)
|
|
915
|
+
assert.equal(flippedCount(tree2), 0, '翻页后不应有卡处于背面')
|
|
916
|
+
|
|
917
|
+
// ③ 切回原来那张 ⇒ 必须是正面(bug 时这里会是背面)
|
|
918
|
+
const side2 = findAllByClass(tree2, 'soul-persona-card').find((c) => c.props['data-current'] === 'false' && typeof c.props.onClick === 'function')
|
|
919
|
+
await side2.props.onClick()
|
|
920
|
+
for (let i = 0; i < 8; i++) await new Promise((r) => drainTick(r))
|
|
921
|
+
const tree3 = await renderSettings(world)
|
|
922
|
+
assert.equal(
|
|
923
|
+
flippedCount(tree3),
|
|
924
|
+
0,
|
|
925
|
+
'切回原来那张时不得记住翻面态(应显示正面;bug 症状:仍以背面出现)'
|
|
926
|
+
)
|
|
927
|
+
|
|
928
|
+
// ④ 圆点导航同样要复位(不能只修侧边卡这一条路径)
|
|
929
|
+
await front[0].props.onClick()
|
|
930
|
+
for (let i = 0; i < 6; i++) await new Promise((r) => drainTick(r))
|
|
931
|
+
const dots = findAll(await renderSettings(world), (n) => n.props?.role === 'tab')
|
|
932
|
+
assert.ok(dots.length > 1, '前置条件:应有多个位置圆点')
|
|
933
|
+
await dots[1].props.onClick()
|
|
934
|
+
for (let i = 0; i < 8; i++) await new Promise((r) => drainTick(r))
|
|
935
|
+
assert.equal(flippedCount(await renderSettings(world)), 0, '圆点导航换卡也应复位翻面态')
|
|
936
|
+
|
|
937
|
+
// ⑤ 折叠 → 重新展开:也不得带出上次的背面
|
|
938
|
+
//
|
|
939
|
+
// ⚠️ 这一场景必须让「自动定位」落回**同一张卡**才有判别力:
|
|
940
|
+
// 自动定位会把 index 设为 activePersona 的位置。若翻的是另一张卡,
|
|
941
|
+
// 重新展开后当前卡已换人 ⇒ flippedName 天然不匹配 ⇒ 即使不清理也显示正面,
|
|
942
|
+
// 测试就成了「永远通过」(初版正因如此漏掉了这个分支 —— 判断力验证发现)。
|
|
943
|
+
// 故用独立 world,让 activeName 就是被翻的那张。
|
|
944
|
+
const w5 = await boot({}, {
|
|
945
|
+
ok: true,
|
|
946
|
+
personas: {
|
|
947
|
+
苏格拉底式提问者: { style: 'professional', builtin: true },
|
|
948
|
+
极简主义者: { style: 'efficient', builtin: true }
|
|
949
|
+
},
|
|
950
|
+
// activeName 取列表第 0 项 ⇒ 展开时自动定位回第 0 张
|
|
951
|
+
activeName: '苏格拉底式提问者'
|
|
952
|
+
})
|
|
953
|
+
const closed5 = await renderSettings(w5)
|
|
954
|
+
await findAll(closed5, (n) => n.type === 'button' && textOf(n).includes(w5.dicts.zh['group.personas']))[0].props.onClick()
|
|
955
|
+
let t5 = await renderSettings(w5)
|
|
956
|
+
|
|
957
|
+
// 翻开**第 0 张**(= activeName 指向的那张,自动定位会回到它)
|
|
958
|
+
const card0 = currentCard(t5)
|
|
959
|
+
assert.ok(card0, '前置条件:应有当前卡')
|
|
960
|
+
const front5 = findAll(card0, (n) => typeof n.props?.className === 'string' && String(n.props.className).includes('soul-persona-face-front'))
|
|
961
|
+
await front5[0].props.onClick()
|
|
962
|
+
for (let i = 0; i < 6; i++) await new Promise((r) => drainTick(r))
|
|
963
|
+
t5 = await renderSettings(w5)
|
|
964
|
+
assert.equal(flippedCount(t5), 1, '前置条件:应已翻到背面')
|
|
965
|
+
|
|
966
|
+
// 折叠
|
|
967
|
+
await findAll(t5, (n) => n.type === 'button' && textOf(n).includes(w5.dicts.zh['group.personas']))[0].props.onClick()
|
|
968
|
+
for (let i = 0; i < 6; i++) await new Promise((r) => drainTick(r))
|
|
969
|
+
// 重新展开(自动定位会回到第 0 张 = 刚被翻的那张)
|
|
970
|
+
t5 = await renderSettings(w5)
|
|
971
|
+
await findAll(t5, (n) => n.type === 'button' && textOf(n).includes(w5.dicts.zh['group.personas']))[0].props.onClick()
|
|
972
|
+
for (let i = 0; i < 8; i++) await new Promise((r) => drainTick(r))
|
|
973
|
+
assert.equal(
|
|
974
|
+
flippedCount(await renderSettings(w5)),
|
|
975
|
+
0,
|
|
976
|
+
'折叠后重新展开不得记得上次的翻面态(自动定位落回同一张卡时)'
|
|
977
|
+
)
|
|
978
|
+
})
|
|
979
|
+
|
|
980
|
+
await check('C23 全局轮询注册在装配层(底栏不依赖设置页是否挂载)', async () => {
|
|
981
|
+
// 用户实测:用 /soul confirm 确认人设变更后(宿主已写盘,配置文件正确),
|
|
982
|
+
// 底栏「个性化已启用 · 人设名」仍显示旧值。
|
|
983
|
+
//
|
|
984
|
+
// 根因:三条刷新路径(setInterval / focus / visibilitychange)**全部注册在
|
|
985
|
+
// SoulSettings 组件内** —— 设置页没挂载时它们都不存在,底栏读的 store 无人刷新。
|
|
986
|
+
// 底栏是常驻组件,其数据链路不该依赖另一个组件的生命周期。
|
|
987
|
+
//
|
|
988
|
+
// 这里断言三件事:
|
|
989
|
+
// ① 定时器只在装配层注册(全客户端仅一处 setInterval)
|
|
990
|
+
// ② 装配层注册了页面级事件监听(focus / visibilitychange)
|
|
991
|
+
// ③ 设置页不再自己起定时器,只同步「暂停标记」
|
|
992
|
+
const world = await boot()
|
|
993
|
+
// ① 装配期即已注册(boot 会执行 ctx.effect)
|
|
994
|
+
const intervals = timerQueue.filter((t) => t.repeat === true)
|
|
995
|
+
assert.ok(intervals.length >= 1, '装配层应注册轮询定时器(interval)')
|
|
996
|
+
|
|
997
|
+
// ② 事件监听挂在 window/document 上(而非仅在设置页组件内)
|
|
998
|
+
// 测试台的 window/document stub 记录了 addEventListener 调用
|
|
999
|
+
assert.ok(
|
|
1000
|
+
world.captured.events.window.includes('focus'),
|
|
1001
|
+
'装配层应监听 window focus(切回窗口立即刷新,不必等下一个轮询周期)'
|
|
1002
|
+
)
|
|
1003
|
+
assert.ok(
|
|
1004
|
+
world.captured.events.document.includes('visibilitychange'),
|
|
1005
|
+
'装配层应监听 document visibilitychange'
|
|
1006
|
+
)
|
|
1007
|
+
|
|
1008
|
+
// ③ 设置页只同步暂停标记:不打开它时轮询照常,打开且编辑时暂停
|
|
1009
|
+
const before = world.controller.paused
|
|
1010
|
+
assert.equal(before, false, '初始不应处于暂停态')
|
|
1011
|
+
const tree = await renderSettings(world)
|
|
1012
|
+
// 渲染设置页后(无改动)仍不应暂停
|
|
1013
|
+
assert.equal(world.controller.paused, false, '无未保存编辑时不应暂停轮询')
|
|
1014
|
+
|
|
1015
|
+
// ④ 暂停语义:置位后 canRefresh 应为 false(编辑中不被远端覆盖)
|
|
1016
|
+
world.controller.paused = true
|
|
1017
|
+
assert.equal(world.controller.canRefresh(), false, 'paused 时不应允许刷新')
|
|
1018
|
+
world.controller.paused = false
|
|
1019
|
+
assert.equal(world.controller.canRefresh(), true, '复位后应恢复刷新')
|
|
1020
|
+
|
|
1021
|
+
// ⑤ 卸载复位:设置页卸载时必须把 paused 复原,否则**底栏永久停摆** ——
|
|
1022
|
+
// 这正是本次 bug 的镜像形态(一个组件的生命周期管住了常驻组件的刷新)。
|
|
1023
|
+
// 这条是判断力验证补出来的:变异「卸载时不复位 paused」首轮漏网,
|
|
1024
|
+
// 同时暴露了测试台缺口(垫片原先只在依赖变化时跑清理,卸载时从不执行)。
|
|
1025
|
+
const w2 = await boot()
|
|
1026
|
+
await renderSettings(w2)
|
|
1027
|
+
w2.controller.paused = true // 模拟「编辑中被卸载」这一最坏情形
|
|
1028
|
+
w2.runtime.unmount()
|
|
1029
|
+
assert.equal(
|
|
1030
|
+
w2.controller.paused,
|
|
1031
|
+
false,
|
|
1032
|
+
'设置页卸载后必须复位 paused(否则外部轮询永久停摆,底栏不再刷新)'
|
|
1033
|
+
)
|
|
1034
|
+
})
|
|
1035
|
+
|
|
1036
|
+
await check('C21 底栏展示当前人设,未匹配时显示「自定义」', async () => {
|
|
1037
|
+
// 需求:底栏「个性化已启用」后必须回答「现在用的是哪套人设」。
|
|
1038
|
+
// 此前只有匹配到预设才缀名字,手改过特质后留空 —— 等于没回答。
|
|
1039
|
+
// 现在:匹配到预设显示预设名,否则显示「自定义」。
|
|
1040
|
+
const personaLib = (activeName) => ({
|
|
1041
|
+
ok: true,
|
|
1042
|
+
personas: {
|
|
1043
|
+
苏格拉底式提问者: {
|
|
1044
|
+
style: 'professional', builtin: true, headingLists: 'default', emoji: 'default',
|
|
1045
|
+
tables: 'default', replyLength: 'normal', customInstructions: ''
|
|
1046
|
+
},
|
|
1047
|
+
极简主义者: { style: 'efficient', builtin: true }
|
|
1048
|
+
},
|
|
1049
|
+
activeName
|
|
1050
|
+
})
|
|
1051
|
+
|
|
1052
|
+
// ① 匹配到预设 ⇒ 缀预设名
|
|
1053
|
+
const w1 = await boot({}, personaLib('苏格拉底式提问者'))
|
|
1054
|
+
// 人设列表由**设置页组件挂载后的 refresh** 拉取(apply 阶段只拉 config)。
|
|
1055
|
+
// 快捷开关单独渲染不会触发它 —— 先渲染一次设置页把人设灌进 store,
|
|
1056
|
+
// 否则下面读到的 activePersona 恒为 null(此前踩过:断言读到「自定义」)。
|
|
1057
|
+
await renderSettings(w1)
|
|
1058
|
+
const btn1 = await w1.runtime.settle(quickToggleElement(w1))
|
|
1059
|
+
const name1 = findAll(btn1, (n) => typeof n.props?.className === 'string' && String(n.props.className).includes('soul-quick-toggle-name'))
|
|
1060
|
+
assert.equal(name1.length, 1, `启用时应有一个人设名节点,实际 ${name1.length}`)
|
|
1061
|
+
assert.ok(
|
|
1062
|
+
textOf(name1[0]).includes('苏格拉底式提问者'),
|
|
1063
|
+
`匹配到预设时应显示预设名,实际:「${textOf(name1[0])}」`
|
|
1064
|
+
)
|
|
1065
|
+
|
|
1066
|
+
// ② 未匹配(手改过特质)⇒ 显示「自定义」,不能留空
|
|
1067
|
+
const w2 = await boot({}, personaLib(null))
|
|
1068
|
+
await renderSettings(w2)
|
|
1069
|
+
const btn2 = await w2.runtime.settle(quickToggleElement(w2))
|
|
1070
|
+
const name2 = findAll(btn2, (n) => typeof n.props?.className === 'string' && String(n.props.className).includes('soul-quick-toggle-name'))
|
|
1071
|
+
assert.equal(name2.length, 1, '未匹配时也应有人设名节点(回退为「自定义」)')
|
|
1072
|
+
assert.ok(
|
|
1073
|
+
textOf(name2[0]).includes(w2.dicts.zh['quick.custom']),
|
|
1074
|
+
`未匹配时应显示「${w2.dicts.zh['quick.custom']}」,实际:「${textOf(name2[0])}」`
|
|
1075
|
+
)
|
|
1076
|
+
|
|
1077
|
+
// ③ 关闭个性化 ⇒ 整块人设名不显示(此刻人设不生效,挂名字会误导)
|
|
1078
|
+
const w3 = await boot({}, personaLib('苏格拉底式提问者'))
|
|
1079
|
+
// 关闭个性化:必须让 GET 与 POST 都返回 enabled:false —— boot 的 configPayload
|
|
1080
|
+
// 只展开进 GET 的响应,POST 分支仍是 {...CONFIG}(enabled:true),
|
|
1081
|
+
// 直接 boot({enabled:false}) 会被后续 refresh 覆盖回 true(此前踩过)。
|
|
1082
|
+
const disabledConfig = { ...CONFIG, enabled: false }
|
|
1083
|
+
w3.routes['/api/soul/config'] = (call) => (call.method === 'GET'
|
|
1084
|
+
? { ok: true, config: { ...disabledConfig }, configError: null, deliveryWarning: null }
|
|
1085
|
+
: { ok: true, config: { ...disabledConfig }, changed: [] })
|
|
1086
|
+
await renderSettings(w3)
|
|
1087
|
+
w3.controller.store.update((s) => { s.enabled = false })
|
|
1088
|
+
const btn3 = await w3.runtime.settle(quickToggleElement(w3))
|
|
1089
|
+
const name3 = findAll(btn3, (n) => typeof n.props?.className === 'string' && String(n.props.className).includes('soul-quick-toggle-name'))
|
|
1090
|
+
assert.equal(name3.length, 0, '关闭个性化时不应显示人设名')
|
|
1091
|
+
})
|
|
1092
|
+
|
|
1093
|
+
await check('C20 点叠层侧边卡只翻页、不翻面(整面点击必须限定当前卡)', async () => {
|
|
1094
|
+
// 用户实测 bug:点侧边卡切换下一个人设后,新当前卡直接以【背面】出现。
|
|
1095
|
+
// 根因:整面翻面的 onClick 挂在了【所有卡】的正面 face 上 —— 点侧边卡时,
|
|
1096
|
+
// 事件先冒泡到它的 face onClick(setFlippedName(侧边卡名)),再冒泡到卡片的
|
|
1097
|
+
// go() 翻页 ⇒ 翻页后它成了当前卡 ⇒ flippedName === 它 ⇒ 一上来就是背面。
|
|
1098
|
+
// 修法:face 的 onClick 限定 current;cursor:pointer 同理只给当前卡。
|
|
1099
|
+
const world = await boot({}, {
|
|
1100
|
+
ok: true,
|
|
1101
|
+
personas: {
|
|
1102
|
+
苏格拉底式提问者: { style: 'professional', builtin: true },
|
|
1103
|
+
极简主义者: { style: 'efficient', builtin: true },
|
|
1104
|
+
资深架构师: { style: 'professional', headingLists: 'more', builtin: true }
|
|
1105
|
+
},
|
|
1106
|
+
activeName: null
|
|
1107
|
+
})
|
|
1108
|
+
const closed = await renderSettings(world)
|
|
1109
|
+
const trigger = findAll(closed, (n) => n.type === 'button' && textOf(n).includes(world.dicts.zh['group.personas']))
|
|
1110
|
+
await trigger[0].props.onClick()
|
|
1111
|
+
const tree = await renderSettings(world)
|
|
1112
|
+
|
|
1113
|
+
// ① 叠层侧边卡的正面 face 不得挂 onClick(否则点击会先翻面再翻页)
|
|
1114
|
+
const side = findAllByClass(tree, 'soul-persona-card')
|
|
1115
|
+
.find((c) => c.props['data-current'] === 'false' && typeof c.props.onClick === 'function')
|
|
1116
|
+
assert.ok(side, '前置条件:应存在可点的叠层侧边卡')
|
|
1117
|
+
const sideFront = findAll(side, (n) => typeof n.props?.className === 'string' && String(n.props.className).includes('soul-persona-face-front'))
|
|
1118
|
+
assert.equal(
|
|
1119
|
+
Boolean(sideFront[0] && sideFront[0].props.onClick),
|
|
1120
|
+
false,
|
|
1121
|
+
'叠层侧边卡的正面不得挂翻面 onClick(会与翻页冲突,导致新当前卡以背面出现)'
|
|
1122
|
+
)
|
|
1123
|
+
// ② 点它 ⇒ 只翻页
|
|
1124
|
+
await side.props.onClick()
|
|
1125
|
+
for (let i = 0; i < 6; i++) await new Promise((r) => drainTick(r))
|
|
1126
|
+
const after = await renderSettings(world)
|
|
1127
|
+
const cur = findAllByClass(after, 'soul-persona-card').filter((c) => c.props['data-current'] === 'true')
|
|
1128
|
+
assert.equal(cur.length, 1, '翻页后应恰好一张当前卡')
|
|
1129
|
+
assert.equal(cur[0].props['data-flipped'], 'false', '翻页后新当前卡必须是正面(bug 症状:直接背面)')
|
|
1130
|
+
// ③ 当前卡的正面仍必须可点翻面(修复不能把功能一并去掉)
|
|
1131
|
+
const curFront = findAll(cur[0], (n) => typeof n.props?.className === 'string' && String(n.props.className).includes('soul-persona-face-front'))
|
|
1132
|
+
assert.ok(curFront[0] && typeof curFront[0].props.onClick === 'function', '当前卡正面应保留整面点击翻面')
|
|
1133
|
+
// ④ 翻到背面后,正面的整面点击必须撤掉(否则背面点击会再次触发翻面回调)
|
|
1134
|
+
await curFront[0].props.onClick() // 翻到背面
|
|
1135
|
+
for (let i = 0; i < 6; i++) await new Promise((r) => drainTick(r))
|
|
1136
|
+
const flippedTree = await renderSettings(world)
|
|
1137
|
+
const flippedCard = findAllByClass(flippedTree, 'soul-persona-card').find((c) => c.props['data-flipped'] === 'true')
|
|
1138
|
+
assert.ok(flippedCard, '前置条件:应有一张卡处于背面')
|
|
1139
|
+
const flippedFront = findAll(flippedCard, (n) => typeof n.props?.className === 'string' && String(n.props.className).includes('soul-persona-face-front'))
|
|
1140
|
+
assert.equal(
|
|
1141
|
+
Boolean(flippedFront[0] && flippedFront[0].props.onClick),
|
|
1142
|
+
false,
|
|
1143
|
+
'背面状态下正面不得再挂整面点击(否则会重复触发翻面)'
|
|
1144
|
+
)
|
|
1145
|
+
// ⑤ 卡背整面点击=返回正面(与正面「整面点击翻到背面」对称)
|
|
1146
|
+
const backFace = findAll(flippedCard, (n) => typeof n.props?.className === 'string' && String(n.props.className).includes('soul-persona-face-back'))
|
|
1147
|
+
assert.ok(backFace[0] && typeof backFace[0].props.onClick === 'function', '卡背应整面可点返回')
|
|
1148
|
+
// ⑥ 但拖选文字时不得返回:模拟一个非折叠选区
|
|
1149
|
+
const realGetSelection = globalThis.getSelection
|
|
1150
|
+
try {
|
|
1151
|
+
globalThis.getSelection = () => ({ isCollapsed: false, toString: () => '选中的指令文本' })
|
|
1152
|
+
backFace[0].props.onClick()
|
|
1153
|
+
for (let i = 0; i < 4; i++) await new Promise((r) => drainTick(r))
|
|
1154
|
+
const stillFlipped = findAllByClass(await renderSettings(world), 'soul-persona-card').filter((c) => c.props['data-flipped'] === 'true')
|
|
1155
|
+
assert.equal(stillFlipped.length, 1, '拖选文字时不得翻回正面(否则一选就跳走)')
|
|
1156
|
+
// ⑦ 无选区时点击 ⇒ 正常返回
|
|
1157
|
+
globalThis.getSelection = () => ({ isCollapsed: true, toString: () => '' })
|
|
1158
|
+
backFace[0].props.onClick()
|
|
1159
|
+
for (let i = 0; i < 4; i++) await new Promise((r) => drainTick(r))
|
|
1160
|
+
const afterBack = findAllByClass(await renderSettings(world), 'soul-persona-card').filter((c) => c.props['data-flipped'] === 'true')
|
|
1161
|
+
assert.equal(afterBack.length, 0, '无选区时点卡背应返回正面')
|
|
1162
|
+
} finally {
|
|
1163
|
+
if (realGetSelection === undefined) delete globalThis.getSelection
|
|
1164
|
+
else globalThis.getSelection = realGetSelection
|
|
1165
|
+
}
|
|
1166
|
+
})
|
|
1167
|
+
|
|
1168
|
+
await check('C18 展开「人设预设」时自动定位到当前生效的那张(而不是永远第 1 张)', async () => {
|
|
1169
|
+
// 需求背景:卡片一次只展示一张,内置项排在前面,生效项(尤其是自建)往往在后面。
|
|
1170
|
+
// 若固定从第 1 张开始,用户点开分组后要翻好几下才能看到自己在用哪个。
|
|
1171
|
+
//
|
|
1172
|
+
// 这里用**行为**验证(不看源码):生效项排在第 2 位 ⇒ 展开后应直接停在 2 / 2。
|
|
1173
|
+
const personaLib = {
|
|
1174
|
+
ok: true,
|
|
1175
|
+
personas: {
|
|
1176
|
+
苏格拉底式提问者: { style: 'professional', builtin: true },
|
|
1177
|
+
我的自建: { style: 'roast', emoji: 'more' }
|
|
1178
|
+
},
|
|
1179
|
+
activeName: '我的自建' // ← 生效项是第 2 个(内置排前面)
|
|
1180
|
+
}
|
|
1181
|
+
const open = async (w) => {
|
|
1182
|
+
const closed = await renderSettings(w)
|
|
1183
|
+
const trigger = findAll(closed, (n) => n.type === 'button' && textOf(n).includes(w.dicts.zh['group.personas']))
|
|
1184
|
+
await trigger[0].props.onClick()
|
|
1185
|
+
return renderSettings(w)
|
|
1186
|
+
}
|
|
1187
|
+
// ① 展开即定位到生效项
|
|
1188
|
+
const w1 = await boot({}, personaLib)
|
|
1189
|
+
const t1 = await open(w1)
|
|
1190
|
+
assert.equal(
|
|
1191
|
+
textOf(findAllByClass(t1, 'soul-persona-card-pos')[0]),
|
|
1192
|
+
'2 / 2',
|
|
1193
|
+
'展开后应定位到生效项(第 2 张),而不是停在首张'
|
|
1194
|
+
)
|
|
1195
|
+
// ② 定位的是**生效项本身**,不是碰巧的末张 —— 换个生效项再验一次
|
|
1196
|
+
const w2 = await boot({}, {
|
|
1197
|
+
ok: true,
|
|
1198
|
+
personas: personaLib.personas,
|
|
1199
|
+
activeName: '苏格拉底式提问者' // ← 生效项改成第 1 个
|
|
1200
|
+
})
|
|
1201
|
+
const t2 = await open(w2)
|
|
1202
|
+
assert.equal(
|
|
1203
|
+
textOf(findAllByClass(t2, 'soul-persona-card-pos')[0]),
|
|
1204
|
+
'1 / 2',
|
|
1205
|
+
'生效项换到第 1 个时应定位到 1 / 2(证明是按生效项定位,而不是恒取末张)'
|
|
1206
|
+
)
|
|
1207
|
+
// ③ 无生效项(都不匹配)时退回首张 —— 总要给个确定落点
|
|
1208
|
+
const w3 = await boot({}, { ok: true, personas: personaLib.personas, activeName: null })
|
|
1209
|
+
const t3 = await open(w3)
|
|
1210
|
+
assert.equal(
|
|
1211
|
+
textOf(findAllByClass(t3, 'soul-persona-card-pos')[0]),
|
|
1212
|
+
'1 / 2',
|
|
1213
|
+
'没有生效项时应退回第 1 张'
|
|
1214
|
+
)
|
|
1215
|
+
// ④ 手动翻页后**不能被轮询拽回** —— 展开期间刷新 personas 不应重置位置
|
|
1216
|
+
// (实现上用 autoPositionedRef 记住「本次展开已定位过」)
|
|
1217
|
+
const moved = await (async () => {
|
|
1218
|
+
const nav = findAllByClass(t1, 'soul-persona-navbtn')
|
|
1219
|
+
await nav[0].props.onClick() // 生效项在末张 ⇒ 点「上一张」翻到首张
|
|
1220
|
+
return renderSettings(w1)
|
|
1221
|
+
})()
|
|
1222
|
+
assert.equal(
|
|
1223
|
+
textOf(findAllByClass(moved, 'soul-persona-card-pos')[0]),
|
|
1224
|
+
'1 / 2',
|
|
1225
|
+
'手动翻页后应停在 1 / 2'
|
|
1226
|
+
)
|
|
1227
|
+
// 触发一次「轮询刷新 personas」(内容不变)后位置必须还在 1 / 2
|
|
1228
|
+
await w1.controller.fetchPersonas?.()
|
|
1229
|
+
for (let i = 0; i < 6; i++) await new Promise((r) => drainTick(r))
|
|
1230
|
+
const afterRefresh = await renderSettings(w1)
|
|
1231
|
+
assert.equal(
|
|
1232
|
+
textOf(findAllByClass(afterRefresh, 'soul-persona-card-pos')[0]),
|
|
1233
|
+
'1 / 2',
|
|
1234
|
+
'展开期间轮询刷新不应把手动翻到的位置拽回生效项'
|
|
1235
|
+
)
|
|
1236
|
+
// ⑤ 折叠再展开 ⇒ 重新定位回生效项(需求原文:「每次点击开」都要展示当前使用的)
|
|
1237
|
+
const collapsed = await renderSettings(w1)
|
|
1238
|
+
const trigger2 = findAll(collapsed, (n) => n.type === 'button' && textOf(n).includes(w1.dicts.zh['group.personas']))
|
|
1239
|
+
await trigger2[0].props.onClick() // 折叠
|
|
1240
|
+
await renderSettings(w1)
|
|
1241
|
+
const reopened = await open(w1)
|
|
1242
|
+
assert.equal(
|
|
1243
|
+
textOf(findAllByClass(reopened, 'soul-persona-card-pos')[0]),
|
|
1244
|
+
'2 / 2',
|
|
1245
|
+
'折叠后再次展开应重新定位到生效项'
|
|
1246
|
+
)
|
|
1247
|
+
// ⑥ **展开时数据尚未就绪**(personas 仍是 null)不得抛错 —— 等数据到了再定位。
|
|
1248
|
+
// 这条专防「去掉空值守卫」:无守卫时 personaNames 为 null,
|
|
1249
|
+
// `null.indexOf(...)` 会抛 TypeError,直接把设置页渲染打断。
|
|
1250
|
+
// 桩用一个**永不 resolve** 的 promise 把 personas 钉在「加载中」。
|
|
1251
|
+
const w4 = loadClient({
|
|
1252
|
+
'/api/soul/config': (call) => (call.method === 'GET' ? { ok: true, config: { ...CONFIG } } : { ok: true, config: { ...CONFIG }, changed: [] }),
|
|
1253
|
+
'/api/soul/config/reset': () => ({ ok: true, config: { ...CONFIG }, changed: [], backupPath: null }),
|
|
1254
|
+
'/api/soul/personas': () => new Promise(() => {}),
|
|
1255
|
+
'/api/soul/prompt': () => ({ ok: true, prompt: 'P', enabled: true })
|
|
1256
|
+
})
|
|
1257
|
+
for (let i = 0; i < 8; i++) await new Promise((r) => drainTick(r))
|
|
1258
|
+
assert.equal(
|
|
1259
|
+
w4.controller.store.getSnapshot().personas,
|
|
1260
|
+
null,
|
|
1261
|
+
'前置条件:personas 应停在加载中(null)'
|
|
1262
|
+
)
|
|
1263
|
+
const closed4 = await renderSettings(w4)
|
|
1264
|
+
const trigger4 = findAll(closed4, (n) => n.type === 'button' && textOf(n).includes(w4.dicts.zh['group.personas']))
|
|
1265
|
+
await trigger4[0].props.onClick()
|
|
1266
|
+
const loadingTree = await renderSettings(w4) // 不应抛错
|
|
1267
|
+
assert.ok(loadingTree, '展开时数据未就绪不应导致渲染失败')
|
|
1268
|
+
assert.equal(
|
|
1269
|
+
findAllByClass(loadingTree, 'soul-persona-card-pos').length,
|
|
1270
|
+
0,
|
|
1271
|
+
'数据未就绪时不应出现位置指示(此时还没有任何卡片)'
|
|
1272
|
+
)
|
|
1273
|
+
// ⑦ 更刁的组合:**personas 加载中(null)但 activePersona 已有值**。
|
|
1274
|
+
// 真实场景:先应用过某个预设(配置与它匹配),随后刷新页面 —— 配置先到、
|
|
1275
|
+
// 预设列表还在路上。此时若去掉 `!personaNames` 守卫,
|
|
1276
|
+
// `personaNames.indexOf(...)` 会对 null 调用而抛 TypeError,直接打断设置页渲染。
|
|
1277
|
+
// (上面 ⑥ 的桩里 activePersona 为 null,走的是 `: -1` 分支,覆盖不到这一条。)
|
|
1278
|
+
const w5 = loadClient({
|
|
1279
|
+
// 配置与桩里那个预签名一致 ⇒ 宿主会回报 activeName
|
|
1280
|
+
'/api/soul/config': (call) => (call.method === 'GET' ? { ok: true, config: { ...CONFIG } } : { ok: true, config: { ...CONFIG }, changed: [] }),
|
|
1281
|
+
'/api/soul/config/reset': () => ({ ok: true, config: { ...CONFIG }, changed: [], backupPath: null }),
|
|
1282
|
+
'/api/soul/personas': () => new Promise(() => {}),
|
|
1283
|
+
'/api/soul/prompt': () => ({ ok: true, prompt: 'P', enabled: true })
|
|
1284
|
+
})
|
|
1285
|
+
for (let i = 0; i < 8; i++) await new Promise((r) => drainTick(r))
|
|
1286
|
+
// 直接往 store 里塞一个非空 activePersona,复刻「配置先到、预设列表未到」
|
|
1287
|
+
w5.controller.store.update((s) => { s.activePersona = '苏格拉底式提问者' })
|
|
1288
|
+
for (let i = 0; i < 4; i++) await new Promise((r) => drainTick(r))
|
|
1289
|
+
const closed5 = await renderSettings(w5)
|
|
1290
|
+
const trigger5 = findAll(closed5, (n) => n.type === 'button' && textOf(n).includes(w5.dicts.zh['group.personas']))
|
|
1291
|
+
await trigger5[0].props.onClick()
|
|
1292
|
+
const tree5 = await renderSettings(w5) // ← 去掉守卫时这里会抛 TypeError
|
|
1293
|
+
assert.ok(tree5, '预设列表加载中但已有生效人设时,展开分组不应抛错')
|
|
1294
|
+
assert.equal(
|
|
1295
|
+
findAllByClass(tree5, 'soul-persona-card-pos').length,
|
|
1296
|
+
0,
|
|
1297
|
+
'预设列表未就绪时不应出现位置指示'
|
|
1298
|
+
)
|
|
1299
|
+
})
|
|
1300
|
+
|
|
1301
|
+
await check('C17 应用预设成功后清掉上一轮的失败提示', async () => {
|
|
1302
|
+
// 该端点不返回 deliveryWarning;此前不清 error ⇒ 上一轮保存失败过的红色提示
|
|
1303
|
+
// 会一直挂在设置页上,即使随后切预设成功。
|
|
1304
|
+
const world = await boot()
|
|
1305
|
+
world.routes['/api/soul/config'] = (call) => (call.method === 'GET' ? { ok: true, config: { ...CONFIG } } : false)
|
|
1306
|
+
const failed = await world.controller.saveConfig({ style: 'casual' })
|
|
1307
|
+
assert.equal(failed, undefined, '前置条件:保存失败应返回 undefined')
|
|
1308
|
+
assert.equal(world.controller.store.getSnapshot().error, 'boom', '前置条件:失败原因应写进 store')
|
|
1309
|
+
world.routes['/api/soul/personas/use'] = () => ({ ok: true, config: { ...CONFIG }, changed: ['style'], invalid: [] })
|
|
1310
|
+
await world.controller.usePersona('任意')
|
|
1311
|
+
assert.equal(world.controller.store.getSnapshot().error, null, '应用预设成功后不得残留上一轮的失败提示')
|
|
1312
|
+
})
|
|
1313
|
+
} finally {
|
|
1314
|
+
restoreTimers()
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
if (!assertCount('verify-client', passed, EXPECTED_ASSERTIONS)) process.exit(1)
|
|
1318
|
+
|
|
1319
|
+
console.log(`\n全部通过:${passed} 项检查`)
|