Files
Cyrene_v2/frontend/web/src/lib/api/adminMemoryTidyApply.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

211 lines
8.9 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.
// 记忆整理**应用路径** api 客户端的测试(切片 TIDY-1)。
//
// 为什么需要它:这张卡的价值全在「点得到且说真话」,而最容易走样的是**传输层**的三件事 ——
// ① 五个端点的 URL / 方法形状(批量是 `…/candidates/apply`,不是 `…/candidates/{id}/apply`);
// ② `{data}` 信封解包(回到调用方的必须是 data 本身,不能是信封);
// ③ **`status:"skipped"` 是 200、不是错误** —— 传输层不许把它抛成异常(界面才不会报红),
// 而真正的失败(tidy_unavailable / not_found / tidy_run_not_found)必须带着 `code` 抛出,
// **绝不**回一份空结果假装「没有候选」。
//
// 跑法(与 flowTimeline.test.ts / configFacts.test.ts 同手法;项目无前端测试运行器):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/lib/api/adminMemoryTidyApply.test.ts \
// --bundle --platform=node --format=esm \
// --banner:js='globalThis.window={localStorage:{getItem:()=>null,setItem:()=>{},removeItem:()=>{}}};' \
// --outfile=/tmp/cyrene-memory-tidy-apply-api.test.mjs \
// && node /tmp/cyrene-memory-tidy-apply-api.test.mjs
//
// `tsc --noEmit` 会对本文件做类型检查,因此它不 import `node:*`(用 globalThis 取 fetch)。
import { ApiError } from "./core";
import {
adminTidyCandidateApply,
adminTidyCandidateRevert,
adminTidyCandidates,
adminTidyCandidatesApplyBatch,
adminTidyRun,
} from "./adminMemoryTidyApply";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function same<T>(got: T, want: T, what: string): void {
ok(got === want, `${what}(实际 ${JSON.stringify(got)},期望 ${JSON.stringify(want)})`);
}
type Call = { url: string; method: string };
const calls: Call[] = [];
/** api 客户端的 `request` 会读 `localStorage` 取 token / API key;node 下没有它。 */
globalThis.localStorage = {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
} as unknown as Storage;
/** 2xx 桩:回 `{data: body}` 信封(真后端 writeJSON 就是这个形状)。 */
function stubOK(body: unknown): void {
globalThis.fetch = ((url: string, init?: { method?: string }) => {
calls.push({ url: String(url), method: init?.method ?? "GET" });
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({ data: body }),
} as unknown as Response);
}) as typeof globalThis.fetch;
}
/** 非 2xx 桩:回 `{error:{code,message}}` 信封(真后端 writeError 的形状)。 */
function stubErr(status: number, code: string, message: string): void {
globalThis.fetch = ((url: string, init?: { method?: string }) => {
calls.push({ url: String(url), method: init?.method ?? "GET" });
return Promise.resolve({
ok: false,
status,
json: () => Promise.resolve({ error: { code, message } }),
} as unknown as Response);
}) as typeof globalThis.fetch;
}
/** 跑一次调用并把它抛出来的错误码取回来(没抛则回 "no-error")。 */
async function codeOf(task: () => Promise<unknown>): Promise<string> {
try {
await task();
return "no-error";
} catch (err: unknown) {
return err instanceof ApiError ? (err.code ?? "no-code") : "not-apierror";
}
}
// ── ① 候选列表:URL 形状 + `{data}` 解包 ────────────────────────────────────
const CANDIDATES_BODY = { candidates: [], count: 0 };
stubOK(CANDIDATES_BODY);
const list = await adminTidyCandidates({ status: "pending" });
same(calls[0].url, "/api/v1/admin/memories/candidates?status=pending", "候选列表 URL");
same(calls[0].method, "GET", "候选列表是只读 GET");
ok(list.candidates.length === 0 && list.count === 0, "解包 `{data}` 信封(回的是 data 本身)");
stubOK(CANDIDATES_BODY);
await adminTidyCandidates();
same(calls[calls.length - 1].url, "/api/v1/admin/memories/candidates", "不给 status 时不带查询串");
// ── ② 单条应用 / 回滚:路径 + POST,且 **skipped 是 200 不是错误** ─────────
const SKIPPED = {
candidateId: "cand-1",
action: "link",
status: "skipped",
reason: "action_not_applyable",
affectedIds: [],
};
stubOK(SKIPPED);
const skipped = await adminTidyCandidateApply("cand-1");
same(calls[calls.length - 1].url, "/api/v1/admin/memories/candidates/cand-1/apply", "单条应用 URL");
same(calls[calls.length - 1].method, "POST", "应用是写动作 POST");
same(skipped.status, "skipped", "200 + skipped 正常解包(客户端不许把它抛成异常)");
same(skipped.reason, "action_not_applyable", "skipped 的机器可读原因要透传给调用方");
stubOK({ ...SKIPPED, status: "applied", affectedIds: ["mem-short"] });
const applied = await adminTidyCandidateApply("cand-1");
same(applied.status, "applied", "applied 回执按后端值透传");
same(applied.affectedIds.length, 1, "受影响行 id 要透传(回滚的抓手)");
stubOK({ candidateId: "cand-1", action: "merge", status: "applied", affectedIds: ["mem-short"] });
await adminTidyCandidateRevert("cand-1");
same(calls[calls.length - 1].url, "/api/v1/admin/memories/candidates/cand-1/revert", "回滚 URL");
same(calls[calls.length - 1].method, "POST", "回滚是写动作 POST");
// 候选 id 必须 URL 编码(不拼裸串)。
stubOK(SKIPPED);
await adminTidyCandidateApply("cand a/b");
same(
calls[calls.length - 1].url,
"/api/v1/admin/memories/candidates/cand%20a%2Fb/apply",
"候选 id 要 URL 编码"
);
// ── ③ 批量应用:`…/candidates/apply` + `truncated`/`total` 原样透传 ──────────
const BATCH = {
limit: 100,
total: 342,
truncated: true,
applied: 90,
skipped: 10,
failed: 0,
merged: 80,
archived: 10,
reclassified: 0,
results: [SKIPPED],
};
stubOK(BATCH);
const batch = await adminTidyCandidatesApplyBatch();
same(calls[calls.length - 1].url, "/api/v1/admin/memories/candidates/apply", "批量应用 URL(不带 id)");
same(calls[calls.length - 1].method, "POST", "批量应用是写动作 POST");
same(batch.total, 342, "total 是真待定总数,原样透传");
same(batch.truncated, true, "truncated 原样透传(界面据此说「还剩多少未处理」)");
stubOK({ ...BATCH, runId: "run-1", total: 5, truncated: false });
await adminTidyCandidatesApplyBatch({ runId: "run-1", limit: 50 });
same(
calls[calls.length - 1].url,
"/api/v1/admin/memories/candidates/apply?runId=run-1&limit=50",
"按轮次批量应用要带 runId 与 limit"
);
// ── ④ 手动真跑 ────────────────────────────────────────────────────────────
stubOK({
runId: "run-9",
trigger: "manual",
ruleVersion: "t0.1",
durationMs: 12,
partitions: 1,
scanned: 3,
compared: 1,
pairs: 1,
vetoed: 0,
errors: 0,
vetoReasons: {},
candidatesCreated: 2,
candidatesDuplicate: 0,
candidatesByAction: { merge: 2 },
});
const run = await adminTidyRun();
same(calls[calls.length - 1].url, "/api/v1/admin/memories/tidy/run", "手动真跑 URL");
same(calls[calls.length - 1].method, "POST", "手动真跑是写动作 POST");
same(run.candidatesCreated, 2, "真跑回执按后端值透传");
// ── ⑤ 诚实失败:错误码必须原样上抛(绝不回空结果) ──────────────────────────
stubErr(500, "tidy_unavailable", "记忆整理候选未接线");
same(
await codeOf(() => adminTidyCandidates({ status: "pending" })),
"tidy_unavailable",
"写面未接线 ⇒ 抛 tidy_unavailable(不是空候选列表)"
);
stubErr(404, "not_found", "整理候选不存在");
same(await codeOf(() => adminTidyCandidateApply("nope")), "not_found", "候选不存在 ⇒ not_found");
stubErr(404, "tidy_run_not_found", "整理运行不存在");
same(
await codeOf(() => adminTidyCandidatesApplyBatch({ runId: "nope" })),
"tidy_run_not_found",
"runId 查不到 ⇒ tidy_run_not_found(与「这一轮没有待定候选」是两件事)"
);
stubErr(409, "tidy_disabled", "记忆整理已被配置关闭");
same(await codeOf(() => adminTidyRun()), "tidy_disabled", "配置关闭 ⇒ 409 tidy_disabled");
stubErr(500, "tidy_run_unavailable", "记忆整理真跑入口未接线");
same(await codeOf(() => adminTidyRun()), "tidy_run_unavailable", "只接了试算 ⇒ 真跑回 unavailable");
stubErr(503, "tidy_timeout", "记忆整理真跑超时");
same(await codeOf(() => adminTidyRun()), "tidy_timeout", "超时 ⇒ 503 tidy_timeout");
stubErr(400, "bad_request", "invalid limit");
same(
await codeOf(() => adminTidyCandidates({ status: "pending", limit: -1 })),
"bad_request",
"非法 limit 由后端回 400(客户端只转发合法值)"
);
// eslint-disable-next-line no-console
console.log(`adminMemoryTidyApply: ${checks} checks passed`);