From 1e6f4ec01150e417c47905931afc9837328801e3 Mon Sep 17 00:00:00 2001 From: pbanddev <202373785+PBandDev@users.noreply.github.com> Date: Wed, 14 Jan 2026 17:03:46 -0600 Subject: [PATCH 1/2] fix: nested group position not updating when parent has token layout --- src/layout/selected-groups.ts | 167 +++++++++------- tests/fixtures/group-test.json | 227 ++++++++++++++++++++++ tests/integration/selected-groups.test.ts | 94 +++++++++ 3 files changed, 421 insertions(+), 67 deletions(-) create mode 100644 tests/fixtures/group-test.json diff --git a/src/layout/selected-groups.ts b/src/layout/selected-groups.ts index 16677f9..38b7fa8 100644 --- a/src/layout/selected-groups.ts +++ b/src/layout/selected-groups.ts @@ -14,6 +14,97 @@ import { parseLayoutToken, arrangeByMode } from "./title-tokens"; /** Title bar height for groups */ const GROUP_TITLE_HEIGHT = 50; +/** + * Update group bounds using multiple methods for compatibility + * Handles pos/size arrays, _pos/_size internal properties, + * _bounding Rectangle, and bounding array formats + */ +function updateGroupBounds( + group: LGraphGroup, + x: number, + y: number, + width: number, + height: number +): void { + // Method 1: Direct pos/size array mutation + if (Array.isArray(group.pos)) { + group.pos[0] = x; + group.pos[1] = y; + } + if (Array.isArray(group.size)) { + group.size[0] = width; + group.size[1] = height; + } + + // Method 2: Try _pos/_size internal properties + const g = group as LGraphGroup & { + _pos?: [number, number]; + _size?: [number, number]; + }; + if (Array.isArray(g._pos)) { + g._pos[0] = x; + g._pos[1] = y; + } + if (Array.isArray(g._size)) { + g._size[0] = width; + g._size[1] = height; + } + + // Method 3: Update _bounding Rectangle if present + if (group._bounding) { + const b = group._bounding; + b.x = x; + b.y = y; + b.width = width; + b.height = height; + } + + // Method 4: Try bounding array (older format / JSON serialization) + const gAny = group as LGraphGroup & { bounding?: number[] }; + if (Array.isArray(gAny.bounding)) { + gAny.bounding[0] = x; + gAny.bounding[1] = y; + gAny.bounding[2] = width; + gAny.bounding[3] = height; + } +} + +/** + * Translate group position by delta, handling all position formats + * Similar to updateGroupBounds but for relative position changes + */ +function translateGroupPosition( + group: LGraphGroup, + deltaX: number, + deltaY: number +): void { + // Method 1: Direct pos array mutation + if (Array.isArray(group.pos)) { + group.pos[0] += deltaX; + group.pos[1] += deltaY; + } + + // Method 2: Try _pos internal property + const g = group as LGraphGroup & { _pos?: [number, number] }; + if (Array.isArray(g._pos)) { + g._pos[0] += deltaX; + g._pos[1] += deltaY; + } + + // Method 3: Update _bounding Rectangle if present + if (group._bounding) { + group._bounding.x += deltaX; + group._bounding.y += deltaY; + } + + // Method 4: Try bounding array (older format / JSON serialization) + const gAny = group as LGraphGroup & { bounding?: number[] }; + if (Array.isArray(gAny.bounding)) { + gAny.bounding[0] += deltaX; + gAny.bounding[1] += deltaY; + } +} + /** * Resize a group to fit its member nodes */ @@ -43,11 +134,12 @@ function resizeGroupToFitMembers( if (minX === Infinity) return; - // Update group position and size with padding - group.pos[0] = minX - config.groupPadding; - group.pos[1] = minY - config.groupPadding - GROUP_TITLE_HEIGHT; - group.size[0] = maxX - minX + config.groupPadding * 2; - group.size[1] = maxY - minY + config.groupPadding * 2 + GROUP_TITLE_HEIGHT; + // Update group position and size with padding (handles all formats) + const newX = minX - config.groupPadding; + const newY = minY - config.groupPadding - GROUP_TITLE_HEIGHT; + const newW = maxX - minX + config.groupPadding * 2; + const newH = maxY - minY + config.groupPadding * 2 + GROUP_TITLE_HEIGHT; + updateGroupBounds(group, newX, newY, newW, newH); } /** @@ -61,11 +153,8 @@ function translateGroupWithMembers( allNodes: Map, allGroups: LGraphGroup[] ): void { - // Move the group itself - if (Array.isArray(group.pos)) { - group.pos[0] += deltaX; - group.pos[1] += deltaY; - } + // Move the group itself (handles all formats: pos, _pos, _bounding, bounding) + translateGroupPosition(group, deltaX, deltaY); // Translate tracked member nodes for (const nodeId of memberIds) { @@ -80,10 +169,7 @@ function translateGroupWithMembers( for (const nestedGroup of allGroups) { if (nestedGroup === group) continue; if (groupContainsGroup(group, nestedGroup)) { - if (Array.isArray(nestedGroup.pos)) { - nestedGroup.pos[0] += deltaX; - nestedGroup.pos[1] += deltaY; - } + translateGroupPosition(nestedGroup, deltaX, deltaY); } } } @@ -174,59 +260,6 @@ function collectDirectMembers( return members; } -/** - * Update group bounds using multiple methods for compatibility - */ -function updateGroupBounds( - group: LGraphGroup, - x: number, - y: number, - width: number, - height: number -): void { - // Method 1: Direct pos/size array mutation - if (Array.isArray(group.pos)) { - group.pos[0] = x; - group.pos[1] = y; - } - if (Array.isArray(group.size)) { - group.size[0] = width; - group.size[1] = height; - } - - // Method 2: Try _pos/_size internal properties - const g = group as LGraphGroup & { - _pos?: [number, number]; - _size?: [number, number]; - }; - if (Array.isArray(g._pos)) { - g._pos[0] = x; - g._pos[1] = y; - } - if (Array.isArray(g._size)) { - g._size[0] = width; - g._size[1] = height; - } - - // Method 3: Update _bounding Rectangle if present - if (group._bounding) { - const b = group._bounding; - b.x = x; - b.y = y; - b.width = width; - b.height = height; - } - - // Method 4: Try bounding array (older format) - const gAny = group as LGraphGroup & { bounding?: number[] }; - if (Array.isArray(gAny.bounding)) { - gAny.bounding[0] = x; - gAny.bounding[1] = y; - gAny.bounding[2] = width; - gAny.bounding[3] = height; - } -} - /** * Layout contents of a single group and resize it to fit * Returns the bounding box of the group's contents diff --git a/tests/fixtures/group-test.json b/tests/fixtures/group-test.json new file mode 100644 index 0000000..6e52cbd --- /dev/null +++ b/tests/fixtures/group-test.json @@ -0,0 +1,227 @@ +{ + "id": "bbe5e07d-ae25-42f3-b43f-63e9274165a1", + "revision": 0, + "last_node_id": 6, + "last_link_id": 4, + "nodes": [ + { + "id": 3, + "type": "PreviewImage", + "pos": [ + 530, + 560 + ], + "size": [ + 660, + 1000 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 3 + } + ], + "outputs": [], + "title": "Preview Image 1", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.8.2", + "Node name for S&R": "PreviewImage", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5.2" + } + }, + "widgets_values": [] + }, + { + "id": 4, + "type": "PreviewImage", + "pos": [ + 1300, + 560 + ], + "size": [ + 660, + 1000 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 4 + } + ], + "outputs": [], + "title": "Preview Image 2", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.8.2", + "Node name for S&R": "PreviewImage", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5.2" + } + }, + "widgets_values": [] + }, + { + "id": 5, + "type": "GetNode", + "pos": [ + 140, + 150 + ], + "size": [ + 210, + 60 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "*", + "type": "*", + "links": [ + 3 + ] + } + ], + "title": "GetNode 1", + "properties": { + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.5.2", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "" + ] + }, + { + "id": 6, + "type": "GetNode", + "pos": [ + 410, + 160 + ], + "size": [ + 210, + 58 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "*", + "type": "*", + "links": [ + 4 + ] + } + ], + "title": "GetNode 2", + "properties": { + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.5.2", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "" + ] + } + ], + "links": [ + [ + 3, + 5, + 0, + 3, + 0, + "IMAGE" + ], + [ + 4, + 6, + 0, + 4, + 0, + "IMAGE" + ] + ], + "groups": [ + { + "id": 1, + "title": "Previews [2ROW]", + "bounding": [ + 80, + -10, + 2030, + 1670 + ], + "color": "#3f789e", + "font_size": 22, + "flags": {} + }, + { + "id": 2, + "title": "GetNodes [HORIZONTAL]", + "bounding": [ + 110, + 70, + 580, + 170 + ], + "color": "#3f789e", + "font_size": 22, + "flags": {} + }, + { + "id": 3, + "title": "Preview Nodes [HORIZONTAL]", + "bounding": [ + 520, + 490, + 1450, + 1080.8 + ], + "color": "#3f789e", + "font_size": 22, + "flags": {} + } + ], + "config": {}, + "extra": { + "workflowRendererVersion": "LG", + "ue_links": [], + "ds": { + "scale": 0.47082251883518567, + "offset": [ + 951.4604865029822, + 455.47858311011794 + ] + }, + "frontendVersion": "1.37.9", + "VHS_latentpreview": false, + "VHS_latentpreviewrate": 0, + "VHS_MetadataImage": true, + "VHS_KeepIntermediate": true + }, + "version": 0.4 +} \ No newline at end of file diff --git a/tests/integration/selected-groups.test.ts b/tests/integration/selected-groups.test.ts index 05baf73..a235c1b 100644 --- a/tests/integration/selected-groups.test.ts +++ b/tests/integration/selected-groups.test.ts @@ -717,4 +717,98 @@ describe("layoutSelectedGroups", () => { assertFiniteCoordinates(graph); }); }); + + describe("nested group position update regression (group-test.json)", () => { + it("nested group position updates when parent has token layout", () => { + const graph = loadFixture("group-test.json"); + + // Group 1 "Previews [2ROW]" contains nested Group 3 "Preview Nodes [HORIZONTAL]" + // Nodes 3, 4 are inside Group 3 + layoutSelectedGroups(graph, new Set([1])); + + // Group 3 should be repositioned to contain its member nodes + const group3 = graph._groups.find((g) => g.id === 3)!; + const node3 = graph._nodes.find((n) => n.id === 3)!; + const node4 = graph._nodes.find((n) => n.id === 4)!; + + // Nodes must be inside group bounds (x >= group.x, y >= group.y + title_height) + const GROUP_TITLE_HEIGHT = 50; + expect(node3.pos[0]).toBeGreaterThanOrEqual(group3.pos[0]); + expect(node3.pos[1]).toBeGreaterThanOrEqual(group3.pos[1] + GROUP_TITLE_HEIGHT); + expect(node4.pos[0]).toBeGreaterThanOrEqual(group3.pos[0]); + expect(node4.pos[1]).toBeGreaterThanOrEqual(group3.pos[1] + GROUP_TITLE_HEIGHT); + + // Nodes must be inside group bounds (right/bottom edges) + expect(node3.pos[0] + node3.size[0]).toBeLessThanOrEqual(group3.pos[0] + group3.size[0]); + expect(node3.pos[1] + node3.size[1]).toBeLessThanOrEqual(group3.pos[1] + group3.size[1]); + expect(node4.pos[0] + node4.size[0]).toBeLessThanOrEqual(group3.pos[0] + group3.size[0]); + expect(node4.pos[1] + node4.size[1]).toBeLessThanOrEqual(group3.pos[1] + group3.size[1]); + + assertFiniteCoordinates(graph); + }); + + it("maintains node-group containment after [2ROW] token layout", () => { + const graph = loadFixture("group-test.json"); + layoutSelectedGroups(graph, new Set([1])); + assertNodesInsideGroups(graph); + assertFiniteCoordinates(graph); + }); + + it("updates bounding array when translating nested group", () => { + // Simulate production environment where groups have bounding array + const graph: LGraph = { + _nodes: [ + { + id: 1, + type: "Note", + title: "Note 1", + pos: [180, 180] as [number, number], + size: [200, 100] as [number, number], + inputs: [], + outputs: [], + }, + { + id: 2, + type: "Note", + title: "Note 2", + pos: [500, 180] as [number, number], + size: [200, 100] as [number, number], + inputs: [], + outputs: [], + }, + ], + _groups: [ + { + id: 1, + title: "Outer [HORIZONTAL]", + pos: [100, 50] as [number, number], + size: [700, 300] as [number, number], + }, + { + id: 2, + title: "Inner Group", + pos: [450, 130] as [number, number], + size: [300, 200] as [number, number], + }, + ], + links: new Map(), + }; + + // Add bounding arrays to simulate ComfyUI runtime format + const group1 = graph._groups[0] as typeof graph._groups[0] & { bounding: number[] }; + const group2 = graph._groups[1] as typeof graph._groups[1] & { bounding: number[] }; + group1.bounding = [100, 50, 700, 300]; + group2.bounding = [450, 130, 300, 200]; + + layoutSelectedGroups(graph, new Set([1])); + + // After layout, bounding should be updated to match pos + expect(group2.bounding[0]).toBe(group2.pos[0]); + expect(group2.bounding[1]).toBe(group2.pos[1]); + expect(group2.bounding[2]).toBe(group2.size[0]); + expect(group2.bounding[3]).toBe(group2.size[1]); + + assertFiniteCoordinates(graph); + }); + }); }); From 9994b404ee391fca4070e4a6cb992eb385b2802b Mon Sep 17 00:00:00 2001 From: pbanddev <202373785+PBandDev@users.noreply.github.com> Date: Wed, 14 Jan 2026 17:41:00 -0600 Subject: [PATCH 2/2] fix: nested group overlap with disconnected nodes in default layout mode --- src/layout/selected-groups.ts | 78 ++++++--- tests/fixtures/group-test-simple.json | 188 ++++++++++++++++++++++ tests/integration/selected-groups.test.ts | 117 ++++++++++++++ 3 files changed, 360 insertions(+), 23 deletions(-) create mode 100644 tests/fixtures/group-test-simple.json diff --git a/src/layout/selected-groups.ts b/src/layout/selected-groups.ts index 38b7fa8..feb3f77 100644 --- a/src/layout/selected-groups.ts +++ b/src/layout/selected-groups.ts @@ -528,7 +528,7 @@ function layoutGroupContents( } // Process nested groups (bottom-up: deepest first) - // Sort by containment depth + // In default mode, reposition direct nested groups below member content const sortedNested = [...nestedGroups].sort((a, b) => { // Count how many other nested groups contain each let depthA = 0; @@ -540,13 +540,52 @@ function layoutGroupContents( return depthB - depthA; // Deepest first }); - for (const nestedGroup of sortedNested) { - // Get direct members of nested group + // Identify direct nested groups (not contained by other nested groups) + const directNestedGroups = sortedNested.filter((g) => { + for (const other of nestedGroups) { + if (other !== g && groupContainsGroup(other, g)) return false; + } + return true; + }); + + // Sort direct groups by original position for stable ordering + directNestedGroups.sort((a, b) => { + const yDiff = a.pos[1] - b.pos[1]; + return yDiff !== 0 ? yDiff : a.pos[0] - b.pos[0]; + }); + + for (const nestedGroup of directNestedGroups) { + // Skip if already processed (by token mode) + if (processedGroups.has(nestedGroup)) { + // Still include bounds + minX = Math.min(minX, nestedGroup.pos[0]); + minY = Math.min(minY, nestedGroup.pos[1]); + maxX = Math.max(maxX, nestedGroup.pos[0] + nestedGroup.size[0]); + maxY = Math.max(maxY, nestedGroup.pos[1] + nestedGroup.size[1]); + continue; + } + + // Get nested group's members and children const nestedChildren = collectNestedGroups(nestedGroup, nestedGroups); const nestedMembers = collectDirectMembers(nestedGroup, allNodes, nestedChildren); - // Recursively layout nested group - const nestedBounds = layoutGroupContents( + // In default mode, reposition nested group below current content + if (layoutMode.type === "default") { + // Position nested group's left edge at startX (same as members) for consistent alignment + // This prevents the group from shifting leftward on each layout run + const targetX = startX; + const targetY = contentEndY + config.verticalGap; + const deltaX = targetX - nestedGroup.pos[0]; + const deltaY = targetY - nestedGroup.pos[1]; + + if (deltaX !== 0 || deltaY !== 0) { + translateGroupWithMembers(nestedGroup, deltaX, deltaY, nestedMembers, allNodes, nestedGroups); + debugLog(` Repositioned nested group "${nestedGroup.title}" to [${targetX}, ${targetY}]`); + } + } + + // Recursively layout nested group contents + layoutGroupContents( nestedGroup, nestedMembers, nestedChildren, @@ -555,24 +594,17 @@ function layoutGroupContents( processedGroups ); - if (nestedBounds) { - // Update bounds to include nested group - minX = Math.min(minX, nestedBounds.minX - config.groupPadding); - minY = Math.min(minY, nestedBounds.minY - config.groupPadding - GROUP_TITLE_HEIGHT); - maxX = Math.max(maxX, nestedBounds.maxX + config.groupPadding); - maxY = Math.max(maxY, nestedBounds.maxY + config.groupPadding); - } else { - // Include nested group's current bounds - const nx = nestedGroup.pos[0]; - const ny = nestedGroup.pos[1]; - const nw = nestedGroup.size[0]; - const nh = nestedGroup.size[1]; - - minX = Math.min(minX, nx); - minY = Math.min(minY, ny); - maxX = Math.max(maxX, nx + nw); - maxY = Math.max(maxY, ny + nh); - } + // Resize nested group to fit its organized contents + resizeGroupToFitMembers(nestedGroup, nestedMembers, allNodes, config); + + // Update contentEndY for next nested group + contentEndY = Math.max(contentEndY, nestedGroup.pos[1] + nestedGroup.size[1]); + + // Update bounds to include nested group + minX = Math.min(minX, nestedGroup.pos[0]); + minY = Math.min(minY, nestedGroup.pos[1]); + maxX = Math.max(maxX, nestedGroup.pos[0] + nestedGroup.size[0]); + maxY = Math.max(maxY, nestedGroup.pos[1] + nestedGroup.size[1]); } // Resize group to fit contents diff --git a/tests/fixtures/group-test-simple.json b/tests/fixtures/group-test-simple.json new file mode 100644 index 0000000..a1a99cf --- /dev/null +++ b/tests/fixtures/group-test-simple.json @@ -0,0 +1,188 @@ +{ + "id": "bbe5e07d-ae25-42f3-b43f-63e9274165a1", + "revision": 0, + "last_node_id": 6, + "last_link_id": 4, + "nodes": [ + { + "id": 3, + "type": "PreviewImage", + "pos": [ + 550, + 570 + ], + "size": [ + 660, + 1000 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 3 + } + ], + "outputs": [], + "title": "Preview Image 1", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.8.2", + "Node name for S&R": "PreviewImage" + }, + "widgets_values": [] + }, + { + "id": 4, + "type": "PreviewImage", + "pos": [ + 1310, + 570 + ], + "size": [ + 660, + 1000 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 4 + } + ], + "outputs": [], + "title": "Preview Image 2", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.8.2", + "Node name for S&R": "PreviewImage" + }, + "widgets_values": [] + }, + { + "id": 5, + "type": "GetNode", + "pos": [ + 140, + 150 + ], + "size": [ + 210, + 60 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "*", + "type": "*", + "links": [ + 3 + ] + } + ], + "title": "GetNode 1", + "properties": {}, + "widgets_values": [ + "" + ] + }, + { + "id": 6, + "type": "GetNode", + "pos": [ + 450, + 150 + ], + "size": [ + 210, + 58 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "*", + "type": "*", + "links": [ + 4 + ] + } + ], + "title": "GetNode 2", + "properties": {}, + "widgets_values": [ + "" + ] + } + ], + "links": [ + [ + 3, + 5, + 0, + 3, + 0, + "IMAGE" + ], + [ + 4, + 6, + 0, + 4, + 0, + "IMAGE" + ] + ], + "groups": [ + { + "id": 1, + "title": "Group Wrapper", + "bounding": [ + 80, + -10, + 2030, + 1670 + ], + "color": "#3f789e", + "font_size": 22, + "flags": {} + }, + { + "id": 2, + "title": "Inner Group", + "bounding": [ + 110, + 70, + 580, + 170 + ], + "color": "#3f789e", + "font_size": 22, + "flags": {} + } + ], + "config": {}, + "extra": { + "workflowRendererVersion": "LG", + "ue_links": [], + "ds": { + "scale": 0.47082251883518567, + "offset": [ + 1126.5268822743458, + 397.1123544821961 + ] + }, + "frontendVersion": "1.37.9" + }, + "version": 0.4 +} diff --git a/tests/integration/selected-groups.test.ts b/tests/integration/selected-groups.test.ts index a235c1b..f6f5403 100644 --- a/tests/integration/selected-groups.test.ts +++ b/tests/integration/selected-groups.test.ts @@ -718,6 +718,123 @@ describe("layoutSelectedGroups", () => { }); }); + describe("nested group + disconnected nodes overlap regression (group-test-simple.json)", () => { + it("positions nested groups below disconnected nodes (no token)", () => { + const graph = loadFixture("group-test-simple.json"); + layoutSelectedGroups(graph, new Set([1])); // "Group Wrapper" + + const innerGroup = graph._groups.find((g) => g.id === 2)!; + const node3 = graph._nodes.find((n) => n.id === 3)!; + const node4 = graph._nodes.find((n) => n.id === 4)!; + + // Inner group should be BELOW the preview images + const node3Bottom = node3.pos[1] + node3.size[1]; + const node4Bottom = node4.pos[1] + node4.size[1]; + const membersBottom = Math.max(node3Bottom, node4Bottom); + expect(innerGroup.pos[1]).toBeGreaterThan(membersBottom); + }); + + it("maintains no overlaps after default layout", () => { + const graph = loadFixture("group-test-simple.json"); + layoutSelectedGroups(graph, new Set([1])); + assertNodesInsideGroups(graph); + assertFiniteCoordinates(graph); + }); + + it("is idempotent with nested groups in default mode", () => { + const graph = loadFixture("group-test-simple.json"); + layoutSelectedGroups(graph, new Set([1])); + const positions1 = capturePositions(graph); + + layoutSelectedGroups(graph, new Set([1])); + const positions2 = capturePositions(graph); + + // Compare positions with small tolerance + for (const [id, pos1] of positions1) { + const pos2 = positions2.get(id); + if (pos2) { + expect(Math.abs(pos1.x - pos2.x)).toBeLessThan(5); + expect(Math.abs(pos1.y - pos2.y)).toBeLessThan(5); + } + } + }); + + it("multiple sibling nested groups stack vertically", () => { + const graph: LGraph = { + _nodes: [ + // Disconnected node in parent + { + id: 1, + type: "Note", + title: "Parent Node", + pos: [100, 100] as [number, number], + size: [200, 100] as [number, number], + inputs: [], + outputs: [], + }, + // Node in first nested group + { + id: 2, + type: "Note", + title: "Nested 1 Node", + pos: [100, 300] as [number, number], + size: [200, 100] as [number, number], + inputs: [], + outputs: [], + }, + // Node in second nested group + { + id: 3, + type: "Note", + title: "Nested 2 Node", + pos: [100, 500] as [number, number], + size: [200, 100] as [number, number], + inputs: [], + outputs: [], + }, + ], + _groups: [ + { + id: 1, + title: "Parent Group", + pos: [50, 50] as [number, number], + size: [400, 600] as [number, number], + }, + { + id: 2, + title: "Nested 1", + pos: [80, 250] as [number, number], + size: [300, 180] as [number, number], + }, + { + id: 3, + title: "Nested 2", + pos: [80, 450] as [number, number], + size: [300, 180] as [number, number], + }, + ], + links: new Map(), + }; + + layoutSelectedGroups(graph, new Set([1])); + + const nested1 = graph._groups.find((g) => g.id === 2)!; + const nested2 = graph._groups.find((g) => g.id === 3)!; + const parentNode = graph._nodes.find((n) => n.id === 1)!; + + // Both nested groups should be below the parent node + const parentNodeBottom = parentNode.pos[1] + parentNode.size[1]; + expect(nested1.pos[1]).toBeGreaterThan(parentNodeBottom); + expect(nested2.pos[1]).toBeGreaterThan(parentNodeBottom); + + // Nested groups should not overlap each other + const nested1Bottom = nested1.pos[1] + nested1.size[1]; + expect(nested2.pos[1]).toBeGreaterThanOrEqual(nested1Bottom); + + assertFiniteCoordinates(graph); + }); + }); + describe("nested group position update regression (group-test.json)", () => { it("nested group position updates when parent has token layout", () => { const graph = loadFixture("group-test.json");