diff --git a/canvas/src/store/__tests__/canvas-events.test.ts b/canvas/src/store/__tests__/canvas-events.test.ts index f6e0924d4..5d60003c8 100644 --- a/canvas/src/store/__tests__/canvas-events.test.ts +++ b/canvas/src/store/__tests__/canvas-events.test.ts @@ -332,6 +332,105 @@ describe("handleCanvasEvent – WORKSPACE_PROVISIONING", () => { const bPos = lastNodes.find((n) => n.id === "ws-b")!.position; expect(bPos).toEqual({ x: 420, y: 100 }); // idx 1 = (100 + 320, 100) }); + + it("uses finalX/finalY from payload when parentId is set and parent exists in store", () => { + // Org-import child lands with explicit coords — these are server-computed + // parent-relative positions. The handler must trust them verbatim. + const parent = makeNode("parent-root", { name: "Root" }); + const { get, set } = makeStore([parent]); + + handleCanvasEvent( + makeMsg({ + event: "WORKSPACE_PROVISIONING", + workspace_id: "child-org", + payload: { + name: "Org Child", + tier: 2, + parent_id: "parent-root", + x: 500, + y: 300, + }, + }), + get, + set + ); + + const newNodes = (set.mock.calls[0][0] as { nodes: Node[] }).nodes; + expect(newNodes).toHaveLength(2); + const child = newNodes.find((n) => n.id === "child-org")!; + + // Must use the server-provided coords, not grid + expect(child.position).toEqual({ x: 500, y: 300 }); + // Must bind parentId so RF renders it nested inside the parent card + expect(child.parentId).toBe("parent-root"); + expect(child.data.parentId).toBe("parent-root"); + expect(child.data.name).toBe("Org Child"); + expect(child.data.status).toBe("provisioning"); + }); + + it("uses grid position when parentId is set but parent is NOT in store yet", () => { + // Rare WS-reorder: child event arrives before parent's PROVISIONING event. + // Must not crash — uses grid slot as fallback. Parent will reparent + // the child when it lands. + const { get, set } = makeStore([]); + + handleCanvasEvent( + makeMsg({ + event: "WORKSPACE_PROVISIONING", + workspace_id: "orphan-child", + payload: { + name: "Orphan", + parent_id: "unknown-parent", + x: 999, + y: 888, + }, + }), + get, + set + ); + + const newNodes = (set.mock.calls[0][0] as { nodes: Node[] }).nodes; + const child = newNodes.find((n) => n.id === "orphan-child")!; + + // Must NOT use finalX/finalY — parent isn't in store so grid slot is used + expect(child.position).not.toEqual({ x: 999, y: 888 }); + // Grid slot for idx 0: (100, 100) + expect(child.position).toEqual({ x: 100, y: 100 }); + // parentId is NOT set on the node when parent is unknown: + // the node will be reparented when the parent eventually lands + expect(child.data.parentId).not.toBe("unknown-parent"); + }); + + it("no-op cascade: parent in store but no finalX/Y → grid position, no parentId", () => { + // Parent exists but payload has no x/y → must not crash, uses grid slot. + // parentId is NOT set because we don't have parent-relative coords. + const parent = makeNode("parent-exists"); + const { get, set } = makeStore([parent]); + + handleCanvasEvent( + makeMsg({ + event: "WORKSPACE_PROVISIONING", + workspace_id: "child-no-coords", + payload: { + name: "No Coords", + parent_id: "parent-exists", + // no x or y + }, + }), + get, + set + ); + + const newNodes = (set.mock.calls[0][0] as { nodes: Node[] }).nodes; + const child = newNodes.find((n) => n.id === "child-no-coords")!; + + // Grid slot for idx 0: (100, 100) + expect(child.position).toEqual({ x: 100, y: 100 }); + // parentId stays null (not undefined) when no finalX/Y — server has no + // position for this node, and the handler initialises parentId=null + expect(child.parentId).toBeUndefined(); + expect(child.data.parentId).toBeNull(); + }); }); // --------------------------------------------------------------------------- diff --git a/canvas/src/store/__tests__/canvas.test.ts b/canvas/src/store/__tests__/canvas.test.ts index e3410b147..02ea22884 100644 --- a/canvas/src/store/__tests__/canvas.test.ts +++ b/canvas/src/store/__tests__/canvas.test.ts @@ -848,6 +848,374 @@ describe("hydrationError", () => { }); }); +// ---------- growParentsToFitChildren ---------- +// +// growParentsToFitChildren walks every parent node and expands its width/height +// so all children fit inside with padding. Collapsed parents are skipped (grow- +// only, never shrink). Returns the same array reference when no changes are +// needed, a new array when at least one parent grew. +// +// Constants (from canvas-topology.ts): +// CHILD_DEFAULT_WIDTH = 240 +// CHILD_DEFAULT_HEIGHT = 130 +// PARENT_SIDE_PADDING = 16 +// PARENT_BOTTOM_PADDING = 16 +// +// For a child at (childX, childY) with size (childW, childH): +// requiredParentW = childX + childW + PARENT_SIDE_PADDING +// requiredParentH = childY + childH + PARENT_BOTTOM_PADDING +// +// Coverage targets: +// - Node with no parentId → skipped entirely (returns same node) +// - Parent with no children → skipped (kids.length === 0 → returns n) +// - Collapsed parent → skipped even when children overflow +// - Child fits within existing parent → no-op (requiredW <= currentW && requiredH <= currentH) +// - Child overflows parent width → grows width only +// - Child overflows parent height → grows height only +// - Child overflows both → grows both +// - Missing measured.width (falls back to width, then CHILD_DEFAULT_WIDTH) +// - Missing measured.height (falls back to height, then CHILD_DEFAULT_HEIGHT) +// - Missing parent measured.width (falls back to width, then 0) +// - Missing parent measured.height (falls back to height, then 0) +// - No change at all → returns same array reference (changed=false path) + +describe("growParentsToFitChildren", () => { + it("skips nodes with no parentId (standalone roots)", () => { + useCanvasStore.setState({ + nodes: [ + { + id: "root", + type: "workspaceNode", + position: { x: 0, y: 0 }, + data: { name: "Root", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: null, currentTask: "", needsRestart: false, runtime: "", budgetLimit: null }, + measured: { width: 200, height: 150 }, + }, + ], + }); + + const before = useCanvasStore.getState().nodes; + useCanvasStore.getState().growParentsToFitChildren(); + const after = useCanvasStore.getState().nodes; + + // Same array reference (no change needed) + expect(after).toBe(before); + }); + + it("skips parent with no children (orphan parentId)", () => { + useCanvasStore.setState({ + nodes: [ + { + id: "orphan", + type: "workspaceNode", + position: { x: 0, y: 0 }, + parentId: "nonexistent", + data: { name: "Orphan", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: null, currentTask: "", needsRestart: false, runtime: "", budgetLimit: null }, + measured: { width: 100, height: 100 }, + }, + ], + }); + + const before = useCanvasStore.getState().nodes; + useCanvasStore.getState().growParentsToFitChildren(); + const after = useCanvasStore.getState().nodes; + + // Same array reference (parentId exists but no children reference it) + expect(after).toBe(before); + expect(after[0].measured).toEqual({ width: 100, height: 100 }); + }); + + it("skips collapsed parents even when children overflow", () => { + // Child at (500, 400) → requires parent 500+240+16=756w, 400+130+16=546h + // Parent is collapsed AND tiny — must NOT grow + useCanvasStore.setState({ + nodes: [ + { + id: "parent", + type: "workspaceNode", + position: { x: 0, y: 0 }, + data: { name: "Parent", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: true, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: null, currentTask: "", needsRestart: false, runtime: "", budgetLimit: null }, + measured: { width: 200, height: 150 }, + }, + { + id: "child", + type: "workspaceNode", + position: { x: 500, y: 400 }, + parentId: "parent", + data: { name: "Child", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: "parent", currentTask: "", needsRestart: false, runtime: "", budgetLimit: null }, + measured: { width: 240, height: 130 }, + }, + ], + }); + + const before = useCanvasStore.getState().nodes; + useCanvasStore.getState().growParentsToFitChildren(); + const after = useCanvasStore.getState().nodes; + + // Same reference (collapsed → skipped entirely) + expect(after).toBe(before); + const parent = after.find((n) => n.id === "parent")!; + expect(parent.measured).toEqual({ width: 200, height: 150 }); + }); + + it("no-op when child fits within existing parent size", () => { + // Child at (0,0) 240x130 → requires 0+240+16=256w, 0+130+16=146h + // Parent is exactly 256×146 → fits perfectly + useCanvasStore.setState({ + nodes: [ + { + id: "parent", + type: "workspaceNode", + position: { x: 0, y: 0 }, + data: { name: "Parent", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: null, currentTask: "", needsRestart: false, runtime: "", budgetLimit: null }, + measured: { width: 256, height: 146 }, + }, + { + id: "child", + type: "workspaceNode", + position: { x: 0, y: 0 }, + parentId: "parent", + data: { name: "Child", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: "parent", currentTask: "", needsRestart: false, runtime: "", budgetLimit: null }, + measured: { width: 240, height: 130 }, + }, + ], + }); + + const before = useCanvasStore.getState().nodes; + useCanvasStore.getState().growParentsToFitChildren(); + const after = useCanvasStore.getState().nodes; + + // Same array reference (no change needed) + expect(after).toBe(before); + const parent = after.find((n) => n.id === "parent")!; + expect(parent.measured).toEqual({ width: 256, height: 146 }); + }); + + it("grows parent width only when child overflows width but not height", () => { + // Child at (100, 0) 240x130 → requires 100+240+16=356w, 0+130+16=146h + // Parent is 256×146 → fits height, overflows width → grows to 356×146 + useCanvasStore.setState({ + nodes: [ + { + id: "parent", + type: "workspaceNode", + position: { x: 0, y: 0 }, + data: { name: "Parent", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: null, currentTask: "", needsRestart: false, runtime: "", budgetLimit: null }, + measured: { width: 256, height: 146 }, + }, + { + id: "child", + type: "workspaceNode", + position: { x: 100, y: 0 }, + parentId: "parent", + data: { name: "Child", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: "parent", currentTask: "", needsRestart: false, runtime: "", budgetLimit: null }, + measured: { width: 240, height: 130 }, + }, + ], + }); + + useCanvasStore.getState().growParentsToFitChildren(); + const parent = useCanvasStore.getState().nodes.find((n) => n.id === "parent")!; + + expect(parent.width).toBe(356); // 100+240+16 + expect(parent.height).toBe(146); // unchanged + }); + + it("grows parent height only when child overflows height but not width", () => { + // Child at (0, 50) 240x130 → requires 0+240+16=256w, 50+130+16=196h + // Parent is 256×146 → fits width, overflows height → grows to 256×196 + useCanvasStore.setState({ + nodes: [ + { + id: "parent", + type: "workspaceNode", + position: { x: 0, y: 0 }, + data: { name: "Parent", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: null, currentTask: "", needsRestart: false, runtime: "", budgetLimit: null }, + measured: { width: 256, height: 146 }, + }, + { + id: "child", + type: "workspaceNode", + position: { x: 0, y: 50 }, + parentId: "parent", + data: { name: "Child", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: "parent", currentTask: "", needsRestart: false, runtime: "", budgetLimit: null }, + measured: { width: 240, height: 130 }, + }, + ], + }); + + useCanvasStore.getState().growParentsToFitChildren(); + const parent = useCanvasStore.getState().nodes.find((n) => n.id === "parent")!; + + expect(parent.width).toBe(256); // unchanged + expect(parent.height).toBe(196); // 50+130+16 + }); + + it("grows parent in both dimensions when child overflows both", () => { + // Child at (200, 100) 240x130 → requires 200+240+16=456w, 100+130+16=246h + // Parent is 256×146 → grows to 456×246 + useCanvasStore.setState({ + nodes: [ + { + id: "parent", + type: "workspaceNode", + position: { x: 0, y: 0 }, + data: { name: "Parent", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: null, currentTask: "", needsRestart: false, runtime: "", budgetLimit: null }, + measured: { width: 256, height: 146 }, + }, + { + id: "child", + type: "workspaceNode", + position: { x: 200, y: 100 }, + parentId: "parent", + data: { name: "Child", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: "parent", currentTask: "", needsRestart: false, runtime: "", budgetLimit: null }, + measured: { width: 240, height: 130 }, + }, + ], + }); + + useCanvasStore.getState().growParentsToFitChildren(); + const parent = useCanvasStore.getState().nodes.find((n) => n.id === "parent")!; + + expect(parent.width).toBe(456); // 200+240+16 + expect(parent.height).toBe(246); // 100+130+16 + }); + + it("uses CHILD_DEFAULT_WIDTH/HEIGHT when child has no measured or explicit dimensions", () => { + // Child with NO measured, NO width/height → falls back to 240×130 defaults + // Child at (500, 200) → requires 500+240+16=756w, 200+130+16=346h + useCanvasStore.setState({ + nodes: [ + { + id: "parent", + type: "workspaceNode", + position: { x: 0, y: 0 }, + data: { name: "Parent", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: null, currentTask: "", needsRestart: false, runtime: "", budgetLimit: null }, + measured: { width: 100, height: 100 }, + }, + { + id: "child", + type: "workspaceNode", + position: { x: 500, y: 200 }, + parentId: "parent", + // No measured, no width/height → uses CHILD_DEFAULT_WIDTH=240, CHILD_DEFAULT_HEIGHT=130 + data: { name: "Child", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: "parent", currentTask: "", needsRestart: false, runtime: "", budgetLimit: null }, + }, + ], + }); + + useCanvasStore.getState().growParentsToFitChildren(); + const parent = useCanvasStore.getState().nodes.find((n) => n.id === "parent")!; + + expect(parent.width).toBe(756); // 500+240+16 + expect(parent.height).toBe(346); // 200+130+16 + }); + + it("uses explicit width/height when measured is absent on child", () => { + // Child has width/height but NOT measured + // Child at (300, 50) with explicit 200×100 → requires 300+200+16=516w, 50+100+16=166h + useCanvasStore.setState({ + nodes: [ + { + id: "parent", + type: "workspaceNode", + position: { x: 0, y: 0 }, + data: { name: "Parent", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: null, currentTask: "", needsRestart: false, runtime: "", budgetLimit: null }, + measured: { width: 200, height: 100 }, + }, + { + id: "child", + type: "workspaceNode", + position: { x: 300, y: 50 }, + parentId: "parent", + width: 200, + height: 100, + // No measured → falls back to width=200, height=100 + data: { name: "Child", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: "parent", currentTask: "", needsRestart: false, runtime: "", budgetLimit: null }, + }, + ], + }); + + useCanvasStore.getState().growParentsToFitChildren(); + const parent = useCanvasStore.getState().nodes.find((n) => n.id === "parent")!; + + expect(parent.width).toBe(516); // 300+200+16 + expect(parent.height).toBe(166); // 50+100+16 + }); + + it("uses measured when present (takes precedence over explicit width/height)", () => { + // Child has both measured AND explicit width/height — measured should win + // Child at (0,0) measured=240×130 explicit=100×50 → uses measured + // Required: 0+240+16=256w, 0+130+16=146h + useCanvasStore.setState({ + nodes: [ + { + id: "parent", + type: "workspaceNode", + position: { x: 0, y: 0 }, + data: { name: "Parent", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: null, currentTask: "", needsRestart: false, runtime: "", budgetLimit: null }, + measured: { width: 256, height: 146 }, // fits exactly + }, + { + id: "child", + type: "workspaceNode", + position: { x: 0, y: 0 }, + parentId: "parent", + width: 100, // ignored (measured present) + height: 50, // ignored + measured: { width: 240, height: 130 }, + data: { name: "Child", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: "parent", currentTask: "", needsRestart: false, runtime: "", budgetLimit: null }, + }, + ], + }); + + const before = useCanvasStore.getState().nodes; + useCanvasStore.getState().growParentsToFitChildren(); + const after = useCanvasStore.getState().nodes; + + // Same reference (measured fits exactly) + expect(after).toBe(before); + }); + + it("multiple children: grows to fit the furthest child in each dimension", () => { + // Child 1 at (0, 0) 240×130 → maxRight=240, maxBottom=130 + // Child 2 at (300, 200) 240×130 → maxRight=540, maxBottom=330 + // Required: 540+16=556w, 330+16=346h + useCanvasStore.setState({ + nodes: [ + { + id: "parent", + type: "workspaceNode", + position: { x: 0, y: 0 }, + data: { name: "Parent", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: null, currentTask: "", needsRestart: false, runtime: "", budgetLimit: null }, + measured: { width: 100, height: 100 }, + }, + { + id: "child1", + type: "workspaceNode", + position: { x: 0, y: 0 }, + parentId: "parent", + data: { name: "Child1", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: "parent", currentTask: "", needsRestart: false, runtime: "", budgetLimit: null }, + measured: { width: 240, height: 130 }, + }, + { + id: "child2", + type: "workspaceNode", + position: { x: 300, y: 200 }, + parentId: "parent", + data: { name: "Child2", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: "parent", currentTask: "", needsRestart: false, runtime: "", budgetLimit: null }, + measured: { width: 240, height: 130 }, + }, + ], + }); + + useCanvasStore.getState().growParentsToFitChildren(); + const parent = useCanvasStore.getState().nodes.find((n) => n.id === "parent")!; + + expect(parent.width).toBe(556); // max(0+240, 300+240)+16 + expect(parent.height).toBe(346); // max(0+130, 200+130)+16 + }); +}); + // ---------- ACTIVITY_LOGGED event ---------- describe("ACTIVITY_LOGGED event", () => {