主人报障「面板上点不到『应用』」→ 后端 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 凭据 + 真后端),证据止于渲染层与传输层。
211 lines
8.9 KiB
TypeScript
211 lines
8.9 KiB
TypeScript
// 记忆整理**应用路径** 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`);
|