主人报障「面板上点不到『应用』」→ 后端 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 凭据 + 真后端),证据止于渲染层与传输层。
240 lines
12 KiB
TypeScript
240 lines
12 KiB
TypeScript
// 记忆整理**候选应用卡**的真实渲染测试(切片 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`);
|