Files
writer-work-flow/apps/web/lib/chain/chain.test.ts
Yaojia Wang a2dcd89881 feat(web): Scope B B1 — 多章工作流链前端 UI(发起/轮询/裁决续跑)
链页 app/projects/[id]/chains(RSC fetchProject)+ ChainPage/ChainStarter/
ChainProgress/ChainAdjudication;lib/chain 纯函数(result 收窄/相位映射/resume 组装)。
复用 useJobPoll 轮询、ConflictCard + decisions.ts 裁决、normalizeConflicts/latestReview、
friendlyError;nav 加 chains 入口。awaiting(interrupt)→ 读该章冲突渲 ConflictCard →
POST .../chains/runs/{job_id}/resume 续跑。gen:api 纳入 chains run/resume。
守 #5(链暂停=等裁决,token/正文不入 job result)。
2026-06-23 19:21:00 +02:00

113 lines
3.2 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.

import { describe, expect, it } from "vitest";
import { emptyDecisions, setNote, setVerdict } from "@/lib/review/decisions";
import type { JobView } from "@/lib/jobs/job";
import {
buildResumeRequest,
chainPhase,
chainResultView,
} from "./chain";
function job(result: Record<string, unknown> | null): JobView {
return {
id: "j1",
kind: "chain",
status: "running",
progress: 0,
result,
error: null,
};
}
describe("chainResultView", () => {
it("narrows a completed chain result", () => {
const view = chainResultView(
job({
chain_key: "draft_volume",
written: [1, 2, 3],
completed: true,
awaiting_chapter: null,
}),
);
expect(view).toEqual({
chainKey: "draft_volume",
written: [1, 2, 3],
awaitingChapter: null,
completed: true,
});
});
it("narrows an awaiting result (interrupt on a chapter)", () => {
const view = chainResultView(
job({
chain_key: "draft_volume",
written: [1],
completed: false,
awaiting_chapter: 2,
}),
);
expect(view.awaitingChapter).toBe(2);
expect(view.completed).toBe(false);
expect(view.written).toEqual([1]);
});
it("returns safe defaults for null/garbage result", () => {
expect(chainResultView(job(null))).toEqual({
chainKey: null,
written: [],
awaitingChapter: null,
completed: false,
});
expect(
chainResultView(job({ written: "nope", awaiting_chapter: "x" })).written,
).toEqual([]);
});
it("keeps only finite integer chapter numbers in written", () => {
const view = chainResultView(
job({ written: [1, "2", 3.5, null, 4] }),
);
expect(view.written).toEqual([1, 4]);
});
});
describe("chainPhase", () => {
it("maps polling with no awaiting chapter to running", () => {
expect(chainPhase("polling", null)).toBe("running");
});
it("maps any awaiting chapter to awaiting (interrupt signal is authoritative)", () => {
// 后端 interrupt 时置 status=awaiting_input被 narrowJob 抹平成 queued→仍 polling
// 但 result.awaiting_chapter 已置数;运行中该值恒 null故它是暂停的权威信号。
expect(chainPhase("polling", 2)).toBe("awaiting");
expect(chainPhase("done", 2)).toBe("awaiting");
});
it("maps done + no awaiting to completed", () => {
expect(chainPhase("done", null)).toBe("completed");
});
it("maps error to failed (even if an awaiting chapter lingers)", () => {
expect(chainPhase("error", null)).toBe("failed");
expect(chainPhase("error", 2)).toBe("failed");
});
});
describe("buildResumeRequest", () => {
it("includes only resolved decisions and trims notes (reuses accept decision logic)", () => {
let drafts = emptyDecisions(3);
drafts = setVerdict(drafts, 0, "accept");
drafts = setVerdict(drafts, 2, "manual");
drafts = setNote(drafts, 2, " 改成后天淬炼 ");
const req = buildResumeRequest(drafts);
expect(req.decisions).toEqual([
{ conflict_index: 0, verdict: "accept" },
{ conflict_index: 2, verdict: "manual", note: "改成后天淬炼" },
]);
});
it("empty drafts → empty decisions list", () => {
expect(buildResumeRequest([])).toEqual({ decisions: [] });
});
});