import { describe, expect, it } from "vitest"; import type { ProjectPlanView } from "@/lib/api/types"; import { applyPlanPatch, canAdvance, canGeneratePlan, canSubmit, clampStep, emptyWizardForm, mapPlanResultToWizardForm, STEP_TITLES, toCreateRequest, toPlanSeedRequest, WIZARD_STEPS, type WizardForm, } from "./wizard"; const fullPlan: ProjectPlanView = { title_candidates: ["逐光而行", "剑试九霄"], setting: "上古仙门倾轧的洞天世界", narrative_structure: "黄金三章立钩 → 拜师 → 宗门大比爆发", story_core: "废柴少年觉醒禁忌血脉,逆势封神的代价", ending_design: "登顶后归隐,自我救赎收束", tone: "热血", }; describe("wizard step metadata", () => { it("provides one title per step", () => { expect(STEP_TITLES).toHaveLength(WIZARD_STEPS); }); it("ends on a confirmation step rather than an empty world step", () => { expect(STEP_TITLES[WIZARD_STEPS - 1]).toBe("确认与提交"); }); }); describe("wizard step gating", () => { it("blocks advancing past step 1 without a title", () => { expect(canAdvance(1, emptyWizardForm)).toBe(false); }); it("allows advancing step 1 once title is set", () => { expect(canAdvance(1, { ...emptyWizardForm, title: "逐光而行" })).toBe(true); }); it("allows advancing later steps even when fields are empty", () => { expect(canAdvance(3, emptyWizardForm)).toBe(true); }); it("clamps step within bounds", () => { expect(clampStep(0)).toBe(1); expect(clampStep(WIZARD_STEPS + 3)).toBe(WIZARD_STEPS); expect(clampStep(3)).toBe(3); }); it("requires a title to submit", () => { expect(canSubmit(emptyWizardForm)).toBe(false); expect(canSubmit({ ...emptyWizardForm, title: "x" })).toBe(true); }); }); describe("toCreateRequest", () => { it("maps form to snake_case request, nulling empty optionals", () => { const form: WizardForm = { title: " 逐光而行 ", genre: "玄幻", logline: "", sellingPoints: ["逆袭", "系统流"], structure: "三幕", premise: " ", protagonist: "", theme: "抗争", tone: "", endingType: "", narrativePov: "", }; expect(toCreateRequest(form)).toEqual({ title: "逐光而行", genre: "玄幻", logline: null, premise: null, theme: "抗争", selling_points: ["逆袭", "系统流"], structure: "三幕", tone: null, ending_type: null, narrative_pov: null, }); }); // ④ 立项新增基调/结局/视角:camelCase 表单 → snake_case 请求,空值落 null。 it("maps tone, endingType, narrativePov to snake_case fields", () => { const form: WizardForm = { ...emptyWizardForm, title: "书", tone: "热血", endingType: "HE", narrativePov: "第一人称", }; const req = toCreateRequest(form); expect(req.tone).toBe("热血"); expect(req.ending_type).toBe("HE"); expect(req.narrative_pov).toBe("第一人称"); }); it("nulls empty tone, endingType, narrativePov", () => { const req = toCreateRequest({ ...emptyWizardForm, title: "书" }); expect(req.tone).toBeNull(); expect(req.ending_type).toBeNull(); expect(req.narrative_pov).toBeNull(); }); // QA H1 回归:立意(premise)与主角/金手指(protagonist)是两个独立输入, // 不能互相覆盖——提交时各占一段合并进 premise。 it("merges premise and protagonist into premise without overwriting", () => { const form: WizardForm = { ...emptyWizardForm, title: "书", premise: "凡人逆袭的代价", protagonist: "主角林川,金手指=吞噬术,限制:每次吞噬折寿", }; const req = toCreateRequest(form); expect(req.premise).toBe( "凡人逆袭的代价\n\n主角/金手指:主角林川,金手指=吞噬术,限制:每次吞噬折寿", ); }); it("keeps protagonist alone when premise is empty", () => { const form: WizardForm = { ...emptyWizardForm, title: "书", protagonist: "主角设定", }; expect(toCreateRequest(form).premise).toBe("主角/金手指:主角设定"); }); }); // ⑤ AI 立项方案 → 向导映射(含 ④ 字段 tone;错配字段折进 premise,不静默丢内容)。 describe("mapPlanResultToWizardForm", () => { it("maps title candidate, structure and tone to their wizard homes", () => { const patch = mapPlanResultToWizardForm(fullPlan); // 书名候选 → title(取首个)。 expect(patch.title).toBe("逐光而行"); // 叙事结构 → structure。 expect(patch.structure).toBe("黄金三章立钩 → 拜师 → 宗门大比爆发"); // ④ 字段:基调 → tone。 expect(patch.tone).toBe("热血"); }); it("folds setting and ending design into premise without dropping content", () => { const patch = mapPlanResultToWizardForm(fullPlan); // 故事核心 + 时空背景 + 结局设计 逐段拼进 premise(无独立向导字段,不静默丢)。 expect(patch.premise).toContain("废柴少年觉醒禁忌血脉,逆势封神的代价"); expect(patch.premise).toContain("时空背景:上古仙门倾轧的洞天世界"); expect(patch.premise).toContain("结局设计:登顶后归隐,自我救赎收束"); }); it("does not fabricate endingType or narrativePov (④ presets left for author)", () => { // 结局取向/叙事视角是预设枚举,无法从自由文本可靠反推 → 不进 patch(不臆造)。 const patch = mapPlanResultToWizardForm(fullPlan); expect(patch.endingType).toBeUndefined(); expect(patch.narrativePov).toBeUndefined(); }); it("omits empty fields from the patch (parse resilience)", () => { const empty: ProjectPlanView = { title_candidates: [], setting: "", narrative_structure: "", story_core: "", ending_design: "", tone: "", }; expect(mapPlanResultToWizardForm(empty)).toEqual({}); }); }); // applyPlanPatch:按字段并入、仅填空字段(保护作者已编辑内容,非 bulk 覆盖)。 describe("applyPlanPatch", () => { it("fills only empty fields, preserving author-edited values", () => { const form: WizardForm = { ...emptyWizardForm, title: "作者已取的书名", genre: "仙侠", }; const patch = { title: "AI书名", structure: "三幕", tone: "热血" }; const next = applyPlanPatch(form, patch); // 已编辑的 title 受保护、不被覆盖;空的 structure/tone 才回填。 expect(next.title).toBe("作者已取的书名"); expect(next.structure).toBe("三幕"); expect(next.tone).toBe("热血"); // 未在 patch 中的字段原样保留。 expect(next.genre).toBe("仙侠"); }); it("treats whitespace-only fields as empty (fillable)", () => { const form: WizardForm = { ...emptyWizardForm, premise: " " }; const next = applyPlanPatch(form, { premise: "AI 立意" }); expect(next.premise).toBe("AI 立意"); }); it("returns a new object without mutating the input form", () => { const form: WizardForm = { ...emptyWizardForm }; const next = applyPlanPatch(form, { tone: "热血" }); expect(next).not.toBe(form); expect(form.tone).toBe(""); }); }); // canGeneratePlan:种子门控(题材 + logline 都非空)。 describe("canGeneratePlan", () => { it("requires both genre and logline", () => { expect(canGeneratePlan(emptyWizardForm)).toBe(false); expect( canGeneratePlan({ ...emptyWizardForm, genre: "仙侠" }), ).toBe(false); expect( canGeneratePlan({ ...emptyWizardForm, logline: "一句话" }), ).toBe(false); expect( canGeneratePlan({ ...emptyWizardForm, genre: "仙侠", logline: "一句话" }), ).toBe(true); }); it("rejects whitespace-only seeds", () => { expect( canGeneratePlan({ ...emptyWizardForm, genre: " ", logline: " " }), ).toBe(false); }); }); // toPlanSeedRequest:向导草稿 → 生成请求(snake_case,空值省略)。 describe("toPlanSeedRequest", () => { it("serializes seed and filled fields, omitting empties", () => { const form: WizardForm = { ...emptyWizardForm, genre: "都市", logline: "外卖骑手觉醒读心术。", title: "听风者", sellingPoints: ["逆袭"], endingType: "HE", }; const req = toPlanSeedRequest(form); expect(req.genre).toBe("都市"); expect(req.logline).toBe("外卖骑手觉醒读心术。"); expect(req.title).toBe("听风者"); expect(req.ending_type).toBe("HE"); expect(req.selling_points).toEqual(["逆袭"]); // 空字段省略(undefined),不发空串。 expect(req.premise).toBeUndefined(); expect(req.tone).toBeUndefined(); }); }); // wizard→create 流程:归一后的请求体经 mock 客户端提交,返回新建项目 id。 describe("wizard create flow (mocked client)", () => { it("submits the normalized body and resolves a project id", async () => { const post = async ( _path: string, opts: { body: ReturnType }, ): Promise<{ data: { id: string }; error: null }> => { expect(opts.body.title).toBe("青冥录"); expect(opts.body.selling_points).toEqual(["群像"]); return { data: { id: "proj-123" }, error: null }; }; const form: WizardForm = { ...emptyWizardForm, title: "青冥录", sellingPoints: ["群像"], }; const { data } = await post("/projects", { body: toCreateRequest(form) }); expect(data.id).toBe("proj-123"); }); });