// 记忆整理**候选应用卡**的真实渲染测试(切片 TIDY-1)。 // // 为什么需要它:这张卡是「整理建议的执行面」,而修好之后最容易走样的三条口径 // 都是**呈现**: // ① 批量回执被上限截断时,必须如实说出「命中多少 / 本轮处理多少 / 还剩多少未 // 处理」—— 绝不能画成「全部完成」(否则主人会以为 342 条都处理完了); // ② `skipped` 是**幂等命中/动作不可应用**,不是错误 —— 界面**不许报红**; // ③ 行的两个按钮按**后端 status** 决定可用性(pending 行的回滚必须禁用), // 未知状态原样显示、不吞、不猜。 // // 跑法(与 ConfigFactsCards.render.test.ts / MemoryTidyDryRunCard.render.test.ts 同): // // cd frontend/web \ // && ./node_modules/.bin/esbuild src/admin/modules/MemoryTidyApplyCard.render.test.ts \ // --bundle --platform=node --format=cjs \ // --outfile=/tmp/cyrene-memory-tidy-apply.render.test.cjs \ // && node /tmp/cyrene-memory-tidy-apply.render.test.cjs // // 🔴 渲染类测试必须 `--format=cjs`:`--format=esm` 会整批崩在 bundle 深处。 // `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。 import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { TIDY_STATUS_APPLIED, TIDY_STATUS_PENDING } from "../../lib/api"; import type { AdminTidyApplyBatch, AdminTidyCandidateRecord, AdminTidyRunResult, } from "../../lib/types"; import { MemoryTidyApplyBody, TIDY_APPLY_ALL_DONE_TAG, TIDY_APPLY_SKIPPED_HINT, TIDY_APPLY_TRUNCATED_HINT, TIDY_CANDIDATE_LIST_ERROR_HINT, } from "./MemoryTidyApplyCard"; import type { TidyApplyActions, TidyApplyViewState } from "./memoryTidyApplyState"; 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 countOf(haystack: string, needle: string): number { return haystack.split(needle).length - 1; } /** 按钮的禁用属性在静态标记里的形态(`disabled:opacity-50` 这个**类名**里也有 * "disabled" 字样,因此只能按属性形态 `disabled=""` 计数)。 */ const DISABLED_ATTR = 'disabled=""'; /** 动作替身:展示体只调这些出口,测试不关心它们做什么。 */ const NOOP_ACTIONS: TidyApplyActions = { onStatusChange: () => {}, onApply: () => {}, onRevert: () => {}, onRequest: () => {}, onConfirm: () => {}, onCancelConfirm: () => {}, }; const PENDING: AdminTidyCandidateRecord = { id: "cand-pending-1", action: "merge", status: "pending", sourceIds: ["mem-short"], targetId: "mem-long", personaId: "cyrene", scope: "persona", kind: "relationship", proposedContent: "主人常在深夜与助手聊天,有熬夜习惯", mergedContent: "主人常在深夜与助手聊天,有熬夜习惯,作息偏晚", reason: "containment_a_subset_b", ruleVersion: "t0.1", runId: "run-1", scoreMin: 0.95, scoreMax: 0.95, createdAt: "2026-10-05T10:18:10Z", }; const APPLIED: AdminTidyCandidateRecord = { ...PENDING, id: "cand-applied-1", status: "applied" }; /** 后端候选状态闭集只有 pending / applied;这个 fixture 用的是**闭集外**的值, * 用来钉住「未知值原样显示、不猜」。 */ const UNKNOWN: AdminTidyCandidateRecord = { ...PENDING, id: "cand-weird-1", status: "reverted" }; function viewState(patch: Partial = {}): TidyApplyViewState { return { status: TIDY_STATUS_PENDING, list: { kind: "ready", data: { candidates: [PENDING], count: 1 } }, busy: null, confirming: null, batch: null, run: null, last: null, fail: null, ...patch, }; } function withCandidates(candidate: AdminTidyCandidateRecord, status = TIDY_STATUS_PENDING): TidyApplyViewState { return viewState({ status, list: { kind: "ready", data: { candidates: [candidate], count: 1 } } }); } function render(state: TidyApplyViewState): string { return renderToStaticMarkup(createElement(MemoryTidyApplyBody, { state, actions: NOOP_ACTIONS })); } // ── ① 候选表:动作 / 状态 / 摘要 / 理由 / 时间 + 每行两个动作 ──────────────── const pendingHtml = render(withCandidates(PENDING)); ok(has(pendingHtml, "并入"), "merge 要显示中文标签「并入」"); ok(has(pendingHtml, "待定"), "状态要按后端值显示「待定」"); ok(has(pendingHtml, "作息偏晚"), "摘要要显示存活条目原文(diff 预览)"); ok(has(pendingHtml, "containment_a_subset_b"), "必须显示机器可读理由"); ok(has(pendingHtml, "2026-10-05 10:18"), "必须显示候选产生时间(RFC3339 裁到分)"); ok(has(pendingHtml, "应用") && has(pendingHtml, "回滚"), "每行必须有「应用」与「回滚」两个按钮"); // ── ② 回滚只在 status=applied 时可用(按后端 status,不按「我记得点过」)─────── const pendingDisabled = countOf(pendingHtml, DISABLED_ATTR); ok(pendingDisabled === 1, `待定行只有回滚该禁用(实际 disabled 属性出现 ${pendingDisabled} 次)`); const appliedHtml = render(withCandidates(APPLIED, TIDY_STATUS_APPLIED)); ok(has(appliedHtml, "已应用"), "已应用行要显示「已应用」"); const appliedDisabled = countOf(appliedHtml, DISABLED_ATTR); ok(appliedDisabled === 0, `已应用行的回滚必须可点(实际 disabled 属性出现 ${appliedDisabled} 次)`); const unknownHtml = render(withCandidates(UNKNOWN)); ok(has(unknownHtml, "reverted"), "闭集外的状态值必须原样显示(绝不吞掉)"); ok(countOf(unknownHtml, DISABLED_ATTR) === 1, "未知状态按「非已应用」处理:回滚禁用"); // ── ③ 批量回执:truncated 时必须如实显示「还剩多少未处理」 ────────────────── const BATCH_TRUNCATED: AdminTidyApplyBatch = { limit: 100, total: 342, truncated: true, applied: 90, skipped: 10, failed: 0, merged: 80, archived: 10, reclassified: 0, results: [], }; const truncatedHtml = render(viewState({ batch: BATCH_TRUNCATED })); ok(has(truncatedHtml, TIDY_APPLY_TRUNCATED_HINT), "截断时必须显式点明「命中数大于本轮处理数」"); ok(has(truncatedHtml, "命中待定 342 条"), "必须显示命中的**真**总数(不是 len(results))"); ok(has(truncatedHtml, "本轮处理 100 条"), "必须显示本轮真正处理了多少条"); ok(has(truncatedHtml, "还有 242 条待定未处理"), "必须算出并显示未处理量(342 - 100)"); ok(!has(truncatedHtml, TIDY_APPLY_ALL_DONE_TAG), "截断时绝不得渲染「已全部处理」标记"); ok(!has(truncatedHtml, "待实现"), "截断提示不得带「待实现」前缀(那会被读成这功能还没做)"); const BATCH_DONE: AdminTidyApplyBatch = { ...BATCH_TRUNCATED, limit: 100, total: 5, applied: 3, skipped: 2, failed: 0, truncated: false }; const doneHtml = render(viewState({ batch: BATCH_DONE })); ok(has(doneHtml, TIDY_APPLY_ALL_DONE_TAG), "未截断时才可以说「待定候选已全部处理」"); ok(!has(doneHtml, "未处理"), "未截断时不得出现「未处理」字样(否则那句话是假的)"); // ── ④ skipped 不显示为错误 ──────────────────────────────────────────────── const skippedHtml = render( viewState({ batch: BATCH_DONE, last: { candidateId: "cand-link-9", action: "link", status: "skipped", reason: "action_not_applyable", affectedIds: [], }, }) ); ok(has(skippedHtml, TIDY_APPLY_SKIPPED_HINT), "skipped 必须显式说明「不是错误」"); ok(has(skippedHtml, "已跳过"), "单条回执要按后端 status 显示「已跳过」"); ok(has(skippedHtml, "reason=action_not_applyable"), "必须带上机器可读的跳过原因"); ok(!has(skippedHtml, "text-[var(--mu-err)]"), "skipped 绝不得用错误色渲染(界面不许报红)"); ok(!has(skippedHtml, "应用失败"), "skipped 不得被呈现成失败"); // 反向哨兵:确有 failed 时错误色**必须**出现 —— 否则上面那条否定断言是空的。 const failedHtml = render( viewState({ batch: { ...BATCH_DONE, applied: 2, skipped: 0, failed: 1, total: 3 } }) ); ok(has(failedHtml, "text-[var(--mu-err)]"), "失败计数非 0 时必须出现错误色(否定断言的对照)"); ok(countOf(failedHtml, TIDY_APPLY_ALL_DONE_TAG) === 1, "有失败但未截断:完成标记仍按 truncated=false 显示"); // ── ⑤ 列表读失败 ≠ 没有候选 ────────────────────────────────────────────── const listErrHtml = render( viewState({ list: { kind: "error", message: "tidy_unavailable:记忆整理候选未接线" } }) ); ok(has(listErrHtml, "tidy_unavailable"), "必须显示后端给的原因"); ok(has(listErrHtml, TIDY_CANDIDATE_LIST_ERROR_HINT), "要说清「没读到」不是「没有候选」"); ok(!has(listErrHtml, "没有待定候选"), "读失败时绝不得渲染空态文案(把失败当结论)"); ok(has(render(viewState({ list: { kind: "loading" } })), "加载候选…"), "取数中要显示加载态"); // ── ⑥ 两个写动作必须先二次确认 ──────────────────────────────────────────── const idleHtml = render(viewState()); ok(has(idleHtml, "批量应用全部待定") && has(idleHtml, "手动真跑一轮"), "顶部必须给出两个写动作"); ok(!has(idleHtml, "确认批量应用"), "没点按钮时不得出现确认块"); const confirmBatchHtml = render(viewState({ confirming: "batch" })); ok(has(confirmBatchHtml, "确认批量应用"), "批量应用要先展开二次确认"); ok(has(confirmBatchHtml, "行永不物理删除"), "确认文案要说清软动作与可回滚"); const confirmRunHtml = render(viewState({ confirming: "run" })); ok(has(confirmRunHtml, "确认真跑一轮"), "真跑也要先二次确认"); ok(has(confirmRunHtml, "不自动应用"), "真跑确认文案要说清「不自动应用」"); // ── ⑦ 手动真跑回执:建议侧计数 + 只产候选 ────────────────────────────────── const RUN: AdminTidyRunResult = { runId: "run-20261005-1018", trigger: "manual", ruleVersion: "t0.1", durationMs: 1234, partitions: 3, scanned: 736, compared: 400, pairs: 12, vetoed: 4, errors: 0, truncated: [], vetoReasons: { scope: 2 }, candidatesCreated: 42, candidatesDuplicate: 7, candidatesByAction: { merge: 30, link: 12 }, }; const runHtml = render(viewState({ run: RUN })); ok(has(runHtml, "只产候选,未自动应用"), "真跑回执必须点明不自动应用"); ok(has(runHtml, "run-20261005-1018"), "必须显示 runId(批量应用按轮次要用它)"); ok(has(runHtml, "新增候选 42 条"), "必须显示新建候选数"); ok(has(runHtml, "幂等去重 7 条"), "必须显示被幂等键挡下的数量"); ok(has(runHtml, "并入=30") && has(runHtml, "只建链接=12"), "按动作分布要用人话标签"); const runErrHtml = render(viewState({ run: { ...RUN, errors: 2 } })); ok(has(runErrHtml, "本轮有 2 处失败"), "真跑里有失败必须如实显示"); // ── ⑧ 写动作失败要显示后端文案(不自己编)────────────────────────────────── const failHtml = render(viewState({ fail: "tidy_disabled:记忆整理已被配置关闭" })); ok(has(failHtml, "tidy_disabled"), "写动作失败要显示后端 message"); // eslint-disable-next-line no-console console.log(`MemoryTidyApplyCard.render: ${checks} checks passed`);