import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, renderHook, waitFor } from "@testing-library/react"; import type { ReactNode } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { acceptPMFlowChangeItem, commitPMFlowChangeSet, consumePMFlowExecutionHandoff, createPMFlowExecutionHandoff, createPMFlowExecutionProgressEvent, createPMFlowReleaseValidationRun, createPMFlowThreadMessage, deferPMFlowChangeItem, editPMFlowChangeItem, getPMFlowExecutionSummary, getPMFlowTruthState, type PMFlowReleaseGate, } from "@/api/pmflowOs"; import { createInitialPMFlowState } from "@/features/pmflow-os/mockData"; import { PMFlowOsProvider, usePMFlowOsStore } from "@/features/pmflow-os/store"; import type { ProjectTruthState } from "@/features/pmflow-os/types"; vi.mock("@/api/pmflowOs", () => ({ getPMFlowTruthState: vi.fn(), getPMFlowExecutionSummary: vi.fn(), createPMFlowThreadMessage: vi.fn(), createPMFlowExtraction: vi.fn(), getPMFlowChangeSet: vi.fn(), acceptPMFlowChangeItem: vi.fn(), commitPMFlowChangeSet: vi.fn(), consumePMFlowExecutionHandoff: vi.fn(), createPMFlowExecutionHandoff: vi.fn(), createPMFlowExecutionProgressEvent: vi.fn(), createPMFlowReleaseValidationRun: vi.fn(), editPMFlowChangeItem: vi.fn(), deferPMFlowChangeItem: vi.fn(), })); const getTruthStateMock = vi.mocked(getPMFlowTruthState); const getExecutionSummaryMock = vi.mocked(getPMFlowExecutionSummary); const createThreadMessageMock = vi.mocked(createPMFlowThreadMessage); const createExecutionHandoffMock = vi.mocked(createPMFlowExecutionHandoff); const consumeExecutionHandoffMock = vi.mocked(consumePMFlowExecutionHandoff); const createExecutionProgressEventMock = vi.mocked(createPMFlowExecutionProgressEvent); const createReleaseValidationRunMock = vi.mocked(createPMFlowReleaseValidationRun); const acceptItemMock = vi.mocked(acceptPMFlowChangeItem); const commitChangeSetMock = vi.mocked(commitPMFlowChangeSet); const editItemMock = vi.mocked(editPMFlowChangeItem); const deferItemMock = vi.mocked(deferPMFlowChangeItem); function realState(): ProjectTruthState { const state = createInitialPMFlowState(); state.project.id = "123"; state.thread.id = "7"; state.requirements = []; state.activeChangeSetId = "11"; state.changeSets = [ { id: "11", threadId: "7", summary: "真实待审变更", headline: "真实变更集", createdAt: "2026-04-26T00:00:00", status: "open", itemIds: ["21"], }, ]; state.changeItems = [ { id: "21", changeSetId: "11", group: "new_object", objectType: "Requirement", objectId: "pending-21", title: "新增需求:真实审查", summary: "来自后端 projection", diff: "待确认", confidence: 0.8, riskLevel: "medium", status: "pending_review", evidenceIds: ["31"], }, ]; state.evidence = [ { id: "31", kind: "conversation", title: "项目线程消息", excerpt: "用户输入", sourceLabel: "项目线程", }, ]; return state; } function releaseGate(status: "ready" | "blocked" | "needs_confirmation" | "empty" = "needs_confirmation"): PMFlowReleaseGate { return { status, title: status === "blocked" ? "发布阻塞" : "发布候选,需人工确认", summary: "来自真实 execution-summary 的发布门禁判断", blockingReasons: status === "blocked" ? ["测试失败"] : [], nextActions: ["负责人确认后再发布"], checks: [ { key: "test", label: "测试证据", status: status === "blocked" ? "failed" : "passed", detail: "真实测试回写" }, ], evidence: [ { id: "703", type: "test_passed", summary: "真实测试回写", prNumber: 88 }, ], updatedAt: "2026-04-26T00:00:00", }; } function wrapper() { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return function Wrapper({ children }: { children: ReactNode }) { return ( {children} ); }; } describe("PMFlowOsProvider real change review actions", () => { beforeEach(() => { vi.clearAllMocks(); window.history.pushState({}, "", "/pmflow-os?projectId=123"); getTruthStateMock.mockResolvedValue(realState()); getExecutionSummaryMock.mockResolvedValue({ projectId: "123", execution: { taskId: "task-99", status: "coding", prompt: "真实 prompt package", commitLog: ["真实进度事件"], prStatus: "pending", testSummary: "pending", writebackStatus: "coding", }, releaseGate: releaseGate(), devSync: { active_task_count: 1 }, promptPackage: { id: "501", project_id: "123", charter_id: "301", task_id: "task-99", status: "active", package_kind: "development", package_text: "真实 prompt package", package_payload: {}, created_at: "2026-04-26T00:00:00", }, repoBindings: [ { id: "601", project_id: "123", provider: "github", owner: "pmflow", repo_name: "app", full_name: "pmflow/app", default_branch: "main", status: "active", }, ], activeDevTasks: [ { id: "task-99", title: "真实任务", description: "真实任务描述", status: "confirmed", priority: "high", project_id: "123", charter_id: "301", charter_title: "真实宪章", assignee: { id: "1", name: "Owner", role: "MEMBER" }, prompt_package_count: 1, code_link: null, }, ], recentProgressEvents: [], recentDeliveries: [], warnings: [], }); createThreadMessageMock.mockResolvedValue({ id: "41", threadId: "7", projectId: "123", role: "user", author: "你", content: "继续补充", createdAt: "2026-04-26T00:00:00", status: "sent", source: "web", metadata: {}, }); acceptItemMock.mockResolvedValue({ changeSet: {}, items: [], evidence: [], approvals: [] }); commitChangeSetMock.mockResolvedValue({ changeSet: { id: "11", status: "committed" }, committedItems: [{ id: "21", status: "committed" }], skippedItems: [], failedItems: [], }); editItemMock.mockResolvedValue({ changeSet: {}, items: [], evidence: [], approvals: [] }); deferItemMock.mockResolvedValue({ changeSet: {}, items: [], evidence: [], approvals: [] }); createExecutionHandoffMock.mockResolvedValue({ projectId: "123", execution: { taskId: "task-99", status: "pushed", prompt: "真实 prompt package", commitLog: ["执行交接已创建"], prStatus: "pending", testSummary: "pending", writebackStatus: "pushed", }, releaseGate: releaseGate(), devSync: { active_task_count: 1, recent_event_count: 1 }, promptPackage: { id: "501", project_id: "123", charter_id: "301", task_id: "task-99", status: "active", package_kind: "execution_handoff", package_text: "真实 prompt package", package_payload: {}, created_at: "2026-04-26T00:00:00", }, repoBindings: [ { id: "601", project_id: "123", provider: "github", owner: "pmflow", repo_name: "app", full_name: "pmflow/app", default_branch: "main", status: "active", }, ], activeDevTasks: [], recentProgressEvents: [ { id: "701", event_type: "handoff_created", source: "pmflow_os", summary: "执行交接已创建", workflow_state: "pushed", derived_task_status: "pushed", }, ], recentDeliveries: [], warnings: [], }); consumeExecutionHandoffMock.mockResolvedValue({ projectId: "123", execution: { taskId: "task-99", status: "coding", prompt: "真实 prompt package", commitLog: ["执行交接已领取"], prStatus: "pending", testSummary: "pending", writebackStatus: "coding", }, releaseGate: releaseGate(), devSync: { active_task_count: 1, recent_event_count: 2 }, promptPackage: { id: "501", project_id: "123", charter_id: "301", task_id: "task-99", status: "active", package_kind: "execution_handoff", package_text: "真实 prompt package", package_payload: { latest_handoff: { handoff_state: "consumed" } }, created_at: "2026-04-26T00:00:00", }, repoBindings: [], activeDevTasks: [], recentProgressEvents: [ { id: "702", event_type: "handoff_consumed", source: "pmflow_os", summary: "执行交接已领取", workflow_state: "coding", derived_task_status: "coding", }, ], recentDeliveries: [], warnings: [], }); createExecutionProgressEventMock.mockResolvedValue({ projectId: "123", execution: { taskId: "task-99", status: "pr_open", prompt: "真实 prompt package", commitLog: ["执行结果回写"], prStatus: "PR #88 · linked", testSummary: "pending", writebackStatus: "in_review", }, releaseGate: releaseGate(), devSync: { active_task_count: 1, recent_event_count: 3 }, promptPackage: { id: "501", project_id: "123", charter_id: "301", task_id: "task-99", status: "active", package_kind: "execution_handoff", package_text: "真实 prompt package", package_payload: {}, created_at: "2026-04-26T00:00:00", }, repoBindings: [], activeDevTasks: [], recentProgressEvents: [ { id: "703", event_type: "pr_opened", source: "github_agent", summary: "执行结果回写", workflow_state: "in_review", derived_task_status: "review", pr_number: 88, }, ], recentDeliveries: [], warnings: [], }); createReleaseValidationRunMock.mockResolvedValue({ releaseValidationRun: { id: "901", projectId: "123", status: "needs_confirmation", score: 0.6, keyMetrics: { evidenceCount: 1 }, riskItems: [], rollbackPlan: { deploymentExecuted: false }, validationSummary: { source: "pmflow_os_release_gate", releaseGateStatus: "needs_confirmation", deploymentExecuted: false, }, createdBy: "1", createdAt: "2026-04-26T00:00:00", }, executionSummary: { projectId: "123", execution: { taskId: "task-99", status: "testing", prompt: "真实 prompt package", commitLog: ["发布验证记录已创建"], prStatus: "PR #88 · linked", testSummary: "真实测试回写", writebackStatus: "testing", }, releaseGate: { ...releaseGate(), latestValidationRun: { id: "901", projectId: "123", status: "needs_confirmation", score: 0.6, keyMetrics: { evidenceCount: 1 }, riskItems: [], rollbackPlan: { deploymentExecuted: false }, validationSummary: { source: "pmflow_os_release_gate", releaseGateStatus: "needs_confirmation", deploymentExecuted: false, }, createdBy: "1", createdAt: "2026-04-26T00:00:00", }, }, devSync: { active_task_count: 1 }, promptPackage: null, repoBindings: [], activeDevTasks: [], recentProgressEvents: [], recentDeliveries: [], warnings: [], }, warnings: [], }); }); afterEach(() => { vi.clearAllMocks(); }); it("calls real message and review APIs in api mode and does not locally promote to project memory", async () => { const { result } = renderHook(() => usePMFlowOsStore(), { wrapper: wrapper() }); await waitFor(() => expect(result.current.dataSourceStatus).toBe("success")); await waitFor(() => expect(result.current.executionSummaryStatus).toBe("success")); expect(getExecutionSummaryMock).toHaveBeenCalledWith("123"); expect(result.current.executionSummary?.promptPackage?.package_text).toBe("真实 prompt package"); expect(result.current.executionSummary?.releaseGate.status).toBe("needs_confirmation"); act(() => result.current.sendMessage("继续补充")); await waitFor(() => expect(createThreadMessageMock).toHaveBeenCalledWith("123", "7", expect.objectContaining({ content: "继续补充" }))); act(() => result.current.acceptChange("21")); await waitFor(() => expect(acceptItemMock).toHaveBeenCalledWith("21")); act(() => result.current.editChange("21", "人工修订")); await waitFor(() => expect(editItemMock).toHaveBeenCalledWith("21", expect.objectContaining({ diff: "人工修订" }))); act(() => result.current.deferChange("21")); await waitFor(() => expect(deferItemMock).toHaveBeenCalledWith("21", "用户暂缓")); act(() => result.current.promoteAcceptedToGraph("11")); await waitFor(() => expect(commitChangeSetMock).toHaveBeenCalledWith( "11", expect.objectContaining({ mode: "atomic", note: "用户确认写入项目记忆" }) ) ); act(() => result.current.createExecutionHandoff({ task_id: "task-99", repo_binding_id: "601", runtime_target: "local_bridge", note: "测试执行交接", }) ); await waitFor(() => expect(createExecutionHandoffMock).toHaveBeenCalledWith( "123", expect.objectContaining({ task_id: "task-99", repo_binding_id: "601", runtime_target: "local_bridge" }) ) ); act(() => result.current.consumeExecutionHandoff("501")); await waitFor(() => expect(consumeExecutionHandoffMock).toHaveBeenCalledWith( "123", "501", expect.objectContaining({ runtime_target: "codex", consumer_label: "Codex" }) ) ); act(() => result.current.recordExecutionProgress({ task_id: "task-99", repo_binding_id: "601", event_type: "pr_opened", source: "github_agent", branch_name: "codex/main-flow-d3", pr_number: 88, }) ); await waitFor(() => expect(createExecutionProgressEventMock).toHaveBeenCalledWith( "123", expect.objectContaining({ task_id: "task-99", event_type: "pr_opened", source: "github_agent", pr_number: 88 }) ) ); act(() => result.current.createReleaseValidationRun({ decision: "enter_release_candidate", note: "人工确认进入发布候选", }) ); await waitFor(() => expect(createReleaseValidationRunMock).toHaveBeenCalledWith( "123", expect.objectContaining({ decision: "enter_release_candidate", note: "人工确认进入发布候选" }) ) ); expect(result.current.state.requirements).toEqual([]); }); it("keeps api failures explicit instead of mutating local success state", async () => { acceptItemMock.mockRejectedValueOnce(new Error("item_already_reviewed")); const { result } = renderHook(() => usePMFlowOsStore(), { wrapper: wrapper() }); await waitFor(() => expect(result.current.dataSourceStatus).toBe("success")); act(() => result.current.acceptChange("21")); await waitFor(() => expect(result.current.dataSourceError).toBe("item_already_reviewed")); expect(result.current.state.changeItems[0]?.status).toBe("pending_review"); }); it("still uses local demo mutation when demo mode is explicit", () => { window.history.pushState({}, "", "/pmflow-os?demo=1"); const { result } = renderHook(() => usePMFlowOsStore(), { wrapper: wrapper() }); const pending = result.current.state.changeItems.find((item) => item.status === "pending_review"); act(() => result.current.acceptChange(pending!.id)); expect(acceptItemMock).not.toHaveBeenCalled(); expect(createExecutionHandoffMock).not.toHaveBeenCalled(); expect(consumeExecutionHandoffMock).not.toHaveBeenCalled(); expect(createExecutionProgressEventMock).not.toHaveBeenCalled(); expect(createReleaseValidationRunMock).not.toHaveBeenCalled(); expect(getExecutionSummaryMock).not.toHaveBeenCalled(); expect(result.current.state.changeItems.find((item) => item.id === pending!.id)?.status).toBe("accepted"); }); });