灵感计划 §3④ + §4 D 枚举决策: - projects 表加 tone/ending_type/narrative_pov(Text nullable、无 CHECK,同 genre/structure; 迁移 e5f6a7b8c9d0 nullable 免 backfill)。 - ProjectCreateRequest/ProjectResponse + domain ProjectCreate/ProjectView 全链路透传(snake_case)。 - 完整 stable_core 链路:ProjectSpecView + SqlProjectSpecRepo.spec + _build_spec_section 渲染进 「作品蓝本」块——书级常量随书稳定,字节稳定守缓存前缀不变量 #9,绝不入 volatile。 - 前端 wizard.ts 三字段 + 预设数组 TONES/ENDING_TYPES/NARRATIVE_POVS + ProjectWizard 控件与确认页回显。 - 契约变更已 pnpm gen:api + memory/contracts.md 登记。
146 lines
4.5 KiB
TypeScript
146 lines
4.5 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
|
||
import {
|
||
canAdvance,
|
||
canSubmit,
|
||
clampStep,
|
||
emptyWizardForm,
|
||
STEP_TITLES,
|
||
toCreateRequest,
|
||
WIZARD_STEPS,
|
||
type WizardForm,
|
||
} from "./wizard";
|
||
|
||
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("主角/金手指:主角设定");
|
||
});
|
||
});
|
||
|
||
// 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");
|
||
});
|
||
});
|