layout-v8.test.ts 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. import { describe, expect, it, vi } from "vitest";
  2. import { buildHorizontalLayout } from "@/lib/layout";
  3. import { makeStory, makeView } from "@/tests/fixtures";
  4. const callbacks = { onOpen: vi.fn(), onToggleRound: vi.fn(), onToggleBranch: vi.fn(), onToggleRetrievalAgent: vi.fn(), expandedRetrievalAgents: new Set<string>() };
  5. describe("V8 divergence, review, and convergence layout", () => {
  6. it("places the real planning analysis before the persisted creative objective", () => {
  7. const view = makeView(1, 1, 1);
  8. view.planning = makeStory("run:planning-analysis", "planning-analysis", "execution", {
  9. title: "创作规划解析",
  10. detailRef: "event:3760",
  11. });
  12. const result = buildHorizontalLayout(view, { expandedRounds: new Set(), expandedBranches: new Set(), ...callbacks });
  13. const planning = result.nodes.find((node) => node.id === "run:planning-analysis")!;
  14. const objective = result.nodes.find((node) => node.id === view.objective.id)!;
  15. expect(planning.position.x).toBeLessThan(objective.position.x);
  16. expect(result.edges.some((edge) => edge.source === planning.id && edge.target === objective.id)).toBe(true);
  17. });
  18. it("places collapsed rounds in vertically separated rows", () => {
  19. const view = makeView(10, 2, 1);
  20. const result = buildHorizontalLayout(view, { expandedRounds: new Set(), expandedBranches: new Set(), ...callbacks });
  21. const rounds = view.rounds.map((round) => result.nodes.find((node) => node.id === `round:${round.roundIndex}:summary`)!);
  22. expect(new Set(rounds.map((node) => node.position.x)).size).toBe(1);
  23. expect(rounds.map((node) => node.position.y)).toEqual([...rounds.map((node) => node.position.y)].sort((a, b) => a - b));
  24. rounds.slice(1).forEach((node, index) => expect(node.position.y).toBeGreaterThanOrEqual(rounds[index].position.y + 400 + 104));
  25. });
  26. it("places expanded rounds on separate rows without adding their widths together", () => {
  27. const view = makeView(4, 2, 1);
  28. const expandedRounds = new Set(view.rounds.map((round) => round.roundIndex));
  29. const result = buildHorizontalLayout(view, { expandedRounds, expandedBranches: new Set(), ...callbacks });
  30. const rounds = view.rounds.map((round) => result.nodes.find((node) => node.id === `frame:round:${round.roundIndex}`)!);
  31. expect(new Set(rounds.map((node) => node.position.x)).size).toBe(1);
  32. rounds.slice(1).forEach((node, index) => {
  33. const previous = rounds[index];
  34. expect(node.position.y).toBeGreaterThanOrEqual(previous.position.y + Number(previous.style?.height) + 104);
  35. });
  36. });
  37. it("fans branches out and converges each branch into its recorded decision batch", () => {
  38. const view = makeView(1, 6, 2);
  39. const result = buildHorizontalLayout(view, { expandedRounds: new Set([1]), expandedBranches: new Set(), ...callbacks });
  40. const candidates = result.nodes.filter((node) => node.type === "candidateCard");
  41. expect(new Set(candidates.map((node) => node.position.y)).size).toBe(6);
  42. for (const batch of view.rounds[0].convergenceBatches) {
  43. expect(result.nodes.find((node) => node.id === batch.review?.id)?.type).toBe("agentDecision");
  44. expect(result.nodes.find((node) => node.id === batch.decision?.id)?.type).toBe("agentDecision");
  45. }
  46. for (const batch of view.rounds[0].convergenceBatches) {
  47. 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);
  48. expect(result.edges.some((edge) => edge.source === batch.review?.id && edge.target === batch.decision?.id)).toBe(true);
  49. }
  50. });
  51. it("uses backend roles and ids rather than Chinese titles", () => {
  52. const view = makeView(1, 1, 1);
  53. view.rounds[0].goal.title = "completely renamed";
  54. view.rounds[0].convergenceBatches[0].decision!.node.title = "also renamed";
  55. const result = buildHorizontalLayout(view, { expandedRounds: new Set([1]), expandedBranches: new Set(), ...callbacks });
  56. expect(result.nodes.some((node) => node.id === "r1-goal")).toBe(true);
  57. expect(result.nodes.some((node) => node.id === "multipath-1-1")).toBe(true);
  58. });
  59. it("keeps 10 expanded implementation lanes separated", () => {
  60. const view = makeView(1, 10, 2);
  61. const expandedBranches = new Set(view.rounds[0].branches.map((branch) => `1:${branch.branchId}`));
  62. const result = buildHorizontalLayout(view, { expandedRounds: new Set([1]), expandedBranches, ...callbacks });
  63. const frames = result.nodes.filter((node) => node.type === "branchFrame");
  64. 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);
  65. expect(boxes).toHaveLength(10);
  66. boxes.slice(1).forEach((box, index) => expect(box.top).toBeGreaterThan(boxes[index].bottom));
  67. });
  68. it("renders a missing decision stage without inventing a batch", () => {
  69. const view = makeView(1, 2, 0);
  70. const result = buildHorizontalLayout(view, { expandedRounds: new Set([1]), expandedBranches: new Set(), ...callbacks });
  71. expect(view.rounds[0].convergenceBatches[0].decision).toBeUndefined();
  72. expect(result.nodes.some((node) => node.id === "r1-missing-review")).toBe(true);
  73. expect(result.nodes.some((node) => node.id === "r1-missing-decision")).toBe(true);
  74. });
  75. it("tightens expanded-round spacing as semantic zoom hides card details", () => {
  76. const view = makeView(1, 3, 1);
  77. view.rounds[0].branches[0].summary.task = "很长的候选任务说明。".repeat(40);
  78. const heightAt = (detailLevel: "full" | "compact" | "overview") => {
  79. const result = buildHorizontalLayout(view, { expandedRounds: new Set([1]), expandedBranches: new Set(), detailLevel, ...callbacks });
  80. return Number(result.nodes.find((node) => node.id === "frame:round:1")?.style?.height || 0);
  81. };
  82. expect(heightAt("overview")).toBeLessThan(heightAt("compact"));
  83. expect(heightAt("compact")).toBeLessThan(heightAt("full"));
  84. });
  85. it("keeps compact branch cards separated without hiding agent regions", () => {
  86. const view = makeView(1, 10, 2);
  87. const result = buildHorizontalLayout(view, { expandedRounds: new Set([1]), expandedBranches: new Set(), detailLevel: "compact", ...callbacks });
  88. const candidates = result.nodes.filter((node) => node.type === "candidateCard").sort((a, b) => a.position.y - b.position.y);
  89. expect(candidates).toHaveLength(10);
  90. expect(result.nodes.filter((node) => node.type === "agentRegion")).toHaveLength(6);
  91. candidates.slice(1).forEach((node, index) => {
  92. const gap = node.position.y - candidates[index].position.y - 232;
  93. expect(gap).toBeGreaterThanOrEqual(18);
  94. expect(gap).toBeLessThanOrEqual(24);
  95. });
  96. });
  97. it("keeps implementation and retrieval agents visible in overview mode", () => {
  98. const view = makeView(1, 1, 1);
  99. const result = buildHorizontalLayout(view, {
  100. expandedRounds: new Set([1]),
  101. expandedBranches: new Set(["1:1"]),
  102. detailLevel: "overview",
  103. ...callbacks,
  104. });
  105. expect(result.nodes.some((node) => node.type === "roundFrame")).toBe(true);
  106. expect(result.nodes.some((node) => node.type === "branchFrame")).toBe(true);
  107. expect(result.nodes.some((node) => node.type === "retrievalAgent")).toBe(true);
  108. expect(result.nodes.filter((node) => node.type === "agentRegion")).toHaveLength(6);
  109. expect(result.nodes.some((node) => node.type === "roundSummary")).toBe(false);
  110. });
  111. it("centers cards by their own height instead of sharing one top coordinate", () => {
  112. const view = makeView(1, 1, 1);
  113. view.rounds[0].goal.card.primary!.value = "这是一个较长的本轮目标,用来验证高卡片依然围绕同一条中线排列。".repeat(8);
  114. const result = buildHorizontalLayout(view, { expandedRounds: new Set([1]), expandedBranches: new Set(), detailLevel: "full", ...callbacks });
  115. const goal = result.nodes.find((node) => node.id === "r1-goal")!;
  116. const output = result.nodes.find((node) => node.id === "r1-result")!;
  117. expect(goal.position.y).toBeLessThan(output.position.y);
  118. });
  119. it("stacks retrieval operations vertically even when they belong to different waves", () => {
  120. const view = makeView(1, 1, 1);
  121. const branch = view.rounds[0].branches[0];
  122. const result = buildHorizontalLayout(view, { expandedRounds: new Set([1]), expandedBranches: new Set(["1:1"]), ...callbacks });
  123. const operationIds = [...branch.retrievalStage.directToolGroups, ...branch.retrievalStage.agentRuns].map((operation) => operation.id);
  124. const operations = operationIds.map((id) => result.nodes.find((node) => node.id === id)!);
  125. const stage = result.nodes.find((node) => node.id === `frame:${branch.retrievalStage.id}`)!;
  126. expect(new Set(operations.map((node) => node.position.x))).toEqual(new Set([20]));
  127. expect(operations.map((node) => node.position.y)).toEqual([...operations.map((node) => node.position.y)].sort((a, b) => a - b));
  128. expect(Number(stage.style?.width)).toBe(380);
  129. });
  130. it("grows expanded retrieval frames upward as query details make the stack taller", () => {
  131. const view = makeView(1, 1, 1);
  132. const branch = view.rounds[0].branches[0];
  133. const baseOptions = { expandedRounds: new Set([1]), expandedBranches: new Set(["1:1"]), ...callbacks };
  134. const collapsed = buildHorizontalLayout(view, baseOptions);
  135. const expanded = buildHorizontalLayout(view, { ...baseOptions, expandedRetrievalAgents: new Set(branch.retrievalStage.agentRuns.map((run) => run.id)) });
  136. const collapsedRound = collapsed.nodes.find((node) => node.id === "frame:round:1")!;
  137. const expandedRound = expanded.nodes.find((node) => node.id === "frame:round:1")!;
  138. expect(Number(expandedRound.style?.height)).toBeGreaterThan(Number(collapsedRound.style?.height));
  139. expect(expandedRound.position.y).toBeLessThan(collapsedRound.position.y);
  140. });
  141. });