dsh-soul 0.7.1 → 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/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 +988 -222
- package/index.mjs +308 -10
- package/lib/personas.mjs +31 -6
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +3 -1
- package/scripts/verify-client.mjs +651 -32
- package/scripts/verify-config.mjs +1290 -85
|
@@ -26,7 +26,7 @@ import { assertCount } from './lib/skip-report.mjs'
|
|
|
26
26
|
|
|
27
27
|
// 基线运行应跑出的断言数。本脚本没有环境相关跳过(客户端源码与 Node 都在),
|
|
28
28
|
// 所以链里的 `--strict` 对它无额外语义;数字由下面的 assertCount 守住。
|
|
29
|
-
const EXPECTED_ASSERTIONS =
|
|
29
|
+
const EXPECTED_ASSERTIONS = 23
|
|
30
30
|
|
|
31
31
|
const source = readFileSync(new URL('../client/index.mjs', import.meta.url), 'utf8')
|
|
32
32
|
|
|
@@ -129,6 +129,20 @@ function createRuntime() {
|
|
|
129
129
|
React,
|
|
130
130
|
jsx,
|
|
131
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
|
+
},
|
|
132
146
|
/** 渲染 + 跑本轮 effect,直到状态不再变化(含 effect / handler 里已 resolve 的 promise)。 */
|
|
133
147
|
async settle(root) {
|
|
134
148
|
for (let pass = 0; pass < 40; pass++) {
|
|
@@ -172,6 +186,20 @@ function findAll(tree, predicate) {
|
|
|
172
186
|
return hits
|
|
173
187
|
}
|
|
174
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
|
+
|
|
175
203
|
// ==================== 三、沙箱:假 window / document / 定时器 / fetch ====================
|
|
176
204
|
|
|
177
205
|
const realTimers = {
|
|
@@ -233,7 +261,7 @@ function loadClient(routes) {
|
|
|
233
261
|
})
|
|
234
262
|
const windowStub = {
|
|
235
263
|
__ModuleLoader__: { load: (m) => { factory = m.factory } },
|
|
236
|
-
addEventListener() {},
|
|
264
|
+
addEventListener: (type) => { captured.events.window.push(type) },
|
|
237
265
|
removeEventListener() {},
|
|
238
266
|
location: { origin: 'http://dsh.test' }
|
|
239
267
|
}
|
|
@@ -241,7 +269,7 @@ function loadClient(routes) {
|
|
|
241
269
|
visibilityState: 'visible',
|
|
242
270
|
body: domNode(),
|
|
243
271
|
head: domNode(),
|
|
244
|
-
addEventListener() {},
|
|
272
|
+
addEventListener: (type) => { captured.events.document.push(type) },
|
|
245
273
|
removeEventListener() {},
|
|
246
274
|
// 插件在装配时会把样式表插到 head(用 querySelector 判重),导航图标那一层还会
|
|
247
275
|
// 挂 MutationObserver —— 这里只需让这些调用成立;本脚本断言的是行为,不是 DOM。
|
|
@@ -276,9 +304,10 @@ function loadClient(routes) {
|
|
|
276
304
|
if (spec === 'react') return runtime.React
|
|
277
305
|
if (spec === 'react/jsx-runtime') return { jsx: runtime.jsx, jsxs: runtime.jsx, Fragment }
|
|
278
306
|
if (spec === '@deepseek-ai/dsh-client-ui-primitives') {
|
|
279
|
-
// ui.Button
|
|
280
|
-
//
|
|
281
|
-
|
|
307
|
+
// ui.Button / ui.Toast 单独换成宿主元素标记:这样它们连同 onClick / text /
|
|
308
|
+
// tone / holdMs / onDone 原样留在渲染树里,断言就能像用户一样
|
|
309
|
+
// 「找到那个按钮并点它」、「读出提示条的文案与色调」,而不是去猜某个闭包。
|
|
310
|
+
return { Button: 'ui-button', Toast: 'ui-toast' }
|
|
282
311
|
}
|
|
283
312
|
throw new Error(`未预料的 require:${spec}`)
|
|
284
313
|
}
|
|
@@ -286,7 +315,10 @@ function loadClient(routes) {
|
|
|
286
315
|
new Function('window', source)(windowStub)
|
|
287
316
|
const module = factory(requireStub)
|
|
288
317
|
|
|
289
|
-
|
|
318
|
+
// events:window/document 上注册过的事件名。用于断言「轮询与页面级监听挂在
|
|
319
|
+
// **装配层**」—— 此前它们全在 SoulSettings 组件内,设置页没挂载时全部失效
|
|
320
|
+
// (底栏人设名因此不刷新)。stub 原本是空实现,这里让它可观测。
|
|
321
|
+
const captured = { provide: {}, sections: [], dicts: null, runtime, events: { window: [], document: [] } }
|
|
290
322
|
const ctx = {
|
|
291
323
|
provide: (name, value) => { captured.provide[name] = value },
|
|
292
324
|
effect: (fn) => { const dispose = fn(); if (typeof dispose === 'function') captured.sections.push({ dispose }) },
|
|
@@ -401,12 +433,16 @@ async function clickButton(world, labelKey) {
|
|
|
401
433
|
assert.equal(buttons.length, 1, `期望恰好一个「${label}」按钮,实际 ${buttons.length} 个`)
|
|
402
434
|
await buttons[0].props.onClick()
|
|
403
435
|
const after = await renderSettings(world)
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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' }
|
|
409
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' }
|
|
410
446
|
}
|
|
411
447
|
|
|
412
448
|
// ==================== 五、用例 ====================
|
|
@@ -505,7 +541,7 @@ try {
|
|
|
505
541
|
const toast = await clickButton(world, 'button.reset')
|
|
506
542
|
assert.equal(toast.kind, 'error', `重置失败的提示应为错误样式,实际 ${toast.kind}`)
|
|
507
543
|
assert.equal(toast.text, world.dicts.zh['toast.resetFailed'])
|
|
508
|
-
assert.notEqual(toast.text, world.dicts.zh['
|
|
544
|
+
assert.notEqual(toast.text, world.dicts.zh['notice.reset'])
|
|
509
545
|
})
|
|
510
546
|
|
|
511
547
|
await check('C9 重置成功且丢弃过损坏文件时,提示「已备份」(而不是普通的「已重置」)', async () => {
|
|
@@ -515,14 +551,14 @@ try {
|
|
|
515
551
|
})
|
|
516
552
|
const toast = await clickButton(world, 'button.reset')
|
|
517
553
|
assert.equal(toast.kind, 'success')
|
|
518
|
-
assert.equal(toast.text, world.dicts.zh['
|
|
554
|
+
assert.equal(toast.text, world.dicts.zh['notice.resetRecovered'])
|
|
519
555
|
})
|
|
520
556
|
|
|
521
557
|
await check('C10 普通重置只提示「已重置」,且不留残余的送达警告', async () => {
|
|
522
558
|
const world = await boot({ deliveryWarning: '旧的送达警告' })
|
|
523
559
|
world.routes['/api/soul/config/reset'] = () => ({ ok: true, config: { ...CONFIG }, changed: [], backupPath: null })
|
|
524
560
|
const toast = await clickButton(world, 'button.reset')
|
|
525
|
-
assert.equal(toast.text, world.dicts.zh['
|
|
561
|
+
assert.equal(toast.text, world.dicts.zh['notice.reset'])
|
|
526
562
|
assert.equal(world.controller.store.getSnapshot().error, null, '重置后不应残留上一轮的送达警告')
|
|
527
563
|
})
|
|
528
564
|
|
|
@@ -534,7 +570,7 @@ try {
|
|
|
534
570
|
const toast = await clickButton(world, 'button.save')
|
|
535
571
|
assert.equal(toast.kind, 'error')
|
|
536
572
|
assert.equal(toast.text, world.dicts.zh['toast.saveFailed'])
|
|
537
|
-
assert.notEqual(toast.text, world.dicts.zh['
|
|
573
|
+
assert.notEqual(toast.text, world.dicts.zh['notice.noChanges'])
|
|
538
574
|
})
|
|
539
575
|
|
|
540
576
|
await check('C12 设置页真的渲染出可点的保存 / 重置按钮(断言不是对着空树跑)', async () => {
|
|
@@ -632,7 +668,7 @@ try {
|
|
|
632
668
|
)
|
|
633
669
|
})
|
|
634
670
|
|
|
635
|
-
await check('C16
|
|
671
|
+
await check('C16 人设卡片按宿主协议渲染:★ 生效标记、内置徽标、卡片堆叠与翻页', async () => {
|
|
636
672
|
// 这条补的是一个**覆盖空洞**:此前桩返回 `{ personas: [], activePersona: null }`
|
|
637
673
|
// (字段名与类型都与宿主不符),于是人设行渲染与 ★ 标记从未被执行过 —— 把桩改成
|
|
638
674
|
// 真实形状后,整套 C 系列仍全绿(实测),说明没有任何断言吃过这份数据。
|
|
@@ -659,24 +695,607 @@ try {
|
|
|
659
695
|
'activeName 未被写进 store.activePersona —— 字段名与宿主协议不符'
|
|
660
696
|
)
|
|
661
697
|
|
|
662
|
-
// ②
|
|
663
|
-
const
|
|
664
|
-
assert.equal(
|
|
665
|
-
const
|
|
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(' | ')}`)
|
|
666
709
|
|
|
667
710
|
// ③ 生效预设带 ★,非生效的不带(防空过:两个方向都要断言,否则「恒加★」也会过)
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
assert.ok(
|
|
673
|
-
assert.ok(!
|
|
674
|
-
|
|
675
|
-
// ④ 内置行带「内置」标记、且不渲染删除按钮;自建行相反
|
|
676
|
-
assert.ok(idleRow.includes(world.dicts.zh['personas.builtin']), `内置行应带「内置」标记:${idleRow}`)
|
|
677
|
-
assert.ok(!activeRow.includes(world.dicts.zh['personas.builtin']), `自建行不应带「内置」标记:${activeRow}`)
|
|
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}`)
|
|
678
717
|
const deleteButtons = findAll(tree, (n) => n.type === 'button' && textOf(n) === world.dicts.zh['personas.delete'])
|
|
679
|
-
assert.equal(deleteButtons.length, 1,
|
|
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
|
+
)
|
|
680
1299
|
})
|
|
681
1300
|
|
|
682
1301
|
await check('C17 应用预设成功后清掉上一轮的失败提示', async () => {
|