From 2d094554f77906b30b3cd192b617c46617aa62c1 Mon Sep 17 00:00:00 2001 From: PBandDev <202373785+PBandDev@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:41:54 -0500 Subject: [PATCH] Bundle the pure core API for Node consumers (#6) * feat: extract pure core api * refactor: centralize core api helpers * build: bundle core api output --- .gitignore | 3 +- AGENTS.md | 4 + README.md | 17 +++ package.json | 12 ++ scripts/smoke-core-package.mjs | 7 ++ src/adapter.ts | 125 ++++++------------ src/core.ts | 167 ++++++++++++++++++++++++ src/group-geometry.ts | 66 ++++++---- src/group-membership.ts | 88 +++++++++++++ src/index.ts | 26 ++-- src/layout/algorithm-factory.ts | 23 ++++ src/layout/group-ids.ts | 23 ++++ tests/unit/algorithm-factory.test.ts | 66 ++++++++++ tests/unit/core-source-imports.test.ts | 18 +++ tests/unit/core.test.ts | 168 +++++++++++++++++++++++++ tests/unit/group-ids.test.ts | 23 ++++ tests/unit/group-membership.test.ts | 95 ++++++++++++++ tests/unit/lib-build-config.test.ts | 19 +++ tsconfig.lib.json | 21 ++++ vite.config.lib.ts | 17 +++ 20 files changed, 861 insertions(+), 127 deletions(-) create mode 100644 scripts/smoke-core-package.mjs create mode 100644 src/core.ts create mode 100644 src/group-membership.ts create mode 100644 src/layout/algorithm-factory.ts create mode 100644 src/layout/group-ids.ts create mode 100644 tests/unit/algorithm-factory.test.ts create mode 100644 tests/unit/core-source-imports.test.ts create mode 100644 tests/unit/core.test.ts create mode 100644 tests/unit/group-ids.test.ts create mode 100644 tests/unit/group-membership.test.ts create mode 100644 tests/unit/lib-build-config.test.ts create mode 100644 tsconfig.lib.json create mode 100644 vite.config.lib.ts diff --git a/.gitignore b/.gitignore index 5126ce5..9c8bf6e 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,5 @@ node.zip .changeset/ .test-comfy/ test-results/ -playwright-report/ \ No newline at end of file +playwright-report/ +lib/ diff --git a/AGENTS.md b/AGENTS.md index aa197fa..4910f66 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index 73aa395..a04cb74 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/package.json b/package.json index ac3d115..327a8a4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/smoke-core-package.mjs b/scripts/smoke-core-package.mjs new file mode 100644 index 0000000..fc21441 --- /dev/null +++ b/scripts/smoke-core-package.mjs @@ -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"); diff --git a/src/adapter.ts b/src/adapter.ts index 73f2429..dbfa5c0 100644 --- a/src/adapter.ts +++ b/src/adapter.ts @@ -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(); - 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(); - const groupAreas = new Map(); - 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); diff --git a/src/core.ts b/src/core.ts new file mode 100644 index 0000000..dbbf099 --- /dev/null +++ b/src/core.ts @@ -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; + readonly childGroupIds: ReadonlyArray; +} + +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; +} + +export { DEFAULT_FRAMEWORK_CONFIG, type FrameworkConfig } from "./layout/types"; + +export function normalizeWorkflowGeometry( + input: { + readonly nodes: ReadonlyArray; + readonly edges: ReadonlyArray; + readonly groups: ReadonlyArray; + }, + 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], + })), + }; +} diff --git a/src/group-geometry.ts b/src/group-geometry.ts index 8cbfa05..1b89b11 100644 --- a/src/group-geometry.ts +++ b/src/group-geometry.ts @@ -3,41 +3,59 @@ export interface PositionedItem { readonly size: ArrayLike; } +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]), + }; +} diff --git a/src/group-membership.ts b/src/group-membership.ts new file mode 100644 index 0000000..d8a89f2 --- /dev/null +++ b/src/group-membership.ts @@ -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, + groups: ReadonlyArray, +): GroupMembership[] { + if (groups.length === 0) { + return []; + } + + const groupById = new Map(); + const groupAreas = new Map(); + for (const group of groups) { + groupById.set(group.id, group); + groupAreas.set(group.id, group.width * group.height); + } + + const childGroupIds = new Map(); + 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], + }; + }); +} diff --git a/src/index.ts b/src/index.ts index 25f80c0..cec7883 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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) => 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, ): 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): LayoutAlgorithm { diff --git a/src/layout/algorithm-factory.ts b/src/layout/algorithm-factory.ts new file mode 100644 index 0000000..4a1e83b --- /dev/null +++ b/src/layout/algorithm-factory.ts @@ -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, +): 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, + }); + } +} diff --git a/src/layout/group-ids.ts b/src/layout/group-ids.ts new file mode 100644 index 0000000..f9b08bd --- /dev/null +++ b/src/layout/group-ids.ts @@ -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; +} diff --git a/tests/unit/algorithm-factory.test.ts b/tests/unit/algorithm-factory.test.ts new file mode 100644 index 0000000..bc38a20 --- /dev/null +++ b/tests/unit/algorithm-factory.test.ts @@ -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); + }); +}); diff --git a/tests/unit/core-source-imports.test.ts b/tests/unit/core-source-imports.test.ts new file mode 100644 index 0000000..6205d45 --- /dev/null +++ b/tests/unit/core-source-imports.test.ts @@ -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"/); + } + }); +}); diff --git a/tests/unit/core.test.ts b/tests/unit/core.test.ts new file mode 100644 index 0000000..716b226 --- /dev/null +++ b/tests/unit/core.test.ts @@ -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: [] }]); + }); +}); diff --git a/tests/unit/group-ids.test.ts b/tests/unit/group-ids.test.ts new file mode 100644 index 0000000..cda2057 --- /dev/null +++ b/tests/unit/group-ids.test.ts @@ -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(); + }); +}); diff --git a/tests/unit/group-membership.test.ts b/tests/unit/group-membership.test.ts new file mode 100644 index 0000000..562b41f --- /dev/null +++ b/tests/unit/group-membership.test.ts @@ -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), + ); + }); +}); diff --git a/tests/unit/lib-build-config.test.ts b/tests/unit/lib-build-config.test.ts new file mode 100644 index 0000000..ad6c26a --- /dev/null +++ b/tests/unit/lib-build-config.test.ts @@ -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"); + }); +}); diff --git a/tsconfig.lib.json b/tsconfig.lib.json new file mode 100644 index 0000000..31901e9 --- /dev/null +++ b/tsconfig.lib.json @@ -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"] +} diff --git a/vite.config.lib.ts b/vite.config.lib.ts new file mode 100644 index 0000000..3855335 --- /dev/null +++ b/vite.config.lib.ts @@ -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: [], + }, + }, +});