Files
writer-work-flow/apps/web/lib/chain/chain.test.ts
Yaojia Wang 1a402f5ccc feat(web): F1/F2/F3 前端——拆书入库为规则 / 续写式链选择 / 模板库
- gen:api 纳入 /templates、chain continue_volume、teardown rules ingest 端点
- F1:GeneratorRunner 识别拆书为单对象入库(整 preview 作一条 teardown),
  ingestTable(BookTeardownResult)=rules,buildIngestRequest 组装 teardown 体,
  按 isSingleObjectIngest 隐藏勾选框、改文案「入库为规则」
- F2:ChainStarter 加链类型单选(draft_volume 从头写 / continue_volume 续写),
  chain_key 作 path 参传给 run(CHAIN_KINDS 对齐后端 SUPPORTED_CHAINS)
- F3:模板库页 app/templates + 全局 nav 入口 + TemplatesManager(列/建/删,乐观更新回滚);
  GeneratorRunner 加 TemplateFiller「从模板填入」(body 填进 brief/text,纯前端)
- vitest 覆盖 templates/ingest teardown/templateFillTarget/CHAIN_KINDS 纯逻辑
2026-06-23 20:29:48 +02:00

130 lines
3.7 KiB
TypeScript
Raw Permalink 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,
CHAIN_KINDS,
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: [] });
});
});
describe("CHAIN_KINDS", () => {
it("exposes both built-in chain kinds aligned with backend SUPPORTED_CHAINS", () => {
expect(CHAIN_KINDS.map((k) => k.key)).toEqual([
"draft_volume",
"continue_volume",
]);
});
it("gives each kind a non-empty label and description", () => {
for (const kind of CHAIN_KINDS) {
expect(kind.label.length).toBeGreaterThan(0);
expect(kind.description.length).toBeGreaterThan(0);
}
});
});