Files
Cyrene_v2/frontend/web/src/admin/modules/MemoryTidyApplyCard.render.test.ts
T
AskaEth c512609c63
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(admin): 记忆整理候选的应用/回滚操作面(切片 TIDY-FE)
主人报障「面板上点不到『应用』」→ 后端 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 凭据 + 真后端),证据止于渲染层与传输层。
2026-10-07 09:58:21 +08:00

240 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 记忆整理**候选应用卡**的真实渲染测试(切片 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`);