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:
Yaojia Wang
2026-07-06 16:26:08 +02:00
parent 821ace5989
commit 02d19019f6
22 changed files with 1187 additions and 9 deletions

View 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("后端返回 errorstatus=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();
});
});