dsh-soul 0.7.2 → 0.8.0

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.
@@ -26,7 +26,7 @@ import { assertCount } from './lib/skip-report.mjs'
26
26
 
27
27
  // 基线运行应跑出的断言数。本脚本没有环境相关跳过(客户端源码与 Node 都在),
28
28
  // 所以链里的 `--strict` 对它无额外语义;数字由下面的 assertCount 守住。
29
- const EXPECTED_ASSERTIONS = 23
29
+ const EXPECTED_ASSERTIONS = 30
30
30
 
31
31
  const source = readFileSync(new URL('../client/index.mjs', import.meta.url), 'utf8')
32
32
 
@@ -143,13 +143,22 @@ function createRuntime() {
143
143
  dirty = false
144
144
  queue = []
145
145
  },
146
- /** 渲染 + 跑本轮 effect,直到状态不再变化(含 effect / handler 里已 resolve 的 promise)。 */
146
+ /**
147
+ * 渲染 + 跑 effect,直到**状态与异步副作用都稳定**,并返回稳定后的那棵树。
148
+ *
149
+ * 踩过的坑:上一版在 `if (!dirty) return tree` 时返回的是**倒数第二轮**渲染的树 ——
150
+ * 而「最后一轮」什么都没做(dirty 仍为 false,是上一轮的 false 摆在那)。于是
151
+ * 由 `await` 触发的 setState(例如点「绑定」后 handleBind 里 resolve 的那次)
152
+ * 虽然被记进了 frame,却**永远没有出现在返回的树里**:断言看到的是旧画面。
153
+ */
147
154
  async settle(root) {
155
+ let tree = null
148
156
  for (let pass = 0; pass < 40; pass++) {
149
157
  dirty = false
150
158
  queue = []
151
- const tree = render(root, 'root')
152
- for (const { frame, slot, fn } of queue) {
159
+ tree = render(root, 'root')
160
+ const effects = queue
161
+ for (const { frame, slot, fn } of effects) {
153
162
  const key = `${frame.key}#${slot}`
154
163
  const previous = cleanups.get(key)
155
164
  if (typeof previous === 'function') previous()
@@ -157,7 +166,12 @@ function createRuntime() {
157
166
  cleanups.set(key, typeof returned === 'function' ? returned : null)
158
167
  }
159
168
  await drain()
160
- if (!dirty) return tree
169
+ if (!dirty) {
170
+ // 本帧的 effect 都已执行完。若这一帧**一个 effect 都没跑**,它就是最终画面
171
+ // (上一轮的 tree 与它等价);否则这一帧仍是「状态刚被 effect 改写、尚未重画」
172
+ // 的中间态,必须再渲一轮才把新状态画出来。
173
+ return effects.length === 0 ? tree : render(root, 'root')
174
+ }
161
175
  }
162
176
  throw new Error('渲染未收敛(40 轮仍在变化)—— hook 依赖或 effect 里可能有自激循环')
163
177
  }
@@ -245,7 +259,7 @@ const interpolate = (text, params) =>
245
259
  String(text).replace(/\{(\w+)\}/g, (all, key) => (params && key in params ? String(params[key]) : all))
246
260
 
247
261
  /** 载入真实客户端 bundle 并装配插件(返回真实 controller 与设置页组件)。 */
248
- function loadClient(routes) {
262
+ function loadClient(routes, options = {}) {
249
263
  let factory = null
250
264
  const domNode = () => ({
251
265
  style: { setProperty() {}, removeProperty() {} },
@@ -319,9 +333,31 @@ function loadClient(routes) {
319
333
  // **装配层**」—— 此前它们全在 SoulSettings 组件内,设置页没挂载时全部失效
320
334
  // (底栏人设名因此不刷新)。stub 原本是空实现,这里让它可观测。
321
335
  const captured = { provide: {}, sections: [], dicts: null, runtime, events: { window: [], document: [] } }
336
+ // 目录点选服务桩:默认可用,用例可换成 `{ throw: '...' }` 走降级路径。
337
+ // 宿主 UiWorkspaceService.pickDirectory() 返回 string | null,失败时抛普通 Error
338
+ // (capability 被包在 cause 里、客户端读不到)—— 桩必须同形,见 DESIGN §7.5。
339
+ captured.uiWorkspace = {
340
+ pickDirectory: async () => null
341
+ }
342
+ // inject 的语义要跟宿主一致:**同步回调**、回调里的 ctx 就是新 scope。
343
+ // 返回的是一个可 unload 的 fiber;这里只记录已触发次数与卸载函数。
344
+ captured.injected = []
345
+ captured.uiWorkspaceAbsent = options.uiWorkspace === null
322
346
  const ctx = {
323
347
  provide: (name, value) => { captured.provide[name] = value },
324
348
  effect: (fn) => { const dispose = fn(); if (typeof dispose === 'function') captured.sections.push({ dispose }) },
349
+ // 动态 inject:只有 uiWorkspace 一个依赖。**不调用**回调 = 服务缺席,
350
+ // 正好用来断言「缺席时插件照常装配、只是点选按钮降级」。
351
+ inject: (deps, cb) => {
352
+ captured.injected.push({ deps, cb })
353
+ const requested = Array.isArray(deps) ? deps : Object.keys(deps || {})
354
+ if (!requested.includes('uiWorkspace')) return
355
+ if (captured.uiWorkspaceAbsent) return
356
+ cb({
357
+ uiWorkspace: captured.uiWorkspace,
358
+ effect: (fn) => { const dispose = fn(); if (typeof dispose === 'function') captured.sections.push({ dispose }) }
359
+ })
360
+ },
325
361
  locale: {
326
362
  register: (_ns, dicts) => { captured.dicts = dicts },
327
363
  bind: () => (key, params) => interpolate((captured.dicts?.zh || {})[key] ?? key, params)
@@ -362,7 +398,7 @@ function loadClient(routes) {
362
398
  * **从未被执行过**:把它改成真实形状,整套 C 系列依然全绿(实测)。这正是「测试替身
363
399
  * 与真实协议漂移」的典型:桩喂了一个真实环境不可达的输入,测试永远看不到真问题。
364
400
  */
365
- async function boot(configPayload = {}, personaPayload = null) {
401
+ async function boot(configPayload = {}, personaPayload = null, options = {}) {
366
402
  fetchCalls = []
367
403
  timerQueue = []
368
404
  const routes = {
@@ -373,12 +409,12 @@ async function boot(configPayload = {}, personaPayload = null) {
373
409
  // 默认形状贴近宿主:对象 map + activeName(不是数组、不是 activePersona)
374
410
  '/api/soul/personas': () => (personaPayload || {
375
411
  ok: true,
376
- personas: { 苏格拉底式提问者: { style: 'professional', builtin: true } },
412
+ personas: { 苏格拉底式提问者: { style: 'professional', builtin: true }, 极简主义者: { style: 'efficient' } },
377
413
  activeName: null
378
414
  }),
379
415
  '/api/soul/prompt': () => ({ ok: true, prompt: 'P', enabled: true })
380
416
  }
381
- const world = loadClient(routes)
417
+ const world = loadClient(routes, options)
382
418
  for (let i = 0; i < 8; i++) await new Promise((r) => drainTick(r))
383
419
  return world
384
420
  }
@@ -400,6 +436,18 @@ function rootElement(world) {
400
436
 
401
437
  const renderSettings = (world) => world.runtime.settle(rootElement(world))
402
438
 
439
+ /**
440
+ * 渲染设置页并**等异步副作用落地**(点击后的请求、effect 里的 setState)。
441
+ *
442
+ * `settle` 只保证「状态收敛」,而一个 handler 里的 `await fetch` 要等下一轮
443
+ * microtask 才把结果写回 store。断言「点完之后界面上有什么」时必须用它,
444
+ * 否则读到的是点击前那一帧(本文件踩过:绿字已进 store 却读不出来)。
445
+ */
446
+ async function renderSettingsSettled(world) {
447
+ for (let i = 0; i < 6; i++) await new Promise((r) => drainTick(r))
448
+ return world.runtime.settle(rootElement(world))
449
+ }
450
+
403
451
  /** 输入框左侧的快捷开关(conversation.input.left 槽位),渲染方式与设置页同源。 */
404
452
  function quickToggleElement(world) {
405
453
  const slot = world.captured.sections.find((s) => s.options && s.options.name === 'conversation.input.left')
@@ -1310,6 +1358,497 @@ await check('C20 点叠层侧边卡只翻页、不翻面(整面点击必须限
1310
1358
  await world.controller.usePersona('任意')
1311
1359
  assert.equal(world.controller.store.getSnapshot().error, null, '应用预设成功后不得残留上一轮的失败提示')
1312
1360
  })
1361
+
1362
+ // ==================== 插件详情页「变更记录」区块(0.8.1 双入口)====================
1363
+ //
1364
+ // 0.8.1 起变更记录的主入口是宿主插件详情页(plugins.detail.section 槽位),
1365
+ // 设置页只留一行指引。渲染组件是 SoulHistorySection,判据是
1366
+ // subject.kind === 'bundle' && subject.pkg.name === 'dsh-soul'——
1367
+ // 所以这里的渲染语境是「详情页」:挂载即拉取(不再有「展开才拉」),
1368
+ // subject 不是 dsh-soul 时必须渲染 null(槽位契约原话)。
1369
+
1370
+ /** 详情页槽位(plugins.detail.section)与它的渲染辅助。 */
1371
+ function detailSectionOf(world) {
1372
+ const slot = world.captured.sections.find((s) => s.options && s.options.name === 'plugins.detail.section')
1373
+ assert.ok(slot, '未注册 plugins.detail.section 槽位')
1374
+ assert.equal(slot.options.id, 'soul-detail-history', '详情页区块的 id 必须是 soul-detail-history')
1375
+ return slot
1376
+ }
1377
+ /** 渲染详情页区块。subject 模拟宿主 PackageDetail 的 bundle 形态(packageRef);
1378
+ * subject 缺省才补默认值 —— 显式传 null 必须原样传入(判据要吃得到)。 */
1379
+ async function renderDetail(world, subject) {
1380
+ const slot = detailSectionOf(world)
1381
+ const props = slot.options.inject()
1382
+ const store = props.hooks.soulController
1383
+ const useSoulController = (selector) => {
1384
+ const [, force] = world.runtime.React.useState(0)
1385
+ world.runtime.React.useEffect(() => store.subscribe(() => force((n) => n + 1)), [])
1386
+ return selector(store.getSnapshot())
1387
+ }
1388
+ const pkg = subject === undefined
1389
+ ? { kind: 'bundle', pkg: { name: 'dsh-soul', installed: true, enabled: true, rows: [] } }
1390
+ : subject
1391
+ return world.runtime.settle({
1392
+ type: slot.component,
1393
+ props: { ...props, useSoulController, t: (key, params) => interpolate((world.dicts?.zh || {})[key] ?? key, params), subject: pkg }
1394
+ })
1395
+ }
1396
+ /** 渲染 + 排空异步(挂载拉取与随后的 setState 都要跑完)。 */
1397
+ async function renderDetailSettled(world, subject) {
1398
+ await renderDetail(world, subject)
1399
+ for (let i = 0; i < 8; i++) await new Promise((r) => drainTick(r))
1400
+ return await renderDetail(world, subject)
1401
+ }
1402
+
1403
+ /** 造一条服务端形状的变更记录(before/after 只装变化字段,personas 恒三段)。 */
1404
+ function historyEntry(over) {
1405
+ return {
1406
+ v: 1,
1407
+ id: 'e-0',
1408
+ at: new Date().toISOString(),
1409
+ source: 'web',
1410
+ action: 'save',
1411
+ configHash: 'h',
1412
+ before: {},
1413
+ after: {},
1414
+ personas: { added: [], updated: [], removed: [] },
1415
+ ...over
1416
+ }
1417
+ }
1418
+ /** 唯一的成功绿字(`.soul-saved-notice`)的文案。 */
1419
+ function savedNoticeOf(tree) {
1420
+ const hits = findAllByClass(tree, 'soul-saved-notice')
1421
+ assert.equal(hits.length, 1, `期望恰好一条成功提示,实际 ${hits.length} 条`)
1422
+ return textOf(hits[0])
1423
+ }
1424
+
1425
+ await check('C24 变更记录详情页:subject 判据、挂载即拉取、按记录渲染时间/来源/字段', async () => {
1426
+ const entries = [
1427
+ historyEntry({
1428
+ id: 'aaa-1',
1429
+ at: new Date(Date.now() - 90 * 1000).toISOString(),
1430
+ before: { bio: '旧介绍', style: 'professional' },
1431
+ after: { bio: '', style: 'humorous' }
1432
+ }),
1433
+ historyEntry({
1434
+ id: 'bbb-2',
1435
+ at: new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(),
1436
+ source: 'command',
1437
+ action: 'set',
1438
+ personas: { added: ['头脑风暴伙伴'], updated: [], removed: [{ name: '极简主义者', entry: { style: 'efficient' } }] }
1439
+ }),
1440
+ // 刻意给一条「未来」的记录:服务端时钟略快或本机时钟回拨时真的会出现。
1441
+ // 时间差算出来是负数,显示「-1 分钟前」会让人以为记录坏了。
1442
+ historyEntry({
1443
+ id: 'ccc-3',
1444
+ at: new Date(Date.now() + 30 * 1000).toISOString(),
1445
+ source: 'persona',
1446
+ action: 'use',
1447
+ label: '苏格拉底式提问者',
1448
+ before: { style: 'professional' },
1449
+ after: { style: 'casual' }
1450
+ })
1451
+ ]
1452
+ const world = await boot()
1453
+ const calls = []
1454
+ world.routes['/api/soul/history'] = (call) => {
1455
+ calls.push(call)
1456
+ // 上限刻意不取 200:写死数字的实现在这条断言下会立刻露馅。
1457
+ // (「最多保留 200 条」是一句会被人当真的承诺,客户端只能转述服务端说的话。)
1458
+ return { ok: true, entries, max: 50, warning: null }
1459
+ }
1460
+ const zh = world.dicts.zh
1461
+
1462
+ // ① subject 判据:别的插件详情页 / 非 bundle 形态必须渲染 null(槽位契约)
1463
+ for (const bad of [
1464
+ null,
1465
+ { kind: 'item', pkg: { name: 'dsh-soul' } },
1466
+ { kind: 'row', pkg: { name: 'dsh-soul' } },
1467
+ { kind: 'bundle', pkg: { name: 'dsh-other-plugin' } }
1468
+ ]) {
1469
+ const tree = await renderDetail(world, bad)
1470
+ assert.equal(findAllByClass(tree, 'soul-detail-history').length, 0,
1471
+ `subject=${JSON.stringify(bad)} 不得渲染变更记录区块`)
1472
+ }
1473
+ assert.equal(calls.length, 0, '未渲染自己详情页时不得请求 /api/soul/history')
1474
+
1475
+ // ② 挂载 ⇒ 立即以 GET 拉取(详情页本来就是「专门来看记录」才打开的页面)
1476
+ const tree = await renderDetailSettled(world)
1477
+ assert.equal(calls.length, 1, '挂载详情页区块应恰好拉取一次')
1478
+ assert.equal(calls[0].method, 'GET', '拉取变更记录必须用 GET')
1479
+ assert.equal(calls[0].path, '/api/soul/history', '拉取路径必须是 /api/soul/history')
1480
+
1481
+ // ③ 顶部计数:上限由服务端带回,客户端不写死
1482
+ assert.equal(textOf(findAllByClass(tree, 'soul-detail-history-count')[0]),
1483
+ zh['history.summary'].replace('{count}', '3').replace('{max}', '50'),
1484
+ '计数行必须用服务端带回的条数与条数上限')
1485
+
1486
+ // ④ 每条行摘要:相对时间 · 来源 · 涉及字段(字段名复用设置页既有文案)
1487
+ const items = findAllByClass(tree, 'soul-history-item')
1488
+ assert.equal(items.length, 3, `应渲染 3 条记录,实际 ${items.length}`)
1489
+ assert.equal(textOf(findAllByClass(items[0], 'soul-history-time')[0]), '1 分钟前')
1490
+ assert.equal(textOf(findAllByClass(items[0], 'soul-history-source')[0]), zh['history.source.web'])
1491
+ assert.equal(textOf(findAllByClass(items[0], 'soul-history-fields')[0]),
1492
+ zh['history.fields'].replace('{fields}', `${zh['field.bio']}、${zh['field.style']}`))
1493
+ assert.equal(items[0].props['data-source'], 'web', '行上带来源标识,供按来源取样式')
1494
+ assert.equal(textOf(findAllByClass(items[1], 'soul-history-time')[0]), '3 小时前')
1495
+ assert.equal(textOf(findAllByClass(items[1], 'soul-history-fields')[0]),
1496
+ zh['history.fields'].replace('{fields}',
1497
+ `${zh['history.personasAdded'].replace('{names}', '头脑风暴伙伴')}、${zh['history.personasRemoved'].replace('{names}', '极简主义者')}`),
1498
+ '只有预设事件的记录也得有一句「涉及 …」,否则末尾悬着一个空壳')
1499
+ assert.equal(textOf(findAllByClass(items[2], 'soul-history-time')[0]), zh['history.timeJustNow'],
1500
+ '时间差为负(时钟回拨/服务端稍快)必须归入「刚刚」,不能显示「-1 分钟前」')
1501
+ // ④b label(预设名)必须出现在折叠摘要里 —— 不然「应用预设 · 风格」答不了「应用的是什么」;
1502
+ // 且拼在字段列表**之前**(先说什么预设,再说动了哪些字段,阅读顺序和提问顺序一致)。
1503
+ assert.equal(textOf(findAllByClass(items[2], 'soul-history-fields')[0]),
1504
+ zh['history.fields'].replace('{fields}',
1505
+ `${zh['history.labelSuffix'].replace('{label}', '苏格拉底式提问者')}、${zh['field.style']}`),
1506
+ '应用预设的记录必须在摘要里带预设名')
1507
+
1508
+ // ⑤ 拉取失败必须留下痕迹:否则永远停在「加载中」,与「真的没有记录」无从区分
1509
+ const bad = await boot()
1510
+ bad.routes['/api/soul/history'] = () => false
1511
+ const badTree = await renderDetailSettled(bad)
1512
+ const badErrors = findAllByClass(badTree, 'soul-error').map(textOf)
1513
+ assert.equal(badErrors.length, 1, `拉取失败应在这个区块里显示一条错误,实际 ${badErrors.length} 条`)
1514
+ assert.equal(badErrors[0], bad.dicts.zh['history.loadFailed'].replace('{reason}', 'boom'))
1515
+ // 计数行也得交代状态:失败时不能还挂着「最近 N 条」装作一切正常
1516
+ assert.equal(textOf(findAllByClass(badTree, 'soul-detail-history-count')[0]),
1517
+ bad.dicts.zh['accordion.loadFailed'], '失败时计数行必须显示「加载失败」')
1518
+ })
1519
+
1520
+ await check('C25 变更记录展开:原值→新值、空值占位、预设事件与恢复入口', async () => {
1521
+ const entries = [
1522
+ historyEntry({
1523
+ id: 'ct-1',
1524
+ at: new Date(Date.now() - 30 * 1000).toISOString(),
1525
+ // 刻意凑齐五种取值形态:枚举(要查词典)、被清空的文本(要显示「(空)」)、
1526
+ // 布尔(要显示「已开启/未开启」而不是 true/false)、**只出现在 before 里的键**
1527
+ //(「字段被删掉」的唯一表达方式,只遍历 after 的实现会把它整行漏掉 ——
1528
+ // 而漏掉的那一行恰是「谁把我的职业删了」),以及**枚举外的取值**。
1529
+ // 最后这种:配置文件被手工改坏时真的会出现(`trailSpeed: 'turbo'`),
1530
+ // 必须原样显示 —— 若不加 HISTORY_ENUM_VALUES 白名单就直接查词典,
1531
+ // `t('trail.speed.turbo')` 会渲染成裸键名,比显示原始值更难懂。
1532
+ before: { bio: '旧介绍', style: 'professional', requireToolConfirmation: false, occupation: '工程师', trailSpeed: 'slow' },
1533
+ after: { bio: '', style: 'humorous', requireToolConfirmation: true, trailSpeed: 'turbo' }
1534
+ }),
1535
+ historyEntry({
1536
+ id: 'ct-2',
1537
+ at: new Date(Date.now() - 30 * 1000).toISOString(),
1538
+ source: 'command',
1539
+ action: 'deletePreset',
1540
+ personas: { added: [], updated: [], removed: [{ name: '极简主义者', entry: { style: 'efficient' } }] }
1541
+ })
1542
+ ]
1543
+ const world = await boot()
1544
+ world.routes['/api/soul/history'] = () => ({ ok: true, entries, max: 200, warning: null })
1545
+ const zh = world.dicts.zh
1546
+ let tree = await renderDetailSettled(world)
1547
+
1548
+ // ① 默认不收开任何一行:20 条全铺开会比详情页还长
1549
+ assert.equal(findAllByClass(tree, 'soul-history-detail').length, 0, '默认不得展开任何一条记录')
1550
+ // ② 「恢复此预设」只给删过预设的那条 —— 别的记录点它无事可做
1551
+ const restoreButtons = findAllByClass(tree, 'soul-history-action')
1552
+ .filter((n) => textOf(n) === zh['history.restorePreset'])
1553
+ assert.equal(restoreButtons.length, 1, `「恢复此预设」应只出现在删过预设的记录上,实际 ${restoreButtons.length} 个`)
1554
+
1555
+ // ③ 展开第一条 ⇒ 逐字段「原值 → 新值」
1556
+ const mainOf = (index) => {
1557
+ const items = findAllByClass(tree, 'soul-history-item')
1558
+ const hit = findAllByClass(items[index], 'soul-history-item-main')
1559
+ assert.equal(hit.length, 1, '每条记录应恰好一个可展开主体')
1560
+ return hit[0]
1561
+ }
1562
+ await mainOf(0).props.onClick()
1563
+ tree = await renderDetail(world)
1564
+ const rows = findAllByClass(tree, 'soul-history-diff-row')
1565
+ assert.equal(rows.length, 5, `第一条应展开出 5 个字段,实际 ${rows.length}`)
1566
+ const rowOf = (label) => {
1567
+ const hit = rows.filter((r) => textOf(findAllByClass(r, 'soul-history-diff-field')[0]) === label)
1568
+ assert.equal(hit.length, 1, `应恰好一行「${label}」`)
1569
+ return hit[0]
1570
+ }
1571
+ const styleRow = rowOf(zh['field.style'])
1572
+ // 「展开即全文」:值放在 .soul-history-diff-value 里,块前有 .soul-history-diff-tag 标「原值/新值」
1573
+ const cellOf = (row, side) => {
1574
+ const cells = findAllByClass(row, 'soul-history-diff-cell')
1575
+ assert.equal(cells.length, side === 'from' ? 2 : 2, `展开行应有两块(原值/新值),实际 ${cells.length}`)
1576
+ const index = side === 'from' ? 0 : 1
1577
+ assert.equal(textOf(findAllByClass(cells[index], 'soul-history-diff-tag')[0]),
1578
+ zh[side === 'from' ? 'history.field.from' : 'history.field.to'],
1579
+ `值块前必须有「${side === 'from' ? '原值' : '新值'}」标签 —— 光靠位置分不清哪边是原值`)
1580
+ return findAllByClass(cells[index], 'soul-history-diff-value')[0]
1581
+ }
1582
+ assert.equal(textOf(cellOf(styleRow, 'from')), zh['style.professional'])
1583
+ assert.equal(textOf(cellOf(styleRow, 'to')), zh['style.humorous'])
1584
+ const bioRow = rowOf(zh['field.bio'])
1585
+ assert.equal(textOf(cellOf(bioRow, 'from')), '旧介绍')
1586
+ assert.equal(textOf(cellOf(bioRow, 'to')), zh['history.valueEmpty'],
1587
+ '被清空的字段必须显示「(空)」而不是留白 —— 留白会被读成渲染坏了')
1588
+ // ④ 布尔字段画的是「已开启/未开启」,不是 true/false
1589
+ const toolRow = rowOf(zh['field.toolConfirm'])
1590
+ assert.equal(textOf(cellOf(toolRow, 'from')), zh['accordion.off'])
1591
+ assert.equal(textOf(cellOf(toolRow, 'to')), zh['accordion.on'])
1592
+ // ⑤ 只出现在 before 里的键(字段被删掉)也必须占一行,新值那侧显示「(空)」
1593
+ const occRow = rowOf(zh['field.occupation'])
1594
+ assert.equal(textOf(cellOf(occRow, 'from')), '工程师')
1595
+ assert.equal(textOf(cellOf(occRow, 'to')), zh['history.valueEmpty'],
1596
+ '只在 before 里出现的字段必须照常占一行 —— 否则「谁把我这一项删了」就永远查不到')
1597
+ // ⑦ 枚举外的取值原样显示(查词典前必须先过取值白名单)
1598
+ const speedRow = rowOf(zh['trail.speed'])
1599
+ assert.equal(textOf(cellOf(speedRow, 'from')), zh['trail.speed.slow'])
1600
+ assert.equal(textOf(cellOf(speedRow, 'to')), 'turbo',
1601
+ '枚举外的取值必须原样显示 —— 直接查词典会渲染成裸键名 `trail.speed.turbo`')
1602
+
1603
+ // ⑧ 展开第二条:预设事件单独成行,并**取代**第一条的展开(同时只展开一行)
1604
+ await mainOf(1).props.onClick()
1605
+ tree = await renderDetail(world)
1606
+ const personaLines = findAllByClass(tree, 'soul-history-diff-persona').map(textOf)
1607
+ assert.ok(personaLines.includes(zh['history.personasRemoved'].replace('{names}', '极简主义者')),
1608
+ `应有一行「删除预设 极简主义者」,实际 ${JSON.stringify(personaLines)}`)
1609
+ assert.equal(findAllByClass(tree, 'soul-history-diff-row').length, 0,
1610
+ '展开第二条时第一条必须收起 —— 同时摊开多行会让区块长度不可控')
1611
+ // ⑨ 再点一次收起
1612
+ await mainOf(1).props.onClick()
1613
+ tree = await renderDetail(world)
1614
+ assert.equal(findAllByClass(tree, 'soul-history-detail').length, 0, '点第二次应收起')
1615
+ })
1616
+
1617
+ await check('C26 变更记录:撤销与清空都要过内联二次确认(不用原生 confirm)', async () => {
1618
+ const entries = [historyEntry({
1619
+ id: 'u-1',
1620
+ at: new Date().toISOString(),
1621
+ before: { style: 'professional' },
1622
+ after: { style: 'casual' }
1623
+ })]
1624
+ const world = await boot()
1625
+ const rollbacks = []
1626
+ const clears = []
1627
+ world.routes['/api/soul/history'] = () => ({ ok: true, entries, max: 200, warning: null })
1628
+ world.routes['/api/soul/history/rollback'] = (call) => {
1629
+ rollbacks.push(call.body)
1630
+ return {
1631
+ ok: true,
1632
+ config: { ...CONFIG },
1633
+ changed: ['style'],
1634
+ skipped: [],
1635
+ reversed: ['u-1'],
1636
+ unchanged: false,
1637
+ entry: null,
1638
+ deliveryWarning: null
1639
+ }
1640
+ }
1641
+ world.routes['/api/soul/history/clear'] = () => { clears.push(true); return { ok: true, removed: 1 } }
1642
+ const zh = world.dicts.zh
1643
+ let tree = await renderDetailSettled(world)
1644
+
1645
+ // ① 撤销:第一次点只进入待确认,一个请求都不发
1646
+ const undoBtn = () => findAllByClass(tree, 'soul-history-action')[0]
1647
+ assert.equal(textOf(undoBtn()), zh['history.restore'])
1648
+ await undoBtn().props.onClick()
1649
+ tree = await renderDetail(world)
1650
+ assert.equal(textOf(undoBtn()), zh['history.restoreArmed'], '第一次点后按钮应变成「撤销?」')
1651
+ assert.equal(rollbacks.length, 0, '第一次点「撤销这一次」不得直接发请求')
1652
+ // ② 第二次点才真的撤销
1653
+ await undoBtn().props.onClick()
1654
+ tree = await renderDetailSettled(world)
1655
+ assert.equal(rollbacks.length, 1, '第二次点「撤销?」才发请求')
1656
+ assert.equal(rollbacks[0].id, 'u-1', '撤销必须带上这一条的 id')
1657
+ assert.equal(savedNoticeOf(tree), zh['history.restoreDone'].replace('{count}', '1'))
1658
+
1659
+ // ③ 清空:同样的两步。它是这个区块唯一的危险色按钮(不可逆)
1660
+ const clearBtn = () => findAllByClass(tree, 'soul-history-clear')[0]
1661
+ assert.equal(textOf(clearBtn()), zh['history.clear'])
1662
+ await clearBtn().props.onClick()
1663
+ tree = await renderDetail(world)
1664
+ assert.equal(textOf(clearBtn()), zh['history.clearArmed'], '第一次点后按钮应变成「确认清空?」')
1665
+ assert.equal(clears.length, 0, '第一次点「清空记录」不得直接发请求')
1666
+ await clearBtn().props.onClick()
1667
+ tree = await renderDetailSettled(world)
1668
+ assert.equal(clears.length, 1, '第二次点「确认清空?」才发请求')
1669
+ assert.equal(savedNoticeOf(tree), zh['history.clearDone'].replace('{count}', '1'))
1670
+ })
1671
+
1672
+ await check('C27 变更记录:超过 20 条折叠可展开,warning 不得被当成失败', async () => {
1673
+ const many = Array.from({ length: 22 }, (_, i) => historyEntry({
1674
+ id: `m-${String(i).padStart(2, '0')}`,
1675
+ at: new Date(Date.now() - (i + 1) * 60 * 1000).toISOString(),
1676
+ before: { style: 'professional' },
1677
+ after: { style: 'humorous' }
1678
+ }))
1679
+ const world = await boot()
1680
+ world.routes['/api/soul/history'] = () => ({
1681
+ ok: true,
1682
+ entries: many,
1683
+ max: 200,
1684
+ warning: '记录文件较大,只读取了最近的部分。'
1685
+ })
1686
+ const zh = world.dicts.zh
1687
+ let tree = await renderDetailSettled(world)
1688
+
1689
+ // ① 一条一条铺开会让这个区块比详情页还长 ⇒ 只渲染 20 条
1690
+ assert.equal(findAllByClass(tree, 'soul-history-item').length, 20, '最多只渲染 20 条')
1691
+ // ② 剩下的必须有出口:「最多保留 200 条」这句承诺得配一个能看到更多的按钮
1692
+ const more = findAllByClass(tree, 'soul-history-more')
1693
+ assert.equal(more.length, 1, '超出 20 条时应给出「还有 N 条」')
1694
+ assert.equal(textOf(more[0]), zh['history.more'].replace('{count}', '2'))
1695
+ await more[0].props.onClick()
1696
+ tree = await renderDetail(world)
1697
+ assert.equal(findAllByClass(tree, 'soul-history-item').length, 22, '点开「还有 2 条」后应全部摊开')
1698
+ assert.equal(findAllByClass(tree, 'soul-history-more').length, 0, '全部摊开后不该再留「还有 N 条」')
1699
+
1700
+ // ③ warning 是「看到的不是全部」,不是失败:留在区块内、且不能染红
1701
+ const warn = findAllByClass(tree, 'soul-history-warning')
1702
+ assert.equal(warn.length, 1, 'warning 应留在这个区块内提示')
1703
+ assert.equal(textOf(warn[0]), zh['history.warning'].replace('{reason}', '记录文件较大,只读取了最近的部分。'))
1704
+ assert.ok(String(warn[0].props.className).includes('soul-status'), 'warning 用普通提示样式,不是 .soul-error')
1705
+ assert.equal(findAllByClass(tree, 'soul-error').length, 0, 'warning 不得被当成错误')
1706
+ })
1707
+
1708
+ await check('C28 设置页指引:一条轻提示 + 问号里的全文,不再铺两行正文', async () => { const world = await boot()
1709
+ world.routes['/api/soul/history'] = () => ({ ok: true, entries: [historyEntry({ id: 'a' })], max: 200, warning: null })
1710
+ const zh = world.dicts.zh
1711
+
1712
+ // ① 折叠态:变更记录不该在设置页有独立分组,也不该出现在折叠标题旁。
1713
+ const closed = await renderSettings(world)
1714
+ // 判据用 accordion **标题 span** 而不是整个 accordion 文本:指引文案里就有
1715
+ //「变更记录」四个字,整树文本匹配会被自己误伤(首轮假红就是这么来的)。
1716
+ const closedTitles = findAllByClass(closed, 'soul-accordion-title')
1717
+ assert.equal(closedTitles.some((n) => textOf(n) === zh['group.history']), false,
1718
+ '设置页不得再保留「变更记录」手风琴分组')
1719
+ assert.equal(findAllByClass(closed, 'soul-history-entry').length, 0, '设置页不得再保留独立指引行(.soul-history-entry)')
1720
+ assert.equal(findAllByClass(closed, 'soul-accordion-head').length, 0,
1721
+ '折叠标题旁的 head 层应已删除(说明改放展开区内)')
1722
+ assert.equal(textOf(closed).includes(zh['history.entryHint']), false,
1723
+ '折叠态不该出现变更记录指引 —— 它解释的是展开后的东西')
1724
+
1725
+ // ② 展开「人设预设」:两条说明各自一行,正文只有一句话,细节在问号里。
1726
+ const triggers = findAll(closed, (n) => n.type === 'button' && textOf(n).includes(zh['group.personas']))
1727
+ assert.ok(triggers.length > 0, '应存在「人设预设」分组标题(前置条件)')
1728
+ await triggers[0].props.onClick()
1729
+ const tree = await renderSettings(world)
1730
+
1731
+ const notes = findAllByClass(tree, 'soul-note-inline')
1732
+ assert.equal(notes.length, 2, `应有两行轻提示(预设范围 + 变更记录去向),实际 ${notes.length}`)
1733
+ // 每行都是「一句话 + 一个问号」,正文走 .soul-note-text
1734
+ const texts = notes.map((n) => textOf(findAllByClass(n, 'soul-note-text')[0]))
1735
+ assert.deepEqual(texts, [zh['personas.hint'], zh['history.entryHint']],
1736
+ `两行正文应分别是两条短文案,实际 ${JSON.stringify(texts)}`)
1737
+
1738
+ // ③ 关键:正文**不得**再穷举清单 —— 那些名词在卡片和按钮上本来就看得到。
1739
+ // 细节只能出现在问号的 tooltip 里(.soul-hint-tip),不能混进正文。
1740
+ for (const key of ['personas.hint', 'history.entryHint']) {
1741
+ assert.ok(zh[key].length <= 40, `${key} 应是短句(≤40 字),实际 ${zh[key].length} 字:${zh[key]}`)
1742
+ }
1743
+ const tips = findAllByClass(tree, 'soul-hint-tip').map((n) => textOf(n))
1744
+ assert.ok(tips.includes(zh['personas.hintDetail']), '预设的完整说明应在问号 tooltip 里')
1745
+ assert.ok(tips.includes(zh['history.entryHintMore']), '变更记录的去向全文应在问号 tooltip 里')
1746
+ // 反向:tooltip 的细节文字绝不能漏进正文(那等于没瘦身)
1747
+ const bodyText = texts.join('')
1748
+ for (const detailKey of ['personas.hintDetail', 'history.entryHintMore']) {
1749
+ assert.equal(bodyText.includes(zh[detailKey]), false, `正文不得混入 ${detailKey} 的全文`)
1750
+ }
1751
+ // ④ 说明是正文,不是按钮内容:绝不能嵌进 trigger(button 内不允许嵌可聚焦元素)
1752
+ const openTriggers = findAllByClass(tree, 'soul-accordion-trigger')
1753
+ assert.equal(openTriggers.some((n) => textOf(n).includes(zh['history.entryHint'])), false,
1754
+ '指引不得嵌进 trigger 按钮')
1755
+ // ⑤ 其余分组的展开区不得误挂说明(说明块是 personas 专属)
1756
+ const bodies = findAllByClass(tree, 'soul-accordion-body')
1757
+ const others = bodies.filter((n) => textOf(n) !== textOf(notes[0].parent) && !(n.children || []).includes(notes[0]))
1758
+ assert.equal(others.some((n) => findAllByClass(n, 'soul-note').length > 0), false,
1759
+ '只有人设预设分组挂说明块,其他分组不得误挂')
1760
+ })
1761
+
1762
+ await check('C35 0.8.0 起设置页不得再出现「工作区绑定」分组(逻辑层保留,UI 撤下)', async () => {
1763
+ const world = await boot()
1764
+ // 绑定接口给它一条数据:如果分组被误加回来,展开后一定能看到这条 ——
1765
+ // 用空数据测不出问题(空态与「分组不存在」在文本上都可能什么都不显示)。
1766
+ world.routes['/api/soul/bindings'] = () => ({
1767
+ ok: true,
1768
+ bindings: { 'D:\\项目A': '苏格拉底式提问者' },
1769
+ invalid: [], dropped: 0, warning: null, max: 50, maxPathLength: 260, sessions: []
1770
+ })
1771
+ const zh = world.dicts.zh
1772
+
1773
+ // ① 逐个分组展开,任何一组里都不得出现这个手风琴(连同它的标题与主体)
1774
+ let tree = await renderSettings(world)
1775
+ for (const groupKey of ['about', 'traits', 'personas', 'tool']) {
1776
+ const titleKey = { about: 'group.aboutYou', traits: 'group.traits', personas: 'group.personas', tool: 'group.tool' }[groupKey]
1777
+ const btn = findAll(tree, (n) => n.type === 'button' && textOf(n).includes(zh[titleKey]))[0]
1778
+ assert.ok(btn, `前置条件:应存在「${zh[titleKey]}」分组`)
1779
+ await btn.props.onClick()
1780
+ tree = await renderSettingsSettled(world)
1781
+ // 判据一:折叠标题里不得有这一项
1782
+ assert.equal(
1783
+ findAllByClass(tree, 'soul-accordion-title').some((n) => textOf(n) === zh['group.bindings']),
1784
+ false,
1785
+ `展开「${zh[titleKey]}」后不得出现「工作区绑定」分组标题`
1786
+ )
1787
+ // 判据二:主体(那个会渲染绑定表的组件)不得被挂载
1788
+ assert.equal(findAllByClass(tree, 'soul-bindings').length, 0,
1789
+ `展开「${zh[titleKey]}」后不得渲染绑定主体(.soul-bindings)`)
1790
+ }
1791
+ // ② 折叠态整树文本里也不该出现它的说明文案
1792
+ assert.equal(textOf(tree).includes(zh['bindings.hint']), false,
1793
+ '设置页不得出现工作区绑定的说明文案')
1794
+ })
1795
+
1796
+
1797
+ // ==================== 工作区绑定分组 ====================
1798
+ //
1799
+ // 这块的 UI 用例(原 C29–C33)已随设置页分组一并删除:分组在 0.8.0 发布前被撤下,
1800
+ // 而它们断言的是「点开分组之后」的交互,没有渲染入口就没有可断言的对象。
1801
+ //
1802
+ // **逻辑层没有失去看护**:路径规范化 / 前缀匹配 / 损坏与版本处理由 verify-bindings
1803
+ // (47 项)守着;宿主侧 cwd 解析、三条绑定路由、写入即生效、解绑语义由
1804
+ // verify-e2e-prompt 的 W1–W15 与 R1–R13 守着。0.9.0 用工作区平铺卡片重写 UI 时,
1805
+ // 会在这里补新的行为用例。
1806
+
1807
+ await check('C34 保留条数在变更记录区块里就地改并即时落盘,设置页不得再有它', async () => {
1808
+ const entries = [historyEntry({ id: 'r-1', at: new Date().toISOString(), before: { style: 'professional' }, after: { style: 'casual' } })]
1809
+ const world = await boot()
1810
+ const posts = []
1811
+ world.routes['/api/soul/history'] = () => ({ ok: true, entries, max: 200, warning: null })
1812
+ world.routes['/api/soul/config'] = (call) => {
1813
+ if (call.method === 'GET') return { ok: true, config: { ...CONFIG }, configError: null, deliveryWarning: null }
1814
+ posts.push(call.body)
1815
+ // 服务端把 POST 体当 patch 合并,并把(夹取后的)值回传
1816
+ return { ok: true, config: { ...CONFIG, ...call.body }, changed: ['historyMaxEntries'], deliveryWarning: null }
1817
+ }
1818
+ const zh = world.dicts.zh
1819
+
1820
+ // ① 设置页的每个分组里都不得再有这个控件(逐个展开查,避免只看了默认展开的那组)
1821
+ let tree = await renderSettings(world)
1822
+ for (const groupKey of ['group.traits', 'group.personas', 'group.bindings', 'group.tool']) {
1823
+ const btn = findAll(tree, (n) => n.type === 'button' && textOf(n).includes(zh[groupKey]))[0]
1824
+ if (!btn) continue
1825
+ await btn.props.onClick()
1826
+ tree = await renderSettingsSettled(world)
1827
+ assert.equal(findAll(tree, (n) => n.props && n.props.id === 'soul-historyMaxEntries').length, 0,
1828
+ `设置页「${zh[groupKey]}」分组里不得再出现保留条数下拉(0.8.1 已迁到详情页)`)
1829
+ }
1830
+
1831
+ // ② 详情页的变更记录区块里必须有它
1832
+ let detail = await renderDetailSettled(world)
1833
+ const selects = findAll(detail, (n) => n.type === 'select' && n.props.id === 'soul-historyMaxEntries')
1834
+ assert.equal(selects.length, 1, `详情页应恰好一个保留条数下拉,实际 ${selects.length}`)
1835
+ assert.equal(selects[0].props.value, '200', '初始值应来自配置(默认 200)')
1836
+ // 档位完整(50 / 100 / 200 / 500),否则用户没法把窗口调大
1837
+ const options = findAll(selects[0], (n) => n.type === 'option').map((n) => n.props.value)
1838
+ assert.deepEqual(options, ['50', '100', '200', '500'], `档位应为四档,实际 ${JSON.stringify(options)}`)
1839
+
1840
+ // ③ 改一次就落盘,不用等设置页那个保存按钮
1841
+ await selects[0].props.onChange({ target: { value: '500' } })
1842
+ detail = await renderDetailSettled(world)
1843
+ assert.equal(posts.length, 1, `改一次应恰好发一次保存请求,实际 ${posts.length}`)
1844
+ // 只发这一个字段:整份配置回传会覆盖掉别的标签页刚改的东西
1845
+ assert.deepEqual(posts[0], { historyMaxEntries: 500 }, `请求体只该带这一个字段,实际 ${JSON.stringify(posts[0])}`)
1846
+ const after = findAll(detail, (n) => n.type === 'select' && n.props.id === 'soul-historyMaxEntries')[0]
1847
+ assert.equal(after.props.value, '500', '成功后下拉应停在新值上')
1848
+ const okText = findAllByClass(detail, 'soul-saved-notice')
1849
+ assert.equal(okText.length, 1, '成功后应有一行绿字')
1850
+ assert.equal(textOf(okText[0]), zh['history.maxEntriesSaved'].replace('{count}', '500'))
1851
+ })
1313
1852
  } finally {
1314
1853
  restoreTimers()
1315
1854
  }