Bundle the pure core API for Node consumers (#6)

* feat: extract pure core api

* refactor: centralize core api helpers

* build: bundle core api output
This commit is contained in:
PBandDev
2026-03-24 12:41:54 -05:00
committed by GitHub
parent c950a3a274
commit 2d094554f7
20 changed files with 861 additions and 127 deletions
+2 -1
View File
@@ -18,4 +18,5 @@ node.zip
.changeset/
.test-comfy/
test-results/
playwright-report/
playwright-report/
lib/
+4
View File
@@ -12,6 +12,8 @@ Use `.changeset/plans/` only when prior design context is needed.
```bash
pnpm install
pnpm build
pnpm build:lib
pnpm test:lib
pnpm typecheck
pnpm test
pnpm setup:e2e
@@ -30,6 +32,8 @@ Version bumping:
- Keep `.python-version` and `uv.lock` in sync with the publish workflow.
- Local `.changeset/*` files are intentionally gitignored. Do not commit them.
- Verify runtime behavior in ComfyUI before changing adapter logic or making claims about ComfyUI APIs.
- `src/core.ts` is the public library entrypoint. It must stay free of ComfyUI-specific imports such as `index.ts`, `adapter.ts`, `runtime.ts`, `settings.ts`, `debug.ts`, `bounds.ts`, or `utils.ts`.
- Library output is emitted to `lib/` by `pnpm build:lib` / `tsc -p tsconfig.lib.json`. The `prepare` script runs that build automatically for GitHub dependency consumers.
## Runtime Facts
+17
View File
@@ -52,12 +52,29 @@ Add tokens to group titles to control how nodes are arranged:
- `pnpm typecheck`
- `pnpm test`
- `pnpm build`
- `pnpm build:lib` emits the pure library entrypoint to `lib/`
- `pnpm test:lib` smoke-tests the built `comfyui-node-organizer/core` export
- `pnpm setup:e2e` provisions a dedicated ComfyUI instance for browser tests and installs the exact workflow-template package required by the pinned ComfyUI checkout
- `pnpm test:e2e` runs the full Playwright suite against that instance
- Broad installed-template coverage is discovered live at E2E runtime from the pinned test ComfyUI environment, with one correctness-invariant test per installed workflow template
Checked-in repo fixtures live in `tests/fixtures/`. Visual regression is graph-canvas scoped and runs as part of the normal E2E suite.
Visual regression baselines are platform-specific: committed snapshots use `-win32` and `-linux` suffixes.
## Library API
Most users can ignore this section. The extension works entirely inside ComfyUI.
`comfyui-node-organizer/core` exists for developers who want to reuse the layout engine outside ComfyUI, for example in a script, service, test harness, or workflow conversion tool.
It exposes a small pure API:
- `normalizeWorkflowGeometry(...)` lays out plain workflow data and returns absolute node and group rectangles
- `inferGroupMembership(...)` infers direct group membership from node and group rectangles
In practice: if you want "organize this workflow JSON the same way the extension would" without loading ComfyUI, this is the entrypoint.
Build it with `pnpm build:lib`. `pnpm test:lib` verifies that the built `comfyui-node-organizer/core` package can be imported by Node.
### Local CI
Install [act](https://github.com/nektos/act) and Docker, then run:
+12
View File
@@ -3,11 +3,23 @@
"version": "2.0.0",
"description": "ComfyUI extension to organize nodes",
"type": "module",
"exports": {
".": {
"import": "./dist/index.js"
},
"./core": {
"import": "./lib/core.js",
"types": "./lib/core.d.ts"
}
},
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"build:lib": "vite build -c vite.config.lib.ts && tsc -p tsconfig.lib.json",
"test:lib": "pnpm build:lib && node scripts/smoke-core-package.mjs",
"watch": "tsc && vite build --watch",
"typecheck": "tsc --noEmit",
"prepare": "pnpm build:lib",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
+7
View File
@@ -0,0 +1,7 @@
import assert from "node:assert/strict";
const coreModule = await import("comfyui-node-organizer/core");
assert.equal(typeof coreModule.inferGroupMembership, "function");
assert.equal(typeof coreModule.normalizeWorkflowGeometry, "function");
assert.equal(typeof coreModule.DEFAULT_FRAMEWORK_CONFIG, "object");
+39 -86
View File
@@ -17,7 +17,14 @@ import type {
} from "./layout/types";
import { parseLayoutToken } from "./layout/tokens";
import { debugLog } from "./debug";
import { isGroupInsideGroup, isNodeCenterInsideGroup } from "./group-geometry";
import {
inferGroupMembership,
type Rect as MembershipRect,
} from "./group-membership";
import {
fromNumericGroupLayoutId,
toGroupLayoutId,
} from "./layout/group-ids";
// ---------------------------------------------------------------------------
// Structural types for ComfyUI runtime objects
@@ -63,16 +70,6 @@ export interface GraphLike {
setDirtyCanvas?(fg: boolean, bg: boolean): void;
}
function toGroupLayoutId(groupId: number): string {
return `group:${groupId}`;
}
function fromGroupLayoutId(groupId: string): number | null {
if (!groupId.startsWith("group:")) return null;
const parsed = Number(groupId.slice("group:".length));
return Number.isFinite(parsed) ? parsed : null;
}
// ---------------------------------------------------------------------------
// Extract layout input from a ComfyUI graph
// ---------------------------------------------------------------------------
@@ -142,83 +139,39 @@ function buildGroupsFromGraph(graph: GraphLike): LayoutGroup[] {
const graphGroups = graph._groups;
if (graphGroups.length === 0) return [];
const graphGroupById = new Map<number, GraphGroup>();
for (const group of graphGroups) {
graphGroupById.set(group.id, group);
}
const nodeRects: MembershipRect[] = graph._nodes.map((node) => ({
id: String(node.id),
x: Number(node.pos[0]),
y: Number(node.pos[1]),
width: Number(node.size[0]),
height: Number(node.size[1]),
}));
const groupRects: MembershipRect[] = graphGroups.map((group) => ({
id: String(group.id),
x: Number(group.pos[0]),
y: Number(group.pos[1]),
width: Number(group.size[0]),
height: Number(group.size[1]),
}));
const memberships = inferGroupMembership(nodeRects, groupRects);
const membershipsByGroupId = new Map(
memberships.map((membership) => [membership.groupId, membership]),
);
const childGroupIds = new Map<number, number[]>();
const groupAreas = new Map<number, number>();
for (const group of graphGroups) {
groupAreas.set(group.id, Number(group.size[0]) * Number(group.size[1]));
}
return graphGroups.map((group) => {
const membership = membershipsByGroupId.get(String(group.id));
const token: LayoutToken | null = parseLayoutToken(group.title);
// Determine the nearest containing parent for each group.
for (const inner of graphGroups) {
let nearestParent: GraphGroup | null = null;
let nearestArea = Infinity;
for (const outer of graphGroups) {
if (outer.id === inner.id) continue;
if (!isGroupInside(inner, outer)) continue;
const outerArea = groupAreas.get(outer.id) ?? Infinity;
if (outerArea < nearestArea) {
nearestParent = outer;
nearestArea = outerArea;
}
}
if (nearestParent) {
const children = childGroupIds.get(nearestParent.id) ?? [];
children.push(inner.id);
childGroupIds.set(nearestParent.id, children);
}
}
// Build the LayoutGroup array
const result: LayoutGroup[] = [];
for (const g of graphGroups) {
const memberIds: string[] = [];
for (const node of graph._nodes) {
if (!isNodeInsideGroup(node, g)) continue;
let insideDirectChild = false;
for (const childGroupId of childGroupIds.get(g.id) ?? []) {
const childGroup = graphGroupById.get(childGroupId);
if (childGroup && isNodeInsideGroup(node, childGroup)) {
insideDirectChild = true;
break;
}
}
if (!insideDirectChild) {
memberIds.push(String(node.id));
}
}
const token: LayoutToken | null = parseLayoutToken(g.title);
result.push({
id: toGroupLayoutId(g.id),
title: g.title,
memberIds,
childGroupIds: (childGroupIds.get(g.id) ?? []).map(toGroupLayoutId),
return {
id: toGroupLayoutId(group.id),
title: group.title,
memberIds: membership?.nodeIds ?? [],
childGroupIds: (membership?.childGroupIds ?? []).map((childGroupId) =>
toGroupLayoutId(Number(childGroupId)),
),
...(token ? { token } : {}),
});
}
return result;
}
/** Check if inner group is spatially inside outer group */
function isGroupInside(inner: GraphGroup, outer: GraphGroup): boolean {
return isGroupInsideGroup(inner, outer);
}
function isNodeInsideGroup(node: GraphNode, group: GraphGroup): boolean {
return isNodeCenterInsideGroup(node, group);
};
});
}
// ---------------------------------------------------------------------------
@@ -395,7 +348,7 @@ export function anchorSelectedGroupLayoutResult(
);
for (const rootGroupId of selectedRootIds) {
const originalGraphGroupId = fromGroupLayoutId(rootGroupId);
const originalGraphGroupId = fromNumericGroupLayoutId(rootGroupId);
if (originalGraphGroupId === null) continue;
const originalGroup = graphGroupById.get(originalGraphGroupId);
+167
View File
@@ -0,0 +1,167 @@
import {
createLayoutAlgorithm,
type LayoutAlgorithmName,
} from "./layout/algorithm-factory";
import { layoutWithGroups } from "./layout/framework";
import { toGroupLayoutId } from "./layout/group-ids";
import { parseLayoutToken } from "./layout/tokens";
import {
DEFAULT_FRAMEWORK_CONFIG,
type FrameworkConfig,
type LayoutEdge as InternalLayoutEdge,
type LayoutGroup as InternalLayoutGroup,
type LayoutNode as InternalLayoutNode,
} from "./layout/types";
export {
inferGroupMembership,
type GroupMembership,
type Rect,
} from "./group-membership";
export interface WorkflowNode {
readonly id: string;
readonly width: number;
readonly height: number;
readonly kind?: "node" | "subgraph-input" | "subgraph-output";
}
export interface WorkflowEdge {
readonly source: string;
readonly target: string;
}
export interface WorkflowGroup {
readonly id: string;
readonly title: string;
readonly memberIds: ReadonlyArray<string>;
readonly childGroupIds: ReadonlyArray<string>;
}
export interface WorkflowRect {
readonly id: string;
readonly x: number;
readonly y: number;
readonly width: number;
readonly height: number;
}
export interface WorkflowMembership {
readonly groupId: string;
readonly nodeIds: string[];
readonly childGroupIds: string[];
}
export interface NormalizeWorkflowResult {
readonly nodes: WorkflowRect[];
readonly groups: WorkflowRect[];
readonly memberships: WorkflowMembership[];
}
export interface NormalizeWorkflowOptions {
readonly algorithm?: LayoutAlgorithmName;
readonly config?: Partial<FrameworkConfig>;
}
export { DEFAULT_FRAMEWORK_CONFIG, type FrameworkConfig } from "./layout/types";
export function normalizeWorkflowGeometry(
input: {
readonly nodes: ReadonlyArray<WorkflowNode>;
readonly edges: ReadonlyArray<WorkflowEdge>;
readonly groups: ReadonlyArray<WorkflowGroup>;
},
options?: NormalizeWorkflowOptions,
): NormalizeWorkflowResult {
const config: FrameworkConfig = {
...DEFAULT_FRAMEWORK_CONFIG,
...options?.config,
};
const algorithm = createLayoutAlgorithm(
options?.algorithm ?? "sugiyama",
config,
);
const nodes: InternalLayoutNode[] = input.nodes.map((node) => {
if (node.kind === "subgraph-input") {
return {
id: node.id,
width: node.width,
height: node.height,
kind: node.kind,
layerConstraint: "first",
};
}
if (node.kind === "subgraph-output") {
return {
id: node.id,
width: node.width,
height: node.height,
kind: node.kind,
layerConstraint: "last",
};
}
return {
id: node.id,
width: node.width,
height: node.height,
};
});
const edges: InternalLayoutEdge[] = input.edges.map((edge) => ({
source: edge.source,
target: edge.target,
}));
const groups: InternalLayoutGroup[] = input.groups.map((group) => {
const token = parseLayoutToken(group.title);
return {
id: toGroupLayoutId(group.id),
title: group.title,
memberIds: [...group.memberIds],
childGroupIds: group.childGroupIds.map(toGroupLayoutId),
...(token ? { token } : {}),
};
});
const result = layoutWithGroups(nodes, edges, groups, algorithm, config);
return {
nodes: input.nodes.flatMap((node) => {
const position = result.positions.get(node.id);
return position
? [
{
id: node.id,
x: position.x,
y: position.y,
width: node.width,
height: node.height,
},
]
: [];
}),
groups: input.groups.flatMap((group) => {
const bounds = result.groupBounds.get(toGroupLayoutId(group.id));
return bounds
? [
{
id: group.id,
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
},
]
: [];
}),
memberships: input.groups.map((group) => ({
groupId: group.id,
nodeIds: [...group.memberIds],
childGroupIds: [...group.childGroupIds],
})),
};
}
+42 -24
View File
@@ -3,41 +3,59 @@ export interface PositionedItem {
readonly size: ArrayLike<number>;
}
export interface RectGeometry {
readonly x: number;
readonly y: number;
readonly width: number;
readonly height: number;
}
export function isGroupInsideGroup(
inner: PositionedItem,
outer: PositionedItem,
): boolean {
const ix = Number(inner.pos[0]);
const iy = Number(inner.pos[1]);
const iw = Number(inner.size[0]);
const ih = Number(inner.size[1]);
const ox = Number(outer.pos[0]);
const oy = Number(outer.pos[1]);
const ow = Number(outer.size[0]);
const oh = Number(outer.size[1]);
return (
ix >= ox && iy >= oy && ix + iw <= ox + ow && iy + ih <= oy + oh
);
return isRectInsideRect(toRectGeometry(inner), toRectGeometry(outer));
}
export function isNodeCenterInsideGroup(
node: PositionedItem,
group: PositionedItem,
): boolean {
const centerX = Number(node.pos[0]) + Number(node.size[0]) / 2;
const centerY = Number(node.pos[1]) + Number(node.size[1]) / 2;
const gx = Number(group.pos[0]);
const gy = Number(group.pos[1]);
const gw = Number(group.size[0]);
const gh = Number(group.size[1]);
return isCenterInsideRect(toRectGeometry(node), toRectGeometry(group));
}
export function isRectInsideRect(
inner: RectGeometry,
outer: RectGeometry,
): boolean {
return (
centerX >= gx &&
centerX <= gx + gw &&
centerY >= gy &&
centerY <= gy + gh
inner.x >= outer.x &&
inner.y >= outer.y &&
inner.x + inner.width <= outer.x + outer.width &&
inner.y + inner.height <= outer.y + outer.height
);
}
export function isCenterInsideRect(
item: RectGeometry,
container: RectGeometry,
): boolean {
const centerX = item.x + item.width / 2;
const centerY = item.y + item.height / 2;
return (
centerX >= container.x &&
centerX <= container.x + container.width &&
centerY >= container.y &&
centerY <= container.y + container.height
);
}
function toRectGeometry(item: PositionedItem): RectGeometry {
return {
x: Number(item.pos[0]),
y: Number(item.pos[1]),
width: Number(item.size[0]),
height: Number(item.size[1]),
};
}
+88
View File
@@ -0,0 +1,88 @@
import {
isCenterInsideRect,
isRectInsideRect,
} from "./group-geometry";
export interface Rect {
readonly id: string;
readonly x: number;
readonly y: number;
readonly width: number;
readonly height: number;
}
export interface GroupMembership {
readonly groupId: string;
readonly nodeIds: string[];
readonly childGroupIds: string[];
}
export function inferGroupMembership(
nodes: ReadonlyArray<Rect>,
groups: ReadonlyArray<Rect>,
): GroupMembership[] {
if (groups.length === 0) {
return [];
}
const groupById = new Map<string, Rect>();
const groupAreas = new Map<string, number>();
for (const group of groups) {
groupById.set(group.id, group);
groupAreas.set(group.id, group.width * group.height);
}
const childGroupIds = new Map<string, string[]>();
for (const inner of groups) {
let nearestParent: Rect | null = null;
let nearestArea = Number.POSITIVE_INFINITY;
for (const outer of groups) {
if (outer.id === inner.id || !isRectInsideRect(inner, outer)) {
continue;
}
const outerArea = groupAreas.get(outer.id) ?? Number.POSITIVE_INFINITY;
if (outerArea < nearestArea) {
nearestParent = outer;
nearestArea = outerArea;
}
}
if (nearestParent) {
const children = childGroupIds.get(nearestParent.id) ?? [];
children.push(inner.id);
childGroupIds.set(nearestParent.id, children);
}
}
return groups.map((group) => {
const directChildIds = childGroupIds.get(group.id) ?? [];
const nodeIds: string[] = [];
for (const node of nodes) {
if (!isCenterInsideRect(node, group)) {
continue;
}
let insideDirectChild = false;
for (const childId of directChildIds) {
const childGroup = groupById.get(childId);
if (childGroup && isCenterInsideRect(node, childGroup)) {
insideDirectChild = true;
break;
}
}
if (!insideDirectChild) {
nodeIds.push(node.id);
}
}
return {
groupId: group.id,
nodeIds,
childGroupIds: [...directChildIds],
};
});
}
+10 -16
View File
@@ -6,6 +6,10 @@
*/
import type { ComfyApp } from "@comfyorg/comfyui-frontend-types";
import {
createLayoutAlgorithm,
type LayoutAlgorithmName,
} from "./layout/algorithm-factory";
import {
SETTING_IDS,
EXTENSION_NAME,
@@ -24,9 +28,6 @@ import {
} from "./adapter";
import type { GraphLike } from "./adapter";
import { layoutWithGroups } from "./layout/framework";
import { createSugiyamaAlgorithm } from "./layout/algorithms/sugiyama";
import { createHorizontalAlgorithm } from "./layout/algorithms/horizontal";
import { createVerticalAlgorithm } from "./layout/algorithms/vertical";
import type { LayoutAlgorithm, FrameworkConfig } from "./layout/types";
import { computeGraphBounds } from "./bounds";
import { DEFAULT_FRAMEWORK_CONFIG } from "./layout/types";
@@ -39,18 +40,6 @@ declare global {
}
const DEFAULT_ALGORITHM_NAME: DefaultAlgorithmName = "sugiyama";
const algorithmFactories: Record<
DefaultAlgorithmName,
(config: Partial<FrameworkConfig>) => LayoutAlgorithm
> = {
sugiyama: (config) =>
createSugiyamaAlgorithm({
horizontalGap: config.horizontalGap,
verticalGap: config.verticalGap,
}),
horizontal: (config) => createHorizontalAlgorithm(config.horizontalGap),
vertical: (config) => createVerticalAlgorithm(config.verticalGap),
};
// ---------------------------------------------------------------------------
// Helpers
@@ -70,7 +59,12 @@ export function createConfiguredDefaultAlgorithm(
algorithmName: DefaultAlgorithmName,
config: Partial<FrameworkConfig>,
): LayoutAlgorithm {
return algorithmFactories[algorithmName](config);
return createLayoutAlgorithm(algorithmName as LayoutAlgorithmName, {
horizontalGap:
config.horizontalGap ?? DEFAULT_FRAMEWORK_CONFIG.horizontalGap,
verticalGap:
config.verticalGap ?? DEFAULT_FRAMEWORK_CONFIG.verticalGap,
});
}
function getDefaultAlgorithm(config: Partial<FrameworkConfig>): LayoutAlgorithm {
+23
View File
@@ -0,0 +1,23 @@
import { createHorizontalAlgorithm } from "./algorithms/horizontal";
import { createSugiyamaAlgorithm } from "./algorithms/sugiyama";
import { createVerticalAlgorithm } from "./algorithms/vertical";
import type { FrameworkConfig, LayoutAlgorithm } from "./types";
export type LayoutAlgorithmName = "sugiyama" | "horizontal" | "vertical";
export function createLayoutAlgorithm(
algorithmName: LayoutAlgorithmName,
config: Pick<FrameworkConfig, "horizontalGap" | "verticalGap">,
): LayoutAlgorithm {
switch (algorithmName) {
case "horizontal":
return createHorizontalAlgorithm(config.horizontalGap);
case "vertical":
return createVerticalAlgorithm(config.verticalGap);
case "sugiyama":
return createSugiyamaAlgorithm({
horizontalGap: config.horizontalGap,
verticalGap: config.verticalGap,
});
}
}
+23
View File
@@ -0,0 +1,23 @@
const GROUP_LAYOUT_ID_PREFIX = "group:";
export function toGroupLayoutId(groupId: string | number): string {
return `${GROUP_LAYOUT_ID_PREFIX}${groupId}`;
}
export function fromGroupLayoutId(groupId: string): string | null {
if (!groupId.startsWith(GROUP_LAYOUT_ID_PREFIX)) {
return null;
}
return groupId.slice(GROUP_LAYOUT_ID_PREFIX.length);
}
export function fromNumericGroupLayoutId(groupId: string): number | null {
const rawGroupId = fromGroupLayoutId(groupId);
if (rawGroupId === null) {
return null;
}
const parsed = Number(rawGroupId);
return Number.isFinite(parsed) ? parsed : null;
}
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { createLayoutAlgorithm } from "../../src/layout/algorithm-factory";
import type { LayoutInput, LayoutNode } from "../../src/layout/types";
describe("createLayoutAlgorithm", () => {
it("creates a horizontal algorithm using horizontalGap", () => {
const algorithm = createLayoutAlgorithm("horizontal", {
horizontalGap: 123,
verticalGap: 77,
});
const positions = algorithm.layout({
nodes: [
{ id: "a", width: 10, height: 10 },
{ id: "b", width: 10, height: 10 },
],
edges: [],
}).positions;
expect(algorithm.name).toBe("horizontal");
expect(positions.get("b")?.x).toBe(133);
});
it("creates a vertical algorithm using verticalGap", () => {
const algorithm = createLayoutAlgorithm("vertical", {
horizontalGap: 123,
verticalGap: 77,
});
const positions = algorithm.layout({
nodes: [
{ id: "a", width: 10, height: 10 },
{ id: "b", width: 10, height: 10 },
],
edges: [],
}).positions;
expect(algorithm.name).toBe("vertical");
expect(positions.get("b")?.y).toBe(87);
});
it("creates a sugiyama algorithm using both spacing values", () => {
const algorithm = createLayoutAlgorithm("sugiyama", {
horizontalGap: 123,
verticalGap: 77,
});
const input: LayoutInput = {
nodes: [
{ id: "left-a", width: 10, height: 10 },
{ id: "left-b", width: 10, height: 10 },
{ id: "right", width: 10, height: 10 },
] satisfies LayoutNode[],
edges: [
{ source: "left-a", target: "right" },
{ source: "left-b", target: "right" },
],
};
const positions = algorithm.layout(input).positions;
expect(algorithm.name).toBe("sugiyama");
expect(positions.get("right")?.x).toBe(133);
expect(positions.get("left-b")?.y).toBe(87);
});
});
+18
View File
@@ -0,0 +1,18 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const sourceFiles = [
new URL("../../src/core.ts", import.meta.url),
new URL("../../src/group-membership.ts", import.meta.url),
new URL("../../src/group-geometry.ts", import.meta.url),
];
describe("pure core source imports", () => {
it("keeps relative TypeScript imports extensionless", () => {
for (const fileUrl of sourceFiles) {
const source = readFileSync(fileUrl, "utf-8");
expect(source).not.toMatch(/from "\.\/.*\.js"/);
expect(source).not.toMatch(/from "\.\.\/.*\.js"/);
}
});
});
+168
View File
@@ -0,0 +1,168 @@
import { describe, expect, it } from "vitest";
import { inferGroupMembership, normalizeWorkflowGeometry } from "../../src/core";
function layoutNode(
id: string,
width = 100,
height = 60,
): { id: string; width: number; height: number } {
return { id, width, height };
}
function layoutEdge(source: string, target: string): { source: string; target: string } {
return { source, target };
}
describe("normalizeWorkflowGeometry", () => {
it("lays out a single node", () => {
const result = normalizeWorkflowGeometry({
nodes: [layoutNode("A")],
edges: [],
groups: [],
});
expect(result.nodes).toHaveLength(1);
expect(result.nodes[0]).toMatchObject({
id: "A",
width: 100,
height: 60,
});
expect(Number.isFinite(result.nodes[0]?.x)).toBe(true);
expect(Number.isFinite(result.nodes[0]?.y)).toBe(true);
expect(result.groups).toEqual([]);
expect(result.memberships).toEqual([]);
});
it("lays out a chain", () => {
const result = normalizeWorkflowGeometry({
nodes: [layoutNode("A"), layoutNode("B"), layoutNode("C")],
edges: [layoutEdge("A", "B"), layoutEdge("B", "C")],
groups: [],
});
expect(result.nodes).toHaveLength(3);
for (const node of result.nodes) {
expect(Number.isFinite(node.x)).toBe(true);
expect(Number.isFinite(node.y)).toBe(true);
}
});
it("handles groups with members", () => {
const result = normalizeWorkflowGeometry({
nodes: [layoutNode("n1"), layoutNode("n2"), layoutNode("n3")],
edges: [layoutEdge("n1", "n2")],
groups: [
{
id: "g1",
title: "My Group",
memberIds: ["n1", "n2"],
childGroupIds: [],
},
],
});
expect(result.groups).toHaveLength(1);
expect(result.groups[0]?.id).toBe("g1");
expect(result.memberships).toEqual([
{ groupId: "g1", nodeIds: ["n1", "n2"], childGroupIds: [] },
]);
expect((result.groups[0]?.width ?? 0) > 0).toBe(true);
expect((result.groups[0]?.height ?? 0) > 0).toBe(true);
});
it("parses layout tokens from group titles automatically", () => {
const result = normalizeWorkflowGeometry({
nodes: [layoutNode("n1"), layoutNode("n2"), layoutNode("n3")],
edges: [],
groups: [
{
id: "g1",
title: "Row Group [HORIZONTAL]",
memberIds: ["n1", "n2", "n3"],
childGroupIds: [],
},
],
});
const groupNodes = result.nodes.filter((node) => ["n1", "n2", "n3"].includes(node.id));
expect(new Set(groupNodes.map((node) => node.y)).size).toBe(1);
});
it("accepts an algorithm option", () => {
const input = {
nodes: [layoutNode("A"), layoutNode("B")],
edges: [layoutEdge("A", "B")],
groups: [],
};
expect(normalizeWorkflowGeometry(input, { algorithm: "sugiyama" }).nodes).toHaveLength(2);
expect(normalizeWorkflowGeometry(input, { algorithm: "horizontal" }).nodes).toHaveLength(2);
expect(normalizeWorkflowGeometry(input, { algorithm: "vertical" }).nodes).toHaveLength(2);
});
it("returns deterministic results", () => {
const input = {
nodes: [layoutNode("A"), layoutNode("B"), layoutNode("C")],
edges: [layoutEdge("A", "B"), layoutEdge("B", "C")],
groups: [],
};
expect(normalizeWorkflowGeometry(input)).toEqual(normalizeWorkflowGeometry(input));
});
it("returns a JSON-serializable result", () => {
const result = normalizeWorkflowGeometry({
nodes: [layoutNode("A"), layoutNode("B")],
edges: [layoutEdge("A", "B")],
groups: [
{
id: "g1",
title: "Group",
memberIds: ["A", "B"],
childGroupIds: [],
},
],
});
expect(JSON.parse(JSON.stringify(result))).toEqual(result);
});
it("handles subgraph boundary nodes", () => {
const result = normalizeWorkflowGeometry({
nodes: [
layoutNode("A"),
{ id: "in", width: 80, height: 40, kind: "subgraph-input" as const },
{ id: "out", width: 80, height: 40, kind: "subgraph-output" as const },
],
edges: [layoutEdge("in", "A"), layoutEdge("A", "out")],
groups: [],
});
expect(result.nodes.map((node) => node.id).sort()).toEqual(["A", "in", "out"]);
});
it("handles empty input", () => {
expect(
normalizeWorkflowGeometry({
nodes: [],
edges: [],
groups: [],
}),
).toEqual({
nodes: [],
groups: [],
memberships: [],
});
});
});
describe("core re-exports inferGroupMembership", () => {
it("is callable from the core entrypoint", () => {
expect(
inferGroupMembership(
[{ id: "n1", x: 10, y: 10, width: 50, height: 50 }],
[{ id: "g1", x: 0, y: 0, width: 200, height: 200 }],
),
).toEqual([{ groupId: "g1", nodeIds: ["n1"], childGroupIds: [] }]);
});
});
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import {
fromGroupLayoutId,
fromNumericGroupLayoutId,
toGroupLayoutId,
} from "../../src/layout/group-ids";
describe("group layout ids", () => {
it("namespaces string and numeric ids consistently", () => {
expect(toGroupLayoutId("abc")).toBe("group:abc");
expect(toGroupLayoutId(42)).toBe("group:42");
});
it("parses raw ids back out of namespaced ids", () => {
expect(fromGroupLayoutId("group:abc")).toBe("abc");
expect(fromGroupLayoutId("node:abc")).toBeNull();
});
it("parses numeric group ids when they are valid numbers", () => {
expect(fromNumericGroupLayoutId("group:42")).toBe(42);
expect(fromNumericGroupLayoutId("group:abc")).toBeNull();
});
});
+95
View File
@@ -0,0 +1,95 @@
import { describe, expect, it } from "vitest";
import {
inferGroupMembership,
type GroupMembership,
type Rect,
} from "../../src/group-membership";
function rect(id: string, x: number, y: number, width: number, height: number): Rect {
return { id, x, y, width, height };
}
function membership(groupId: string, nodeIds: string[], childGroupIds: string[]): GroupMembership {
return { groupId, nodeIds, childGroupIds };
}
describe("inferGroupMembership", () => {
it("returns empty array when no groups", () => {
const nodes = [rect("n1", 0, 0, 100, 60)];
expect(inferGroupMembership(nodes, [])).toEqual([]);
});
it("assigns node to group via center-point containment", () => {
const nodes = [rect("n1", 0, 0, 100, 60)];
const groups = [rect("g1", 0, 0, 200, 200)];
expect(inferGroupMembership(nodes, groups)).toEqual([
membership("g1", ["n1"], []),
]);
});
it("does not assign node when center is outside group", () => {
const nodes = [rect("n1", 300, 300, 100, 60)];
const groups = [rect("g1", 0, 0, 200, 200)];
expect(inferGroupMembership(nodes, groups)).toEqual([
membership("g1", [], []),
]);
});
it("infers nested group hierarchy via full-rect containment", () => {
const nodes = [rect("n1", 50, 50, 80, 40)];
const groups = [rect("outer", 0, 0, 400, 400), rect("inner", 20, 20, 200, 200)];
const result = inferGroupMembership(nodes, groups);
expect(result.find((entry) => entry.groupId === "outer")).toEqual(
membership("outer", [], ["inner"]),
);
expect(result.find((entry) => entry.groupId === "inner")).toEqual(
membership("inner", ["n1"], []),
);
});
it("assigns node to nearest containing group, not ancestor", () => {
const nodes = [rect("n1", 50, 50, 60, 40)];
const groups = [rect("outer", 0, 0, 500, 500), rect("inner", 30, 30, 200, 200)];
const result = inferGroupMembership(nodes, groups);
expect(result.find((entry) => entry.groupId === "outer")).toEqual(
membership("outer", [], ["inner"]),
);
expect(result.find((entry) => entry.groupId === "inner")).toEqual(
membership("inner", ["n1"], []),
);
});
it("handles multiple nodes across multiple groups", () => {
const nodes = [
rect("n1", 10, 10, 50, 50),
rect("n2", 310, 10, 50, 50),
rect("n3", 600, 600, 50, 50),
];
const groups = [rect("g1", 0, 0, 200, 200), rect("g2", 300, 0, 200, 200)];
const result = inferGroupMembership(nodes, groups);
expect(result.find((entry) => entry.groupId === "g1")).toEqual(
membership("g1", ["n1"], []),
);
expect(result.find((entry) => entry.groupId === "g2")).toEqual(
membership("g2", ["n2"], []),
);
});
it("is deterministic across repeated calls", () => {
const nodes = [rect("n1", 10, 10, 50, 50), rect("n2", 60, 60, 50, 50)];
const groups = [rect("g1", 0, 0, 300, 300), rect("g2", 5, 5, 200, 200)];
expect(inferGroupMembership(nodes, groups)).toEqual(
inferGroupMembership(nodes, groups),
);
});
});
+19
View File
@@ -0,0 +1,19 @@
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import libConfig from "../../vite.config.lib.ts";
describe("vite library build config", () => {
it("builds the core entrypoint in library mode", () => {
const libBuild = libConfig.build?.lib;
expect(libBuild).toBeDefined();
if (!libBuild) {
throw new Error("expected Vite library mode to be configured");
}
expect(libBuild.entry).toBe(
fileURLToPath(new URL("../../src/core.ts", import.meta.url)),
);
expect(libConfig.build?.outDir).toBe("lib");
});
});
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020"],
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"emitDeclarationOnly": true,
"declarationMap": true,
"outDir": "lib",
"rootDir": "src",
"strict": true,
"skipLibCheck": true,
"isolatedModules": true,
"resolveJsonModule": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/core.ts", "src/group-membership.ts"]
}
+17
View File
@@ -0,0 +1,17 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vite";
export default defineConfig({
build: {
emptyOutDir: true,
lib: {
entry: fileURLToPath(new URL("./src/core.ts", import.meta.url)),
fileName: () => "core.js",
formats: ["es"],
},
outDir: "lib",
rollupOptions: {
external: [],
},
},
});