import { act, renderHook, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useProjectLaunchState } from "@/features/project-launch/useProjectLaunchState"; import { confirmPMFlowLaunchDraft, createPMFlowLaunchDraftJob, getPMFlowLaunchDraftJob, revisePMFlowLaunchDraft, type PMFlowLaunchDraft, } from "@/api/pmflowLaunch"; import { spaceApi } from "@/api/space"; import { savePendingIntent } from "@/lib/pendingIntent"; vi.mock("@/api/pmflowLaunch", () => ({ createPMFlowLaunchDraftJob: vi.fn(), getPMFlowLaunchDraftJob: vi.fn(), revisePMFlowLaunchDraft: vi.fn(), confirmPMFlowLaunchDraft: vi.fn(), })); vi.mock("@/api/space", () => ({ spaceApi: { createSavedIdea: vi.fn(), }, })); const createDraftJobMock = vi.mocked(createPMFlowLaunchDraftJob); const getDraftJobMock = vi.mocked(getPMFlowLaunchDraftJob); const reviseDraftMock = vi.mocked(revisePMFlowLaunchDraft); const confirmDraftMock = vi.mocked(confirmPMFlowLaunchDraft); const createSavedIdeaMock = vi.mocked(spaceApi.createSavedIdea); const draftPayload: PMFlowLaunchDraft = { draft_id: "draft_test", status: "draft", expires_at: "2026-04-26T00:00:00Z", title: "AI 会议纪要工具", one_liner: "自动整理会议结论和待办", target_user: "产品经理、研发负责人", problem: "会议后行动项分散", solution: "从会议内容中提取结论、风险和任务", requirements: [ { title: "上传会议内容", summary: "支持录音或文本输入", acceptance_criteria: ["能提交文本"] }, { title: "提取行动项", summary: "输出责任人和待办", acceptance_criteria: ["能生成任务"] }, { title: "同步项目上下文", summary: "把结论带入项目", acceptance_criteria: ["能进入项目页"] }, ], risks: [ { title: "隐私风险", summary: "会议内容可能包含敏感信息", level: "high", mitigation: "确认权限边界" }, { title: "识别误差", summary: "行动项可能识别不完整", level: "medium", mitigation: "保留人工确认" }, ], decisions: [{ title: "先做文本输入", reason: "降低首版复杂度", owner: "用户确认" }], tasks: [ { title: "设计会议输入表单", summary: "完成最小可用输入", group: "product", priority: "medium", acceptance_criteria: ["可以提交"], requirement_index: 0, }, { title: "实现草案解析", summary: "生成结构化结果", group: "engineering", priority: "high", acceptance_criteria: ["接口返回草案"], requirement_index: 1, }, { title: "验证输出质量", summary: "检查关键字段", group: "qa", priority: "medium", acceptance_criteria: ["失败时有错误态"], requirement_index: 2, }, ], evidence: [{ source: "user_input", excerpt: "AI 会议纪要工具" }], confidence: 0.82, warnings: ["需要确认企业微信接入边界"], }; const completedJob = { job_id: "job_test", revision_id: "rev_test", status: "completed" as const, mode: "create", parent_draft_id: null, superseded_by_job_id: null, draft_id: "draft_test", draft: draftPayload, changed_fields: ["requirements", "tasks"], error_code: null, error_message: null, started_at: "2026-04-26T00:00:00Z", completed_at: "2026-04-26T00:00:01Z", expires_at: "2026-04-27T00:00:00Z", }; const regeneratedJob = { ...completedJob, job_id: "job_regenerate", revision_id: "rev_regenerate", mode: "regenerate", parent_draft_id: "draft_test", draft_id: "draft_regenerated", draft: { ...draftPayload, draft_id: "draft_regenerated", title: "AI 会议行动包工具", one_liner: "把会议内容转成可确认的项目行动包", }, changed_fields: ["title", "one_liner", "requirements", "tasks"], }; describe("useProjectLaunchState", () => { beforeEach(() => { vi.clearAllMocks(); createSavedIdeaMock.mockResolvedValue({ id: "idea_test", title: "AI 会议纪要工具", rawText: "我想做一个 AI 会议纪要工具", summary: "我想做一个 AI 会议纪要工具", sourceType: "project_launch", sourceRef: null, inputMode: "idea", attachments: [], status: "needs_more_info", statusLabel: "信息不足", aiSummary: null, missingQuestions: [], readinessScore: 0.5, enrichmentStatus: "baseline", enrichmentPayload: { source: "system_baseline" }, enrichmentError: null, enrichedAt: null, launchDraftId: null, promotedProjectId: null, createdAt: "2026-04-28T00:00:00", updatedAt: "2026-04-28T00:00:00", href: "/project-launch?ideaId=idea_test", }); localStorage.clear(); sessionStorage.clear(); localStorage.setItem("pmflow_token", "token-1"); window.history.pushState({}, "", "/project-launch"); }); afterEach(() => { vi.clearAllMocks(); localStorage.clear(); sessionStorage.clear(); }); it("uses the real launch draft API by default", async () => { createDraftJobMock.mockResolvedValueOnce(completedJob); const { result } = renderHook(() => useProjectLaunchState()); act(() => result.current.updateInput("我想做一个 AI 会议纪要工具")); act(() => result.current.startGeneration()); expect(createDraftJobMock).toHaveBeenCalledWith({ input: "我想做一个 AI 会议纪要工具", input_type: "text", attachments: [], source: "web", mode: "create", }); await waitFor(() => expect(result.current.snapshot.stage).toBe("human_review")); expect(result.current.snapshot.dataSourceMode).toBe("api"); expect(result.current.snapshot.draftId).toBe("draft_test"); expect(result.current.snapshot.draft.name).toBe("AI 会议纪要工具"); expect(result.current.snapshot.warnings).toEqual(["需要确认企业微信接入边界"]); }); it("regenerates an existing draft through the revise endpoint instead of recreating the same prompt", async () => { createDraftJobMock.mockResolvedValueOnce(completedJob); reviseDraftMock.mockResolvedValueOnce(regeneratedJob); const { result } = renderHook(() => useProjectLaunchState()); act(() => result.current.updateInput("我想做一个 AI 会议纪要工具")); act(() => result.current.startGeneration()); await waitFor(() => expect(result.current.snapshot.stage).toBe("human_review")); act(() => result.current.regenerateDraft()); expect(reviseDraftMock).toHaveBeenCalledWith( "draft_test", expect.objectContaining({ input: "我想做一个 AI 会议纪要工具", input_type: "text", attachments: [], source: "web", mode: "regenerate", parent_draft_id: "draft_test", revision_note: expect.stringContaining("替代草案"), }) ); expect(createDraftJobMock).toHaveBeenCalledTimes(1); await waitFor(() => expect(result.current.snapshot.draftId).toBe("draft_regenerated")); expect(result.current.snapshot.draft.name).toBe("AI 会议行动包工具"); }); it("prefills launch input from PMFlow AI action links", () => { window.history.pushState({}, "", `/project-launch?source=pmflow-ai&prefill=${encodeURIComponent("我想做一个 AI 漫剧工具")}`); const { result } = renderHook(() => useProjectLaunchState()); expect(result.current.snapshot.stage).toBe("intent_input"); expect(result.current.snapshot.input.text).toBe("我想做一个 AI 漫剧工具"); }); it("prefills launch input from PMFlow AI action payload stored before navigation", () => { window.history.pushState({}, "", "/project-launch?source=pmflow-ai"); window.sessionStorage.setItem( "pmflow_project_launch_prefill", JSON.stringify({ mode: "idea", text: "我想做个AI漫剧的工具", source: "pmflow_ai" }), ); const { result } = renderHook(() => useProjectLaunchState()); expect(result.current.snapshot.stage).toBe("intent_input"); expect(result.current.snapshot.input.text).toBe("我想做个AI漫剧的工具"); expect(window.sessionStorage.getItem("pmflow_project_launch_prefill")).toBeNull(); }); it("starts draft generation when PMFlow AI action requests project launch", async () => { createDraftJobMock.mockResolvedValueOnce(completedJob); window.history.pushState({}, "", `/project-launch?source=pmflow-ai&autostart=1&prefill=${encodeURIComponent("我想做个AI漫剧的工具")}`); const { result } = renderHook(() => useProjectLaunchState()); await waitFor(() => { expect(createDraftJobMock).toHaveBeenCalledWith({ input: "我想做个AI漫剧的工具", input_type: "text", attachments: [], source: "web", mode: "create", }); }); await waitFor(() => expect(result.current.snapshot.stage).toBe("human_review")); expect(result.current.snapshot.draftId).toBe("draft_test"); }); it("starts draft generation from stored PMFlow AI payload when input is too large for URL", async () => { createDraftJobMock.mockResolvedValueOnce(completedJob); window.history.pushState({}, "", "/project-launch?source=pmflow-ai&autostart=1"); window.localStorage.setItem( "pmflow_project_launch_prefill", JSON.stringify({ mode: "idea", text: "我想做个AI漫剧的工具", source: "pmflow_ai", autoStart: true }), ); const { result } = renderHook(() => useProjectLaunchState()); await waitFor(() => { expect(createDraftJobMock).toHaveBeenCalledWith({ input: "我想做个AI漫剧的工具", input_type: "text", attachments: [], source: "web", mode: "create", }); }); await waitFor(() => expect(result.current.snapshot.stage).toBe("human_review")); expect(result.current.snapshot.draftId).toBe("draft_test"); expect(window.localStorage.getItem("pmflow_project_launch_prefill")).toBeNull(); }); it("passes the Space AI launch seed into the draft job so Project Launch keeps the same product understanding", async () => { createDraftJobMock.mockResolvedValueOnce(completedJob); const launchSeed = { packet_version: "pmflow_project_launch_seed.v1", source: "space_ai_turn", idea_input: "AI 音乐自动生成工具", customer_safe_claims: [{ claim_text: "首版围绕文本生成音乐和音频导出闭环。" }], materialization_policy: { mode: "seed_first", do_not_reinterpret_as_new_idea: true }, }; window.history.pushState({}, "", "/project-launch?source=pmflow-ai&autostart=1"); window.sessionStorage.setItem( "pmflow_project_launch_prefill", JSON.stringify({ mode: "idea", text: "AI 音乐自动生成工具", source: "pmflow_ai", autoStart: true, launchSeed }), ); const { result } = renderHook(() => useProjectLaunchState()); await waitFor(() => { expect(createDraftJobMock).toHaveBeenCalledWith({ input: "AI 音乐自动生成工具", input_type: "text", attachments: [ { type: "pmflow_launch_seed", name: "Space AI 本轮产品方案种子", summary: "来自 PMFlow AI 本轮回答的产品定位、MVP 边界、风险、验证路径和下一步动作,用于保持项目草案与回答口径一致。", payload: launchSeed, }, ], source: "web", mode: "create", }); }); expect(result.current.snapshot.input.launchSeed).toEqual(launchSeed); }); it("keeps API draft failures in an explicit failed state", async () => { createDraftJobMock.mockRejectedValueOnce(new Error("draft_structured_failed")); const { result } = renderHook(() => useProjectLaunchState()); act(() => result.current.updateInput("缺字段的项目")); act(() => result.current.startGeneration()); await waitFor(() => expect(result.current.snapshot.stage).toBe("generation_failed")); expect(result.current.snapshot.failureKind).toBe("ai_generation_failed"); expect(result.current.snapshot.errorMessage).toBe("AI 暂时没有完成项目草案生成。你的输入已保留,可以稍后重试。"); expect(result.current.snapshot.draftId).toBeNull(); }); it("maps 401 draft failure to session_expired and hides raw axios copy", async () => { createDraftJobMock.mockRejectedValueOnce({ message: "Request failed with status code 401", response: { status: 401, data: { detail: "Could not validate credentials" } }, }); const { result } = renderHook(() => useProjectLaunchState()); act(() => result.current.updateInput("我想做一个 AI 项目")); act(() => result.current.startGeneration()); await waitFor(() => expect(result.current.snapshot.stage).toBe("generation_failed")); expect(result.current.snapshot.failureKind).toBe("session_expired"); expect(result.current.snapshot.errorMessage).toBe("登录状态已失效,重新登录后可以继续。"); expect(result.current.snapshot.errorMessage).not.toContain("Request failed with status code 401"); }); it("maps 403 draft failure to permission_denied", async () => { createDraftJobMock.mockRejectedValueOnce({ message: "Request failed with status code 403", response: { status: 403, data: { detail: "Access denied" } }, }); const { result } = renderHook(() => useProjectLaunchState()); act(() => result.current.updateInput("我想做一个 AI 项目")); act(() => result.current.startGeneration()); await waitFor(() => expect(result.current.snapshot.stage).toBe("generation_failed")); expect(result.current.snapshot.failureKind).toBe("permission_denied"); expect(result.current.snapshot.errorMessage).toBe("当前账号没有执行此操作的权限。"); }); it("maps network draft failure to network_unavailable", async () => { createDraftJobMock.mockRejectedValueOnce({ code: "ERR_NETWORK", message: "Network Error" }); const { result } = renderHook(() => useProjectLaunchState()); act(() => result.current.updateInput("我想做一个 AI 项目")); act(() => result.current.startGeneration()); await waitFor(() => expect(result.current.snapshot.stage).toBe("generation_failed")); expect(result.current.snapshot.failureKind).toBe("network_unavailable"); expect(result.current.snapshot.errorMessage).toBe("服务连接异常,请稍后重试。"); }); it("localizes low-information draft failure without exposing backend code", async () => { createDraftJobMock.mockRejectedValueOnce({ message: "Request failed with status code 422", response: { status: 422, data: { code: 422, message: "input_needs_more_info: 当前输入还不足以生成稳定的项目草案。请至少补充:想做什么产品、面向谁、解决什么问题。", data: null, }, }, }); const { result } = renderHook(() => useProjectLaunchState()); act(() => result.current.updateInput("你好")); act(() => result.current.startGeneration()); await waitFor(() => expect(result.current.snapshot.stage).toBe("generation_failed")); expect(result.current.snapshot.failureKind).toBe("input_invalid"); expect(result.current.snapshot.errorMessage).toBe("当前输入还不足以生成稳定的项目草案。请至少补充:想做什么产品、面向谁、解决什么问题。"); expect(result.current.snapshot.errorMessage).not.toContain("input_needs_more_info"); }); it("saves pending input and does not call the draft API when token is missing", async () => { localStorage.removeItem("pmflow_token"); const { result } = renderHook(() => useProjectLaunchState()); act(() => result.current.updateInput("登录后继续的启动台输入")); act(() => result.current.startGeneration()); await waitFor(() => expect(result.current.snapshot.stage).toBe("generation_failed")); expect(result.current.snapshot.failureKind).toBe("auth_required"); expect(createDraftJobMock).not.toHaveBeenCalled(); }); it("confirms through the real API and stores the backend redirect URL", async () => { createDraftJobMock.mockResolvedValueOnce(completedJob); confirmDraftMock.mockResolvedValueOnce({ project_id: 123, redirect_url: "/pmflow-os?projectId=123", created_truth_node_ids: [1, 2, 3], created_task_ids: [10, 11], warnings: [], }); const { result } = renderHook(() => useProjectLaunchState()); act(() => result.current.updateInput("我想做一个 AI 会议纪要工具")); act(() => result.current.startGeneration()); await waitFor(() => expect(result.current.snapshot.stage).toBe("human_review")); act(() => result.current.createProject()); expect(result.current.snapshot.creatingProject).toBe(true); await waitFor(() => expect(result.current.snapshot.stage).toBe("creation_success")); expect(result.current.snapshot.creatingProject).toBe(false); expect(confirmDraftMock).toHaveBeenCalledWith( "draft_test", expect.objectContaining({ confirm: true, edited_draft: expect.objectContaining({ title: "AI 会议纪要工具" }) }) ); expect(result.current.snapshot.redirectUrl).toBe("/pmflow-os?projectId=123"); }); it("does not enter success state when confirm API fails", async () => { createDraftJobMock.mockResolvedValueOnce(completedJob); confirmDraftMock.mockRejectedValueOnce(new Error("confirm_failed")); const { result } = renderHook(() => useProjectLaunchState()); act(() => result.current.updateInput("我想做一个 AI 会议纪要工具")); act(() => result.current.startGeneration()); await waitFor(() => expect(result.current.snapshot.stage).toBe("human_review")); act(() => result.current.createProject()); await waitFor(() => expect(result.current.snapshot.stage).toBe("generation_failed")); expect(result.current.snapshot.creatingProject).toBe(false); expect(result.current.snapshot.failureKind).toBe("system_failed"); expect(result.current.snapshot.errorMessage).toBe("操作没有完成,请稍后重试。"); expect(result.current.snapshot.redirectUrl).toBeNull(); }); it("only uses local demo state when demo mode is explicit", () => { window.history.pushState({}, "", "/project-launch?demo=1"); const { result } = renderHook(() => useProjectLaunchState()); expect(result.current.snapshot.dataSourceMode).toBe("demo"); expect(result.current.snapshot.input.text).toContain("AI 周报工具"); expect(createDraftJobMock).not.toHaveBeenCalled(); }); it("restores pending project launch input without submitting it", () => { sessionStorage.clear(); savePendingIntent({ intentType: "project_launch_input", route: "/project-launch", payload: { text: "恢复登录前输入", mode: "idea" }, }); const { result } = renderHook(() => useProjectLaunchState()); expect(result.current.snapshot.input.text).toBe("恢复登录前输入"); expect(result.current.snapshot.stage).toBe("intent_input"); expect(createDraftJobMock).not.toHaveBeenCalled(); }); it("saves launch input as a real saved idea", async () => { const { result } = renderHook(() => useProjectLaunchState()); act(() => result.current.updateInput("我想做一个 AI 会议纪要工具")); act(() => result.current.saveIdea()); await waitFor(() => expect(result.current.snapshot.savedIdea).toBe(true)); expect(createSavedIdeaMock).toHaveBeenCalledWith(expect.objectContaining({ raw_text: "我想做一个 AI 会议纪要工具", source_type: "project_launch", input_mode: "idea", })); expect(result.current.snapshot.savedIdeaId).toBe("idea_test"); expect(result.current.snapshot.savedIdeaError).toBeNull(); }); it("restores a promoted saved idea draft without re-submitting generation", () => { sessionStorage.clear(); savePendingIntent({ intentType: "project_launch_draft", route: "/project-launch", payload: { text: "从暂存想法生成草案", mode: "idea", draftId: draftPayload.draft_id, rawDraft: draftPayload as unknown as Record, }, }); const { result } = renderHook(() => useProjectLaunchState()); expect(result.current.snapshot.stage).toBe("human_review"); expect(result.current.snapshot.draftId).toBe("draft_test"); expect(result.current.snapshot.draft.name).toBe("AI 会议纪要工具"); expect(createDraftJobMock).not.toHaveBeenCalled(); }); });