516 lines
20 KiB
TypeScript
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();
|
|
});
|
|
});
|