feat(admin): 记忆整理候选的应用/回滚操作面(切片 TIDY-FE)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

主人报障「面板上点不到『应用』」→ 后端 TIDY-1 的五条端点(契约 v0.83 B20–B24)
此前只能 curl 驱动。本片补齐界面:候选表(动作/状态/摘要/理由/时间 + 行内
应用/回滚,回滚仅在 status=applied 时可点)、status 筛选、批量应用全部待定、
手动真跑一轮(两个写动作都走二次确认)。

三条契约硬口径落到界面:
- 幂等不是错误:skipped 用中性色,绝不渲染成失败;
- 超限必须如实:truncated=true 时黄牌「还有 N 条待定未处理」,且该分支绝不
  渲染「已全部处理」;未处理量只由后端权威字段算得(total - 已处理三项);
- 权威键只读:状态与计数全部取自响应,不做「应该成功了」的推断。

一个刻意的偏离(有测试钉住):截断提示不用共用 Notice tone=warn —— 那个组件
的固定前缀是「待实现」,贴在「还有 N 条未处理」上会被读成「功能还没做」。

验收(主会话复跑 + 亲自变异):tsc --noEmit 0 错;vite build 通过;
渲染测试 43 checks、传输层测试 28 checks 全绿;我把 truncated 判断取反 ⇒
渲染测试红在「截断时必须显式点明命中数大于本轮处理数」,还原后 md5 一致且绿。

⚠️ 未跑真机浏览器交互(需 owner 凭据 + 真后端),证据止于渲染层与传输层。
This commit is contained in:
AskaEth committed 2026-10-07 09:58:21 +08:00
1 parent aa0bf8b5a0
commit c512609c63
9 files changed
+1370

No files matched your search

@@ -0,0 +1,239 @@
// 记忆整理**候选应用卡**的真实渲染测试(切片 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> = {}): 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`);