import { describe, expect, it, vi } from "vitest"; import { buildHorizontalLayout } from "@/lib/layout"; import { makeStory, makeView } from "@/tests/fixtures"; const callbacks = { onOpen: vi.fn(), onToggleRound: vi.fn(), onToggleBranch: vi.fn(), onToggleRetrievalAgent: vi.fn(), expandedRetrievalAgents: new Set() }; describe("V8 divergence, review, and convergence layout", () => { it("places the real planning analysis before the persisted creative objective", () => { const view = makeView(1, 1, 1); view.planning = makeStory("run:planning-analysis", "planning-analysis", "execution", { title: "创作规划解析", detailRef: "event:3760", }); const result = buildHorizontalLayout(view, { expandedRounds: new Set(), expandedBranches: new Set(), ...callbacks }); const planning = result.nodes.find((node) => node.id === "run:planning-analysis")!; const objective = result.nodes.find((node) => node.id === view.objective.id)!; expect(planning.position.x).toBeLessThan(objective.position.x); expect(result.edges.some((edge) => edge.source === planning.id && edge.target === objective.id)).toBe(true); }); it("places collapsed rounds in vertically separated rows", () => { const view = makeView(10, 2, 1); const result = buildHorizontalLayout(view, { expandedRounds: new Set(), expandedBranches: new Set(), ...callbacks }); const rounds = view.rounds.map((round) => result.nodes.find((node) => node.id === `round:${round.roundIndex}:summary`)!); expect(new Set(rounds.map((node) => node.position.x)).size).toBe(1); expect(rounds.map((node) => node.position.y)).toEqual([...rounds.map((node) => node.position.y)].sort((a, b) => a - b)); rounds.slice(1).forEach((node, index) => expect(node.position.y).toBeGreaterThanOrEqual(rounds[index].position.y + 400 + 104)); }); it("places expanded rounds on separate rows without adding their widths together", () => { const view = makeView(4, 2, 1); const expandedRounds = new Set(view.rounds.map((round) => round.roundIndex)); const result = buildHorizontalLayout(view, { expandedRounds, expandedBranches: new Set(), ...callbacks }); const rounds = view.rounds.map((round) => result.nodes.find((node) => node.id === `frame:round:${round.roundIndex}`)!); expect(new Set(rounds.map((node) => node.position.x)).size).toBe(1); rounds.slice(1).forEach((node, index) => { const previous = rounds[index]; expect(node.position.y).toBeGreaterThanOrEqual(previous.position.y + Number(previous.style?.height) + 104); }); }); it("fans branches out and converges each branch into its recorded decision batch", () => { const view = makeView(1, 6, 2); const result = buildHorizontalLayout(view, { expandedRounds: new Set([1]), expandedBranches: new Set(), ...callbacks }); const candidates = result.nodes.filter((node) => node.type === "candidateCard"); expect(new Set(candidates.map((node) => node.position.y)).size).toBe(6); for (const batch of view.rounds[0].convergenceBatches) { expect(result.nodes.find((node) => node.id === batch.review?.id)?.type).toBe("agentDecision"); expect(result.nodes.find((node) => node.id === batch.decision?.id)?.type).toBe("agentDecision"); } for (const batch of view.rounds[0].convergenceBatches) { for (const branchId of batch.branchIds) expect(result.edges.some((edge) => edge.source === `round:1:branch:${branchId}:summary` && edge.target === batch.review?.id)).toBe(true); expect(result.edges.some((edge) => edge.source === batch.review?.id && edge.target === batch.decision?.id)).toBe(true); } }); it("uses backend roles and ids rather than Chinese titles", () => { const view = makeView(1, 1, 1); view.rounds[0].goal.title = "completely renamed"; view.rounds[0].convergenceBatches[0].decision!.node.title = "also renamed"; const result = buildHorizontalLayout(view, { expandedRounds: new Set([1]), expandedBranches: new Set(), ...callbacks }); expect(result.nodes.some((node) => node.id === "r1-goal")).toBe(true); expect(result.nodes.some((node) => node.id === "multipath-1-1")).toBe(true); }); it("keeps 10 expanded implementation lanes separated", () => { const view = makeView(1, 10, 2); const expandedBranches = new Set(view.rounds[0].branches.map((branch) => `1:${branch.branchId}`)); const result = buildHorizontalLayout(view, { expandedRounds: new Set([1]), expandedBranches, ...callbacks }); const frames = result.nodes.filter((node) => node.type === "branchFrame"); const boxes = frames.map((node) => ({ top: node.position.y, bottom: node.position.y + Number(node.style?.height || 0) })).sort((a, b) => a.top - b.top); expect(boxes).toHaveLength(10); boxes.slice(1).forEach((box, index) => expect(box.top).toBeGreaterThan(boxes[index].bottom)); }); it("renders a missing decision stage without inventing a batch", () => { const view = makeView(1, 2, 0); const result = buildHorizontalLayout(view, { expandedRounds: new Set([1]), expandedBranches: new Set(), ...callbacks }); expect(view.rounds[0].convergenceBatches[0].decision).toBeUndefined(); expect(result.nodes.some((node) => node.id === "r1-missing-review")).toBe(true); expect(result.nodes.some((node) => node.id === "r1-missing-decision")).toBe(true); }); it("tightens expanded-round spacing as semantic zoom hides card details", () => { const view = makeView(1, 3, 1); view.rounds[0].branches[0].summary.task = "很长的候选任务说明。".repeat(40); const heightAt = (detailLevel: "full" | "compact" | "overview") => { const result = buildHorizontalLayout(view, { expandedRounds: new Set([1]), expandedBranches: new Set(), detailLevel, ...callbacks }); return Number(result.nodes.find((node) => node.id === "frame:round:1")?.style?.height || 0); }; expect(heightAt("overview")).toBeLessThan(heightAt("compact")); expect(heightAt("compact")).toBeLessThan(heightAt("full")); }); it("keeps compact branch cards separated without hiding agent regions", () => { const view = makeView(1, 10, 2); const result = buildHorizontalLayout(view, { expandedRounds: new Set([1]), expandedBranches: new Set(), detailLevel: "compact", ...callbacks }); const candidates = result.nodes.filter((node) => node.type === "candidateCard").sort((a, b) => a.position.y - b.position.y); expect(candidates).toHaveLength(10); expect(result.nodes.filter((node) => node.type === "agentRegion")).toHaveLength(6); candidates.slice(1).forEach((node, index) => { const gap = node.position.y - candidates[index].position.y - 232; expect(gap).toBeGreaterThanOrEqual(18); expect(gap).toBeLessThanOrEqual(24); }); }); it("keeps implementation and retrieval agents visible in overview mode", () => { const view = makeView(1, 1, 1); const result = buildHorizontalLayout(view, { expandedRounds: new Set([1]), expandedBranches: new Set(["1:1"]), detailLevel: "overview", ...callbacks, }); expect(result.nodes.some((node) => node.type === "roundFrame")).toBe(true); expect(result.nodes.some((node) => node.type === "branchFrame")).toBe(true); expect(result.nodes.some((node) => node.type === "retrievalAgent")).toBe(true); expect(result.nodes.filter((node) => node.type === "agentRegion")).toHaveLength(6); expect(result.nodes.some((node) => node.type === "roundSummary")).toBe(false); }); it("centers cards by their own height instead of sharing one top coordinate", () => { const view = makeView(1, 1, 1); view.rounds[0].goal.card.primary!.value = "这是一个较长的本轮目标,用来验证高卡片依然围绕同一条中线排列。".repeat(8); const result = buildHorizontalLayout(view, { expandedRounds: new Set([1]), expandedBranches: new Set(), detailLevel: "full", ...callbacks }); const goal = result.nodes.find((node) => node.id === "r1-goal")!; const output = result.nodes.find((node) => node.id === "r1-result")!; expect(goal.position.y).toBeLessThan(output.position.y); }); it("stacks retrieval operations vertically even when they belong to different waves", () => { const view = makeView(1, 1, 1); const branch = view.rounds[0].branches[0]; const result = buildHorizontalLayout(view, { expandedRounds: new Set([1]), expandedBranches: new Set(["1:1"]), ...callbacks }); const operationIds = [...branch.retrievalStage.directToolGroups, ...branch.retrievalStage.agentRuns].map((operation) => operation.id); const operations = operationIds.map((id) => result.nodes.find((node) => node.id === id)!); const stage = result.nodes.find((node) => node.id === `frame:${branch.retrievalStage.id}`)!; expect(new Set(operations.map((node) => node.position.x))).toEqual(new Set([20])); expect(operations.map((node) => node.position.y)).toEqual([...operations.map((node) => node.position.y)].sort((a, b) => a - b)); expect(Number(stage.style?.width)).toBe(380); }); it("grows expanded retrieval frames upward as query details make the stack taller", () => { const view = makeView(1, 1, 1); const branch = view.rounds[0].branches[0]; const baseOptions = { expandedRounds: new Set([1]), expandedBranches: new Set(["1:1"]), ...callbacks }; const collapsed = buildHorizontalLayout(view, baseOptions); const expanded = buildHorizontalLayout(view, { ...baseOptions, expandedRetrievalAgents: new Set(branch.retrievalStage.agentRuns.map((run) => run.id)) }); const collapsedRound = collapsed.nodes.find((node) => node.id === "frame:round:1")!; const expandedRound = expanded.nodes.find((node) => node.id === "frame:round:1")!; expect(Number(expandedRound.style?.height)).toBeGreaterThan(Number(collapsedRound.style?.height)); expect(expandedRound.position.y).toBeLessThan(collapsedRound.position.y); }); });