From ceff7d2480295f8a652e6e076389ffb5014c60c0 Mon Sep 17 00:00:00 2001 From: pbanddev <202373785+PBandDev@users.noreply.github.com> Date: Tue, 24 Mar 2026 11:43:53 -0500 Subject: [PATCH] refactor: centralize core api helpers --- AGENTS.md | 1 + README.md | 1 + package.json | 1 + scripts/smoke-core-package.ts | 7 ++ src/adapter.ts | 16 +--- src/core.ts | 93 ++++++++----------- src/group-geometry.ts | 66 ++++++++----- src/group-membership.ts | 26 +----- src/index.ts | 26 ++---- src/layout/algorithm-factory.ts | 23 +++++ src/layout/algorithms/grid.ts | 2 +- src/layout/algorithms/horizontal.ts | 2 +- src/layout/algorithms/sugiyama.ts | 10 +- .../algorithms/sugiyama/coordinate-assign.ts | 2 +- .../algorithms/sugiyama/crossing-minimize.ts | 2 +- src/layout/algorithms/sugiyama/cycle-break.ts | 2 +- src/layout/algorithms/sugiyama/index.ts | 10 +- .../algorithms/sugiyama/layer-assign.ts | 2 +- src/layout/algorithms/vertical.ts | 2 +- src/layout/compact.ts | 2 +- src/layout/framework.ts | 6 +- src/layout/group-ids.ts | 23 +++++ src/layout/index.ts | 8 +- src/layout/tokens.ts | 8 +- tests/unit/algorithm-factory.test.ts | 66 +++++++++++++ tests/unit/group-ids.test.ts | 23 +++++ tsconfig.lib.json | 4 +- 27 files changed, 276 insertions(+), 158 deletions(-) create mode 100644 scripts/smoke-core-package.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/group-ids.test.ts diff --git a/AGENTS.md b/AGENTS.md index 166f5e1..e766dbd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,7 @@ Use `.changeset/plans/` only when prior design context is needed. pnpm install pnpm build pnpm build:lib +pnpm test:lib pnpm typecheck pnpm test pnpm setup:e2e diff --git a/README.md b/README.md index af1db5b..7899b9f 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ Add tokens to group titles to control how nodes are arranged: - `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 diff --git a/package.json b/package.json index 63ff21e..cc12a83 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "dev": "vite", "build": "tsc && vite build", "build:lib": "tsc -p tsconfig.lib.json", + "test:lib": "pnpm build:lib && tsx scripts/smoke-core-package.ts", "watch": "tsc && vite build --watch", "typecheck": "tsc --noEmit", "prepare": "pnpm build:lib", diff --git a/scripts/smoke-core-package.ts b/scripts/smoke-core-package.ts new file mode 100644 index 0000000..fc21441 --- /dev/null +++ b/scripts/smoke-core-package.ts @@ -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 4ac5aee..dbfa5c0 100644 --- a/src/adapter.ts +++ b/src/adapter.ts @@ -21,6 +21,10 @@ import { inferGroupMembership, type Rect as MembershipRect, } from "./group-membership"; +import { + fromNumericGroupLayoutId, + toGroupLayoutId, +} from "./layout/group-ids"; // --------------------------------------------------------------------------- // Structural types for ComfyUI runtime objects @@ -66,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 // --------------------------------------------------------------------------- @@ -354,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 index addc09e..209f393 100644 --- a/src/core.ts +++ b/src/core.ts @@ -1,43 +1,44 @@ -import { createHorizontalAlgorithm } from "./layout/algorithms/horizontal"; -import { createSugiyamaAlgorithm } from "./layout/algorithms/sugiyama"; -import { createVerticalAlgorithm } from "./layout/algorithms/vertical"; -import { layoutWithGroups } from "./layout/framework"; -import { parseLayoutToken } from "./layout/tokens"; +import { + createLayoutAlgorithm, + type LayoutAlgorithmName, +} from "./layout/algorithm-factory.js"; +import { layoutWithGroups } from "./layout/framework.js"; +import { toGroupLayoutId } from "./layout/group-ids.js"; +import { parseLayoutToken } from "./layout/tokens.js"; import { DEFAULT_FRAMEWORK_CONFIG, type FrameworkConfig, - type LayoutAlgorithm, type LayoutEdge as InternalLayoutEdge, type LayoutGroup as InternalLayoutGroup, type LayoutNode as InternalLayoutNode, -} from "./layout/types"; +} from "./layout/types.js"; export { inferGroupMembership, type GroupMembership, type Rect, -} from "./group-membership"; +} from "./group-membership.js"; -export interface LayoutNode { +export interface WorkflowNode { readonly id: string; readonly width: number; readonly height: number; readonly kind?: "node" | "subgraph-input" | "subgraph-output"; } -export interface LayoutEdge { +export interface WorkflowEdge { readonly source: string; readonly target: string; } -export interface LayoutGroup { +export interface WorkflowGroup { readonly id: string; readonly title: string; readonly memberIds: ReadonlyArray; readonly childGroupIds: ReadonlyArray; } -export interface PositionedRect { +export interface WorkflowRect { readonly id: string; readonly x: number; readonly y: number; @@ -45,38 +46,41 @@ export interface PositionedRect { readonly height: number; } -export interface NormalizeResult { - readonly nodes: PositionedRect[]; - readonly groups: PositionedRect[]; - readonly memberships: Array<{ - readonly groupId: string; - readonly nodeIds: string[]; - readonly childGroupIds: string[]; - }>; +export interface WorkflowMembership { + readonly groupId: string; + readonly nodeIds: string[]; + readonly childGroupIds: string[]; } -export interface NormalizeOptions { - readonly algorithm?: "sugiyama" | "horizontal" | "vertical"; +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"; - -type AlgorithmName = NonNullable; +export { DEFAULT_FRAMEWORK_CONFIG, type FrameworkConfig } from "./layout/types.js"; export function normalizeWorkflowGeometry( input: { - readonly nodes: ReadonlyArray; - readonly edges: ReadonlyArray; - readonly groups: ReadonlyArray; + readonly nodes: ReadonlyArray; + readonly edges: ReadonlyArray; + readonly groups: ReadonlyArray; }, - options?: NormalizeOptions, -): NormalizeResult { + options?: NormalizeWorkflowOptions, +): NormalizeWorkflowResult { const config: FrameworkConfig = { ...DEFAULT_FRAMEWORK_CONFIG, ...options?.config, }; - const algorithm = createAlgorithm(options?.algorithm ?? "sugiyama", config); + const algorithm = createLayoutAlgorithm( + options?.algorithm ?? "sugiyama", + config, + ); const nodes: InternalLayoutNode[] = input.nodes.map((node) => { if (node.kind === "subgraph-input") { @@ -115,10 +119,10 @@ export function normalizeWorkflowGeometry( const token = parseLayoutToken(group.title); return { - id: toInternalGroupId(group.id), + id: toGroupLayoutId(group.id), title: group.title, memberIds: [...group.memberIds], - childGroupIds: group.childGroupIds.map(toInternalGroupId), + childGroupIds: group.childGroupIds.map(toGroupLayoutId), ...(token ? { token } : {}), }; }); @@ -141,7 +145,7 @@ export function normalizeWorkflowGeometry( : []; }), groups: input.groups.flatMap((group) => { - const bounds = result.groupBounds.get(toInternalGroupId(group.id)); + const bounds = result.groupBounds.get(toGroupLayoutId(group.id)); return bounds ? [ { @@ -161,24 +165,3 @@ export function normalizeWorkflowGeometry( })), }; } - -function createAlgorithm( - algorithmName: AlgorithmName, - config: FrameworkConfig, -): 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, - }); - } -} - -function toInternalGroupId(groupId: string): string { - return `group:${groupId}`; -} 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 index 3814171..5352973 100644 --- a/src/group-membership.ts +++ b/src/group-membership.ts @@ -1,3 +1,8 @@ +import { + isCenterInsideRect, + isRectInsideRect, +} from "./group-geometry.js"; + export interface Rect { readonly id: string; readonly x: number; @@ -81,24 +86,3 @@ export function inferGroupMembership( }; }); } - -function isRectInsideRect(inner: Rect, outer: Rect): boolean { - return ( - inner.x >= outer.x && - inner.y >= outer.y && - inner.x + inner.width <= outer.x + outer.width && - inner.y + inner.height <= outer.y + outer.height - ); -} - -function isCenterInsideRect(item: Rect, container: Rect): 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 - ); -} 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..ea1059f --- /dev/null +++ b/src/layout/algorithm-factory.ts @@ -0,0 +1,23 @@ +import { createHorizontalAlgorithm } from "./algorithms/horizontal.js"; +import { createSugiyamaAlgorithm } from "./algorithms/sugiyama.js"; +import { createVerticalAlgorithm } from "./algorithms/vertical.js"; +import type { FrameworkConfig, LayoutAlgorithm } from "./types.js"; + +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/algorithms/grid.ts b/src/layout/algorithms/grid.ts index d7055ba..9eb5b20 100644 --- a/src/layout/algorithms/grid.ts +++ b/src/layout/algorithms/grid.ts @@ -6,7 +6,7 @@ * Ignores edges (token layouts ignore DAG structure). */ -import type { LayoutAlgorithm, LayoutInput, LayoutOutput, Position } from "../types"; +import type { LayoutAlgorithm, LayoutInput, LayoutOutput, Position } from "../types.js"; const DEFAULT_GAP = 40; diff --git a/src/layout/algorithms/horizontal.ts b/src/layout/algorithms/horizontal.ts index d756a10..f78e055 100644 --- a/src/layout/algorithms/horizontal.ts +++ b/src/layout/algorithms/horizontal.ts @@ -5,7 +5,7 @@ * Nodes are placed in input order; vertically centered by midpoint. */ -import type { LayoutAlgorithm, LayoutInput, LayoutOutput, Position } from "../types"; +import type { LayoutAlgorithm, LayoutInput, LayoutOutput, Position } from "../types.js"; const DEFAULT_GAP = 40; diff --git a/src/layout/algorithms/sugiyama.ts b/src/layout/algorithms/sugiyama.ts index 6f0dadc..81b1f26 100644 --- a/src/layout/algorithms/sugiyama.ts +++ b/src/layout/algorithms/sugiyama.ts @@ -15,11 +15,11 @@ import type { LayoutInput, LayoutOutput, LayoutNode, -} from "../types"; -import { breakCycles } from "./sugiyama/cycle-break"; -import { assignLayers } from "./sugiyama/layer-assign"; -import { minimizeCrossings } from "./sugiyama/crossing-minimize"; -import { assignCoordinates } from "./sugiyama/coordinate-assign"; +} from "../types.js"; +import { breakCycles } from "./sugiyama/cycle-break.js"; +import { assignLayers } from "./sugiyama/layer-assign.js"; +import { minimizeCrossings } from "./sugiyama/crossing-minimize.js"; +import { assignCoordinates } from "./sugiyama/coordinate-assign.js"; export interface SugiyamaConfig { readonly horizontalGap: number; diff --git a/src/layout/algorithms/sugiyama/coordinate-assign.ts b/src/layout/algorithms/sugiyama/coordinate-assign.ts index 9dc895a..425609a 100644 --- a/src/layout/algorithms/sugiyama/coordinate-assign.ts +++ b/src/layout/algorithms/sugiyama/coordinate-assign.ts @@ -5,7 +5,7 @@ * and Y coordinates by stacking nodes top-to-bottom within each layer. */ -import type { LayoutNode, Position } from "../../types"; +import type { LayoutNode, Position } from "../../types.js"; interface CoordinateConfig { readonly horizontalGap: number; diff --git a/src/layout/algorithms/sugiyama/crossing-minimize.ts b/src/layout/algorithms/sugiyama/crossing-minimize.ts index b98cefc..787c2e5 100644 --- a/src/layout/algorithms/sugiyama/crossing-minimize.ts +++ b/src/layout/algorithms/sugiyama/crossing-minimize.ts @@ -6,7 +6,7 @@ * their neighbors in the adjacent fixed layer. */ -import type { LayoutNode, LayoutEdge } from "../../types"; +import type { LayoutNode, LayoutEdge } from "../../types.js"; /** * Reorder nodes within each layer to minimize edge crossings. diff --git a/src/layout/algorithms/sugiyama/cycle-break.ts b/src/layout/algorithms/sugiyama/cycle-break.ts index 3d230c4..412a27b 100644 --- a/src/layout/algorithms/sugiyama/cycle-break.ts +++ b/src/layout/algorithms/sugiyama/cycle-break.ts @@ -6,7 +6,7 @@ * repeatedly extract sinks, sources, then the node with max (out - in). */ -import type { LayoutNode, LayoutEdge } from "../../types"; +import type { LayoutNode, LayoutEdge } from "../../types.js"; export interface CycleBreakResult { readonly edges: ReadonlyArray; diff --git a/src/layout/algorithms/sugiyama/index.ts b/src/layout/algorithms/sugiyama/index.ts index 01d126c..5f86eff 100644 --- a/src/layout/algorithms/sugiyama/index.ts +++ b/src/layout/algorithms/sugiyama/index.ts @@ -2,11 +2,11 @@ * Sugiyama sub-module barrel export. */ -export { breakCycles } from "./cycle-break"; -export type { CycleBreakResult } from "./cycle-break"; +export { breakCycles } from "./cycle-break.js"; +export type { CycleBreakResult } from "./cycle-break.js"; -export { assignLayers } from "./layer-assign"; +export { assignLayers } from "./layer-assign.js"; -export { minimizeCrossings } from "./crossing-minimize"; +export { minimizeCrossings } from "./crossing-minimize.js"; -export { assignCoordinates } from "./coordinate-assign"; +export { assignCoordinates } from "./coordinate-assign.js"; diff --git a/src/layout/algorithms/sugiyama/layer-assign.ts b/src/layout/algorithms/sugiyama/layer-assign.ts index 04163df..26b84d4 100644 --- a/src/layout/algorithms/sugiyama/layer-assign.ts +++ b/src/layout/algorithms/sugiyama/layer-assign.ts @@ -5,7 +5,7 @@ * path from any source to that node. Sources (in-degree 0) get layer 0. */ -import type { LayoutNode, LayoutEdge } from "../../types"; +import type { LayoutNode, LayoutEdge } from "../../types.js"; /** * Assign layer indices to each node using longest-path from sources. diff --git a/src/layout/algorithms/vertical.ts b/src/layout/algorithms/vertical.ts index 6090b2e..dd90be9 100644 --- a/src/layout/algorithms/vertical.ts +++ b/src/layout/algorithms/vertical.ts @@ -5,7 +5,7 @@ * Nodes are placed in input order; horizontally centered by midpoint. */ -import type { LayoutAlgorithm, LayoutInput, LayoutOutput, Position } from "../types"; +import type { LayoutAlgorithm, LayoutInput, LayoutOutput, Position } from "../types.js"; const DEFAULT_GAP = 40; diff --git a/src/layout/compact.ts b/src/layout/compact.ts index 7188100..f618f22 100644 --- a/src/layout/compact.ts +++ b/src/layout/compact.ts @@ -3,7 +3,7 @@ * All functions are pure: they return new maps, never mutate inputs. */ -import type { Position, LayoutNode } from "./types"; +import type { Position, LayoutNode } from "./types.js"; /** * Remove vertical gaps between nodes by shifting them up. diff --git a/src/layout/framework.ts b/src/layout/framework.ts index 9ba0d7b..fbc6fb7 100644 --- a/src/layout/framework.ts +++ b/src/layout/framework.ts @@ -17,9 +17,9 @@ import type { FrameworkResult, Position, GroupBounds, -} from "./types"; -import { DEFAULT_FRAMEWORK_CONFIG } from "./types"; -import { tokenToAlgorithm } from "./tokens"; +} from "./types.js"; +import { DEFAULT_FRAMEWORK_CONFIG } from "./types.js"; +import { tokenToAlgorithm } from "./tokens.js"; // --------------------------------------------------------------------------- // Public API 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/src/layout/index.ts b/src/layout/index.ts index fa02453..6339bcc 100644 --- a/src/layout/index.ts +++ b/src/layout/index.ts @@ -8,9 +8,9 @@ export { splitDisconnected, placeDisconnected, translatePositions, -} from "./framework"; +} from "./framework.js"; -export { compactVertically, compactHorizontally } from "./compact"; +export { compactVertically, compactHorizontally } from "./compact.js"; export type { Position, @@ -24,6 +24,6 @@ export type { GroupBounds, FrameworkResult, FrameworkConfig, -} from "./types"; +} from "./types.js"; -export { DEFAULT_FRAMEWORK_CONFIG } from "./types"; +export { DEFAULT_FRAMEWORK_CONFIG } from "./types.js"; diff --git a/src/layout/tokens.ts b/src/layout/tokens.ts index aef2ed4..8639f91 100644 --- a/src/layout/tokens.ts +++ b/src/layout/tokens.ts @@ -5,10 +5,10 @@ * and creates the corresponding LayoutAlgorithm instances. */ -import type { LayoutToken, LayoutAlgorithm } from "./types"; -import { createHorizontalAlgorithm } from "./algorithms/horizontal"; -import { createVerticalAlgorithm } from "./algorithms/vertical"; -import { createGridAlgorithm } from "./algorithms/grid"; +import type { LayoutToken, LayoutAlgorithm } from "./types.js"; +import { createHorizontalAlgorithm } from "./algorithms/horizontal.js"; +import { createVerticalAlgorithm } from "./algorithms/vertical.js"; +import { createGridAlgorithm } from "./algorithms/grid.js"; /** * Regex to match layout tokens in group titles. 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/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/tsconfig.lib.json b/tsconfig.lib.json index 3fba870..cd505ba 100644 --- a/tsconfig.lib.json +++ b/tsconfig.lib.json @@ -2,8 +2,8 @@ "compilerOptions": { "target": "ES2020", "lib": ["ES2020"], - "module": "ESNext", - "moduleResolution": "bundler", + "module": "NodeNext", + "moduleResolution": "NodeNext", "declaration": true, "declarationMap": true, "sourceMap": true,