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。
This commit is contained in:
95
apps/web/lib/wizard/useProjectPlan.test.ts
Normal file
95
apps/web/lib/wizard/useProjectPlan.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ProjectPlanGenerateRequest } from "@/lib/api/types";
|
||||
|
||||
import { useProjectPlan } from "./useProjectPlan";
|
||||
|
||||
// 后端客户端与 Toast 是 hook 的外部副作用边界,单测一律 mock。
|
||||
const post = vi.fn();
|
||||
const toast = vi.fn();
|
||||
vi.mock("@/lib/api/client", () => ({ api: { POST: (...a: unknown[]) => post(...a) } }));
|
||||
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
|
||||
|
||||
const seed: ProjectPlanGenerateRequest = {
|
||||
genre: "仙侠",
|
||||
logline: "废柴逆袭封神。",
|
||||
};
|
||||
|
||||
describe("useProjectPlan", () => {
|
||||
beforeEach(() => {
|
||||
post.mockReset();
|
||||
toast.mockReset();
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it("初始为 idle、无方案", () => {
|
||||
const { result } = renderHook(() => useProjectPlan());
|
||||
expect(result.current.status).toBe("idle");
|
||||
expect(result.current.plan).toBeNull();
|
||||
});
|
||||
|
||||
it("生成成功:status 走到 done 并填充方案", async () => {
|
||||
const plan = {
|
||||
title_candidates: ["逐光而行"],
|
||||
setting: "仙门世界",
|
||||
narrative_structure: "立钩",
|
||||
story_core: "逆袭",
|
||||
ending_design: "归隐",
|
||||
tone: "热血",
|
||||
};
|
||||
post.mockResolvedValue({ data: plan, error: null });
|
||||
|
||||
const { result } = renderHook(() => useProjectPlan());
|
||||
await act(async () => {
|
||||
await result.current.generate(seed);
|
||||
});
|
||||
|
||||
expect(result.current.status).toBe("done");
|
||||
expect(result.current.plan).toEqual(plan);
|
||||
expect(toast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("后端返回 error:status=error 且弹错误 toast", async () => {
|
||||
post.mockResolvedValue({ data: null, error: { detail: "配额不足" } });
|
||||
const { result } = renderHook(() => useProjectPlan());
|
||||
await act(async () => {
|
||||
await result.current.generate(seed);
|
||||
});
|
||||
expect(result.current.status).toBe("error");
|
||||
expect(result.current.plan).toBeNull();
|
||||
expect(toast).toHaveBeenCalledWith(expect.any(String), "error");
|
||||
});
|
||||
|
||||
it("请求抛异常:status=error 且弹网络异常 toast", async () => {
|
||||
post.mockRejectedValue(new Error("network down"));
|
||||
const { result } = renderHook(() => useProjectPlan());
|
||||
await act(async () => {
|
||||
await result.current.generate(seed);
|
||||
});
|
||||
expect(result.current.status).toBe("error");
|
||||
expect(toast).toHaveBeenCalledWith("生成请求异常,请检查网络。", "error");
|
||||
});
|
||||
|
||||
it("reset 清回 idle 与空方案", async () => {
|
||||
post.mockResolvedValue({
|
||||
data: {
|
||||
title_candidates: ["书"],
|
||||
setting: "",
|
||||
narrative_structure: "",
|
||||
story_core: "",
|
||||
ending_design: "",
|
||||
tone: "",
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
const { result } = renderHook(() => useProjectPlan());
|
||||
await act(async () => {
|
||||
await result.current.generate(seed);
|
||||
});
|
||||
act(() => result.current.reset());
|
||||
expect(result.current.status).toBe("idle");
|
||||
expect(result.current.plan).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user