向导阶段(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。
278 lines
9.5 KiB
TypeScript
278 lines
9.5 KiB
TypeScript
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");
|
||
});
|
||
});
|