// 记忆整理「试算」卡的**真实渲染**测试(契约 v0.75 §4.12;切片 MT1)。 // // 为什么需要它:这张卡要回答的是「它会建议合并哪些」,而修好之后的三条关键断言 // 都是**文案与呈现**: // ① 有候选:动作/理由/分数/id 去向/diff 预览都要一眼读得出来; // ② 空:必须说明「算完了但没建议」**不等于**「库里没有重复」(否则空态会被读成 // 「库很干净」); // ③ 错误:**显示错误、绝不显示 0**(把失败画成「0 条候选」就是把「没算出来」 // 冒充成结论 —— 这正是本项目面板明令禁止的那一类)。 // // 跑法(与 ConfigFactsCards.render.test.ts 同): // // cd frontend/web \ // && ./node_modules/.bin/esbuild src/admin/modules/MemoryTidyDryRunCard.render.test.ts \ // --bundle --platform=node --format=cjs \ // --outfile=/tmp/cyrene-memory-tidy-dryrun.render.test.cjs \ // && node /tmp/cyrene-memory-tidy-dryrun.render.test.cjs // // `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。 import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import type { AdminTidyDryRunResult } from "../../lib/types"; import { MemoryTidyDryRunBody, TIDY_DRY_RUN_DISCLAIMER, TIDY_DRY_RUN_ERROR_HINT, TIDY_RECALL_UNAVAILABLE, TIDY_RECALL_UNAVAILABLE_HINT, type TidyDryRunState, } from "./MemoryTidyDryRunCard"; let checks = 0; function ok(cond: boolean, what: string): void { checks += 1; if (!cond) throw new Error(`FAILED: ${what}`); } function has(haystack: string, needle: string): boolean { return haystack.includes(needle); } function render(state: TidyDryRunState): string { return renderToStaticMarkup(createElement(MemoryTidyDryRunBody, { state, onRun: () => {} })); } const RESULT: AdminTidyDryRunResult = { dryRun: true, recallMode: "embedding", durationMs: 42, ruleVersion: "t0.1", partitions: 2, scanned: 736, compared: 400, pairs: 5, vetoed: 2, vetoReasons: { scope: 1, temporal: 1 }, candidates: [ { action: "merge", sourceIds: ["mem-short", "mem-long"], targetId: "mem-long", personaId: "cyrene", scope: "persona", kind: "relationship", proposedContent: "主人常在深夜与助手聊天,有熬夜习惯", mergedContent: "主人常在深夜与助手聊天,有熬夜习惯,作息偏晚", reason: "containment_a_subset_b", ruleVersion: "t0.1", scoreMin: 0.95, scoreMax: 0.95, evidence: { similarity: 0.95, recall_mode: "embedding", containment: "a_subset_b" }, }, { action: "link", sourceIds: ["mem-redbull", "mem-iced"], personaId: "cyrene", scope: "persona", kind: "relationship", proposedContent: "", mergedContent: "", reason: "similar_but_not_contained", ruleVersion: "t0.1", scoreMin: 0.88, scoreMax: 0.88, }, ], }; // ── ① 有候选:动作 / 理由 / 分数 / id 去向 / diff 预览 / n观测口径 ────────── const doneHtml = render({ kind: "done", result: RESULT }); ok(has(doneHtml, TIDY_DRY_RUN_DISCLAIMER), "恒显示「仅试算,不会改动任何记忆」"); ok(has(doneHtml, "并入"), "merge 动作要显示中文标签「并入」"); ok(has(doneHtml, "只建链接"), "link 动作要显示「只建链接」(绝不合并)"); ok(has(doneHtml, "containment_a_subset_b"), "必须显示机器可读理由"); ok(has(doneHtml, "0.950"), "必须显示分数(区间)"); ok(has(doneHtml, "mem-short") && has(doneHtml, "保留 mem-long"), "必须显示「哪条 → 保留哪条」"); ok(has(doneHtml, "作息偏晚"), "必须显示合并后的存活条目原文(diff 预览)"); ok(has(doneHtml, "扫描 736") && has(doneHtml, "用时 42 ms"), "必须显示扫描口径与耗时"); ok(has(doneHtml, "召回来源:语义向量(embedding)"), "必须显示召回来源(lexical 是降级信号)"); ok(has(doneHtml, "scope=1"), "必须显示否决分布(它解释「为什么没有更多建议」)"); // ── 截断:必须说明「这一轮看到的比库里少 + 该调哪个键」 ───────────────────── const truncatedHtml = render({ kind: "done", result: { ...RESULT, truncated: ["cyrene/persona/(compare)"] }, }); ok(has(truncatedHtml, "被配额截断"), "命中护栏时必须如实说明"); ok(has(truncatedHtml, "MEMORY_TIDY_SCAN_LIMIT") && has(truncatedHtml, "MEMORY_TIDY_CANDIDATE_LIMIT"), "要指向真键名"); // ── ② 空:说清「算完了但没建议」≠「没有重复」 ────────────────────────────── const emptyHtml = render({ kind: "done", result: { ...RESULT, candidates: [], pairs: 0, vetoed: 0, vetoReasons: {} } }); ok(has(emptyHtml, "这一轮没有产生任何候选"), "空态必须明说「没有产生候选」"); ok(has(emptyHtml, "不等于"), "空态必须说清「不等于库里没有重复」"); ok(has(emptyHtml, "召回门"), "空态要解释候选的三道门(召回门 / 包含判定 / 规则否决)"); ok(!has(emptyHtml, "建议 0 条"), "空态不得渲染成「建议 0 条」(那不是失败文案也不是空态文案)"); // ── ③ 错误:显示错误、绝不显示 0 ──────────────────────────────────────────── const errorHtml = render({ kind: "error", message: "语义召回(embedding)不可用,试算未产出候选" }); ok(has(errorHtml, "试算失败"), "错误态必须显式标出失败"); ok(has(errorHtml, "语义召回(embedding)不可用"), "必须显示后端给的原因"); ok(has(errorHtml, TIDY_DRY_RUN_ERROR_HINT), "必须说清「没算出来」≠「算过了、没有建议」"); ok(!has(errorHtml, "算完了"), "错误态绝不得渲染完成态标记(「算完了,一行都没写」)"); ok(!has(errorHtml, "扫描 "), "错误态绝不得渲染观测口径(扫描/候选数)—— 把失败画成 0 条就是把失败当结论"); ok(!has(errorHtml, "这一轮没有产生任何候选"), "错误态不得复用空态文案(两者含义不同)"); ok(has(errorHtml, "把失败画成「0 条」"), "错误态反而要解释「为什么不显示 0 条」"); // ── ④ 召回来源(契约 v0.91):四种取值**各有各的话**,空串绝不是空标签 ──────── // 起因是 M6 的诊断(DevDocs/audit/去重失效诊断-2026-10-13.md):旧实现把「全量 // 降级」静默报成 embedding,面板据此判读会把「向量不可用」读成「向量正常」。 // 因此这个字段的**全部价值**就是让人看得出这一轮到底走了哪条路。 const recallCases: Array<[string, string]> = [ ["embedding", "语义向量"], ["lexical", "已降级"], ["mixed", "部分分区降级"], ["", "无候选可比"], ["unregistered_mode", "未登记取值"], ]; for (const [mode, want] of recallCases) { const html = render({ kind: "done", result: { ...RESULT, recallMode: mode } }); ok(has(html, want), `recallMode=${JSON.stringify(mode)} 必须渲染成可读中文(含「${want}」)`); } // 空串绝不许渲染成「召回来源:」后面空白(那是本次要修的那个缺陷)。 const noCandidateHtml = render({ kind: "done", result: { ...RESULT, recallMode: "", candidates: [], pairs: 0 } }); ok(has(noCandidateHtml, "无候选可比"), "空串 = 本轮没做召回,必须说出来"); ok(!has(noCandidateHtml, "召回来源:<"), "空串绝不能渲染成空标签(后面什么都没有)"); ok(!has(noCandidateHtml, "召回来源: