371 lines
11 KiB
TypeScript
371 lines
11 KiB
TypeScript
import { readFileSync } from "node:fs";
|
|
import { describe, expect, it } from "vitest";
|
|
import { getCurrentGraph, getSelectedGroups } from "../../src/runtime";
|
|
import { isGroup } from "../../src/utils";
|
|
import {
|
|
SETTING_IDS,
|
|
SETTINGS_PREFIX,
|
|
EXTENSION_NAME,
|
|
REPOSITORY_URL,
|
|
CURRENT_VERSION,
|
|
DEFAULT_ALGORITHM_OPTIONS,
|
|
} from "../../src/settings";
|
|
import { DEFAULT_FRAMEWORK_CONFIG } from "../../src/layout/types";
|
|
import type { Position, GroupBounds } from "../../src/layout/types";
|
|
import { computeGraphBounds } from "../../src/bounds";
|
|
import type { LayoutInput, LayoutNode } from "../../src/layout/types";
|
|
|
|
describe("isGroup", () => {
|
|
it("identifies a plain group without a type", () => {
|
|
expect(
|
|
isGroup({ id: 1, title: "Group", pos: [0, 0], size: [100, 100] }),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("prefers the LGraphGroup constructor tag when present", () => {
|
|
expect(
|
|
isGroup({
|
|
id: 1,
|
|
title: "Runtime Group",
|
|
pos: [0, 0],
|
|
constructor: { name: "LGraphGroup" },
|
|
type: "UnexpectedNodeType",
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("rejects a node with a string type", () => {
|
|
expect(
|
|
isGroup({
|
|
id: 1,
|
|
title: "Sampler",
|
|
type: "KSampler",
|
|
pos: [0, 0],
|
|
size: [100, 100],
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("rejects a reroute node", () => {
|
|
expect(
|
|
isGroup({
|
|
id: 1,
|
|
title: "Reroute",
|
|
type: "Reroute",
|
|
pos: [0, 0],
|
|
size: [30, 30],
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("treats null and undefined types as groups", () => {
|
|
expect(
|
|
isGroup({ id: 1, title: "Null Type", type: null, pos: [0, 0] }),
|
|
).toBe(true);
|
|
expect(
|
|
isGroup({ id: 2, title: "Undefined Type", type: undefined, pos: [0, 0] }),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("rejects items without a string title", () => {
|
|
expect(isGroup({ id: 1, pos: [0, 0] })).toBe(false);
|
|
expect(
|
|
isGroup({ id: 2, title: undefined, pos: [0, 0] }),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("rejects an empty-string type because runtime nodes use string types", () => {
|
|
expect(
|
|
isGroup({ id: 1, title: "Empty Type", type: "", pos: [0, 0] }),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("getCurrentGraph", () => {
|
|
it("prefers canvas.getCurrentGraph when available", () => {
|
|
const visibleGraph = { _nodes: [], _groups: [], links: new Map() };
|
|
const fallbackCanvasGraph = { _nodes: [1], _groups: [], links: new Map() };
|
|
const fallbackAppGraph = { _nodes: [2], _groups: [], links: new Map() };
|
|
|
|
expect(
|
|
getCurrentGraph({
|
|
canvas: {
|
|
graph: fallbackCanvasGraph,
|
|
getCurrentGraph: () => visibleGraph,
|
|
},
|
|
graph: fallbackAppGraph,
|
|
}),
|
|
).toBe(visibleGraph);
|
|
});
|
|
|
|
it("falls back to canvas.graph, then app.graph", () => {
|
|
const canvasGraph = { _nodes: [], _groups: [], links: new Map() };
|
|
const appGraph = { _nodes: [], _groups: [], links: new Map() };
|
|
|
|
expect(
|
|
getCurrentGraph({
|
|
canvas: { graph: canvasGraph },
|
|
graph: appGraph,
|
|
}),
|
|
).toBe(canvasGraph);
|
|
expect(
|
|
getCurrentGraph({
|
|
canvas: { graph: null },
|
|
graph: appGraph,
|
|
}),
|
|
).toBe(appGraph);
|
|
});
|
|
|
|
it("returns null when no graph is available", () => {
|
|
expect(getCurrentGraph({ canvas: null })).toBeNull();
|
|
expect(getCurrentGraph({})).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("SETTING_IDS", () => {
|
|
it("has all expected setting IDs with correct prefix", () => {
|
|
const expectedKeys = [
|
|
"VERSION",
|
|
"KEYBINDINGS",
|
|
"DEFAULT_ALGORITHM",
|
|
"HORIZONTAL_GAP",
|
|
"VERTICAL_GAP",
|
|
"GROUP_PADDING",
|
|
"DISCONNECTED_GAP",
|
|
"FIT_TO_VIEW",
|
|
"DEBUG_LOGGING",
|
|
] as const;
|
|
|
|
for (const key of expectedKeys) {
|
|
expect(SETTING_IDS[key]).toBeDefined();
|
|
expect(SETTING_IDS[key]).toContain(SETTINGS_PREFIX);
|
|
}
|
|
});
|
|
|
|
it("has unique IDs for all settings", () => {
|
|
const values = Object.values(SETTING_IDS);
|
|
expect(new Set(values).size).toBe(values.length);
|
|
});
|
|
|
|
it("spacing IDs map to correct FrameworkConfig defaults", () => {
|
|
expect(DEFAULT_FRAMEWORK_CONFIG.horizontalGap).toBe(100);
|
|
expect(DEFAULT_FRAMEWORK_CONFIG.verticalGap).toBe(40);
|
|
expect(DEFAULT_FRAMEWORK_CONFIG.groupPadding).toBe(30);
|
|
expect(DEFAULT_FRAMEWORK_CONFIG.disconnectedGap).toBe(150);
|
|
});
|
|
});
|
|
|
|
describe("settings constants", () => {
|
|
const packageVersion = JSON.parse(
|
|
readFileSync(new URL("../../package.json", import.meta.url), "utf-8"),
|
|
) as { version: string };
|
|
const pyproject = readFileSync(
|
|
new URL("../../pyproject.toml", import.meta.url),
|
|
"utf-8",
|
|
);
|
|
const pyprojectVersion =
|
|
pyproject.match(/^version = "(.+)"$/m)?.[1] ?? null;
|
|
const bumpCurrentVersion =
|
|
pyproject.match(/^current_version = "(.+)"$/m)?.[1] ?? null;
|
|
|
|
it("has a valid extension name", () => {
|
|
expect(EXTENSION_NAME).toBe("comfy-node-organizer");
|
|
});
|
|
|
|
it("has a valid repository URL", () => {
|
|
expect(REPOSITORY_URL).toContain("github.com");
|
|
expect(REPOSITORY_URL).toContain("comfyui-node-organizer");
|
|
});
|
|
|
|
it("has a semver version string", () => {
|
|
expect(CURRENT_VERSION).toMatch(/^\d+\.\d+\.\d+$/);
|
|
});
|
|
|
|
it("keeps CURRENT_VERSION in sync with package.json", () => {
|
|
expect(CURRENT_VERSION).toBe(packageVersion.version);
|
|
});
|
|
|
|
it("keeps pyproject version metadata in sync with CURRENT_VERSION", () => {
|
|
expect(pyprojectVersion).toBe(CURRENT_VERSION);
|
|
expect(bumpCurrentVersion).toBe(CURRENT_VERSION);
|
|
});
|
|
|
|
it("includes sugiyama, horizontal, and vertical algorithms", () => {
|
|
expect(DEFAULT_ALGORITHM_OPTIONS).toContain("sugiyama");
|
|
expect(DEFAULT_ALGORITHM_OPTIONS).toContain("horizontal");
|
|
expect(DEFAULT_ALGORITHM_OPTIONS).toContain("vertical");
|
|
expect(DEFAULT_ALGORITHM_OPTIONS.length).toBe(3);
|
|
});
|
|
});
|
|
|
|
describe("createConfiguredDefaultAlgorithm", () => {
|
|
it("passes spacing config into default algorithm factories", async () => {
|
|
const globalScope = globalThis as typeof globalThis & {
|
|
app?: {
|
|
registerExtension: (extension: unknown) => void;
|
|
extensionManager?: { setting?: { get: <T>(_id: string) => T | undefined } };
|
|
canvas?: null;
|
|
};
|
|
};
|
|
const originalApp = globalScope.app;
|
|
|
|
globalScope.app = {
|
|
registerExtension: () => undefined,
|
|
extensionManager: {
|
|
setting: {
|
|
get: () => undefined,
|
|
},
|
|
},
|
|
canvas: null,
|
|
};
|
|
|
|
try {
|
|
const { createConfiguredDefaultAlgorithm } = await import("../../src/index");
|
|
|
|
const twoNodes: LayoutNode[] = [
|
|
{ id: "a", width: 10, height: 10 },
|
|
{ id: "b", width: 10, height: 10 },
|
|
];
|
|
const verticalPair: LayoutInput = {
|
|
nodes: twoNodes,
|
|
edges: [],
|
|
};
|
|
const sugiyamaInput: LayoutInput = {
|
|
nodes: [
|
|
{ id: "left-a", width: 10, height: 10 },
|
|
{ id: "left-b", width: 10, height: 10 },
|
|
{ id: "right", width: 10, height: 10 },
|
|
],
|
|
edges: [
|
|
{ source: "left-a", target: "right" },
|
|
{ source: "left-b", target: "right" },
|
|
],
|
|
};
|
|
|
|
const horizontal = createConfiguredDefaultAlgorithm("horizontal", {
|
|
horizontalGap: 123,
|
|
});
|
|
expect(horizontal.layout(verticalPair).positions.get("b")?.x).toBe(133);
|
|
|
|
const vertical = createConfiguredDefaultAlgorithm("vertical", {
|
|
verticalGap: 77,
|
|
});
|
|
expect(vertical.layout(verticalPair).positions.get("b")?.y).toBe(87);
|
|
|
|
const sugiyama = createConfiguredDefaultAlgorithm("sugiyama", {
|
|
horizontalGap: 123,
|
|
verticalGap: 77,
|
|
});
|
|
const sugiyamaPositions = sugiyama.layout(sugiyamaInput).positions;
|
|
expect(sugiyamaPositions.get("right")?.x).toBe(133);
|
|
expect(sugiyamaPositions.get("left-b")?.y).toBe(87);
|
|
} finally {
|
|
if (originalApp === undefined) {
|
|
delete globalScope.app;
|
|
} else {
|
|
globalScope.app = originalApp;
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("getSelectedGroups", () => {
|
|
it("returns only selected groups", () => {
|
|
const selectedItems = [
|
|
{ id: 100, title: "Group A", pos: [0, 0] as [number, number] },
|
|
{ id: 1, title: "Sampler", type: "KSampler", pos: [10, 10] as [number, number] },
|
|
{ id: 101, title: "Group B", pos: [20, 20] as [number, number] },
|
|
];
|
|
|
|
expect(getSelectedGroups({ graph: null, selectedItems })).toEqual([
|
|
{ id: 100, title: "Group A" },
|
|
{ id: 101, title: "Group B" },
|
|
]);
|
|
});
|
|
|
|
it("returns an empty array when nothing group-like is selected", () => {
|
|
expect(getSelectedGroups(undefined)).toEqual([]);
|
|
expect(getSelectedGroups({ graph: null })).toEqual([]);
|
|
expect(
|
|
getSelectedGroups({
|
|
graph: null,
|
|
selectedItems: [{ id: 1, title: "Node", type: "KSampler", pos: [0, 0] }],
|
|
}),
|
|
).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("computeGraphBounds", () => {
|
|
const makeNode = (id: number, x: number, y: number, w: number, h: number) =>
|
|
({ id, pos: [x, y], size: [w, h] });
|
|
|
|
it("computes bounds from regular nodes", () => {
|
|
const nodes = [makeNode(1, 0, 0, 100, 50), makeNode(2, 200, 100, 100, 50)];
|
|
const positions = new Map<string, Position>([
|
|
["1", { x: 0, y: 0 }],
|
|
["2", { x: 200, y: 100 }],
|
|
]);
|
|
|
|
const result = computeGraphBounds(nodes, undefined, undefined, positions, new Map());
|
|
expect(result).toEqual([0, 0, 300, 150]);
|
|
});
|
|
|
|
it("returns null when no items have positions", () => {
|
|
const result = computeGraphBounds([], undefined, undefined, new Map(), new Map());
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("includes inputNode in bounds", () => {
|
|
const nodes = [makeNode(1, 100, 50, 100, 50)];
|
|
const inputNode = makeNode(-10, -200, 0, 50, 30);
|
|
const positions = new Map<string, Position>([
|
|
["1", { x: 100, y: 50 }],
|
|
["-10", { x: -200, y: 0 }],
|
|
]);
|
|
|
|
const result = computeGraphBounds(nodes, inputNode, undefined, positions, new Map());
|
|
// Without inputNode: bounds would be [100, 50, 100, 50]
|
|
// With inputNode: bounds should extend left to -200
|
|
expect(result).toEqual([-200, 0, 400, 100]);
|
|
});
|
|
|
|
it("includes outputNode in bounds", () => {
|
|
const nodes = [makeNode(1, 0, 0, 100, 50)];
|
|
const outputNode = makeNode(-20, 500, 0, 50, 30);
|
|
const positions = new Map<string, Position>([
|
|
["1", { x: 0, y: 0 }],
|
|
["-20", { x: 500, y: 0 }],
|
|
]);
|
|
|
|
const result = computeGraphBounds(nodes, undefined, outputNode, positions, new Map());
|
|
// Without outputNode: bounds would be [0, 0, 100, 50]
|
|
// With outputNode: bounds should extend right to 550
|
|
expect(result).toEqual([0, 0, 550, 50]);
|
|
});
|
|
|
|
it("includes both boundary nodes in subgraph scenario", () => {
|
|
const nodes = [makeNode(1, 100, 50, 100, 50)];
|
|
const inputNode = makeNode(-10, -200, 0, 50, 30);
|
|
const outputNode = makeNode(-20, 500, 0, 50, 30);
|
|
const positions = new Map<string, Position>([
|
|
["1", { x: 100, y: 50 }],
|
|
["-10", { x: -200, y: 0 }],
|
|
["-20", { x: 500, y: 0 }],
|
|
]);
|
|
|
|
const result = computeGraphBounds(nodes, inputNode, outputNode, positions, new Map());
|
|
expect(result).toEqual([-200, 0, 750, 100]);
|
|
});
|
|
|
|
it("includes group bounds", () => {
|
|
const nodes = [makeNode(1, 50, 50, 100, 50)];
|
|
const positions = new Map<string, Position>([["1", { x: 50, y: 50 }]]);
|
|
const groupBounds = new Map<string, GroupBounds>([
|
|
["group:1", { x: 0, y: 0, width: 300, height: 200 }],
|
|
]);
|
|
|
|
const result = computeGraphBounds(nodes, undefined, undefined, positions, groupBounds);
|
|
expect(result).toEqual([0, 0, 300, 200]);
|
|
});
|
|
});
|