511 lines
17 KiB
TypeScript
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");
|
|
});
|
|
});
|