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

1090 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, cleanup, fireEvent, render, renderHook, screen, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { MemoryRouter, useLocation } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getAccountSettings } from "@/api/accountSettings";
import {
acceptPMFlowChangeItem,
commitPMFlowChangeSet,
consumePMFlowExecutionHandoff,
createPMFlowExecutionHandoff,
createPMFlowExecutionProgressEvent,
createPMFlowIterationRecommendationGroupChangeSetDraft,
createPMFlowIterationRecommendationChangeSetDraft,
createPMFlowReleaseValidationRun,
createPMFlowReleaseFeedbackIngest,
createPMFlowReviewDecision,
createPMFlowThreadMessage,
getPMFlowExecutionCapabilities,
deferPMFlowChangeItem,
editPMFlowChangeItem,
getPMFlowExecutionSummary,
getPMFlowTruthState,
type PMFlowExecutionSummary,
type PMFlowReleaseGate,
} from "@/api/pmflowOs";
import { createInitialPMFlowState } from "@/features/pmflow-os/mockData";
import TeamLayerPage from "@/features/pmflow-os/pages/TeamLayerPage";
import { PMFlowOsProvider, usePMFlowOsStore } from "@/features/pmflow-os/store";
import type { PMFlowReviewerRouting, ProjectTruthState } from "@/features/pmflow-os/types";
vi.mock("@/lib/utils", () => ({
cn: (...inputs: Array<string | false | null | undefined>) => inputs.filter(Boolean).join(" "),
}));
vi.mock("@/api/accountSettings", () => ({
getAccountSettings: vi.fn(),
shouldShowAccountNotification: vi.fn(
(settings: { notifications?: Record<string, { enabled?: boolean }> } | null | undefined, key: string) =>
settings?.notifications?.[key]?.enabled ?? true
),
}));
vi.mock("@/api/pmflowOs", () => ({
getPMFlowTruthState: vi.fn(),
getPMFlowExecutionSummary: vi.fn(),
getPMFlowExecutionCapabilities: 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(),
createPMFlowIterationRecommendationGroupChangeSetDraft: vi.fn(),
createPMFlowIterationRecommendationChangeSetDraft: vi.fn(),
createPMFlowReleaseValidationRun: vi.fn(),
createPMFlowReleaseFeedbackIngest: vi.fn(),
createPMFlowReviewDecision: vi.fn(),
editPMFlowChangeItem: vi.fn(),
deferPMFlowChangeItem: vi.fn(),
}));
const getTruthStateMock = vi.mocked(getPMFlowTruthState);
const getExecutionSummaryMock = vi.mocked(getPMFlowExecutionSummary);
const getExecutionCapabilitiesMock = vi.mocked(getPMFlowExecutionCapabilities);
const getAccountSettingsMock = vi.mocked(getAccountSettings);
const createThreadMessageMock = vi.mocked(createPMFlowThreadMessage);
const createExecutionHandoffMock = vi.mocked(createPMFlowExecutionHandoff);
const consumeExecutionHandoffMock = vi.mocked(consumePMFlowExecutionHandoff);
const createExecutionProgressEventMock = vi.mocked(createPMFlowExecutionProgressEvent);
const createIterationRecommendationGroupChangeSetDraftMock = vi.mocked(createPMFlowIterationRecommendationGroupChangeSetDraft);
const createIterationRecommendationChangeSetDraftMock = vi.mocked(createPMFlowIterationRecommendationChangeSetDraft);
const createReleaseValidationRunMock = vi.mocked(createPMFlowReleaseValidationRun);
const createReleaseFeedbackIngestMock = vi.mocked(createPMFlowReleaseFeedbackIngest);
const createReviewDecisionMock = vi.mocked(createPMFlowReviewDecision);
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: "项目线程",
},
];
state.feed = [
{
id: "review-request-11",
kind: "review",
title: "已请求 ChangeSet 审查",
summary: "真实变更集 已请求 审查负责人 审查;包含 high risk 变更。",
timestamp: "2026-04-26T10:00:00",
},
{
id: "release-gate-blocked",
kind: "release",
title: "发布门禁已阻塞",
summary: "执行回写中存在失败或阻塞事件,发布门禁已关闭。",
timestamp: "2026-04-26T11:00:00",
},
];
state.reviewerRouting = reviewerRoutingFixture();
return state;
}
function reviewerRoutingFixture(): PMFlowReviewerRouting {
return {
status: "ready",
warningCode: null,
summary: {
totalPendingReview: 1,
highRiskCount: 1,
missingReviewerCount: 0,
iterationDraftCount: 1,
},
items: [
{
projectId: "123",
changeSetId: "11",
changeSetTitle: "真实变更集",
changeSetStatus: "open",
sourceType: "iteration_recommendation",
reviewReason: "包含 high risk 变更,需要 REVIEWER 或 OWNER 复核。",
riskLevel: "high",
priority: "urgent",
changeTypes: ["Risk", "Task"],
itemCount: 2,
pendingItemCount: 1,
evidenceCount: 1,
reviewDecision: null,
approvalState: "not_requested",
suggestedReviewer: {
userId: "9",
displayName: "审查负责人",
role: "REVIEWER",
reason: "REVIEWER 角色,优先复核 high risk 变更。",
confidence: "high",
},
fallbackReviewerRole: null,
targetUrl: "/change-review?projectId=123&changeSetId=11",
createdAt: "2026-04-26T00:00:00",
updatedAt: "2026-04-26T00:00:00",
},
],
};
}
function LocationProbe() {
const location = useLocation();
return <div data-testid="location">{`${location.pathname}${location.search}`}</div>;
}
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: status === "blocked" ? "test_failed" : "test_passed",
summary: status === "blocked" ? "测试失败" : "真实测试回写",
prNumber: 88,
},
],
updatedAt: "2026-04-26T00:00:00",
};
}
function accountSettingsPreference({ releaseGate = true, failureAlert = true }: { releaseGate?: boolean; failureAlert?: boolean } = {}) {
return {
profile: { displayName: "Owner", roleTitle: "产品负责人", email: "", defaultLanguage: "zh-CN" },
aiPreferences: { answerDepth: "balanced", evidenceDisplay: "inline" },
notifications: {
pendingConfirmation: { enabled: true, desktop: true, emailDigest: false },
executionWriteback: { enabled: true, desktop: true, emailDigest: false },
releaseGate: { enabled: releaseGate, desktop: false, emailDigest: false },
failureAlert: { enabled: failureAlert, desktop: false, emailDigest: false },
},
saveScope: "local",
updatedAt: "2026-04-26T00:00:00",
} as const;
}
function executionSummaryWithGate(gate: PMFlowReleaseGate): PMFlowExecutionSummary {
const executionStatus: PMFlowExecutionSummary["execution"]["status"] = gate.status === "blocked" ? "failed" : "testing";
return {
projectId: "123",
execution: {
taskId: "task-99",
status: executionStatus,
prompt: "真实 prompt package",
commitLog: ["真实进度事件"],
prStatus: "PR #88 · linked",
testSummary: gate.status === "blocked" ? "测试失败" : "真实测试回写",
writebackStatus: gate.status === "blocked" ? "failed" : "testing",
},
releaseGate: gate,
devSync: { active_task_count: 1 },
promptPackage: null,
repoBindings: [],
activeDevTasks: [],
recentProgressEvents: [],
recentDeliveries: [],
warnings: [],
};
}
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");
getAccountSettingsMock.mockResolvedValue(accountSettingsPreference());
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: [],
});
getExecutionCapabilitiesMock.mockResolvedValue({
projectId: "123",
executionModes: [
{
mode: "ai_with_user_acceptance",
label: "AI 执行 + 用户验收",
status: "partial",
reason: "等待 repo 与执行证据补齐。",
targetHref: "/execution-bridge?projectId=123",
},
],
personalReadiness: {
status: "partial",
toolReadiness: [
{
key: "repo",
label: "Repo 连接",
status: "blocked",
summary: "未接入 repo,AI 执行包缺少真实代码落点。",
href: "/execution-bridge?projectId=123",
},
],
waitingForUser: {
pendingConfirmations: 1,
pendingReviews: 1,
waitingAcceptance: 0,
},
blockers: ["未接入 repo,AI 执行包缺少真实代码落点。"],
recommendedNextStep: "先处理分配给你的审查项,再继续推进 AI 执行。",
targetHref: "/change-review?projectId=123",
},
teamSignals: [
{
userId: "9",
displayName: "审查负责人",
role: "REVIEWER",
judgementResponsibilities: ["技术/产品审查"],
reviewResponsibilities: ["实现风险"],
capacity: {
status: "ready",
label: "可承接",
workloadRatio: 32,
pendingReviewCount: 1,
activeExecutionCount: 0,
},
context: {
status: "partial",
label: "有项目证据或能力参考",
evidenceCount: 1,
recentContributionCount: 1,
},
toolStatus: {
status: "unknown",
label: "未接入",
connectedTools: [],
},
referenceSkills: ["审查责任"],
recommendedModes: ["ai_with_technical_review"],
warnings: ["未发现活跃 IDE / Agent 工具连接。"],
},
],
warnings: ["repo_missing: 未接入 repo,AI 执行包无法落到真实代码上下文。"],
});
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: [],
});
createReviewDecisionMock.mockResolvedValue({
changeSetDetail: {
changeSet: { id: "11", status: "reviewed" },
items: [{ id: "21", status: "accepted" }],
evidence: [],
approvals: [{ id: "91", action: "approve", decision: "approved", changeSetId: "11", changeItemId: "21" }],
},
truthState: realState(),
warnings: [],
});
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("renders reviewer routing on team layer without assigning reviewers", async () => {
render(
<MemoryRouter>
<TeamLayerPage />
<LocationProbe />
</MemoryRouter>,
{ wrapper: wrapper() }
);
expect(await screen.findByText("待审 ChangeSet 审查路由")).toBeInTheDocument();
expect(await screen.findByText("执行能力与容量信号")).toBeInTheDocument();
expect(await screen.findByText("真实变更集")).toBeInTheDocument();
expect(screen.getAllByText("审查负责人").length).toBeGreaterThan(0);
expect(screen.getAllByText("REVIEWER").length).toBeGreaterThan(0);
expect(screen.getByText("下一轮迭代")).toBeInTheDocument();
expect(screen.getByText("证据 1")).toBeInTheDocument();
expect(screen.getByText("已请求 ChangeSet 审查")).toBeInTheDocument();
expect(screen.getByText("发布门禁已阻塞")).toBeInTheDocument();
expect(screen.getAllByText("待我审").length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole("button", { name: /去审查/ }));
await waitFor(() => expect(screen.getByTestId("location").textContent).toBe("/change-review?projectId=123&changeSetId=11"));
expect(acceptItemMock).not.toHaveBeenCalled();
expect(commitChangeSetMock).not.toHaveBeenCalled();
});
it("records reviewer decisions through the PMFlow OS api in real mode", async () => {
const { result } = renderHook(() => usePMFlowOsStore(), { wrapper: wrapper() });
await waitFor(() => expect(result.current.dataSourceStatus).toBe("success"));
act(() =>
result.current.createReviewDecision("11", {
action: "approve",
reviewer_id: 9,
reason: "reviewer 通过,不自动 commit。",
item_ids: ["21"],
})
);
await waitFor(() =>
expect(createReviewDecisionMock).toHaveBeenCalledWith(
"11",
expect.objectContaining({
action: "approve",
reviewer_id: 9,
item_ids: ["21"],
})
)
);
expect(commitChangeSetMock).not.toHaveBeenCalled();
});
it("keeps reviewer routing empty when payload omits it", async () => {
const state = realState();
delete state.reviewerRouting;
getTruthStateMock.mockResolvedValueOnce(state);
const { result } = renderHook(() => usePMFlowOsStore(), { wrapper: wrapper() });
await waitFor(() => expect(result.current.dataSourceStatus).toBe("success"));
expect(result.current.state.reviewerRouting).toBeUndefined();
});
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("shows release gate and failure reminders when account notification preferences are enabled", async () => {
getAccountSettingsMock.mockResolvedValue(accountSettingsPreference({ releaseGate: true, failureAlert: true }));
getExecutionSummaryMock.mockResolvedValue(executionSummaryWithGate(releaseGate("blocked")));
const { default: ReleaseGrowthPage } = await import("@/features/pmflow-os/pages/ReleaseGrowthPage");
render(<ReleaseGrowthPage />, { wrapper: wrapper() });
expect(await screen.findByText("发布门禁提醒")).toBeTruthy();
expect(await screen.findByText("失败提醒")).toBeTruthy();
expect(screen.getByText("执行证据")).toBeTruthy();
expect(screen.getAllByText("测试失败").length).toBeGreaterThan(0);
});
it("keeps release evidence visible while silencing release and failure reminders when preferences are disabled", async () => {
getAccountSettingsMock.mockResolvedValue(accountSettingsPreference({ releaseGate: false, failureAlert: false }));
getExecutionSummaryMock.mockResolvedValue(executionSummaryWithGate(releaseGate("blocked")));
const { default: ReleaseGrowthPage } = await import("@/features/pmflow-os/pages/ReleaseGrowthPage");
render(<ReleaseGrowthPage />, { wrapper: wrapper() });
expect((await screen.findAllByText("执行证据")).length).toBeGreaterThan(0);
expect(screen.queryByText("发布门禁提醒")).toBeNull();
expect(screen.queryByText("失败提醒")).toBeNull();
expect((await screen.findAllByText("测试失败")).length).toBeGreaterThan(0);
});
it("shows real release feedback signals and keeps empty state explicit", async () => {
getExecutionSummaryMock.mockResolvedValue({
...executionSummaryWithGate(releaseGate("ready")),
releaseSignals: {
status: "active",
relationship: {
releaseGateStatus: "ready",
latestValidationRunId: "900",
latestValidationRunStatus: "ready",
source: "pmflow_os_release_growth",
changeSetCreated: false,
},
summary: { feedbackCount: 1, revenueEventCount: 0, iterationRecommendationCount: 1, latestSignalAt: "2026-04-26T00:00:00" },
feedback: [
{
id: "feedback-1",
projectId: "123",
sourceType: "manual",
title: "首批试用反馈",
content: "用户反馈上传入口难找,希望下一轮优化 onboarding。",
metrics: { negative_feedback_count: 3 },
samples: [],
status: "active",
createdAt: "2026-04-26T00:00:00",
updatedAt: "2026-04-26T00:00:00",
},
],
revenueEvents: [],
recommendations: [
{
id: "rec-1",
projectId: "123",
feedbackIngestId: "feedback-1",
status: "draft",
priority: "high",
recommendationSummary: { summary: "优先优化 onboarding 上传入口。" },
proposedCharterPatch: {},
proposedTasks: [],
changeSetCreated: false,
createdAt: "2026-04-26T00:00:00",
updatedAt: "2026-04-26T00:00:00",
},
],
nextActions: ["基于真实反馈查看 iteration recommendation,但本阶段不自动创建 ChangeSet。"],
warnings: [],
},
});
const { default: ReleaseGrowthPage } = await import("@/features/pmflow-os/pages/ReleaseGrowthPage");
render(<ReleaseGrowthPage />, { wrapper: wrapper() });
expect(await screen.findByText("发布后反馈信号")).toBeTruthy();
expect(await screen.findByText("首批试用反馈")).toBeTruthy();
expect(screen.getByText("优先优化 onboarding 上传入口。")).toBeTruthy();
expect(screen.getByText("生成下一轮待确认变更")).toBeTruthy();
expect(screen.queryByText(/页面不会用 mock 成功数据/)).toBeNull();
});
it("creates a reviewable ChangeSet draft from a release recommendation", async () => {
getExecutionSummaryMock.mockResolvedValue({
...executionSummaryWithGate(releaseGate("ready")),
releaseSignals: {
status: "active",
relationship: {
releaseGateStatus: "ready",
latestValidationRunId: "900",
latestValidationRunStatus: "ready",
source: "pmflow_os_release_growth",
changeSetCreated: false,
},
summary: { feedbackCount: 1, revenueEventCount: 0, iterationRecommendationCount: 1, latestSignalAt: "2026-04-26T00:00:00" },
feedback: [],
revenueEvents: [],
recommendations: [
{
id: "rec-1",
projectId: "123",
feedbackIngestId: "feedback-1",
status: "draft",
priority: "high",
recommendationSummary: { summary: "优先优化 onboarding 上传入口。" },
proposedCharterPatch: {},
proposedTasks: [],
changeSetCreated: false,
createdAt: "2026-04-26T00:00:00",
updatedAt: "2026-04-26T00:00:00",
},
],
nextActions: ["基于真实反馈选择 iteration recommendation,生成下一轮待确认 ChangeSet draft。"],
warnings: [],
},
});
createIterationRecommendationChangeSetDraftMock.mockResolvedValue({
changeSetDetail: {
changeSet: { id: "77", status: "open" },
items: [{ id: "78", objectType: "Requirement", status: "pending_review" }],
evidence: [{ id: "79", sourceType: "iteration_recommendation" }],
approvals: [],
},
executionSummary: executionSummaryWithGate(releaseGate("ready")),
warnings: [],
});
const { default: ReleaseGrowthPage } = await import("@/features/pmflow-os/pages/ReleaseGrowthPage");
render(<ReleaseGrowthPage />, { wrapper: wrapper() });
const action = await screen.findByText("生成下一轮待确认变更");
fireEvent.click(action);
await waitFor(() =>
expect(createIterationRecommendationChangeSetDraftMock).toHaveBeenCalledWith(
"123",
"rec-1",
expect.objectContaining({ note: expect.stringContaining("ReleaseGrowthPage") })
)
);
});
it("creates a grouped ChangeSet draft from recommendation quality groups", async () => {
getExecutionSummaryMock.mockResolvedValue({
...executionSummaryWithGate(releaseGate("ready")),
releaseSignals: {
status: "active",
relationship: {
releaseGateStatus: "ready",
latestValidationRunId: "900",
latestValidationRunStatus: "ready",
source: "pmflow_os_release_growth",
changeSetCreated: false,
},
summary: { feedbackCount: 2, revenueEventCount: 0, iterationRecommendationCount: 2, latestSignalAt: "2026-04-26T00:00:00" },
feedback: [],
revenueEvents: [],
recommendations: [
{
id: "rec-1",
projectId: "123",
feedbackIngestId: "feedback-1",
status: "draft",
priority: "high",
recommendationSummary: { summary: "补齐失败样本。" },
proposedCharterPatch: {},
proposedTasks: [],
changeSetCreated: false,
groupKey: "quality",
mergedCount: 2,
impactLevel: "high",
riskLevel: "high",
suggestedChangeTypes: ["Requirement", "Decision", "Task", "Risk"],
reviewPriority: "p0",
},
{
id: "rec-2",
projectId: "123",
feedbackIngestId: "feedback-2",
status: "draft",
priority: "high",
recommendationSummary: { summary: "修复幻觉回答。" },
proposedCharterPatch: {},
proposedTasks: [],
changeSetCreated: false,
groupKey: "quality",
duplicateOf: "rec-1",
mergedCount: 2,
impactLevel: "high",
riskLevel: "high",
suggestedChangeTypes: ["Requirement", "Decision", "Task", "Risk"],
reviewPriority: "p0",
},
],
recommendationGroups: [
{
groupKey: "quality",
title: "quality 反馈组",
recommendationIds: ["rec-1", "rec-2"],
mergedCount: 2,
impactLevel: "high",
riskLevel: "high",
suggestedChangeTypes: ["Requirement", "Decision", "Task", "Risk"],
reviewPriority: "p0",
changeSetCreated: false,
rationale: "聚焦 quality;负向反馈 4 条;建议任务 2 个。",
},
],
nextActions: ["同组 recommendation 可批量生成下一轮待确认 ChangeSet draft。"],
warnings: [],
},
});
createIterationRecommendationGroupChangeSetDraftMock.mockResolvedValue({
changeSetDetail: {
changeSet: { id: "88", status: "open" },
items: [{ id: "89", objectType: "Requirement", status: "pending_review" }],
evidence: [{ id: "90", sourceType: "iteration_recommendation_group", metadata: { groupKey: "quality" } }],
approvals: [],
},
executionSummary: executionSummaryWithGate(releaseGate("ready")),
warnings: [],
});
const { default: ReleaseGrowthPage } = await import("@/features/pmflow-os/pages/ReleaseGrowthPage");
render(<ReleaseGrowthPage />, { wrapper: wrapper() });
expect(await screen.findByText("quality 反馈组")).toBeTruthy();
expect(screen.getByText("聚焦 quality;负向反馈 4 条;建议任务 2 个。")).toBeTruthy();
const action = await screen.findByText("批量生成这一组待确认变更");
fireEvent.click(action);
await waitFor(() =>
expect(createIterationRecommendationGroupChangeSetDraftMock).toHaveBeenCalledWith(
"123",
"quality",
expect.objectContaining({
recommendation_ids: ["rec-1", "rec-2"],
note: expect.stringContaining("quality"),
})
)
);
});
it("writes release feedback through the PMFlow OS api in real mode", async () => {
createReleaseFeedbackIngestMock.mockResolvedValue({
ingest: {
id: "feedback-1",
projectId: "123",
sourceType: "manual",
title: "Release/Growth 人工反馈",
content: "用户反馈上传入口难找",
metrics: {},
samples: [],
status: "active",
},
recommendation: {
id: "rec-1",
projectId: "123",
feedbackIngestId: "feedback-1",
status: "draft",
priority: "high",
recommendationSummary: {},
proposedCharterPatch: {},
proposedTasks: [],
changeSetCreated: false,
},
releaseSignals: {
status: "active",
relationship: { releaseGateStatus: "ready", changeSetCreated: false },
summary: { feedbackCount: 1, revenueEventCount: 0, iterationRecommendationCount: 1 },
feedback: [],
revenueEvents: [],
recommendations: [],
nextActions: [],
warnings: [],
},
executionSummary: executionSummaryWithGate(releaseGate("ready")),
warnings: [],
});
const { result } = renderHook(() => usePMFlowOsStore(), { wrapper: wrapper() });
await waitFor(() => expect(result.current.dataSourceStatus).toBe("success"));
act(() =>
result.current.createReleaseFeedbackIngest({
source_type: "manual",
title: "Release/Growth 人工反馈",
content: "用户反馈上传入口难找",
})
);
await waitFor(() =>
expect(createReleaseFeedbackIngestMock).toHaveBeenCalledWith(
"123",
expect.objectContaining({ source_type: "manual", content: "用户反馈上传入口难找" })
)
);
});
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(createReleaseFeedbackIngestMock).not.toHaveBeenCalled();
expect(createIterationRecommendationGroupChangeSetDraftMock).not.toHaveBeenCalled();
expect(createIterationRecommendationChangeSetDraftMock).not.toHaveBeenCalled();
expect(getExecutionSummaryMock).not.toHaveBeenCalled();
expect(result.current.state.changeItems.find((item) => item.id === pending!.id)?.status).toBe("accepted");
});
});