PMFlow/frontend/src/features/project-launch/useProjectLaunchState.test.ts

516 lines
20 KiB
TypeScript

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<string, unknown>,
},
});
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();
});
});