- 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 纯逻辑
130 lines
3.7 KiB
TypeScript
130 lines
3.7 KiB
TypeScript
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);
|
||
}
|
||
});
|
||
});
|