@modusensus/dsh-mneme 0.13.0 → 0.14.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -4
- package/bin/dsh-mneme-choro.mjs +9 -1
- package/docs/CONFIGURATION.md +5 -3
- package/lib/api.js +211 -5
- package/lib/client.js +1240 -354
- package/lib/config.js +27 -0
- package/lib/dream/decisions.js +39 -1
- package/lib/dream/sleep.js +146 -6
- package/lib/dream/triage.js +139 -0
- package/lib/dream.js +123 -8
- package/lib/hot-memory.js +14 -2
- package/lib/index.js +31 -1
- package/lib/inject.js +14 -2
- package/lib/lang.js +11 -0
- package/lib/service.js +76 -4
- package/lib/settings.js +16 -1
- package/lib/store.js +238 -5
- package/lib/wiki/claims.js +267 -0
- package/lib/wiki/queries.js +168 -2
- package/lib/wiki/render.js +74 -0
- package/lib/wiki/t0.js +43 -6
- package/lib/wiki/t1.js +138 -31
- package/package.json +15 -1
- package/scripts/release-docs.mjs +7 -1
- package/scripts/release-notes.mjs +4 -1
- package/src/api.js +211 -5
- package/src/config.js +27 -0
- package/src/dream/decisions.js +39 -1
- package/src/dream/sleep.js +146 -6
- package/src/dream/triage.js +139 -0
- package/src/dream.js +123 -8
- package/src/hot-memory.js +14 -2
- package/src/index.js +31 -1
- package/src/inject.js +14 -2
- package/src/lang.js +11 -0
- package/src/service.js +76 -4
- package/src/settings.js +16 -1
- package/src/store.js +238 -5
- package/src/wiki/claims.js +267 -0
- package/src/wiki/queries.js +168 -2
- package/src/wiki/render.js +74 -0
- package/src/wiki/t0.js +43 -6
- package/src/wiki/t1.js +138 -31
- package/test/api.test.js +11 -1
- package/test/client.test.js +275 -34
- package/test/conflict-ledger-enforce.test.js +452 -0
- package/test/conflict-ledger.test.js +242 -0
- package/test/contrast.test.js +124 -12
- package/test/dream-baseline-clamp.test.js +174 -0
- package/test/graph-api.test.js +124 -0
- package/test/hot-memory.test.js +48 -0
- package/test/inject-parent-gate.test.js +6 -3
- package/test/inject.test.js +59 -0
- package/test/panel-chart-scale.test.js +90 -0
- package/test/panel-tokens.test.js +245 -0
- package/test/release-docs.test.js +20 -0
- package/test/release-notes.test.js +25 -0
- package/test/security-doc.test.js +10 -0
- package/test/sleep.test.js +37 -0
- package/test/wiki-api.test.js +110 -3
- package/test/wiki-claims.test.js +194 -0
- package/test/wiki-provenance.test.js +202 -0
- package/test/wiki-queries.test.js +121 -0
- package/test/wiki-t0.test.js +77 -1
- package/test/wiki-t1.test.js +226 -1
package/test/client.test.js
CHANGED
|
@@ -255,6 +255,76 @@ test("choro page wires the two feature toggles, the T1 job and the stale marker"
|
|
|
255
255
|
);
|
|
256
256
|
});
|
|
257
257
|
|
|
258
|
+
// 证据复核(Grounded Claims):用 git diff 判「哪条结论被改动碰到了」。
|
|
259
|
+
// 这条锁的是三件在屏幕上看不见但一错就骗人的事:
|
|
260
|
+
// · 三种结局(干净 / 基线找不到 / 比对失败)必须走三条分支,不能并成一句;
|
|
261
|
+
// · 四种命中原因各自有文案,尤其 touched 与 shifted 不许混成一个「失效」;
|
|
262
|
+
// · 「没算」不许长得像「没事」——#607 的病根就是把没做成说成做成了。
|
|
263
|
+
test("claim evidence check reports three distinct outcomes instead of one reassuring blob", () => {
|
|
264
|
+
const fn = sliceComponent("WikiMainPanel");
|
|
265
|
+
assert.ok(fn.includes("/api/dsh-mneme/choro/claims?repo="), "the check must read the /choro/claims route");
|
|
266
|
+
// 手动触发,不进 useEffect:每次切页都跑 git diff 会把浏览拖慢,而且这个面板
|
|
267
|
+
// 第一次就自动跑的话,「没点过」和「点过没事」在屏幕上就长一个样了。
|
|
268
|
+
assert.ok(
|
|
269
|
+
!/useEffect\(\(\) => \{[^}]*checkDrift/.test(fn),
|
|
270
|
+
"the check must not run automatically on mount — an unasked check cannot be told apart from an unrun one"
|
|
271
|
+
);
|
|
272
|
+
for (const key of ["wiki.drift.clean", "wiki.drift.anchorStale", "wiki.drift.blocked", "wiki.drift.summary"]) {
|
|
273
|
+
assert.ok(fn.includes(`t("${key}")`), `each outcome needs its own sentence, missing ${key}`);
|
|
274
|
+
}
|
|
275
|
+
for (const key of ["wiki.drift.touched", "wiki.drift.shifted", "wiki.drift.deleted", "wiki.drift.unverifiable"]) {
|
|
276
|
+
assert.ok(fn.includes(`t("${key}")`), `each hit reason needs its own sentence, missing ${key}`);
|
|
277
|
+
}
|
|
278
|
+
// touched 与 shifted 分家:混成「失效」会让只是行号挪了的结论也被喊去重写。
|
|
279
|
+
assert.ok(
|
|
280
|
+
fn.includes('c.reason === "touched"') && fn.includes('c.reason === "shifted"'),
|
|
281
|
+
"touched and shifted must stay two verdicts"
|
|
282
|
+
);
|
|
283
|
+
// 正文有语义段、库里零条结论(T1 落盘非原子留下的残局)要单独说出来,
|
|
284
|
+
// 否则这页会永远显示成干净。
|
|
285
|
+
assert.ok(fn.includes('t("wiki.drift.inconsistent")'), "a page with prose but no claims must say so");
|
|
286
|
+
// 每页的条数挂在树里,用户不用先点开才知道哪页被改动碰到。
|
|
287
|
+
assert.ok(fn.includes("mneme-wikidriftn"), "the tree must carry a per-page count badge");
|
|
288
|
+
|
|
289
|
+
// 文案本身也要能区分「没算」与「没事」:干净那句必须提到 HEAD/基线,
|
|
290
|
+
// 失败那句必须提到失败——两者措辞相同就等于没区分。
|
|
291
|
+
const clean = [...clientSource.matchAll(/"wiki\.drift\.clean":\s*"((?:[^"\\]|\\.)+)"/g)].map((m) => m[1]);
|
|
292
|
+
const blocked = [...clientSource.matchAll(/"wiki\.drift\.blocked":\s*"((?:[^"\\]|\\.)+)"/g)].map((m) => m[1]);
|
|
293
|
+
assert.equal(clean.length, 2, "wiki.drift.clean must exist in both dictionaries");
|
|
294
|
+
assert.equal(blocked.length, 2, "wiki.drift.blocked must exist in both dictionaries");
|
|
295
|
+
for (const line of clean) {
|
|
296
|
+
assert.ok(/HEAD|基线|baseline/.test(line), "the clean copy must say what it compared against");
|
|
297
|
+
}
|
|
298
|
+
for (const line of blocked) {
|
|
299
|
+
assert.ok(/失败|failed/i.test(line), "the blocked copy must admit the comparison did not happen");
|
|
300
|
+
}
|
|
301
|
+
assert.notEqual(clean[0], blocked[0], "the two outcomes must not share copy");
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// 自动刷新被主动跳过(仓库里有富化页)。这条锁的是「拒绝不许长得像失败」:
|
|
305
|
+
// 服务端已经把 autoRefreshSkipped 与 autoRefreshError 分成两个字段,面板必须
|
|
306
|
+
// 跟着分——合成一句「刷新失败」就是在报一个没发生过的故障,而真正的后果
|
|
307
|
+
// (语义段还在、快照还是旧的)反而没人说。
|
|
308
|
+
test("auto-refresh skip is shown as a standing reason, not as a failure toast", () => {
|
|
309
|
+
const fn = sliceComponent("WikiMainPanel");
|
|
310
|
+
assert.ok(fn.includes("autoRefreshSkipped"), "the panel must read the skip signal");
|
|
311
|
+
assert.ok(fn.includes("autoRefreshNote"), "the skip reason needs a visible home");
|
|
312
|
+
assert.ok(
|
|
313
|
+
!/autoRefreshError[^\n]*autoRefreshSkipped|autoRefreshSkipped[^\n]*autoRefreshError/.test(fn),
|
|
314
|
+
"skipped and error must not be merged into one branch"
|
|
315
|
+
);
|
|
316
|
+
// 提示条不能只在 toast 里:toast 2.5 秒就没了,而「为什么没刷」是一个
|
|
317
|
+
// 仍然成立的状态(库里还有富化页),用户回头还要能看到。
|
|
318
|
+
assert.ok(fn.includes("mneme-wikidriftbar") && fn.includes("state.autoRefreshNote"),
|
|
319
|
+
"the skip note must render as a bar, not a self-clearing toast");
|
|
320
|
+
const zh = [...clientSource.matchAll(/"wiki\.autoRefresh\.skipped":\s*"((?:[^"\\]|\\.)+)"/g)].map((m) => m[1]);
|
|
321
|
+
const en = zh.length;
|
|
322
|
+
assert.equal(en, 2, "wiki.autoRefresh.skipped must exist in both dictionaries");
|
|
323
|
+
assert.ok(/没.*做|没有做/.test(zh[0]), "the Chinese copy must say it did not happen");
|
|
324
|
+
assert.ok(!/失败/.test(zh[0]), "the skip copy must not claim a failure");
|
|
325
|
+
assert.ok(/skipped/i.test(zh[1]), "the English copy must say skipped");
|
|
326
|
+
});
|
|
327
|
+
|
|
258
328
|
// 知识库页(契约 §3.6)。这条锁的是「路由与就近文案都在」,不是布局:
|
|
259
329
|
// · 409/503 各有自己的空态,不把裸状态码丢屏幕上;
|
|
260
330
|
// · disabled 那句必须说清去哪儿开(#607 的教训:只说「未启用」等于没说);
|
|
@@ -1220,8 +1290,8 @@ test("consolidation chart: own 56-day window, null-vs-empty split, and a narrow-
|
|
|
1220
1290
|
"two columns must collapse to one in a narrow container"
|
|
1221
1291
|
);
|
|
1222
1292
|
// 两根柱的语义色必须走语义层,不能直接拿宿主 label token 当填充色(#178)。
|
|
1223
|
-
assert.ok(clientSource.includes(".mneme-sinkbar--added{background:var(--mneme-
|
|
1224
|
-
assert.ok(clientSource.includes(".mneme-sinkbar--merged{background:var(--mneme-
|
|
1293
|
+
assert.ok(clientSource.includes(".mneme-sinkbar--added{background:var(--mneme-sink-added)}"));
|
|
1294
|
+
assert.ok(clientSource.includes(".mneme-sinkbar--merged{background:var(--mneme-sink-merged)}"));
|
|
1225
1295
|
});
|
|
1226
1296
|
|
|
1227
1297
|
// 巩固/睡眠模型路由 UI:下拉数据来自宿主侧已注册适配器(/llm-providers,
|
|
@@ -1540,11 +1610,18 @@ test("scope isolation ships panel switches and provenance surfaces (A4)", () =>
|
|
|
1540
1610
|
// 继续藏。**v0.13.x 反转**:主人决定把 M.O. 下架,那一组连同首屏位一起摘掉,
|
|
1541
1611
|
// 预算回到 8,本锁随之收紧回两组。反转是产品决策、不是放宽,注释留档防下次
|
|
1542
1612
|
// 有人照着旧注释把组加回来。
|
|
1613
|
+
// **v0.13.x 第二次产品豁免(#607 后续)**:首屏加 group.ext(仓库地志 / 知识库 /
|
|
1614
|
+
// 日记本三个扩展子系统的总闸),可点项预算 8→11(3 总开关 + autoInject 2 子 +
|
|
1615
|
+
// 扩展 3 闸 + scope 2 + 「全部设置」按钮)。理由:这三个子系统此前分别散在
|
|
1616
|
+
// enhance 组尾(choro/diary)或压根没进 FEATURE_GROUPS(kb),用户「开了才
|
|
1617
|
+
// 找得到、找不到就没法开」——#607 报的「知识库没成型」一半是入口问题。它们
|
|
1618
|
+
// 是「装不装这个子系统」而非「每轮付多少」,与 enhance 的行为开关不同级,
|
|
1619
|
+
// 所以单列一组并提到首屏。**再往首屏塞东西前先问:它是不是同级子系统?**
|
|
1543
1620
|
test("settings card folds everything beyond the first screen behind All settings (issue #427)", () => {
|
|
1544
|
-
// ①
|
|
1621
|
+
// ① 首屏层组成:三组都要在(basic 总开关 / ext 子系统总闸 / scope 必答题),
|
|
1545
1622
|
// 且 basic 的成员是从 core/dream 移走的。
|
|
1546
|
-
assert.ok(/const FEATURE_FIRST_SCREEN = \["group\.basic", "group\.scope"\];/.test(clientSource),
|
|
1547
|
-
"the first screen must be
|
|
1623
|
+
assert.ok(/const FEATURE_FIRST_SCREEN = \["group\.basic", "group\.ext", "group\.scope"\];/.test(clientSource),
|
|
1624
|
+
"the first screen must be the basic, ext and scope groups (M.O. taken down in v0.13.x; ext added as the second product exemption)");
|
|
1548
1625
|
// M.O. 下架锁:组从 FEATURE_GROUPS 摘掉 ≠ 只把首屏位去掉。少了这一条,下次
|
|
1549
1626
|
// 有人「顺手把组加回来」时会折进「全部设置」,测试照绿而 M.O. 又出现在面板上。
|
|
1550
1627
|
assert.ok(!/\{ key: "group\.mo", items: \[/.test(clientSource),
|
|
@@ -1554,8 +1631,31 @@ test("settings card folds everything beyond the first screen behind All settings
|
|
|
1554
1631
|
"the M.O. flags must remain reachable (config + settings whitelist) so the takedown stays reversible");
|
|
1555
1632
|
assert.ok(/key: "group\.basic", items: \["autoInject", "autoSummarize", "autoDream"\]/.test(clientSource),
|
|
1556
1633
|
"group.basic must carry exactly the three master switches");
|
|
1557
|
-
|
|
1558
|
-
|
|
1634
|
+
// 扩展组必须同时收三个子系统的总闸,且一个都不能漏:漏掉哪个,那个子系统就
|
|
1635
|
+
// 退回 #607 的状态——后端接好了、面板上没有入口,用户只能靠改配置文件打开。
|
|
1636
|
+
assert.ok(/key: "group\.ext", items: \["chorographyEnabled", "kbEnabled", "diaryEnabled"\]/.test(clientSource),
|
|
1637
|
+
"group.ext must carry the three subsystem master switches (chorography / kb / diary)");
|
|
1638
|
+
// 迁移锁:choro/diary 从 enhance 组移出(render-once,同一项只渲染一次)。
|
|
1639
|
+
// 只把首屏位加上而不移成员,会让两个开关同时出现在两组里,状态还可能不同步。
|
|
1640
|
+
// #427 revamp(2026-11-11):group.enhance 进一步拆成 safety/pipeline/retrieve/
|
|
1641
|
+
// persona——它曾是设置页「难找难看」的重灾区(14 个异质开关混装),拆完不许
|
|
1642
|
+
// 有人顺手把旧组名加回来。
|
|
1643
|
+
assert.ok(!/group\.enhance", items: \[/.test(clientSource),
|
|
1644
|
+
"group.enhance must stay split into safety/pipeline/retrieve/persona (the revamp's core move)");
|
|
1645
|
+
assert.ok(!/group\.core", items: \[/.test(clientSource),
|
|
1646
|
+
"group.core must stay split too (its audit/defense rows moved to group.safety)");
|
|
1647
|
+
for (const moved of ["chorographyEnabled", "diaryEnabled"]) {
|
|
1648
|
+
const extItems = clientSource.match(/key: "group\.ext", items: \[([^\]]*)\]/)?.[1] ?? "";
|
|
1649
|
+
assert.ok(extItems.includes(`"${moved}"`), `${moved} must stay in group.ext`);
|
|
1650
|
+
const occurrencesInGroups = [...clientSource.matchAll(/items: \[([^\]]*)\]/g)]
|
|
1651
|
+
.filter((m) => m[1].includes(`"${moved}"`)).length;
|
|
1652
|
+
assert.equal(occurrencesInGroups, 1, `${moved} must render exactly once across all groups (render-once)`);
|
|
1653
|
+
}
|
|
1654
|
+
// 四个新组都在:少了哪个,那批开关就退回「没座位」状态。
|
|
1655
|
+
for (const groupKey of ["group.safety", "group.pipeline", "group.retrieve", "group.persona"]) {
|
|
1656
|
+
assert.ok(clientSource.includes(`key: "${groupKey}", items: [`),
|
|
1657
|
+
`${groupKey} must exist in FEATURE_GROUPS (the revamp's regrouping)`);
|
|
1658
|
+
}
|
|
1559
1659
|
// ② 两层折叠的接线:restGroups 只在 showAll 展开后渲染;「高级」按钮出现在
|
|
1560
1660
|
// 「全部设置」之后(同一渲染分支内),showAdv 展开的条目走第二层。
|
|
1561
1661
|
assert.ok(/showAll && h\(react\.Fragment, null,\s*restGroups\.map\(renderGroup\)/.test(clientSource),
|
|
@@ -1563,16 +1663,24 @@ test("settings card folds everything beyond the first screen behind All settings
|
|
|
1563
1663
|
const allBtn = clientSource.indexOf('t("memory.features.allToggle")');
|
|
1564
1664
|
const advBtn = clientSource.indexOf('t("memory.features.advancedToggle")');
|
|
1565
1665
|
assert.ok(allBtn >= 0 && advBtn > allBtn, "the advanced fold must be nested after All settings");
|
|
1566
|
-
assert.equal(clientSource.match(/const FEATURE_ADVANCED_BOOLS = \[([^\]]*)\];/)[1].split(",").filter((s) => s.trim()).length,
|
|
1567
|
-
"the advanced second tier must hold exactly the
|
|
1666
|
+
assert.equal(clientSource.match(/const FEATURE_ADVANCED_BOOLS = \[([^\]]*)\];/)[1].split(",").filter((s) => s.trim()).length, 9,
|
|
1667
|
+
"the advanced second tier must hold exactly the 9 rarer switches the label promises (entityRecall + graphAnchoring joined in the revamp)");
|
|
1568
1668
|
// ③ 徽标:两类各一套中英文案;判定读 overrides 本地判,不引入后端字段。
|
|
1569
|
-
|
|
1669
|
+
// #427 revamp:tagDefault 的徽标不再渲染(默认态由开关位置本身表达),i18n
|
|
1670
|
+
// 键随之下线——这里锁的是「不再渲染」这件事本身,防止有人把灰字墙加回来。
|
|
1671
|
+
for (const key of ["memory.features.tagChanged", "memory.features.tagPending",
|
|
1570
1672
|
"memory.features.group.basic", "memory.features.allToggle", "memory.features.advancedToggle"]) {
|
|
1571
1673
|
const occurrences = clientSource.split("\"" + key + "\"").length - 1;
|
|
1572
1674
|
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
1573
1675
|
}
|
|
1574
1676
|
assert.ok(clientSource.includes("Object.prototype.hasOwnProperty.call(ovr, k)"),
|
|
1575
|
-
"the
|
|
1677
|
+
"the changed badge must be decided from overrides, not a new backend field");
|
|
1678
|
+
// 「默认」徽标的下线锁:flagTag 对默认态必须 return null(CSS 类本身保留——
|
|
1679
|
+
// 事实卡的 actor 徽标还用它),禁的是功能开关行再挂灰字墙。
|
|
1680
|
+
assert.ok(/if \(!Object\.prototype\.hasOwnProperty\.call\(ovr, k\)\) return null;/.test(clientSource),
|
|
1681
|
+
"flagTag must return null on default rows (no more always-grey 「默认」 chips)");
|
|
1682
|
+
assert.ok(!clientSource.includes("memory.features.tagDefault"),
|
|
1683
|
+
"the tagDefault copy must stay deleted along with its render site");
|
|
1576
1684
|
});
|
|
1577
1685
|
|
|
1578
1686
|
// #481:面板开关「改了不生效」的体验坑。开关在宿主启动时合并进冻结 cfg,
|
|
@@ -2315,12 +2423,12 @@ test("client bundle parses: 面板产物必须是合法 JS(无重复声明等
|
|
|
2315
2423
|
test("distill retention switches are reachable from the panel (#399)", () => {
|
|
2316
2424
|
// 认「组里有没有」不要用 `"key"]` 这种子串——`eff["distillRecords.enabled"]`
|
|
2317
2425
|
// 也长这样,把组里那一项删掉它照样命中(变异验证实测:删项后仍全绿)。
|
|
2318
|
-
//
|
|
2319
|
-
const
|
|
2320
|
-
assert.ok(
|
|
2426
|
+
// 必须把所属组的 items 数组抠出来单独看。#427 revamp 后归属 pipeline 组。
|
|
2427
|
+
const pipelineGroup = clientSource.match(/key: "group\.pipeline", items: \[([\s\S]*?)\]/);
|
|
2428
|
+
assert.ok(pipelineGroup, "the pipeline group must be declared");
|
|
2321
2429
|
assert.ok(
|
|
2322
|
-
|
|
2323
|
-
"distillRecords.enabled must be an item of the
|
|
2430
|
+
pipelineGroup[1].includes('"distillRecords.enabled"'),
|
|
2431
|
+
"distillRecords.enabled must be an item of the pipeline group, not merely referenced elsewhere"
|
|
2324
2432
|
);
|
|
2325
2433
|
// 数值清单会随新键增长(v0.13 加了 diaryWriteEveryMinutes),所以钉「这三个都在、
|
|
2326
2434
|
// 且顺序不变」,不钉整行字面量——否则每加一个数值键这条锁就假红一次。
|
|
@@ -2342,15 +2450,19 @@ test("distill retention switches are reachable from the panel (#399)", () => {
|
|
|
2342
2450
|
/const distillSub = eff\["distillRecords\.enabled"\] && h\("div", \{ className: "mneme-featsub" \},\s*\n\s*numRow\("distillRecords\.retentionDays"\)/.test(clientSource),
|
|
2343
2451
|
"the retention-days row must be gated on the parent switch and live in its sub-block"
|
|
2344
2452
|
);
|
|
2345
|
-
// 定义了不等于接线了:把 distillSub
|
|
2346
|
-
//
|
|
2347
|
-
//
|
|
2348
|
-
const
|
|
2349
|
-
assert.ok(
|
|
2453
|
+
// 定义了不等于接线了:把 distillSub 从 subAfter 里摘掉,上面每条断言照样全绿
|
|
2454
|
+
// (变异验证实测:摘掉后仍全绿)。#427 revamp 后子块一律走 subAfter 紧贴自己的
|
|
2455
|
+
// 总闸(core 组已拆、组尾挂法退役),所以锁的是 subAfter 这条接线本身。
|
|
2456
|
+
const subAfterMap = clientSource.match(/const subAfter = \{([^}]*)\}/);
|
|
2457
|
+
assert.ok(subAfterMap, "the subAfter map must exist (sub-blocks ride next to their master switches)");
|
|
2350
2458
|
assert.ok(
|
|
2351
|
-
/\bdistillSub\b/.test(
|
|
2352
|
-
"distillSub must be
|
|
2459
|
+
/\bdistillSub\b/.test(subAfterMap[1]) && subAfterMap[1].includes('"distillRecords.enabled"'),
|
|
2460
|
+
"distillSub must be wired to distillRecords.enabled via subAfter, not merely declared above"
|
|
2353
2461
|
);
|
|
2462
|
+
for (const wired of ["entitySub", "summarizeSub", "choroSub", "diarySub"]) {
|
|
2463
|
+
assert.ok(new RegExp(`\\b${wired}\\b`).test(subAfterMap[1]),
|
|
2464
|
+
`${wired} must ride next to its master switch via subAfter (detached sub-blocks are the old mess)`);
|
|
2465
|
+
}
|
|
2354
2466
|
assert.ok(
|
|
2355
2467
|
/if \(!\/\^\\d\+\$\/\.test\(raw\)\) \{\s*\n\s*setNums\(\(c\) => \(\{ \.\.\.c, \[key\]: String\(eff\[key\] \?\? ""\) \}\)\);\s*\n\s*return;\s*\n\s*\}/.test(clientSource),
|
|
2356
2468
|
"a non-integer draft must be rejected locally and reverted to the effective value"
|
|
@@ -2637,9 +2749,10 @@ test("diary book: paper choice is one shared setting across both entries", () =>
|
|
|
2637
2749
|
"a same-page change must be broadcast, storage events never fire in the same document");
|
|
2638
2750
|
assert.ok(clientSource.includes('addEventListener("mneme:diary-book-changed"'),
|
|
2639
2751
|
"the other entry must listen for that broadcast");
|
|
2640
|
-
//
|
|
2752
|
+
// 三种纸都要有文案,否则按钮上是裸 key(牛皮已删:它只是同一层上色的换色相,
|
|
2753
|
+
// 与素纸差 1.01:1,且是这块唯一硬编码 hex)。
|
|
2641
2754
|
for (const key of ["memory.diary.bookLabel", "memory.diary.book.plain", "memory.diary.book.ruled",
|
|
2642
|
-
"memory.diary.book.grid"
|
|
2755
|
+
"memory.diary.book.grid"]) {
|
|
2643
2756
|
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
2644
2757
|
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
2645
2758
|
}
|
|
@@ -2652,6 +2765,51 @@ test("diary book: paper choice is one shared setting across both entries", () =>
|
|
|
2652
2765
|
"the ruled texture must be locked to the 26px line height");
|
|
2653
2766
|
});
|
|
2654
2767
|
|
|
2768
|
+
// 2026-11-11 查出来的真 bug:四条纸面风格(--ruled/--grid/--kraft/--plain)与基础规则
|
|
2769
|
+
// `.mneme-diary-paper` 同为 (0,1,0)、基础规则在数组里更靠后、且用 background 简写——
|
|
2770
|
+
// 前四条设的 background-image/color 被整条重置,四种纸实测全是 background-image:none,
|
|
2771
|
+
// 所以「几种风格毫无变化」不是没做完。修法两层:纹理挪到 .mneme-diary-text(后代选择器,
|
|
2772
|
+
// 结构上不再和基础规则抢同一个元素),基础规则只用 background-color。
|
|
2773
|
+
test("diary paper: 纹理挂在正文元素上,基础规则不许退回 background 简写", () => {
|
|
2774
|
+
const at = clientSource.indexOf('".mneme-diary-paper{');
|
|
2775
|
+
assert.ok(at > -1, "the base paper rule must exist");
|
|
2776
|
+
const rule = clientSource.slice(at + 1, clientSource.indexOf('",', at));
|
|
2777
|
+
assert.ok(rule.includes("background-color:"),
|
|
2778
|
+
"the base rule must paint its tint with background-color");
|
|
2779
|
+
assert.ok(!/(^|;|\s)background:/.test(rule),
|
|
2780
|
+
"the base rule must not use the background shorthand — it resets every texture above it");
|
|
2781
|
+
for (const key of ["--ruled", "--grid"]) {
|
|
2782
|
+
assert.ok(clientSource.includes(".mneme-diary-paper" + key + " .mneme-diary-text{"),
|
|
2783
|
+
"the " + key + " texture must hang off .mneme-diary-text, not off the base class");
|
|
2784
|
+
}
|
|
2785
|
+
assert.ok(clientSource.includes("--mneme-rule:"),
|
|
2786
|
+
"the rule colour needs its own token (border-l1 is 1.09:1 on paper — invisible)");
|
|
2787
|
+
assert.ok(!clientSource.includes("memory.diary.book.kraft"),
|
|
2788
|
+
"kraft was removed; it only differed from plain by hue (1.01:1) and carried the block's only hardcoded hex");
|
|
2789
|
+
});
|
|
2790
|
+
|
|
2791
|
+
// 走查发现:savedAt 只写不清 —— 保存成功后再改字段仍显示「已保存」,先成功后失败会
|
|
2792
|
+
// 「已保存」+「保存失败」两行并存。三处清零缺一处就会复现,所以按处数锁。
|
|
2793
|
+
test("persona save: 改任何字段都把「已保存」收回去(假成功回归锁)", () => {
|
|
2794
|
+
const zeros = (clientSource.match(/setSavedAt\(0\)/g) || []).length;
|
|
2795
|
+
assert.ok(zeros >= 3, "save 开头 / setField / toggleEnabled 三处都要清零(现有 " + zeros + " 处)");
|
|
2796
|
+
assert.ok(clientSource.includes("setSavedAt(Date.now())"), "the success path still stamps the time");
|
|
2797
|
+
});
|
|
2798
|
+
|
|
2799
|
+
// 窄侧栏(约 400px)下日记与人格原来是横向分栏:正文纸被挤到 250px、人格编辑区
|
|
2800
|
+
// 只剩 180px;六个页签还会各自折成两行。这两条都在 480 档容器查询里修,锁住防回退。
|
|
2801
|
+
test("窄容器:日记与人格上下堆叠,页签不折行", () => {
|
|
2802
|
+
const at = clientSource.indexOf('"@container (max-width: 480px){');
|
|
2803
|
+
assert.ok(at > -1, "the 480 container query must exist");
|
|
2804
|
+
const block = clientSource.slice(at, clientSource.indexOf('"', at + 2));
|
|
2805
|
+
assert.ok(block.includes(".mneme-diary-body{flex-direction:column}"),
|
|
2806
|
+
"the diary list + paper must stack in a narrow panel");
|
|
2807
|
+
assert.ok(block.includes(".mneme-persona-body{flex-direction:column}"),
|
|
2808
|
+
"the persona card rail + editor must stack in a narrow panel");
|
|
2809
|
+
assert.ok(block.includes("white-space:nowrap}"),
|
|
2810
|
+
"panel tabs must not wrap their label onto two lines");
|
|
2811
|
+
});
|
|
2812
|
+
|
|
2655
2813
|
// 日记本不再占侧边栏座位(2026-11-09 桉桉:还保留旧版本的侧边栏打开,这个要去掉)。
|
|
2656
2814
|
// 它现在是记忆面板内的一个标签页,和人格页并列;右栏那个入口是另一回事,保留。
|
|
2657
2815
|
test("diary is a panel tab now, not a sidebar seat", () => {
|
|
@@ -2858,6 +3016,21 @@ test("panel copy and icons must not silently vanish (missing dict lines, unregis
|
|
|
2858
3016
|
for (const b of expr.matchAll(/[?:]\s*"([a-zA-Z]+)"/g)) iconNames.add(b[1]);
|
|
2859
3017
|
}
|
|
2860
3018
|
for (const m of clientSource.matchAll(/feedRow\(\s*"([a-zA-Z]+)"/g)) iconNames.add(m[1]);
|
|
3019
|
+
// 页签图标走 TAB_ICONS 查表(子视图 key -> 图标名),不是 h(Icon, ...) 字面量,
|
|
3020
|
+
// 上面两条提取器看不见它。这里把表里的值按 ICON_PATHS 的同形状收进来——否则
|
|
3021
|
+
// 改表时把某个 key 指向未注册名字,闸门照绿而屏幕上那格图标是空的。
|
|
3022
|
+
const tabTable = clientSource.match(/const TAB_ICONS = \{((?:[^}]|\n)*?)\}/);
|
|
3023
|
+
assert.ok(tabTable, "TAB_ICONS must be parseable (subview key -> icon name)");
|
|
3024
|
+
const tabEntries = [...tabTable[1].matchAll(/([a-zA-Z]+):\s*"([a-zA-Z]+)"/g)];
|
|
3025
|
+
assert.ok(tabEntries.length >= 7, `TAB_ICONS must cover every subview (saw ${tabEntries.length})`);
|
|
3026
|
+
for (const m of tabEntries) iconNames.add(m[2]);
|
|
3027
|
+
// 反向:每个 subviews 的 key 都得在表里有一行,否则那一格掉到 ?? "settings" 上。
|
|
3028
|
+
const subviewsBlock = clientSource.match(/const subviews = \[((?:[^\]]|\n)*?)\]/);
|
|
3029
|
+
assert.ok(subviewsBlock, "subviews must be parseable");
|
|
3030
|
+
const subviewKeys = [...subviewsBlock[1].matchAll(/key: "([a-zA-Z]+)"/g)].map((m) => m[1]);
|
|
3031
|
+
assert.ok(subviewKeys.length >= 7, `subviews must be parseable (saw ${subviewKeys.length})`);
|
|
3032
|
+
const unmapped = subviewKeys.filter((k) => !tabEntries.some((m) => m[1] === k));
|
|
3033
|
+
assert.deepEqual(unmapped, [], `页签没有图标映射(会退化成设置齿轮):${unmapped.join(", ")}`);
|
|
2861
3034
|
assert.ok(iconNames.size >= 8, `icon usage must be parseable (saw ${iconNames.size})`);
|
|
2862
3035
|
const noIcon = [...iconNames].filter((n) => !registered.has(n)).sort();
|
|
2863
3036
|
assert.deepEqual(noIcon, [], `Icon 静默 return null 的未注册名:${noIcon.join(", ")}`);
|
|
@@ -2866,12 +3039,13 @@ test("panel copy and icons must not silently vanish (missing dict lines, unregis
|
|
|
2866
3039
|
// 人格定义页(beta)面板面回归:形状沿用 A4 的「分组 + i18n 双语 occurrences ≥ 2
|
|
2867
3040
|
// + 关键接线」断言风格(只做源码断言,不挂 React 渲染环境)。
|
|
2868
3041
|
test("persona page is a panel tab, not a settings card (beta v2)", () => {
|
|
2869
|
-
// 开关进 FEATURE_GROUPS 的 enhance
|
|
2870
|
-
//
|
|
2871
|
-
|
|
2872
|
-
|
|
3042
|
+
// 开关进 FEATURE_GROUPS 的 persona 组(#427 revamp:从 enhance 拆出)。按组解析
|
|
3043
|
+
// 成员再逐个点名,而不是把某一行写成字面量正则——那样往组里加第二个键就会把
|
|
3044
|
+
// 这条护栏弄红(实测踩过)。
|
|
3045
|
+
const personaGroup = clientSource.match(/group\.persona", items: \[((?:[^\]]|\n)*)\]/);
|
|
3046
|
+
assert.ok(personaGroup, "FEATURE_GROUPS group.persona must be parseable");
|
|
2873
3047
|
for (const flag of ["personaEnabled", "personaStrictAddressing"]) {
|
|
2874
|
-
assert.ok(
|
|
3048
|
+
assert.ok(personaGroup[1].includes(`"${flag}"`), `FEATURE_GROUPS group.persona must carry ${flag}`);
|
|
2875
3049
|
}
|
|
2876
3050
|
// i18n 键中英双语齐备(occurrences ≥ 2 模式;label + .hint 两路由字典锁统一覆盖)
|
|
2877
3051
|
for (const key of [
|
|
@@ -3066,8 +3240,8 @@ test("profile extraction ships its button, distinct outcomes and correct save wi
|
|
|
3066
3240
|
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
3067
3241
|
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
3068
3242
|
}
|
|
3069
|
-
assert.ok(/group\.
|
|
3070
|
-
"FEATURE_GROUPS group.
|
|
3243
|
+
assert.ok(/group\.persona[\s\S]{0,700}"profileExtractEnabled"/.test(clientSource),
|
|
3244
|
+
"FEATURE_GROUPS group.persona must carry profileExtractEnabled");
|
|
3071
3245
|
|
|
3072
3246
|
// 位置锁(2026-11-09 再改口径):提取按钮属于「用户画像」卡,而画像卡本身
|
|
3073
3247
|
// 已经从设置页搬进记忆面板的「人格」页(桉桉:mneme 设置里面的个性化还没移走)。
|
|
@@ -3155,8 +3329,8 @@ test("profile facts card ships management UI, run outcomes and never touches the
|
|
|
3155
3329
|
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
3156
3330
|
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
3157
3331
|
}
|
|
3158
|
-
assert.ok(/group\.
|
|
3159
|
-
"FEATURE_GROUPS group.
|
|
3332
|
+
assert.ok(/group\.persona[\s\S]{0,1200}"profileFactsEnabled"/.test(clientSource),
|
|
3333
|
+
"FEATURE_GROUPS group.persona must carry profileFactsEnabled");
|
|
3160
3334
|
|
|
3161
3335
|
// 位置锁(2026-11-09 改口径):画像卡与事实卡一起搬进了人格页,事实卡仍是画像卡
|
|
3162
3336
|
// 之后的一张。切段比较,防止设置页的规则卡把顺序骗过去;设置页里两张卡都不许再有。
|
|
@@ -4182,3 +4356,70 @@ test("bootstrap job outlives the tab body: store in module scope, replay on remo
|
|
|
4182
4356
|
assert.ok(/setBootstrapJob\(\{ phase: "failed", error:/.test(bs558Body), "failure must land in the store, not in unmounted component state");
|
|
4183
4357
|
assert.ok(/setBootstrapJob\(\{ phase: "done", result:/.test(bs558Body), "success must land in the store too");
|
|
4184
4358
|
});
|
|
4359
|
+
|
|
4360
|
+
// #619 可见化 C:情绪一栏的「三种状态不许互相冒充」。
|
|
4361
|
+
// 界面在这里最容易犯的错是把「请求失败」渲染成「没有数据」——两者对用户的
|
|
4362
|
+
// 含义完全相反(前者是系统坏了,后者是功能没用),而空列表长得一模一样。
|
|
4363
|
+
test("affective panel keeps loading, error and empty apart (#619)", () => {
|
|
4364
|
+
const start = clientSource.indexOf("function AffectivePanel({ t })");
|
|
4365
|
+
assert.ok(start > 0, "AffectivePanel must exist");
|
|
4366
|
+
const end = clientSource.indexOf("function StatusPanel({ t, onBrowse })", start);
|
|
4367
|
+
assert.ok(end > start, "panel body must be bounded");
|
|
4368
|
+
const body = clientSource.slice(start, end);
|
|
4369
|
+
|
|
4370
|
+
// 失败分支必须 role=alert 且用 error 文案,不能掉进空态文案。
|
|
4371
|
+
assert.ok(/state\.error\)\s*\{[\s\S]*?memory\.affective\.error/.test(body),
|
|
4372
|
+
"失败时要显式说「这是取失败」,不能用空态文案顶替");
|
|
4373
|
+
assert.ok(/memory\.affective\.empty/.test(body), "真的一条都没有时才有空态文案");
|
|
4374
|
+
|
|
4375
|
+
// catch 里不许把 items 落在 [],否则「上游挂了」与「还没记过情绪」同形。
|
|
4376
|
+
const catchBlock = body.slice(body.indexOf(".catch("));
|
|
4377
|
+
assert.ok(/error:\s*true/.test(catchBlock), "catch 必须立 error 标记");
|
|
4378
|
+
assert.ok(!/items:\s*\[\]/.test(catchBlock.split("\n").slice(0, 3).join("\n")) ||
|
|
4379
|
+
/error:\s*true/.test(catchBlock),
|
|
4380
|
+
"catch 分支不能只留空列表而丢掉失败");
|
|
4381
|
+
|
|
4382
|
+
// 截断与跳过都要显式说出来,不能静默截断。
|
|
4383
|
+
assert.ok(/memory\.affective\.truncated/.test(body), "截断要说出来");
|
|
4384
|
+
assert.ok(/memory\.affective\.skipped/.test(body), "没情绪名的边要报出来");
|
|
4385
|
+
|
|
4386
|
+
// trait 与 occasion 必须用不同文案,否则「这次」被读成「一贯」。
|
|
4387
|
+
assert.ok(/memory\.affective\.trait/.test(body) && /memory\.affective\.occasion/.test(body),
|
|
4388
|
+
"trait 与 occasion 两档文案都要在");
|
|
4389
|
+
|
|
4390
|
+
// 页签要注册,不然这一栏根本没有入口。
|
|
4391
|
+
assert.ok(clientSource.includes('{ key: "affective", label: t("memory.explorer.tabAffective") }'),
|
|
4392
|
+
"情绪页签必须注册");
|
|
4393
|
+
assert.ok(clientSource.includes('view === "affective"'), "视图分支必须存在");
|
|
4394
|
+
});
|
|
4395
|
+
|
|
4396
|
+
// 回归锁:情绪行必须说出「谁」,页签图标必须各自认领(v0.13.0 复验轮)。
|
|
4397
|
+
// 这两处都是「数据在、界面没显示」的假绿形状:服务端一直带 person、ICON_PATHS
|
|
4398
|
+
// 之外的页签则静默落到 else 分支,闸门全绿而屏幕上少一块信息。
|
|
4399
|
+
test("affective rows name the person, and every tab owns its icon (#619)", () => {
|
|
4400
|
+
const start = clientSource.indexOf("function AffectivePanel({ t })");
|
|
4401
|
+
const end = clientSource.indexOf("function StatusPanel({ t, onBrowse })", start);
|
|
4402
|
+
const body = clientSource.slice(start, end);
|
|
4403
|
+
|
|
4404
|
+
// 谁:person 必须进渲染,缺名给占位,不许把 undefined 打到界面上。
|
|
4405
|
+
assert.ok(/it\.person/.test(body), "情绪行要渲染 person(服务端 from_name 一直都在)");
|
|
4406
|
+
assert.ok(/it\.person\s*\?\?\s*"/.test(body), "person 缺失时要给占位符,不能渲染出 undefined");
|
|
4407
|
+
// 顺序:谁 -> 情绪 -> 对什么。少了前半句「不安」就不知道是谁对谁。
|
|
4408
|
+
const order = [body.indexOf('className: "mneme-affective-person"'), body.indexOf('className: "mneme-affective-emotion"'), body.indexOf('className: "mneme-affective-target"')];
|
|
4409
|
+
assert.ok(order.every((i) => i > 0) && order[0] < order[1] && order[1] < order[2],
|
|
4410
|
+
`情绪行的字段顺序必须是 谁 · 情绪 · 对什么(实际 ${order.join(",")})`);
|
|
4411
|
+
|
|
4412
|
+
// 页签图标:affective 与 diary 各自有名字,且都在 ICON_PATHS 注册表里。
|
|
4413
|
+
const tabTable = clientSource.match(/const TAB_ICONS = \{((?:[^}]|\n)*?)\}/);
|
|
4414
|
+
assert.ok(tabTable, "TAB_ICONS must exist");
|
|
4415
|
+
const map = Object.fromEntries([...tabTable[1].matchAll(/([a-zA-Z]+):\s*"([a-zA-Z]+)"/g)].map((m) => [m[1], m[2]]));
|
|
4416
|
+
assert.equal(map.affective, "heart", "情绪页签要用 heart");
|
|
4417
|
+
assert.equal(map.diary, "book", "日记页签要用 book");
|
|
4418
|
+
for (const [key, icon] of Object.entries(map)) {
|
|
4419
|
+
assert.ok(new RegExp(`^\\s{6}${icon}: \\[\\[`, "m").test(clientSource),
|
|
4420
|
+
`页签 ${key} 指向的图标 ${icon} 没在 ICON_PATHS 注册(会静默渲染成空)`);
|
|
4421
|
+
}
|
|
4422
|
+
// 页签渲染只许走查表;再长出嵌套三元就又会把新页签塞到 else 分支。
|
|
4423
|
+
assert.ok(/h\(Icon, \{ name: TAB_ICONS\[s\.key\]/.test(clientSource),
|
|
4424
|
+
"页签图标必须走 TAB_ICONS 查表,不许回到嵌套三元");
|
|
4425
|
+
});
|