932 lines
27 KiB
TypeScript
932 lines
27 KiB
TypeScript
import { describe, it, beforeEach, expect } from "vitest";
|
|
import { loadFixture, capturePositions } from "../helpers";
|
|
import { layoutSelectedGroups } from "../../src/layout/selected-groups";
|
|
import { parseLayoutToken } from "../../src/layout/title-tokens";
|
|
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);
|
|
});
|
|
});
|
|
|
|
describe("parseLayoutToken", () => {
|
|
it("parses [HORIZONTAL] token", () => {
|
|
expect(parseLayoutToken("My Group [HORIZONTAL]")).toEqual({
|
|
type: "horizontal",
|
|
});
|
|
});
|
|
|
|
it("parses [VERTICAL] token", () => {
|
|
expect(parseLayoutToken("[VERTICAL] Group")).toEqual({ type: "vertical" });
|
|
});
|
|
|
|
it("parses [xROW] tokens", () => {
|
|
expect(parseLayoutToken("Group [2ROW]")).toEqual({ type: "rows", count: 2 });
|
|
expect(parseLayoutToken("[3ROW] Test")).toEqual({ type: "rows", count: 3 });
|
|
});
|
|
|
|
it("parses [xCOL] tokens", () => {
|
|
expect(parseLayoutToken("Group [2COL]")).toEqual({
|
|
type: "columns",
|
|
count: 2,
|
|
});
|
|
expect(parseLayoutToken("[4COL] Test")).toEqual({
|
|
type: "columns",
|
|
count: 4,
|
|
});
|
|
});
|
|
|
|
it("treats [1ROW] as horizontal", () => {
|
|
expect(parseLayoutToken("Group [1ROW]")).toEqual({ type: "horizontal" });
|
|
});
|
|
|
|
it("treats [1COL] as vertical", () => {
|
|
expect(parseLayoutToken("Group [1COL]")).toEqual({ type: "vertical" });
|
|
});
|
|
|
|
it("is case-insensitive", () => {
|
|
expect(parseLayoutToken("[horizontal]")).toEqual({ type: "horizontal" });
|
|
expect(parseLayoutToken("[Vertical]")).toEqual({ type: "vertical" });
|
|
expect(parseLayoutToken("[2row]")).toEqual({ type: "rows", count: 2 });
|
|
expect(parseLayoutToken("[3Col]")).toEqual({ type: "columns", count: 3 });
|
|
});
|
|
|
|
it("returns default for no token", () => {
|
|
expect(parseLayoutToken("Normal Group")).toEqual({ type: "default" });
|
|
expect(parseLayoutToken("")).toEqual({ type: "default" });
|
|
});
|
|
|
|
it("returns first matching token", () => {
|
|
// HORIZONTAL comes before xROW in parsing order
|
|
expect(parseLayoutToken("[HORIZONTAL] [2ROW]")).toEqual({
|
|
type: "horizontal",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("groups with layout tokens", () => {
|
|
function createTestGraph(groupTitle: string): LGraph {
|
|
return {
|
|
_nodes: [
|
|
{
|
|
id: 1,
|
|
type: "Note",
|
|
title: "Node 1",
|
|
pos: [100, 100],
|
|
size: [200, 100],
|
|
inputs: [],
|
|
outputs: [],
|
|
},
|
|
{
|
|
id: 2,
|
|
type: "Note",
|
|
title: "Node 2",
|
|
pos: [100, 250],
|
|
size: [200, 100],
|
|
inputs: [],
|
|
outputs: [],
|
|
},
|
|
{
|
|
id: 3,
|
|
type: "Note",
|
|
title: "Node 3",
|
|
pos: [100, 400],
|
|
size: [200, 100],
|
|
inputs: [],
|
|
outputs: [],
|
|
},
|
|
{
|
|
id: 4,
|
|
type: "Note",
|
|
title: "Node 4",
|
|
pos: [100, 550],
|
|
size: [200, 100],
|
|
inputs: [],
|
|
outputs: [],
|
|
},
|
|
],
|
|
_groups: [
|
|
{
|
|
id: 1,
|
|
title: groupTitle,
|
|
pos: [50, 50],
|
|
size: [500, 700],
|
|
},
|
|
],
|
|
links: new Map(),
|
|
};
|
|
}
|
|
|
|
it("[HORIZONTAL] arranges nodes in single row", () => {
|
|
const graph = createTestGraph("Test [HORIZONTAL]");
|
|
layoutSelectedGroups(graph, new Set([1]));
|
|
|
|
// All nodes should have same Y (single row)
|
|
const ys = graph._nodes.map((n) => n.pos[1]);
|
|
expect(new Set(ys).size).toBe(1);
|
|
|
|
// Nodes should be spread horizontally
|
|
const xs = graph._nodes.map((n) => n.pos[0]).sort((a, b) => a - b);
|
|
expect(xs[3]).toBeGreaterThan(xs[0]);
|
|
|
|
assertFiniteCoordinates(graph);
|
|
});
|
|
|
|
it("[VERTICAL] arranges nodes in single column", () => {
|
|
const graph = createTestGraph("Test [VERTICAL]");
|
|
layoutSelectedGroups(graph, new Set([1]));
|
|
|
|
// All nodes should have same X (single column)
|
|
const xs = graph._nodes.map((n) => n.pos[0]);
|
|
expect(new Set(xs).size).toBe(1);
|
|
|
|
// Nodes should be spread vertically
|
|
const ys = graph._nodes.map((n) => n.pos[1]).sort((a, b) => a - b);
|
|
expect(ys[3]).toBeGreaterThan(ys[0]);
|
|
|
|
assertFiniteCoordinates(graph);
|
|
});
|
|
|
|
it("[2ROW] distributes nodes into 2 rows", () => {
|
|
const graph = createTestGraph("Test [2ROW]");
|
|
layoutSelectedGroups(graph, new Set([1]));
|
|
|
|
// Should have exactly 2 distinct Y values (2 rows)
|
|
const ys = new Set(graph._nodes.map((n) => n.pos[1]));
|
|
expect(ys.size).toBe(2);
|
|
|
|
assertFiniteCoordinates(graph);
|
|
});
|
|
|
|
it("[3COL] distributes nodes into 3 columns", () => {
|
|
const graph = createTestGraph("Test [3COL]");
|
|
layoutSelectedGroups(graph, new Set([1]));
|
|
|
|
// Should have 3 distinct X values (4 nodes into 3 cols = 2,1,1 distribution)
|
|
const xs = new Set(graph._nodes.map((n) => n.pos[0]));
|
|
expect(xs.size).toBe(3);
|
|
|
|
assertFiniteCoordinates(graph);
|
|
});
|
|
|
|
it("lowercase token works", () => {
|
|
const graph = createTestGraph("Test [horizontal]");
|
|
layoutSelectedGroups(graph, new Set([1]));
|
|
|
|
// All nodes should have same Y (single row)
|
|
const ys = graph._nodes.map((n) => n.pos[1]);
|
|
expect(new Set(ys).size).toBe(1);
|
|
|
|
assertFiniteCoordinates(graph);
|
|
});
|
|
|
|
it("ignores DAG structure with token", () => {
|
|
// Create graph with 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 [HORIZONTAL]",
|
|
pos: [50, 50],
|
|
size: [500, 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 [HORIZONTAL], both nodes should have same Y regardless of connection
|
|
const node1 = graph._nodes.find((n) => n.id === 1)!;
|
|
const node2 = graph._nodes.find((n) => n.id === 2)!;
|
|
expect(node1.pos[1]).toBe(node2.pos[1]);
|
|
|
|
assertFiniteCoordinates(graph);
|
|
});
|
|
|
|
it("nested groups respect their own tokens", () => {
|
|
const graph: LGraph = {
|
|
_nodes: [
|
|
// Nodes in outer group (not in inner)
|
|
{
|
|
id: 1,
|
|
type: "Note",
|
|
title: "Outer 1",
|
|
pos: [100, 100],
|
|
size: [100, 100],
|
|
inputs: [],
|
|
outputs: [],
|
|
},
|
|
{
|
|
id: 2,
|
|
type: "Note",
|
|
title: "Outer 2",
|
|
pos: [100, 250],
|
|
size: [100, 100],
|
|
inputs: [],
|
|
outputs: [],
|
|
},
|
|
// Nodes in inner group
|
|
{
|
|
id: 3,
|
|
type: "Note",
|
|
title: "Inner 1",
|
|
pos: [350, 150],
|
|
size: [100, 100],
|
|
inputs: [],
|
|
outputs: [],
|
|
},
|
|
{
|
|
id: 4,
|
|
type: "Note",
|
|
title: "Inner 2",
|
|
pos: [350, 300],
|
|
size: [100, 100],
|
|
inputs: [],
|
|
outputs: [],
|
|
},
|
|
],
|
|
_groups: [
|
|
{
|
|
id: 1,
|
|
title: "Outer [HORIZONTAL]",
|
|
pos: [50, 50],
|
|
size: [600, 500],
|
|
},
|
|
{
|
|
id: 2,
|
|
title: "Inner [VERTICAL]",
|
|
pos: [300, 100],
|
|
size: [200, 350],
|
|
},
|
|
],
|
|
links: new Map(),
|
|
};
|
|
|
|
layoutSelectedGroups(graph, new Set([1]));
|
|
|
|
// Inner group nodes should have same X (vertical)
|
|
const inner1 = graph._nodes.find((n) => n.id === 3)!;
|
|
const inner2 = graph._nodes.find((n) => n.id === 4)!;
|
|
expect(inner1.pos[0]).toBe(inner2.pos[0]);
|
|
|
|
assertFiniteCoordinates(graph);
|
|
});
|
|
|
|
it("resizes group to fit token-arranged contents", () => {
|
|
const graph = createTestGraph("Test [HORIZONTAL]");
|
|
layoutSelectedGroups(graph, new Set([1]));
|
|
|
|
const group = graph._groups.find((g) => g.id === 1)!;
|
|
|
|
// Group should be wider than tall (horizontal layout)
|
|
// With 4 nodes of 200px width + gaps, width should be significant
|
|
expect(group.size[0]).toBeGreaterThan(600);
|
|
|
|
assertFiniteCoordinates(graph);
|
|
});
|
|
});
|
|
|
|
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");
|
|
|
|
// 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);
|
|
});
|
|
});
|
|
});
|