Merge branch 'feature/organize-selection'

This commit is contained in:
pbanddev
2026-01-13 17:00:30 -06:00
parent ed0b01b2c2
commit d0846626ce
8 changed files with 1038 additions and 20 deletions
+23 -1
View File
@@ -56,6 +56,7 @@ Use docs-mcp server for ComfyUI API reference.
7. `positioning.ts` - Assign coordinates, place disconnected nodes, restore reroutes
8. `groups.ts` - Resize groups to fit members
9. `layout-utils.ts` - Shared functions for internal edge detection and member layer assignment
10. `selected-groups.ts` - Organize only selected groups independently
**Configuration** in `types.ts`:
- `maxColumns`: 0=auto, 1=vertical stack, 2+=fixed columns
@@ -70,6 +71,26 @@ Use docs-mcp server for ComfyUI API reference.
- `assignNodeYPositions` tracks reserved Y regions to prevent node/group overlap within layers
- **Idempotency**: Layout is a pure function of topology + sizes; overlap resolution decoupled from node.pos writes
## Selected Groups Layout
`layoutSelectedGroups(graph, selectedGroupIds, config?)` in `src/layout/selected-groups.ts`:
**Context menu items** (in `src/index.ts`):
- "Organize Group" - when single group selected
- "Organize N Groups" - when multiple groups selected
**Behavior**:
- Uses internal connections only (ignores external edges)
- Disconnected nodes within group stacked vertically
- Nested groups auto-included when parent selected
- External overlaps acceptable (no resolution with non-selected nodes)
- Groups resized to fit organized contents
**Selection detection**:
- Uses `app.canvas.selectedItems` (Set of Positionable)
- Duck-typing via `isLGraphGroup()` checks for pos/size/title/id
- Handles Float64Array (ComfyUI runtime uses typed arrays for pos/size)
## Testing
**Vitest-based regression testing** in `tests/`:
@@ -84,10 +105,11 @@ Use docs-mcp server for ComfyUI API reference.
- `assertIdempotent` - stable after multiple runs
- `assertGroupMembershipPreserved` - nodes originally in groups remain in groups
- `tests/integration/layout.test.ts` - Main regression tests
- `tests/integration/selected-groups.test.ts` - Selected groups layout tests (17 cases)
**Coverage**: 70% line threshold, excludes `src/index.ts` (ComfyUI runtime) and `src/layout/reroute-collapse.ts` (no fixtures)
All 4 fixtures pass all invariants including idempotency.
All fixtures pass all invariants including idempotency. 50 total tests.
## CI/CD
+5
View File
@@ -0,0 +1,5 @@
export const SETTINGS_PREFIX = "Node Organizer";
export const SETTINGS_IDS = {
VERSION: `${SETTINGS_PREFIX}. ${SETTINGS_PREFIX}`,
DEBUG_LOGGING: `${SETTINGS_PREFIX}.Debug Logging`,
};
+13 -7
View File
@@ -1,16 +1,22 @@
/** Setting ID for debug logging */
const SETTINGS_PREFIX = "Node Organizer";
const SETTINGS_IDS = {
DEBUG_LOGGING: `${SETTINGS_PREFIX}.Debug Logging`,
};
import { SETTINGS_IDS } from "./consants";
/** Check if debug logging is enabled */
export function isDebugEnabled(): boolean {
try {
// Access app only if it exists (not in test environment)
if (typeof globalThis !== "undefined" && "app" in globalThis) {
const app = (globalThis as { app?: { extensionManager?: { setting?: { get: <T>(id: string) => T } } } }).app;
return app?.extensionManager?.setting?.get<boolean>(SETTINGS_IDS.DEBUG_LOGGING) ?? false;
const app = (
globalThis as {
app?: {
extensionManager?: { setting?: { get: <T>(id: string) => T } };
};
}
).app;
return (
app?.extensionManager?.setting?.get<boolean>(
SETTINGS_IDS.DEBUG_LOGGING
) ?? false
);
}
return false;
} catch {
+105 -11
View File
@@ -1,6 +1,7 @@
import type { ComfyApp } from "@comfyorg/comfyui-frontend-types";
import type { LGraph } from "./layout/types";
import { layoutGraph } from "./layout";
import type { LGraph, LGraphGroup } from "./layout/types";
import { layoutGraph, layoutSelectedGroups } from "./layout";
import { SETTINGS_IDS } from "./consants";
/** Context menu option */
interface ContextMenuOption {
@@ -16,7 +17,11 @@ interface LGraphCanvasType {
}
declare global {
const app: ComfyApp;
const app: ComfyApp & {
canvas?: {
selectedItems?: Set<unknown>;
};
};
const LGraphCanvas: LGraphCanvasType;
interface Window {
@@ -24,12 +29,49 @@ declare global {
}
}
/** Setting ID for debug logging */
const SETTINGS_PREFIX = "Node Organizer";
const SETTINGS_IDS = {
VERSION: `${SETTINGS_PREFIX}. ${SETTINGS_PREFIX}`,
DEBUG_LOGGING: `${SETTINGS_PREFIX}.Debug Logging`,
};
/**
* Check if value is array-like (regular array or typed array like Float64Array)
*/
function isArrayLike(value: unknown): boolean {
if (Array.isArray(value)) return true;
if (!value || typeof value !== "object") return false;
// Check for typed arrays (Float64Array, etc.) which have numeric length
const obj = value as { length?: unknown };
return typeof obj.length === "number" && obj.length >= 0;
}
/**
* Check if an item is a group (duck typing)
* Groups have pos/size (can be Float64Array), title, id, but no .type like nodes
*/
function isLGraphGroup(item: unknown): item is LGraphGroup {
if (!item || typeof item !== "object") return false;
const obj = item as Record<string, unknown>;
return (
isArrayLike(obj.pos) &&
isArrayLike(obj.size) &&
typeof obj.title === "string" &&
typeof obj.id === "number" &&
typeof obj.type !== "string" // Nodes have .type, groups don't
);
}
/**
* Get selected groups from global app.canvas.selectedItems
* Per ComfyUI docs: use app.canvas.selectedItems for selection access
*/
function getSelectedGroups(): LGraphGroup[] {
const groups: LGraphGroup[] = [];
const selectedItems = app.canvas?.selectedItems;
if (!selectedItems) return groups;
for (const item of selectedItems) {
if (isLGraphGroup(item)) {
groups.push(item);
}
}
return groups;
}
app.registerExtension({
name: "ComfyUI Node Organizer",
@@ -58,7 +100,7 @@ app.registerExtension({
},
],
getCanvasMenuItems(canvas) {
return [
const items: (ContextMenuOption | null)[] = [
null, // separator
{
content: "Organize Workflow",
@@ -81,7 +123,59 @@ app.registerExtension({
}
},
},
null, // separator
];
// Check for selected groups via app.canvas.selectedItems
const selectedGroups = getSelectedGroups();
if (selectedGroups.length === 1) {
items.push({
content: "Organize Group",
callback: () => {
const graph = canvas.getCurrentGraph() as LGraph | null;
if (!graph) {
console.warn("[node-organizer] No active graph");
return;
}
try {
const groupIds = new Set([selectedGroups[0].id]);
const result = layoutSelectedGroups(graph, groupIds);
console.log(
`[node-organizer] Group organized: ${result.nodeCount} nodes, ` +
`${result.groupCount} groups, ${result.executionMs.toFixed(1)}ms`
);
} catch (err) {
console.error("[node-organizer] Group layout failed:", err);
}
},
});
} else if (selectedGroups.length > 1) {
items.push({
content: `Organize ${selectedGroups.length} Groups`,
callback: () => {
const graph = canvas.getCurrentGraph() as LGraph | null;
if (!graph) {
console.warn("[node-organizer] No active graph");
return;
}
try {
const groupIds = new Set(selectedGroups.map((g) => g.id));
const result = layoutSelectedGroups(graph, groupIds);
console.log(
`[node-organizer] Groups organized: ${result.nodeCount} nodes, ` +
`${result.groupCount} groups, ${result.executionMs.toFixed(1)}ms`
);
} catch (err) {
console.error("[node-organizer] Groups layout failed:", err);
}
},
});
}
items.push(null); // separator
return items;
},
});
+4 -1
View File
@@ -99,5 +99,8 @@ export function layoutGraph(
}
// Re-export types and config
export type { LayoutConfig, LayoutResult } from "./types";
export type { LayoutConfig, LayoutResult, SelectedGroupLayoutResult } from "./types";
export { DEFAULT_CONFIG } from "./types";
// Export selected groups layout
export { layoutSelectedGroups } from "./selected-groups";
+469
View File
@@ -0,0 +1,469 @@
import type {
LGraph,
LGraphNode,
LGraphGroup,
LayoutConfig,
SelectedGroupLayoutResult,
} from "./types";
import { DEFAULT_CONFIG } from "./types";
import { getInternalEdges, assignMemberLayers } from "./layout-utils";
import { packNodesIntoRows } from "./bin-pack";
import { debugLog } from "../debug";
/** Title bar height for groups */
const GROUP_TITLE_HEIGHT = 50;
/**
* Check if outer group's bounds fully contain inner group's bounds
*/
function groupContainsGroup(outer: LGraphGroup, inner: LGraphGroup): boolean {
const ox = outer.pos[0];
const oy = outer.pos[1];
const ow = outer.size[0];
const oh = outer.size[1];
const ix = inner.pos[0];
const iy = inner.pos[1];
const iw = inner.size[0];
const ih = inner.size[1];
return ix >= ox && iy >= oy && ix + iw <= ox + ow && iy + ih <= oy + oh;
}
/**
* Check if a node's origin is inside a group's content area
*/
function nodeInsideGroup(node: LGraphNode, group: LGraphGroup): boolean {
const gx = group.pos[0];
const gy = group.pos[1] + GROUP_TITLE_HEIGHT;
const gw = group.size[0];
const gh = group.size[1] - GROUP_TITLE_HEIGHT;
const nx = node.pos?.[0] ?? 0;
const ny = node.pos?.[1] ?? 0;
return nx >= gx && nx < gx + gw && ny >= gy && ny < gy + gh;
}
/**
* Collect all nested child groups within a parent group
*/
function collectNestedGroups(
parent: LGraphGroup,
allGroups: LGraphGroup[]
): LGraphGroup[] {
const nested: LGraphGroup[] = [];
const queue = [parent];
while (queue.length > 0) {
const current = queue.shift()!;
for (const group of allGroups) {
if (group === current) continue;
if (groupContainsGroup(current, group) && !nested.includes(group)) {
nested.push(group);
queue.push(group);
}
}
}
return nested;
}
/**
* Collect all member nodes within a group (excluding nodes in nested child groups)
*/
function collectDirectMembers(
group: LGraphGroup,
allNodes: Map<number, LGraphNode>,
nestedGroups: LGraphGroup[]
): Set<number> {
const members = new Set<number>();
for (const [nodeId, node] of allNodes) {
if (!nodeInsideGroup(node, group)) continue;
// Check if node is inside a nested group (exclude it from direct members)
let inNestedGroup = false;
for (const nested of nestedGroups) {
if (nodeInsideGroup(node, nested)) {
inNestedGroup = true;
break;
}
}
if (!inNestedGroup) {
members.add(nodeId);
}
}
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
*/
function layoutGroupContents(
group: LGraphGroup,
memberIds: Set<number>,
nestedGroups: LGraphGroup[],
allNodes: Map<number, LGraphNode>,
config: LayoutConfig,
processedGroups: Set<LGraphGroup>
): { minX: number; minY: number; maxX: number; maxY: number } | null {
// Skip if already processed (handles nested groups selected individually)
if (processedGroups.has(group)) {
return null;
}
processedGroups.add(group);
// Collect non-locked members
const members: LGraphNode[] = [];
for (const nodeId of memberIds) {
const node = allNodes.get(nodeId);
if (!node) continue;
if (node.flags?.pinned || node.locked) continue;
members.push(node);
}
// Handle empty group with no nested groups
if (members.length === 0 && nestedGroups.length === 0) {
debugLog(`Skipping empty group "${group.title}"`);
return null;
}
debugLog(
`layoutGroupContents "${group.title}": members=${members.length}, nestedGroups=${nestedGroups.length}`
);
// Get the group's current content area start position
const startX = group.pos[0] + config.groupPadding;
const startY = group.pos[1] + config.groupPadding + GROUP_TITLE_HEIGHT;
// Track bounds of all positioned content
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
// Get internal edges between members
const edges = getInternalEdges(memberIds, allNodes);
let contentEndY = startY;
if (members.length > 0) {
if (edges.length > 0) {
// Has internal connections - use layer-based layout
const layers = assignMemberLayers(memberIds, edges, allNodes);
let x = startX;
for (let layerIdx = 0; layerIdx < layers.length; layerIdx++) {
const layer = layers[layerIdx];
if (layer.length === 0) continue;
// Sort layer by height (tallest first for stable layout)
layer.sort((a, b) => (b.size?.[1] ?? 100) - (a.size?.[1] ?? 100));
let y = startY;
let layerMaxWidth = 0;
for (const node of layer) {
if (node.pos) {
node.pos[0] = x;
node.pos[1] = y;
debugLog(
` Member ${node.id} (${node.type}): layer ${layerIdx}, pos [${x}, ${y}]`
);
}
const nw = node.size?.[0] ?? 200;
const nh = node.size?.[1] ?? 100;
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x + nw);
maxY = Math.max(maxY, y + nh);
layerMaxWidth = Math.max(layerMaxWidth, nw);
y += nh + config.verticalGap;
}
contentEndY = Math.max(contentEndY, y - config.verticalGap);
x += layerMaxWidth + config.horizontalGap;
}
} else {
// No internal connections - use vertical stacking
members.sort((a, b) => (b.size?.[1] ?? 100) - (a.size?.[1] ?? 100));
// Build sizes map
const sizes = new Map<number, { width: number; height: number }>();
for (const node of members) {
sizes.set(node.id, {
width: node.size?.[0] ?? 200,
height: node.size?.[1] ?? 100,
});
}
// Pack into rows (vertical stack with maxColumns=1)
const nodeIds = members.map((n) => n.id);
const rows = packNodesIntoRows(nodeIds, sizes, {
...config,
maxColumns: 1,
});
for (const row of rows) {
const x = startX;
const y = startY + row.yOffset;
for (const nodeId of row.nodes) {
const node = allNodes.get(nodeId);
if (node?.pos) {
node.pos[0] = x;
node.pos[1] = y;
debugLog(` Member ${node.id} (${node.type}): stacked pos [${x}, ${y}]`);
const nw = node.size?.[0] ?? 200;
const nh = node.size?.[1] ?? 100;
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x + nw);
maxY = Math.max(maxY, y + nh);
}
}
contentEndY = Math.max(contentEndY, startY + row.yOffset + row.height);
}
}
}
// Process nested groups (bottom-up: deepest first)
// Sort by containment depth
const sortedNested = [...nestedGroups].sort((a, b) => {
// Count how many other nested groups contain each
let depthA = 0;
let depthB = 0;
for (const other of nestedGroups) {
if (other !== a && groupContainsGroup(other, a)) depthA++;
if (other !== b && groupContainsGroup(other, b)) depthB++;
}
return depthB - depthA; // Deepest first
});
for (const nestedGroup of sortedNested) {
// Get direct members of nested group
const nestedChildren = collectNestedGroups(nestedGroup, nestedGroups);
const nestedMembers = collectDirectMembers(nestedGroup, allNodes, nestedChildren);
// Recursively layout nested group
const nestedBounds = layoutGroupContents(
nestedGroup,
nestedMembers,
nestedChildren,
allNodes,
config,
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 group to fit contents
if (minX !== Infinity) {
const newX = minX - config.groupPadding;
const newY = minY - config.groupPadding - GROUP_TITLE_HEIGHT;
const newWidth = maxX - minX + config.groupPadding * 2;
const newHeight = maxY - minY + config.groupPadding * 2 + GROUP_TITLE_HEIGHT;
debugLog(
`Resizing group "${group.title}" to [${newX}, ${newY}, ${newWidth}, ${newHeight}]`
);
updateGroupBounds(group, newX, newY, newWidth, newHeight);
return { minX, minY, maxX, maxY };
}
return null;
}
/**
* Layout only the contents of selected groups
* Ignores external connections - uses only internal edges
* Auto-includes nested groups when parent is selected
*
* @param graph - The full graph (for node/link access)
* @param selectedGroupIds - Set of group IDs to organize
* @param config - Layout configuration
*/
export function layoutSelectedGroups(
graph: LGraph,
selectedGroupIds: Set<number>,
config?: Partial<LayoutConfig>
): SelectedGroupLayoutResult {
const fullConfig = { ...DEFAULT_CONFIG, ...config };
const startTime = performance.now();
// Build allNodes map
const allNodes = new Map<number, LGraphNode>();
for (const node of graph._nodes ?? []) {
if (!node || node.id === undefined) continue;
allNodes.set(node.id, node);
}
// Get all groups
const allGroups = graph._groups ?? [];
// Find selected groups
const selectedGroups: LGraphGroup[] = [];
for (const group of allGroups) {
if (selectedGroupIds.has(group.id)) {
selectedGroups.push(group);
}
}
if (selectedGroups.length === 0) {
debugLog("No selected groups found");
return {
nodeCount: 0,
groupCount: 0,
executionMs: performance.now() - startTime,
};
}
// Filter to only top-level selected groups
// (nested groups selected individually will be handled, but if both parent and child
// are selected, we only process from the parent)
const topLevelSelected: LGraphGroup[] = [];
for (const group of selectedGroups) {
let hasSelectedParent = false;
for (const other of selectedGroups) {
if (other !== group && groupContainsGroup(other, group)) {
hasSelectedParent = true;
break;
}
}
if (!hasSelectedParent) {
topLevelSelected.push(group);
}
}
// Track processed groups to avoid double-processing
const processedGroups = new Set<LGraphGroup>();
let totalNodes = 0;
// Process each top-level selected group
for (const group of topLevelSelected) {
// Collect nested groups within this group
const nestedGroups = collectNestedGroups(group, allGroups);
// Collect direct members (excluding those in nested groups)
const memberIds = collectDirectMembers(group, allNodes, nestedGroups);
debugLog(
`Processing group "${group.title}": ${memberIds.size} direct members, ${nestedGroups.length} nested groups`
);
// Layout group contents
layoutGroupContents(
group,
memberIds,
nestedGroups,
allNodes,
fullConfig,
processedGroups
);
totalNodes += memberIds.size;
// Count nested group members too
for (const nested of nestedGroups) {
const nestedChildren = collectNestedGroups(nested, nestedGroups);
const nestedMembers = collectDirectMembers(nested, allNodes, nestedChildren);
totalNodes += nestedMembers.size;
}
}
// Trigger canvas redraw
graph.setDirtyCanvas?.(true, true);
const result: SelectedGroupLayoutResult = {
nodeCount: totalNodes,
groupCount: processedGroups.size,
executionMs: performance.now() - startTime,
};
debugLog(
`layoutSelectedGroups complete: ${result.nodeCount} nodes, ${result.groupCount} groups, ${result.executionMs.toFixed(1)}ms`
);
return result;
}
+10
View File
@@ -262,3 +262,13 @@ export interface LayoutResult {
/** Execution time in ms */
executionMs: number;
}
/** Result from selected group layout */
export interface SelectedGroupLayoutResult {
/** Number of nodes organized */
nodeCount: number;
/** Number of groups organized */
groupCount: number;
/** Execution time in ms */
executionMs: number;
}
+409
View File
@@ -0,0 +1,409 @@
import { describe, it, beforeEach, expect } from "vitest";
import { loadFixture, capturePositions } from "../helpers";
import { layoutSelectedGroups } from "../../src/layout/selected-groups";
import {
assertNodesInsideGroups,
assertFiniteCoordinates,
} from "../invariants";
import type { LGraph } from "../../src/layout/types";
/**
* Tests for layoutSelectedGroups function
* Note: External overlaps are acceptable per requirements
*/
describe("layoutSelectedGroups", () => {
describe("single group with internal connections", () => {
let graph: LGraph;
beforeEach(() => {
// nested-groups.json has groups with internal connections
// Group 1 "Step 1 - Load model" contains node 4 (CheckpointLoaderSimple)
graph = loadFixture("nested-groups.json");
});
it("organizes nodes within selected group", () => {
// Select group 1 which contains node 4
const result = layoutSelectedGroups(graph, new Set([1]));
expect(result.nodeCount).toBeGreaterThanOrEqual(0);
expect(result.groupCount).toBe(1);
assertFiniteCoordinates(graph);
});
it("resizes group to fit contents", () => {
layoutSelectedGroups(graph, new Set([1]));
const groupAfter = graph._groups.find((g) => g.id === 1);
// Group size should be finite and positive
expect(Number.isFinite(groupAfter!.size[0])).toBe(true);
expect(Number.isFinite(groupAfter!.size[1])).toBe(true);
expect(groupAfter!.size[0]).toBeGreaterThan(0);
expect(groupAfter!.size[1]).toBeGreaterThan(0);
});
it("preserves external node positions", () => {
// Capture positions of nodes NOT in group 1
const nodesBefore = new Map<number, [number, number]>();
for (const node of graph._nodes) {
// Node 4 is in group 1, all others should be unchanged
if (node.id !== 4) {
nodesBefore.set(node.id, [...node.pos]);
}
}
layoutSelectedGroups(graph, new Set([1]));
// Verify external nodes haven't moved
for (const [nodeId, posBefore] of nodesBefore) {
const node = graph._nodes.find((n) => n.id === nodeId);
expect(node).toBeDefined();
expect(node!.pos[0]).toBe(posBefore[0]);
expect(node!.pos[1]).toBe(posBefore[1]);
}
});
it("is idempotent", () => {
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);
}
}
});
});
describe("single group with disconnected nodes", () => {
let graph: LGraph;
beforeEach(() => {
// Create a graph with a group containing disconnected nodes
graph = {
_nodes: [
{
id: 1,
type: "Note",
title: "Note 1",
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
},
{
id: 2,
type: "Note",
title: "Note 2",
pos: [150, 250],
size: [200, 100],
inputs: [],
outputs: [],
},
{
id: 3,
type: "Note",
title: "External Node",
pos: [500, 500],
size: [200, 100],
inputs: [],
outputs: [],
},
],
_groups: [
{
id: 1,
title: "Test Group",
pos: [50, 50],
size: [350, 350],
},
],
links: new Map(),
};
});
it("stacks disconnected nodes vertically", () => {
const result = layoutSelectedGroups(graph, new Set([1]));
expect(result.nodeCount).toBe(2);
expect(result.groupCount).toBe(1);
// Nodes should have same X (stacked vertically)
const node1 = graph._nodes.find((n) => n.id === 1)!;
const node2 = graph._nodes.find((n) => n.id === 2)!;
expect(node1.pos[0]).toBe(node2.pos[0]);
assertFiniteCoordinates(graph);
});
it("resizes group to fit stacked nodes", () => {
layoutSelectedGroups(graph, new Set([1]));
const group = graph._groups.find((g) => g.id === 1)!;
expect(Number.isFinite(group.size[0])).toBe(true);
expect(Number.isFinite(group.size[1])).toBe(true);
expect(group.size[0]).toBeGreaterThan(0);
expect(group.size[1]).toBeGreaterThan(0);
});
});
describe("multiple groups", () => {
let graph: LGraph;
beforeEach(() => {
graph = loadFixture("nested-groups.json");
});
it("organizes each group independently", () => {
// Select groups 1 and 2 (not nested)
const result = layoutSelectedGroups(graph, new Set([1, 2]));
expect(result.groupCount).toBeGreaterThanOrEqual(2);
assertFiniteCoordinates(graph);
});
it("handles non-overlapping groups", () => {
layoutSelectedGroups(graph, new Set([1, 2]));
// Both groups should have valid coordinates
const group1 = graph._groups.find((g) => g.id === 1)!;
const group2 = graph._groups.find((g) => g.id === 2)!;
expect(Number.isFinite(group1.pos[0])).toBe(true);
expect(Number.isFinite(group2.pos[0])).toBe(true);
});
});
describe("nested groups", () => {
let graph: LGraph;
beforeEach(() => {
// nested-groups.json has Group 3 containing Group 4 (nested)
graph = loadFixture("nested-groups.json");
});
it("auto-includes nested groups when parent selected", () => {
// Select group 3 which contains group 4
const result = layoutSelectedGroups(graph, new Set([3]));
// Should include the nested group
expect(result.groupCount).toBeGreaterThanOrEqual(1);
assertFiniteCoordinates(graph);
});
it("positions nested groups correctly", () => {
layoutSelectedGroups(graph, new Set([3]));
// Group 4 should still be inside Group 3
const group3 = graph._groups.find((g) => g.id === 3)!;
const group4 = graph._groups.find((g) => g.id === 4)!;
// Check group 4 is within group 3 bounds
expect(group4.pos[0]).toBeGreaterThanOrEqual(group3.pos[0]);
expect(group4.pos[1]).toBeGreaterThanOrEqual(group3.pos[1]);
expect(group4.pos[0] + group4.size[0]).toBeLessThanOrEqual(
group3.pos[0] + group3.size[0]
);
expect(group4.pos[1] + group4.size[1]).toBeLessThanOrEqual(
group3.pos[1] + group3.size[1]
);
});
it("keeps nodes inside selected groups", () => {
layoutSelectedGroups(graph, new Set([3]));
// Nodes inside group 3 should remain inside it
assertNodesInsideGroups(graph);
});
});
describe("edge cases", () => {
it("handles empty graph", () => {
const graph: LGraph = {
_nodes: [],
_groups: [],
links: new Map(),
};
const result = layoutSelectedGroups(graph, new Set([1]));
expect(result.nodeCount).toBe(0);
expect(result.groupCount).toBe(0);
});
it("handles non-existent group IDs", () => {
const graph = loadFixture("nested-groups.json");
const result = layoutSelectedGroups(graph, new Set([999]));
expect(result.groupCount).toBe(0);
});
it("handles empty groups", () => {
const graph: LGraph = {
_nodes: [
{
id: 1,
type: "Note",
title: "External",
pos: [500, 500],
size: [200, 100],
inputs: [],
outputs: [],
},
],
_groups: [
{
id: 1,
title: "Empty Group",
pos: [50, 50],
size: [200, 200],
},
],
links: new Map(),
};
// Should not crash
const result = layoutSelectedGroups(graph, new Set([1]));
// Empty group is still processed, just has no content
expect(result.nodeCount).toBe(0);
assertFiniteCoordinates(graph);
});
it("handles locked nodes", () => {
const graph: LGraph = {
_nodes: [
{
id: 1,
type: "Note",
title: "Locked Node",
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
locked: true,
},
{
id: 2,
type: "Note",
title: "Normal Node",
pos: [100, 250],
size: [200, 100],
inputs: [],
outputs: [],
},
],
_groups: [
{
id: 1,
title: "Test Group",
pos: [50, 50],
size: [350, 400],
},
],
links: new Map(),
};
const lockedPosBefore = [...graph._nodes[0].pos];
layoutSelectedGroups(graph, new Set([1]));
// Locked node should not move
expect(graph._nodes[0].pos[0]).toBe(lockedPosBefore[0]);
expect(graph._nodes[0].pos[1]).toBe(lockedPosBefore[1]);
});
it("handles group with only locked nodes", () => {
const graph: LGraph = {
_nodes: [
{
id: 1,
type: "Note",
title: "Locked Node",
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
locked: true,
},
],
_groups: [
{
id: 1,
title: "Test Group",
pos: [50, 50],
size: [350, 250],
},
],
links: new Map(),
};
// Should not crash
const result = layoutSelectedGroups(graph, new Set([1]));
expect(result).toBeDefined();
});
});
describe("groups with internal connections", () => {
it("uses layer-based layout for connected nodes", () => {
const graph: LGraph = {
_nodes: [
{
id: 1,
type: "NodeA",
title: "Node A",
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [{ name: "out", type: "FLOAT", links: [1] }],
},
{
id: 2,
type: "NodeB",
title: "Node B",
pos: [100, 300],
size: [200, 100],
inputs: [{ name: "in", type: "FLOAT", link: 1 }],
outputs: [],
},
],
_groups: [
{
id: 1,
title: "Test Group",
pos: [50, 50],
size: [400, 400],
},
],
links: new Map([
[
1,
{
id: 1,
origin_id: 1,
origin_slot: 0,
target_id: 2,
target_slot: 0,
type: "FLOAT",
},
],
]),
};
layoutSelectedGroups(graph, new Set([1]));
// With layer-based layout, source (node 1) should be left of target (node 2)
const node1 = graph._nodes.find((n) => n.id === 1)!;
const node2 = graph._nodes.find((n) => n.id === 2)!;
expect(node1.pos[0]).toBeLessThan(node2.pos[0]);
assertFiniteCoordinates(graph);
});
});
});