Files
writer-work-flow/apps/web/lib/wizard/wizard.test.ts
Yaojia Wang 821ace5989 feat(web): 立项新增 基调/结局/视角 三字段(书级常量入 stable_core 缓存前缀)
灵感计划 §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 登记。
2026-07-06 15:59:49 +02:00

146 lines
4.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 {
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");
});
});