import { fireEvent, render, screen, within } from "@testing-library/react";
import { ReactFlowProvider } from "@xyflow/react";
import { describe, expect, it, vi } from "vitest";
import { DirectToolGroupCard } from "@/components/flow/DirectToolGroupCard";
import { RetrievalAgentCard } from "@/components/flow/RetrievalAgentCard";
import { buildHorizontalLayout, retrievalAgentItem, retrievalDirectItem } from "@/lib/layout";
import { makeBranch, makeRetrievalStage, makeView } from "@/tests/fixtures";
const base = { onOpen: vi.fn(), onToggleRound: vi.fn(), onToggleBranch: vi.fn(), onToggleRetrievalAgent: vi.fn() };
const nodeProps = { selected: false, zIndex: 1, isConnectable: false, positionAbsoluteX: 0, positionAbsoluteY: 0, dragging: false, draggable: true, selectable: true, deletable: false };
const wrap = (node: React.ReactNode) => render({node});
describe("V8 retrieval stage", () => {
it("shows an immediate Agent retrieval preview on keyboard focus", () => {
const branch = makeBranch(1, 1);
const item = retrievalAgentItem(branch.retrievalStage.agentRuns[0], 1, 1);
const { container } = wrap();
fireEvent.focus(container.querySelector(".retrievalAgentCard")!);
const preview = screen.getByRole("dialog", { name: /取数预览/ });
expect(within(preview).getByText("命中了什么")).toBeInTheDocument();
expect(within(preview).getByText("初筛得到")).toBeInTheDocument();
});
it("loads and presents real tool results only after the preview opens", async () => {
const branch = makeBranch(1, 1);
const item = retrievalDirectItem(branch.retrievalStage.directToolGroups[0], 1, 1);
if (item.itemType !== "retrieval-direct") throw new Error("expected retrieval-direct item");
item.group.calls = [{ id: "event:4047", eventId: 4047, businessLabel: "领域信息", status: "success", resultSummary: "已读取", detailRef: "event:4047" }];
item.group.callCount = 1;
item.group.successCount = 1;
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ detailKind: "retrieval-event", outcome: { state: "hit", count: 1, message: "读取成功,共返回 1 条记录。" }, items: [{ id: "fact-1", title: "领域事实 1", subtitle: "核实了布里丹寓言的史实背景。", meta: [], sections: [] }], technical: {} }),
});
vi.stubGlobal("fetch", fetchMock);
const { container } = wrap();
expect(fetchMock).not.toHaveBeenCalled();
fireEvent.focus(container.querySelector(".directToolCard")!);
const preview = screen.getByRole("dialog", { name: /取数预览/ });
expect(await within(preview).findByText("领域事实 1")).toBeInTheDocument();
expect(fetchMock).toHaveBeenCalledTimes(1);
vi.unstubAllGlobals();
});
it("presents direct tools and delegated agents as peer retrieval operations", () => {
const branch = makeBranch(1, 1);
const direct = retrievalDirectItem(branch.retrievalStage.directToolGroups[0], 1, 1);
const agent = retrievalAgentItem(branch.retrievalStage.agentRuns[0], 1, 1);
wrap(<>>);
expect(screen.getByText("工具取数")).toBeInTheDocument();
expect(screen.getByText("Agent 取数")).toBeInTheDocument();
expect(screen.getByText("初筛整理")).toBeInTheDocument();
expect(screen.queryByText("数据取舍")).not.toBeInTheDocument();
});
it("keeps repeated attempts inside one agent and opens an individual query", () => {
const branch = makeBranch(1, 1);
const run = { ...branch.retrievalStage.agentRuns[0], attempts: branch.retrievalStage.agentRuns[0].attempts.concat(Array.from({ length: 10 }, (_, index) => ({ ...branch.retrievalStage.agentRuns[0].attempts[0], id: `event:extra-${index}`, eventId: 900 + index, queryLabel: `额外查询 ${index + 1}`, detailRef: `event:${900 + index}` }))) };
run.querySummary = { ...run.querySummary, total: 13 };
const item = retrievalAgentItem(run, 1, 1);
const onOpen = vi.fn();
wrap();
expect(screen.getByText((_, element) => element?.tagName === "P" && element.textContent?.startsWith("13 次") === true)).toBeInTheDocument();
expect(screen.getAllByRole("listitem")).toHaveLength(13);
fireEvent.click(screen.getByText("额外查询 1"));
expect(onOpen).toHaveBeenCalledWith(expect.objectContaining({ itemType: "query-attempt", detailRef: "event:900" }), "business");
});
it("stacks both parallel and sequential retrieval operations vertically", () => {
const parallelView = makeView(1, 1, 1);
parallelView.rounds[0].branches[0].retrievalStage = makeRetrievalStage(1, 1, 2, 1, "parallel");
const options = { expandedRounds: new Set([1]), expandedBranches: new Set(["1:1"]), expandedRetrievalAgents: new Set(), ...base };
const parallel = buildHorizontalLayout(parallelView, options).nodes.filter((node) => node.type === "directToolGroup" || node.type === "retrievalAgent");
expect(new Set(parallel.map((node) => node.position.x)).size).toBe(1);
expect(new Set(parallel.map((node) => node.position.y)).size).toBe(parallel.length);
const sequentialView = makeView(1, 1, 1);
const sequential = buildHorizontalLayout(sequentialView, options).nodes.filter((node) => node.type === "directToolGroup" || node.type === "retrievalAgent");
expect(new Set(sequential.map((node) => node.position.x)).size).toBe(1);
expect(new Set(sequential.map((node) => node.position.y)).size).toBe(sequential.length);
});
it("keeps unknown-time operations in one neutral column inside the retrieval frame", () => {
const view = makeView(1, 1, 1);
const stage = view.rounds[0].branches[0].retrievalStage;
stage.observedMode = "unknown";
stage.waves = [];
stage.directToolGroups.forEach((item) => { item.waveIndex = undefined; item.startedAt = undefined; item.endedAt = undefined; });
stage.agentRuns.forEach((item) => { item.waveIndex = undefined; item.startedAt = undefined; item.endedAt = undefined; });
const options = { expandedRounds: new Set([1]), expandedBranches: new Set(["1:1"]), expandedRetrievalAgents: new Set(), ...base };
const layout = buildHorizontalLayout(view, options);
const operations = layout.nodes.filter((node) => node.type === "directToolGroup" || node.type === "retrievalAgent");
const frame = layout.nodes.find((node) => node.type === "retrievalStageFrame");
expect(new Set(operations.map((node) => node.position.x)).size).toBe(1);
expect(Math.max(...operations.map((node) => node.position.x + Number(node.style?.width || 340)))).toBeLessThanOrEqual(Number(frame?.style?.width || 0));
});
it("marks expanded query lists so semantic zoom cannot hide a successful expansion", () => {
const branch = makeBranch(1, 1);
const item = retrievalAgentItem(branch.retrievalStage.agentRuns[0], 1, 1);
const { container } = wrap();
expect(container.querySelector(".retrievalAgentCard")).toHaveClass("isQueryExpanded");
expect(container.querySelector(".queryAttemptList")).toBeInTheDocument();
});
it("grows the branch frame when query rows are expanded", () => {
const view = makeView(1, 1, 1);
const agentId = view.rounds[0].branches[0].retrievalStage.agentRuns[0].id;
const baseOptions = { expandedRounds: new Set([1]), expandedBranches: new Set(["1:1"]), ...base };
const collapsed = buildHorizontalLayout(view, { ...baseOptions, expandedRetrievalAgents: new Set() });
const expanded = buildHorizontalLayout(view, { ...baseOptions, expandedRetrievalAgents: new Set([agentId]) });
const height = (nodes: typeof collapsed.nodes) => Number(nodes.find((node) => node.type === "branchFrame")?.style?.height || 0);
expect(height(expanded.nodes)).toBeGreaterThan(height(collapsed.nodes));
});
});