Files
writer-work-flow/apps/web/lib/wizard/wizard.test.ts
Yaojia Wang 02d19019f6 feat(web): AI 立项方案生成+预填(种子门控 + 按字段接受)
向导阶段(project 未建)一键推演结构化书级蓝图预填立项向导。

- 新 SPEC project_plan_spec(analyst 档,纯预览 reads/writes=())+ schema
  ProjectPlanResult(书名候选 + 时空背景/叙事结构/故事核心/结局设计/基调,
  全字段默认值守解析韧性)+ 注册 SCHEMA_CATALOG;计数三处 21→22 + regen golden。
- 端点 POST /skills/project-plan/generate(不带 project 前缀,绕开 toolbox 404):
  请求体序列化向导草稿为 project_context,复用 build_brief_context + run_generator;
  种子门控(缺 genre/logline→422);请求 schema 加 max_length 上界。
- run_generator/_build_request 的 project_id 放宽 uuid.UUID|None(向导无 project)。
- 前端 wizard.ts 加 mapPlanResultToWizardForm(错配字段折进 premise 不静默丢)+
  applyPlanPatch(仅回填空字段、保护已编辑)+ useProjectPlan hook + PlanAssistant。
2026-07-06 16:26:08 +02:00

278 lines
9.5 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 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<typeof toCreateRequest> },
): 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");
});
});