PMFlow/frontend/src/features/pmflow-os/store.api.test.tsx

511 lines
17 KiB
TypeScript

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 (
<QueryClientProvider client={queryClient}>
<PMFlowOsProvider>{children}</PMFlowOsProvider>
</QueryClientProvider>
);
};
}
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");
});
});