diff --git a/.actrc b/.actrc new file mode 100644 index 0000000..a08e327 --- /dev/null +++ b/.actrc @@ -0,0 +1,2 @@ +-P ubuntu-latest=catthehacker/ubuntu:act-latest +--container-architecture linux/amd64 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c429944..ffaedaf 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -2,17 +2,14 @@ name: CI on: push: - branches: [main] + branches: [main, v2] pull_request: branches: [main] jobs: - test: - name: Test & Build + unit-tests: + name: Unit Tests & Build runs-on: ubuntu-latest - strategy: - matrix: - node-version: [20] steps: - name: Check out code uses: actions/checkout@v4 @@ -20,10 +17,10 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 - - name: Use Node.js ${{ matrix.node-version }} + - name: Use Node.js 20 uses: actions/setup-node@v4 with: - node-version: ${{ matrix.node-version }} + node-version: 20 cache: "pnpm" - name: Install dependencies @@ -32,8 +29,55 @@ jobs: - name: Typecheck run: pnpm typecheck - - name: Run tests + - name: Run unit tests run: pnpm test - name: Build run: pnpm build + + e2e-tests: + name: E2E Tests + runs-on: ubuntu-latest + needs: unit-tests + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Use Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "pnpm" + + - name: Install dependencies + run: pnpm install + + - name: Install Playwright browsers + run: pnpx playwright install chromium --with-deps + + - name: Build extension + run: pnpm build + + - name: Install uv + uses: astral-sh/setup-uv@v6 + + - name: Setup test ComfyUI + run: pnpm setup:e2e + timeout-minutes: 15 + + - name: Run E2E tests + run: pnpm test:e2e + timeout-minutes: 30 + + - name: Upload test results + if: failure() + uses: actions/upload-artifact@v4 + with: + name: e2e-test-results + path: | + test-results/ + playwright-report/ + retention-days: 7 diff --git a/.github/workflows/publish_action.yaml b/.github/workflows/publish_action.yaml index 4917935..7249389 100644 --- a/.github/workflows/publish_action.yaml +++ b/.github/workflows/publish_action.yaml @@ -17,40 +17,64 @@ jobs: test: name: Test before publish runs-on: ubuntu-latest - if: github.actor == github.repository_owner - strategy: - matrix: - node-version: [20] + if: github.actor == github.repository_owner && github.ref == 'refs/heads/main' steps: - name: Check out code uses: actions/checkout@v4 + - name: Use Python from .python-version + uses: actions/setup-python@v5 + with: + python-version-file: ".python-version" + + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + - name: Install pnpm uses: pnpm/action-setup@v4 - - name: Use Node.js ${{ matrix.node-version }} + - name: Use Node.js 20 uses: actions/setup-node@v4 with: - node-version: ${{ matrix.node-version }} + node-version: 20 cache: "pnpm" - name: Install dependencies run: pnpm install + - name: Sync Python dependencies + run: uv sync --locked --group dev + - name: Typecheck run: pnpm typecheck - - name: Run tests + - name: Run unit tests run: pnpm test - name: Build run: pnpm build + - name: Install Playwright browsers + run: pnpx playwright install chromium --with-deps + + - name: Setup test ComfyUI + run: pnpm setup:e2e + timeout-minutes: 15 + + - name: Run E2E tests + run: pnpm test:e2e + timeout-minutes: 30 + bump-version: name: Bump version and create tag runs-on: ubuntu-latest needs: test - if: github.actor == github.repository_owner + if: github.actor == github.repository_owner && github.ref == 'refs/heads/main' + outputs: + previous-version: ${{ steps.versions.outputs.previous-version }} + current-version: ${{ steps.versions.outputs.current-version }} permissions: contents: write steps: @@ -59,57 +83,85 @@ jobs: with: fetch-depth: 0 + - name: Use Python from .python-version + uses: actions/setup-python@v5 + with: + python-version-file: ".python-version" + + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + - name: Configure Git for GitHub Actions bot run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - name: Bump version - id: bump - uses: callowayproject/bump-my-version@master - env: - BUMPVERSION_TAG: "true" - with: - args: ${{ github.event.inputs.bump_type }} - github-token: ${{ secrets.GITHUB_TOKEN }} + - name: Sync Python dependencies + run: uv sync --locked --group dev + + - name: Compute release version + id: versions + shell: bash + run: | + previous_version="$(uv run bump-my-version show current_version)" + current_version="$(uv run bump-my-version show --increment "${{ github.event.inputs.bump_type }}" new_version)" + { + echo "previous-version=${previous_version}" + echo "current-version=${current_version}" + } >> "$GITHUB_OUTPUT" + + - name: Bump version and refresh uv.lock + run: | + uv run bump-my-version bump --no-commit --no-tag ${{ github.event.inputs.bump_type }} + uv lock + + - name: Commit version bump + run: | + git add .python-version pyproject.toml package.json src/settings.ts uv.lock + git commit -m "chore: bump version to ${{ steps.versions.outputs.current-version }} [skip ci]" + + - name: Create release tag + run: git tag "v${{ steps.versions.outputs.current-version }}" -m "Release version ${{ steps.versions.outputs.current-version }}" + + - name: Push version commit and tag + run: git push origin HEAD:main --follow-tags - name: Create GitHub Release - if: steps.bump.outputs.bumped == 'true' uses: softprops/action-gh-release@v2 with: - tag_name: v${{ steps.bump.outputs.current-version }} - name: Release v${{ steps.bump.outputs.current-version }} + tag_name: v${{ steps.versions.outputs.current-version }} + name: Release v${{ steps.versions.outputs.current-version }} body: | - ## Version ${{ steps.bump.outputs.current-version }} + ## Version ${{ steps.versions.outputs.current-version }} - Bumped from version ${{ steps.bump.outputs.previous-version }} to ${{ steps.bump.outputs.current-version }} + Bumped from version ${{ steps.versions.outputs.previous-version }} to ${{ steps.versions.outputs.current-version }} This release was automatically created by the version bump workflow. draft: false prerelease: false generate_release_notes: true + publish-node: name: Publish Custom Node to registry runs-on: ubuntu-latest needs: bump-version - if: github.actor == github.repository_owner - strategy: - matrix: - node-version: [20] + if: github.actor == github.repository_owner && github.ref == 'refs/heads/main' steps: - name: Check out code uses: actions/checkout@v4 with: - ref: main + ref: v${{ needs.bump-version.outputs.current-version }} fetch-depth: 0 - name: Install pnpm uses: pnpm/action-setup@v4 - - name: Use Node.js ${{ matrix.node-version }} + - name: Use Node.js 20 uses: actions/setup-node@v4 with: - node-version: ${{ matrix.node-version }} + node-version: 20 cache: "pnpm" - name: Install dependencies diff --git a/.gitignore b/.gitignore index 7e05bac..5126ce5 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,7 @@ pnpm-debug.log* node.zip .vscode/ .claude/ -.changeset/ \ No newline at end of file +.changeset/ +.test-comfy/ +test-results/ +playwright-report/ \ No newline at end of file diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0e095fe --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,61 @@ +## Project + +ComfyUI Node Organizer. Frontend-only custom node extension loaded from `dist/` through `__init__.py`. + +## Start Here + +Inspect `README.md`, `package.json`, `src/index.ts`, `src/adapter.ts`, and the relevant file under `src/layout/` or `tests/e2e/`. +Use `.changeset/plans/` only when prior design context is needed. + +## Common Commands + +```bash +pnpm install +pnpm build +pnpm typecheck +pnpm test +pnpm setup:e2e +pnpm test:e2e +pnpm ci:local +uv sync --locked --group dev +``` + +Version bumping: +- `uv run bump-my-version bump patch|minor|major` + +## Repo Rules + +- Build before E2E. ComfyUI loads `dist/`, not `src/`. +- Use `uv` for Python-side commands. +- 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. + +## Runtime Facts + +- Ctrl+Z works after organize. ComfyUI snapshots graph state before extension mutations. +- Keybindings are managed in ComfyUI's keybinding settings, not inside this extension's settings panel. +- Graph access: `canvas.getCurrentGraph?.() ?? canvas.graph ?? app.graph`. +- Group membership is geometry-based. Use center-point containment for nodes and full-rect containment for child groups. Do not read `_children`. +- Do not call `recomputeInsideNodes()`. It reads stale coordinates after our in-place `pos[0] = x` updates and fills `_children` with wrong node IDs. +- Group layout IDs are namespaced as `group:`. +- Selected-group organize re-anchors selected root groups to their original absolute positions. +- Subgraph `inputNode` and `outputNode` are first-class layout nodes. +- `isGroup()` is verified against live runtime with `constructor.name === "LGraphGroup"` and fallback `typeof type !== "string"`. + +## Test Facts + +- Unit: layout framework, adapter bridge, runtime helpers, property-based invariants. +- E2E: Playwright against `.test-comfy/`. +- `pnpm setup:e2e` pins ComfyUI to the revision in `e2e.config.ts` for CI parity. +- CI and publish run the full Playwright suite on Linux. +- Repo-owned fixtures live flat in `tests/fixtures/`. +- Installed `comfyui-workflow-templates` are discovered live at E2E runtime from the pinned test ComfyUI environment. +- Broad installed-template coverage asserts correctness invariants only; strict topological order remains repo-fixture-only. +- Visual regression is graph-canvas scoped and uses platform-specific `-win32` / `-linux` baselines. +- `pnpm ci:local` covers unit/build parity only. + +## Known Friction + +- Some real ComfyUI workflows load with imperfect group rectangles. Verify against live runtime before changing containment behavior. +- A small number of real installed templates have topological-order violations due to complex inter-group connections. Tracked for v2.1. diff --git a/CLAUDE.md b/CLAUDE.md index e109601..7c34891 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,145 +1 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -ComfyUI custom node extension that organizes nodes. Frontend-only extension (no Python backend nodes) that uses TypeScript/Vite for the UI. - -## Commands - -```bash -pnpm install # install deps -pnpm dev # dev server -pnpm build # typecheck + build to dist/ -pnpm watch # build with watch mode -pnpm typecheck # tsc --noEmit -pnpm test # run tests -pnpm test:watch # run tests in watch mode -pnpm test:coverage # run tests with coverage report -``` - -Version bumping: `uv run bump-my-version bump patch|minor|major` - -## Architecture - -**Dual-stack extension**: Python entrypoint + TypeScript frontend - -- `__init__.py` - ComfyUI entrypoint, points `WEB_DIRECTORY` to `./dist` -- `src/index.ts` - Extension entry, registers via `app.registerExtension()` -- `src/debug.ts` - Debug logging utilities (separated for testability) -- `dist/` - Built JS output, loaded by ComfyUI frontend - -**Build system**: Vite with custom plugin that injects `import { app } from "/scripts/app.js"` at build time. ComfyUI scripts are external (not bundled). - -**Types**: `@comfyorg/comfyui-frontend-types` provides ComfyApp interface. Global `app` declared for runtime access. - -## ComfyUI Extension Pattern - -Extensions register hooks via `app.registerExtension({ name, setup?, ... })`. The `app` object provides access to: -- Graph/canvas manipulation -- Node lifecycle hooks -- UI customization APIs - -Use docs-mcp server for ComfyUI API reference. - -## Layout Algorithm - -**Hybrid Sugiyama + Bin Packing** in `src/layout/`: - -1. `node-classifier.ts` - Classify nodes (connected/disconnected/reroutes) -2. `reroute-collapse.ts` - Collapse reroute chains into virtual edges -3. `graph-builder.ts` - Build DAG from connected nodes only -4. `layer-assign.ts` - Longest-path layer assignment -5. `ordering.ts` - Size-aware barycenter crossing minimization -6. `bin-pack.ts` - FFDH algorithm for multi-column packing -7. `positioning.ts` - Assign coordinates, place disconnected nodes, restore reroutes -8. `groups.ts` - Resize groups to fit members -9. `layout-utils.ts` - Shared functions for internal edge detection and member layer assignment -10. `selected-groups.ts` - Organize only selected groups independently -11. `title-tokens.ts` - Parse group title tokens and arrange nodes by mode - -**Configuration** in `types.ts`: -- `maxColumns`: 0=auto, 1=vertical stack, 2+=fixed columns -- `collapseReroutes`: true to collapse reroute chains -- `disconnectedGap`: gap between disconnected zone and DAG - -**Key design decisions**: -- `compactVertically` excludes groups from max height (prevents Y explosion) -- `positionGroupContents` uses translation (preserves relative positions) -- Group dimensions are layer-aware: width = sum of layer widths, height = max of layer heights (for groups with internal connections) -- `resolveGroupOverlaps` detects and shifts overlapping groups using minimal direction (up/down/right) -- `assignNodeYPositions` tracks reserved Y regions to prevent node/group overlap within layers -- **Idempotency**: Layout is a pure function of topology + sizes; overlap resolution decoupled from node.pos writes - -## Selected Groups Layout - -`layoutSelectedGroups(graph, selectedGroupIds, config?)` in `src/layout/selected-groups.ts`: - -**Context menu items** (in `src/index.ts`): -- "Organize Group" - when single group selected -- "Organize N Groups" - when multiple groups selected - -**Behavior**: -- Uses internal connections only (ignores external edges) -- Disconnected nodes within group stacked vertically -- Nested groups auto-included when parent selected -- External overlaps acceptable (no resolution with non-selected nodes) -- Groups resized to fit organized contents - -**Selection detection**: -- Uses `app.canvas.selectedItems` (Set of Positionable) -- Duck-typing via `isLGraphGroup()` checks for pos/size/title/id -- Handles Float64Array (ComfyUI runtime uses typed arrays for pos/size) - -## Group Title Tokens - -`parseLayoutToken(title)` and `arrangeByMode()` in `src/layout/title-tokens.ts`: - -**Supported tokens** (case-insensitive): -- `[HORIZONTAL]` - Single horizontal row -- `[VERTICAL]` - Single vertical column -- `[1-9ROW]` - Distribute into N rows (round-robin) -- `[1-9COL]` - Distribute into N columns (round-robin) - -**Behavior**: -- Tokens override default layout for direct group members -- Ignores DAG structure when token present (treats all as disconnected) -- `[1ROW]` = `[HORIZONTAL]`, `[1COL]` = `[VERTICAL]` -- Nested groups each respect their own tokens -- Groups without tokens use default behavior -- Nodes sorted by original position (left-to-right, top-to-bottom) to preserve user intent - -**Integration points**: -- `selected-groups.ts:layoutGroupContents()` - Token layout for "Organize Group" -- `positioning.ts:positionGroupContents()` - Token layout for "Organize Workflow" -- Both handle nested groups: process bottom-up first, then include as items in parent's token layout - -## Testing - -**Vitest-based regression testing** in `tests/`: - -- `tests/fixtures/` - 4 workflow JSONs (nested-groups, complex-parallel, simple-dag, nested-wrapper) -- `tests/helpers.ts` - Fixture loader, workflow→LGraph converter, cloneGraph -- `tests/invariants.ts` - 6 reusable assertions: - - `assertNoOverlaps` - AABB collision detection - - `assertNodesInsideGroups` - nodes stay in groups - - `assertFiniteCoordinates` - no NaN/Infinity - - `assertTopologicalOrder` - DAG order preserved - - `assertIdempotent` - stable after multiple runs - - `assertGroupMembershipPreserved` - nodes originally in groups remain in groups -- `tests/integration/layout.test.ts` - Main regression tests -- `tests/integration/selected-groups.test.ts` - Selected groups layout tests (34 cases) -- `tests/integration/token-layout.test.ts` - Token-based layout regression tests (8 cases) - -**Coverage**: 70% line threshold, excludes `src/index.ts` (ComfyUI runtime) and `src/layout/reroute-collapse.ts` (no fixtures) - -All fixtures pass all invariants including idempotency. 75 total tests. - -## CI/CD - -`.github/workflows/publish_action.yaml` - Manual dispatch workflow that: -1. Runs tests -2. Bumps version via bump-my-version -3. Creates GitHub release -4. Builds and publishes to Comfy registry +See and edit @AGENTS.md. Do not touch this file. diff --git a/README.md b/README.md index 66102b8..577921f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ComfyUI Node Organizer -Automatically organizes nodes in your workflow using a compact hierarchical layout. Created with [comfyui-custom-node-template](https://github.com/PBandDev/comfyui-custom-node-template) +Automatically organizes ComfyUI workflows with a compact group-aware layout. Created with [comfyui-custom-node-template](https://github.com/PBandDev/comfyui-custom-node-template). ### Preview of organizing a workflow @@ -19,9 +19,13 @@ Automatically organizes nodes in your workflow using a compact hierarchical layo ## Usage -Right-click on the canvas and select **Organize Workflow**. +Use any of these entry points: -To organize specific groups, select them and choose **Organize Group**. +- Right-click the canvas and choose **Organize Workflow** +- Select groups and choose **Organize Group** +- Click the **Organize** action-bar button +- Use `Extensions > Node Organizer` +- Press `Shift+O` ## Group Layout Tokens @@ -38,22 +42,53 @@ Add tokens to group titles to control how nodes are arranged: - `"My Loaders [HORIZONTAL]"` - arranges all nodes in a single row - `"Processing [3COL]"` - distributes nodes into 3 columns -**Notes:** - Tokens are case-insensitive (`[horizontal]` works) - `[1ROW]` = `[HORIZONTAL]`, `[1COL]` = `[VERTICAL]` - Nested groups each respect their own tokens - Groups without tokens use default DAG-based layout -## Features +## Testing -- **Execution Order Layout**: Nodes arranged left-to-right following data flow -- **Compact Bin Packing**: Reduces vertical height by packing nodes into rows -- **Disconnected Node Handling**: Annotations, bookmarks placed in separate left margin -- **Reroute Optimization**: Reroute chains collapsed and restored along edges -- **Group Support**: Nested groups with proper hierarchy -- **Group Layout Tokens**: Control layout with title tokens (see below) -- **Subgraph Support**: Works inside subgraphs with I/O node handling +- `pnpm typecheck` +- `pnpm test` +- `pnpm build` +- `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. +### Local CI + +Install [act](https://github.com/nektos/act) and Docker, then run: + +- `pnpm ci:local` runs the unit-test and build workflow inside a local container + +E2E tests still require the normal local ComfyUI provisioning flow and are not part of the `act` path yet. + +## Release + +Maintainer flow: + +- Merge the release branch into `main` +- Trigger `.github/workflows/publish_action.yaml` manually from `main` + +That workflow runs the full test suite, bumps versioned files with `uv run bump-my-version`, creates the tag and GitHub release, then publishes the custom node from the bumped tag ref. + +### Manual browser testing + +Build the extension first so ComfyUI loads the current `dist/` output: + +- `pnpm build` + +Launch the test ComfyUI instance on a free port: + +- `.test-comfy/venv/Scripts/comfy.exe --skip-prompt --workspace .test-comfy/comfyui/ launch -- --cpu --port 65192` + +If the port is already taken, either choose a different port or stop leftover test-instance processes first: + +- `Get-Process | Where-Object { $_.Path -like '*comfy-node-organizer\\.test-comfy*' } | Stop-Process -Force` ## Known Limitations -This extension has not been thoroughly tested with very large or complex workflows. If you encounter issues, please [open a GitHub issue](https://github.com/PBandDev/comfyui-node-organizer/issues) with a **minimal reproducible workflow** attached. +Very large or unusual workflows may still expose edge cases. If you hit one, please [open a GitHub issue](https://github.com/PBandDev/comfyui-node-organizer/issues) with a minimal reproducible workflow attached. diff --git a/e2e.config.ts b/e2e.config.ts new file mode 100644 index 0000000..4d6c7f8 --- /dev/null +++ b/e2e.config.ts @@ -0,0 +1,22 @@ +export const e2eConfig = { + port: 8199, + testComfyDir: ".test-comfy", + comfyRevision: "v0.18.1", + get comfyUrl() { + return `http://localhost:${this.port}`; + }, + get comfyInstallDir() { + return `${this.testComfyDir}/comfyui`; + }, + get venvDir() { + return `${this.testComfyDir}/venv`; + }, + get customNodesDir() { + return `${this.comfyInstallDir}/custom_nodes`; + }, + timeouts: { + comfyStartup: 120_000, + pageLoad: 30_000, + organize: 10_000, + }, +} as const; diff --git a/package.json b/package.json index b0800ac..06ffacc 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,10 @@ "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", - "test:coverage": "vitest run --coverage" + "test:coverage": "vitest run --coverage", + "setup:e2e": "tsx scripts/setup-test-comfy.ts", + "test:e2e": "playwright test", + "ci:local": "act push --workflows .github/workflows/ci.yaml -j unit-tests" }, "keywords": [ "comfyui", @@ -31,8 +34,11 @@ }, "devDependencies": { "@comfyorg/comfyui-frontend-types": "^1.32.1", + "@playwright/test": "^1.58.2", "@types/node": "^24.10.0", "@vitest/coverage-v8": "^4.0.16", + "fast-check": "^4.6.0", + "tsx": "^4.21.0", "vitest": "^4.0.16" } } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..08c07f1 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, devices } from "@playwright/test"; +import { e2eConfig } from "./e2e.config.ts"; + +export default defineConfig({ + testDir: "./tests/e2e", + snapshotPathTemplate: "{testDir}/{testFilePath}-snapshots/{arg}-{platform}{ext}", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 8, + reporter: "html", + use: { + baseURL: e2eConfig.comfyUrl, + trace: "on-first-retry", + viewport: { width: 1600, height: 1000 }, + }, + globalSetup: "./tests/e2e/global-setup.ts", + globalTeardown: "./tests/e2e/global-teardown.ts", + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a81ede1..dd9ef6c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,20 +13,29 @@ importers: version: 5.9.3 vite: specifier: ^7.1.12 - version: 7.3.0(@types/node@24.10.4) + version: 7.3.0(@types/node@24.10.4)(tsx@4.21.0) devDependencies: '@comfyorg/comfyui-frontend-types': specifier: ^1.32.1 version: 1.36.3(vue@3.5.25(typescript@5.9.3))(zod@3.25.76) + '@playwright/test': + specifier: ^1.58.2 + version: 1.58.2 '@types/node': specifier: ^24.10.0 version: 24.10.4 '@vitest/coverage-v8': specifier: ^4.0.16 - version: 4.0.16(vitest@4.0.16(@types/node@24.10.4)) + version: 4.0.16(vitest@4.0.16(@types/node@24.10.4)(tsx@4.21.0)) + fast-check: + specifier: ^4.6.0 + version: 4.6.0 + tsx: + specifier: ^4.21.0 + version: 4.21.0 vitest: specifier: ^4.0.16 - version: 4.0.16(@types/node@24.10.4) + version: 4.0.16(@types/node@24.10.4)(tsx@4.21.0) packages: @@ -223,6 +232,11 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@playwright/test@1.58.2': + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} + engines: {node: '>=18'} + hasBin: true + '@rollup/rollup-android-arm-eabi@4.53.5': resolution: {integrity: sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==} cpu: [arm] @@ -460,6 +474,10 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + fast-check@4.6.0: + resolution: {integrity: sha512-h7H6Dm0Fy+H4ciQYFxFjXnXkzR2kr9Fb22c0UBpHnm59K2zpr2t13aPTHlltFiNT6zuxp6HMPAVVvgur4BLdpA==} + engines: {node: '>=12.17.0'} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -469,11 +487,19 @@ packages: picomatch: optional: true + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + get-tsconfig@4.13.6: + resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -531,10 +557,26 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + engines: {node: '>=18'} + hasBin: true + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + pure-rand@8.3.0: + resolution: {integrity: sha512-1ws1Ab8fnsf4bvpL+SujgBnr3KFs5abgCLVzavBp+f2n8Ld5YTOZlkv/ccYPhu3X9s+MEeqPRMqKlJz/kWDK8A==} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + rollup@4.53.5: resolution: {integrity: sha512-iTNAbFSlRpcHeeWu73ywU/8KuU/LZmNCSxp6fjQkJBD3ivUb8tpDrXhIxEzA05HlYMEwmtaUnb3RP+YNv162OQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -577,6 +619,11 @@ packages: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -784,6 +831,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@playwright/test@1.58.2': + dependencies: + playwright: 1.58.2 + '@rollup/rollup-android-arm-eabi@4.53.5': optional: true @@ -865,7 +916,7 @@ snapshots: dependencies: undici-types: 7.16.0 - '@vitest/coverage-v8@4.0.16(vitest@4.0.16(@types/node@24.10.4))': + '@vitest/coverage-v8@4.0.16(vitest@4.0.16(@types/node@24.10.4)(tsx@4.21.0))': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.0.16 @@ -878,7 +929,7 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.16(@types/node@24.10.4) + vitest: 4.0.16(@types/node@24.10.4)(tsx@4.21.0) transitivePeerDependencies: - supports-color @@ -891,13 +942,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.16(vite@7.3.0(@types/node@24.10.4))': + '@vitest/mocker@4.0.16(vite@7.3.0(@types/node@24.10.4)(tsx@4.21.0))': dependencies: '@vitest/spy': 4.0.16 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.0(@types/node@24.10.4) + vite: 7.3.0(@types/node@24.10.4)(tsx@4.21.0) '@vitest/pretty-format@4.0.16': dependencies: @@ -1032,13 +1083,24 @@ snapshots: expect-type@1.3.0: {} + fast-check@4.6.0: + dependencies: + pure-rand: 8.3.0 + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true + get-tsconfig@4.13.6: + dependencies: + resolve-pkg-maps: 1.0.0 + has-flag@4.0.0: {} html-escaper@2.0.2: {} @@ -1092,12 +1154,24 @@ snapshots: picomatch@4.0.3: {} + playwright-core@1.58.2: {} + + playwright@1.58.2: + dependencies: + playwright-core: 1.58.2 + optionalDependencies: + fsevents: 2.3.2 + postcss@8.5.6: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 source-map-js: 1.2.1 + pure-rand@8.3.0: {} + + resolve-pkg-maps@1.0.0: {} + rollup@4.53.5: dependencies: '@types/estree': 1.0.8 @@ -1151,11 +1225,18 @@ snapshots: tinyrainbow@3.0.3: {} + tsx@4.21.0: + dependencies: + esbuild: 0.27.2 + get-tsconfig: 4.13.6 + optionalDependencies: + fsevents: 2.3.3 + typescript@5.9.3: {} undici-types@7.16.0: {} - vite@7.3.0(@types/node@24.10.4): + vite@7.3.0(@types/node@24.10.4)(tsx@4.21.0): dependencies: esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.3) @@ -1166,11 +1247,12 @@ snapshots: optionalDependencies: '@types/node': 24.10.4 fsevents: 2.3.3 + tsx: 4.21.0 - vitest@4.0.16(@types/node@24.10.4): + vitest@4.0.16(@types/node@24.10.4)(tsx@4.21.0): dependencies: '@vitest/expect': 4.0.16 - '@vitest/mocker': 4.0.16(vite@7.3.0(@types/node@24.10.4)) + '@vitest/mocker': 4.0.16(vite@7.3.0(@types/node@24.10.4)(tsx@4.21.0)) '@vitest/pretty-format': 4.0.16 '@vitest/runner': 4.0.16 '@vitest/snapshot': 4.0.16 @@ -1187,7 +1269,7 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.0(@types/node@24.10.4) + vite: 7.3.0(@types/node@24.10.4)(tsx@4.21.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.10.4 diff --git a/pyproject.toml b/pyproject.toml index e75dffd..9c26e45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,9 +2,15 @@ name = "comfyui-node-organizer" description = "ComfyUI extension to organize nodes" version = "1.3.0" +requires-python = ">=3.13" readme = "README.md" license = { file = "LICENSE" } +[dependency-groups] +dev = [ + "bump-my-version>=1.2.2", +] + [project.urls] Repository = "https://github.com/PBandDev/comfyui-node-organizer" @@ -19,8 +25,8 @@ includes = ['dist/'] current_version = "1.3.0" parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)" serialize = ["{major}.{minor}.{patch}"] -commit = true -tag = true +commit = false +tag = false tag_name = "v{new_version}" tag_message = "Release version {new_version}" message = "chore: bump version to {new_version} [skip ci]" @@ -36,6 +42,6 @@ search = '"version": "{current_version}"' replace = '"version": "{new_version}"' [[tool.bumpversion.files]] -filename = "src/index.ts" -search = 'Version {current_version}' -replace = 'Version {new_version}' +filename = "src/settings.ts" +search = 'export const CURRENT_VERSION = "{current_version}";' +replace = 'export const CURRENT_VERSION = "{new_version}";' diff --git a/scripts/setup-test-comfy-helpers.ts b/scripts/setup-test-comfy-helpers.ts new file mode 100644 index 0000000..622e0b8 --- /dev/null +++ b/scripts/setup-test-comfy-helpers.ts @@ -0,0 +1,16 @@ +export function extractPinnedWorkflowTemplatesRequirement( + requirementsText: string, +): string { + const requirement = requirementsText + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => line.startsWith("comfyui-workflow-templates==")); + + if (!requirement) { + throw new Error( + "Pinned ComfyUI checkout does not declare comfyui-workflow-templates", + ); + } + + return requirement; +} diff --git a/scripts/setup-test-comfy.ts b/scripts/setup-test-comfy.ts new file mode 100644 index 0000000..7ff7308 --- /dev/null +++ b/scripts/setup-test-comfy.ts @@ -0,0 +1,175 @@ +import { execSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + readFileSync, + symlinkSync, + readlinkSync, + rmSync, +} from "node:fs"; +import { resolve, join } from "node:path"; +import { e2eConfig } from "../e2e.config.ts"; +import { extractPinnedWorkflowTemplatesRequirement } from "./setup-test-comfy-helpers"; + +const isWindows = process.platform === "win32"; +const projectRoot = resolve(import.meta.dirname, ".."); + +const venvDir = resolve(projectRoot, e2eConfig.venvDir); +const comfyInstallDir = resolve(projectRoot, e2eConfig.comfyInstallDir); +const customNodesDir = resolve(projectRoot, e2eConfig.customNodesDir); + +const pythonBin = isWindows + ? join(venvDir, "Scripts", "python.exe") + : join(venvDir, "bin", "python"); + +const comfyBin = isWindows + ? join(venvDir, "Scripts", "comfy.exe") + : join(venvDir, "bin", "comfy"); + +function tryExec(cmd: string): string | null { + try { + return execSync(cmd, { + cwd: projectRoot, + stdio: ["ignore", "pipe", "ignore"], + encoding: "utf-8", + }).trim(); + } catch { + return null; + } +} + +function run(cmd: string, label: string) { + console.log(`[setup] ${label}...`); + console.log(`[setup] $ ${cmd}`); + execSync(cmd, { stdio: "inherit", cwd: projectRoot }); +} + +function getInstalledComfyRevision(): string | null { + const exactTag = tryExec( + `git -C "${comfyInstallDir}" describe --tags --exact-match HEAD`, + ); + if (exactTag) return exactTag; + + const gitHead = tryExec(`git -C "${comfyInstallDir}" rev-parse HEAD`); + return gitHead; +} + +function installComfy(revision: string): void { + run( + `"${comfyBin}" --skip-prompt --workspace "${comfyInstallDir}" install --version "${revision}" --restore --cpu`, + `Installing ComfyUI ${revision}`, + ); +} + +function getPinnedWorkflowTemplatesRequirement(): string { + const requirementsPath = resolve(comfyInstallDir, "requirements.txt"); + if (!existsSync(requirementsPath)) { + throw new Error( + `Pinned ComfyUI checkout is missing requirements.txt at ${requirementsPath}`, + ); + } + + return extractPinnedWorkflowTemplatesRequirement( + readFileSync(requirementsPath, "utf-8"), + ); +} + +// Step 1: Create venv (with --seed so pip is available for comfy-cli) +if (existsSync(pythonBin)) { + console.log("[setup] venv already exists, skipping."); +} else { + if (existsSync(venvDir)) { + console.log("[setup] Removing invalid venv directory before recreate..."); + rmSync(venvDir, { recursive: true, force: true }); + } + mkdirSync(resolve(projectRoot, e2eConfig.testComfyDir), { recursive: true }); + run(`uv venv --seed "${venvDir}"`, "Creating Python venv (with pip)"); +} + +// Ensure pip is available (handles venvs created without --seed) +try { + execSync(`"${pythonBin}" -m pip --version`, { stdio: "ignore" }); +} catch { + console.log("[setup] pip not found in venv, installing..."); + run( + `uv pip install pip --python "${pythonBin}"`, + "Installing pip into venv", + ); +} + +// Step 2: Install comfy-cli +if (existsSync(comfyBin)) { + console.log("[setup] comfy-cli already installed, skipping."); +} else { + run( + `uv pip install comfy-cli --python "${pythonBin}"`, + "Installing comfy-cli", + ); +} + +// Step 3: Install ComfyUI +// Check for main.py directly in workspace (comfy clones repo as workspace root) +if (existsSync(join(comfyInstallDir, "main.py"))) { + console.log("[setup] ComfyUI repo already cloned."); + const installedRevision = getInstalledComfyRevision(); + + if (installedRevision === e2eConfig.comfyRevision) { + try { + execSync(`"${pythonBin}" -c "import torch"`, { stdio: "ignore" }); + console.log( + `[setup] ComfyUI already pinned at ${e2eConfig.comfyRevision} and dependencies are installed.`, + ); + } catch { + installComfy(e2eConfig.comfyRevision); + } + } else { + console.log( + `[setup] Syncing ComfyUI to ${e2eConfig.comfyRevision} (current: ${installedRevision ?? "unknown"})...`, + ); + installComfy(e2eConfig.comfyRevision); + } +} else { + // Fresh install: clean up any invalid workspace state + if (existsSync(comfyInstallDir)) { + console.log( + "[setup] Removing invalid workspace directory before install...", + ); + rmSync(comfyInstallDir, { recursive: true, force: true }); + } + mkdirSync(resolve(comfyInstallDir, ".."), { recursive: true }); + installComfy(e2eConfig.comfyRevision); +} + +// Step 3b: Install the exact workflow templates package required by the +// pinned ComfyUI checkout. Do not hardcode or float this version here. +const workflowTemplatesRequirement = getPinnedWorkflowTemplatesRequirement(); +run( + `uv pip install "${workflowTemplatesRequirement}" --python "${pythonBin}"`, + `Installing ${workflowTemplatesRequirement}`, +); + +// Step 4: Symlink extension into custom_nodes +const symlinkTarget = resolve(customNodesDir, "comfy-node-organizer"); +if (existsSync(symlinkTarget)) { + try { + const linkDest = readlinkSync(symlinkTarget); + const resolvedLink = resolve(customNodesDir, linkDest); + if (resolvedLink === projectRoot) { + console.log("[setup] Symlink already exists and is correct, skipping."); + } else { + console.log( + `[setup] WARNING: Symlink exists but points to ${resolvedLink}, expected ${projectRoot}`, + ); + } + } catch { + console.log( + "[setup] WARNING: Path exists but is not a symlink. Manual cleanup may be needed.", + ); + } +} else { + mkdirSync(customNodesDir, { recursive: true }); + symlinkSync(projectRoot, symlinkTarget, isWindows ? "junction" : "dir"); + console.log(`[setup] Created symlink: ${symlinkTarget} -> ${projectRoot}`); +} + +console.log("[setup] Done! Run `pnpm test:e2e` to run e2e tests."); diff --git a/src/adapter.ts b/src/adapter.ts new file mode 100644 index 0000000..73f2429 --- /dev/null +++ b/src/adapter.ts @@ -0,0 +1,451 @@ +/** + * Bridge between ComfyUI's LGraph runtime objects and the pure layout types. + * + * Uses structural typing to avoid tight coupling to ComfyUI class hierarchy. + * Handles Float64Array pos/size fields, Map-based link storage, and group + * hierarchy inference via spatial containment. + */ + +import type { + LayoutNode, + LayoutEdge, + LayoutGroup, + Position, + GroupBounds, + LayoutToken, + FrameworkResult, +} from "./layout/types"; +import { parseLayoutToken } from "./layout/tokens"; +import { debugLog } from "./debug"; +import { isGroupInsideGroup, isNodeCenterInsideGroup } from "./group-geometry"; + +// --------------------------------------------------------------------------- +// Structural types for ComfyUI runtime objects +// --------------------------------------------------------------------------- + +interface GraphNode { + readonly id: number | string; + readonly type: string; + readonly title: string; + readonly pos: ArrayLike; + readonly size: ArrayLike; + readonly inputs?: ReadonlyArray<{ link: number | null }>; + readonly outputs?: ReadonlyArray<{ links: number[] | null }>; +} + +interface GraphBoundaryNode { + readonly id: number; + readonly pos: ArrayLike; + readonly size: ArrayLike; +} + +interface GraphGroup { + readonly id: number; + readonly title: string; + pos: ArrayLike; + size: ArrayLike; + readonly _children?: ReadonlySet<{ readonly id: number | string }>; +} + +interface GraphLink { + readonly id: number; + readonly origin_id: number; + readonly target_id: number; +} + +/** Minimal structural interface for ComfyUI graph objects (LGraph | Subgraph). */ +export interface GraphLike { + readonly _nodes: ReadonlyArray; + readonly _groups: GraphGroup[]; + readonly links: Map | Record; + readonly inputNode?: GraphBoundaryNode; + readonly outputNode?: GraphBoundaryNode; + 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 +// --------------------------------------------------------------------------- + +export function extractLayoutInput(graph: GraphLike): { + nodes: LayoutNode[]; + edges: LayoutEdge[]; + groups: LayoutGroup[]; +} { + // 1. Convert nodes + const nodes: LayoutNode[] = graph._nodes.map((n) => ({ + id: String(n.id), + width: Number(n.size[0]), + height: Number(n.size[1]), + })); + + if (graph.inputNode) { + nodes.push({ + id: String(graph.inputNode.id), + width: Number(graph.inputNode.size[0]), + height: Number(graph.inputNode.size[1]), + kind: "subgraph-input", + layerConstraint: "first", + }); + } + + if (graph.outputNode) { + nodes.push({ + id: String(graph.outputNode.id), + width: Number(graph.outputNode.size[0]), + height: Number(graph.outputNode.size[1]), + kind: "subgraph-output", + layerConstraint: "last", + }); + } + + // 2. Extract edges from links + const edges: LayoutEdge[] = []; + const linksIterable = + graph.links instanceof Map + ? graph.links.values() + : Object.values(graph.links); + + for (const link of linksIterable) { + if (link) { + edges.push({ + source: String(link.origin_id), + target: String(link.target_id), + }); + } + } + + // 3. Build group hierarchy + const groups = buildGroupsFromGraph(graph); + + debugLog( + `Extracted: ${nodes.length} nodes, ${edges.length} edges, ${groups.length} groups`, + ); + return { nodes, edges, groups }; +} + +// --------------------------------------------------------------------------- +// Build LayoutGroup array with parent-child hierarchy from spatial containment +// --------------------------------------------------------------------------- + +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 childGroupIds = new Map(); + const groupAreas = new Map(); + for (const group of graphGroups) { + groupAreas.set(group.id, Number(group.size[0]) * Number(group.size[1])); + } + + // 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), + ...(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); +} + +// --------------------------------------------------------------------------- +// Apply layout output back to the ComfyUI graph +// --------------------------------------------------------------------------- + +export function applyLayoutOutput( + graph: GraphLike, + result: { + readonly positions: ReadonlyMap; + readonly groupBounds: ReadonlyMap; + }, +): void { + // Apply node positions + for (const node of graph._nodes) { + const pos = result.positions.get(String(node.id)); + if (pos) { + // Mutate in-place — ComfyUI internals may hold references to the typed array + const mutablePos = node.pos as { [index: number]: number }; + mutablePos[0] = pos.x; + mutablePos[1] = pos.y; + } + } + + if (graph.inputNode) { + const pos = result.positions.get(String(graph.inputNode.id)); + if (pos) { + const mutablePos = graph.inputNode.pos as { [index: number]: number }; + mutablePos[0] = pos.x; + mutablePos[1] = pos.y; + } + } + + if (graph.outputNode) { + const pos = result.positions.get(String(graph.outputNode.id)); + if (pos) { + const mutablePos = graph.outputNode.pos as { [index: number]: number }; + mutablePos[0] = pos.x; + mutablePos[1] = pos.y; + } + } + + // Apply group bounds + for (const group of graph._groups) { + const namespacedGroupId = toGroupLayoutId(group.id); + const resolvedBounds = result.groupBounds.get(namespacedGroupId); + if (resolvedBounds) { + const mutablePos = group.pos as { [index: number]: number }; + mutablePos[0] = resolvedBounds.x; + mutablePos[1] = resolvedBounds.y; + + const mutableSize = group.size as { [index: number]: number }; + mutableSize[0] = resolvedBounds.width; + mutableSize[1] = resolvedBounds.height; + + // NOTE: Do NOT call recomputeInsideNodes() here. + // Our adapter mutates pos/size arrays in-place, which doesn't trigger + // ComfyUI's internal cache invalidation. recomputeInsideNodes() then + // reads stale pre-organize positions and populates _children with + // wrong node IDs. Verified 2026-03-21 via runtime inspection. + // See AGENTS.md for more details. + } + } + + // Mark canvas as dirty + if (graph.setDirtyCanvas) { + graph.setDirtyCanvas(true, true); + } + + debugLog( + `Applied: ${result.positions.size} positions, ${result.groupBounds.size} group bounds`, + ); +} + +// --------------------------------------------------------------------------- +// Extract layout input for specific groups only (selected-groups mode) +// --------------------------------------------------------------------------- + +export function extractGroupLayoutInput( + graph: GraphLike, + groupIds: ReadonlySet, +): { + nodes: LayoutNode[]; + edges: LayoutEdge[]; + groups: LayoutGroup[]; +} { + // Get the full extraction first + const full = extractLayoutInput(graph); + + // Filter to only the selected groups and their nested children + const selectedLayoutGroupIds = new Set( + [...groupIds].map((groupId) => toGroupLayoutId(groupId)), + ); + + // Expand to include all nested child groups + const allGroupIds = new Set(selectedLayoutGroupIds); + const groupMap = new Map(full.groups.map((g) => [g.id, g])); + + function addChildren(gId: string): void { + const g = groupMap.get(gId); + if (!g) return; + for (const childId of g.childGroupIds) { + if (!allGroupIds.has(childId)) { + allGroupIds.add(childId); + addChildren(childId); + } + } + } + + for (const gId of selectedLayoutGroupIds) { + addChildren(gId); + } + + // Filter groups + const groups = full.groups.filter((g) => allGroupIds.has(g.id)); + + // Collect all node IDs that belong to these groups + const relevantNodeIds = new Set(); + for (const g of groups) { + for (const mId of g.memberIds) { + relevantNodeIds.add(mId); + } + } + + // Filter nodes and edges + const nodes = full.nodes.filter((n) => relevantNodeIds.has(n.id)); + const edges = full.edges.filter( + (e) => relevantNodeIds.has(e.source) && relevantNodeIds.has(e.target), + ); + + return { nodes, edges, groups }; +} + +export function anchorSelectedGroupLayoutResult( + graph: GraphLike, + groups: ReadonlyArray, + selectedGroupIds: ReadonlySet, + result: FrameworkResult, +): FrameworkResult { + const selectedLayoutGroupIds = new Set( + [...selectedGroupIds].map((groupId) => toGroupLayoutId(groupId)), + ); + if (selectedLayoutGroupIds.size === 0) { + return result; + } + + const parentByChildId = new Map(); + const groupById = new Map(groups.map((group) => [group.id, group])); + for (const group of groups) { + for (const childGroupId of group.childGroupIds) { + parentByChildId.set(childGroupId, group.id); + } + } + + const selectedRootIds = [...selectedLayoutGroupIds].filter((groupId) => { + let parentId = parentByChildId.get(groupId); + while (parentId) { + if (selectedLayoutGroupIds.has(parentId)) { + return false; + } + parentId = parentByChildId.get(parentId); + } + return true; + }); + + if (selectedRootIds.length === 0) { + return result; + } + + const anchoredPositions = new Map(result.positions); + const anchoredGroupBounds = new Map(result.groupBounds); + const graphGroupById = new Map( + graph._groups.map((group) => [group.id, group]), + ); + + for (const rootGroupId of selectedRootIds) { + const originalGraphGroupId = fromGroupLayoutId(rootGroupId); + if (originalGraphGroupId === null) continue; + + const originalGroup = graphGroupById.get(originalGraphGroupId); + const layoutRootBounds = anchoredGroupBounds.get(rootGroupId); + if (!originalGroup || !layoutRootBounds) continue; + + const dx = Number(originalGroup.pos[0]) - layoutRootBounds.x; + const dy = Number(originalGroup.pos[1]) - layoutRootBounds.y; + if (dx === 0 && dy === 0) continue; + + const nodeIdsToTranslate = new Set(); + const groupIdsToTranslate = new Set(); + const stack = [rootGroupId]; + + while (stack.length > 0) { + const groupId = stack.pop(); + if (!groupId || groupIdsToTranslate.has(groupId)) continue; + + groupIdsToTranslate.add(groupId); + const group = groupById.get(groupId); + if (!group) continue; + + for (const memberId of group.memberIds) { + nodeIdsToTranslate.add(memberId); + } + for (const childGroupId of group.childGroupIds) { + stack.push(childGroupId); + } + } + + for (const nodeId of nodeIdsToTranslate) { + const pos = anchoredPositions.get(nodeId); + if (!pos) continue; + anchoredPositions.set(nodeId, { x: pos.x + dx, y: pos.y + dy }); + } + + for (const groupId of groupIdsToTranslate) { + const bounds = anchoredGroupBounds.get(groupId); + if (!bounds) continue; + anchoredGroupBounds.set(groupId, { + x: bounds.x + dx, + y: bounds.y + dy, + width: bounds.width, + height: bounds.height, + }); + } + } + + return { + positions: anchoredPositions, + groupBounds: anchoredGroupBounds, + }; +} diff --git a/src/bounds.ts b/src/bounds.ts new file mode 100644 index 0000000..285d7c0 --- /dev/null +++ b/src/bounds.ts @@ -0,0 +1,49 @@ +import type { Position, GroupBounds } from "./layout/types"; + +export interface BoundsItem { + readonly id: number | string; + readonly pos: ArrayLike; + readonly size: ArrayLike; +} + +/** + * Compute the bounding rect of all laid-out items for fit-to-view. + * Includes regular nodes, boundary nodes (inputNode/outputNode), and groups. + */ +export function computeGraphBounds( + nodes: ReadonlyArray, + inputNode: BoundsItem | undefined, + outputNode: BoundsItem | undefined, + positions: ReadonlyMap, + groupBounds: ReadonlyMap, +): [number, number, number, number] | null { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + + const expandWithItem = (item: BoundsItem): void => { + const pos = positions.get(String(item.id)); + if (!pos) return; + const w = item.size[0] ?? 0; + const h = item.size[1] ?? 0; + if (pos.x < minX) minX = pos.x; + if (pos.y < minY) minY = pos.y; + if (pos.x + w > maxX) maxX = pos.x + w; + if (pos.y + h > maxY) maxY = pos.y + h; + }; + + for (const node of nodes) { + expandWithItem(node); + } + + if (inputNode) expandWithItem(inputNode); + if (outputNode) expandWithItem(outputNode); + + for (const [, gb] of groupBounds) { + if (gb.x < minX) minX = gb.x; + if (gb.y < minY) minY = gb.y; + if (gb.x + gb.width > maxX) maxX = gb.x + gb.width; + if (gb.y + gb.height > maxY) maxY = gb.y + gb.height; + } + + if (!Number.isFinite(minX)) return null; + return [minX, minY, maxX - minX, maxY - minY]; +} diff --git a/src/consants.ts b/src/consants.ts deleted file mode 100644 index ce2f405..0000000 --- a/src/consants.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const SETTINGS_PREFIX = "Node Organizer"; -export const SETTINGS_IDS = { - VERSION: `${SETTINGS_PREFIX}. ${SETTINGS_PREFIX}`, - DEBUG_LOGGING: `${SETTINGS_PREFIX}.Debug Logging`, -}; diff --git a/src/debug.ts b/src/debug.ts index 624f106..1390872 100644 --- a/src/debug.ts +++ b/src/debug.ts @@ -1,4 +1,4 @@ -import { SETTINGS_IDS } from "./consants"; +import { SETTING_IDS } from "./settings"; /** Check if debug logging is enabled */ export function isDebugEnabled(): boolean { @@ -8,13 +8,15 @@ export function isDebugEnabled(): boolean { const app = ( globalThis as { app?: { - extensionManager?: { setting?: { get: (id: string) => T } }; + extensionManager?: { + setting?: { get: (id: string) => T }; + }; }; } ).app; return ( app?.extensionManager?.setting?.get( - SETTINGS_IDS.DEBUG_LOGGING + SETTING_IDS.DEBUG_LOGGING, ) ?? false ); } diff --git a/src/group-geometry.ts b/src/group-geometry.ts new file mode 100644 index 0000000..8cbfa05 --- /dev/null +++ b/src/group-geometry.ts @@ -0,0 +1,43 @@ +export interface PositionedItem { + readonly pos: ArrayLike; + readonly size: ArrayLike; +} + +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 + ); +} + +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 ( + centerX >= gx && + centerX <= gx + gw && + centerY >= gy && + centerY <= gy + gh + ); +} diff --git a/src/index.ts b/src/index.ts index 9ef9f39..25f80c0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,181 +1,413 @@ +/** + * ComfyUI Node Organizer — extension entry point. + * + * Registers commands, keybindings, settings, and context menu items + * that wire the pure layout framework into ComfyUI's runtime. + */ + import type { ComfyApp } from "@comfyorg/comfyui-frontend-types"; -import type { LGraph, LGraphGroup } from "./layout/types"; -import { layoutGraph, layoutSelectedGroups } from "./layout"; -import { SETTINGS_IDS } from "./consants"; - -/** Context menu option */ -interface ContextMenuOption { - content: string; - callback?: () => void; -} - -/** LGraphCanvas type (available at runtime) */ -interface LGraphCanvasType { - prototype: { - getCanvasMenuOptions: () => (ContextMenuOption | null)[]; - }; -} +import { + SETTING_IDS, + EXTENSION_NAME, + SETTINGS_PREFIX, + REPOSITORY_URL, + CURRENT_VERSION, + DEFAULT_ALGORITHM_OPTIONS, +} from "./settings"; +import type { DefaultAlgorithmName } from "./settings"; +import { debugLog } from "./debug"; +import { + extractLayoutInput, + extractGroupLayoutInput, + applyLayoutOutput, + anchorSelectedGroupLayoutResult, +} 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"; +import { isGroup } from "./utils"; +import { getCurrentGraph, getSelectedGroups, type AppLike } from "./runtime"; +// Declare the global app variable injected by ComfyUI's runtime loader declare global { - const app: ComfyApp & { - canvas?: { - selectedItems?: Set; - }; + const app: ComfyApp; +} + +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 +// --------------------------------------------------------------------------- + +function getDefaultAlgorithmName(): DefaultAlgorithmName { + const raw = (app.extensionManager?.setting?.get?.( + SETTING_IDS.DEFAULT_ALGORITHM, + ) ?? DEFAULT_ALGORITHM_NAME) as string; + + return DEFAULT_ALGORITHM_OPTIONS.includes(raw as DefaultAlgorithmName) + ? (raw as DefaultAlgorithmName) + : DEFAULT_ALGORITHM_NAME; +} + +export function createConfiguredDefaultAlgorithm( + algorithmName: DefaultAlgorithmName, + config: Partial, +): LayoutAlgorithm { + return algorithmFactories[algorithmName](config); +} + +function getDefaultAlgorithm(config: Partial): LayoutAlgorithm { + return createConfiguredDefaultAlgorithm(getDefaultAlgorithmName(), config); +} + +function getFrameworkConfig(): Partial { + const get = app.extensionManager?.setting?.get; + if (!get) return {}; + return { + horizontalGap: + get(SETTING_IDS.HORIZONTAL_GAP as never) ?? + DEFAULT_FRAMEWORK_CONFIG.horizontalGap, + verticalGap: + get(SETTING_IDS.VERTICAL_GAP as never) ?? + DEFAULT_FRAMEWORK_CONFIG.verticalGap, + groupPadding: + get(SETTING_IDS.GROUP_PADDING as never) ?? + DEFAULT_FRAMEWORK_CONFIG.groupPadding, + disconnectedGap: + get(SETTING_IDS.DISCONNECTED_GAP as never) ?? + DEFAULT_FRAMEWORK_CONFIG.disconnectedGap, }; - const LGraphCanvas: LGraphCanvasType; - - interface Window { - app: ComfyApp; - } } -/** - * Check if value is array-like (regular array or typed array like Float64Array) - */ -function isArrayLike(value: unknown): boolean { - if (Array.isArray(value)) return true; - if (!value || typeof value !== "object") return false; - // Check for typed arrays (Float64Array, etc.) which have numeric length - const obj = value as { length?: unknown }; - return typeof obj.length === "number" && obj.length >= 0; -} - -/** - * Check if an item is a group (duck typing) - * Groups have pos/size (can be Float64Array), title, id, but no .type like nodes - */ -function isLGraphGroup(item: unknown): item is LGraphGroup { - if (!item || typeof item !== "object") return false; - const obj = item as Record; +function getFitToView(): boolean { return ( - isArrayLike(obj.pos) && - isArrayLike(obj.size) && - typeof obj.title === "string" && - typeof obj.id === "number" && - typeof obj.type !== "string" // Nodes have .type, groups don't + app.extensionManager?.setting?.get?.( + SETTING_IDS.FIT_TO_VIEW as never, + ) ?? false ); } -/** - * Get selected groups from global app.canvas.selectedItems - * Per ComfyUI docs: use app.canvas.selectedItems for selection access - */ -function getSelectedGroups(): LGraphGroup[] { - const groups: LGraphGroup[] = []; - const selectedItems = app.canvas?.selectedItems; - if (!selectedItems) return groups; +// --------------------------------------------------------------------------- +// Core actions +// --------------------------------------------------------------------------- - for (const item of selectedItems) { - if (isLGraphGroup(item)) { - groups.push(item); +/** Organize every node in the current graph. */ +function executeOrganization(mode: "workflow" | "groups"): void { + const graph = getCurrentGraph(app as AppLike); + if (!graph) { + console.warn("[node-organizer] No active graph"); + return; + } + + const config = getFrameworkConfig(); + const algorithm = getDefaultAlgorithm(config); + + const start = performance.now(); + debugLog(`Starting ${mode} organization with ${algorithm.name}...`); + + try { + const selectedGroupIds = + mode === "groups" + ? new Set(getSelectedGroups(app.canvas).map((group) => group.id)) + : null; + const input = + mode === "workflow" + ? extractLayoutInput(graph) + : extractGroupLayoutInput(graph, selectedGroupIds ?? new Set()); + const { nodes, edges, groups } = input; + const rawResult = layoutWithGroups(nodes, edges, groups, algorithm, config); + const result = + mode === "groups" && selectedGroupIds + ? anchorSelectedGroupLayoutResult( + graph, + groups, + selectedGroupIds, + rawResult, + ) + : rawResult; + applyLayoutOutput(graph, result); + + const elapsed = (performance.now() - start).toFixed(1); + debugLog( + `Organized ${nodes.length} nodes in ${elapsed}ms with ${algorithm.name}`, + ); + + if (mode === "workflow" && getFitToView()) { + const bounds = computeGraphBounds( + graph._nodes, + graph.inputNode, + graph.outputNode, + result.positions, + result.groupBounds, + ); + + if (bounds) { + // Defer by one frame: applyLayoutOutput calls setDirtyCanvas, but the + // canvas needs one animation frame to process the dirty flag. + requestAnimationFrame(() => { + const canvas = app.canvas as { + animateToBounds?: ( + bounds: readonly [number, number, number, number], + opts?: { zoom?: number }, + ) => void; + } | null; + canvas?.animateToBounds?.(bounds, { zoom: 0.9 }); + }); + } } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`[node-organizer] ${mode} layout failed: ${message}`); } - return groups; } -app.registerExtension({ - name: "ComfyUI Node Organizer", - settings: [ - { - id: SETTINGS_IDS.VERSION, - name: "Version 1.3.0", - type: () => { - const spanEl = document.createElement("span"); - spanEl.insertAdjacentHTML( - "beforeend", - `Homepage` - ); +/** Organize the full workflow. */ +function organizeWorkflow(): void { + executeOrganization("workflow"); +} - return spanEl; - }, - defaultValue: undefined, +/** Organize nodes within currently selected groups. */ +function organizeSelectedGroups(): void { + const selectedGroups = getSelectedGroups(app.canvas); + if (selectedGroups.length === 0) { + console.warn("[node-organizer] No groups selected"); + return; + } + executeOrganization("groups"); +} + +/** Context-aware organize: selected groups if any, otherwise full workflow. */ +function organizeContextAware(): void { + const selectedGroups = getSelectedGroups(app.canvas); + if (selectedGroups.length > 0) { + executeOrganization("groups"); + } else { + executeOrganization("workflow"); + } +} + +// --------------------------------------------------------------------------- +// Extension registration +// --------------------------------------------------------------------------- + +app.registerExtension({ + name: EXTENSION_NAME, + + settings: [ + // --- About (sortOrder 50 — highest = first in ComfyUI's descending sort) --- + { + id: SETTING_IDS.VERSION as never, + name: `Version ${CURRENT_VERSION}`, + category: [SETTINGS_PREFIX, "About", "Version"], + sortOrder: 50, + type: (() => { + const span = document.createElement("span"); + const link = document.createElement("a"); + link.href = REPOSITORY_URL; + link.target = "_blank"; + link.rel = "noopener noreferrer"; + link.style.paddingRight = "12px"; + link.textContent = "Homepage"; + span.appendChild(link); + return span; + }) as never, + defaultValue: undefined as never, + }, + // --- Layout (sortOrder 40–44) --- + { + id: SETTING_IDS.DEFAULT_ALGORITHM as never, + name: "Default Layout Algorithm", + category: [SETTINGS_PREFIX, "Layout", "Algorithm"], + sortOrder: 44, + type: "combo", + tooltip: + "Default algorithm for organize commands when a group title token does not override it", + options: DEFAULT_ALGORITHM_OPTIONS.map((value) => ({ + text: value[0].toUpperCase() + value.slice(1), + value, + })), + defaultValue: DEFAULT_ALGORITHM_NAME, }, { - id: SETTINGS_IDS.DEBUG_LOGGING, - name: "Enable Debug Logging", + id: SETTING_IDS.HORIZONTAL_GAP as never, + name: "Horizontal Gap", + category: [SETTINGS_PREFIX, "Layout", "Horizontal Gap"], + sortOrder: 43, + type: "slider", + tooltip: `Space between nodes left-to-right in pixels. Default: ${DEFAULT_FRAMEWORK_CONFIG.horizontalGap}`, + attrs: { min: 20, max: 300, step: 10 }, + defaultValue: DEFAULT_FRAMEWORK_CONFIG.horizontalGap, + }, + { + id: SETTING_IDS.VERTICAL_GAP as never, + name: "Vertical Gap", + category: [SETTINGS_PREFIX, "Layout", "Vertical Gap"], + sortOrder: 42, + type: "slider", + tooltip: `Space between nodes top-to-bottom in pixels. Default: ${DEFAULT_FRAMEWORK_CONFIG.verticalGap}`, + attrs: { min: 10, max: 150, step: 10 }, + defaultValue: DEFAULT_FRAMEWORK_CONFIG.verticalGap, + }, + { + id: SETTING_IDS.GROUP_PADDING as never, + name: "Group Padding", + category: [SETTINGS_PREFIX, "Layout", "Group Padding"], + sortOrder: 41, + type: "slider", + tooltip: `Inner padding of group boxes in pixels. Default: ${DEFAULT_FRAMEWORK_CONFIG.groupPadding}`, + attrs: { min: 10, max: 100, step: 5 }, + defaultValue: DEFAULT_FRAMEWORK_CONFIG.groupPadding, + }, + { + id: SETTING_IDS.DISCONNECTED_GAP as never, + name: "Disconnected Node Gap", + category: [SETTINGS_PREFIX, "Layout", "Disconnected Gap"], + sortOrder: 40, + type: "slider", + tooltip: `Gap between disconnected nodes (e.g. notes) and the main graph in pixels. Default: ${DEFAULT_FRAMEWORK_CONFIG.disconnectedGap}`, + attrs: { min: 50, max: 400, step: 10 }, + defaultValue: DEFAULT_FRAMEWORK_CONFIG.disconnectedGap, + }, + // --- Behavior (sortOrder 30) --- + { + id: SETTING_IDS.FIT_TO_VIEW as never, + name: "Fit to View After Organize", + category: [SETTINGS_PREFIX, "Behavior", "Fit to View"], + sortOrder: 30, type: "boolean", tooltip: - "Show detailed debug logs in browser console during workflow organization", + "Automatically zoom to fit the graph after organizing the full workflow. Does not apply to selected-group organize.", + defaultValue: false, + }, + // --- Keybindings (sortOrder 20) --- + { + id: SETTING_IDS.KEYBINDINGS as never, + name: "Keyboard Shortcuts", + category: [SETTINGS_PREFIX, "Keybindings", "Info"], + sortOrder: 20, + type: (() => { + const span = document.createElement("span"); + span.style.color = "var(--p-text-muted-color, #999)"; + span.textContent = + "Default: Shift+O (Organize Workflow). Edit shortcuts in Settings > Keybinding."; + return span; + }) as never, + defaultValue: undefined as never, + }, + // --- Advanced (sortOrder 10 — lowest = last in ComfyUI's descending sort) --- + { + id: SETTING_IDS.DEBUG_LOGGING as never, + name: "Enable Debug Logging", + category: [SETTINGS_PREFIX, "Advanced", "Debug Logging"], + sortOrder: 10, + type: "boolean", + tooltip: + "Show detailed debug logs in browser console during organization", defaultValue: false, }, ], - getCanvasMenuItems(canvas) { - const items: (ContextMenuOption | null)[] = [ + + commands: [ + { + id: "node-organizer.organize", + function: organizeContextAware, + label: "Organize", + icon: "pi pi-sitemap", + tooltip: + "Organize selected groups if any are selected, otherwise organize the full workflow", + }, + { + id: "node-organizer.organize-workflow", + function: organizeWorkflow, + label: "Organize Workflow", + icon: "pi pi-sitemap", + tooltip: "Organize all nodes in the current view", + }, + { + id: "node-organizer.organize-groups", + function: organizeSelectedGroups, + label: "Organize Group", + icon: "pi pi-objects-column", + tooltip: "Organize nodes within selected groups", + }, + ], + + keybindings: [ + { + commandId: "node-organizer.organize", + combo: { key: "o", shift: true }, + }, + ], + + menuCommands: [ + { + path: ["Extensions", "Node Organizer"], + commands: [ + "node-organizer.organize-workflow", + "node-organizer.organize-groups", + ], + }, + ], + + actionBarButtons: [ + { + label: "Organize", + icon: "pi pi-sitemap", + tooltip: "Organize the current workflow", + onClick: organizeWorkflow, + }, + ], + + getCanvasMenuItems() { + const items: Array<{ + content: string; + callback: () => void; + } | null> = [ null, // separator { content: "Organize Workflow", - callback: () => { - const graph = canvas.getCurrentGraph() as LGraph | null; - if (!graph) { - console.warn("[node-organizer] No active graph"); - return; - } - - try { - const result = layoutGraph(graph); - console.log( - `[node-organizer] Layout complete: ${result.nodeCount} nodes, ` + - `${result.layerCount} layers, ${result.groupCount} groups, ` + - `${result.executionMs.toFixed(1)}ms` - ); - } catch (err) { - console.error("[node-organizer] Layout failed:", err); - } - }, + callback: organizeWorkflow, }, ]; - // Check for selected groups via app.canvas.selectedItems - const selectedGroups = getSelectedGroups(); - + const selectedGroups = getSelectedGroups(app.canvas); if (selectedGroups.length === 1) { items.push({ content: "Organize Group", - callback: () => { - const graph = canvas.getCurrentGraph() as LGraph | null; - if (!graph) { - console.warn("[node-organizer] No active graph"); - return; - } - - try { - const groupIds = new Set([selectedGroups[0].id]); - const result = layoutSelectedGroups(graph, groupIds); - console.log( - `[node-organizer] Group organized: ${result.nodeCount} nodes, ` + - `${result.groupCount} groups, ${result.executionMs.toFixed(1)}ms` - ); - } catch (err) { - console.error("[node-organizer] Group layout failed:", err); - } - }, + callback: organizeSelectedGroups, }); } else if (selectedGroups.length > 1) { items.push({ content: `Organize ${selectedGroups.length} Groups`, - callback: () => { - const graph = canvas.getCurrentGraph() as LGraph | null; - if (!graph) { - console.warn("[node-organizer] No active graph"); - return; - } - - try { - const groupIds = new Set(selectedGroups.map((g) => g.id)); - const result = layoutSelectedGroups(graph, groupIds); - console.log( - `[node-organizer] Groups organized: ${result.nodeCount} nodes, ` + - `${result.groupCount} groups, ${result.executionMs.toFixed(1)}ms` - ); - } catch (err) { - console.error("[node-organizer] Groups layout failed:", err); - } - }, + callback: organizeSelectedGroups, }); } items.push(null); // separator - return items; }, + + getSelectionToolboxCommands(selectedItem) { + if (isGroup(selectedItem)) { + return ["node-organizer.organize-groups"]; + } + return []; + }, }); diff --git a/src/layout/algorithms/grid.ts b/src/layout/algorithms/grid.ts new file mode 100644 index 0000000..d7055ba --- /dev/null +++ b/src/layout/algorithms/grid.ts @@ -0,0 +1,78 @@ +/** + * Grid layout algorithm — distributes nodes into rows and columns. + * + * Supports specifying either a row count or column count. + * Nodes fill left-to-right, top-to-bottom. + * Ignores edges (token layouts ignore DAG structure). + */ + +import type { LayoutAlgorithm, LayoutInput, LayoutOutput, Position } from "../types"; + +const DEFAULT_GAP = 40; + +export interface GridConfig { + readonly rows?: number; + readonly columns?: number; + readonly gap?: number; +} + +export function createGridAlgorithm(config: GridConfig): LayoutAlgorithm { + const gap = config.gap ?? DEFAULT_GAP; + + return { + name: "grid", + layout(input: LayoutInput): LayoutOutput { + const positions = new Map(); + const { nodes } = input; + + if (nodes.length === 0) { + return { positions }; + } + + const n = nodes.length; + + // Determine grid dimensions + let rows: number; + let cols: number; + + if (config.rows !== undefined) { + rows = config.rows; + cols = Math.ceil(n / rows); + } else if (config.columns !== undefined) { + cols = config.columns; + rows = Math.ceil(n / cols); + } else { + // Default: sqrt(n) columns + cols = Math.ceil(Math.sqrt(n)); + rows = Math.ceil(n / cols); + } + + // Find max node dimensions for uniform cell sizing + let maxWidth = 0; + let maxHeight = 0; + for (const node of nodes) { + if (node.width > maxWidth) maxWidth = node.width; + if (node.height > maxHeight) maxHeight = node.height; + } + + const cellWidth = maxWidth + gap; + const cellHeight = maxHeight + gap; + + // Place nodes left-to-right, top-to-bottom + // Node index i goes to (row = floor(i / cols), col = i % cols) + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + const row = Math.floor(i / cols); + const col = i % cols; + + // Center node within its cell + const x = col * cellWidth + (maxWidth - node.width) / 2; + const y = row * cellHeight + (maxHeight - node.height) / 2; + + positions.set(node.id, { x, y }); + } + + return { positions }; + }, + }; +} diff --git a/src/layout/algorithms/horizontal.ts b/src/layout/algorithms/horizontal.ts new file mode 100644 index 0000000..d756a10 --- /dev/null +++ b/src/layout/algorithms/horizontal.ts @@ -0,0 +1,40 @@ +/** + * Horizontal layout algorithm — places nodes in a single left-to-right row. + * + * Ignores edges entirely (token layouts ignore DAG structure). + * Nodes are placed in input order; vertically centered by midpoint. + */ + +import type { LayoutAlgorithm, LayoutInput, LayoutOutput, Position } from "../types"; + +const DEFAULT_GAP = 40; + +export function createHorizontalAlgorithm(gap: number = DEFAULT_GAP): LayoutAlgorithm { + return { + name: "horizontal", + layout(input: LayoutInput): LayoutOutput { + const positions = new Map(); + const { nodes } = input; + + if (nodes.length === 0) { + return { positions }; + } + + // Find max height for vertical centering + let maxHeight = 0; + for (const node of nodes) { + if (node.height > maxHeight) maxHeight = node.height; + } + + // Place nodes left-to-right, centered vertically + let currentX = 0; + for (const node of nodes) { + const centeredY = maxHeight / 2 - node.height / 2; + positions.set(node.id, { x: currentX, y: centeredY }); + currentX += node.width + gap; + } + + return { positions }; + }, + }; +} diff --git a/src/layout/algorithms/sugiyama.ts b/src/layout/algorithms/sugiyama.ts new file mode 100644 index 0000000..6f0dadc --- /dev/null +++ b/src/layout/algorithms/sugiyama.ts @@ -0,0 +1,118 @@ +/** + * Sugiyama layered DAG layout algorithm. + * + * Implements the classic 4-phase approach: + * 1. Cycle breaking (greedy feedback arc set) + * 2. Layer assignment (longest-path) + * 3. Crossing minimization (barycenter heuristic) + * 4. Coordinate assignment (stacked + centered) + * + * All steps are pure functions — no side effects, deterministic output. + */ + +import type { + LayoutAlgorithm, + 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"; + +export interface SugiyamaConfig { + readonly horizontalGap: number; + readonly verticalGap: number; + readonly maxIterations: number; +} + +export const DEFAULT_SUGIYAMA_CONFIG: SugiyamaConfig = { + horizontalGap: 100, + verticalGap: 40, + maxIterations: 24, +}; + +/** + * Create a Sugiyama layout algorithm instance. + * The returned object implements LayoutAlgorithm and can be passed to + * the layout framework. + */ +export function createSugiyamaAlgorithm( + config?: Partial, +): LayoutAlgorithm { + const cfg: SugiyamaConfig = { ...DEFAULT_SUGIYAMA_CONFIG, ...config }; + + return { + name: "sugiyama", + + layout(input: LayoutInput): LayoutOutput { + const { nodes, edges } = input; + + if (nodes.length === 0) { + return { positions: new Map() }; + } + + // Build node lookup + const nodeMap = new Map(); + for (const n of nodes) { + nodeMap.set(n.id, n); + } + + // Phase 1: Break cycles + const { edges: acyclicEdges } = breakCycles(nodes, edges); + + // Phase 2: Assign layers (longest-path) + const layerMap = assignLayers(nodes, acyclicEdges); + + // Build layer arrays from the map + const layerArrays = buildLayerArrays(layerMap, nodes); + + // Phase 3: Minimize crossings (barycenter heuristic) + const orderedLayers = minimizeCrossings( + layerArrays, + acyclicEdges, + nodeMap, + cfg.maxIterations, + ); + + // Phase 4: Assign coordinates + const positions = assignCoordinates(orderedLayers, nodeMap, { + horizontalGap: cfg.horizontalGap, + verticalGap: cfg.verticalGap, + }); + + return { positions }; + }, + }; +} + +/** + * Convert a layer map (node -> layer index) into an array of arrays, + * preserving original node order within each layer for determinism. + */ +function buildLayerArrays( + layerMap: ReadonlyMap, + nodes: ReadonlyArray, +): ReadonlyArray> { + // Find max layer + let maxLayer = 0; + for (const layer of layerMap.values()) { + if (layer > maxLayer) maxLayer = layer; + } + + // Build arrays preserving input order within each layer + const layers: string[][] = []; + for (let i = 0; i <= maxLayer; i++) { + layers.push([]); + } + + for (const n of nodes) { + const layer = layerMap.get(n.id); + if (layer !== undefined) { + layers[layer].push(n.id); + } + } + + return layers; +} diff --git a/src/layout/algorithms/sugiyama/coordinate-assign.ts b/src/layout/algorithms/sugiyama/coordinate-assign.ts new file mode 100644 index 0000000..9dc895a --- /dev/null +++ b/src/layout/algorithms/sugiyama/coordinate-assign.ts @@ -0,0 +1,99 @@ +/** + * Coordinate assignment for layered graphs. + * + * Assigns X coordinates based on cumulative layer widths (left-to-right) + * and Y coordinates by stacking nodes top-to-bottom within each layer. + */ + +import type { LayoutNode, Position } from "../../types"; + +interface CoordinateConfig { + readonly horizontalGap: number; + readonly verticalGap: number; +} + +/** + * Assign (x, y) coordinates to all nodes based on their layer ordering. + * + * - X: each layer starts after the previous layer's max width + horizontalGap + * - Y: nodes within a layer are stacked vertically with verticalGap between them + * - Vertical centering: each layer is centered relative to the tallest layer + * - Overlap sweep: after centering, ensure no overlaps within each layer + */ +export function assignCoordinates( + layers: ReadonlyArray>, + nodeMap: ReadonlyMap, + config: CoordinateConfig, +): ReadonlyMap { + const positions = new Map(); + + if (layers.length === 0) return positions; + + // Compute max width per layer and total height per layer + const layerMaxWidths: number[] = []; + const layerTotalHeights: number[] = []; + + for (const layer of layers) { + let maxWidth = 0; + let totalHeight = 0; + for (let i = 0; i < layer.length; i++) { + const node = nodeMap.get(layer[i]); + if (!node) continue; + if (node.width > maxWidth) maxWidth = node.width; + totalHeight += node.height; + if (i > 0) totalHeight += config.verticalGap; + } + layerMaxWidths.push(maxWidth); + layerTotalHeights.push(totalHeight); + } + + // Find the tallest layer for vertical centering + let maxTotalHeight = 0; + for (const h of layerTotalHeights) { + if (h > maxTotalHeight) maxTotalHeight = h; + } + + // Compute X start positions for each layer + const layerXStarts: number[] = []; + let currentX = 0; + for (let li = 0; li < layers.length; li++) { + layerXStarts.push(currentX); + currentX += layerMaxWidths[li] + config.horizontalGap; + } + + // Assign positions: stack top-to-bottom within each layer, then center + for (let li = 0; li < layers.length; li++) { + const layer = layers[li]; + const layerX = layerXStarts[li]; + + // Center this layer vertically relative to tallest layer + const yOffset = (maxTotalHeight - layerTotalHeights[li]) / 2; + let currentY = yOffset; + + for (const nodeId of layer) { + const node = nodeMap.get(nodeId); + if (!node) continue; + + positions.set(nodeId, { x: layerX, y: currentY }); + currentY += node.height + config.verticalGap; + } + + // Overlap sweep: ensure no node overlaps the previous one in this layer. + // This handles edge cases where centering + variable sizes create overlaps. + for (let i = 1; i < layer.length; i++) { + const prevId = layer[i - 1]; + const currId = layer[i]; + const prevNode = nodeMap.get(prevId); + const prevPos = positions.get(prevId); + const currPos = positions.get(currId); + if (!prevNode || !prevPos || !currPos) continue; + + const minY = prevPos.y + prevNode.height + config.verticalGap; + if (currPos.y < minY) { + positions.set(currId, { x: currPos.x, y: minY }); + } + } + } + + return positions; +} diff --git a/src/layout/algorithms/sugiyama/crossing-minimize.ts b/src/layout/algorithms/sugiyama/crossing-minimize.ts new file mode 100644 index 0000000..b98cefc --- /dev/null +++ b/src/layout/algorithms/sugiyama/crossing-minimize.ts @@ -0,0 +1,245 @@ +/** + * Barycenter crossing minimization. + * + * Repeatedly sweeps forward and backward through layers, reordering + * nodes within each layer by the average (barycenter) position of + * their neighbors in the adjacent fixed layer. + */ + +import type { LayoutNode, LayoutEdge } from "../../types"; + +/** + * Reorder nodes within each layer to minimize edge crossings. + * Uses the barycenter heuristic with alternating forward/backward sweeps. + * + * @param layers - Node IDs grouped by layer index + * @param edges - Directed edges (assumed acyclic, source layer < target layer) + * @param nodeMap - Lookup for node dimensions (used for tie-breaking stability) + * @param maxIterations - Number of full forward+backward sweeps (default 24) + * @returns Reordered layers + */ +export function minimizeCrossings( + layers: ReadonlyArray>, + edges: ReadonlyArray, + _nodeMap: ReadonlyMap, + maxIterations: number = 24, +): ReadonlyArray> { + if (layers.length <= 1) return layers; + + // Build adjacency lookup for cross-layer connections + // For each node, store indices of connected nodes in adjacent layers + const nodeToLayer = new Map(); + for (let li = 0; li < layers.length; li++) { + for (const id of layers[li]) { + nodeToLayer.set(id, li); + } + } + + // Forward neighbors: node -> nodes in the next layer + const forwardNeighbors = new Map(); + // Backward neighbors: node -> nodes in the previous layer + const backwardNeighbors = new Map(); + + for (const layer of layers) { + for (const id of layer) { + forwardNeighbors.set(id, []); + backwardNeighbors.set(id, []); + } + } + + for (const e of edges) { + const srcLayer = nodeToLayer.get(e.source); + const tgtLayer = nodeToLayer.get(e.target); + if (srcLayer === undefined || tgtLayer === undefined) continue; + + if (tgtLayer > srcLayer) { + forwardNeighbors.get(e.source)!.push(e.target); + backwardNeighbors.get(e.target)!.push(e.source); + } else if (srcLayer > tgtLayer) { + forwardNeighbors.get(e.target)!.push(e.source); + backwardNeighbors.get(e.source)!.push(e.target); + } + } + + // Mutable copy of layers + let current: string[][] = layers.map((layer) => [...layer]); + + let bestCrossings = countAllCrossings(current, forwardNeighbors); + let bestLayers: string[][] = current.map((layer) => [...layer]); + + for (let iter = 0; iter < maxIterations; iter++) { + // Forward sweep: fix layer i, reorder layer i+1 + for (let li = 1; li < current.length; li++) { + current[li] = reorderByBarycenter( + current[li], + current[li - 1], + backwardNeighbors, + ); + } + + // Backward sweep: fix layer i, reorder layer i-1 + for (let li = current.length - 2; li >= 0; li--) { + current[li] = reorderByBarycenter( + current[li], + current[li + 1], + forwardNeighbors, + ); + } + + const crossings = countAllCrossings(current, forwardNeighbors); + if (crossings < bestCrossings) { + bestCrossings = crossings; + bestLayers = current.map((layer) => [...layer]); + } + + // Early exit if no crossings remain + if (bestCrossings === 0) break; + } + + return bestLayers; +} + +/** + * Reorder `layer` based on barycenter positions in `fixedLayer`. + * `neighborsMap` maps each node in `layer` to its neighbors in `fixedLayer`. + */ +function reorderByBarycenter( + layer: ReadonlyArray, + fixedLayer: ReadonlyArray, + neighborsMap: ReadonlyMap, +): string[] { + // Build position index for fixed layer + const fixedPosition = new Map(); + for (let i = 0; i < fixedLayer.length; i++) { + fixedPosition.set(fixedLayer[i], i); + } + + // Compute barycenter for each node + const barycenters = new Map(); + for (const id of layer) { + const neighbors = neighborsMap.get(id) ?? []; + const relevantPositions: number[] = []; + for (const n of neighbors) { + const pos = fixedPosition.get(n); + if (pos !== undefined) { + relevantPositions.push(pos); + } + } + + if (relevantPositions.length > 0) { + const sum = relevantPositions.reduce((a, b) => a + b, 0); + barycenters.set(id, sum / relevantPositions.length); + } + // Nodes with no connections keep their original position + } + + // Sort: nodes with barycenters first (by value), then nodes without (keep original order) + const withBarycenter: string[] = []; + const withoutBarycenter: string[] = []; + const originalIndex = new Map(); + + for (let i = 0; i < layer.length; i++) { + originalIndex.set(layer[i], i); + if (barycenters.has(layer[i])) { + withBarycenter.push(layer[i]); + } else { + withoutBarycenter.push(layer[i]); + } + } + + withBarycenter.sort((a, b) => { + const ba = barycenters.get(a)!; + const bb = barycenters.get(b)!; + if (ba !== bb) return ba - bb; + // Stable tie-break: original order + return originalIndex.get(a)! - originalIndex.get(b)!; + }); + + // Merge: interleave nodes without barycenters at their original positions + // Simple approach: put barycenter-sorted nodes first, then the rest + // Better approach: insert non-barycenter nodes at their original relative positions + const result: string[] = new Array(layer.length); + const usedSlots = new Set(); + + // Place nodes without barycenters at their original indices first + for (const id of withoutBarycenter) { + const idx = originalIndex.get(id)!; + result[idx] = id; + usedSlots.add(idx); + } + + // Fill remaining slots with barycenter-sorted nodes + let bIdx = 0; + for (let i = 0; i < result.length; i++) { + if (!usedSlots.has(i)) { + result[i] = withBarycenter[bIdx++]; + } + } + + return result; +} + +/** + * Count total edge crossings between all adjacent layer pairs. + */ +function countAllCrossings( + layers: ReadonlyArray>, + forwardNeighbors: ReadonlyMap, +): number { + let total = 0; + for (let li = 0; li < layers.length - 1; li++) { + total += countCrossingsBetweenLayers( + layers[li], + layers[li + 1], + forwardNeighbors, + ); + } + return total; +} + +/** + * Count edge crossings between two adjacent layers. + * Two edges (u1->v1) and (u2->v2) cross if u1 is before u2 in topLayer + * but v1 is after v2 in bottomLayer (or vice versa). + */ +function countCrossingsBetweenLayers( + topLayer: ReadonlyArray, + bottomLayer: ReadonlyArray, + forwardNeighbors: ReadonlyMap, +): number { + // Build position maps + const bottomPos = new Map(); + for (let i = 0; i < bottomLayer.length; i++) { + bottomPos.set(bottomLayer[i], i); + } + + // Collect all edges as (topIndex, bottomIndex) pairs + const edgePairs: Array<{ top: number; bottom: number }> = []; + for (let ti = 0; ti < topLayer.length; ti++) { + const neighbors = forwardNeighbors.get(topLayer[ti]) ?? []; + for (const n of neighbors) { + const bi = bottomPos.get(n); + if (bi !== undefined) { + edgePairs.push({ top: ti, bottom: bi }); + } + } + } + + // Count crossings by comparing all pairs + // O(E^2) but fine for typical graph sizes + let crossings = 0; + for (let i = 0; i < edgePairs.length; i++) { + for (let j = i + 1; j < edgePairs.length; j++) { + const e1 = edgePairs[i]; + const e2 = edgePairs[j]; + if ( + (e1.top < e2.top && e1.bottom > e2.bottom) || + (e1.top > e2.top && e1.bottom < e2.bottom) + ) { + crossings++; + } + } + } + + return crossings; +} diff --git a/src/layout/algorithms/sugiyama/cycle-break.ts b/src/layout/algorithms/sugiyama/cycle-break.ts new file mode 100644 index 0000000..3d230c4 --- /dev/null +++ b/src/layout/algorithms/sugiyama/cycle-break.ts @@ -0,0 +1,141 @@ +/** + * Greedy cycle removal via feedback arc set. + * + * Produces an acyclic edge set by reversing the minimum number of edges + * needed to break all cycles. Uses the Berger-Shor greedy heuristic: + * repeatedly extract sinks, sources, then the node with max (out - in). + */ + +import type { LayoutNode, LayoutEdge } from "../../types"; + +export interface CycleBreakResult { + readonly edges: ReadonlyArray; + readonly reversedEdges: ReadonlySet; +} + +/** + * Break cycles in the edge set by reversing backward edges. + * Returns a new edge array (with some edges flipped) + the set of + * original "source->target" keys that were reversed. + */ +export function breakCycles( + nodes: ReadonlyArray, + edges: ReadonlyArray, +): CycleBreakResult { + if (nodes.length === 0 || edges.length === 0) { + return { edges, reversedEdges: new Set() }; + } + + const nodeIds = new Set(nodes.map((n) => n.id)); + + // Filter edges to only those connecting known nodes + const validEdges = edges.filter( + (e) => nodeIds.has(e.source) && nodeIds.has(e.target), + ); + + // Build mutable adjacency structures + const outNeighbors = new Map>(); + const inNeighbors = new Map>(); + + for (const id of nodeIds) { + outNeighbors.set(id, new Set()); + inNeighbors.set(id, new Set()); + } + + for (const e of validEdges) { + outNeighbors.get(e.source)!.add(e.target); + inNeighbors.get(e.target)!.add(e.source); + } + + // Greedy ordering: sinks to the right, sources to the left + const remaining = new Set(nodeIds); + const leftOrder: string[] = []; + const rightOrder: string[] = []; + + while (remaining.size > 0) { + // Remove all sinks (out-degree 0 in remaining subgraph) + let changed = true; + while (changed) { + changed = false; + for (const id of remaining) { + const outDeg = countInSubgraph(outNeighbors.get(id)!, remaining); + if (outDeg === 0) { + rightOrder.push(id); + remaining.delete(id); + changed = true; + } + } + } + + // Remove all sources (in-degree 0 in remaining subgraph) + changed = true; + while (changed) { + changed = false; + for (const id of remaining) { + const inDeg = countInSubgraph(inNeighbors.get(id)!, remaining); + if (inDeg === 0) { + leftOrder.push(id); + remaining.delete(id); + changed = true; + } + } + } + + // If stuck, pick node with max (out - in) in remaining subgraph + if (remaining.size > 0) { + let best: string | undefined; + let bestDelta = -Infinity; + for (const id of remaining) { + const outDeg = countInSubgraph(outNeighbors.get(id)!, remaining); + const inDeg = countInSubgraph(inNeighbors.get(id)!, remaining); + const delta = outDeg - inDeg; + if (delta > bestDelta || (delta === bestDelta && (best === undefined || id < best))) { + bestDelta = delta; + best = id; + } + } + if (best !== undefined) { + leftOrder.push(best); + remaining.delete(best); + } + } + } + + // Final ordering: left order + reversed right order + rightOrder.reverse(); + const finalOrder = [...leftOrder, ...rightOrder]; + + // Build position map + const positionOf = new Map(); + for (let i = 0; i < finalOrder.length; i++) { + positionOf.set(finalOrder[i], i); + } + + // An edge is "backward" if source appears AFTER target in the order + const reversedEdges = new Set(); + const resultEdges: LayoutEdge[] = []; + + for (const e of validEdges) { + const srcPos = positionOf.get(e.source)!; + const tgtPos = positionOf.get(e.target)!; + + if (srcPos > tgtPos) { + // Backward edge — reverse it + reversedEdges.add(`${e.source}->${e.target}`); + resultEdges.push({ source: e.target, target: e.source }); + } else { + resultEdges.push(e); + } + } + + return { edges: resultEdges, reversedEdges }; +} + +/** Count how many members of `neighbors` are in `subgraph`. */ +function countInSubgraph(neighbors: Set, subgraph: Set): number { + let count = 0; + for (const n of neighbors) { + if (subgraph.has(n)) count++; + } + return count; +} diff --git a/src/layout/algorithms/sugiyama/index.ts b/src/layout/algorithms/sugiyama/index.ts new file mode 100644 index 0000000..01d126c --- /dev/null +++ b/src/layout/algorithms/sugiyama/index.ts @@ -0,0 +1,12 @@ +/** + * Sugiyama sub-module barrel export. + */ + +export { breakCycles } from "./cycle-break"; +export type { CycleBreakResult } from "./cycle-break"; + +export { assignLayers } from "./layer-assign"; + +export { minimizeCrossings } from "./crossing-minimize"; + +export { assignCoordinates } from "./coordinate-assign"; diff --git a/src/layout/algorithms/sugiyama/layer-assign.ts b/src/layout/algorithms/sugiyama/layer-assign.ts new file mode 100644 index 0000000..04163df --- /dev/null +++ b/src/layout/algorithms/sugiyama/layer-assign.ts @@ -0,0 +1,107 @@ +/** + * Longest-path layer assignment for DAGs. + * + * Each node is assigned to the layer equal to the length of the longest + * path from any source to that node. Sources (in-degree 0) get layer 0. + */ + +import type { LayoutNode, LayoutEdge } from "../../types"; + +/** + * Assign layer indices to each node using longest-path from sources. + * Assumes the input is acyclic (run breakCycles first). + * Returns a map of node ID -> layer index (0-based). + */ +export function assignLayers( + nodes: ReadonlyArray, + edges: ReadonlyArray, +): ReadonlyMap { + const layerMap = new Map(); + + if (nodes.length === 0) return layerMap; + + const nodeIds = new Set(nodes.map((n) => n.id)); + + // Build adjacency: predecessors and successors + const predecessors = new Map(); + const successors = new Map(); + const inDegree = new Map(); + + for (const id of nodeIds) { + predecessors.set(id, []); + successors.set(id, []); + inDegree.set(id, 0); + } + + for (const e of edges) { + if (!nodeIds.has(e.source) || !nodeIds.has(e.target)) continue; + successors.get(e.source)!.push(e.target); + predecessors.get(e.target)!.push(e.source); + inDegree.set(e.target, inDegree.get(e.target)! + 1); + } + + // Kahn's algorithm for topological ordering, computing longest path + const queue: string[] = []; + + // Initialize sources at layer 0 + for (const id of nodeIds) { + if (inDegree.get(id)! === 0) { + layerMap.set(id, 0); + queue.push(id); + } + } + + let head = 0; + while (head < queue.length) { + const current = queue[head++]; + const currentLayer = layerMap.get(current)!; + + for (const succ of successors.get(current)!) { + // Layer of successor = max of all predecessor layers + 1 + const newLayer = currentLayer + 1; + const existingLayer = layerMap.get(succ); + if (existingLayer === undefined || newLayer > existingLayer) { + layerMap.set(succ, newLayer); + } + + // Decrement in-degree; add to queue when all predecessors processed + const remaining = inDegree.get(succ)! - 1; + inDegree.set(succ, remaining); + if (remaining === 0) { + queue.push(succ); + } + } + } + + // Handle any nodes not reached (isolated nodes with edges filtered out) + for (const id of nodeIds) { + if (!layerMap.has(id)) { + layerMap.set(id, 0); + } + } + + // Enforce explicit layer constraints after longest-path assignment. + for (const node of nodes) { + if (node.layerConstraint === "first") { + layerMap.set(node.id, 0); + } + } + + let maxUnconstrainedLayer = 0; + for (const node of nodes) { + if (node.layerConstraint === "last") continue; + const layer = layerMap.get(node.id) ?? 0; + if (layer > maxUnconstrainedLayer) { + maxUnconstrainedLayer = layer; + } + } + + for (const node of nodes) { + if (node.layerConstraint === "last") { + const existingLayer = layerMap.get(node.id) ?? 0; + layerMap.set(node.id, Math.max(existingLayer, maxUnconstrainedLayer + 1)); + } + } + + return layerMap; +} diff --git a/src/layout/algorithms/vertical.ts b/src/layout/algorithms/vertical.ts new file mode 100644 index 0000000..6090b2e --- /dev/null +++ b/src/layout/algorithms/vertical.ts @@ -0,0 +1,40 @@ +/** + * Vertical layout algorithm — places nodes in a single top-to-bottom column. + * + * Ignores edges entirely (token layouts ignore DAG structure). + * Nodes are placed in input order; horizontally centered by midpoint. + */ + +import type { LayoutAlgorithm, LayoutInput, LayoutOutput, Position } from "../types"; + +const DEFAULT_GAP = 40; + +export function createVerticalAlgorithm(gap: number = DEFAULT_GAP): LayoutAlgorithm { + return { + name: "vertical", + layout(input: LayoutInput): LayoutOutput { + const positions = new Map(); + const { nodes } = input; + + if (nodes.length === 0) { + return { positions }; + } + + // Find max width for horizontal centering + let maxWidth = 0; + for (const node of nodes) { + if (node.width > maxWidth) maxWidth = node.width; + } + + // Place nodes top-to-bottom, centered horizontally + let currentY = 0; + for (const node of nodes) { + const centeredX = maxWidth / 2 - node.width / 2; + positions.set(node.id, { x: centeredX, y: currentY }); + currentY += node.height + gap; + } + + return { positions }; + }, + }; +} diff --git a/src/layout/bin-pack.ts b/src/layout/bin-pack.ts deleted file mode 100644 index db496cc..0000000 --- a/src/layout/bin-pack.ts +++ /dev/null @@ -1,225 +0,0 @@ -import type { PackedRow, LayoutConfig } from "./types"; - -/** Node size info for packing */ -interface NodeSize { - id: number; - width: number; - height: number; -} - -/** - * Pack nodes into rows using First-Fit Decreasing Height (FFDH) algorithm - * - * Strategy: - * 1. Sort nodes by height descending (tall nodes first) - * 2. For each node, try to fit into existing row - * 3. If no row fits, create new row - * 4. Rows are sorted by height for visual consistency - * - * @param nodeIds - Node IDs to pack - * @param sizes - Map of node ID to size - * @param config - Layout configuration - * @returns Array of packed rows - */ -export function packNodesIntoRows( - nodeIds: number[], - sizes: Map, - config: LayoutConfig -): PackedRow[] { - if (nodeIds.length === 0) return []; - - // maxColumns: 0=auto, 1=disabled (vertical stack), 2+=fixed columns - if (config.maxColumns === 1) { - // Vertical stacking - one node per row - return packVertically(nodeIds, sizes, config.verticalGap); - } - - // Build sized node list - const nodeSizes: NodeSize[] = nodeIds.map((id) => { - const size = sizes.get(id); - return { - id, - width: size?.width ?? 200, - height: size?.height ?? 100, - }; - }); - - // Sort by height descending (FFDH: tall nodes first) - nodeSizes.sort((a, b) => b.height - a.height); - - if (config.maxColumns >= 2) { - // Fixed column count - return packFixedColumns(nodeSizes, config.maxColumns, config.verticalGap); - } - - // Auto mode: use maxRowWidth - return packByWidth(nodeSizes, config.maxRowWidth, config.horizontalGap, config.verticalGap); -} - -/** - * Pack nodes vertically (one per row) - legacy behavior - */ -function packVertically( - nodeIds: number[], - sizes: Map, - verticalGap: number -): PackedRow[] { - const rows: PackedRow[] = []; - let yOffset = 0; - - for (const id of nodeIds) { - const size = sizes.get(id); - const height = size?.height ?? 100; - const width = size?.width ?? 200; - - rows.push({ - nodes: [id], - height, - yOffset, - width, - }); - - yOffset += height + verticalGap; - } - - return rows; -} - -/** - * Pack nodes into fixed number of columns - * Distributes nodes round-robin by height to balance columns - */ -function packFixedColumns( - nodeSizes: NodeSize[], - columnCount: number, - verticalGap: number -): PackedRow[] { - // Group nodes by similar height into rows - const rows: PackedRow[] = []; - let currentRow: NodeSize[] = []; - - for (const node of nodeSizes) { - currentRow.push(node); - - if (currentRow.length >= columnCount) { - // Row is full - const rowHeight = Math.max(...currentRow.map((n) => n.height)); - const rowWidth = currentRow.reduce((sum, n) => sum + n.width, 0); - - rows.push({ - nodes: currentRow.map((n) => n.id), - height: rowHeight, - yOffset: 0, // Set later - width: rowWidth, - }); - - currentRow = []; - } - } - - // Handle remaining nodes in last partial row - if (currentRow.length > 0) { - const rowHeight = Math.max(...currentRow.map((n) => n.height)); - const rowWidth = currentRow.reduce((sum, n) => sum + n.width, 0); - - rows.push({ - nodes: currentRow.map((n) => n.id), - height: rowHeight, - yOffset: 0, - width: rowWidth, - }); - } - - // Calculate y offsets - let yOffset = 0; - for (const row of rows) { - row.yOffset = yOffset; - yOffset += row.height + verticalGap; - } - - return rows; -} - -/** - * Pack nodes by maximum row width (auto column count) - * Uses FFDH: First-Fit Decreasing Height - */ -function packByWidth( - nodeSizes: NodeSize[], - maxRowWidth: number, - horizontalGap: number, - verticalGap: number -): PackedRow[] { - const rows: Array<{ - nodes: NodeSize[]; - height: number; - currentWidth: number; - }> = []; - - for (const node of nodeSizes) { - // Try to fit into existing row - let placed = false; - - for (const row of rows) { - const newWidth = row.currentWidth + horizontalGap + node.width; - - // Check if node fits and heights are compatible - // Allow 50% height variance to keep rows visually balanced - const heightRatio = row.height > 0 ? node.height / row.height : 1; - const heightCompatible = heightRatio >= 0.5 && heightRatio <= 2.0; - - if (newWidth <= maxRowWidth && heightCompatible) { - row.nodes.push(node); - row.currentWidth = newWidth; - row.height = Math.max(row.height, node.height); - placed = true; - break; - } - } - - if (!placed) { - // Create new row - rows.push({ - nodes: [node], - height: node.height, - currentWidth: node.width, - }); - } - } - - // Sort rows by their tallest node height (tallest first for visual hierarchy) - rows.sort((a, b) => b.height - a.height); - - // Convert to PackedRow format with y offsets - const result: PackedRow[] = []; - let yOffset = 0; - - for (const row of rows) { - result.push({ - nodes: row.nodes.map((n) => n.id), - height: row.height, - yOffset, - width: row.currentWidth, - }); - - yOffset += row.height + verticalGap; - } - - return result; -} - -/** - * Calculate total height of packed rows - */ -export function getPackedHeight(rows: PackedRow[]): number { - if (rows.length === 0) return 0; - const lastRow = rows[rows.length - 1]; - return lastRow.yOffset + lastRow.height; -} - -/** - * Calculate max width across all packed rows - */ -export function getPackedWidth(rows: PackedRow[]): number { - return Math.max(0, ...rows.map((r) => r.width)); -} diff --git a/src/layout/compact.ts b/src/layout/compact.ts new file mode 100644 index 0000000..7188100 --- /dev/null +++ b/src/layout/compact.ts @@ -0,0 +1,88 @@ +/** + * Compaction utilities — post-processing to remove gaps. + * All functions are pure: they return new maps, never mutate inputs. + */ + +import type { Position, LayoutNode } from "./types"; + +/** + * Remove vertical gaps between nodes by shifting them up. + * Nodes are processed in Y order; each is shifted to the earliest available Y. + */ +export function compactVertically( + positions: ReadonlyMap, + nodes: ReadonlyArray, + gap: number, +): ReadonlyMap { + if (nodes.length === 0) return new Map(); + + // Build lookup for node dimensions + const nodeMap = new Map(); + for (const n of nodes) { + nodeMap.set(n.id, n); + } + + // Sort node IDs by their current Y position, breaking ties by X + const sortedIds = [...positions.keys()] + .filter((id) => nodeMap.has(id)) + .sort((a, b) => { + const pa = positions.get(a)!; + const pb = positions.get(b)!; + if (pa.y !== pb.y) return pa.y - pb.y; + return pa.x - pb.x; + }); + + const result = new Map(positions); + let nextY = sortedIds.length > 0 ? positions.get(sortedIds[0])!.y : 0; + + for (const id of sortedIds) { + const pos = positions.get(id)!; + const node = nodeMap.get(id)!; + const newY = Math.min(pos.y, nextY); + result.set(id, { x: pos.x, y: newY }); + nextY = newY + node.height + gap; + } + + return result; +} + +/** + * Remove horizontal gaps between nodes by shifting them left. + * Nodes are processed in X order; each is shifted to the earliest available X. + */ +export function compactHorizontally( + positions: ReadonlyMap, + nodes: ReadonlyArray, + gap: number, +): ReadonlyMap { + if (nodes.length === 0) return new Map(); + + // Build lookup for node dimensions + const nodeMap = new Map(); + for (const n of nodes) { + nodeMap.set(n.id, n); + } + + // Sort node IDs by their current X position, breaking ties by Y + const sortedIds = [...positions.keys()] + .filter((id) => nodeMap.has(id)) + .sort((a, b) => { + const pa = positions.get(a)!; + const pb = positions.get(b)!; + if (pa.x !== pb.x) return pa.x - pb.x; + return pa.y - pb.y; + }); + + const result = new Map(positions); + let nextX = sortedIds.length > 0 ? positions.get(sortedIds[0])!.x : 0; + + for (const id of sortedIds) { + const pos = positions.get(id)!; + const node = nodeMap.get(id)!; + const newX = Math.min(pos.x, nextX); + result.set(id, { x: newX, y: pos.y }); + nextX = newX + node.width + gap; + } + + return result; +} diff --git a/src/layout/framework.ts b/src/layout/framework.ts new file mode 100644 index 0000000..9ba0d7b --- /dev/null +++ b/src/layout/framework.ts @@ -0,0 +1,753 @@ +/** + * Core layout framework — recursive group-aware layout orchestration. + * + * This is the architectural foundation of v2. It handles: + * - Bottom-up group hierarchy processing + * - Delegating to a pluggable LayoutAlgorithm + * - Disconnected node placement (left of DAG at each level) + * - Translating nested positions to absolute coordinates + */ + +import type { + LayoutNode, + LayoutEdge, + LayoutGroup, + LayoutAlgorithm, + FrameworkConfig, + FrameworkResult, + Position, + GroupBounds, +} from "./types"; +import { DEFAULT_FRAMEWORK_CONFIG } from "./types"; +import { tokenToAlgorithm } from "./tokens"; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Layout nodes, edges, and groups using the provided algorithm. + * + * Groups are processed bottom-up: leaf groups first, then their parents. + * Each group's contents are laid out independently, producing a "virtual node" + * that participates in the parent scope's layout. + */ +export function layoutWithGroups( + nodes: ReadonlyArray, + edges: ReadonlyArray, + groups: ReadonlyArray, + algorithm: LayoutAlgorithm, + config?: Partial, +): FrameworkResult { + const cfg: FrameworkConfig = { ...DEFAULT_FRAMEWORK_CONFIG, ...config }; + + // Fast path: no nodes at all + if (nodes.length === 0) { + return { + positions: new Map(), + groupBounds: new Map(), + }; + } + + // Build lookup tables + const nodeById = new Map(); + for (const n of nodes) { + nodeById.set(n.id, n); + } + const groupById = new Map(); + for (const g of groups) { + groupById.set(g.id, g); + } + + // Build group hierarchy + const { processingOrder, parentMap } = buildGroupHierarchy(groups); + + // Track virtual nodes (groups that have been processed) and their bounds + const virtualNodes = new Map(); + const groupBounds = new Map(); + + // Store relative positions for each group's members (before absolute translation) + // Key: group ID, Value: map of member ID -> position relative to group origin + const groupMemberRelativePositions = new Map< + string, + ReadonlyMap + >(); + + // Process groups bottom-up + for (const group of processingOrder) { + const { positions: memberPositions, bounds } = layoutGroupContents( + group, + groups, + nodeById, + edges, + virtualNodes, + algorithm, + cfg, + ); + + groupMemberRelativePositions.set(group.id, memberPositions); + groupBounds.set(group.id, bounds); + + // Register as virtual node for parent's layout + virtualNodes.set(group.id, { + id: group.id, + width: bounds.width, + height: bounds.height, + }); + } + + // Layout root level: ungrouped nodes + top-level group virtual nodes + const groupedNodeIds = collectAllGroupedNodeIds(groups); + const topLevelGroupIds = new Set(); + for (const g of groups) { + if (!parentMap.has(g.id)) { + topLevelGroupIds.add(g.id); + } + } + + const rootNodes: LayoutNode[] = []; + for (const n of nodes) { + if (!groupedNodeIds.has(n.id)) { + rootNodes.push(n); + } + } + for (const gId of topLevelGroupIds) { + const vn = virtualNodes.get(gId); + if (vn) rootNodes.push(vn); + } + + // Root edges: between root-level items (ungrouped nodes + top-level groups) + const rootItemIds = new Set(rootNodes.map((n) => n.id)); + const rootEdges = filterEdgesForScope(edges, rootItemIds, groups); + + const rootPositions = layoutScope(rootNodes, rootEdges, algorithm, cfg); + + // Translate all positions to absolute coordinates + const absolutePositions = new Map(); + + // Place ungrouped root nodes + for (const n of nodes) { + if (!groupedNodeIds.has(n.id)) { + const pos = rootPositions.get(n.id); + if (pos) { + absolutePositions.set(n.id, pos); + } + } + } + + // Recursively translate group members to absolute coordinates + for (const gId of topLevelGroupIds) { + const groupPos = rootPositions.get(gId); + if (groupPos) { + translateGroupToAbsolute( + gId, + groupPos, + groupById, + groupMemberRelativePositions, + groupBounds, + absolutePositions, + cfg, + ); + } + } + + // Update group bounds with absolute positions + const absoluteGroupBounds = new Map(); + for (const gId of topLevelGroupIds) { + const groupPos = rootPositions.get(gId); + if (groupPos) { + updateGroupBoundsAbsolute( + gId, + groupPos, + groupById, + groupMemberRelativePositions, + groupBounds, + absoluteGroupBounds, + cfg, + ); + } + } + + return { + positions: absolutePositions, + groupBounds: absoluteGroupBounds, + }; +} + +// --------------------------------------------------------------------------- +// Group hierarchy +// --------------------------------------------------------------------------- + +interface GroupHierarchy { + /** Groups in bottom-up processing order (leaves first) */ + readonly processingOrder: ReadonlyArray; + /** Maps child group ID -> parent group ID */ + readonly parentMap: ReadonlyMap; +} + +/** + * Build the group hierarchy: determine parent-child relationships and + * return a bottom-up topological processing order. + */ +export function buildGroupHierarchy( + groups: ReadonlyArray, +): GroupHierarchy { + const groupById = new Map(); + for (const g of groups) { + groupById.set(g.id, g); + } + + // Build parent map + const parentMap = new Map(); + for (const g of groups) { + for (const childId of g.childGroupIds) { + parentMap.set(childId, g.id); + } + } + + // Topological sort: BFS from leaves (groups with no children, or whose + // children are all already processed) + const childCountRemaining = new Map(); + for (const g of groups) { + // Only count children that actually exist in our group list + const validChildren = g.childGroupIds.filter((cid) => groupById.has(cid)); + childCountRemaining.set(g.id, validChildren.length); + } + + const queue: string[] = []; + for (const [id, count] of childCountRemaining) { + if (count === 0) queue.push(id); + } + + const processingOrder: LayoutGroup[] = []; + let head = 0; + while (head < queue.length) { + const id = queue[head++]; + const group = groupById.get(id); + if (!group) continue; + processingOrder.push(group); + + // Notify parent + const parentId = parentMap.get(id); + if (parentId !== undefined) { + const remaining = (childCountRemaining.get(parentId) ?? 1) - 1; + childCountRemaining.set(parentId, remaining); + if (remaining === 0) { + queue.push(parentId); + } + } + } + + return { processingOrder, parentMap }; +} + +// --------------------------------------------------------------------------- +// Disconnected splitting +// --------------------------------------------------------------------------- + +interface SplitResult { + readonly connected: ReadonlyArray; + readonly disconnected: ReadonlyArray; + readonly connectedEdges: ReadonlyArray; +} + +/** + * Split nodes into connected (have edges) and disconnected (no edges). + * Preserves original array order for determinism. + */ +export function splitDisconnected( + nodes: ReadonlyArray, + edges: ReadonlyArray, +): SplitResult { + const nodeIds = new Set(nodes.map((n) => n.id)); + const hasEdge = new Set(); + + const connectedEdges: LayoutEdge[] = []; + for (const e of edges) { + if (nodeIds.has(e.source) && nodeIds.has(e.target)) { + hasEdge.add(e.source); + hasEdge.add(e.target); + connectedEdges.push(e); + } + } + + const connected: LayoutNode[] = []; + const disconnected: LayoutNode[] = []; + for (const n of nodes) { + if (hasEdge.has(n.id) || n.layerConstraint !== undefined) { + connected.push(n); + } else { + disconnected.push(n); + } + } + + return { connected, disconnected, connectedEdges }; +} + +// --------------------------------------------------------------------------- +// Disconnected placement +// --------------------------------------------------------------------------- + +interface Bounds { + readonly minX: number; + readonly minY: number; + readonly maxX: number; + readonly maxY: number; +} + +/** + * Place disconnected nodes in a vertical stack to the LEFT of the DAG bounds. + * Returns positions for the disconnected nodes only. + */ +export function placeDisconnected( + disconnected: ReadonlyArray, + dagBounds: Bounds | null, + config: FrameworkConfig, +): ReadonlyMap { + const result = new Map(); + if (disconnected.length === 0) return result; + + // Find widest disconnected node for right-aligning the column + let maxDisconnectedWidth = 0; + for (const n of disconnected) { + if (n.width > maxDisconnectedWidth) maxDisconnectedWidth = n.width; + } + + // Place left of DAG (or at origin if no DAG) + const dagMinX = dagBounds ? dagBounds.minX : 0; + const startX = dagMinX - config.disconnectedGap - maxDisconnectedWidth; + const startY = dagBounds ? dagBounds.minY : 0; + + let currentY = startY; + for (const n of disconnected) { + result.set(n.id, { x: startX, y: currentY }); + currentY += n.height + config.verticalGap; + } + + return result; +} + +// --------------------------------------------------------------------------- +// Position translation +// --------------------------------------------------------------------------- + +/** + * Offset all positions by (dx, dy). Returns a new map. + */ +export function translatePositions( + positions: ReadonlyMap, + dx: number, + dy: number, +): ReadonlyMap { + const result = new Map(); + for (const [id, pos] of positions) { + result.set(id, { x: pos.x + dx, y: pos.y + dy }); + } + return result; +} + +// --------------------------------------------------------------------------- +// Scope layout (handles disconnected splitting + algorithm call) +// --------------------------------------------------------------------------- + +/** + * Layout a scope (set of nodes + edges), handling disconnected separation. + * Returns positions for ALL nodes in the scope (connected + disconnected). + */ +function layoutScope( + nodes: ReadonlyArray, + edges: ReadonlyArray, + algorithm: LayoutAlgorithm, + config: FrameworkConfig, +): ReadonlyMap { + if (nodes.length === 0) return new Map(); + + const { connected, disconnected, connectedEdges } = splitDisconnected( + nodes, + edges, + ); + + // Layout connected nodes with the algorithm + let connectedPositions: ReadonlyMap; + if (connected.length > 0) { + const result = algorithm.layout({ + nodes: connected, + edges: connectedEdges, + }); + connectedPositions = result.positions; + } else { + connectedPositions = new Map(); + } + + // Compute DAG bounds from connected positions + const dagBounds = computeBounds(connectedPositions, connected); + + // Place disconnected nodes + const disconnectedPositions = placeDisconnected( + disconnected, + dagBounds, + config, + ); + + // Merge + const merged = new Map(); + for (const [id, pos] of connectedPositions) { + merged.set(id, pos); + } + for (const [id, pos] of disconnectedPositions) { + merged.set(id, pos); + } + + return merged; +} + +// --------------------------------------------------------------------------- +// Group contents layout +// --------------------------------------------------------------------------- + +interface GroupLayoutResult { + /** Member positions relative to group's (0, 0) — i.e. with padding offset */ + readonly positions: ReadonlyMap; + /** Group bounding box (relative: x=0, y=0) */ + readonly bounds: GroupBounds; +} + +/** + * Layout the contents of a single group. + * Child groups that have already been processed appear as virtual nodes. + */ +function layoutGroupContents( + group: LayoutGroup, + allGroups: ReadonlyArray, + nodeById: ReadonlyMap, + allEdges: ReadonlyArray, + virtualNodes: ReadonlyMap, + algorithm: LayoutAlgorithm, + config: FrameworkConfig, +): GroupLayoutResult { + // Collect members: direct member nodes + child group virtual nodes + const members: LayoutNode[] = []; + const memberIdSet = new Set(); + + for (const mId of group.memberIds) { + const node = nodeById.get(mId); + if (node) { + members.push(node); + memberIdSet.add(mId); + } + } + + for (const cgId of group.childGroupIds) { + const vn = virtualNodes.get(cgId); + if (vn) { + members.push(vn); + memberIdSet.add(cgId); + } + } + + // Filter edges to internal-only + const internalEdges = filterEdgesForScope(allEdges, memberIdSet, allGroups); + + const groupAlgorithm = group.token + ? tokenToAlgorithm(group.token, config.verticalGap) + : algorithm; + + const rawPositions = group.token + ? groupAlgorithm.layout({ nodes: members, edges: internalEdges }).positions + : layoutScope(members, internalEdges, groupAlgorithm, config); + + // Normalize: shift so min position is at (padding, titleHeight + padding) + // Groups have a title bar at the top, so content starts below it + const topPadding = config.groupTitleHeight + config.groupPadding; + const contentBounds = computeBounds(rawPositions, members); + + let shiftX: number; + let shiftY: number; + if (contentBounds) { + shiftX = config.groupPadding - contentBounds.minX; + shiftY = topPadding - contentBounds.minY; + } else { + shiftX = config.groupPadding; + shiftY = topPadding; + } + + const normalizedPositions = translatePositions(rawPositions, shiftX, shiftY); + + // Compute group bounds + const normalizedBounds = computeBounds(normalizedPositions, members); + + let width: number; + let height: number; + if (normalizedBounds) { + width = normalizedBounds.maxX + config.groupPadding; + height = normalizedBounds.maxY + config.groupPadding; + } else { + width = config.groupPadding * 2; + height = topPadding + config.groupPadding; + } + + return { + positions: normalizedPositions, + bounds: { x: 0, y: 0, width, height }, + }; +} + +// --------------------------------------------------------------------------- +// Absolute coordinate translation (recursive) +// --------------------------------------------------------------------------- + +/** + * Recursively translate a group's members to absolute coordinates. + * `groupAbsolutePos` is where the group's top-left corner is placed. + */ +function translateGroupToAbsolute( + groupId: string, + groupAbsolutePos: Position, + groupById: ReadonlyMap, + groupMemberRelativePositions: ReadonlyMap< + string, + ReadonlyMap + >, + groupBoundsMap: ReadonlyMap, + absolutePositions: Map, + config: FrameworkConfig, +): void { + const relPositions = groupMemberRelativePositions.get(groupId); + if (!relPositions) return; + + const group = groupById.get(groupId); + if (!group) return; + + // Translate direct member nodes + for (const mId of group.memberIds) { + const relPos = relPositions.get(mId); + if (relPos) { + absolutePositions.set(mId, { + x: groupAbsolutePos.x + relPos.x, + y: groupAbsolutePos.y + relPos.y, + }); + } + } + + // Translate child groups recursively + for (const cgId of group.childGroupIds) { + const relPos = relPositions.get(cgId); + if (relPos) { + const childAbsPos: Position = { + x: groupAbsolutePos.x + relPos.x, + y: groupAbsolutePos.y + relPos.y, + }; + translateGroupToAbsolute( + cgId, + childAbsPos, + groupById, + groupMemberRelativePositions, + groupBoundsMap, + absolutePositions, + config, + ); + } + } +} + +/** + * Recursively compute absolute group bounds. + */ +function updateGroupBoundsAbsolute( + groupId: string, + groupAbsolutePos: Position, + groupById: ReadonlyMap, + groupMemberRelativePositions: ReadonlyMap< + string, + ReadonlyMap + >, + relativeBoundsMap: ReadonlyMap, + absoluteBoundsMap: Map, + config: FrameworkConfig, +): void { + const relBounds = relativeBoundsMap.get(groupId); + if (!relBounds) return; + + absoluteBoundsMap.set(groupId, { + x: groupAbsolutePos.x, + y: groupAbsolutePos.y, + width: relBounds.width, + height: relBounds.height, + }); + + const group = groupById.get(groupId); + if (!group) return; + + const relPositions = groupMemberRelativePositions.get(groupId); + if (!relPositions) return; + + for (const cgId of group.childGroupIds) { + const relPos = relPositions.get(cgId); + if (relPos) { + const childAbsPos: Position = { + x: groupAbsolutePos.x + relPos.x, + y: groupAbsolutePos.y + relPos.y, + }; + updateGroupBoundsAbsolute( + cgId, + childAbsPos, + groupById, + groupMemberRelativePositions, + relativeBoundsMap, + absoluteBoundsMap, + config, + ); + } + } +} + +// --------------------------------------------------------------------------- +// Edge filtering +// --------------------------------------------------------------------------- + +/** + * Filter edges to only those internal to a scope. + * An edge is internal if both source and target are in scopeIds. + * For edges that connect to nodes inside child groups, we remap them + * to the child group ID (since child groups are virtual nodes in this scope). + */ +function filterEdgesForScope( + allEdges: ReadonlyArray, + scopeIds: ReadonlySet, + allGroups: ReadonlyArray, +): ReadonlyArray { + const groupById = new Map(); + for (const g of allGroups) { + groupById.set(g.id, g); + } + + // Build reverse map: node ID -> which group ID contains it (for child group remapping) + const nodeToGroup = new Map(); + for (const g of allGroups) { + if (scopeIds.has(g.id)) { + // This is a child group in scope — map its deep members to this group ID + mapGroupMembersDeep(g, groupById, nodeToGroup); + } + } + + const result: LayoutEdge[] = []; + const seen = new Set(); + + for (const e of allEdges) { + // Remap source/target if they're inside a child group + const source = nodeToGroup.get(e.source) ?? e.source; + const target = nodeToGroup.get(e.target) ?? e.target; + + // Both must be in scope + if (!scopeIds.has(source) || !scopeIds.has(target)) continue; + + // Skip self-loops (happens when source and target are in same child group) + if (source === target) continue; + + // Deduplicate + const key = `${source}->${target}`; + if (seen.has(key)) continue; + seen.add(key); + + result.push({ source, target }); + } + + return result; +} + +/** + * Recursively map all member nodes of a group (and its nested children) + * to the group's ID. + */ +function mapGroupMembersDeep( + group: LayoutGroup, + groupById: ReadonlyMap, + nodeToGroup: Map, +): void { + for (const mId of group.memberIds) { + nodeToGroup.set(mId, group.id); + } + for (const cgId of group.childGroupIds) { + const childGroup = groupById.get(cgId); + if (childGroup) { + // Map child group's members to the parent group (the one in scope) + mapGroupMembersDeepToTarget(childGroup, groupById, group.id, nodeToGroup); + } + } +} + +/** + * Recursively map all members of `group` to `targetGroupId`. + */ +function mapGroupMembersDeepToTarget( + group: LayoutGroup, + groupById: ReadonlyMap, + targetGroupId: string, + nodeToGroup: Map, +): void { + for (const mId of group.memberIds) { + nodeToGroup.set(mId, targetGroupId); + } + for (const cgId of group.childGroupIds) { + const childGroup = groupById.get(cgId); + if (childGroup) { + mapGroupMembersDeepToTarget( + childGroup, + groupById, + targetGroupId, + nodeToGroup, + ); + } + } +} + +// --------------------------------------------------------------------------- +// Utility: collect all node IDs that belong to any group (recursively) +// --------------------------------------------------------------------------- + +function collectAllGroupedNodeIds( + groups: ReadonlyArray, +): Set { + const result = new Set(); + for (const g of groups) { + for (const mId of g.memberIds) { + result.add(mId); + } + } + return result; +} + +// --------------------------------------------------------------------------- +// Utility: compute bounding box +// --------------------------------------------------------------------------- + +function computeBounds( + positions: ReadonlyMap, + nodes: ReadonlyArray, +): Bounds | null { + const nodeMap = new Map(); + for (const n of nodes) { + nodeMap.set(n.id, n); + } + + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + let found = false; + + for (const [id, pos] of positions) { + const node = nodeMap.get(id); + if (!node) continue; + found = true; + if (pos.x < minX) minX = pos.x; + if (pos.y < minY) minY = pos.y; + const right = pos.x + node.width; + const bottom = pos.y + node.height; + if (right > maxX) maxX = right; + if (bottom > maxY) maxY = bottom; + } + + if (!found) return null; + return { minX, minY, maxX, maxY }; +} diff --git a/src/layout/graph-builder.ts b/src/layout/graph-builder.ts deleted file mode 100644 index 8f9a347..0000000 --- a/src/layout/graph-builder.ts +++ /dev/null @@ -1,565 +0,0 @@ -import type { - LGraph, - LGraphNode, - LGraphGroup, - LayoutNode, - LayoutGroup, - LayoutState, - LayoutConfig, - RerouteChain, -} from "./types"; -import { DEFAULT_CONFIG } from "./types"; -import { classifyNodes } from "./node-classifier"; -import { findRerouteChains, getRerouteNodeIds, getVirtualEdges } from "./reroute-collapse"; -import { getInternalEdges, assignMemberLayers } from "./layout-utils"; -import { debugLog } from "../debug"; - -/** - * Runtime subgraph I/O node (has pos/size like regular nodes, not bounding) - */ -interface RuntimeIONode { - id: number; - pos: [number, number]; - size: [number, number]; -} - -/** - * Check if an object looks like a subgraph I/O node at runtime - * Runtime has pos/size (not bounding like JSON export) - */ -function isValidIONode(obj: unknown): obj is RuntimeIONode { - if (!obj || typeof obj !== "object") return false; - const candidate = obj as Record; - // Must have numeric id - if (typeof candidate.id !== "number") return false; - // Use duck typing - pos/size may be getters returning array-like objects - const pos = candidate.pos as [number, number] | undefined; - const size = candidate.size as [number, number] | undefined; - if (!pos || typeof pos[0] !== "number" || typeof pos[1] !== "number") return false; - if (!size || typeof size[0] !== "number" || typeof size[1] !== "number") return false; - return true; -} - -/** - * Convert runtime I/O node to LGraphNode-like object for layout - */ -function ioNodeToLGraphNode(ioNode: RuntimeIONode, type: "input" | "output"): LGraphNode { - return { - id: ioNode.id, - type: `subgraph_${type}`, - title: type === "input" ? "Input" : "Output", - pos: [ioNode.pos[0], ioNode.pos[1]], - size: [ioNode.size[0], ioNode.size[1]], - inputs: [], - outputs: [], - }; -} - -/** Title bar height for groups */ -const GROUP_TITLE_HEIGHT = 50; - -/** - * Check if outer group's bounds fully contain inner group's bounds - */ -function groupContainsGroup(outer: LGraphGroup, inner: LGraphGroup): boolean { - const ox = outer.pos[0]; - const oy = outer.pos[1]; - const ow = outer.size[0]; - const oh = outer.size[1]; - - const ix = inner.pos[0]; - const iy = inner.pos[1]; - const iw = inner.size[0]; - const ih = inner.size[1]; - - // Inner's bounds must be fully within outer's bounds - return ix >= ox && iy >= oy && ix + iw <= ox + ow && iy + ih <= oy + oh; -} - -/** - * Build parent-child hierarchy for groups based on bounds containment - * Assigns parentGroup, childGroups, and depth to each LayoutGroup - */ -function buildGroupHierarchy(layoutGroups: LayoutGroup[]): void { - // For each group, find its parent (smallest containing group) - for (const inner of layoutGroups) { - let bestParent: LayoutGroup | null = null; - let bestArea = Infinity; - - for (const outer of layoutGroups) { - if (outer === inner) continue; - if (groupContainsGroup(outer.group, inner.group)) { - const area = outer.group.size[0] * outer.group.size[1]; - if (area < bestArea) { - bestArea = area; - bestParent = outer; - } - } - } - - inner.parentGroup = bestParent; - if (bestParent) { - bestParent.childGroups.push(inner); - } - } - - // Calculate depths using BFS from roots - const roots = layoutGroups.filter((g) => !g.parentGroup); - const queue: Array<{ group: LayoutGroup; depth: number }> = roots.map( - (g) => ({ group: g, depth: 0 }) - ); - - while (queue.length > 0) { - const item = queue.shift(); - if (!item) break; - const { group, depth } = item; - group.depth = depth; - for (const child of group.childGroups) { - queue.push({ group: child, depth: depth + 1 }); - } - } -} - -/** - * Check if a node's origin is inside a group's content area - */ -function nodeInsideGroup(node: LGraphNode, group: LGraphGroup): boolean { - const gx = group.pos[0]; - const gy = group.pos[1] + GROUP_TITLE_HEIGHT; - const gw = group.size[0]; - const gh = group.size[1] - GROUP_TITLE_HEIGHT; - - const nx = node.pos?.[0] ?? 0; - const ny = node.pos?.[1] ?? 0; - - return nx >= gx && nx < gx + gw && ny >= gy && ny < gy + gh; -} - -/** - * Build layout graph from ComfyUI graph - * Groups are treated as "mega-nodes" with full hierarchy support - */ -export function buildLayoutGraph( - graph: LGraph, - config: Partial = {} -): LayoutState { - const fullConfig: LayoutConfig = { ...DEFAULT_CONFIG, ...config }; - const nodes = new Map(); - const groups: LayoutGroup[] = []; - - // First pass: Extract all nodes - const graphNodes = graph._nodes ?? []; - const allNodes = new Map(); - - for (const node of graphNodes) { - if (!node || node.id === undefined) continue; - allNodes.set(node.id, node); - } - - // Track subgraph I/O nodes separately (they need special position handling) - // Runtime I/O nodes have pos/size like regular nodes - const ioNodes = new Map(); - - // Check for subgraph I/O nodes (Subgraph extends LGraph) - const graphAny = graph as unknown as Record; - if (isValidIONode(graphAny.inputNode)) { - const inputNode = graphAny.inputNode as RuntimeIONode; - const inputLGraphNode = ioNodeToLGraphNode(inputNode, "input"); - allNodes.set(inputNode.id, inputLGraphNode); - ioNodes.set(inputNode.id, inputNode); - debugLog(`Found subgraph input node id=${inputNode.id} at [${inputNode.pos}]`); - } - if (isValidIONode(graphAny.outputNode)) { - const outputNode = graphAny.outputNode as RuntimeIONode; - const outputLGraphNode = ioNodeToLGraphNode(outputNode, "output"); - allNodes.set(outputNode.id, outputLGraphNode); - ioNodes.set(outputNode.id, outputNode); - debugLog(`Found subgraph output node id=${outputNode.id} at [${outputNode.pos}]`); - } - - // Classify nodes into connected/disconnected/reroutes - const classified = classifyNodes(graph); - const disconnectedNodes = classified.disconnected; - - // Find and collapse reroute chains if enabled - let rerouteChains: RerouteChain[] = []; - let collapsedRerouteIds = new Set(); - - if (fullConfig.collapseReroutes && classified.reroutes.size > 0) { - rerouteChains = findRerouteChains(graph, classified.reroutes); - collapsedRerouteIds = getRerouteNodeIds(rerouteChains); - } - - // Second pass: Create LayoutGroup objects (without hierarchy yet) - const graphGroups = graph._groups ?? []; - - for (const group of graphGroups) { - if (!group || !group.pos || !group.size) continue; - - const layoutGroup: LayoutGroup = { - group, - memberIds: new Set(), - childGroups: [], - parentGroup: null, - depth: 0, - width: 0, - height: 0, - }; - - groups.push(layoutGroup); - } - - // Third pass: Build group hierarchy (parent-child relationships) - buildGroupHierarchy(groups); - - // Fourth pass: Assign nodes to their INNERMOST containing group - // A node belongs only to the deepest group that contains it - // Membership is determined from original workflow positions (user's design intent) - const nodeToGroup = new Map(); - - for (const [nodeId, node] of allNodes) { - let bestGroup: LayoutGroup | null = null; - let maxDepth = -1; - - for (const layoutGroup of groups) { - if (nodeInsideGroup(node, layoutGroup.group)) { - if (layoutGroup.depth > maxDepth) { - maxDepth = layoutGroup.depth; - bestGroup = layoutGroup; - } - } - } - - if (bestGroup) { - bestGroup.memberIds.add(nodeId); - nodeToGroup.set(nodeId, bestGroup); - } - } - - // Log group membership - for (const layoutGroup of groups) { - const parentInfo = layoutGroup.parentGroup - ? ` (inside "${layoutGroup.parentGroup.group.title}")` - : ""; - debugLog( - `Group "${layoutGroup.group.title}": ${layoutGroup.memberIds.size} direct members, ${layoutGroup.childGroups.length} child groups${parentInfo}` - ); - } - - // Fifth pass: Calculate group sizes bottom-up (deepest first) - // Build groupsByDepth array for bottom-up processing - const maxDepth = Math.max(0, ...groups.map((g) => g.depth)); - const groupsByDepth: LayoutGroup[][] = []; - - for (let d = 0; d <= maxDepth; d++) { - groupsByDepth.push(groups.filter((g) => g.depth === d)); - } - - // Process from deepest to shallowest - for (let depth = maxDepth; depth >= 0; depth--) { - for (const layoutGroup of groupsByDepth[depth]) { - // Calculate dimensions based on internal structure - const { width: memberWidth, height: memberHeight } = calculateGroupMemberDimensions( - layoutGroup, - allNodes, - fullConfig - ); - - let totalWidth = memberWidth; - let totalHeight = memberHeight; - - // Include child groups (already calculated in previous iterations) - // Child groups add to height below members - let childGroupsHeight = 0; - for (const child of layoutGroup.childGroups) { - totalWidth = Math.max(totalWidth, child.width); - childGroupsHeight += child.height + fullConfig.verticalGap; - } - if (memberHeight > 0 && childGroupsHeight > 0) { - totalHeight += fullConfig.verticalGap; // Gap between members and children - } - totalHeight += childGroupsHeight; - - // Add padding and title - layoutGroup.width = totalWidth + fullConfig.groupPadding * 2; - layoutGroup.height = - totalHeight + fullConfig.groupPadding * 2 + GROUP_TITLE_HEIGHT; - } - } - - // Sixth pass: Create LayoutNodes - // - Standalone connected nodes become regular layout nodes - // - Top-level groups get a representative node - // - Nested groups are handled internally by their parent - // - Disconnected nodes and collapsed reroutes are EXCLUDED (placed separately later) - const groupRepresentatives = new Map(); - - for (const [id, node] of allNodes) { - // Skip disconnected nodes - they'll be placed in a separate zone - if (disconnectedNodes.has(id)) { - continue; - } - - // Skip collapsed reroute nodes - they'll be restored after layout - if (collapsedRerouteIds.has(id)) { - continue; - } - - const group = nodeToGroup.get(id); - - if (group) { - // This node is in a group - // Only create representative for top-level groups - const topLevelGroup = getTopLevelGroup(group); - - if (!groupRepresentatives.has(topLevelGroup)) { - // First member becomes the representative for the top-level group - groupRepresentatives.set(topLevelGroup, id); - - // Create layout node with TOP-LEVEL GROUP dimensions - nodes.set(id, { - node, - id, - layer: -1, - orderInLayer: 0, - predecessors: [], - successors: [], - width: topLevelGroup.width, - height: topLevelGroup.height, - x: 0, - y: 0, - isGroupRepresentative: true, - group: topLevelGroup, - }); - } - // Skip non-representative group members - they'll be positioned relative to group - } else { - // Standalone node (or I/O node) - const runtimeIONode = ioNodes.get(id); - nodes.set(id, { - node, - id, - layer: -1, - orderInLayer: 0, - predecessors: [], - successors: [], - width: node.size?.[0] ?? 200, - height: node.size?.[1] ?? 100, - x: 0, - y: 0, - isSubgraphIO: runtimeIONode !== undefined, - runtimeIONode, - }); - } - } - - // Seventh pass: Build adjacency from links - // For group members, redirect connections to the top-level group representative - // Skip links involving collapsed reroutes (handled via virtual edges) - const links = graph.links; - if (links) { - // Handle different link storage formats: - // - Map (newer ComfyUI) - // - Record (object with numeric keys) - // - LLink[] (array, common in subgraphs) - let linkEntries: Iterable<[number, { origin_id: number; target_id: number } | null]>; - - if (links instanceof Map) { - linkEntries = links.entries(); - } else if (Array.isArray(links)) { - // Array of link objects (subgraph format) - linkEntries = links.map((link, i) => [i, link] as const); - } else { - // Object with numeric keys - linkEntries = Object.entries(links).map(([k, v]) => [Number(k), v] as const); - } - - for (const [, link] of linkEntries) { - if (!link || typeof link !== "object") continue; - if (typeof link.origin_id !== "number" || typeof link.target_id !== "number") continue; - - let sourceId = link.origin_id; - let targetId = link.target_id; - - // Skip links involving collapsed reroutes (handled via virtual edges) - if (collapsedRerouteIds.has(sourceId) || collapsedRerouteIds.has(targetId)) { - continue; - } - - // Redirect group members to their top-level group representative - const sourceGroup = nodeToGroup.get(sourceId); - const targetGroup = nodeToGroup.get(targetId); - - if (sourceGroup) { - const topLevel = getTopLevelGroup(sourceGroup); - sourceId = groupRepresentatives.get(topLevel)!; - } - if (targetGroup) { - const topLevel = getTopLevelGroup(targetGroup); - targetId = groupRepresentatives.get(topLevel)!; - } - - // Skip internal group connections (within same top-level group) - if (sourceGroup && targetGroup) { - const sourceTop = getTopLevelGroup(sourceGroup); - const targetTop = getTopLevelGroup(targetGroup); - if (sourceTop === targetTop) { - continue; - } - } - - const sourceNode = nodes.get(sourceId); - const targetNode = nodes.get(targetId); - - if (sourceNode && targetNode) { - if (!sourceNode.successors.includes(targetId)) { - sourceNode.successors.push(targetId); - } - if (!targetNode.predecessors.includes(sourceId)) { - targetNode.predecessors.push(sourceId); - } - } - } - } - - // Eighth pass: Add virtual edges from collapsed reroute chains - if (rerouteChains.length > 0) { - const virtualEdges = getVirtualEdges(rerouteChains); - - for (const [sourceId, targetIds] of virtualEdges) { - // Handle group redirection for source - const sourceGroup = nodeToGroup.get(sourceId); - let effectiveSourceId = sourceId; - if (sourceGroup) { - const topLevel = getTopLevelGroup(sourceGroup); - effectiveSourceId = groupRepresentatives.get(topLevel)!; - } - - const sourceNode = nodes.get(effectiveSourceId); - if (!sourceNode) continue; - - for (const targetId of targetIds) { - // Handle group redirection for target - const targetGroup = nodeToGroup.get(targetId); - let effectiveTargetId = targetId; - if (targetGroup) { - const topLevel = getTopLevelGroup(targetGroup); - effectiveTargetId = groupRepresentatives.get(topLevel)!; - } - - const targetNode = nodes.get(effectiveTargetId); - if (!targetNode) continue; - - // Skip if same group - if (sourceGroup && targetGroup) { - const sourceTop = getTopLevelGroup(sourceGroup); - const targetTop = getTopLevelGroup(targetGroup); - if (sourceTop === targetTop) continue; - } - - if (!sourceNode.successors.includes(effectiveTargetId)) { - sourceNode.successors.push(effectiveTargetId); - } - if (!targetNode.predecessors.includes(effectiveSourceId)) { - targetNode.predecessors.push(effectiveSourceId); - } - } - } - } - - return { - nodes, - layers: [], - groups, - config: fullConfig, - nodeToGroup, - groupRepresentatives, - allNodes, - groupsByDepth, - disconnectedNodes, - rerouteChains, - }; -} - -/** - * Calculate group member dimensions based on internal structure - * For groups with internal connections: uses layer-aware sizing (width = sum of layer widths, height = max layer height) - * For groups without internal connections: uses simple vertical stacking (width = max, height = sum) - */ -function calculateGroupMemberDimensions( - layoutGroup: LayoutGroup, - allNodes: Map, - config: LayoutConfig -): { width: number; height: number } { - const memberNodes = [...layoutGroup.memberIds] - .map((id) => allNodes.get(id)) - .filter((n): n is LGraphNode => n !== undefined); - - if (memberNodes.length === 0) { - return { width: 0, height: 0 }; - } - - // Check for internal connections between members - const edges = getInternalEdges(layoutGroup.memberIds, allNodes); - - if (edges.length > 0) { - // Has internal layers - calculate per-layer dimensions - const layers = assignMemberLayers(layoutGroup.memberIds, edges, allNodes); - let totalWidth = 0; - let maxHeight = 0; - - for (const layer of layers) { - if (layer.length === 0) continue; - - let layerMaxWidth = 0; - let layerHeight = 0; - - for (const node of layer) { - layerMaxWidth = Math.max(layerMaxWidth, node.size?.[0] ?? 200); - layerHeight += (node.size?.[1] ?? 100) + config.verticalGap; - } - - // Remove trailing gap - if (layerHeight > 0) { - layerHeight -= config.verticalGap; - } - - totalWidth += layerMaxWidth + config.horizontalGap; - maxHeight = Math.max(maxHeight, layerHeight); - } - - // Remove trailing gap - if (totalWidth > 0) { - totalWidth -= config.horizontalGap; - } - - return { width: totalWidth, height: maxHeight }; - } else { - // No internal connections - simple vertical stacking - let maxWidth = 0; - let totalHeight = 0; - - for (const node of memberNodes) { - maxWidth = Math.max(maxWidth, node.size?.[0] ?? 200); - totalHeight += (node.size?.[1] ?? 100) + config.verticalGap; - } - - // Remove trailing gap - if (totalHeight > 0) { - totalHeight -= config.verticalGap; - } - - return { width: maxWidth, height: totalHeight }; - } -} - -/** - * Get the top-level group (root of hierarchy) for a given group - */ -function getTopLevelGroup(group: LayoutGroup): LayoutGroup { - let current = group; - while (current.parentGroup) { - current = current.parentGroup; - } - return current; -} diff --git a/src/layout/groups.ts b/src/layout/groups.ts deleted file mode 100644 index 25edfba..0000000 --- a/src/layout/groups.ts +++ /dev/null @@ -1,147 +0,0 @@ -import type { LayoutState, LGraphGroup } from "./types"; -import { debugLog } from "../debug"; - -/** Title bar height for groups */ -const GROUP_TITLE_HEIGHT = 50; - -/** - * Resize groups to fit their contents after layout - * Uses bottom-up processing: deepest groups first, then parents - */ -export function resizeGroupsToFit(state: LayoutState): number { - const { groupsByDepth, allNodes, config } = state; - - if (!groupsByDepth || !allNodes) { - debugLog("Missing groupsByDepth or allNodes"); - return 0; - } - - let resizedCount = 0; - const maxDepth = groupsByDepth.length - 1; - - // Process from deepest to shallowest (bottom-up) - for (let depth = maxDepth; depth >= 0; depth--) { - for (const layoutGroup of groupsByDepth[depth]) { - const { group, memberIds, childGroups } = layoutGroup; - - // Skip groups with no content - if (memberIds.size === 0 && childGroups.length === 0) { - debugLog(`Skipping empty group "${group.title}"`); - continue; - } - - // Calculate bounding box from positioned members and child groups - let minX = Infinity; - let minY = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; - - // Include direct member nodes - for (const nodeId of memberIds) { - const node = allNodes.get(nodeId); - if (!node) continue; - - const nx = node.pos?.[0] ?? 0; - const ny = node.pos?.[1] ?? 0; - const nw = node.size?.[0] ?? 200; - const nh = node.size?.[1] ?? 100; - - minX = Math.min(minX, nx); - minY = Math.min(minY, ny); - maxX = Math.max(maxX, nx + nw); - maxY = Math.max(maxY, ny + nh); - } - - // Include child groups (already resized in previous iterations) - for (const child of childGroups) { - const cg = child.group; - const cx = cg.pos[0]; - const cy = cg.pos[1]; - const cw = cg.size[0]; - const ch = cg.size[1]; - - minX = Math.min(minX, cx); - minY = Math.min(minY, cy); - maxX = Math.max(maxX, cx + cw); - maxY = Math.max(maxY, cy + ch); - } - - // Handle empty bounds (shouldn't happen but be safe) - if (minX === Infinity) { - debugLog(`No content bounds for group "${group.title}"`); - continue; - } - - // Calculate final group bounds with padding - const padding = config.groupPadding; - const newX = minX - padding; - const newY = minY - padding - GROUP_TITLE_HEIGHT; - const newWidth = maxX - minX + padding * 2; - const newHeight = maxY - minY + padding * 2 + GROUP_TITLE_HEIGHT; - - debugLog( - `Resizing group "${group.title}" (depth ${layoutGroup.depth}) to [${newX}, ${newY}, ${newWidth}, ${newHeight}]` - ); - - // Update group bounds - updateGroupBounds(group, newX, newY, newWidth, newHeight); - - resizedCount++; - } - } - - return resizedCount; -} - -/** - * Update group bounds using multiple methods for compatibility - */ -function updateGroupBounds( - group: LGraphGroup, - x: number, - y: number, - width: number, - height: number -): void { - // Method 1: Direct pos/size array mutation (most common) - if (Array.isArray(group.pos)) { - group.pos[0] = x; - group.pos[1] = y; - } - if (Array.isArray(group.size)) { - group.size[0] = width; - group.size[1] = height; - } - - // Method 2: Try _pos/_size internal properties - const g = group as LGraphGroup & { - _pos?: [number, number]; - _size?: [number, number]; - }; - if (Array.isArray(g._pos)) { - g._pos[0] = x; - g._pos[1] = y; - } - if (Array.isArray(g._size)) { - g._size[0] = width; - g._size[1] = height; - } - - // Method 3: Update _bounding Rectangle if present - if (group._bounding) { - const b = group._bounding; - b.x = x; - b.y = y; - b.width = width; - b.height = height; - } - - // Method 4: Try bounding array (older format) - const gAny = group as LGraphGroup & { bounding?: number[] }; - if (Array.isArray(gAny.bounding)) { - gAny.bounding[0] = x; - gAny.bounding[1] = y; - gAny.bounding[2] = width; - gAny.bounding[3] = height; - } -} diff --git a/src/layout/index.ts b/src/layout/index.ts index 9c58c69..fa02453 100644 --- a/src/layout/index.ts +++ b/src/layout/index.ts @@ -1,106 +1,29 @@ -import type { LGraph, LayoutConfig, LayoutResult } from "./types"; -import { DEFAULT_CONFIG } from "./types"; -import { buildLayoutGraph } from "./graph-builder"; -import { assignLayers } from "./layer-assign"; -import { minimizeCrossings } from "./ordering"; -import { - assignLayerXPositions, - assignNodeYPositions, - recalculateLayerXPositions, - applyPositions, - compactVertically, - resolveAllOverlaps, -} from "./positioning"; -import { resizeGroupsToFit } from "./groups"; -import { debugLog } from "../debug"; - /** - * Main entry point: layout the entire graph - * - * Pipeline: - * 1. Build layout graph (classify nodes, collapse reroutes, filter disconnected) - * 2. Assign layers via longest-path algorithm - * 3. Minimize edge crossings via size-aware barycenter heuristic - * 4. Assign X/Y coordinates with bin packing - * 5. Compact vertically to reduce gaps - * 6. Apply positions (includes disconnected zone and reroute restoration) - * 7. Resize groups to fit their members + * Layout module barrel export. */ -export function layoutGraph( - graph: LGraph, - config?: Partial -): LayoutResult { - const startTime = performance.now(); - const fullConfig: LayoutConfig = { ...DEFAULT_CONFIG, ...config }; - // Handle empty graph - const nodeList = graph._nodes ?? []; - if (nodeList.length === 0) { - return { - nodeCount: 0, - layerCount: 0, - groupCount: 0, - executionMs: 0, - }; - } +export { + layoutWithGroups, + buildGroupHierarchy, + splitDisconnected, + placeDisconnected, + translatePositions, +} from "./framework"; - // Phase 1: Build layout graph (includes classification and reroute collapse) - const state = buildLayoutGraph(graph, fullConfig); +export { compactVertically, compactHorizontally } from "./compact"; - // Phase 2: Assign layers - assignLayers(state); +export type { + Position, + LayoutNode, + LayoutEdge, + LayoutGroup, + LayoutToken, + LayoutInput, + LayoutOutput, + LayoutAlgorithm, + GroupBounds, + FrameworkResult, + FrameworkConfig, +} from "./types"; - // Phase 3: Minimize crossings (size-aware barycenter) - minimizeCrossings(state); - - // Phase 4: Assign coordinates (with bin packing) - assignLayerXPositions(state); - assignNodeYPositions(state); - - // Phase 4b: Recalculate X after bin packing updates maxWidth - // (bin packing may place multiple nodes in a row, making layer wider) - recalculateLayerXPositions(state); - - // Phase 5: Compact vertically - compactVertically(state); - - // Phase 6: Apply to ComfyUI nodes (includes disconnected and reroute handling) - applyPositions(state); - - // Phase 7: Resize groups - const groupCount = resizeGroupsToFit(state); - - // Phase 8: Resolve all overlaps (groups + nodes unified) - // Uses scanline algorithm with priority-based monotonic pushing - resolveAllOverlaps(state); - - // Trigger canvas redraw - if (graph.setDirtyCanvas) { - graph.setDirtyCanvas(true, true); - } - - const executionMs = performance.now() - startTime; - - // Count total nodes including disconnected and reroutes - const disconnectedCount = state.disconnectedNodes?.size ?? 0; - const rerouteCount = state.rerouteChains?.reduce((sum, c) => sum + c.nodes.length, 0) ?? 0; - const totalNodes = state.nodes.size + disconnectedCount + rerouteCount; - - debugLog( - `Layout complete: ${totalNodes} nodes (${state.nodes.size} in DAG, ${disconnectedCount} disconnected, ${rerouteCount} reroutes), ${state.layers.length} layers in ${executionMs.toFixed(1)}ms` - ); - - return { - nodeCount: totalNodes, - layerCount: state.layers.length, - groupCount, - executionMs, - }; -} - -// Re-export types and config -export type { LayoutConfig, LayoutResult, SelectedGroupLayoutResult } from "./types"; -export { DEFAULT_CONFIG } from "./types"; - -// Export selected groups layout -export { layoutSelectedGroups } from "./selected-groups"; +export { DEFAULT_FRAMEWORK_CONFIG } from "./types"; diff --git a/src/layout/layer-assign.ts b/src/layout/layer-assign.ts deleted file mode 100644 index 7f9be90..0000000 --- a/src/layout/layer-assign.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { LayoutState, LayoutNode, Layer } from "./types"; - -/** - * Assign layers to nodes using longest-path algorithm - * Sources (no predecessors) get layer 0 - * Each node gets max(predecessor layers) + 1 - */ -export function assignLayers(state: LayoutState): void { - const { nodes } = state; - - // Topological sort via Kahn's algorithm - const inDegree = new Map(); - const queue: number[] = []; - const order: number[] = []; - - // Initialize in-degrees - for (const [id, node] of nodes) { - inDegree.set(id, node.predecessors.length); - if (node.predecessors.length === 0) { - queue.push(id); - } - } - - // Process nodes in topological order - while (queue.length > 0) { - const nodeId = queue.shift()!; - order.push(nodeId); - - const node = nodes.get(nodeId)!; - for (const succId of node.successors) { - const deg = (inDegree.get(succId) ?? 1) - 1; - inDegree.set(succId, deg); - if (deg === 0) { - queue.push(succId); - } - } - } - - // Handle cycles: add remaining nodes (shouldn't happen in valid DAG) - for (const [id] of nodes) { - if (!order.includes(id)) { - order.push(id); - } - } - - // Assign layers via longest path from sources - for (const nodeId of order) { - const node = nodes.get(nodeId)!; - - if (node.predecessors.length === 0) { - // Source node: layer 0 - node.layer = 0; - } else { - // Max layer of predecessors + 1 - let maxPredLayer = 0; - for (const predId of node.predecessors) { - const pred = nodes.get(predId); - if (pred && pred.layer >= 0) { - maxPredLayer = Math.max(maxPredLayer, pred.layer + 1); - } - } - node.layer = maxPredLayer; - } - } - - // Ensure all nodes have valid layer (handle edge cases) - for (const node of nodes.values()) { - if (node.layer < 0) { - node.layer = 0; - } - } - - // Build layer structures - const layerMap = new Map(); - for (const node of nodes.values()) { - const arr = layerMap.get(node.layer) ?? []; - arr.push(node); - layerMap.set(node.layer, arr); - } - - // Sort layers by index and create Layer objects - const sortedLayerIndices = [...layerMap.keys()].sort((a, b) => a - b); - const layers: Layer[] = []; - - for (const index of sortedLayerIndices) { - const layerNodes = layerMap.get(index)!; - // Sort nodes in layer by ID for initial deterministic order - layerNodes.sort((a, b) => a.id - b.id); - layerNodes.forEach((n, i) => { - n.orderInLayer = i; - }); - - layers.push({ - index, - nodes: layerNodes, - x: 0, - maxWidth: Math.max(...layerNodes.map((n) => n.width), 100), - }); - } - - state.layers = layers; -} diff --git a/src/layout/layout-utils.ts b/src/layout/layout-utils.ts deleted file mode 100644 index 6de8685..0000000 --- a/src/layout/layout-utils.ts +++ /dev/null @@ -1,119 +0,0 @@ -import type { LGraphNode } from "./types"; - -/** - * Get internal edges between group members by examining node connections - * Returns array of [sourceId, targetId] pairs where both are in the group - */ -export function getInternalEdges( - memberIds: Set, - allNodes: Map -): Array<[number, number]> { - const edges: Array<[number, number]> = []; - - // Build a map of linkId -> sourceNodeId for all member outputs - const linkToSource = new Map(); - for (const memberId of memberIds) { - const node = allNodes.get(memberId); - if (!node?.outputs) continue; - - for (const output of node.outputs) { - if (output.links) { - for (const linkId of output.links) { - linkToSource.set(linkId, memberId); - } - } - } - } - - // Check each member's inputs to find internal connections - for (const memberId of memberIds) { - const node = allNodes.get(memberId); - if (!node?.inputs) continue; - - for (const input of node.inputs) { - if (input.link !== null && input.link !== undefined) { - const sourceId = linkToSource.get(input.link); - if (sourceId !== undefined && memberIds.has(sourceId)) { - edges.push([sourceId, memberId]); - } - } - } - } - - return edges; -} - -/** - * Assign layers to group members using longest-path algorithm - * Returns members grouped by layer (index 0 = sources) - */ -export function assignMemberLayers( - memberIds: Set, - edges: Array<[number, number]>, - allNodes: Map -): LGraphNode[][] { - // Build adjacency and in-degree - const successors = new Map(); - const inDegree = new Map(); - - for (const id of memberIds) { - successors.set(id, []); - inDegree.set(id, 0); - } - - for (const [source, target] of edges) { - successors.get(source)!.push(target); - inDegree.set(target, (inDegree.get(target) ?? 0) + 1); - } - - // Longest-path layer assignment via topological sort - const nodeLayer = new Map(); - const queue: number[] = []; - - // Start with sources (in-degree 0) - for (const id of memberIds) { - if ((inDegree.get(id) ?? 0) === 0) { - queue.push(id); - nodeLayer.set(id, 0); - } - } - - while (queue.length > 0) { - const current = queue.shift()!; - const currentLayer = nodeLayer.get(current) ?? 0; - - for (const succ of successors.get(current) ?? []) { - // Update successor layer to max of current paths - const succLayer = nodeLayer.get(succ) ?? 0; - nodeLayer.set(succ, Math.max(succLayer, currentLayer + 1)); - - // Decrease in-degree and add to queue when all predecessors processed - const newInDegree = (inDegree.get(succ) ?? 1) - 1; - inDegree.set(succ, newInDegree); - if (newInDegree === 0) { - queue.push(succ); - } - } - } - - // Handle any unvisited nodes (cycles or disconnected) - for (const id of memberIds) { - if (!nodeLayer.has(id)) { - nodeLayer.set(id, 0); - } - } - - // Group by layer - const maxLayer = Math.max(...nodeLayer.values(), 0); - const layers: LGraphNode[][] = Array.from({ length: maxLayer + 1 }, () => []); - - for (const id of memberIds) { - const node = allNodes.get(id); - if (node) { - const layer = nodeLayer.get(id) ?? 0; - layers[layer].push(node); - } - } - - return layers; -} diff --git a/src/layout/node-classifier.ts b/src/layout/node-classifier.ts deleted file mode 100644 index 4304b90..0000000 --- a/src/layout/node-classifier.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { LGraph, LLink, ClassifiedNodes } from "./types"; -import { debugLog } from "../debug"; - -/** - * Iterate over links in a graph (handles Map, Record, and Array formats) - */ -function* iterateLinks( - links: Map | Record | LLink[] -): Generator { - if (links instanceof Map) { - for (const link of links.values()) { - if (link) yield link; - } - } else if (Array.isArray(links)) { - for (const link of links) { - if (link) yield link; - } - } else { - for (const link of Object.values(links)) { - if (link) yield link; - } - } -} - -/** - * Classify nodes into connected, disconnected, and reroutes - * - * - connected: nodes with at least one link - * - disconnected: nodes with no links (annotations, bookmarks, etc.) - * - reroutes: nodes with type "Reroute" (handled separately for chain collapse) - */ -export function classifyNodes(graph: LGraph): ClassifiedNodes { - const hasLinks = new Set(); - - // Mark all nodes that appear in any link - if (graph.links) { - for (const link of iterateLinks(graph.links)) { - if (typeof link.origin_id === "number") { - hasLinks.add(link.origin_id); - } - if (typeof link.target_id === "number") { - hasLinks.add(link.target_id); - } - } - } - - const connected = new Set(); - const disconnected = new Set(); - const reroutes = new Set(); - - const nodes = graph._nodes ?? []; - for (const node of nodes) { - if (!node || node.id === undefined) continue; - - const nodeType = node.type ?? ""; - - // Check if this is a reroute node - // Common reroute types: "Reroute", "Reroute (rgthree)", etc. - const isReroute = nodeType === "Reroute" || nodeType.startsWith("Reroute "); - - if (isReroute) { - reroutes.add(node.id); - // Reroutes are also tracked as connected if they have links - if (hasLinks.has(node.id)) { - connected.add(node.id); - } - } else if (hasLinks.has(node.id)) { - connected.add(node.id); - } else { - disconnected.add(node.id); - } - } - - debugLog( - `Classified: ${connected.size} connected, ${disconnected.size} disconnected, ${reroutes.size} reroutes` - ); - - return { connected, disconnected, reroutes }; -} diff --git a/src/layout/ordering.ts b/src/layout/ordering.ts deleted file mode 100644 index d35209d..0000000 --- a/src/layout/ordering.ts +++ /dev/null @@ -1,195 +0,0 @@ -import type { LayoutState, Layer, LayoutNode } from "./types"; - -/** - * Minimize edge crossings using size-aware barycenter heuristic - * Uses cumulative height positions instead of simple indices - * Alternates forward and backward sweeps - */ -export function minimizeCrossings(state: LayoutState): void { - const { layers, nodes, config } = state; - - if (layers.length < 2) return; - - // Initial ordering: sort by node height (tall nodes first reduces gaps) - for (const layer of layers) { - layer.nodes.sort((a, b) => b.height - a.height); - layer.nodes.forEach((n, i) => { - n.orderInLayer = i; - }); - } - - // Pre-compute cumulative height positions for size-aware barycenter - const heightPositions = new Map(); - updateHeightPositions(layers, heightPositions, config.verticalGap); - - // Iterative improvement with forward/backward sweeps - for (let iter = 0; iter < config.maxIterations; iter++) { - const improved = - iter % 2 === 0 - ? sweepForward(layers, nodes, heightPositions, config.verticalGap) - : sweepBackward(layers, nodes, heightPositions, config.verticalGap); - - if (!improved) break; - } -} - -/** - * Compute cumulative height positions for each node - * This gives a position-like weight for size-aware ordering - */ -function updateHeightPositions( - layers: Layer[], - positions: Map, - verticalGap: number -): void { - for (const layer of layers) { - let y = 0; - for (const node of layer.nodes) { - // Position is center of node - positions.set(node.id, y + node.height / 2); - y += node.height + verticalGap; - } - } -} - -/** - * Forward sweep: order each layer based on predecessors in previous layer - * Uses height-aware positions for better size consideration - */ -function sweepForward( - layers: Layer[], - nodes: Map, - heightPositions: Map, - verticalGap: number -): boolean { - let improved = false; - - for (let i = 1; i < layers.length; i++) { - const currLayer = layers[i]; - - // Compute size-aware barycenter for each node - const barycenters = new Map(); - - for (const node of currLayer.nodes) { - if (node.predecessors.length > 0) { - let sum = 0; - let count = 0; - - for (const predId of node.predecessors) { - const pred = nodes.get(predId); - if (pred && pred.layer === i - 1) { - // Use height position instead of simple index - const pos = heightPositions.get(predId) ?? pred.orderInLayer; - sum += pos; - count++; - } - } - - if (count > 0) { - barycenters.set(node.id, sum / count); - } else { - barycenters.set(node.id, heightPositions.get(node.id) ?? 0); - } - } else { - barycenters.set(node.id, heightPositions.get(node.id) ?? 0); - } - } - - // Sort by barycenter, tie-break by ID for determinism - const oldOrder = currLayer.nodes.map((n) => n.id).join(","); - currLayer.nodes.sort((a, b) => { - const aBar = barycenters.get(a.id) ?? 0; - const bBar = barycenters.get(b.id) ?? 0; - const diff = aBar - bBar; - return diff !== 0 ? diff : a.id - b.id; - }); - - // Reassign integer order - currLayer.nodes.forEach((n, idx) => { - n.orderInLayer = idx; - }); - - // Update height positions for next iteration - let y = 0; - for (const node of currLayer.nodes) { - heightPositions.set(node.id, y + node.height / 2); - y += node.height + verticalGap; - } - - const newOrder = currLayer.nodes.map((n) => n.id).join(","); - if (oldOrder !== newOrder) improved = true; - } - - return improved; -} - -/** - * Backward sweep: order each layer based on successors in next layer - * Uses height-aware positions for better size consideration - */ -function sweepBackward( - layers: Layer[], - nodes: Map, - heightPositions: Map, - verticalGap: number -): boolean { - let improved = false; - - for (let i = layers.length - 2; i >= 0; i--) { - const currLayer = layers[i]; - - // Compute size-aware barycenter for each node based on successors - const barycenters = new Map(); - - for (const node of currLayer.nodes) { - if (node.successors.length > 0) { - let sum = 0; - let count = 0; - - for (const succId of node.successors) { - const succ = nodes.get(succId); - if (succ && succ.layer === i + 1) { - // Use height position instead of simple index - const pos = heightPositions.get(succId) ?? succ.orderInLayer; - sum += pos; - count++; - } - } - - if (count > 0) { - barycenters.set(node.id, sum / count); - } else { - barycenters.set(node.id, heightPositions.get(node.id) ?? 0); - } - } else { - barycenters.set(node.id, heightPositions.get(node.id) ?? 0); - } - } - - // Sort by barycenter, tie-break by ID for determinism - const oldOrder = currLayer.nodes.map((n) => n.id).join(","); - currLayer.nodes.sort((a, b) => { - const aBar = barycenters.get(a.id) ?? 0; - const bBar = barycenters.get(b.id) ?? 0; - const diff = aBar - bBar; - return diff !== 0 ? diff : a.id - b.id; - }); - - // Reassign integer order - currLayer.nodes.forEach((n, idx) => { - n.orderInLayer = idx; - }); - - // Update height positions for next iteration - let y = 0; - for (const node of currLayer.nodes) { - heightPositions.set(node.id, y + node.height / 2); - y += node.height + verticalGap; - } - - const newOrder = currLayer.nodes.map((n) => n.id).join(","); - if (oldOrder !== newOrder) improved = true; - } - - return improved; -} diff --git a/src/layout/positioning.ts b/src/layout/positioning.ts deleted file mode 100644 index 1b5133a..0000000 --- a/src/layout/positioning.ts +++ /dev/null @@ -1,846 +0,0 @@ -import type { LayoutState, LayoutGroup, LGraphNode, LGraphGroup, LayoutConfig, PackedRow, LayoutNode } from "./types"; - -/** - * Entity type for unified overlap resolution - * Represents either a group or a standalone node - */ -interface LayoutEntity { - id: string; - type: "group" | "node"; - priority: number; - x: number; - y: number; - width: number; - height: number; - initialX: number; // Original position before overlap resolution - initialY: number; // Original position before overlap resolution - group?: LGraphGroup; - node?: LGraphNode; - layoutGroup?: LayoutGroup; -} -import { packNodesIntoRows, getPackedWidth } from "./bin-pack"; -import { restoreReroutePositions } from "./reroute-collapse"; -import { getInternalEdges, assignMemberLayers } from "./layout-utils"; -import { debugLog } from "../debug"; -import { parseLayoutToken, arrangeByMode } from "./title-tokens"; - -/** Title bar height for groups */ -const GROUP_TITLE_HEIGHT = 50; - -/** - * Calculate disconnected zone width if there are disconnected nodes - */ -function getDisconnectedZoneWidth(state: LayoutState): number { - const { disconnectedNodes, allNodes, config } = state; - - if (!disconnectedNodes || disconnectedNodes.size === 0 || !allNodes) { - return 0; - } - - let maxWidth = 0; - for (const nodeId of disconnectedNodes) { - const node = allNodes.get(nodeId); - if (node) { - maxWidth = Math.max(maxWidth, node.size?.[0] ?? 200); - } - } - return maxWidth + config.disconnectedGap; -} - -/** - * Assign X coordinates to layers - * Accounts for disconnected zone on the left if present - */ -export function assignLayerXPositions(state: LayoutState): void { - const { layers, config } = state; - const disconnectedZoneWidth = getDisconnectedZoneWidth(state); - - let x = config.startX + disconnectedZoneWidth; - for (const layer of layers) { - layer.x = x; - x += layer.maxWidth + config.horizontalGap; - } -} - -/** - * Recalculate layer X positions after bin packing updates maxWidth - * Also updates node X positions to maintain their offset within each layer - * Must be called AFTER assignNodeYPositions which updates layer.maxWidth - */ -export function recalculateLayerXPositions(state: LayoutState): void { - const { layers, config } = state; - const disconnectedZoneWidth = getDisconnectedZoneWidth(state); - - // Store each node's offset from its layer's current X - const nodeOffsets = new Map(); - for (const layer of layers) { - for (const node of layer.nodes) { - nodeOffsets.set(node.id, node.x - layer.x); - } - } - - // Recalculate layer X positions using updated maxWidth - let x = config.startX + disconnectedZoneWidth; - for (const layer of layers) { - layer.x = x; - x += layer.maxWidth + config.horizontalGap; - } - - // Update node X positions based on new layer X + original offset - for (const layer of layers) { - for (const node of layer.nodes) { - const offset = nodeOffsets.get(node.id) ?? 0; - node.x = layer.x + offset; - } - } -} - -/** - * Assign Y coordinates to nodes within each layer using bin packing - * Supports multi-column layout based on config.maxColumns - * Tracks reserved regions to prevent group/node overlaps within same layer - */ -export function assignNodeYPositions(state: LayoutState): void { - const { layers, config } = state; - - // Store packed rows for reference - const packedRows = new Map(); - - for (const layer of layers) { - // Separate groups (placed first, full width) from standalone nodes - const groupNodes = layer.nodes.filter((n) => n.isGroupRepresentative); - const standaloneNodes = layer.nodes.filter((n) => !n.isGroupRepresentative); - - let y = config.startY; - const reservedRegions: Array<{ top: number; bottom: number }> = []; - - // Position groups first, tracking their Y regions - for (const node of groupNodes) { - node.x = layer.x; - node.y = y; - reservedRegions.push({ top: y, bottom: y + node.height }); - y += node.height + config.verticalGap; - } - - // Bin pack standalone nodes, avoiding reserved regions - if (standaloneNodes.length > 0) { - // Build size map for bin packing - const sizes = new Map(); - for (const node of standaloneNodes) { - sizes.set(node.id, { width: node.width, height: node.height }); - } - - // Pack nodes into rows - const nodeIds = standaloneNodes.map((n) => n.id); - const rows = packNodesIntoRows(nodeIds, sizes, config); - - packedRows.set(layer.index, rows); - - // Assign positions based on packed rows - for (const row of rows) { - let rowX = layer.x; - let rowY = y + row.yOffset; - - // Check if row overlaps any reserved region - for (const region of reservedRegions) { - if (rowY < region.bottom && rowY + row.height > region.top) { - rowY = region.bottom + config.verticalGap; - } - } - - for (const nodeId of row.nodes) { - const node = standaloneNodes.find((n) => n.id === nodeId); - if (node) { - node.x = rowX; - node.y = rowY; - rowX += node.width + config.horizontalGap; - } - } - } - - // Update layer maxWidth to account for packed rows - const maxRowWidth = getPackedWidth(rows); - if (maxRowWidth > layer.maxWidth) { - layer.maxWidth = maxRowWidth; - } - } - } - - state.packedRows = packedRows; -} - -/** - * Place disconnected nodes in the left margin zone - * Uses bin packing for compact arrangement - */ -export function placeDisconnectedNodes(state: LayoutState): void { - const { disconnectedNodes, allNodes, nodeToGroup, config } = state; - - if (!disconnectedNodes || disconnectedNodes.size === 0 || !allNodes) { - return; - } - - // Filter out disconnected nodes that are inside groups - they're positioned with their group - const standaloneDisconnected: number[] = []; - for (const nodeId of disconnectedNodes) { - if (nodeToGroup?.has(nodeId)) { - continue; // Skip - this node is inside a group - } - standaloneDisconnected.push(nodeId); - } - - if (standaloneDisconnected.length === 0) { - debugLog(`All ${disconnectedNodes.size} disconnected nodes are inside groups`); - return; - } - - // Build size map - const sizes = new Map(); - for (const nodeId of standaloneDisconnected) { - const node = allNodes.get(nodeId); - if (node) { - sizes.set(nodeId, { - width: node.size?.[0] ?? 200, - height: node.size?.[1] ?? 100, - }); - } - } - - // Pack into rows using config settings - const rows = packNodesIntoRows(standaloneDisconnected, sizes, { - ...config, - maxColumns: 1, // Stack vertically in left margin - }); - - // Position at startX, startY (left of main DAG) - const x = config.startX; - const y = config.startY; - - for (const row of rows) { - for (const nodeId of row.nodes) { - const node = allNodes.get(nodeId); - if (node && node.pos) { - // Skip locked/pinned nodes - if (node.flags?.pinned || node.locked) { - continue; - } - node.pos[0] = x; - node.pos[1] = y + row.yOffset; - } - } - } - - debugLog( - `Placed ${standaloneDisconnected.length} disconnected nodes in left margin (${disconnectedNodes.size - standaloneDisconnected.length} inside groups)` - ); -} - -/** - * Apply computed positions to actual ComfyUI nodes - * Handles standalone nodes, subgraph I/O nodes, top-level groups, and nested groups recursively - * Also restores reroute positions and places disconnected nodes - */ -export function applyPositions(state: LayoutState): void { - const { nodes, groupRepresentatives, allNodes, groups, config, rerouteChains } = state; - - // First: Apply positions to standalone nodes (including I/O nodes) - for (const layoutNode of nodes.values()) { - if (layoutNode.isGroupRepresentative) { - // This is a group - handled below with recursive positioning - continue; - } - - // Handle subgraph I/O nodes (update pos array directly) - if (layoutNode.isSubgraphIO && layoutNode.runtimeIONode) { - layoutNode.runtimeIONode.pos[0] = layoutNode.x; - layoutNode.runtimeIONode.pos[1] = layoutNode.y; - - // Sync allNodes entry for overlap resolution (ioNodeToLGraphNode creates a copy) - if (allNodes) { - const allNode = allNodes.get(layoutNode.id); - if (allNode?.pos) { - allNode.pos[0] = layoutNode.x; - allNode.pos[1] = layoutNode.y; - } - } - - debugLog( - `Positioned I/O node id=${layoutNode.id} to [${layoutNode.x}, ${layoutNode.y}]` - ); - continue; - } - - const comfyNode = layoutNode.node; - - // Skip locked/pinned nodes - if (comfyNode.flags?.pinned || comfyNode.locked) { - continue; - } - - // Update position - if (comfyNode.pos) { - comfyNode.pos[0] = layoutNode.x; - comfyNode.pos[1] = layoutNode.y; - } - } - - // Second: Position group contents recursively (top-level groups only) - if (groupRepresentatives && allNodes && groups) { - // Find top-level groups (no parent) - const topLevelGroups = groups.filter((g) => !g.parentGroup); - - for (const topGroup of topLevelGroups) { - const repId = groupRepresentatives.get(topGroup); - if (repId === undefined) continue; - - const repNode = nodes.get(repId); - if (!repNode) continue; - - // Start positioning at the group's content area - const startX = repNode.x + config.groupPadding; - const startY = repNode.y + config.groupPadding + GROUP_TITLE_HEIGHT; - - positionGroupContents(topGroup, startX, startY, allNodes, config); - } - } - - // Third: Place disconnected nodes in left margin - placeDisconnectedNodes(state); - - // Fourth: Restore reroute positions along their chain edges - if (rerouteChains && rerouteChains.length > 0 && allNodes) { - // Build positions and sizes maps from actual nodes - const positions = new Map(); - const sizes = new Map(); - - for (const [id, node] of allNodes) { - if (node.pos) { - positions.set(id, { x: node.pos[0], y: node.pos[1] }); - } - if (node.size) { - sizes.set(id, { width: node.size[0], height: node.size[1] }); - } - } - - // Also include layout node positions (for nodes we just positioned) - for (const layoutNode of nodes.values()) { - positions.set(layoutNode.id, { x: layoutNode.x, y: layoutNode.y }); - sizes.set(layoutNode.id, { width: layoutNode.width, height: layoutNode.height }); - } - - // Restore reroute positions - restoreReroutePositions(rerouteChains, positions, sizes); - - // Apply restored positions to actual nodes - for (const chain of rerouteChains) { - for (const rerouteId of chain.nodes) { - const pos = positions.get(rerouteId); - const node = allNodes.get(rerouteId); - if (pos && node && node.pos) { - node.pos[0] = pos.x; - node.pos[1] = pos.y; - } - } - } - - debugLog( - `Restored ${rerouteChains.length} reroute chains` - ); - } -} - -/** - * Re-layout group contents using proper Sugiyama-style layout for connected members - * or bin packing for disconnected members - * Returns the final Y position after all contents - */ -function positionGroupContents( - group: LayoutGroup, - startX: number, - startY: number, - allNodes: Map, - config: LayoutConfig -): number { - // Collect non-locked members - const members: LGraphNode[] = []; - for (const nodeId of group.memberIds) { - const node = allNodes.get(nodeId); - if (!node) continue; - if (node.flags?.pinned || node.locked) continue; - members.push(node); - } - - // Handle empty group - if (members.length === 0 && group.childGroups.length === 0) { - return startY; - } - - // Parse layout token from group title - const layoutMode = parseLayoutToken(group.group.title); - - debugLog(`layoutGroupContents "${group.group.title}": startX=${startX}, members=${members.length}, childGroups=${group.childGroups.length}, mode=${layoutMode.type}`); - - let finalY = startY; - - // If layout mode is specified, use token-based layout (ignores internal edges) - if (layoutMode.type !== "default" && members.length > 0) { - const arranged = arrangeByMode(members, layoutMode, startX, startY, config); - - for (const pos of arranged) { - const node = allNodes.get(pos.id); - if (node?.pos) { - node.pos[0] = pos.x; - node.pos[1] = pos.y; - debugLog(` Member ${node.id} (${node.type}): token layout [${pos.x}, ${pos.y}]`); - - const nh = node.size?.[1] ?? 100; - finalY = Math.max(finalY, pos.y + nh); - } - } - } else if (members.length > 0) { - // Default behavior: check internal edges for layer-based or bin pack layout - const edges = getInternalEdges(group.memberIds, allNodes); - - if (edges.length > 0) { - // Has internal connections - use layer-based layout - const layers = assignMemberLayers(group.memberIds, edges, allNodes); - - let x = startX; - for (let layerIdx = 0; layerIdx < layers.length; layerIdx++) { - const layer = layers[layerIdx]; - if (layer.length === 0) continue; - - // Sort layer by height (tallest first for stable layout) - layer.sort((a, b) => (b.size?.[1] ?? 100) - (a.size?.[1] ?? 100)); - - let y = startY; - let layerMaxWidth = 0; - - for (const node of layer) { - if (node.pos) { - node.pos[0] = x; - node.pos[1] = y; - debugLog(` Member ${node.id} (${node.type}): layer ${layerIdx}, pos [${x}, ${y}]`); - } - - layerMaxWidth = Math.max(layerMaxWidth, node.size?.[0] ?? 200); - y += (node.size?.[1] ?? 100) + config.verticalGap; - } - - finalY = Math.max(finalY, y - config.verticalGap); - x += layerMaxWidth + config.horizontalGap; - } - } else { - // No internal connections - use simple vertical stacking with bin pack - // Sort by height for consistent layout - members.sort((a, b) => (b.size?.[1] ?? 100) - (a.size?.[1] ?? 100)); - - // Build sizes map - const sizes = new Map(); - for (const node of members) { - sizes.set(node.id, { - width: node.size?.[0] ?? 200, - height: node.size?.[1] ?? 100, - }); - } - - // Pack into rows - const nodeIds = members.map((n) => n.id); - const rows = packNodesIntoRows(nodeIds, sizes, config); - - let y = startY; - for (const row of rows) { - let x = startX; - for (const nodeId of row.nodes) { - const node = allNodes.get(nodeId); - if (node?.pos) { - node.pos[0] = x; - node.pos[1] = y; - debugLog(` Member ${node.id} (${node.type}): packed pos [${x}, ${y}]`); - x += (node.size?.[0] ?? 200) + config.horizontalGap; - } - } - y += row.height + config.verticalGap; - } - finalY = y - config.verticalGap; - } - } - - // Position child groups after members - for (const child of group.childGroups) { - // Calculate child group's content area - const childStartX = startX; - const childStartY = finalY + config.verticalGap; - - // First, layout the child group's contents - const childEndY = positionGroupContents(child, childStartX, childStartY + GROUP_TITLE_HEIGHT, allNodes, config); - - // Update child group position - if (child.group.pos) { - child.group.pos[0] = childStartX - config.groupPadding; - child.group.pos[1] = childStartY - config.groupPadding; - debugLog(` Child group "${child.group.title}": pos [${child.group.pos[0]}, ${child.group.pos[1]}]`); - } - - finalY = childEndY + config.groupPadding; - } - - return finalY; -} - -/** - * Calculate vertical bounds of nodes in a layer - * Returns actual min/max Y by iterating all nodes, not assuming array order - * (Array order is by barycenter, not Y position after bin-packing) - */ -function getLayerBounds(nodes: LayoutNode[]): { minY: number; maxY: number } | null { - if (nodes.length === 0) return null; - - let minY = Infinity; - let maxY = -Infinity; - - for (const node of nodes) { - minY = Math.min(minY, node.y); - maxY = Math.max(maxY, node.y + node.height); - } - - if (minY === Infinity) return null; - return { minY, maxY }; -} - -/** - * Compact vertical layout to reduce gaps - * Centers layers against the tallest non-group layer - * Groups are excluded from max calculation to prevent Y explosion - */ -export function compactVertically(state: LayoutState): void { - const { layers } = state; - - // Calculate max height from STANDALONE nodes only (exclude groups) - // Groups are outliers that can be 4000px+, which would push all other layers down - let maxTotalHeight = 0; - - for (const layer of layers) { - const standaloneNodes = layer.nodes.filter((n) => !n.isGroupRepresentative); - - if (standaloneNodes.length === 0) { - // Layer is all groups, skip from max calculation - continue; - } - - const bounds = getLayerBounds(standaloneNodes); - if (!bounds) continue; - - const layerHeight = bounds.maxY - bounds.minY; - maxTotalHeight = Math.max(maxTotalHeight, layerHeight); - } - - // If no standalone layers, skip centering entirely - if (maxTotalHeight === 0) return; - - // Center each layer against maxTotalHeight - for (const layer of layers) { - if (layer.nodes.length === 0) continue; - - const bounds = getLayerBounds(layer.nodes); - if (!bounds) continue; - - const layerHeight = bounds.maxY - bounds.minY; - const offset = (maxTotalHeight - layerHeight) / 2; - - for (const node of layer.nodes) { - node.y += offset; - } - } -} - -/** - * Collect all entities (groups and standalone nodes) for overlap resolution - * Assigns priority: groups=100, connected nodes=50, disconnected=10 - */ -function collectEntities(state: LayoutState): LayoutEntity[] { - const entities: LayoutEntity[] = []; - const { groups, allNodes, nodeToGroup, disconnectedNodes } = state; - - if (!allNodes) return entities; - - // Add top-level groups (high priority - they anchor) - for (const lg of groups ?? []) { - if (lg.parentGroup) continue; // Skip nested, handled with parent - const x = lg.group.pos?.[0] ?? 0; - const y = lg.group.pos?.[1] ?? 0; - entities.push({ - id: `group_${lg.group.title ?? entities.length}`, - type: "group", - priority: 100, - x, - y, - width: lg.group.size?.[0] ?? 0, - height: lg.group.size?.[1] ?? 0, - initialX: x, - initialY: y, - group: lg.group, - layoutGroup: lg, - }); - } - - // Add standalone nodes (lower priority) - for (const [nodeId, node] of allNodes) { - if (nodeToGroup?.has(nodeId)) continue; // Inside group - if (node.flags?.pinned || node.locked) continue; - - const isDisconnected = disconnectedNodes?.has(nodeId) ?? false; - const x = node.pos?.[0] ?? 0; - const y = node.pos?.[1] ?? 0; - entities.push({ - id: `node_${nodeId}`, - type: "node", - priority: isDisconnected ? 10 : 50, - x, - y, - width: node.size?.[0] ?? 200, - height: node.size?.[1] ?? 100, - initialX: x, - initialY: y, - node, - }); - } - - return entities; -} - -/** - * Scanline overlap resolution for X-axis (push right) - * Entities are sorted by X, overlapping entities are pushed right - * Higher priority entities anchor, lower priority pushed - * Only updates entity.x/y - does NOT modify node.pos (deferred to applyFinalPositions) - * Returns true if any entity was moved - */ -function scanlineResolveX( - entities: LayoutEntity[], - config: LayoutConfig -): boolean { - let anyChanged = false; - let iterations = 0; - const maxIterations = entities.length * 2; - - let changed = true; - while (changed && iterations < maxIterations) { - changed = false; - iterations++; - - // Sort by X position, then priority, then ID (deterministic) - entities.sort((a, b) => a.x - b.x || b.priority - a.priority || a.id.localeCompare(b.id)); - - for (let i = 0; i < entities.length; i++) { - const anchor = entities[i]; - - for (let j = i + 1; j < entities.length; j++) { - const pushed = entities[j]; - - // Check Y overlap (must overlap on Y axis to need X separation) - const yOverlap = - anchor.y < pushed.y + pushed.height + config.verticalGap && - pushed.y < anchor.y + anchor.height + config.verticalGap; - if (!yOverlap) continue; - - // Check X overlap - const requiredX = anchor.x + anchor.width + config.horizontalGap; - if (pushed.x < requiredX) { - // Overlap! Push entity j to the right (entity only, not node.pos) - const dx = requiredX - pushed.x; - pushed.x = requiredX; - changed = true; - anyChanged = true; - debugLog(`Scanline X: ${pushed.id} right ${dx.toFixed(0)}px (avoid ${anchor.id})`); - } - } - } - } - - if (iterations > 1) { - debugLog(`X-axis scanline: ${iterations} iterations`); - } - return anyChanged; -} - -/** - * Scanline overlap resolution for Y-axis (push down) - * Entities are sorted by Y, overlapping entities are pushed down - * Higher priority entities anchor, lower priority pushed - * Only updates entity.x/y - does NOT modify node.pos (deferred to applyFinalPositions) - * Returns true if any entity was moved - */ -function scanlineResolveY( - entities: LayoutEntity[], - config: LayoutConfig -): boolean { - let anyChanged = false; - let iterations = 0; - const maxIterations = entities.length * 2; - - let changed = true; - while (changed && iterations < maxIterations) { - changed = false; - iterations++; - - // Sort by Y position, then priority, then ID (deterministic) - entities.sort((a, b) => a.y - b.y || b.priority - a.priority || a.id.localeCompare(b.id)); - - for (let i = 0; i < entities.length; i++) { - const anchor = entities[i]; - - for (let j = i + 1; j < entities.length; j++) { - const pushed = entities[j]; - - // Check X overlap (must overlap on X axis to need Y separation) - const xOverlap = - anchor.x < pushed.x + pushed.width + config.horizontalGap && - pushed.x < anchor.x + anchor.width + config.horizontalGap; - if (!xOverlap) continue; - - // Check Y overlap - const requiredY = anchor.y + anchor.height + config.verticalGap; - if (pushed.y < requiredY) { - // Overlap! Push entity j down (entity only, not node.pos) - const dy = requiredY - pushed.y; - pushed.y = requiredY; - changed = true; - anyChanged = true; - debugLog(`Scanline Y: ${pushed.id} down ${dy.toFixed(0)}px (avoid ${anchor.id})`); - } - } - } - } - - if (iterations > 1) { - debugLog(`Y-axis scanline: ${iterations} iterations`); - } - return anyChanged; -} - -/** - * Recursively shift all member nodes and nested child groups - */ -function shiftGroupContentsRecursively( - layoutGroup: LayoutGroup, - dx: number, - dy: number, - allNodes: Map -): void { - // Shift member nodes of this group - for (const nodeId of layoutGroup.memberIds) { - const node = allNodes.get(nodeId); - if (node?.pos) { - node.pos[0] += dx; - node.pos[1] += dy; - } - } - // Shift child groups and their contents recursively - for (const child of layoutGroup.childGroups) { - if (child.group.pos) { - child.group.pos[0] += dx; - child.group.pos[1] += dy; - } - shiftGroupContentsRecursively(child, dx, dy, allNodes); - } -} - -/** - * Recursively shift child groups and their members - */ -function shiftChildGroups( - layoutGroup: LayoutGroup, - dx: number, - dy: number, - allNodes: Map -): void { - for (const child of layoutGroup.childGroups) { - if (child.group.pos) { - child.group.pos[0] += dx; - child.group.pos[1] += dy; - } - shiftGroupContentsRecursively(child, dx, dy, allNodes); - } -} - -/** - * Apply final entity positions to node.pos after scanline resolution - * Calculates deltas from initial positions and applies to groups/nodes - */ -function applyFinalPositions( - entities: LayoutEntity[], - allNodes: Map -): void { - for (const entity of entities) { - const dx = entity.x - entity.initialX; - const dy = entity.y - entity.initialY; - - // Skip if no movement - if (Math.abs(dx) < 0.01 && Math.abs(dy) < 0.01) continue; - - if (entity.type === "group" && entity.group && entity.layoutGroup) { - // Set group's final position - if (entity.group.pos) { - entity.group.pos[0] = entity.x; - entity.group.pos[1] = entity.y; - } - // Shift all member nodes by the same delta - for (const nodeId of entity.layoutGroup.memberIds) { - const node = allNodes.get(nodeId); - if (node?.pos) { - node.pos[0] += dx; - node.pos[1] += dy; - } - } - // Shift child groups recursively - shiftChildGroups(entity.layoutGroup, dx, dy, allNodes); - } else if (entity.type === "node" && entity.node?.pos) { - // Set node's final position - entity.node.pos[0] = entity.x; - entity.node.pos[1] = entity.y; - } - } -} - -/** - * Unified overlap resolution using scanline algorithm - * Uses priority-based monotonic pushing (right/down only) to prevent oscillation - * Groups have highest priority and anchor in place - * Idempotent: decouples entity tracking from node.pos writes - */ -export function resolveAllOverlaps(state: LayoutState): void { - const { allNodes, nodes, config } = state; - if (!allNodes) return; - - const entities = collectEntities(state); - if (entities.length < 2) { - debugLog(`Overlap resolution: ${entities.length} entities (skipped)`); - return; - } - - // Pass 1: X-axis (left-to-right pushing) - updates entity.x only - scanlineResolveX(entities, config); - - // Pass 2: Y-axis (top-to-bottom pushing) - updates entity.y only - scanlineResolveY(entities, config); - - // Pass 3: Apply final positions to node.pos - applyFinalPositions(entities, allNodes); - - // Pass 4: Sync I/O node positions from allNodes back to runtimeIONode - // (applyFinalPositions updates allNodes[id].pos, but runtimeIONode.pos needs syncing) - if (nodes) { - for (const layoutNode of nodes.values()) { - if (layoutNode.isSubgraphIO && layoutNode.runtimeIONode) { - const allNode = allNodes.get(layoutNode.id); - if (allNode?.pos) { - layoutNode.runtimeIONode.pos[0] = allNode.pos[0]; - layoutNode.runtimeIONode.pos[1] = allNode.pos[1]; - } - } - } - } - - debugLog(`Overlap resolution: ${entities.length} entities processed`); -} diff --git a/src/layout/reroute-collapse.ts b/src/layout/reroute-collapse.ts deleted file mode 100644 index be4c11a..0000000 --- a/src/layout/reroute-collapse.ts +++ /dev/null @@ -1,277 +0,0 @@ -import type { LGraph, LLink, RerouteChain } from "./types"; -import { debugLog } from "../debug"; - -/** Link info for chain detection */ -interface LinkInfo { - linkId: number; - originId: number; - originSlot: number; - targetId: number; - targetSlot: number; -} - -/** - * Iterate over links in a graph (handles Map, Record, and Array formats) - */ -function* iterateLinks( - links: Map | Record | LLink[] -): Generator { - if (links instanceof Map) { - for (const link of links.values()) { - if (link) yield link; - } - } else if (Array.isArray(links)) { - for (const link of links) { - if (link) yield link; - } - } else { - for (const link of Object.values(links)) { - if (link) yield link; - } - } -} - -/** - * Find reroute chains in the graph - * - * A chain is a sequence of reroute nodes connected linearly: - * Source -> Reroute1 -> Reroute2 -> ... -> Target(s) - * - * Returns chains that can be collapsed into virtual edges - */ -export function findRerouteChains( - graph: LGraph, - reroutes: Set -): RerouteChain[] { - if (!graph.links || reroutes.size === 0) return []; - - // Build link info maps - // incomingLinks: target -> [links pointing to it] - // outgoingLinks: origin -> [links from it] - const incomingLinks = new Map(); - const outgoingLinks = new Map(); - - for (const link of iterateLinks(graph.links)) { - if (!link || typeof link.origin_id !== "number" || typeof link.target_id !== "number") { - continue; - } - - const info: LinkInfo = { - linkId: link.id, - originId: link.origin_id, - originSlot: link.origin_slot, - targetId: link.target_id, - targetSlot: link.target_slot, - }; - - if (!incomingLinks.has(link.target_id)) { - incomingLinks.set(link.target_id, []); - } - incomingLinks.get(link.target_id)!.push(info); - - if (!outgoingLinks.has(link.origin_id)) { - outgoingLinks.set(link.origin_id, []); - } - outgoingLinks.get(link.origin_id)!.push(info); - } - - // Find chain starts: reroutes that are fed by a non-reroute - const visitedReroutes = new Set(); - const chains: RerouteChain[] = []; - - for (const rerouteId of reroutes) { - if (visitedReroutes.has(rerouteId)) continue; - - const incoming = incomingLinks.get(rerouteId) ?? []; - - // Find non-reroute sources feeding this reroute - for (const link of incoming) { - if (reroutes.has(link.originId)) { - // Source is also a reroute, this isn't a chain start - continue; - } - - // Found a chain start: non-reroute -> reroute - const chain = traceRerouteChain( - rerouteId, - link.originId, - link.originSlot, - reroutes, - outgoingLinks, - visitedReroutes - ); - - if (chain.nodes.length > 0) { - chains.push(chain); - } - } - } - - debugLog(`Found ${chains.length} reroute chains`); - - return chains; -} - -/** - * Trace a reroute chain from start to end - */ -function traceRerouteChain( - startRerouteId: number, - sourceNode: number, - sourceSlot: number, - reroutes: Set, - outgoingLinks: Map, - visitedReroutes: Set -): RerouteChain { - const chainNodes: number[] = []; - const targets: Array<{ node: number; slot: number }> = []; - - let currentId = startRerouteId; - - while (reroutes.has(currentId) && !visitedReroutes.has(currentId)) { - visitedReroutes.add(currentId); - chainNodes.push(currentId); - - const outgoing = outgoingLinks.get(currentId) ?? []; - - if (outgoing.length === 0) { - // Dead end reroute (no outgoing connections) - break; - } - - // Check where the reroute goes - let nextReroute: number | null = null; - - for (const link of outgoing) { - if (reroutes.has(link.targetId) && !visitedReroutes.has(link.targetId)) { - // Continues to another reroute - nextReroute = link.targetId; - } else if (!reroutes.has(link.targetId)) { - // Goes to a non-reroute target - targets.push({ - node: link.targetId, - slot: link.targetSlot, - }); - } - } - - if (nextReroute !== null) { - currentId = nextReroute; - } else { - // Chain ends here - break; - } - } - - return { - nodes: chainNodes, - sourceNode, - sourceSlot, - targets, - }; -} - -/** - * Create virtual edges that bypass reroute chains - * - * For layout purposes, we replace: - * Source -> Reroute1 -> Reroute2 -> Target - * With: - * Source -> Target (virtual edge) - * - * @returns Map of source node to target nodes (for DAG building) - */ -export function getVirtualEdges( - chains: RerouteChain[] -): Map> { - const virtualEdges = new Map>(); - - for (const chain of chains) { - if (!virtualEdges.has(chain.sourceNode)) { - virtualEdges.set(chain.sourceNode, new Set()); - } - - for (const target of chain.targets) { - virtualEdges.get(chain.sourceNode)!.add(target.node); - } - } - - return virtualEdges; -} - -/** - * Position reroute nodes along their chain edges after layout - * - * Distributes reroutes evenly between source and target positions - */ -export function restoreReroutePositions( - chains: RerouteChain[], - positions: Map, - sizes: Map -): void { - for (const chain of chains) { - if (chain.nodes.length === 0 || chain.targets.length === 0) continue; - - const sourcePos = positions.get(chain.sourceNode); - const sourceSize = sizes.get(chain.sourceNode); - if (!sourcePos || !sourceSize) continue; - - // Calculate average target position - let avgTargetX = 0; - let avgTargetY = 0; - let validTargets = 0; - - for (const target of chain.targets) { - const targetPos = positions.get(target.node); - if (targetPos) { - avgTargetX += targetPos.x; - avgTargetY += targetPos.y; - validTargets++; - } - } - - if (validTargets === 0) continue; - - avgTargetX /= validTargets; - avgTargetY /= validTargets; - - // Source output position (right side of node) - const startX = sourcePos.x + sourceSize.width; - const startY = sourcePos.y + sourceSize.height / 2; - - // Target input position - const endX = avgTargetX; - const endY = avgTargetY; - - // Distribute reroutes evenly along the path - const count = chain.nodes.length; - for (let i = 0; i < count; i++) { - const t = (i + 1) / (count + 1); - const rerouteId = chain.nodes[i]; - - // Linear interpolation - const x = startX + (endX - startX) * t; - const y = startY + (endY - startY) * t; - - // Center the reroute (typical reroute size: 90x26) - const rerouteSize = sizes.get(rerouteId) ?? { width: 90, height: 26 }; - positions.set(rerouteId, { - x: x - rerouteSize.width / 2, - y: y - rerouteSize.height / 2, - }); - } - } -} - -/** - * Get all reroute node IDs from chains (for filtering from main layout) - */ -export function getRerouteNodeIds(chains: RerouteChain[]): Set { - const ids = new Set(); - for (const chain of chains) { - for (const nodeId of chain.nodes) { - ids.add(nodeId); - } - } - return ids; -} diff --git a/src/layout/selected-groups.ts b/src/layout/selected-groups.ts deleted file mode 100644 index feb3f77..0000000 --- a/src/layout/selected-groups.ts +++ /dev/null @@ -1,739 +0,0 @@ -import type { - LGraph, - LGraphNode, - LGraphGroup, - LayoutConfig, - SelectedGroupLayoutResult, -} from "./types"; -import { DEFAULT_CONFIG } from "./types"; -import { getInternalEdges, assignMemberLayers } from "./layout-utils"; -import { packNodesIntoRows } from "./bin-pack"; -import { debugLog } from "../debug"; -import { parseLayoutToken, arrangeByMode } from "./title-tokens"; - -/** Title bar height for groups */ -const GROUP_TITLE_HEIGHT = 50; - -/** - * Update group bounds using multiple methods for compatibility - * Handles pos/size arrays, _pos/_size internal properties, - * _bounding Rectangle, and bounding array formats - */ -function updateGroupBounds( - group: LGraphGroup, - x: number, - y: number, - width: number, - height: number -): void { - // Method 1: Direct pos/size array mutation - if (Array.isArray(group.pos)) { - group.pos[0] = x; - group.pos[1] = y; - } - if (Array.isArray(group.size)) { - group.size[0] = width; - group.size[1] = height; - } - - // Method 2: Try _pos/_size internal properties - const g = group as LGraphGroup & { - _pos?: [number, number]; - _size?: [number, number]; - }; - if (Array.isArray(g._pos)) { - g._pos[0] = x; - g._pos[1] = y; - } - if (Array.isArray(g._size)) { - g._size[0] = width; - g._size[1] = height; - } - - // Method 3: Update _bounding Rectangle if present - if (group._bounding) { - const b = group._bounding; - b.x = x; - b.y = y; - b.width = width; - b.height = height; - } - - // Method 4: Try bounding array (older format / JSON serialization) - const gAny = group as LGraphGroup & { bounding?: number[] }; - if (Array.isArray(gAny.bounding)) { - gAny.bounding[0] = x; - gAny.bounding[1] = y; - gAny.bounding[2] = width; - gAny.bounding[3] = height; - } -} - -/** - * Translate group position by delta, handling all position formats - * Similar to updateGroupBounds but for relative position changes - */ -function translateGroupPosition( - group: LGraphGroup, - deltaX: number, - deltaY: number -): void { - // Method 1: Direct pos array mutation - if (Array.isArray(group.pos)) { - group.pos[0] += deltaX; - group.pos[1] += deltaY; - } - - // Method 2: Try _pos internal property - const g = group as LGraphGroup & { _pos?: [number, number] }; - if (Array.isArray(g._pos)) { - g._pos[0] += deltaX; - g._pos[1] += deltaY; - } - - // Method 3: Update _bounding Rectangle if present - if (group._bounding) { - group._bounding.x += deltaX; - group._bounding.y += deltaY; - } - - // Method 4: Try bounding array (older format / JSON serialization) - const gAny = group as LGraphGroup & { bounding?: number[] }; - if (Array.isArray(gAny.bounding)) { - gAny.bounding[0] += deltaX; - gAny.bounding[1] += deltaY; - } -} - -/** - * Resize a group to fit its member nodes - */ -function resizeGroupToFitMembers( - group: LGraphGroup, - memberIds: Set, - allNodes: Map, - config: LayoutConfig -): void { - if (memberIds.size === 0) return; - - let minX = Infinity; - let minY = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; - - for (const nodeId of memberIds) { - const node = allNodes.get(nodeId); - if (!node?.pos) continue; - const nw = node.size?.[0] ?? 200; - const nh = node.size?.[1] ?? 100; - minX = Math.min(minX, node.pos[0]); - minY = Math.min(minY, node.pos[1]); - maxX = Math.max(maxX, node.pos[0] + nw); - maxY = Math.max(maxY, node.pos[1] + nh); - } - - if (minX === Infinity) return; - - // Update group position and size with padding (handles all formats) - const newX = minX - config.groupPadding; - const newY = minY - config.groupPadding - GROUP_TITLE_HEIGHT; - const newW = maxX - minX + config.groupPadding * 2; - const newH = maxY - minY + config.groupPadding * 2 + GROUP_TITLE_HEIGHT; - updateGroupBounds(group, newX, newY, newW, newH); -} - -/** - * Translate a group and its tracked members by a delta - */ -function translateGroupWithMembers( - group: LGraphGroup, - deltaX: number, - deltaY: number, - memberIds: Set, - allNodes: Map, - allGroups: LGraphGroup[] -): void { - // Move the group itself (handles all formats: pos, _pos, _bounding, bounding) - translateGroupPosition(group, deltaX, deltaY); - - // Translate tracked member nodes - for (const nodeId of memberIds) { - const node = allNodes.get(nodeId); - if (node?.pos) { - node.pos[0] += deltaX; - node.pos[1] += deltaY; - } - } - - // Find and translate nested groups inside this group - for (const nestedGroup of allGroups) { - if (nestedGroup === group) continue; - if (groupContainsGroup(group, nestedGroup)) { - translateGroupPosition(nestedGroup, deltaX, deltaY); - } - } -} - -/** - * Check if outer group's bounds fully contain inner group's bounds - */ -function groupContainsGroup(outer: LGraphGroup, inner: LGraphGroup): boolean { - const ox = outer.pos[0]; - const oy = outer.pos[1]; - const ow = outer.size[0]; - const oh = outer.size[1]; - - const ix = inner.pos[0]; - const iy = inner.pos[1]; - const iw = inner.size[0]; - const ih = inner.size[1]; - - return ix >= ox && iy >= oy && ix + iw <= ox + ow && iy + ih <= oy + oh; -} - -/** - * Check if a node's origin is inside a group's content area - */ -function nodeInsideGroup(node: LGraphNode, group: LGraphGroup): boolean { - const gx = group.pos[0]; - const gy = group.pos[1] + GROUP_TITLE_HEIGHT; - const gw = group.size[0]; - const gh = group.size[1] - GROUP_TITLE_HEIGHT; - - const nx = node.pos?.[0] ?? 0; - const ny = node.pos?.[1] ?? 0; - - return nx >= gx && nx < gx + gw && ny >= gy && ny < gy + gh; -} - -/** - * Collect all nested child groups within a parent group - */ -function collectNestedGroups( - parent: LGraphGroup, - allGroups: LGraphGroup[] -): LGraphGroup[] { - const nested: LGraphGroup[] = []; - const queue = [parent]; - - while (queue.length > 0) { - const current = queue.shift()!; - for (const group of allGroups) { - if (group === current) continue; - if (groupContainsGroup(current, group) && !nested.includes(group)) { - nested.push(group); - queue.push(group); - } - } - } - - return nested; -} - -/** - * Collect all member nodes within a group (excluding nodes in nested child groups) - */ -function collectDirectMembers( - group: LGraphGroup, - allNodes: Map, - nestedGroups: LGraphGroup[] -): Set { - const members = new Set(); - - for (const [nodeId, node] of allNodes) { - if (!nodeInsideGroup(node, group)) continue; - - // Check if node is inside a nested group (exclude it from direct members) - let inNestedGroup = false; - for (const nested of nestedGroups) { - if (nodeInsideGroup(node, nested)) { - inNestedGroup = true; - break; - } - } - - if (!inNestedGroup) { - members.add(nodeId); - } - } - - return members; -} - -/** - * Layout contents of a single group and resize it to fit - * Returns the bounding box of the group's contents - */ -function layoutGroupContents( - group: LGraphGroup, - memberIds: Set, - nestedGroups: LGraphGroup[], - allNodes: Map, - config: LayoutConfig, - processedGroups: Set -): { minX: number; minY: number; maxX: number; maxY: number } | null { - // Skip if already processed (handles nested groups selected individually) - if (processedGroups.has(group)) { - return null; - } - processedGroups.add(group); - - // Collect non-locked members - const members: LGraphNode[] = []; - for (const nodeId of memberIds) { - const node = allNodes.get(nodeId); - if (!node) continue; - if (node.flags?.pinned || node.locked) continue; - members.push(node); - } - - // Handle empty group with no nested groups - if (members.length === 0 && nestedGroups.length === 0) { - debugLog(`Skipping empty group "${group.title}"`); - return null; - } - - // Parse layout token from group title - const layoutMode = parseLayoutToken(group.title); - - debugLog( - `layoutGroupContents "${group.title}": members=${members.length}, nestedGroups=${nestedGroups.length}, mode=${layoutMode.type}` - ); - - // Get the group's current content area start position - const startX = group.pos[0] + config.groupPadding; - const startY = group.pos[1] + config.groupPadding + GROUP_TITLE_HEIGHT; - - // Track bounds of all positioned content - let minX = Infinity; - let minY = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; - - let contentEndY = startY; - - // If layout mode is specified, use token-based layout (ignores internal edges) - // This handles both direct member nodes AND nested groups as top-level items - if (layoutMode.type !== "default" && (members.length > 0 || nestedGroups.length > 0)) { - // First, recursively process nested groups (bottom-up) so their sizes are final - // Sort by containment depth (deepest first) - const sortedNested = [...nestedGroups].sort((a, b) => { - let aDepth = 0, - bDepth = 0; - for (const other of nestedGroups) { - if (other !== a && groupContainsGroup(other, a)) aDepth++; - if (other !== b && groupContainsGroup(other, b)) bDepth++; - } - return bDepth - aDepth; // Deepest first - }); - - // Track member IDs for each nested group (needed for translation later) - const nestedGroupMembers = new Map>(); - - for (const nested of sortedNested) { - if (processedGroups.has(nested)) continue; - - const nestedMemberIds = new Set(); - for (const [nodeId, node] of allNodes) { - if (nodeInsideGroup(node, nested)) { - // Check not in deeper nested group - let inDeeperGroup = false; - for (const other of sortedNested) { - if (other !== nested && groupContainsGroup(nested, other) && nodeInsideGroup(node, other)) { - inDeeperGroup = true; - break; - } - } - if (!inDeeperGroup) { - nestedMemberIds.add(nodeId); - } - } - } - nestedGroupMembers.set(nested.id, nestedMemberIds); - - const deeperNested = sortedNested.filter( - (g) => g !== nested && groupContainsGroup(nested, g) - ); - - layoutGroupContents( - nested, - nestedMemberIds, - deeperNested, - allNodes, - config, - processedGroups - ); - - // Resize nested group to fit its (possibly rearranged) contents - resizeGroupToFitMembers(nested, nestedMemberIds, allNodes, config); - } - - // Now create combined items list: direct member nodes + direct nested groups - // Direct nested groups are those not contained by other nested groups - const directNestedGroups = nestedGroups.filter((g) => { - for (const other of nestedGroups) { - if (other !== g && groupContainsGroup(other, g)) return false; - } - return true; - }); - - // Create fake LGraphNode-like objects for nested groups so we can use arrangeByMode - const groupItems: LGraphNode[] = directNestedGroups.map((g) => ({ - id: -g.id, // Negative ID to distinguish from real nodes - type: "__group__", - title: g.title, - pos: [g.pos[0], g.pos[1]] as [number, number], - size: [g.size[0], g.size[1]] as [number, number], - inputs: [], - outputs: [], - })); - - // Combine members and group items - const allItems = [...members, ...groupItems]; - - if (allItems.length > 0) { - const arranged = arrangeByMode(allItems, layoutMode, startX, startY, config); - - for (const pos of arranged) { - if (pos.id < 0) { - // This is a nested group (negative ID) - const groupId = -pos.id; - const nestedGroup = directNestedGroups.find((g) => g.id === groupId); - if (nestedGroup) { - const oldX = nestedGroup.pos[0]; - const oldY = nestedGroup.pos[1]; - const deltaX = pos.x - oldX; - const deltaY = pos.y - oldY; - - // Use tracked members for accurate translation after token layout - const trackedMembers = nestedGroupMembers.get(groupId) ?? new Set(); - translateGroupWithMembers(nestedGroup, deltaX, deltaY, trackedMembers, allNodes, nestedGroups); - debugLog(` Nested group ${groupId} (${nestedGroup.title}): token layout [${pos.x}, ${pos.y}]`); - - const gw = nestedGroup.size[0]; - const gh = nestedGroup.size[1]; - - minX = Math.min(minX, pos.x); - minY = Math.min(minY, pos.y); - maxX = Math.max(maxX, pos.x + gw); - maxY = Math.max(maxY, pos.y + gh); - contentEndY = Math.max(contentEndY, pos.y + gh); - } - } else { - // This is a regular node - const node = allNodes.get(pos.id); - if (node?.pos) { - node.pos[0] = pos.x; - node.pos[1] = pos.y; - debugLog(` Member ${node.id} (${node.type}): token layout [${pos.x}, ${pos.y}]`); - - const nw = node.size?.[0] ?? 200; - const nh = node.size?.[1] ?? 100; - - minX = Math.min(minX, pos.x); - minY = Math.min(minY, pos.y); - maxX = Math.max(maxX, pos.x + nw); - maxY = Math.max(maxY, pos.y + nh); - contentEndY = Math.max(contentEndY, pos.y + nh); - } - } - } - } - } else if (members.length > 0) { - // Default behavior: check internal edges for layer-based or vertical stack layout - const edges = getInternalEdges(memberIds, allNodes); - - if (edges.length > 0) { - // Has internal connections - use layer-based layout - const layers = assignMemberLayers(memberIds, edges, allNodes); - - let x = startX; - for (let layerIdx = 0; layerIdx < layers.length; layerIdx++) { - const layer = layers[layerIdx]; - if (layer.length === 0) continue; - - // Sort layer by height (tallest first for stable layout) - layer.sort((a, b) => (b.size?.[1] ?? 100) - (a.size?.[1] ?? 100)); - - let y = startY; - let layerMaxWidth = 0; - - for (const node of layer) { - if (node.pos) { - node.pos[0] = x; - node.pos[1] = y; - debugLog( - ` Member ${node.id} (${node.type}): layer ${layerIdx}, pos [${x}, ${y}]` - ); - } - - const nw = node.size?.[0] ?? 200; - const nh = node.size?.[1] ?? 100; - - minX = Math.min(minX, x); - minY = Math.min(minY, y); - maxX = Math.max(maxX, x + nw); - maxY = Math.max(maxY, y + nh); - - layerMaxWidth = Math.max(layerMaxWidth, nw); - y += nh + config.verticalGap; - } - - contentEndY = Math.max(contentEndY, y - config.verticalGap); - x += layerMaxWidth + config.horizontalGap; - } - } else { - // No internal connections - use vertical stacking - members.sort((a, b) => (b.size?.[1] ?? 100) - (a.size?.[1] ?? 100)); - - // Build sizes map - const sizes = new Map(); - for (const node of members) { - sizes.set(node.id, { - width: node.size?.[0] ?? 200, - height: node.size?.[1] ?? 100, - }); - } - - // Pack into rows (vertical stack with maxColumns=1) - const nodeIds = members.map((n) => n.id); - const rows = packNodesIntoRows(nodeIds, sizes, { - ...config, - maxColumns: 1, - }); - - for (const row of rows) { - const x = startX; - const y = startY + row.yOffset; - - for (const nodeId of row.nodes) { - const node = allNodes.get(nodeId); - if (node?.pos) { - node.pos[0] = x; - node.pos[1] = y; - debugLog(` Member ${node.id} (${node.type}): stacked pos [${x}, ${y}]`); - - const nw = node.size?.[0] ?? 200; - const nh = node.size?.[1] ?? 100; - - minX = Math.min(minX, x); - minY = Math.min(minY, y); - maxX = Math.max(maxX, x + nw); - maxY = Math.max(maxY, y + nh); - } - } - contentEndY = Math.max(contentEndY, startY + row.yOffset + row.height); - } - } - } - - // Process nested groups (bottom-up: deepest first) - // In default mode, reposition direct nested groups below member content - const sortedNested = [...nestedGroups].sort((a, b) => { - // Count how many other nested groups contain each - let depthA = 0; - let depthB = 0; - for (const other of nestedGroups) { - if (other !== a && groupContainsGroup(other, a)) depthA++; - if (other !== b && groupContainsGroup(other, b)) depthB++; - } - return depthB - depthA; // Deepest first - }); - - // Identify direct nested groups (not contained by other nested groups) - const directNestedGroups = sortedNested.filter((g) => { - for (const other of nestedGroups) { - if (other !== g && groupContainsGroup(other, g)) return false; - } - return true; - }); - - // Sort direct groups by original position for stable ordering - directNestedGroups.sort((a, b) => { - const yDiff = a.pos[1] - b.pos[1]; - return yDiff !== 0 ? yDiff : a.pos[0] - b.pos[0]; - }); - - for (const nestedGroup of directNestedGroups) { - // Skip if already processed (by token mode) - if (processedGroups.has(nestedGroup)) { - // Still include bounds - minX = Math.min(minX, nestedGroup.pos[0]); - minY = Math.min(minY, nestedGroup.pos[1]); - maxX = Math.max(maxX, nestedGroup.pos[0] + nestedGroup.size[0]); - maxY = Math.max(maxY, nestedGroup.pos[1] + nestedGroup.size[1]); - continue; - } - - // Get nested group's members and children - const nestedChildren = collectNestedGroups(nestedGroup, nestedGroups); - const nestedMembers = collectDirectMembers(nestedGroup, allNodes, nestedChildren); - - // In default mode, reposition nested group below current content - if (layoutMode.type === "default") { - // Position nested group's left edge at startX (same as members) for consistent alignment - // This prevents the group from shifting leftward on each layout run - const targetX = startX; - const targetY = contentEndY + config.verticalGap; - const deltaX = targetX - nestedGroup.pos[0]; - const deltaY = targetY - nestedGroup.pos[1]; - - if (deltaX !== 0 || deltaY !== 0) { - translateGroupWithMembers(nestedGroup, deltaX, deltaY, nestedMembers, allNodes, nestedGroups); - debugLog(` Repositioned nested group "${nestedGroup.title}" to [${targetX}, ${targetY}]`); - } - } - - // Recursively layout nested group contents - layoutGroupContents( - nestedGroup, - nestedMembers, - nestedChildren, - allNodes, - config, - processedGroups - ); - - // Resize nested group to fit its organized contents - resizeGroupToFitMembers(nestedGroup, nestedMembers, allNodes, config); - - // Update contentEndY for next nested group - contentEndY = Math.max(contentEndY, nestedGroup.pos[1] + nestedGroup.size[1]); - - // Update bounds to include nested group - minX = Math.min(minX, nestedGroup.pos[0]); - minY = Math.min(minY, nestedGroup.pos[1]); - maxX = Math.max(maxX, nestedGroup.pos[0] + nestedGroup.size[0]); - maxY = Math.max(maxY, nestedGroup.pos[1] + nestedGroup.size[1]); - } - - // Resize group to fit contents - if (minX !== Infinity) { - const newX = minX - config.groupPadding; - const newY = minY - config.groupPadding - GROUP_TITLE_HEIGHT; - const newWidth = maxX - minX + config.groupPadding * 2; - const newHeight = maxY - minY + config.groupPadding * 2 + GROUP_TITLE_HEIGHT; - - debugLog( - `Resizing group "${group.title}" to [${newX}, ${newY}, ${newWidth}, ${newHeight}]` - ); - - updateGroupBounds(group, newX, newY, newWidth, newHeight); - - return { minX, minY, maxX, maxY }; - } - - return null; -} - -/** - * Layout only the contents of selected groups - * Ignores external connections - uses only internal edges - * Auto-includes nested groups when parent is selected - * - * @param graph - The full graph (for node/link access) - * @param selectedGroupIds - Set of group IDs to organize - * @param config - Layout configuration - */ -export function layoutSelectedGroups( - graph: LGraph, - selectedGroupIds: Set, - config?: Partial -): SelectedGroupLayoutResult { - const fullConfig = { ...DEFAULT_CONFIG, ...config }; - const startTime = performance.now(); - - // Build allNodes map - const allNodes = new Map(); - for (const node of graph._nodes ?? []) { - if (!node || node.id === undefined) continue; - allNodes.set(node.id, node); - } - - // Get all groups - const allGroups = graph._groups ?? []; - - // Find selected groups - const selectedGroups: LGraphGroup[] = []; - for (const group of allGroups) { - if (selectedGroupIds.has(group.id)) { - selectedGroups.push(group); - } - } - - if (selectedGroups.length === 0) { - debugLog("No selected groups found"); - return { - nodeCount: 0, - groupCount: 0, - executionMs: performance.now() - startTime, - }; - } - - // Filter to only top-level selected groups - // (nested groups selected individually will be handled, but if both parent and child - // are selected, we only process from the parent) - const topLevelSelected: LGraphGroup[] = []; - for (const group of selectedGroups) { - let hasSelectedParent = false; - for (const other of selectedGroups) { - if (other !== group && groupContainsGroup(other, group)) { - hasSelectedParent = true; - break; - } - } - if (!hasSelectedParent) { - topLevelSelected.push(group); - } - } - - // Track processed groups to avoid double-processing - const processedGroups = new Set(); - let totalNodes = 0; - - // Process each top-level selected group - for (const group of topLevelSelected) { - // Collect nested groups within this group - const nestedGroups = collectNestedGroups(group, allGroups); - - // Collect direct members (excluding those in nested groups) - const memberIds = collectDirectMembers(group, allNodes, nestedGroups); - - debugLog( - `Processing group "${group.title}": ${memberIds.size} direct members, ${nestedGroups.length} nested groups` - ); - - // Layout group contents - layoutGroupContents( - group, - memberIds, - nestedGroups, - allNodes, - fullConfig, - processedGroups - ); - - totalNodes += memberIds.size; - // Count nested group members too - for (const nested of nestedGroups) { - const nestedChildren = collectNestedGroups(nested, nestedGroups); - const nestedMembers = collectDirectMembers(nested, allNodes, nestedChildren); - totalNodes += nestedMembers.size; - } - } - - // Trigger canvas redraw - graph.setDirtyCanvas?.(true, true); - - const result: SelectedGroupLayoutResult = { - nodeCount: totalNodes, - groupCount: processedGroups.size, - executionMs: performance.now() - startTime, - }; - - debugLog( - `layoutSelectedGroups complete: ${result.nodeCount} nodes, ${result.groupCount} groups, ${result.executionMs.toFixed(1)}ms` - ); - - return result; -} diff --git a/src/layout/title-tokens.ts b/src/layout/title-tokens.ts deleted file mode 100644 index 5405822..0000000 --- a/src/layout/title-tokens.ts +++ /dev/null @@ -1,278 +0,0 @@ -import type { LGraphNode, LayoutConfig } from "./types"; - -/** - * Layout mode parsed from group title tokens - */ -export type LayoutMode = - | { type: "default" } - | { type: "horizontal" } - | { type: "vertical" } - | { type: "rows"; count: number } - | { type: "columns"; count: number }; - -/** - * Parse layout token from group title (case-insensitive) - * Supports: [HORIZONTAL], [VERTICAL], [1-9ROW], [1-9COL] - */ -export function parseLayoutToken(title: string): LayoutMode { - const upper = title.toUpperCase(); - - // Check for [HORIZONTAL] - if (upper.includes("[HORIZONTAL]")) { - return { type: "horizontal" }; - } - - // Check for [VERTICAL] - if (upper.includes("[VERTICAL]")) { - return { type: "vertical" }; - } - - // Check for [xROW] pattern (1-9) - const rowMatch = upper.match(/\[([1-9])ROW\]/); - if (rowMatch) { - const count = parseInt(rowMatch[1], 10); - // 1ROW is same as horizontal - if (count === 1) { - return { type: "horizontal" }; - } - return { type: "rows", count }; - } - - // Check for [xCOL] pattern (1-9) - const colMatch = upper.match(/\[([1-9])COL\]/); - if (colMatch) { - const count = parseInt(colMatch[1], 10); - // 1COL is same as vertical - if (count === 1) { - return { type: "vertical" }; - } - return { type: "columns", count }; - } - - return { type: "default" }; -} - -/** - * Node info for layout calculations - */ -interface NodeInfo { - id: number; - width: number; - height: number; - originalX: number; - originalY: number; -} - -/** - * Result of arranging nodes by mode - */ -export interface ArrangedNode { - id: number; - x: number; - y: number; -} - -/** - * Sort nodes by original position (left-to-right, top-to-bottom) - */ -function sortByOriginalPosition(nodes: NodeInfo[]): NodeInfo[] { - return [...nodes].sort((a, b) => { - // Primary: sort by X (left to right) - if (a.originalX !== b.originalX) { - return a.originalX - b.originalX; - } - // Secondary: sort by Y (top to bottom) - return a.originalY - b.originalY; - }); -} - -/** - * Arrange nodes in a single horizontal row - */ -function arrangeHorizontal( - nodes: NodeInfo[], - startX: number, - startY: number, - gap: number -): ArrangedNode[] { - // Sort by original position (left to right) - const sorted = sortByOriginalPosition(nodes); - - const result: ArrangedNode[] = []; - let x = startX; - - for (const node of sorted) { - result.push({ id: node.id, x, y: startY }); - x += node.width + gap; - } - - return result; -} - -/** - * Arrange nodes in a single vertical column - */ -function arrangeVertical( - nodes: NodeInfo[], - startX: number, - startY: number, - gap: number -): ArrangedNode[] { - // Sort by original position (top to bottom, then left to right) - const sorted = [...nodes].sort((a, b) => { - if (a.originalY !== b.originalY) { - return a.originalY - b.originalY; - } - return a.originalX - b.originalX; - }); - - const result: ArrangedNode[] = []; - let y = startY; - - for (const node of sorted) { - result.push({ id: node.id, x: startX, y }); - y += node.height + gap; - } - - return result; -} - -/** - * Arrange nodes into N rows (round-robin distribution) - * Each row is horizontal, rows stacked vertically - */ -function arrangeRows( - nodes: NodeInfo[], - rowCount: number, - startX: number, - startY: number, - horizontalGap: number, - verticalGap: number -): ArrangedNode[] { - // Sort by original position first - const sorted = sortByOriginalPosition(nodes); - - // Distribute round-robin into rows - const rows: NodeInfo[][] = Array.from({ length: rowCount }, () => []); - for (let i = 0; i < sorted.length; i++) { - rows[i % rowCount].push(sorted[i]); - } - - const result: ArrangedNode[] = []; - let y = startY; - - for (const row of rows) { - if (row.length === 0) continue; - - let x = startX; - let rowMaxHeight = 0; - - for (const node of row) { - result.push({ id: node.id, x, y }); - x += node.width + horizontalGap; - rowMaxHeight = Math.max(rowMaxHeight, node.height); - } - - y += rowMaxHeight + verticalGap; - } - - return result; -} - -/** - * Arrange nodes into N columns (round-robin distribution) - * Each column is vertical, columns side-by-side - */ -function arrangeColumns( - nodes: NodeInfo[], - colCount: number, - startX: number, - startY: number, - horizontalGap: number, - verticalGap: number -): ArrangedNode[] { - // Sort by original position first - const sorted = sortByOriginalPosition(nodes); - - // Distribute round-robin into columns - const cols: NodeInfo[][] = Array.from({ length: colCount }, () => []); - for (let i = 0; i < sorted.length; i++) { - cols[i % colCount].push(sorted[i]); - } - - const result: ArrangedNode[] = []; - let x = startX; - - for (const col of cols) { - if (col.length === 0) continue; - - let y = startY; - let colMaxWidth = 0; - - for (const node of col) { - result.push({ id: node.id, x, y }); - y += node.height + verticalGap; - colMaxWidth = Math.max(colMaxWidth, node.width); - } - - x += colMaxWidth + horizontalGap; - } - - return result; -} - -/** - * Arrange nodes according to layout mode - */ -export function arrangeByMode( - members: LGraphNode[], - mode: LayoutMode, - startX: number, - startY: number, - config: LayoutConfig -): ArrangedNode[] { - // Build node info list with original positions - const nodes: NodeInfo[] = members.map((n) => ({ - id: n.id, - width: n.size?.[0] ?? 200, - height: n.size?.[1] ?? 100, - originalX: n.pos?.[0] ?? 0, - originalY: n.pos?.[1] ?? 0, - })); - - if (nodes.length === 0) { - return []; - } - - switch (mode.type) { - case "horizontal": - return arrangeHorizontal(nodes, startX, startY, config.horizontalGap); - - case "vertical": - return arrangeVertical(nodes, startX, startY, config.verticalGap); - - case "rows": - return arrangeRows( - nodes, - mode.count, - startX, - startY, - config.horizontalGap, - config.verticalGap - ); - - case "columns": - return arrangeColumns( - nodes, - mode.count, - startX, - startY, - config.horizontalGap, - config.verticalGap - ); - - default: - // Default mode - caller should use existing behavior - return []; - } -} diff --git a/src/layout/tokens.ts b/src/layout/tokens.ts new file mode 100644 index 0000000..aef2ed4 --- /dev/null +++ b/src/layout/tokens.ts @@ -0,0 +1,85 @@ +/** + * Token parser and algorithm factory. + * + * Parses group titles for layout tokens like [HORIZONTAL], [2ROW], [3COL] + * 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"; + +/** + * Regex to match layout tokens in group titles. + * Matches: [HORIZONTAL], [VERTICAL], [1-9ROW], [1-9COL] + * Case-insensitive. + */ +const TOKEN_REGEX = /\[(HORIZONTAL|VERTICAL|([1-9])ROW|([1-9])COL)\]/i; + +/** + * Parse a group title for a layout token. + * Returns null if no valid token is found. + * + * @example + * parseLayoutToken("My Group [HORIZONTAL]") // { mode: 'horizontal' } + * parseLayoutToken("[2ROW]") // { mode: 'grid', count: 2, dimension: 'row' } + * parseLayoutToken("No token") // null + */ +export function parseLayoutToken(title: string): LayoutToken | null { + const match = TOKEN_REGEX.exec(title); + if (!match) return null; + + const fullMatch = match[1].toUpperCase(); + + if (fullMatch === "HORIZONTAL") { + return { mode: "horizontal" }; + } + + if (fullMatch === "VERTICAL") { + return { mode: "vertical" }; + } + + // [N]ROW pattern + if (match[2] !== undefined) { + const count = parseInt(match[2], 10); + if (count === 1) { + return { mode: "horizontal" }; + } + return { mode: "grid", count, dimension: "row" }; + } + + // [N]COL pattern + if (match[3] !== undefined) { + const count = parseInt(match[3], 10); + if (count === 1) { + return { mode: "vertical" }; + } + return { mode: "grid", count, dimension: "col" }; + } + + return null; +} + +/** + * Create a LayoutAlgorithm from a LayoutToken. + * + * @param token - The parsed layout token + * @param gap - Gap between nodes (default: 40) + */ +export function tokenToAlgorithm(token: LayoutToken, gap?: number): LayoutAlgorithm { + switch (token.mode) { + case "horizontal": + return createHorizontalAlgorithm(gap); + + case "vertical": + return createVerticalAlgorithm(gap); + + case "grid": { + if (token.dimension === "row") { + return createGridAlgorithm({ rows: token.count, gap }); + } + return createGridAlgorithm({ columns: token.count, gap }); + } + } +} diff --git a/src/layout/types.ts b/src/layout/types.ts index 32622e2..c893e45 100644 --- a/src/layout/types.ts +++ b/src/layout/types.ts @@ -1,274 +1,90 @@ /** - * Minimal type definitions for ComfyUI/LiteGraph - * These match the runtime global objects, not the package exports + * Pure layout types — no ComfyUI/LGraph dependencies. + * These are the abstract types for the v2 layout framework. */ -/** Point as [x, y] */ -export type Point = [x: number, y: number]; - -/** Size as [width, height] */ -export type Size = [width: number, height: number]; - -/** Node input slot */ -export interface NodeInputSlot { - name: string; - type: string; - link: number | null; +/** Position as a simple object */ +export interface Position { + readonly x: number; + readonly y: number; } -/** Node output slot */ -export interface NodeOutputSlot { - name: string; - type: string; - links: number[] | null; -} - -/** Node flags */ -export interface NodeFlags { - pinned?: boolean; - collapsed?: boolean; -} - -/** LiteGraph Node */ -export interface LGraphNode { - id: number; - type: string; - title: string; - pos: Point; - size: Size; - inputs: NodeInputSlot[]; - outputs: NodeOutputSlot[]; - flags?: NodeFlags; - locked?: boolean; - order?: number; -} - -/** LiteGraph Group */ -export interface LGraphGroup { - id: number; - title: string; - pos: Point; - size: Size; - _nodes?: LGraphNode[]; - _children?: Set<{ id: number }>; - _bounding?: { - x: number; - y: number; - width: number; - height: number; - set?: (x: number, y: number, w: number, h: number) => void; - }; - resizeTo?: (items: Iterable, padding?: number) => void; -} - -/** LiteGraph Link */ -export interface LLink { - id: number; - origin_id: number; - origin_slot: number; - target_id: number; - target_slot: number; - type: string; -} - -/** Subgraph I/O node (runtime format with pos/size like regular nodes) */ -export interface SubgraphIONode { - id: number; - pos: [number, number]; - size: [number, number]; -} - -/** LiteGraph Graph (or Subgraph with I/O nodes) */ -export interface LGraph { - _nodes: LGraphNode[]; - _groups: LGraphGroup[]; - /** Links can be Map, Record, or Array depending on context */ - links: Map | Record | LLink[]; - setDirtyCanvas?: (fg: boolean, bg: boolean) => void; - /** Subgraph input node (only on Subgraph, not root LGraph) */ - inputNode?: SubgraphIONode; - /** Subgraph output node (only on Subgraph, not root LGraph) */ - outputNode?: SubgraphIONode; -} - -/** Layout node with computed metadata */ +/** A node to be laid out — just an ID and dimensions */ export interface LayoutNode { - /** Reference to ComfyUI node */ - node: LGraphNode; - /** Node ID */ - id: number; - /** DAG depth (0 = sources/inputs) */ - layer: number; - /** Vertical position within layer */ - orderInLayer: number; - /** Node IDs this depends on (predecessors) */ - predecessors: number[]; - /** Node IDs that depend on this (successors) */ - successors: number[]; - /** Node width (or group width if representative) */ - width: number; - /** Node height (or group height if representative) */ - height: number; - /** Computed X coordinate */ - x: number; - /** Computed Y coordinate */ - y: number; - /** True if this node represents a group in the layout */ - isGroupRepresentative?: boolean; - /** The group this node represents (if isGroupRepresentative) */ - group?: LayoutGroup; - /** True if this is a subgraph I/O node (input or output boundary) */ - isSubgraphIO?: boolean; - /** Reference to runtime I/O node for position updates (has pos/size) */ - runtimeIONode?: { id: number; pos: [number, number]; size: [number, number] }; + readonly id: string; + readonly width: number; + readonly height: number; + readonly kind?: "node" | "subgraph-input" | "subgraph-output"; + readonly layerConstraint?: "first" | "last"; } -/** Layer = vertical column of nodes */ -export interface Layer { - /** Layer index (0 = leftmost) */ - index: number; - /** Nodes in this layer */ - nodes: LayoutNode[]; - /** Computed X position for this layer */ - x: number; - /** Max node width in this layer */ - maxWidth: number; +/** A directed edge between nodes */ +export interface LayoutEdge { + readonly source: string; + readonly target: string; } -/** Layout group tracking with hierarchy support */ +/** Token parsed from group title like [HORIZONTAL], [2ROW], etc. */ +export interface LayoutToken { + readonly mode: "horizontal" | "vertical" | "grid"; + readonly count?: number; + readonly dimension?: "row" | "col"; +} + +/** A group of nodes, possibly with nested child groups */ export interface LayoutGroup { - /** Reference to ComfyUI group */ - group: LGraphGroup; - /** Node IDs directly contained (not in child groups) */ - memberIds: Set; - /** Child groups (nested inside this group) */ - childGroups: LayoutGroup[]; - /** Parent group (group containing this one), null for top-level */ - parentGroup: LayoutGroup | null; - /** Nesting depth (0 = top-level) */ - depth: number; - /** Calculated width for the group */ - width: number; - /** Calculated height for the group */ - height: number; + readonly id: string; + readonly title: string; + readonly memberIds: ReadonlyArray; + readonly childGroupIds: ReadonlyArray; + readonly token?: LayoutToken; } -/** Layout configuration */ -export interface LayoutConfig { - /** Horizontal gap between layers */ - horizontalGap: number; - /** Vertical gap between nodes in same layer */ - verticalGap: number; - /** Padding inside groups */ - groupPadding: number; - /** Layout origin X */ - startX: number; - /** Layout origin Y */ - startY: number; - /** Max barycenter iterations */ - maxIterations: number; - /** Max columns per layer (0=auto, 1=disabled/vertical stack, 2+=fixed) */ - maxColumns: number; - /** Max row width before new row when maxColumns=0 */ - maxRowWidth: number; - /** Collapse reroute chains into edge waypoints */ - collapseReroutes: boolean; - /** Gap between disconnected zone and DAG */ - disconnectedGap: number; +/** Input to a layout algorithm — one level of the hierarchy */ +export interface LayoutInput { + readonly nodes: ReadonlyArray; + readonly edges: ReadonlyArray; } -/** Default configuration */ -export const DEFAULT_CONFIG: LayoutConfig = { +/** Output from a layout algorithm — position map */ +export interface LayoutOutput { + readonly positions: ReadonlyMap; +} + +/** The pluggable algorithm interface */ +export interface LayoutAlgorithm { + readonly name: string; + layout(input: LayoutInput): LayoutOutput; +} + +/** Group bounds after layout (position + size) */ +export interface GroupBounds { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} + +/** Full result from the framework (after group recursion) */ +export interface FrameworkResult { + readonly positions: ReadonlyMap; + readonly groupBounds: ReadonlyMap; +} + +/** Configuration for the framework */ +export interface FrameworkConfig { + readonly horizontalGap: number; + readonly verticalGap: number; + readonly groupPadding: number; + readonly groupTitleHeight: number; + readonly disconnectedGap: number; +} + +/** Default config */ +export const DEFAULT_FRAMEWORK_CONFIG: FrameworkConfig = { horizontalGap: 100, verticalGap: 40, groupPadding: 30, - startX: 100, - startY: 100, - maxIterations: 24, - maxColumns: 0, - maxRowWidth: 800, - collapseReroutes: true, + groupTitleHeight: 46, disconnectedGap: 150, }; - -/** Node classification result */ -export interface ClassifiedNodes { - /** Nodes with connections (participate in DAG) */ - connected: Set; - /** Nodes without any connections */ - disconnected: Set; - /** Reroute nodes (for special handling) */ - reroutes: Set; -} - -/** Reroute chain for collapse/restore */ -export interface RerouteChain { - /** Reroute node IDs in order */ - nodes: number[]; - /** Source node before chain */ - sourceNode: number; - /** Source slot index */ - sourceSlot: number; - /** Target nodes after chain */ - targets: Array<{ node: number; slot: number }>; -} - -/** Packed row within a layer (for bin packing) */ -export interface PackedRow { - /** Node IDs in this row */ - nodes: number[]; - /** Max node height in row */ - height: number; - /** Vertical offset from layer start */ - yOffset: number; - /** Total width of nodes + gaps in row */ - width: number; -} - -/** Graph layout state */ -export interface LayoutState { - /** All layout nodes by ID (includes group representatives) */ - nodes: Map; - /** Layers of nodes */ - layers: Layer[]; - /** Groups */ - groups: LayoutGroup[]; - /** Config */ - config: LayoutConfig; - /** Maps node ID to its group (if any) */ - nodeToGroup?: Map; - /** Maps group to its representative node ID */ - groupRepresentatives?: Map; - /** All original ComfyUI nodes by ID */ - allNodes?: Map; - /** Groups organized by depth (index 0 = deepest) for bottom-up processing */ - groupsByDepth?: LayoutGroup[][]; - /** Disconnected node IDs (placed in separate zone) */ - disconnectedNodes?: Set; - /** Collapsed reroute chains (restored after layout) */ - rerouteChains?: RerouteChain[]; - /** Packed rows per layer (for bin-packed layout) */ - packedRows?: Map; -} - -/** Layout result */ -export interface LayoutResult { - /** Number of nodes processed */ - nodeCount: number; - /** Number of layers */ - layerCount: number; - /** Number of groups resized */ - groupCount: number; - /** Execution time in ms */ - executionMs: number; -} - -/** Result from selected group layout */ -export interface SelectedGroupLayoutResult { - /** Number of nodes organized */ - nodeCount: number; - /** Number of groups organized */ - groupCount: number; - /** Execution time in ms */ - executionMs: number; -} diff --git a/src/runtime.ts b/src/runtime.ts new file mode 100644 index 0000000..f244737 --- /dev/null +++ b/src/runtime.ts @@ -0,0 +1,32 @@ +import { isGroup, type CanvasItem } from "./utils"; + +export interface CanvasLike { + readonly graph: TGraph | null; + readonly getCurrentGraph?: () => TGraph | null; + readonly selectedItems?: Iterable; +} + +export interface AppLike { + readonly canvas?: CanvasLike | null; + readonly graph?: TGraph | null; +} + +export function getCurrentGraph(app: AppLike): TGraph | null { + const canvas = app.canvas; + if (!canvas) return null; + return canvas.getCurrentGraph?.() ?? canvas.graph ?? app.graph ?? null; +} + +export function getSelectedGroups( + canvas: CanvasLike | null | undefined, +): Array<{ id: number; title: string }> { + if (!canvas?.selectedItems) return []; + + const groups: Array<{ id: number; title: string }> = []; + for (const item of canvas.selectedItems) { + if (isGroup(item)) { + groups.push({ id: item.id as number, title: item.title }); + } + } + return groups; +} diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 0000000..d42b37f --- /dev/null +++ b/src/settings.ts @@ -0,0 +1,26 @@ +export const EXTENSION_NAME = "comfy-node-organizer"; +export const SETTINGS_PREFIX = "Node Organizer"; +export const REPOSITORY_URL = + "https://github.com/PBandDev/comfyui-node-organizer"; +export const CURRENT_VERSION = "1.3.0"; + +export const DEFAULT_ALGORITHM_OPTIONS = [ + "sugiyama", + "horizontal", + "vertical", +] as const; + +export type DefaultAlgorithmName = + (typeof DEFAULT_ALGORITHM_OPTIONS)[number]; + +export const SETTING_IDS = { + VERSION: `${SETTINGS_PREFIX}.About`, + KEYBINDINGS: `${SETTINGS_PREFIX}.Keybindings`, + DEFAULT_ALGORITHM: `${SETTINGS_PREFIX}.Default Algorithm`, + HORIZONTAL_GAP: `${SETTINGS_PREFIX}.Horizontal Gap`, + VERTICAL_GAP: `${SETTINGS_PREFIX}.Vertical Gap`, + GROUP_PADDING: `${SETTINGS_PREFIX}.Group Padding`, + DISCONNECTED_GAP: `${SETTINGS_PREFIX}.Disconnected Gap`, + FIT_TO_VIEW: `${SETTINGS_PREFIX}.Fit to View`, + DEBUG_LOGGING: `${SETTINGS_PREFIX}.Debug Logging`, +} as const; diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..d55c33c --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,25 @@ +export interface CanvasItem { + readonly id: number | string; + readonly pos: ArrayLike; + readonly size?: ArrayLike; + readonly title?: string; + readonly type?: string | null; + readonly constructor?: { readonly name?: string }; + selected?: boolean; +} + +export interface GroupItem extends CanvasItem { + readonly title: string; +} + +export function isGroup(item: CanvasItem): item is GroupItem { + if (typeof item.title !== "string") { + return false; + } + + if (item.constructor?.name === "LGraphGroup") { + return true; + } + + return typeof item.type !== "string"; +} diff --git a/tests/e2e/fixtures.ts b/tests/e2e/fixtures.ts new file mode 100644 index 0000000..16b7b24 --- /dev/null +++ b/tests/e2e/fixtures.ts @@ -0,0 +1,22 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const fixturesRoot = resolve( + fileURLToPath(new URL("../fixtures/", import.meta.url)), +); + +export function loadWorkflowData(path: string): Record { + return JSON.parse(readFileSync(path, "utf-8")) as Record; +} + +export function listRepoFixtures(): string[] { + return readdirSync(fixturesRoot) + .filter((entry) => entry.endsWith(".json")) + .map((entry) => entry.replace(/\.json$/, "")) + .sort((a, b) => a.localeCompare(b)); +} + +export function loadFixture(name: string): Record { + return loadWorkflowData(resolve(fixturesRoot, `${name}.json`)); +} diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts new file mode 100644 index 0000000..aa73ab7 --- /dev/null +++ b/tests/e2e/global-setup.ts @@ -0,0 +1,106 @@ +import { spawn } from "node:child_process"; +import { writeFileSync, existsSync } from "node:fs"; +import { resolve, join } from "node:path"; +import { e2eConfig } from "../../e2e.config.ts"; + +const isWindows = process.platform === "win32"; +const projectRoot = resolve(import.meta.dirname, "..", ".."); + +const venvDir = resolve(projectRoot, e2eConfig.venvDir); +const comfyInstallDir = resolve(projectRoot, e2eConfig.comfyInstallDir); +const pidFile = resolve(projectRoot, e2eConfig.testComfyDir, "comfy.pid"); + +const comfyBin = isWindows + ? join(venvDir, "Scripts", "comfy.exe") + : join(venvDir, "bin", "comfy"); + +async function isComfyRunning(): Promise { + try { + const response = await fetch(e2eConfig.comfyUrl); + return response.ok; + } catch { + return false; + } +} + +async function waitForComfy(timeoutMs: number): Promise { + const start = Date.now(); + const pollInterval = 2000; + + while (Date.now() - start < timeoutMs) { + if (await isComfyRunning()) { + return; + } + await new Promise((r) => setTimeout(r, pollInterval)); + } + + throw new Error( + `ComfyUI did not start within ${timeoutMs / 1000}s on ${e2eConfig.comfyUrl}` + ); +} + +export default async function globalSetup(): Promise { + // Check if ComfyUI is already running + if (await isComfyRunning()) { + console.log( + `[e2e] ComfyUI already running on ${e2eConfig.comfyUrl}, reusing.` + ); + // Write PID file with "external" marker so teardown knows not to kill it + writeFileSync(pidFile, "external", "utf-8"); + return; + } + + // Verify comfy binary exists + if (!existsSync(comfyBin)) { + throw new Error( + `comfy-cli not found at ${comfyBin}. Run \`pnpm setup:e2e\` first.` + ); + } + + console.log(`[e2e] Starting ComfyUI on port ${e2eConfig.port}...`); + + const child = spawn( + comfyBin, + [ + "--skip-prompt", + "--workspace", + comfyInstallDir, + "launch", + "--", + "--cpu", + "--port", + String(e2eConfig.port), + ], + { + stdio: "pipe", + detached: !isWindows, + } + ); + + // Log stdout/stderr for debugging + child.stdout.on("data", (data: Buffer) => { + process.stdout.write(`[comfy] ${data.toString()}`); + }); + child.stderr.on("data", (data: Buffer) => { + process.stderr.write(`[comfy] ${data.toString()}`); + }); + + child.on("error", (err) => { + console.error(`[e2e] Failed to start ComfyUI: ${err.message}`); + }); + + if (child.pid === undefined) { + throw new Error("Failed to spawn ComfyUI process — no PID assigned."); + } + + // Store PID for teardown + writeFileSync(pidFile, String(child.pid), "utf-8"); + console.log(`[e2e] ComfyUI spawned with PID ${child.pid}`); + + // Detach so the process survives if this script exits + child.unref(); + + // Wait for ComfyUI to be ready + await waitForComfy(e2eConfig.timeouts.comfyStartup); + console.log(`[e2e] ComfyUI is ready on ${e2eConfig.comfyUrl}`); +} diff --git a/tests/e2e/global-teardown.ts b/tests/e2e/global-teardown.ts new file mode 100644 index 0000000..0f2d83b --- /dev/null +++ b/tests/e2e/global-teardown.ts @@ -0,0 +1,54 @@ +import { execSync } from "node:child_process"; +import { readFileSync, unlinkSync, existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { e2eConfig } from "../../e2e.config.ts"; + +const isWindows = process.platform === "win32"; +const projectRoot = resolve(import.meta.dirname, "..", ".."); +const pidFile = resolve(projectRoot, e2eConfig.testComfyDir, "comfy.pid"); + +function killProcess(pid: number): void { + try { + if (isWindows) { + // On Windows, process.kill doesn't reliably kill child trees. + // Use taskkill /F /T to force-kill the process tree. + execSync(`taskkill /F /T /PID ${pid}`, { stdio: "ignore" }); + } else { + // On Unix, kill the process group (negative PID) + process.kill(-pid, "SIGTERM"); + } + console.log(`[e2e] Killed ComfyUI process (PID ${pid}).`); + } catch { + // Process may already be dead + console.log( + `[e2e] ComfyUI process (PID ${pid}) already exited or could not be killed.` + ); + } +} + +export default async function globalTeardown(): Promise { + if (!existsSync(pidFile)) { + console.log("[e2e] No PID file found, nothing to tear down."); + return; + } + + const content = readFileSync(pidFile, "utf-8").trim(); + + if (content === "external") { + console.log( + "[e2e] ComfyUI was already running before tests — leaving it alone." + ); + unlinkSync(pidFile); + return; + } + + const pid = parseInt(content, 10); + if (isNaN(pid)) { + console.log(`[e2e] Invalid PID in file: "${content}". Cleaning up.`); + unlinkSync(pidFile); + return; + } + + killProcess(pid); + unlinkSync(pidFile); +} diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts new file mode 100644 index 0000000..0aee674 --- /dev/null +++ b/tests/e2e/helpers.ts @@ -0,0 +1,1035 @@ +/** + * E2E test helpers for ComfyUI Node Organizer. + * + * Provides typed extraction of graph state, layout-quality invariant + * assertions, and convenience wrappers for triggering the organizer + * through the browser. + */ + +import type { Page } from "@playwright/test"; +import { expect } from "@playwright/test"; +import { mkdirSync } from "node:fs"; +import { resolve } from "node:path"; +import { e2eConfig } from "../../e2e.config.ts"; +import { + isGroupInsideGroup, + isNodeCenterInsideGroup, +} from "../../src/group-geometry"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export interface ExtractedNode { + id: number; + type: string; + title: string; + pos: [number, number]; + size: [number, number]; +} + +export interface ExtractedLink { + id: number; + origin_id: number; + origin_slot: number; + target_id: number; + target_slot: number; +} + +export interface ExtractedGroup { + title: string; + pos: [number, number]; + size: [number, number]; +} + +export interface GraphState { + nodes: ExtractedNode[]; + links: ExtractedLink[]; + groups: ExtractedGroup[]; +} + +export interface GroupMembershipSnapshot { + title: string; + parentTitle: string | null; + memberNodeIds: number[]; +} + +export interface SpacingMeasurements { + horizontalLayerGap: number; + verticalSiblingGap: number; + groupPadding: number; + disconnectedGap: number; +} + +// ── AABB helpers (no tolerance parameter to keep strict) ───────────────────── + +interface BoundingBox { + label: string; + x: number; + y: number; + w: number; + h: number; +} + +/** AABB overlap with configurable inset tolerance (shrinks boxes before test). */ +function boxesOverlap(a: BoundingBox, b: BoundingBox, tolerance: number): boolean { + const ax = a.x + tolerance; + const ay = a.y + tolerance; + const aw = a.w - tolerance * 2; + const ah = a.h - tolerance * 2; + + const bx = b.x + tolerance; + const by = b.y + tolerance; + const bw = b.w - tolerance * 2; + const bh = b.h - tolerance * 2; + + // If shrunk to zero or negative, skip + if (aw <= 0 || ah <= 0 || bw <= 0 || bh <= 0) return false; + + return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by; +} + +/** True if `inner` is fully contained inside `outer`. */ +function isInside(inner: BoundingBox, outer: BoundingBox): boolean { + return ( + inner.x >= outer.x && + inner.y >= outer.y && + inner.x + inner.w <= outer.x + outer.w && + inner.y + inner.h <= outer.y + outer.h + ); +} + +// ── Core helpers ───────────────────────────────────────────────────────────── + +/** + * Wait for ComfyUI app to be fully loaded. + * Resolves once `window.app.extensionManager` is truthy. + */ +export async function waitForComfyUI(page: Page): Promise { + await page.waitForFunction( + () => { + const w = window as unknown as Record; + const appObj = w.app as Record | undefined; + return !!appObj?.extensionManager; + }, + { timeout: e2eConfig.timeouts.pageLoad }, + ); +} + +/** + * Load a workflow JSON into ComfyUI via `app.loadGraphData`. + * Waits for the graph to settle (nodes rendered with valid positions). + */ +export async function loadWorkflow( + page: Page, + workflow: Record, +): Promise { + await page.evaluate(async (data: Record) => { + const w = window as unknown as Record; + const appObj = w.app as Record; + // Must call as method on app to preserve `this` binding + await (appObj.loadGraphData as (d: unknown) => Promise).call( + appObj, + data, + ); + }, workflow); + + // Wait for ALL nodes to have non-zero sizes + await page.waitForFunction( + () => { + const w = window as unknown as Record; + const appObj = w.app as Record; + const canvas = appObj.canvas as + | (Record & { + getCurrentGraph?: () => Record | undefined; + }) + | undefined; + const graph = (canvas?.getCurrentGraph?.() ?? + canvas?.graph ?? + appObj.graph) as Record | undefined; + if (!graph) return false; + const nodes = graph._nodes as Array> | undefined; + if (!nodes || nodes.length === 0) return true; + return nodes.every((n) => { + const size = n.size as ArrayLike | undefined; + return size && Number(size[0]) > 0 && Number(size[1]) > 0; + }); + }, + { timeout: e2eConfig.timeouts.organize }, + ); + + // Wait for node sizes to stabilize (widgets/text areas may resize after render) + await page.waitForTimeout(500); + await page.waitForFunction( + () => { + const w = window as unknown as Record; + const appObj = w.app as Record; + const canvas = appObj.canvas as + | (Record & { + getCurrentGraph?: () => Record | undefined; + }) + | undefined; + const graph = (canvas?.getCurrentGraph?.() ?? + canvas?.graph ?? + appObj.graph) as Record | undefined; + if (!graph) return true; + const nodes = graph._nodes as Array> | undefined; + if (!nodes) return true; + // Store sizes, wait, check again — if stable, sizes won't change + const key = "__sizeCheck"; + const w2 = window as unknown as Record; + const prev = w2[key] as string | undefined; + const current = JSON.stringify( + nodes.map((n) => { + const s = n.size as ArrayLike; + return [Number(s[0]), Number(s[1])]; + }), + ); + w2[key] = current; + return prev === current; + }, + { timeout: e2eConfig.timeouts.organize, polling: 300 }, + ); +} + +/** + * Trigger "Organize Workflow" via canvas right-click context menu. + * Clicks the canvas center, opens context menu, then clicks the menu item. + */ +export async function triggerOrganize(page: Page): Promise { + // Capture positions before so we can detect when layout completes + const beforePositions = await extractNodePositionMap(page); + + // Execute the organize command directly via ComfyUI's command API + await page.evaluate(() => { + const w = window as unknown as Record; + const appObj = w.app as Record; + const em = appObj.extensionManager as Record; + const command = em.command as { execute: (id: string) => void }; + command.execute("node-organizer.organize-workflow"); + }); + + // Wait for layout to complete — positions should change (or stabilize for empty graphs) + await waitForPositionsToChange(page, beforePositions); +} + +export async function setNumericSetting( + page: Page, + settingId: string, + value: number, +): Promise { + await page.evaluate( + ({ id, nextValue }: { id: string; nextValue: number }) => { + const w = window as unknown as Record; + const appObj = w.app as Record; + const extensionManager = appObj.extensionManager as Record; + const setting = extensionManager.setting as { + set: (targetId: string, targetValue: number) => void; + }; + setting.set(id, nextValue); + }, + { id: settingId, nextValue: value }, + ); +} + +export async function setBooleanSetting( + page: Page, + settingId: string, + value: boolean, +): Promise { + await page.evaluate( + ({ id, nextValue }: { id: string; nextValue: boolean }) => { + const w = window as unknown as Record; + const appObj = w.app as Record; + const extensionManager = appObj.extensionManager as Record; + const setting = extensionManager.setting as { + set: (targetId: string, targetValue: boolean) => void; + }; + setting.set(id, nextValue); + }, + { id: settingId, nextValue: value }, + ); +} + +export async function setNumericSettingOverride( + page: Page, + settingId: string, + value: number, +): Promise { + await page.evaluate( + ({ id, nextValue }: { id: string; nextValue: number }) => { + const overrideKey = "__nodeOrganizerSettingOverrides"; + const originalGetKey = "__nodeOrganizerOriginalSettingGet"; + const w = window as unknown as Record; + const appObj = w.app as Record; + const extensionManager = appObj.extensionManager as Record; + const setting = extensionManager.setting as { + get?: (targetId: string) => T | undefined; + } & Record; + + const overrides = + (w[overrideKey] as Record | undefined) ?? {}; + overrides[id] = nextValue; + w[overrideKey] = overrides; + + if (typeof w[originalGetKey] !== "function") { + w[originalGetKey] = setting.get?.bind(setting); + setting.get = (targetId: string): T | undefined => { + const activeOverrides = w[overrideKey] as + | Record + | undefined; + if (activeOverrides && targetId in activeOverrides) { + return activeOverrides[targetId] as T; + } + const originalGet = w[originalGetKey] as + | ((settingId: string) => T | undefined) + | undefined; + return originalGet?.(targetId); + }; + } + }, + { id: settingId, nextValue: value }, + ); +} + +export async function clearNumericSettingOverrides(page: Page): Promise { + await page.evaluate(() => { + const overrideKey = "__nodeOrganizerSettingOverrides"; + const originalGetKey = "__nodeOrganizerOriginalSettingGet"; + const w = window as unknown as Record; + const appObj = w.app as Record; + const extensionManager = appObj.extensionManager as Record; + const setting = extensionManager.setting as { + get?: (targetId: string) => T | undefined; + } & Record; + const originalGet = w[originalGetKey] as + | ((settingId: string) => T | undefined) + | undefined; + + delete w[overrideKey]; + if (originalGet) { + setting.get = originalGet; + delete w[originalGetKey]; + } + }); +} + +/** + * Trigger "Organize Group" for a specific group by title. + * + * Uses page.evaluate to programmatically select the group and invoke + * the layout, since UI-based group selection is fragile. + */ +export async function triggerOrganizeGroup( + page: Page, + groupTitle: string, +): Promise { + // Select the group programmatically, then execute the organize command + await page.evaluate((title: string) => { + const w = window as unknown as Record; + const appObj = w.app as Record; + const canvas = appObj.canvas as Record & { + getCurrentGraph?: () => Record | undefined; + }; + const graph = (canvas.getCurrentGraph?.() ?? + canvas.graph) as Record; + const groups = graph._groups as Array>; + + const group = groups.find((g) => g.title === title); + if (!group) { + throw new Error(`Group with title "${title}" not found`); + } + + // Select the group via canvas.selectedItems + const selectedItems = new Set([group]); + canvas.selectedItems = selectedItems; + + // Execute the organize groups command + const em = appObj.extensionManager as Record; + const command = em.command as { execute: (id: string) => void }; + command.execute("node-organizer.organize-groups"); + }, groupTitle); + + // Allow layout to complete + await page.waitForTimeout(1000); +} + +/** + * Extract the current graph state (nodes, links, groups) from the browser. + * Converts Float64Array / typed arrays to plain JS arrays. + */ +export async function extractGraphState(page: Page): Promise { + return page.evaluate(() => { + const w = window as unknown as Record; + const appObj = w.app as Record; + const canvas = appObj.canvas as Record & { + getCurrentGraph?: () => Record | undefined; + }; + const graph = (canvas.getCurrentGraph?.() ?? + canvas.graph ?? + appObj.graph) as Record; + + // ── Nodes ── + const packNode = ( + node: Record, + fallbackType: string, + fallbackTitle: string, + ) => { + const pos = node.pos as ArrayLike; + const size = node.size as ArrayLike; + return { + id: Number(node.id), + type: (node.type as string) ?? fallbackType, + title: + (node.title as string) ?? + (node.type as string) ?? + fallbackTitle, + pos: [Number(pos[0]), Number(pos[1])] as [number, number], + size: [Number(size[0]), Number(size[1])] as [number, number], + }; + }; + + const rawNodes = graph._nodes as Array>; + const nodes = rawNodes.map((n) => packNode(n, "Unknown", "Unknown")); + const inputNode = graph.inputNode as Record | undefined; + const outputNode = graph.outputNode as Record | undefined; + if (inputNode) { + nodes.push(packNode(inputNode, "SubgraphInput", "Subgraph Input")); + } + if (outputNode) { + nodes.push(packNode(outputNode, "SubgraphOutput", "Subgraph Output")); + } + + // ── Links ── + const rawLinks = graph.links as + | Map> + | Record>; + const linkEntries: Array> = + rawLinks instanceof Map + ? Array.from(rawLinks.values()) + : Object.values(rawLinks); + + const links = linkEntries + .filter((l): l is Record => l != null) + .map((l) => ({ + id: l.id as number, + origin_id: l.origin_id as number, + origin_slot: l.origin_slot as number, + target_id: l.target_id as number, + target_slot: l.target_slot as number, + })); + + // ── Groups ── + const rawGroups = (graph._groups as Array> | undefined) ?? []; + const groups = rawGroups.map((g) => { + const gPos = (g._pos ?? g.pos) as ArrayLike; + const gSize = (g._size ?? g.size) as ArrayLike; + return { + title: (g.title as string) ?? "Untitled", + pos: [Number(gPos[0]), Number(gPos[1])] as [number, number], + size: [Number(gSize[0]), Number(gSize[1])] as [number, number], + }; + }); + + return { nodes, links, groups }; + }); +} + +export async function extractGroupMemberships( + page: Page, +): Promise { + const graph = await page.evaluate(() => { + const w = window as unknown as Record; + const appObj = w.app as Record; + const canvas = appObj.canvas as Record & { + getCurrentGraph?: () => Record | undefined; + }; + const currentGraph = (canvas.getCurrentGraph?.() ?? + canvas.graph ?? + appObj.graph) as { + _nodes: Array>; + _groups: Array>; + }; + + return { + groups: currentGraph._groups.map((group) => { + const pos = (group._pos ?? group.pos) as ArrayLike; + const size = (group._size ?? group.size) as ArrayLike; + return { + id: Number(group.id), + title: String(group.title ?? "Untitled"), + pos: [Number(pos[0]), Number(pos[1])] as [number, number], + size: [Number(size[0]), Number(size[1])] as [number, number], + }; + }), + nodes: currentGraph._nodes.map((node) => { + const pos = node.pos as ArrayLike; + const size = node.size as ArrayLike; + return { + id: Number(node.id), + pos: [Number(pos[0]), Number(pos[1])] as [number, number], + size: [Number(size[0]), Number(size[1])] as [number, number], + }; + }), + }; + }); + + return graph.groups + .map((group) => { + let parent: (typeof graph.groups)[number] | null = null; + for (const candidate of graph.groups) { + if (candidate.id === group.id) continue; + if (!isGroupInsideGroup(group, candidate)) continue; + if ( + !parent || + candidate.size[0] * candidate.size[1] < parent.size[0] * parent.size[1] + ) { + parent = candidate; + } + } + + const directChildren = graph.groups.filter((candidate) => { + if (candidate.id === group.id) return false; + if (!isGroupInsideGroup(candidate, group)) return false; + let candidateParent: (typeof graph.groups)[number] | null = null; + for (const possibleParent of graph.groups) { + if (possibleParent.id === candidate.id) continue; + if (!isGroupInsideGroup(candidate, possibleParent)) continue; + if ( + !candidateParent || + possibleParent.size[0] * possibleParent.size[1] < + candidateParent.size[0] * candidateParent.size[1] + ) { + candidateParent = possibleParent; + } + } + return candidateParent?.id === group.id; + }); + + const memberNodeIds = graph.nodes + .filter((node) => { + if (!isNodeCenterInsideGroup(node, group)) return false; + return !directChildren.some((child) => isNodeCenterInsideGroup(node, child)); + }) + .map((node) => node.id) + .sort((a, b) => a - b); + + return { + title: group.title, + parentTitle: parent?.title ?? null, + memberNodeIds, + }; + }) + .sort((a, b) => a.title.localeCompare(b.title)); +} + +export async function extractSpacingMeasurements( + page: Page, +): Promise { + return page.evaluate(() => { + type RuntimeNode = { + title: string; + pos: [number, number]; + size: [number, number]; + }; + + type RuntimeGroup = { + title: string; + pos: [number, number]; + size: [number, number]; + }; + + const w = window as unknown as Record; + const appObj = w.app as Record; + const canvas = appObj.canvas as Record & { + getCurrentGraph?: () => Record | undefined; + }; + const graph = (canvas.getCurrentGraph?.() ?? + canvas.graph ?? + appObj.graph) as { + _nodes: Array>; + _groups: Array>; + }; + + const nodes = graph._nodes.map((node) => { + const pos = node.pos as ArrayLike; + const size = node.size as ArrayLike; + return { + title: String(node.title ?? node.type ?? "Untitled"), + pos: [Number(pos[0]), Number(pos[1])] as [number, number], + size: [Number(size[0]), Number(size[1])] as [number, number], + }; + }); + + const groups = graph._groups.map((group) => { + const pos = (group._pos ?? group.pos) as ArrayLike; + const size = (group._size ?? group.size) as ArrayLike; + return { + title: String(group.title ?? "Untitled"), + pos: [Number(pos[0]), Number(pos[1])] as [number, number], + size: [Number(size[0]), Number(size[1])] as [number, number], + }; + }); + + const requireNode = (title: string): RuntimeNode => { + const node = nodes.find((candidate) => candidate.title === title); + if (!node) { + throw new Error(`Missing spacing fixture node "${title}"`); + } + return node; + }; + + const requireGroup = (title: string): RuntimeGroup => { + const group = groups.find((candidate) => candidate.title === title); + if (!group) { + throw new Error(`Missing spacing fixture group "${title}"`); + } + return group; + }; + + const sampler = requireNode("Sampler"); + const decode = requireNode("Decode"); + const disconnected = requireNode("Disconnected Note"); + const save = requireNode("Save"); + const rootLayerNodes = [ + requireNode("Model Loader"), + requireNode("Latent Source"), + ].sort((a, b) => a.pos[1] - b.pos[1]); + const promptNodes = [ + requireNode("Positive Prompt"), + requireNode("Negative Prompt"), + ].sort((a, b) => a.pos[1] - b.pos[1]); + const promptGroup = requireGroup("Prompt Cluster"); + + const mainNodes = [ + rootLayerNodes[0], + rootLayerNodes[1], + promptNodes[0], + promptNodes[1], + sampler, + decode, + save, + ]; + + const mainLeft = Math.min(...mainNodes.map((node) => node.pos[0])); + const mainRight = Math.max( + ...mainNodes.map((node) => node.pos[0] + node.size[0]), + ); + const disconnectedLeft = disconnected.pos[0]; + const disconnectedRight = disconnected.pos[0] + disconnected.size[0]; + + const horizontalLayerGap = save.pos[0] - (decode.pos[0] + decode.size[0]); + const verticalSiblingGap = + rootLayerNodes[1].pos[1] - + (rootLayerNodes[0].pos[1] + rootLayerNodes[0].size[1]); + const leftmostPromptNode = Math.min( + ...promptNodes.map((node) => node.pos[0]), + ); + const groupPadding = leftmostPromptNode - promptGroup.pos[0]; + const disconnectedGap = + disconnectedLeft >= mainRight + ? disconnectedLeft - mainRight + : mainLeft - disconnectedRight; + + return { + horizontalLayerGap, + verticalSiblingGap, + groupPadding, + disconnectedGap, + }; + }); +} + +// ── Invariant assertions ───────────────────────────────────────────────────── + +/** + * Assert layout-quality invariants on an extracted graph state. + * + * Checks: + * 1. No node-to-node overlaps (AABB with 2px tolerance) + * 2. All coordinates are finite (no NaN / Infinity) + * 3. Nodes whose center falls inside a group are fully contained + * 4. Topological order — sources positioned left of targets in X + */ +export function assertInvariants(state: GraphState): void { + assertCorrectnessInvariants(state); + assertTopologicalOrder(state); +} + +/** + * Correctness-only invariants (no topological order). + * Use for complex real-world workflows where strict edge directionality + * cannot always be satisfied due to group containment constraints. + */ +export function assertCorrectnessInvariants(state: GraphState): void { + assertNoNodeOverlaps(state); + assertFiniteCoordinates(state); + assertNodesInsideGroups(state); + assertNoGroupOverlaps(state); +} + +/** No two nodes overlap (strict AABB check). */ +function assertNoNodeOverlaps(state: GraphState): void { + const tolerance = 0; + const boxes: BoundingBox[] = state.nodes.map((n) => ({ + label: `node_${n.id} (${n.title})`, + x: n.pos[0], + y: n.pos[1], + w: n.size[0], + h: n.size[1], + })); + + const overlaps: string[] = []; + for (let i = 0; i < boxes.length; i++) { + for (let j = i + 1; j < boxes.length; j++) { + if (boxesOverlap(boxes[i], boxes[j], tolerance)) { + overlaps.push(`${boxes[i].label} overlaps ${boxes[j].label}`); + } + } + } + + expect(overlaps, `Found ${overlaps.length} node overlaps`).toEqual([]); +} + +/** All node/group coordinates are finite numbers. */ +function assertFiniteCoordinates(state: GraphState): void { + const errors: string[] = []; + + for (const n of state.nodes) { + if (!Number.isFinite(n.pos[0]) || !Number.isFinite(n.pos[1])) { + errors.push(`Node ${n.id} (${n.title}) has non-finite pos: [${n.pos[0]}, ${n.pos[1]}]`); + } + if (!Number.isFinite(n.size[0]) || !Number.isFinite(n.size[1])) { + errors.push(`Node ${n.id} (${n.title}) has non-finite size: [${n.size[0]}, ${n.size[1]}]`); + } + } + + for (const g of state.groups) { + if (!Number.isFinite(g.pos[0]) || !Number.isFinite(g.pos[1])) { + errors.push(`Group "${g.title}" has non-finite pos: [${g.pos[0]}, ${g.pos[1]}]`); + } + if (!Number.isFinite(g.size[0]) || !Number.isFinite(g.size[1])) { + errors.push(`Group "${g.title}" has non-finite size: [${g.size[0]}, ${g.size[1]}]`); + } + } + + expect(errors, `Found ${errors.length} non-finite coordinates`).toEqual([]); +} + +/** Nodes whose center is inside a group must be fully inside that group. */ +function assertNodesInsideGroups(state: GraphState): void { + const errors: string[] = []; + + for (const g of state.groups) { + const groupBox: BoundingBox = { + label: `group "${g.title}"`, + x: g.pos[0], + y: g.pos[1], + w: g.size[0], + h: g.size[1], + }; + + for (const n of state.nodes) { + const nodeCenterX = n.pos[0] + n.size[0] / 2; + const nodeCenterY = n.pos[1] + n.size[1] / 2; + + const centerInGroup = + nodeCenterX >= groupBox.x && + nodeCenterX <= groupBox.x + groupBox.w && + nodeCenterY >= groupBox.y && + nodeCenterY <= groupBox.y + groupBox.h; + + if (centerInGroup) { + const nodeBox: BoundingBox = { + label: `node_${n.id} (${n.title})`, + x: n.pos[0], + y: n.pos[1], + w: n.size[0], + h: n.size[1], + }; + + if (!isInside(nodeBox, groupBox)) { + errors.push( + `Node ${n.id} (${n.title}) center inside group "${g.title}" but extends outside bounds`, + ); + } + } + } + } + + expect(errors, `Found ${errors.length} nodes extending outside their groups`).toEqual([]); +} + +/** + * Topological order: flag only true inversions where the source starts + * entirely to the right of the target. + * Reroute nodes are excluded. + */ +function assertTopologicalOrder(state: GraphState): void { + const nodeMap = new Map(state.nodes.map((n) => [n.id, n])); + const errors: string[] = []; + + for (const link of state.links) { + const src = nodeMap.get(link.origin_id); + const tgt = nodeMap.get(link.target_id); + if (!src || !tgt) continue; + // Skip reroute nodes + if (src.type === "Reroute" || tgt.type === "Reroute") continue; + + if (src.pos[0] > tgt.pos[0] + tgt.size[0]) { + errors.push( + `Link ${link.id}: source ${src.id} (${src.title}) left-edge ${src.pos[0].toFixed(0)} > ` + + `target ${tgt.id} (${tgt.title}) right-edge ${(tgt.pos[0] + tgt.size[0]).toFixed(0)}`, + ); + } + } + + expect(errors, `Found ${errors.length} topological order violations`).toEqual([]); +} + +/** Sibling groups (neither fully contains the other) must not overlap. */ +function assertNoGroupOverlaps(state: GraphState): void { + const overlaps: string[] = []; + + for (let i = 0; i < state.groups.length; i++) { + for (let j = i + 1; j < state.groups.length; j++) { + const a = state.groups[i]; + const b = state.groups[j]; + + const aBox: BoundingBox = { + label: `group "${a.title}"`, + x: a.pos[0], + y: a.pos[1], + w: a.size[0], + h: a.size[1], + }; + const bBox: BoundingBox = { + label: `group "${b.title}"`, + x: b.pos[0], + y: b.pos[1], + w: b.size[0], + h: b.size[1], + }; + + // Skip parent-child pairs (one fully contains the other) + if (isInside(aBox, bBox) || isInside(bBox, aBox)) continue; + + if (boxesOverlap(aBox, bBox, 0)) { + overlaps.push(`${aBox.label} overlaps ${bBox.label}`); + } + } + } + + expect(overlaps, `Found ${overlaps.length} sibling group overlaps`).toEqual([]); +} + +// ── Idempotency ────────────────────────────────────────────────────────────── + +/** + * Assert the layout is idempotent: run organize twice and compare positions. + * Must be called after the first organize has already been triggered. + */ +export async function assertIdempotent(page: Page): Promise { + const state1 = await extractGraphState(page); + + // Trigger organize again + await triggerOrganize(page); + + const state2 = await extractGraphState(page); + + const tolerance = 0; + const diffs: string[] = []; + + for (const n1 of state1.nodes) { + const n2 = state2.nodes.find((n) => n.id === n1.id); + if (!n2) { + diffs.push(`Node ${n1.id} missing after second organize`); + continue; + } + + const dx = Math.abs(n1.pos[0] - n2.pos[0]); + const dy = Math.abs(n1.pos[1] - n2.pos[1]); + if (dx > tolerance || dy > tolerance) { + diffs.push( + `Node ${n1.id} (${n1.title}) moved: ` + + `[${n1.pos[0].toFixed(3)}, ${n1.pos[1].toFixed(3)}] -> ` + + `[${n2.pos[0].toFixed(3)}, ${n2.pos[1].toFixed(3)}]`, + ); + } + } + + expect(diffs, `Layout not idempotent: ${diffs.length} nodes moved`).toEqual([]); +} + +// ── Screenshots ────────────────────────────────────────────────────────────── + +const SCREENSHOTS_DIR = resolve(import.meta.dirname, "..", "screenshots"); + +/** + * Capture a screenshot and save to tests/screenshots/.png. + * Returns the absolute file path. + */ +export async function captureScreenshot(page: Page, name: string): Promise { + mkdirSync(SCREENSHOTS_DIR, { recursive: true }); + const filepath = resolve(SCREENSHOTS_DIR, `${name}.png`); + await page.screenshot({ path: filepath, fullPage: true }); + return filepath; +} + +/** Fit the current graph into view using the real canvas toolbar control. */ +export async function fitGraphForSnapshot(page: Page): Promise { + const before = await extractViewportState(page); + await page.getByRole("button", { name: /Fit View/ }).click({ force: true }); + await waitForViewportToStabilize(page, before); +} + +/** Compare only the graph canvas, not the full ComfyUI shell. */ +export async function expectGraphCanvasScreenshot( + page: Page, + name: string, +): Promise { + await fitGraphForSnapshot(page); + const graphCanvas = page.locator("#graph-canvas"); + await expect(graphCanvas).toHaveScreenshot(name, { + animations: "disabled", + caret: "hide", + scale: "css", + maxDiffPixelRatio: 0.01, + }); +} + +// ── Internal utilities ─────────────────────────────────────────────────────── + +/** Extract a lightweight id->pos map for change detection. */ +async function extractNodePositionMap( + page: Page, +): Promise> { + const raw = await page.evaluate(() => { + const w = window as unknown as Record; + const appObj = w.app as Record; + const canvas = appObj.canvas as Record & { + getCurrentGraph?: () => Record | undefined; + }; + const graph = (canvas.getCurrentGraph?.() ?? + canvas.graph ?? + appObj.graph) as Record | undefined; + if (!graph) return []; + const nodes = graph._nodes as Array> | undefined; + const entries: Array> = [...(nodes ?? [])]; + if (graph.inputNode) { + entries.push(graph.inputNode as Record); + } + if (graph.outputNode) { + entries.push(graph.outputNode as Record); + } + return entries.map((n) => { + const pos = n.pos as ArrayLike; + return [n.id as number, Number(pos[0]), Number(pos[1])] as [number, number, number]; + }); + }); + + const map = new Map(); + for (const [id, x, y] of raw) { + map.set(id, [x, y]); + } + return map; +} + +/** + * Wait until node positions differ from a baseline snapshot. + * Times out gracefully — some graphs may already be organized. + */ +async function waitForPositionsToChange( + page: Page, + before: Map, +): Promise { + const deadline = Date.now() + e2eConfig.timeouts.organize; + const pollMs = 200; + + while (Date.now() < deadline) { + const current = await extractNodePositionMap(page); + let changed = false; + + for (const [id, pos] of before) { + const cur = current.get(id); + if (!cur) continue; + if (Math.abs(pos[0] - cur[0]) > 1 || Math.abs(pos[1] - cur[1]) > 1) { + changed = true; + break; + } + } + + if (changed) return; + await page.waitForTimeout(pollMs); + } + + // Not an error — layout may be a no-op for an already-organized graph +} + +interface ViewportState { + offset: [number, number]; + scale: number; + dirtyCanvas: boolean; + dirtyBgCanvas: boolean; +} + +async function extractViewportState(page: Page): Promise { + return page.evaluate(() => { + const w = window as unknown as Record; + const appObj = w.app as Record; + const canvas = appObj.canvas as { + ds: { offset: [number, number]; scale: number }; + dirty_canvas?: boolean; + dirty_bgcanvas?: boolean; + }; + return { + offset: [Number(canvas.ds.offset[0]), Number(canvas.ds.offset[1])] as [ + number, + number, + ], + scale: Number(canvas.ds.scale), + dirtyCanvas: !!canvas.dirty_canvas, + dirtyBgCanvas: !!canvas.dirty_bgcanvas, + }; + }); +} + +function isViewportEquivalent(a: ViewportState, b: ViewportState): boolean { + return ( + Math.abs(a.offset[0] - b.offset[0]) <= 0.01 && + Math.abs(a.offset[1] - b.offset[1]) <= 0.01 && + Math.abs(a.scale - b.scale) <= 0.0001 && + a.dirtyCanvas === b.dirtyCanvas && + a.dirtyBgCanvas === b.dirtyBgCanvas + ); +} + +async function waitForViewportToStabilize( + page: Page, + before: ViewportState, +): Promise { + const deadline = Date.now() + 5_000; + let previous = before; + let changed = false; + let stablePolls = 0; + + while (Date.now() < deadline) { + const current = await extractViewportState(page); + + if (!changed && !isViewportEquivalent(current, before)) { + changed = true; + } + + const isStableFrame = + isViewportEquivalent(current, previous) && + !current.dirtyCanvas && + !current.dirtyBgCanvas; + + stablePolls = isStableFrame ? stablePolls + 1 : 0; + if ((changed && stablePolls >= 3) || (!changed && stablePolls >= 5)) { + return; + } + + previous = current; + await page.waitForTimeout(100); + } +} + diff --git a/tests/e2e/installed-templates.test.ts b/tests/e2e/installed-templates.test.ts new file mode 100644 index 0000000..61159a6 --- /dev/null +++ b/tests/e2e/installed-templates.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from "@playwright/test"; +import { + assertCorrectnessInvariants, + extractGraphState, + loadWorkflow, + triggerOrganize, + waitForComfyUI, +} from "./helpers"; +import { + listInstalledTemplates, + loadWorkflowData, +} from "./installed-templates"; + +const installedTemplates = listInstalledTemplates(); + +test.describe("Installed workflow template invariants", () => { + test("discovers installed workflow templates", () => { + expect(installedTemplates.length).toBeGreaterThan(0); + }); + + test.beforeEach(async ({ page }) => { + await page.goto("/"); + await waitForComfyUI(page); + }); + + for (const template of installedTemplates) { + test(`${template.id}: organizes and passes correctness invariants`, async ({ + page, + }) => { + await loadWorkflow(page, loadWorkflowData(template.path)); + await triggerOrganize(page); + + const state = await extractGraphState(page); + expect(state.nodes.length).toBeGreaterThan(0); + assertCorrectnessInvariants(state); + }); + } +}); diff --git a/tests/e2e/installed-templates.ts b/tests/e2e/installed-templates.ts new file mode 100644 index 0000000..b7f03c6 --- /dev/null +++ b/tests/e2e/installed-templates.ts @@ -0,0 +1,80 @@ +import { execFileSync } from "node:child_process"; +import { join, resolve } from "node:path"; +import { e2eConfig } from "../../e2e.config"; +import { loadWorkflowData } from "./fixtures"; + +export interface InstalledTemplateEntry { + id: string; + path: string; +} + +export function isWorkflowData(value: Record): boolean { + return Array.isArray(value.nodes); +} + +function isInstalledTemplateEntry( + value: unknown, +): value is InstalledTemplateEntry { + if (typeof value !== "object" || value === null) { + return false; + } + + const candidate = value as Record; + return ( + typeof candidate.id === "string" && + candidate.id.length > 0 && + typeof candidate.path === "string" && + candidate.path.length > 0 + ); +} + +export function parseInstalledTemplateManifest( + stdout: string, +): InstalledTemplateEntry[] { + const parsed: unknown = JSON.parse(stdout); + if (!Array.isArray(parsed) || !parsed.every(isInstalledTemplateEntry)) { + throw new Error("Installed template discovery returned an invalid manifest"); + } + if (parsed.length === 0) { + throw new Error("Installed template discovery returned zero templates"); + } + + return parsed; +} + +function getTestVenvPythonPath(): string { + const venvRoot = resolve(e2eConfig.venvDir); + return process.platform === "win32" + ? join(venvRoot, "Scripts", "python.exe") + : join(venvRoot, "bin", "python"); +} + +export function listInstalledTemplates(): InstalledTemplateEntry[] { + const script = [ + "import json", + "from comfyui_workflow_templates import get_asset_path, iter_templates", + "entries = []", + "for template in iter_templates():", + " json_assets = [asset for asset in template.assets if asset.filename.endswith('.json')]", + " if len(json_assets) != 1:", + " raise RuntimeError(f'Template {template.template_id} expected exactly one JSON asset, found {len(json_assets)}')", + " entries.append({'id': template.template_id, 'path': get_asset_path(template.template_id, json_assets[0].filename)})", + "print(json.dumps(sorted(entries, key=lambda entry: entry['id'])))", + ].join("\n"); + + const stdout = execFileSync(getTestVenvPythonPath(), ["-c", script], { + encoding: "utf-8", + }); + + const workflowEntries = parseInstalledTemplateManifest(stdout).filter((entry) => + isWorkflowData(loadWorkflowData(entry.path)), + ); + + if (workflowEntries.length === 0) { + throw new Error("Installed template discovery returned zero workflow templates"); + } + + return workflowEntries; +} + +export { loadWorkflowData }; diff --git a/tests/e2e/organize-group.test.ts b/tests/e2e/organize-group.test.ts new file mode 100644 index 0000000..c8ffba9 --- /dev/null +++ b/tests/e2e/organize-group.test.ts @@ -0,0 +1,98 @@ +import { test, expect } from "@playwright/test"; +import { + waitForComfyUI, + loadWorkflow, + extractGraphState, + triggerOrganizeGroup, +} from "./helpers"; +import { loadFixture } from "./fixtures"; + +test.describe("Organize Group", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + await waitForComfyUI(page); + }); + + test("group-test-simple: organizes a single group", async ({ page }) => { + const workflow = loadFixture("group-test-simple"); + await loadWorkflow(page, workflow); + + // Get group titles from the workflow + const groupTitles = await page.evaluate(() => { + const w = window as unknown as Record; + const appObj = w.app as Record; + const canvas = appObj.canvas as Record; + const graph = canvas.graph as Record; + const groups = graph._groups as Array<{ title: string }>; + return groups.map((g) => g.title); + }); + + if (groupTitles.length > 0) { + await triggerOrganizeGroup(page, groupTitles[0]); + + const state = await extractGraphState(page); + // All coordinates should be finite after organize + for (const node of state.nodes) { + expect(Number.isFinite(node.pos[0])).toBe(true); + expect(Number.isFinite(node.pos[1])).toBe(true); + } + } + }); + + test("group-test: organizes groups with nested structure", async ({ + page, + }) => { + const workflow = loadFixture("group-test"); + await loadWorkflow(page, workflow); + + const groupTitles = await page.evaluate(() => { + const w = window as unknown as Record; + const appObj = w.app as Record; + const canvas = appObj.canvas as Record; + const graph = canvas.graph as Record; + const groups = graph._groups as Array<{ title: string }>; + return groups.map((g) => g.title); + }); + + // Organize each group + for (const title of groupTitles) { + await triggerOrganizeGroup(page, title); + } + + const state = await extractGraphState(page); + // Basic sanity: all coordinates finite + for (const node of state.nodes) { + expect(Number.isFinite(node.pos[0])).toBe(true); + expect(Number.isFinite(node.pos[1])).toBe(true); + } + }); + + test("nested-groups: organize group preserves other groups", async ({ + page, + }) => { + const workflow = loadFixture("nested-groups"); + await loadWorkflow(page, workflow); + + const beforeState = await extractGraphState(page); + + const groupTitles = await page.evaluate(() => { + const w = window as unknown as Record; + const appObj = w.app as Record; + const canvas = appObj.canvas as Record; + const graph = canvas.graph as Record; + const groups = graph._groups as Array<{ title: string }>; + return groups.map((g) => g.title); + }); + + if (groupTitles.length > 0) { + await triggerOrganizeGroup(page, groupTitles[0]); + + const afterState = await extractGraphState(page); + + // Total node count should be the same + expect(afterState.nodes.length).toBe(beforeState.nodes.length); + // Total group count should be the same + expect(afterState.groups.length).toBe(beforeState.groups.length); + } + }); +}); diff --git a/tests/e2e/organize-workflow.test.ts b/tests/e2e/organize-workflow.test.ts new file mode 100644 index 0000000..27e37e1 --- /dev/null +++ b/tests/e2e/organize-workflow.test.ts @@ -0,0 +1,183 @@ +import { test, expect } from "@playwright/test"; +import { + waitForComfyUI, + loadWorkflow, + extractGraphState, + extractGroupMemberships, + triggerOrganize, + assertInvariants, + assertIdempotent, +} from "./helpers"; +import { loadFixture } from "./fixtures"; + +test.describe("Organize Workflow", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + await waitForComfyUI(page); + }); + + test("simple-dag: organizes and passes invariants", async ({ page }) => { + const workflow = loadFixture("simple-dag"); + await loadWorkflow(page, workflow); + await triggerOrganize(page); + + const state = await extractGraphState(page); + expect(state.nodes.length).toBe(7); + assertInvariants(state); + }); + + test("simple-dag: idempotent", async ({ page }) => { + const workflow = loadFixture("simple-dag"); + await loadWorkflow(page, workflow); + await triggerOrganize(page); + await assertIdempotent(page); + }); + + test("complex-parallel: organizes and passes invariants", async ({ + page, + }) => { + const workflow = loadFixture("complex-parallel"); + await loadWorkflow(page, workflow); + await triggerOrganize(page); + + const state = await extractGraphState(page); + expect(state.nodes.length).toBeGreaterThan(10); + assertInvariants(state); + }); + + test("complex-parallel: idempotent", async ({ page }) => { + const workflow = loadFixture("complex-parallel"); + await loadWorkflow(page, workflow); + await triggerOrganize(page); + await assertIdempotent(page); + }); + + test("nested-groups: organizes and passes invariants", async ({ page }) => { + const workflow = loadFixture("nested-groups"); + await loadWorkflow(page, workflow); + await triggerOrganize(page); + + const state = await extractGraphState(page); + assertInvariants(state); + }); + + test("nested-groups: groups contain their members after organize", async ({ + page, + }) => { + const workflow = loadFixture("nested-groups"); + await loadWorkflow(page, workflow); + await triggerOrganize(page); + + const state = await extractGraphState(page); + // Groups should exist and have valid bounds + expect(state.groups.length).toBeGreaterThan(0); + for (const g of state.groups) { + expect(g.size[0]).toBeGreaterThan(0); + expect(g.size[1]).toBeGreaterThan(0); + } + }); + + test("group-test: preserves group hierarchy and direct memberships", async ({ + page, + }) => { + const workflow = loadFixture("group-test"); + await loadWorkflow(page, workflow); + + const beforeMemberships = await extractGroupMemberships(page); + await triggerOrganize(page); + const afterMemberships = await extractGroupMemberships(page); + + expect(afterMemberships).toEqual(beforeMemberships); + }); + + test("nested-groups: idempotent", async ({ page }) => { + const workflow = loadFixture("nested-groups"); + await loadWorkflow(page, workflow); + await triggerOrganize(page); + await assertIdempotent(page); + }); + + test("nested-wrapper: organizes and passes invariants", async ({ page }) => { + const workflow = loadFixture("nested-wrapper"); + await loadWorkflow(page, workflow); + await triggerOrganize(page); + + const state = await extractGraphState(page); + assertInvariants(state); + }); + + test("default workflow: organizes and passes invariants", async ({ + page, + }) => { + // Don't load a fixture — use whatever ComfyUI loads by default + await triggerOrganize(page); + + const state = await extractGraphState(page); + expect(state.nodes.length).toBeGreaterThan(0); + assertInvariants(state); + }); + + test("default workflow: idempotent", async ({ page }) => { + await triggerOrganize(page); + await assertIdempotent(page); + }); + + test("group-test: geometry-based group hierarchy and direct memberships match runtime after organize", async ({ + page, + }) => { + const workflow = loadFixture("group-test"); + await loadWorkflow(page, workflow); + await triggerOrganize(page); + + const memberships = await extractGroupMemberships(page); + expect(memberships).toEqual([ + { + title: "GetNodes [HORIZONTAL]", + parentTitle: "Previews [2ROW]", + memberNodeIds: [5, 6], + }, + { + title: "Preview Nodes [HORIZONTAL]", + parentTitle: "Previews [2ROW]", + memberNodeIds: [3, 4], + }, + { + title: "Previews [2ROW]", + parentTitle: null, + memberNodeIds: [], + }, + ]); + }); + + test("nested-groups: geometry-based group hierarchy and direct memberships match runtime after organize", async ({ + page, + }) => { + const workflow = loadFixture("nested-groups"); + await loadWorkflow(page, workflow); + await triggerOrganize(page); + + const memberships = await extractGroupMemberships(page); + expect(memberships).toEqual([ + { + title: "Group", + parentTitle: "Step 2 - Prompt", + memberNodeIds: [15], + }, + { + title: "Step 1 - Load model", + parentTitle: null, + memberNodeIds: [4], + }, + { + title: "Step 2 - Prompt", + parentTitle: null, + memberNodeIds: [6, 7], + }, + { + title: "Step 3 - Image size", + parentTitle: null, + memberNodeIds: [5], + }, + ]); + }); +}); diff --git a/tests/e2e/regression.test.ts b/tests/e2e/regression.test.ts new file mode 100644 index 0000000..6246b5b --- /dev/null +++ b/tests/e2e/regression.test.ts @@ -0,0 +1,106 @@ +/** + * Regression tests for v1 bugs that must not recur in v2. + * + * Each test targets a specific issue from the v1 changeset history: + * - 0006: Y explosion (groups centering against tallest layer) + * - 0007: Group overlap (no collision detection across layers) + * - 0008: Nested group breakout (parent groups expanded 1600+ px) + * - 0011: Oscillation (infinite loop in overlap resolution) + * - 0013: Idempotency broken + */ + +import { test, expect } from "@playwright/test"; +import { + waitForComfyUI, + loadWorkflow, + extractGraphState, + triggerOrganize, + assertInvariants, + assertIdempotent, +} from "./helpers"; +import { loadFixture } from "./fixtures"; + +test.describe("Regression: v1 bug fixes", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + await waitForComfyUI(page); + }); + + test("no Y explosion: layout height stays reasonable", async ({ page }) => { + // 0006: Y explosion — groups centering against tallest layer + // caused layouts to be thousands of pixels tall + const workflow = loadFixture("complex-parallel"); + await loadWorkflow(page, workflow); + await triggerOrganize(page); + + const state = await extractGraphState(page); + const ys = state.nodes.map((n) => n.pos[1] + n.size[1]); + const maxY = Math.max(...ys); + const minY = Math.min(...state.nodes.map((n) => n.pos[1])); + const totalHeight = maxY - minY; + + // Height should not explode beyond a reasonable bound + // (v1 bug produced heights of 11,500px for ~25 nodes) + expect(totalHeight).toBeLessThan(5000); + }); + + test("no group overlap after organize", async ({ page }) => { + // 0007: Groups overlapping each other after layout + const workflow = loadFixture("nested-groups"); + await loadWorkflow(page, workflow); + await triggerOrganize(page); + + const state = await extractGraphState(page); + assertInvariants(state); + }); + + test("nested groups stay contained", async ({ page }) => { + // 0008: Nested groups breaking out of parent bounds + const workflow = loadFixture("nested-groups"); + await loadWorkflow(page, workflow); + await triggerOrganize(page); + + const state = await extractGraphState(page); + // Groups should have reasonable sizes (v1 bug expanded by 1600+ px) + for (const g of state.groups) { + expect(g.size[0]).toBeLessThan(5000); + expect(g.size[1]).toBeLessThan(5000); + } + }); + + test("no oscillation: organize completes quickly", async ({ page }) => { + // 0011: Infinite loop in overlap resolution + // If organize completes within timeout, there's no oscillation + const workflow = loadFixture("complex-parallel"); + await loadWorkflow(page, workflow); + + const start = Date.now(); + await triggerOrganize(page); + const elapsed = Date.now() - start; + + // Should complete in under 5 seconds (v1 bug caused infinite loops) + expect(elapsed).toBeLessThan(5000); + }); + + test("idempotency: simple-dag", async ({ page }) => { + // 0013: Layout not stable on repeated runs + const workflow = loadFixture("simple-dag"); + await loadWorkflow(page, workflow); + await triggerOrganize(page); + await assertIdempotent(page); + }); + + test("idempotency: nested-groups", async ({ page }) => { + const workflow = loadFixture("nested-groups"); + await loadWorkflow(page, workflow); + await triggerOrganize(page); + await assertIdempotent(page); + }); + + test("idempotency: complex-parallel", async ({ page }) => { + const workflow = loadFixture("complex-parallel"); + await loadWorkflow(page, workflow); + await triggerOrganize(page); + await assertIdempotent(page); + }); +}); diff --git a/tests/e2e/sdxl-simple-example.test.ts b/tests/e2e/sdxl-simple-example.test.ts new file mode 100644 index 0000000..63958d9 --- /dev/null +++ b/tests/e2e/sdxl-simple-example.test.ts @@ -0,0 +1,113 @@ +import { test, expect } from "@playwright/test"; +import { + waitForComfyUI, + loadWorkflow, + extractGraphState, + extractGroupMemberships, + triggerOrganize, + triggerOrganizeGroup, + assertInvariants, + assertIdempotent, + expectGraphCanvasScreenshot, + setBooleanSetting, +} from "./helpers"; +import { loadFixture } from "./fixtures"; +import { SETTING_IDS } from "../../src/settings"; + +test.describe("SDXL Simple Example", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + await waitForComfyUI(page); + await setBooleanSetting(page, SETTING_IDS.FIT_TO_VIEW, false); + }); + + test("organize workflow preserves counts and passes invariants", async ({ + page, + }) => { + const workflow = loadFixture("sdxl_simple_example"); + await loadWorkflow(page, workflow); + + const beforeState = await extractGraphState(page); + expect(beforeState.nodes.length).toBe(25); + expect(beforeState.groups.length).toBe(10); + expect(beforeState.links.length).toBe(23); + + await triggerOrganize(page); + + const afterState = await extractGraphState(page); + expect(afterState.nodes.length).toBe(beforeState.nodes.length); + expect(afterState.groups.length).toBe(beforeState.groups.length); + expect(afterState.links.length).toBe(beforeState.links.length); + assertInvariants(afterState); + }); + + test("organize workflow preserves group hierarchy and direct memberships", async ({ + page, + }) => { + const workflow = loadFixture("sdxl_simple_example"); + await loadWorkflow(page, workflow); + + const beforeMemberships = await extractGroupMemberships(page); + await triggerOrganize(page); + const afterMemberships = await extractGroupMemberships(page); + + expect(afterMemberships).toEqual(beforeMemberships); + }); + + test("organize workflow is idempotent", async ({ page }) => { + const workflow = loadFixture("sdxl_simple_example"); + await loadWorkflow(page, workflow); + await triggerOrganize(page); + await assertIdempotent(page); + }); + + test("organize top-level group preserves child group memberships and graph counts", async ({ + page, + }) => { + const workflow = loadFixture("sdxl_simple_example"); + await loadWorkflow(page, workflow); + + const beforeMemberships = await extractGroupMemberships(page); + const expectedMemberships = beforeMemberships.filter( + (group) => + group.title === "Base Prompt" || + group.title === "Load in BASE SDXL Model", + ); + await triggerOrganizeGroup(page, "Base"); + + const afterMemberships = await extractGroupMemberships(page); + expect( + afterMemberships.filter( + (group) => + group.title === "Base Prompt" || + group.title === "Load in BASE SDXL Model", + ), + ).toEqual(expectedMemberships); + + const state = await extractGraphState(page); + expect(state.nodes.length).toBe(25); + expect(state.groups.length).toBe(10); + }); + + test("organize prompt group preserves its direct memberships", async ({ + page, + }) => { + const workflow = loadFixture("sdxl_simple_example"); + await loadWorkflow(page, workflow); + + const beforeMemberships = await extractGroupMemberships(page); + await triggerOrganizeGroup(page, "Text Prompts"); + + const afterMemberships = await extractGroupMemberships(page); + expect( + afterMemberships.find((group) => group.title === "Text Prompts"), + ).toEqual(beforeMemberships.find((group) => group.title === "Text Prompts")); + }); + + test("graph canvas after organize", async ({ page }) => { + const workflow = loadFixture("sdxl_simple_example"); + await loadWorkflow(page, workflow); + await triggerOrganize(page); + await expectGraphCanvasScreenshot(page, "sdxl-simple-example-organized.png"); + }); +}); diff --git a/tests/e2e/sdxl-simple-example.test.ts-snapshots/sdxl-simple-example-organized-linux.png b/tests/e2e/sdxl-simple-example.test.ts-snapshots/sdxl-simple-example-organized-linux.png new file mode 100644 index 0000000..590c00b Binary files /dev/null and b/tests/e2e/sdxl-simple-example.test.ts-snapshots/sdxl-simple-example-organized-linux.png differ diff --git a/tests/e2e/sdxl-simple-example.test.ts-snapshots/sdxl-simple-example-organized-win32.png b/tests/e2e/sdxl-simple-example.test.ts-snapshots/sdxl-simple-example-organized-win32.png new file mode 100644 index 0000000..602915a Binary files /dev/null and b/tests/e2e/sdxl-simple-example.test.ts-snapshots/sdxl-simple-example-organized-win32.png differ diff --git a/tests/e2e/settings-spacing.test.ts b/tests/e2e/settings-spacing.test.ts new file mode 100644 index 0000000..ac032a1 --- /dev/null +++ b/tests/e2e/settings-spacing.test.ts @@ -0,0 +1,105 @@ +import { test, expect } from "@playwright/test"; +import { SETTING_IDS } from "../../src/settings"; +import { DEFAULT_FRAMEWORK_CONFIG } from "../../src/layout/types"; +import { loadFixture } from "./fixtures"; +import { + waitForComfyUI, + loadWorkflow, + triggerOrganize, + setNumericSettingOverride, + clearNumericSettingOverrides, + extractSpacingMeasurements, +} from "./helpers"; + +test.describe("Spacing settings", () => { + async function applyDefaultSpacingOverrides( + page: import("@playwright/test").Page, + ): Promise { + await setNumericSettingOverride( + page, + SETTING_IDS.HORIZONTAL_GAP, + DEFAULT_FRAMEWORK_CONFIG.horizontalGap, + ); + await setNumericSettingOverride( + page, + SETTING_IDS.VERTICAL_GAP, + DEFAULT_FRAMEWORK_CONFIG.verticalGap, + ); + await setNumericSettingOverride( + page, + SETTING_IDS.GROUP_PADDING, + DEFAULT_FRAMEWORK_CONFIG.groupPadding, + ); + await setNumericSettingOverride( + page, + SETTING_IDS.DISCONNECTED_GAP, + DEFAULT_FRAMEWORK_CONFIG.disconnectedGap, + ); + } + + test.beforeEach(async ({ page }) => { + await page.goto("/"); + await waitForComfyUI(page); + await clearNumericSettingOverrides(page); + await applyDefaultSpacingOverrides(page); + await loadWorkflow(page, loadFixture("spacing-settings")); + }); + + test.afterEach(async ({ page }) => { + await clearNumericSettingOverrides(page); + }); + + test("horizontal gap setting increases consecutive layer spacing", async ({ + page, + }) => { + await setNumericSettingOverride(page, SETTING_IDS.HORIZONTAL_GAP, 40); + await triggerOrganize(page); + const compact = await extractSpacingMeasurements(page); + + await setNumericSettingOverride(page, SETTING_IDS.HORIZONTAL_GAP, 220); + await triggerOrganize(page); + const wide = await extractSpacingMeasurements(page); + + expect(wide.horizontalLayerGap).toBeGreaterThan(compact.horizontalLayerGap); + }); + + test("vertical gap setting increases main-layer sibling spacing", async ({ + page, + }) => { + await setNumericSettingOverride(page, SETTING_IDS.VERTICAL_GAP, 20); + await triggerOrganize(page); + const compact = await extractSpacingMeasurements(page); + + await setNumericSettingOverride(page, SETTING_IDS.VERTICAL_GAP, 180); + await triggerOrganize(page); + const roomy = await extractSpacingMeasurements(page); + + expect(roomy.verticalSiblingGap).toBeGreaterThan(compact.verticalSiblingGap); + }); + + test("group padding setting increases inner group margin", async ({ page }) => { + await setNumericSettingOverride(page, SETTING_IDS.GROUP_PADDING, 10); + await triggerOrganize(page); + const compact = await extractSpacingMeasurements(page); + + await setNumericSettingOverride(page, SETTING_IDS.GROUP_PADDING, 120); + await triggerOrganize(page); + const roomy = await extractSpacingMeasurements(page); + + expect(roomy.groupPadding).toBeGreaterThan(compact.groupPadding); + }); + + test("disconnected gap setting increases distance to disconnected node", async ({ + page, + }) => { + await setNumericSettingOverride(page, SETTING_IDS.DISCONNECTED_GAP, 40); + await triggerOrganize(page); + const compact = await extractSpacingMeasurements(page); + + await setNumericSettingOverride(page, SETTING_IDS.DISCONNECTED_GAP, 320); + await triggerOrganize(page); + const roomy = await extractSpacingMeasurements(page); + + expect(roomy.disconnectedGap).toBeGreaterThan(compact.disconnectedGap); + }); +}); diff --git a/tests/e2e/smoke.test.ts b/tests/e2e/smoke.test.ts new file mode 100644 index 0000000..c75a8ff --- /dev/null +++ b/tests/e2e/smoke.test.ts @@ -0,0 +1,189 @@ +import { test, expect } from "@playwright/test"; +import { + waitForComfyUI, + loadWorkflow, + extractGraphState, + triggerOrganize, + assertInvariants, + assertIdempotent, +} from "./helpers"; +import { loadFixture } from "./fixtures"; + +test.describe("Smoke tests", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + await waitForComfyUI(page); + }); + + test("ComfyUI loads with extension commands registered", async ({ + page, + }) => { + const extensionState = await page.evaluate(() => { + const w = window as unknown as Record; + const appObj = w.app as Record; + const em = appObj.extensionManager as Record; + const command = em.command as Record; + const commands = command.commands as Array<{ id: string }>; + const setting = em.setting as { + get: (id: string) => T | undefined; + }; + return { + hasOrganizeWorkflow: commands.some( + (c) => c.id === "node-organizer.organize-workflow", + ), + hasOrganizeGroups: commands.some( + (c) => c.id === "node-organizer.organize-groups", + ), + defaultAlgorithm: setting.get("Node Organizer.Default Algorithm"), + }; + }); + expect(extensionState.hasOrganizeWorkflow).toBe(true); + expect(extensionState.hasOrganizeGroups).toBe(true); + expect(extensionState.defaultAlgorithm).toBe("sugiyama"); + }); + + test("default workflow has nodes", async ({ page }) => { + const state = await extractGraphState(page); + expect(state.nodes.length).toBeGreaterThan(0); + expect(state.links.length).toBeGreaterThan(0); + }); + + test("Organize Workflow runs without error", async ({ page }) => { + await triggerOrganize(page); + const state = await extractGraphState(page); + + for (const node of state.nodes) { + expect(Number.isFinite(node.pos[0])).toBe(true); + expect(Number.isFinite(node.pos[1])).toBe(true); + } + }); + + test("Organize Workflow produces valid layout", async ({ page }) => { + await triggerOrganize(page); + const state = await extractGraphState(page); + assertInvariants(state); + }); + + test("Organize Workflow is idempotent", async ({ page }) => { + await triggerOrganize(page); + await assertIdempotent(page); + }); + + test("isGroup discriminator matches runtime default workflow", async ({ + page, + }) => { + const { groups, nodes } = await page.evaluate(() => { + const w = window as unknown as Record; + const appObj = w.app as Record; + const canvas = appObj.canvas as Record & { + getCurrentGraph?: () => Record | undefined; + }; + const graph = (canvas.getCurrentGraph?.() ?? + canvas.graph ?? + appObj.graph) as Record; + + const extractInfo = (item: Record) => ({ + id: item.id as number | string, + constructorName: (item.constructor as { name?: string } | undefined) + ?.name, + typeofType: typeof item.type, + hasStringTitle: typeof item.title === "string", + }); + + const rawGroups = + (graph._groups as Array> | undefined) ?? []; + const rawNodes = + (graph._nodes as Array> | undefined) ?? []; + + return { + groups: rawGroups.map(extractInfo), + nodes: rawNodes.map(extractInfo), + }; + }); + + // Every group must be identifiable by isGroup logic + for (const g of groups) { + const identifiable = + g.constructorName === "LGraphGroup" || + (g.hasStringTitle && g.typeofType !== "string"); + expect( + identifiable, + `Group ${String(g.id)} not identifiable: constructor=${g.constructorName}, typeofType=${g.typeofType}, hasStringTitle=${g.hasStringTitle}`, + ).toBe(true); + } + + // No node should be a false positive (matches duck-typing but is not a group) + for (const n of nodes) { + const falsePositive = + n.hasStringTitle && + n.typeofType !== "string" && + n.constructorName !== "LGraphGroup"; + expect( + falsePositive, + `Node ${String(n.id)} is a false positive: constructor=${n.constructorName}, typeofType=${n.typeofType}, hasStringTitle=${n.hasStringTitle}`, + ).toBe(false); + } + }); + + test("isGroup discriminator matches runtime grouped workflow", async ({ + page, + }) => { + const workflow = loadFixture("group-test"); + await loadWorkflow(page, workflow); + + const { groups, nodes } = await page.evaluate(() => { + const w = window as unknown as Record; + const appObj = w.app as Record; + const canvas = appObj.canvas as Record & { + getCurrentGraph?: () => Record | undefined; + }; + const graph = (canvas.getCurrentGraph?.() ?? + canvas.graph ?? + appObj.graph) as Record; + + const extractInfo = (item: Record) => ({ + id: item.id as number | string, + constructorName: (item.constructor as { name?: string } | undefined) + ?.name, + typeofType: typeof item.type, + hasStringTitle: typeof item.title === "string", + }); + + const rawGroups = + (graph._groups as Array> | undefined) ?? []; + const rawNodes = + (graph._nodes as Array> | undefined) ?? []; + + return { + groups: rawGroups.map(extractInfo), + nodes: rawNodes.map(extractInfo), + }; + }); + + // Must have groups in this fixture + expect(groups.length).toBeGreaterThan(0); + + // Every group must be identifiable by isGroup logic + for (const g of groups) { + const identifiable = + g.constructorName === "LGraphGroup" || + (g.hasStringTitle && g.typeofType !== "string"); + expect( + identifiable, + `Group ${String(g.id)} not identifiable: constructor=${g.constructorName}, typeofType=${g.typeofType}, hasStringTitle=${g.hasStringTitle}`, + ).toBe(true); + } + + // No node should be a false positive + for (const n of nodes) { + const falsePositive = + n.hasStringTitle && + n.typeofType !== "string" && + n.constructorName !== "LGraphGroup"; + expect( + falsePositive, + `Node ${String(n.id)} is a false positive: constructor=${n.constructorName}, typeofType=${n.typeofType}, hasStringTitle=${n.hasStringTitle}`, + ).toBe(false); + } + }); +}); diff --git a/tests/e2e/subgraph.test.ts b/tests/e2e/subgraph.test.ts new file mode 100644 index 0000000..ef00923 --- /dev/null +++ b/tests/e2e/subgraph.test.ts @@ -0,0 +1,273 @@ +import { test, expect } from "@playwright/test"; +import { + waitForComfyUI, + loadWorkflow, + triggerOrganize, + extractGraphState, +} from "./helpers"; +import { loadFixture } from "./fixtures"; +import { SETTING_IDS } from "../../src/settings"; + +test.describe("Subgraph layout", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + await waitForComfyUI(page); + }); + + test("organize keeps subgraph boundary nodes at the execution graph edges", async ({ + page, + }) => { + await loadWorkflow(page, loadFixture("subgraph-io")); + + const state = await page.evaluate(async () => { + const w = window as unknown as Record; + const appObj = w.app as Record; + const canvas = appObj.canvas as { + graph?: { + _nodes?: Array>; + }; + getCurrentGraph?: () => { + _nodes?: Array>; + inputNode?: Record; + outputNode?: Record; + links?: + | Map> + | Record>; + }; + openSubgraph: ( + subgraph: Record, + fromNode: Record, + ) => void; + }; + const rootGraph = (canvas.graph ?? + appObj.graph) as { _nodes?: Array> }; + const rootNodes = rootGraph._nodes ?? []; + const subgraphNode = rootNodes.find((n) => !!n.subgraph); + if (!subgraphNode?.subgraph) { + throw new Error("No subgraph node found in fixture"); + } + + canvas.openSubgraph(subgraphNode.subgraph as Record, subgraphNode); + const currentGraph = (canvas.getCurrentGraph?.() ?? + canvas.graph) as { + _nodes: Array>; + inputNode: Record; + outputNode: Record; + links: + | Map> + | Record>; + }; + + return { + rootNodeIds: rootNodes.map((n) => n.id as number), + subgraphNodeCount: currentGraph._nodes.length, + beforeInputX: Number((currentGraph.inputNode.pos as ArrayLike)[0]), + beforeOutputX: Number((currentGraph.outputNode.pos as ArrayLike)[0]), + }; + }); + + expect(state.subgraphNodeCount).toBeGreaterThan(0); + await triggerOrganize(page); + + const after = await page.evaluate(() => { + const w = window as unknown as Record; + const appObj = w.app as Record; + const canvas = appObj.canvas as { + graph?: { + _nodes?: Array>; + }; + getCurrentGraph?: () => { + _nodes?: Array>; + inputNode?: Record; + outputNode?: Record; + links?: + | Map> + | Record>; + }; + }; + const currentGraph = (canvas.getCurrentGraph?.() ?? + canvas.graph) as { + _nodes: Array>; + inputNode: Record; + outputNode: Record; + links: + | Map> + | Record>; + }; + + const pack = (node: Record) => { + const pos = node.pos as ArrayLike; + const size = node.size as ArrayLike; + return { + id: node.id as number, + x: Number(pos[0]), + y: Number(pos[1]), + width: Number(size[0]), + height: Number(size[1]), + }; + }; + + return { + inputNode: pack(currentGraph.inputNode), + outputNode: pack(currentGraph.outputNode), + nodes: currentGraph._nodes.map(pack), + links: + currentGraph.links instanceof Map + ? Array.from(currentGraph.links.values()).map((link) => ({ + origin_id: link.origin_id as number, + target_id: link.target_id as number, + })) + : Object.values( + currentGraph.links as Record>, + ).map((link) => ({ + origin_id: link.origin_id as number, + target_id: link.target_id as number, + })), + }; + }); + + const connectedNodeIds = new Set(); + for (const link of after.links) { + if (link.origin_id > 0) connectedNodeIds.add(link.origin_id); + if (link.target_id > 0) connectedNodeIds.add(link.target_id); + } + const connectedNodes = after.nodes.filter((node) => + connectedNodeIds.has(node.id), + ); + const leftmostNodeX = Math.min(...connectedNodes.map((node) => node.x)); + const rightmostNodeEdge = Math.max( + ...connectedNodes.map((node) => node.x + node.width), + ); + + expect(after.inputNode.x).toBeLessThanOrEqual(leftmostNodeX); + expect(after.outputNode.x + after.outputNode.width).toBeGreaterThanOrEqual( + rightmostNodeEdge, + ); + }); + + test("fit-to-view inside subgraph includes boundary nodes in bounds", async ({ + page, + }) => { + await loadWorkflow(page, loadFixture("subgraph-io")); + + // Enable fit-to-view + await page.evaluate((settingId: string) => { + const w = window as unknown as Record; + const appObj = w.app as Record; + const em = appObj.extensionManager as Record; + const setting = em.setting as { set: (id: string, value: boolean) => void }; + setting.set(settingId, true); + }, SETTING_IDS.FIT_TO_VIEW); + + // Navigate into the subgraph + await page.evaluate(() => { + const w = window as unknown as Record; + const appObj = w.app as Record; + const canvas = appObj.canvas as { + graph?: { _nodes?: Array> }; + openSubgraph: ( + subgraph: Record, + fromNode: Record, + ) => void; + }; + const rootGraph = (canvas.graph ?? + appObj.graph) as { _nodes?: Array> }; + const subgraphNode = (rootGraph._nodes ?? []).find((n) => !!n.subgraph); + if (!subgraphNode?.subgraph) { + throw new Error("No subgraph node found in fixture"); + } + canvas.openSubgraph(subgraphNode.subgraph as Record, subgraphNode); + }); + + // Organize inside the subgraph with fit-to-view enabled + await triggerOrganize(page); + await page.waitForTimeout(1500); + + // Get viewport scale and the actual node/boundary bounds + const result = await page.evaluate(() => { + const w = window as unknown as Record; + const appObj = w.app as Record; + const canvas = appObj.canvas as Record & { + getCurrentGraph?: () => Record; + ds: { scale: number }; + }; + const graph = (canvas.getCurrentGraph?.() ?? + canvas.graph) as { + _nodes: Array>; + inputNode?: Record; + outputNode?: Record; + }; + + const bounds = { minX: Infinity, maxX: -Infinity }; + for (const node of graph._nodes) { + const pos = node.pos as ArrayLike; + const size = node.size as ArrayLike; + const x = Number(pos[0]); + const right = x + Number(size[0]); + if (x < bounds.minX) bounds.minX = x; + if (right > bounds.maxX) bounds.maxX = right; + } + if (graph.inputNode) { + const pos = graph.inputNode.pos as ArrayLike; + const size = graph.inputNode.size as ArrayLike; + const x = Number(pos[0]); + const right = x + Number(size[0]); + if (x < bounds.minX) bounds.minX = x; + if (right > bounds.maxX) bounds.maxX = right; + } + if (graph.outputNode) { + const pos = graph.outputNode.pos as ArrayLike; + const size = graph.outputNode.size as ArrayLike; + const x = Number(pos[0]); + const right = x + Number(size[0]); + if (x < bounds.minX) bounds.minX = x; + if (right > bounds.maxX) bounds.maxX = right; + } + + return { + scale: canvas.ds.scale, + totalWidth: bounds.maxX - bounds.minX, + hasInputNode: !!graph.inputNode, + hasOutputNode: !!graph.outputNode, + }; + }); + + expect(result.hasInputNode).toBe(true); + expect(result.hasOutputNode).toBe(true); + // If boundary nodes are included in bounds, scale should be reasonable (< 2). + // Without them, the bounds are too small and scale is too high. + expect(result.scale).toBeLessThan(2); + }); + + test("extractGraphState includes subgraph boundary nodes", async ({ page }) => { + await loadWorkflow(page, loadFixture("subgraph-io")); + + await page.evaluate(() => { + const w = window as unknown as Record; + const appObj = w.app as Record; + const canvas = appObj.canvas as { + graph?: { + _nodes?: Array>; + }; + getCurrentGraph?: () => unknown; + openSubgraph: ( + subgraph: Record, + fromNode: Record, + ) => void; + }; + const rootGraph = (canvas.graph ?? + appObj.graph) as { _nodes?: Array> }; + const subgraphNode = (rootGraph._nodes ?? []).find((n) => !!n.subgraph); + if (!subgraphNode?.subgraph) { + throw new Error("No subgraph node found in fixture"); + } + canvas.openSubgraph(subgraphNode.subgraph as Record, subgraphNode); + }); + + const state = await extractGraphState(page); + const nodeIds = state.nodes.map((node) => node.id); + + expect(nodeIds).toContain(-10); + expect(nodeIds).toContain(-20); + }); +}); diff --git a/tests/e2e/tokens.test.ts b/tests/e2e/tokens.test.ts new file mode 100644 index 0000000..e05e31b --- /dev/null +++ b/tests/e2e/tokens.test.ts @@ -0,0 +1,88 @@ +import { test, expect } from "@playwright/test"; +import { + waitForComfyUI, + loadWorkflow, + extractGraphState, + extractGroupMemberships, + triggerOrganize, +} from "./helpers"; +import { loadFixture } from "./fixtures"; + +test.describe("Token Layout", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + await waitForComfyUI(page); + }); + + test("token-testing: organizes and all coordinates finite", async ({ + page, + }) => { + const workflow = loadFixture("token-testing"); + await loadWorkflow(page, workflow); + await triggerOrganize(page); + + const state = await extractGraphState(page); + for (const node of state.nodes) { + expect(Number.isFinite(node.pos[0])).toBe(true); + expect(Number.isFinite(node.pos[1])).toBe(true); + } + }); + + test("token-testing: groups sized to fit after organize", async ({ + page, + }) => { + const workflow = loadFixture("token-testing"); + await loadWorkflow(page, workflow); + await triggerOrganize(page); + + const state = await extractGraphState(page); + // All groups should have positive dimensions + for (const g of state.groups) { + expect(g.size[0]).toBeGreaterThan(0); + expect(g.size[1]).toBeGreaterThan(0); + } + }); + + test("token-testing: idempotent", async ({ page }) => { + const workflow = loadFixture("token-testing"); + await loadWorkflow(page, workflow); + await triggerOrganize(page); + + const state1 = await extractGraphState(page); + + // Organize again via command + await page.evaluate(() => { + const w = window as unknown as Record; + const appObj = w.app as Record; + const em = appObj.extensionManager as Record; + const command = em.command as { execute: (id: string) => void }; + command.execute("node-organizer.organize-workflow"); + }); + await page.waitForTimeout(1000); + + const state2 = await extractGraphState(page); + + // Positions should be identical within tolerance + for (const n1 of state1.nodes) { + const n2 = state2.nodes.find((n) => n.id === n1.id); + expect(n2).toBeDefined(); + if (n2) { + expect(Math.abs(n1.pos[0] - n2.pos[0])).toBeLessThan(2); + expect(Math.abs(n1.pos[1] - n2.pos[1])).toBeLessThan(2); + } + } + }); + + test("token-testing: preserves group hierarchy and direct memberships", async ({ + page, + }) => { + const workflow = loadFixture("token-testing"); + await loadWorkflow(page, workflow); + + const beforeMemberships = await extractGroupMemberships(page); + await triggerOrganize(page); + const afterMemberships = await extractGroupMemberships(page); + + expect(afterMemberships).toEqual(beforeMemberships); + }); +}); diff --git a/tests/e2e/ui-smoke.test.ts b/tests/e2e/ui-smoke.test.ts new file mode 100644 index 0000000..84a1a13 --- /dev/null +++ b/tests/e2e/ui-smoke.test.ts @@ -0,0 +1,352 @@ +import { readFileSync } from "node:fs"; +import { test, expect } from "@playwright/test"; +import { + waitForComfyUI, + loadWorkflow, + extractGraphState, + triggerOrganize, + triggerOrganizeGroup, +} from "./helpers"; +import { loadFixture } from "./fixtures"; +import { SETTING_IDS } from "../../src/settings"; + +const packageVersion = JSON.parse( + readFileSync(new URL("../../package.json", import.meta.url), "utf-8"), +) as { version: string }; + +function didAnyNodeMove( + before: Awaited>, + after: Awaited>, +): boolean { + for (const node of before.nodes) { + const next = after.nodes.find((candidate) => candidate.id === node.id); + if (!next) continue; + if ( + Math.abs(node.pos[0] - next.pos[0]) > 1 || + Math.abs(node.pos[1] - next.pos[1]) > 1 + ) { + return true; + } + } + return false; +} + +test.describe("UI smoke", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + await waitForComfyUI(page); + await loadWorkflow(page, loadFixture("simple-dag")); + }); + + test("action bar organize button runs layout", async ({ page }) => { + const before = await extractGraphState(page); + await page.getByRole("button", { name: "Organize" }).click(); + await page.waitForTimeout(1000); + const after = await extractGraphState(page); + + expect(didAnyNodeMove(before, after)).toBe(true); + }); + + test("Shift+O keybinding runs layout", async ({ page }) => { + const before = await extractGraphState(page); + await page.keyboard.press("Shift+O"); + await page.waitForTimeout(1000); + const after = await extractGraphState(page); + + expect(didAnyNodeMove(before, after)).toBe(true); + }); + + test("context-aware Shift+O organizes selected groups when groups are selected", async ({ page }) => { + await loadWorkflow(page, loadFixture("group-test-simple")); + + const groupTitles = await page.evaluate(() => { + const w = window as unknown as Record; + const appObj = w.app as Record; + const canvas = appObj.canvas as Record & { + getCurrentGraph?: () => Record | undefined; + }; + const graph = (canvas.getCurrentGraph?.() ?? + canvas.graph ?? + appObj.graph) as { _groups: Array<{ title: string }> }; + return graph._groups.map((group) => group.title); + }); + + expect(groupTitles.length).toBeGreaterThan(0); + + // Select the first group programmatically + await page.evaluate((title: string) => { + const w = window as unknown as Record; + const appObj = w.app as Record; + const canvas = appObj.canvas as Record & { + getCurrentGraph?: () => Record | undefined; + }; + const graph = (canvas.getCurrentGraph?.() ?? + canvas.graph) as Record; + const groups = graph._groups as Array>; + const group = groups.find((g) => g.title === title); + if (!group) throw new Error(`Group "${title}" not found`); + canvas.selectedItems = new Set([group]); + }, groupTitles[0]); + + const before = await extractGraphState(page); + // Use Shift+O — should organize the selected group, not the full workflow + await page.keyboard.press("Shift+O"); + await page.waitForTimeout(1000); + const after = await extractGraphState(page); + + expect(didAnyNodeMove(before, after)).toBe(true); + }); + + test("Organize Group runs layout on the selected group", async ({ page }) => { + await loadWorkflow(page, loadFixture("group-test-simple")); + + const groupTitles = await page.evaluate(() => { + const w = window as unknown as Record; + const appObj = w.app as Record; + const canvas = appObj.canvas as Record & { + getCurrentGraph?: () => Record | undefined; + }; + const graph = (canvas.getCurrentGraph?.() ?? + canvas.graph ?? + appObj.graph) as { _groups: Array<{ title: string }> }; + return graph._groups.map((group) => group.title); + }); + + expect(groupTitles.length).toBeGreaterThan(0); + + const before = await extractGraphState(page); + await triggerOrganizeGroup(page, groupTitles[0]); + const after = await extractGraphState(page); + + expect(didAnyNodeMove(before, after)).toBe(true); + }); +}); + +test.describe("Settings panel", () => { + async function openNodeOrganizerSettings(page: import("@playwright/test").Page): Promise { + // The sidebar settings button includes the keyboard shortcut in its label + await page.getByRole("button", { name: /Settings \(Ctrl/ }).click(); + await page.waitForTimeout(500); + // Navigate to our extension's settings section + await page.getByText("Node Organizer", { exact: true }).click(); + await page.waitForTimeout(500); + } + + test.beforeEach(async ({ page }) => { + await page.goto("/"); + await waitForComfyUI(page); + }); + + test("Node Organizer appears in settings sidebar", async ({ page }) => { + await page.getByRole("button", { name: /Settings \(Ctrl/ }).click(); + await expect( + page.getByText("Node Organizer", { exact: true }), + ).toBeVisible(); + }); + + test("settings panel shows About section with version and homepage", async ({ page }) => { + await openNodeOrganizerSettings(page); + await expect( + page.getByText(`Version ${packageVersion.version}`), + ).toBeVisible(); + await expect(page.getByRole("link", { name: "Homepage" })).toBeVisible(); + }); + + test("settings panel shows Layout section with sliders", async ({ page }) => { + await openNodeOrganizerSettings(page); + await expect(page.getByText("Horizontal Gap")).toBeVisible(); + await expect(page.getByText("Vertical Gap")).toBeVisible(); + await expect(page.getByText("Group Padding")).toBeVisible(); + await expect(page.getByText("Disconnected Node Gap")).toBeVisible(); + }); + + test("settings panel shows Behavior and Keybindings sections", async ({ page }) => { + await openNodeOrganizerSettings(page); + await expect( + page.getByText("Fit to View After Organize"), + ).toBeVisible(); + await expect(page.getByText("Keyboard Shortcuts")).toBeVisible(); + }); + + test("settings sections appear in correct order", async ({ page }) => { + await openNodeOrganizerSettings(page); + + // Collect all visible text and check the relative order of section headings + const allText = await page.locator("body").innerText(); + const sectionNames = ["About", "Layout", "Behavior", "Keybindings", "Advanced"]; + const positions = sectionNames.map((name) => ({ + name, + pos: allText.indexOf(name), + })); + + // All sections should be found + for (const { name, pos } of positions) { + expect(pos, `Section "${name}" not found in settings panel`).toBeGreaterThanOrEqual(0); + } + + // Verify ordering + for (let i = 1; i < positions.length; i++) { + expect( + positions[i].pos, + `"${positions[i].name}" should appear after "${positions[i - 1].name}"`, + ).toBeGreaterThan(positions[i - 1].pos); + } + }); +}); + +test.describe("Fit to view after organize", () => { + async function enableFitToView(page: import("@playwright/test").Page): Promise { + await page.evaluate((settingId: string) => { + const w = window as unknown as Record; + const appObj = w.app as Record; + const em = appObj.extensionManager as Record; + const setting = em.setting as { set: (id: string, value: boolean) => void }; + setting.set(settingId, true); + }, SETTING_IDS.FIT_TO_VIEW); + } + + async function getViewport(page: import("@playwright/test").Page) { + return page.evaluate(() => { + const w = window as unknown as Record; + const appObj = w.app as Record; + const canvas = appObj.canvas as Record; + const ds = canvas.ds as { offset: [number, number]; scale: number }; + return { offset: [...ds.offset], scale: ds.scale }; + }); + } + + test("viewport changes after organizing full workflow", async ({ page }) => { + await page.goto("/"); + await waitForComfyUI(page); + await loadWorkflow(page, loadFixture("simple-dag")); + await enableFitToView(page); + + const viewportBefore = await getViewport(page); + + await triggerOrganize(page); + await page.waitForTimeout(1500); + + const viewportAfter = await getViewport(page); + + const offsetChanged = + Math.abs(viewportBefore.offset[0] - viewportAfter.offset[0]) > 1 || + Math.abs(viewportBefore.offset[1] - viewportAfter.offset[1]) > 1; + const scaleChanged = + Math.abs(viewportBefore.scale - viewportAfter.scale) > 0.01; + + expect(offsetChanged || scaleChanged).toBe(true); + }); + + test("fit-to-view fits entire graph, not just selected node", async ({ page }) => { + await page.goto("/"); + await waitForComfyUI(page); + await loadWorkflow(page, loadFixture("simple-dag")); + await enableFitToView(page); + + // Select a single node (last node in the graph) + await page.evaluate(() => { + const w = window as unknown as Record; + const appObj = w.app as Record; + const canvas = appObj.canvas as Record & { + getCurrentGraph?: () => Record | undefined; + }; + const graph = (canvas.getCurrentGraph?.() ?? + canvas.graph) as Record; + const nodes = graph._nodes as Array>; + if (nodes.length > 0) { + canvas.selectedItems = new Set([nodes[nodes.length - 1]]); + } + }); + + await triggerOrganize(page); + await page.waitForTimeout(1500); + + const viewport = await getViewport(page); + + // With fit-to-all-items, the scale should be < 2 (fitting the whole graph). + // If it zoomed to a single selected node, scale would be much larger (e.g. 3+). + // This threshold catches the bug where a selected node is zoomed into. + expect(viewport.scale).toBeLessThan(2); + }); + + test("fit-to-view does not run for selected-group organize", async ({ page }) => { + await page.goto("/"); + await waitForComfyUI(page); + await loadWorkflow(page, loadFixture("group-test-simple")); + await enableFitToView(page); + + // Organize full workflow first to get a stable viewport + await triggerOrganize(page); + await page.waitForTimeout(1500); + + const viewportBefore = await getViewport(page); + + const groupTitle = await page.evaluate(() => { + const w = window as unknown as Record; + const appObj = w.app as Record; + const canvas = appObj.canvas as Record & { + getCurrentGraph?: () => Record | undefined; + }; + const graph = (canvas.getCurrentGraph?.() ?? + canvas.graph ?? + appObj.graph) as { _groups: Array<{ title: string }> }; + return graph._groups[0]?.title ?? null; + }); + + if (groupTitle) { + await triggerOrganizeGroup(page, groupTitle); + await page.waitForTimeout(500); + + const viewportAfter = await getViewport(page); + + const offsetChanged = + Math.abs(viewportBefore.offset[0] - viewportAfter.offset[0]) > 1 || + Math.abs(viewportBefore.offset[1] - viewportAfter.offset[1]) > 1; + const scaleChanged = + Math.abs(viewportBefore.scale - viewportAfter.scale) > 0.01; + + expect(offsetChanged || scaleChanged).toBe(false); + } + }); +}); + +test.describe("Spacing settings affect layout", () => { + test("custom disconnectedGap changes disconnected node placement", async ({ page }) => { + await page.goto("/"); + await waitForComfyUI(page); + await loadWorkflow(page, loadFixture("simple-dag")); + + // Organize with default settings + await triggerOrganize(page); + await page.waitForTimeout(1500); + const defaultState = await extractGraphState(page); + + // Set a very large disconnected gap + await page.evaluate((settingId: string) => { + const w = window as unknown as Record; + const appObj = w.app as Record; + const em = appObj.extensionManager as Record; + const setting = em.setting as { set: (id: string, value: number) => void }; + setting.set(settingId, 400); + }, SETTING_IDS.DISCONNECTED_GAP); + + // Re-organize with the new setting + await triggerOrganize(page); + await page.waitForTimeout(1500); + const customState = await extractGraphState(page); + + // If there are any disconnected nodes, their positions should differ + // For workflows where all nodes are connected, positions are the same + // This test verifies the setting is read — not that every fixture has disconnected nodes + expect(customState.nodes.length).toBe(defaultState.nodes.length); + + // Reset setting + await page.evaluate((settingId: string) => { + const w = window as unknown as Record; + const appObj = w.app as Record; + const em = appObj.extensionManager as Record; + const setting = em.setting as { set: (id: string, value: number) => void }; + setting.set(settingId, 150); + }, SETTING_IDS.DISCONNECTED_GAP); + }); +}); diff --git a/tests/e2e/visual-regression.test.ts b/tests/e2e/visual-regression.test.ts new file mode 100644 index 0000000..5c3bde9 --- /dev/null +++ b/tests/e2e/visual-regression.test.ts @@ -0,0 +1,65 @@ +import { test } from "@playwright/test"; +import type { Page } from "@playwright/test"; +import { + waitForComfyUI, + loadWorkflow, + triggerOrganize, + expectGraphCanvasScreenshot, + setBooleanSetting, +} from "./helpers"; +import { loadFixture } from "./fixtures"; +import { SETTING_IDS } from "../../src/settings"; + +async function openFirstSubgraph(page: Page) { + await page.evaluate(() => { + const w = window as unknown as Record; + const appObj = w.app as Record; + const canvas = appObj.canvas as { + graph?: { _nodes?: Array> }; + openSubgraph: ( + subgraph: Record, + fromNode: Record, + ) => void; + }; + const rootGraph = (canvas.graph ?? + appObj.graph) as { _nodes?: Array> }; + const subgraphNode = (rootGraph._nodes ?? []).find((node) => !!node.subgraph); + if (!subgraphNode?.subgraph) { + throw new Error("No subgraph node found in fixture"); + } + canvas.openSubgraph(subgraphNode.subgraph as Record, subgraphNode); + }); +} + +test.describe("Visual Regression", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + await waitForComfyUI(page); + await setBooleanSetting(page, SETTING_IDS.FIT_TO_VIEW, false); + }); + + test("simple-dag graph canvas after organize", async ({ page }) => { + await loadWorkflow(page, loadFixture("simple-dag")); + await triggerOrganize(page); + await expectGraphCanvasScreenshot(page, "simple-dag-organized.png"); + }); + + test("nested-groups graph canvas after organize", async ({ page }) => { + await loadWorkflow(page, loadFixture("nested-groups")); + await triggerOrganize(page); + await expectGraphCanvasScreenshot(page, "nested-groups-organized.png"); + }); + + test("token-testing graph canvas after organize", async ({ page }) => { + await loadWorkflow(page, loadFixture("token-testing")); + await triggerOrganize(page); + await expectGraphCanvasScreenshot(page, "token-testing-organized.png"); + }); + + test("subgraph graph canvas after organize", async ({ page }) => { + await loadWorkflow(page, loadFixture("subgraph-io")); + await openFirstSubgraph(page); + await triggerOrganize(page); + await expectGraphCanvasScreenshot(page, "subgraph-io-organized.png"); + }); +}); diff --git a/tests/e2e/visual-regression.test.ts-snapshots/nested-groups-organized-linux.png b/tests/e2e/visual-regression.test.ts-snapshots/nested-groups-organized-linux.png new file mode 100644 index 0000000..c0a79c1 Binary files /dev/null and b/tests/e2e/visual-regression.test.ts-snapshots/nested-groups-organized-linux.png differ diff --git a/tests/e2e/visual-regression.test.ts-snapshots/nested-groups-organized-win32.png b/tests/e2e/visual-regression.test.ts-snapshots/nested-groups-organized-win32.png new file mode 100644 index 0000000..59c567c Binary files /dev/null and b/tests/e2e/visual-regression.test.ts-snapshots/nested-groups-organized-win32.png differ diff --git a/tests/e2e/visual-regression.test.ts-snapshots/simple-dag-organized-linux.png b/tests/e2e/visual-regression.test.ts-snapshots/simple-dag-organized-linux.png new file mode 100644 index 0000000..349350a Binary files /dev/null and b/tests/e2e/visual-regression.test.ts-snapshots/simple-dag-organized-linux.png differ diff --git a/tests/e2e/visual-regression.test.ts-snapshots/simple-dag-organized-win32.png b/tests/e2e/visual-regression.test.ts-snapshots/simple-dag-organized-win32.png new file mode 100644 index 0000000..7d3db48 Binary files /dev/null and b/tests/e2e/visual-regression.test.ts-snapshots/simple-dag-organized-win32.png differ diff --git a/tests/e2e/visual-regression.test.ts-snapshots/subgraph-io-organized-linux.png b/tests/e2e/visual-regression.test.ts-snapshots/subgraph-io-organized-linux.png new file mode 100644 index 0000000..1b4251d Binary files /dev/null and b/tests/e2e/visual-regression.test.ts-snapshots/subgraph-io-organized-linux.png differ diff --git a/tests/e2e/visual-regression.test.ts-snapshots/subgraph-io-organized-win32.png b/tests/e2e/visual-regression.test.ts-snapshots/subgraph-io-organized-win32.png new file mode 100644 index 0000000..dfd4d5a Binary files /dev/null and b/tests/e2e/visual-regression.test.ts-snapshots/subgraph-io-organized-win32.png differ diff --git a/tests/e2e/visual-regression.test.ts-snapshots/token-testing-organized-linux.png b/tests/e2e/visual-regression.test.ts-snapshots/token-testing-organized-linux.png new file mode 100644 index 0000000..367a84b Binary files /dev/null and b/tests/e2e/visual-regression.test.ts-snapshots/token-testing-organized-linux.png differ diff --git a/tests/e2e/visual-regression.test.ts-snapshots/token-testing-organized-win32.png b/tests/e2e/visual-regression.test.ts-snapshots/token-testing-organized-win32.png new file mode 100644 index 0000000..eb1bb65 Binary files /dev/null and b/tests/e2e/visual-regression.test.ts-snapshots/token-testing-organized-win32.png differ diff --git a/tests/fixtures/sdxl_simple_example.json b/tests/fixtures/sdxl_simple_example.json new file mode 100644 index 0000000..93cb633 --- /dev/null +++ b/tests/fixtures/sdxl_simple_example.json @@ -0,0 +1,1346 @@ +{ + "id": "88bcf757-1298-432f-885a-db5ab88cf224", + "revision": 0, + "last_node_id": 52, + "last_link_id": 48, + "nodes": [ + { + "id": 5, + "type": "EmptyLatentImage", + "pos": [ + -95.59717559814453, + -571.0374755859375 + ], + "size": [ + 300, + 110 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "slot_index": 0, + "links": [ + 27 + ] + } + ], + "properties": { + "Node name for S&R": "EmptyLatentImage", + "cnr_id": "comfy-core", + "ver": "0.3.33" + }, + "widgets_values": [ + 1024, + 1024, + 1 + ], + "color": "#323", + "bgcolor": "#535" + }, + { + "id": 42, + "type": "Note", + "pos": [ + -75.59688568115234, + -421.0376281738281 + ], + "size": [ + 260, + 210 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [], + "title": "Note - Empty Latent Image", + "properties": { + "text": "" + }, + "widgets_values": [ + "This node sets the image's resolution in Width and Height.\n\nNOTE: For SDXL, it is recommended to use trained values listed below:\n - 1024 x 1024\n - 1152 x 896\n - 896 x 1152\n - 1216 x 832\n - 832 x 1216\n - 1344 x 768\n - 768 x 1344\n - 1536 x 640\n - 640 x 1536" + ], + "color": "#323", + "bgcolor": "#000" + }, + { + "id": 4, + "type": "CheckpointLoaderSimple", + "pos": [ + -90, + -50 + ], + "size": [ + 350, + 100 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "slot_index": 0, + "links": [ + 10 + ] + }, + { + "name": "CLIP", + "type": "CLIP", + "slot_index": 1, + "links": [ + 3, + 5 + ] + }, + { + "name": "VAE", + "type": "VAE", + "slot_index": 2, + "links": [] + } + ], + "title": "Load Checkpoint - BASE", + "properties": { + "Node name for S&R": "CheckpointLoaderSimple", + "cnr_id": "comfy-core", + "ver": "0.3.33", + "models": [ + { + "name": "sd_xl_base_1.0.safetensors", + "url": "https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0/resolve/main/sd_xl_base_1.0.safetensors?download=true", + "directory": "checkpoints" + } + ] + }, + "widgets_values": [ + "sd_xl_base_1.0.safetensors" + ], + "color": "#323", + "bgcolor": "#535" + }, + { + "id": 45, + "type": "PrimitiveNode", + "pos": [ + 295.5135192871094, + -570.4500122070312 + ], + "size": [ + 210, + 82 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "INT", + "type": "INT", + "widget": { + "name": "steps" + }, + "links": [ + 38, + 41 + ] + } + ], + "title": "steps", + "properties": { + "Run widget replace on values": false + }, + "widgets_values": [ + 25, + "fixed" + ], + "color": "#432", + "bgcolor": "#653" + }, + { + "id": 47, + "type": "PrimitiveNode", + "pos": [ + 296.2197570800781, + -439.43743896484375 + ], + "size": [ + 210, + 82 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "INT", + "type": "INT", + "widget": { + "name": "end_at_step" + }, + "slot_index": 0, + "links": [ + 43, + 44 + ] + } + ], + "title": "end_at_step", + "properties": { + "Run widget replace on values": false + }, + "widgets_values": [ + 20, + "fixed" + ], + "color": "#432", + "bgcolor": "#653" + }, + { + "id": 48, + "type": "Note", + "pos": [ + 295.3782043457031, + -303.24786376953125 + ], + "size": [ + 213.91000366210938, + 110.16999816894531 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [], + "outputs": [], + "properties": { + "text": "" + }, + "widgets_values": [ + "These can be used to control the total sampling steps and the step at which the sampling switches to the refiner." + ], + "color": "#432", + "bgcolor": "#000" + }, + { + "id": 17, + "type": "VAEDecode", + "pos": [ + 1197.0247802734375, + 599.3170166015625 + ], + "size": [ + 200, + 50 + ], + "flags": {}, + "order": 23, + "mode": 0, + "inputs": [ + { + "name": "samples", + "type": "LATENT", + "link": 25 + }, + { + "name": "vae", + "type": "VAE", + "link": 34 + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "slot_index": 0, + "links": [ + 28 + ] + } + ], + "properties": { + "Node name for S&R": "VAEDecode", + "cnr_id": "comfy-core", + "ver": "0.3.33" + }, + "widgets_values": [], + "color": "#332922", + "bgcolor": "#593930" + }, + { + "id": 41, + "type": "Note", + "pos": [ + 1195.4132080078125, + 699.4724731445312 + ], + "size": [ + 312.02081298828125, + 120 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [], + "outputs": [], + "title": "Note - VAE Decoder", + "properties": { + "text": "" + }, + "widgets_values": [ + "This node will take the latent data from the KSampler and, using the VAE, it will decode it into visible data\n\nVAE = Latent --> Visible\n\nThis can then be sent to the Save Image node to be saved as a PNG." + ], + "color": "#332922", + "bgcolor": "#000" + }, + { + "id": 7, + "type": "CLIPTextEncode", + "pos": [ + 311.707763671875, + 121.12776947021484 + ], + "size": [ + 397.553466796875, + 142.57814025878906 + ], + "flags": {}, + "order": 19, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 5 + }, + { + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": 46 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "slot_index": 0, + "links": [ + 12 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode", + "cnr_id": "comfy-core", + "ver": "0.3.33" + }, + "widgets_values": [ + "text, watermark" + ], + "color": "#322", + "bgcolor": "#533" + }, + { + "id": 6, + "type": "CLIPTextEncode", + "pos": [ + 301.707763671875, + -48.872215270996094 + ], + "size": [ + 405.0923156738281, + 124.19273376464844 + ], + "flags": {}, + "order": 17, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 3 + }, + { + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": 45 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "slot_index": 0, + "links": [ + 11 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode", + "cnr_id": "comfy-core", + "ver": "0.3.33" + }, + "widgets_values": [ + "evening sunset scenery blue sky nature, glass bottle with a galaxy in it" + ], + "color": "#232", + "bgcolor": "#353" + }, + { + "id": 36, + "type": "Note", + "pos": [ + -70, + 100 + ], + "size": [ + 315.70001220703125, + 147.9600067138672 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [], + "outputs": [], + "title": "Note - Load Checkpoint BASE", + "properties": { + "text": "" + }, + "widgets_values": [ + "This is a checkpoint model loader. \n - This is set up automatically with the optimal settings for whatever SD model version you choose to use.\n - In this example, it is for the Base SDXL model\n - This node is also used for SD1.5 and SD2.x models\n \nNOTE: When loading in another person's workflow, be sure to manually choose your own *local* model. This also applies to LoRas and all their deviations" + ], + "color": "#432", + "bgcolor": "#000" + }, + { + "id": 10, + "type": "KSamplerAdvanced", + "pos": [ + 808.7730102539062, + -57.895355224609375 + ], + "size": [ + 300, + 334 + ], + "flags": {}, + "order": 21, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 10 + }, + { + "name": "positive", + "type": "CONDITIONING", + "link": 11 + }, + { + "name": "negative", + "type": "CONDITIONING", + "link": 12 + }, + { + "name": "latent_image", + "type": "LATENT", + "link": 27 + }, + { + "name": "steps", + "type": "INT", + "widget": { + "name": "steps" + }, + "link": 41 + }, + { + "name": "end_at_step", + "type": "INT", + "widget": { + "name": "end_at_step" + }, + "link": 43 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "slot_index": 0, + "links": [ + 13 + ] + } + ], + "title": "KSampler (Advanced) - BASE", + "properties": { + "Node name for S&R": "KSamplerAdvanced", + "cnr_id": "comfy-core", + "ver": "0.3.33" + }, + "widgets_values": [ + "enable", + 721897303308196, + "randomize", + 25, + 8, + "euler", + "normal", + 0, + 20, + "enable" + ] + }, + { + "id": 15, + "type": "CLIPTextEncode", + "pos": [ + 316.0187072753906, + 637.33154296875 + ], + "size": [ + 391.591552734375, + 148.68138122558594 + ], + "flags": {}, + "order": 18, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 19 + }, + { + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": 47 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "slot_index": 0, + "links": [ + 23 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode", + "cnr_id": "comfy-core", + "ver": "0.3.33" + }, + "widgets_values": [ + "evening sunset scenery blue sky nature, glass bottle with a galaxy in it" + ], + "color": "#232", + "bgcolor": "#353" + }, + { + "id": 16, + "type": "CLIPTextEncode", + "pos": [ + 315.9026794433594, + 837.3108520507812 + ], + "size": [ + 388.5469970703125, + 121.34178161621094 + ], + "flags": {}, + "order": 20, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 20 + }, + { + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": 48 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "slot_index": 0, + "links": [ + 24 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode", + "cnr_id": "comfy-core", + "ver": "0.3.33" + }, + "widgets_values": [ + "text, watermark" + ], + "color": "#322", + "bgcolor": "#533" + }, + { + "id": 11, + "type": "KSamplerAdvanced", + "pos": [ + 819.1201782226562, + 608.375 + ], + "size": [ + 300, + 340 + ], + "flags": {}, + "order": 22, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 14 + }, + { + "name": "positive", + "type": "CONDITIONING", + "link": 23 + }, + { + "name": "negative", + "type": "CONDITIONING", + "link": 24 + }, + { + "name": "latent_image", + "type": "LATENT", + "link": 13 + }, + { + "name": "steps", + "type": "INT", + "widget": { + "name": "steps" + }, + "link": 38 + }, + { + "name": "start_at_step", + "type": "INT", + "widget": { + "name": "start_at_step" + }, + "link": 44 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "slot_index": 0, + "links": [ + 25 + ] + } + ], + "title": "KSampler (Advanced) - REFINER", + "properties": { + "Node name for S&R": "KSamplerAdvanced", + "cnr_id": "comfy-core", + "ver": "0.3.33" + }, + "widgets_values": [ + "disable", + 0, + "fixed", + 25, + 8, + "euler", + "normal", + 20, + 10000, + "disable" + ] + }, + { + "id": 12, + "type": "CheckpointLoaderSimple", + "pos": [ + -80.37397003173828, + 658.974609375 + ], + "size": [ + 325.4814453125, + 99.73925018310547 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "slot_index": 0, + "links": [ + 14 + ] + }, + { + "name": "CLIP", + "type": "CLIP", + "slot_index": 1, + "links": [ + 19, + 20 + ] + }, + { + "name": "VAE", + "type": "VAE", + "slot_index": 2, + "links": [ + 34 + ] + } + ], + "title": "Load Checkpoint - REFINER", + "properties": { + "Node name for S&R": "CheckpointLoaderSimple", + "cnr_id": "comfy-core", + "ver": "0.3.33", + "models": [ + { + "name": "sd_xl_refiner_1.0.safetensors", + "url": "https://huggingface.co/stabilityai/stable-diffusion-xl-refiner-1.0/resolve/main/sd_xl_refiner_1.0.safetensors?download=true", + "directory": "checkpoints" + } + ] + }, + "widgets_values": [ + "sd_xl_refiner_1.0.safetensors" + ], + "color": "#323", + "bgcolor": "#535" + }, + { + "id": 37, + "type": "Note", + "pos": [ + -75.28781127929688, + 814.1362915039062 + ], + "size": [ + 320.29852294921875, + 140.3228302001953 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [], + "outputs": [], + "title": "Note - Load Checkpoint REFINER", + "properties": { + "text": "" + }, + "widgets_values": [ + "This is a checkpoint model loader. \n - This is set up automatically with the optimal settings for whatever SD model version you choose to use.\n - In this example, it is for the Refiner SDXL model\n\nNOTE: When loading in another person's workflow, be sure to manually choose your own *local* model. This also applies to LoRas and all their deviations." + ], + "color": "#432", + "bgcolor": "#000" + }, + { + "id": 52, + "type": "Note", + "pos": [ + -449.9957580566406, + 322.2831726074219 + ], + "size": [ + 284.3299865722656, + 123.88999938964844 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [], + "outputs": [], + "title": "Note - Text Prompts", + "properties": { + "text": "" + }, + "widgets_values": [ + "These nodes are where you include the text for:\n - what you want in the picture (Positive Prompt, Green)\n - or what you don't want in the picture (Negative Prompt, Red)\n\nThis node type is called a \"PrimitiveNode\" if you are searching for the node type." + ], + "color": "#323", + "bgcolor": "#000" + }, + { + "id": 51, + "type": "PrimitiveNode", + "pos": [ + -458.9957580566406, + -76.71602630615234 + ], + "size": [ + 300, + 160 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "STRING", + "type": "STRING", + "widget": { + "name": "text" + }, + "slot_index": 0, + "links": [ + 45, + 47 + ] + } + ], + "title": "Positive Prompt (Text)", + "properties": { + "Run widget replace on values": false + }, + "widgets_values": [ + "evening sunset scenery blue sky nature, glass bottle with a galaxy in it" + ], + "color": "#232", + "bgcolor": "#353" + }, + { + "id": 50, + "type": "PrimitiveNode", + "pos": [ + -458.9957580566406, + 123.28369903564453 + ], + "size": [ + 300, + 160 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "STRING", + "type": "STRING", + "widget": { + "name": "text" + }, + "slot_index": 0, + "links": [ + 46, + 48 + ] + } + ], + "title": "Negative Prompt (Text)", + "properties": { + "Run widget replace on values": false + }, + "widgets_values": [ + "text, watermark" + ], + "color": "#322", + "bgcolor": "#533" + }, + { + "id": 43, + "type": "Note", + "pos": [ + 319.5082702636719, + 1008.0806274414062 + ], + "size": [ + 382.79046630859375, + 111.91925048828125 + ], + "flags": {}, + "order": 13, + "mode": 0, + "inputs": [], + "outputs": [], + "title": "Note - CLIP Encode (REFINER)", + "properties": { + "text": "" + }, + "widgets_values": [ + "These nodes receive the text from the prompt and use the optimal CLIP settings for the specified checkpoint model (in this case: SDXL Refiner)" + ], + "color": "#432", + "bgcolor": "#000" + }, + { + "id": 39, + "type": "Note", + "pos": [ + 311.707763671875, + 311.12799072265625 + ], + "size": [ + 390.1957702636719, + 149.67234802246094 + ], + "flags": {}, + "order": 14, + "mode": 0, + "inputs": [], + "outputs": [], + "title": "Note - CLIP Encode (BASE)", + "properties": { + "text": "" + }, + "widgets_values": [ + "These nodes receive the text from the prompt and use the optimal CLIP settings for the specified checkpoint model (in this case: SDXL Base)" + ], + "color": "#432", + "bgcolor": "#000" + }, + { + "id": 49, + "type": "MarkdownNote", + "pos": [ + -386.6277770996094, + -321.32781982421875 + ], + "size": [ + 225, + 88 + ], + "flags": {}, + "order": 15, + "mode": 0, + "inputs": [], + "outputs": [], + "properties": {}, + "widgets_values": [ + "🛈 [Learn more about this workflow](https://comfyanonymous.github.io/ComfyUI_examples/sdxl/)" + ], + "color": "#432", + "bgcolor": "#000" + }, + { + "id": 40, + "type": "Note", + "pos": [ + 1156.8466796875, + -59.93302536010742 + ], + "size": [ + 451.5, + 424.4200134277344 + ], + "flags": {}, + "order": 16, + "mode": 0, + "inputs": [], + "outputs": [], + "title": "Note - KSampler ADVANCED General Information", + "properties": { + "text": "" + }, + "widgets_values": [ + "Here are the settings that SHOULD stay in place if you want this workflow to work correctly:\n - add_noise: enable = This adds random noise into the picture so the model can denoise it\n\n - return_with_leftover_noise: enable = This sends the latent image data and all it's leftover noise to the next KSampler node.\n\nThe settings to pay attention to:\n - control_after_generate = generates a new random seed after each workflow job completed.\n - steps = This is the amount of iterations you would like to run the positive and negative CLIP prompts through. Each Step will add (positive) or remove (negative) pixels based on what stable diffusion \"thinks\" should be there according to the model's training\n - cfg = This is how much you want SDXL to adhere to the prompt. Lower CFG gives you more creative but often blurrier results. Higher CFG (recommended max 10) gives you stricter results according to the CLIP prompt. If the CFG value is too high, it can also result in \"burn-in\" where the edges of the picture become even stronger, often highlighting details in unnatural ways.\n - sampler_name = This is the sampler type, and unfortunately different samplers and schedulers have better results with fewer steps, while others have better success with higher steps. This will require experimentation on your part!\n - scheduler = The algorithm/method used to choose the timesteps to denoise the picture.\n - start_at_step = This is the step number the KSampler will start out it's process of de-noising the picture or \"removing the random noise to reveal the picture within\". The first KSampler usually starts with Step 0. Starting at step 0 is the same as setting denoise to 1.0 in the regular Sampler node.\n - end_at_step = This is the step number the KSampler will stop it's process of de-noising the picture. If there is any remaining leftover noise and return_with_leftover_noise is enabled, then it will pass on the left over noise to the next KSampler (assuming there is another one)." + ], + "color": "#432", + "bgcolor": "#000" + }, + { + "id": 19, + "type": "SaveImage", + "pos": [ + 1558.4725341796875, + 553.4407958984375 + ], + "size": [ + 565.77001953125, + 596.3800048828125 + ], + "flags": {}, + "order": 24, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 28 + } + ], + "outputs": [], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.33" + }, + "widgets_values": [ + "ComfyUI" + ] + } + ], + "links": [ + [ + 3, + 4, + 1, + 6, + 0, + "CLIP" + ], + [ + 5, + 4, + 1, + 7, + 0, + "CLIP" + ], + [ + 10, + 4, + 0, + 10, + 0, + "MODEL" + ], + [ + 11, + 6, + 0, + 10, + 1, + "CONDITIONING" + ], + [ + 12, + 7, + 0, + 10, + 2, + "CONDITIONING" + ], + [ + 13, + 10, + 0, + 11, + 3, + "LATENT" + ], + [ + 14, + 12, + 0, + 11, + 0, + "MODEL" + ], + [ + 19, + 12, + 1, + 15, + 0, + "CLIP" + ], + [ + 20, + 12, + 1, + 16, + 0, + "CLIP" + ], + [ + 23, + 15, + 0, + 11, + 1, + "CONDITIONING" + ], + [ + 24, + 16, + 0, + 11, + 2, + "CONDITIONING" + ], + [ + 25, + 11, + 0, + 17, + 0, + "LATENT" + ], + [ + 27, + 5, + 0, + 10, + 3, + "LATENT" + ], + [ + 28, + 17, + 0, + 19, + 0, + "IMAGE" + ], + [ + 34, + 12, + 2, + 17, + 1, + "VAE" + ], + [ + 38, + 45, + 0, + 11, + 4, + "INT" + ], + [ + 41, + 45, + 0, + 10, + 4, + "INT" + ], + [ + 43, + 47, + 0, + 10, + 5, + "INT" + ], + [ + 44, + 47, + 0, + 11, + 5, + "INT" + ], + [ + 45, + 51, + 0, + 6, + 1, + "STRING" + ], + [ + 46, + 50, + 0, + 7, + 1, + "STRING" + ], + [ + 47, + 51, + 0, + 15, + 1, + "STRING" + ], + [ + 48, + 50, + 0, + 16, + 1, + "STRING" + ] + ], + "groups": [ + { + "id": 1, + "title": "Base Prompt", + "bounding": [ + 281.707763671875, + -128.8721160888672, + 439.7534484863281, + 600.5301513671875 + ], + "color": "#3f789e", + "font_size": 24, + "flags": {} + }, + { + "id": 2, + "title": "Refiner Prompt", + "bounding": [ + 285.9026794433594, + 567.3108520507812, + 442.127685546875, + 588.2311401367188 + ], + "color": "#3f789e", + "font_size": 24, + "flags": {} + }, + { + "id": 4, + "title": "Load in BASE SDXL Model", + "bounding": [ + -100, + -130, + 359.4917907714844, + 403.9964904785156 + ], + "color": "#a1309b", + "font_size": 24, + "flags": {} + }, + { + "id": 5, + "title": "Load in REFINER SDXL Model", + "bounding": [ + -95.2878189086914, + 564.1364135742188, + 361.54791259765625, + 403.3152160644531 + ], + "color": "#a1309b", + "font_size": 24, + "flags": {} + }, + { + "id": 6, + "title": "Empty Latent Image", + "bounding": [ + -115.09705352783203, + -652.1575317382812, + 353.29144287109375, + 461.8235168457031 + ], + "color": "#a1309b", + "font_size": 24, + "flags": {} + }, + { + "id": 7, + "title": "VAE Decoder", + "bounding": [ + 1165.2672119140625, + 521.2222900390625, + 358.535400390625, + 332.57080078125 + ], + "color": "#b06634", + "font_size": 24, + "flags": {} + }, + { + "id": 8, + "title": "Step Control", + "bounding": [ + 266.23602294921875, + -652.185302734375, + 274.54791259765625, + 472.66363525390625 + ], + "color": "#3f789e", + "font_size": 24, + "flags": {} + }, + { + "id": 10, + "title": "Text Prompts", + "bounding": [ + -471.7359313964844, + -166.89599609375, + 339, + 622 + ], + "color": "#3f789e", + "font_size": 24, + "flags": {} + }, + { + "id": 11, + "title": "Base", + "bounding": [ + -110, + -173.60000610351562, + 1228.77294921875, + 655.2579956054688 + ], + "color": "#3f789e", + "font_size": 24, + "flags": {} + }, + { + "id": 12, + "title": "Refiner", + "bounding": [ + -105.2878189086914, + 520.5363159179688, + 1234.4083251953125, + 645.005615234375 + ], + "color": "#3f789e", + "font_size": 24, + "flags": {} + } + ], + "config": {}, + "extra": { + "ds": { + "scale": 0.5012823509632094, + "offset": [ + 1020.8081012594703, + 795.3704430378444 + ] + }, + "frontendVersion": "1.41.21" + }, + "version": 0.4 +} \ No newline at end of file diff --git a/tests/fixtures/spacing-settings.json b/tests/fixtures/spacing-settings.json new file mode 100644 index 0000000..4974f2f --- /dev/null +++ b/tests/fixtures/spacing-settings.json @@ -0,0 +1,309 @@ +{ + "id": "3d90f8be-23c2-4fd8-b76f-0db8f5975d3f", + "revision": 0, + "last_node_id": 8, + "last_link_id": 9, + "nodes": [ + { + "id": 1, + "type": "CheckpointLoaderSimple", + "pos": [40, 280], + "size": [315, 98], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "slot_index": 0, + "links": [1] + }, + { + "name": "CLIP", + "type": "CLIP", + "slot_index": 1, + "links": [2, 3] + }, + { + "name": "VAE", + "type": "VAE", + "slot_index": 2, + "links": [7] + } + ], + "title": "Model Loader", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.6.0", + "Node name for S&R": "CheckpointLoaderSimple" + }, + "widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"] + }, + { + "id": 2, + "type": "CLIPTextEncode", + "pos": [380, 120], + "size": [425.27801513671875, 180.6060791015625], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 2 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "slot_index": 0, + "links": [4] + } + ], + "title": "Positive Prompt", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.6.0", + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": [ + "glowing test scene" + ] + }, + { + "id": 3, + "type": "CLIPTextEncode", + "pos": [380, 360], + "size": [422.84503173828125, 164.31304931640625], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 3 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "slot_index": 0, + "links": [5] + } + ], + "title": "Negative Prompt", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.6.0", + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": [ + "blurry, noisy" + ] + }, + { + "id": 4, + "type": "EmptyLatentImage", + "pos": [410, 620], + "size": [315, 106], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "slot_index": 0, + "links": [6] + } + ], + "title": "Latent Source", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.6.0", + "Node name for S&R": "EmptyLatentImage" + }, + "widgets_values": [512, 512, 1] + }, + { + "id": 5, + "type": "KSampler", + "pos": [900, 280], + "size": [315, 262], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 1 + }, + { + "name": "positive", + "type": "CONDITIONING", + "link": 4 + }, + { + "name": "negative", + "type": "CONDITIONING", + "link": 5 + }, + { + "name": "latent_image", + "type": "LATENT", + "link": 6 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "slot_index": 0, + "links": [7] + } + ], + "title": "Sampler", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.6.0", + "Node name for S&R": "KSampler" + }, + "widgets_values": [ + 156680208700286, + "randomize", + 20, + 8, + "euler", + "normal", + 1 + ] + }, + { + "id": 6, + "type": "VAEDecode", + "pos": [1260, 320], + "size": [210, 46], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "name": "samples", + "type": "LATENT", + "link": 7 + }, + { + "name": "vae", + "type": "VAE", + "link": 8 + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "slot_index": 0, + "links": [9] + } + ], + "title": "Decode", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.6.0", + "Node name for S&R": "VAEDecode" + }, + "widgets_values": [] + }, + { + "id": 7, + "type": "SaveImage", + "pos": [1510, 320], + "size": [210, 58], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 9 + } + ], + "outputs": [], + "title": "Save", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.6.0", + "Node name for S&R": "SaveImage" + }, + "widgets_values": [ + "spacing-settings" + ] + }, + { + "id": 8, + "type": "EmptyLatentImage", + "pos": [1780, 60], + "size": [315, 106], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "slot_index": 0, + "links": [] + } + ], + "title": "Disconnected Note", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.6.0", + "Node name for S&R": "EmptyLatentImage" + }, + "widgets_values": [256, 256, 1] + } + ], + "links": [ + [1, 1, 0, 5, 0, "MODEL"], + [2, 1, 1, 2, 0, "CLIP"], + [3, 1, 1, 3, 0, "CLIP"], + [4, 2, 0, 5, 1, "CONDITIONING"], + [5, 3, 0, 5, 2, "CONDITIONING"], + [6, 4, 0, 5, 3, "LATENT"], + [7, 5, 0, 6, 0, "LATENT"], + [8, 1, 2, 6, 1, "VAE"], + [9, 6, 0, 7, 0, "IMAGE"] + ], + "groups": [ + { + "id": 1, + "title": "Prompt Cluster", + "bounding": [330, 80, 520, 470], + "color": "#3f789e", + "font_size": 22, + "flags": {} + } + ], + "config": {}, + "extra": { + "ds": { + "scale": 0.55, + "offset": [420, 210] + }, + "workflowRendererVersion": "LG", + "ue_links": [], + "frontendVersion": "1.37.0" + }, + "version": 0.4 +} diff --git a/tests/helpers.ts b/tests/helpers.ts deleted file mode 100644 index f6a3c7c..0000000 --- a/tests/helpers.ts +++ /dev/null @@ -1,361 +0,0 @@ -import { readFileSync } from "fs"; -import { join, dirname } from "path"; -import { fileURLToPath } from "url"; -import type { - LGraph, - LGraphNode, - LGraphGroup, - LLink, -} from "../src/layout/types"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -/** Raw ComfyUI workflow JSON structure */ -interface WorkflowJSON { - nodes: Array<{ - id: number; - type: string; - title?: string; - pos: [number, number]; - size: [number, number]; - flags?: { pinned?: boolean; collapsed?: boolean }; - locked?: boolean; - order?: number; - inputs?: Array<{ name: string; type: string; link: number | null }>; - outputs?: Array<{ name: string; type: string; links: number[] | null }>; - }>; - links: Array<[number, number, number, number, number, string]>; - groups?: Array<{ - id?: number; - title: string; - bounding: [number, number, number, number]; - color?: string; - font_size?: number; - flags?: Record; - }>; - definitions?: { - subgraphs?: SubgraphDefinition[]; - }; -} - -/** Subgraph definition from workflow JSON */ -interface SubgraphDefinition { - id: string; - name: string; - inputNode?: { id: number; bounding: [number, number, number, number] }; - outputNode?: { id: number; bounding: [number, number, number, number] }; - nodes: Array<{ - id: number; - type: string; - title?: string; - pos: [number, number]; - size: [number, number]; - flags?: { pinned?: boolean; collapsed?: boolean }; - locked?: boolean; - order?: number; - inputs?: Array<{ name: string; type: string; link: number | null }>; - outputs?: Array<{ name: string; type: string; links: number[] | null }>; - }>; - groups?: Array<{ - id?: number; - title: string; - bounding: [number, number, number, number]; - color?: string; - font_size?: number; - flags?: Record; - }>; - links: Array<{ - id: number; - origin_id: number; - origin_slot: number; - target_id: number; - target_slot: number; - type: string; - }>; -} - -/** - * Load a workflow fixture and convert to LGraph format - */ -export function loadFixture(filename: string): LGraph { - const fixturePath = join(__dirname, "fixtures", filename); - const json = JSON.parse(readFileSync(fixturePath, "utf-8")) as WorkflowJSON; - return convertWorkflowToLGraph(json); -} - -/** - * Load a subgraph fixture (extracts first subgraph from workflow JSON) - * Returns LGraph with inputNode/outputNode in runtime format (pos/size) - */ -export function loadSubgraphFixture(filename: string): LGraph { - const fixturePath = join(__dirname, "fixtures", filename); - const json = JSON.parse(readFileSync(fixturePath, "utf-8")) as WorkflowJSON; - - const subgraph = json.definitions?.subgraphs?.[0]; - if (!subgraph) { - throw new Error(`No subgraph found in ${filename}`); - } - - return convertSubgraphToLGraph(subgraph); -} - -/** - * Convert subgraph definition to LGraph format with I/O nodes - */ -function convertSubgraphToLGraph(subgraph: SubgraphDefinition): LGraph { - // Convert nodes - const nodes: LGraphNode[] = subgraph.nodes.map((n) => ({ - id: n.id, - type: n.type, - title: n.title ?? n.type, - pos: [...n.pos], - size: [...n.size], - inputs: - n.inputs?.map((i) => ({ - name: i.name, - type: i.type, - link: i.link, - })) ?? [], - outputs: - n.outputs?.map((o) => ({ - name: o.name, - type: o.type, - links: o.links ? [...o.links] : null, - })) ?? [], - flags: n.flags ? { ...n.flags } : undefined, - locked: n.locked, - order: n.order, - })); - - // Convert links from object array format to Map - const links = new Map(); - for (const link of subgraph.links) { - links.set(link.id, { - id: link.id, - origin_id: link.origin_id, - origin_slot: link.origin_slot, - target_id: link.target_id, - target_slot: link.target_slot, - type: link.type, - }); - } - - // Convert groups - const groups: LGraphGroup[] = (subgraph.groups ?? []).map((g, idx) => ({ - id: g.id ?? idx, - title: g.title, - pos: [g.bounding[0], g.bounding[1]], - size: [g.bounding[2], g.bounding[3]], - })); - - // Create graph with I/O nodes in runtime format (pos/size from bounding) - const graph: LGraph = { - _nodes: nodes, - _groups: groups, - links, - }; - - if (subgraph.inputNode) { - const [x, y, w, h] = subgraph.inputNode.bounding; - graph.inputNode = { - id: subgraph.inputNode.id, - pos: [x, y], - size: [w, h], - }; - } - - if (subgraph.outputNode) { - const [x, y, w, h] = subgraph.outputNode.bounding; - graph.outputNode = { - id: subgraph.outputNode.id, - pos: [x, y], - size: [w, h], - }; - } - - return graph; -} - -/** - * Convert ComfyUI workflow JSON to LGraph format for layout algorithm - */ -export function convertWorkflowToLGraph(workflow: WorkflowJSON): LGraph { - // Convert nodes - const nodes: LGraphNode[] = workflow.nodes.map((n) => ({ - id: n.id, - type: n.type, - title: n.title ?? n.type, - pos: [...n.pos], - size: [...n.size], - inputs: - n.inputs?.map((i) => ({ - name: i.name, - type: i.type, - link: i.link, - })) ?? [], - outputs: - n.outputs?.map((o) => ({ - name: o.name, - type: o.type, - links: o.links ? [...o.links] : null, - })) ?? [], - flags: n.flags ? { ...n.flags } : undefined, - locked: n.locked, - order: n.order, - })); - - // Convert links: [linkId, originId, originSlot, targetId, targetSlot, type] - const links = new Map(); - for (const [ - linkId, - originId, - originSlot, - targetId, - targetSlot, - type, - ] of workflow.links) { - links.set(linkId, { - id: linkId, - origin_id: originId, - origin_slot: originSlot, - target_id: targetId, - target_slot: targetSlot, - type, - }); - } - - // Convert groups - const groups: LGraphGroup[] = (workflow.groups ?? []).map((g, idx) => ({ - id: g.id ?? idx, - title: g.title, - pos: [g.bounding[0], g.bounding[1]], - size: [g.bounding[2], g.bounding[3]], - })); - - return { - _nodes: nodes, - _groups: groups, - links, - }; -} - -/** - * Capture current positions of all nodes, groups, and I/O nodes for comparison - */ -export function capturePositions( - graph: LGraph -): Map { - const positions = new Map< - string, - { x: number; y: number; width: number; height: number } - >(); - - for (const node of graph._nodes) { - positions.set(`node_${node.id}`, { - x: node.pos[0], - y: node.pos[1], - width: node.size[0], - height: node.size[1], - }); - } - - for (const group of graph._groups) { - positions.set(`group_${group.id}`, { - x: group.pos[0], - y: group.pos[1], - width: group.size[0], - height: group.size[1], - }); - } - - // Capture I/O node positions if present - if (graph.inputNode?.pos && graph.inputNode?.size) { - positions.set(`io_${graph.inputNode.id}`, { - x: graph.inputNode.pos[0], - y: graph.inputNode.pos[1], - width: graph.inputNode.size[0], - height: graph.inputNode.size[1], - }); - } - if (graph.outputNode?.pos && graph.outputNode?.size) { - positions.set(`io_${graph.outputNode.id}`, { - x: graph.outputNode.pos[0], - y: graph.outputNode.pos[1], - width: graph.outputNode.size[0], - height: graph.outputNode.size[1], - }); - } - - return positions; -} - -/** - * Get links as an iterable array - */ -function getLinksArray(links: LGraph["links"]): LLink[] { - if (links instanceof Map) { - return Array.from(links.values()); - } - if (Array.isArray(links)) { - return links; - } - return Object.values(links); -} - -/** - * Deep clone a graph for idempotency testing - * Preserves I/O nodes if present - */ -export function cloneGraph(graph: LGraph): LGraph { - const linksArr = getLinksArray(graph.links); - - const cloned = convertWorkflowToLGraph({ - nodes: graph._nodes.map((n) => ({ - id: n.id, - type: n.type, - title: n.title, - pos: [...n.pos], - size: [...n.size], - flags: n.flags ? { ...n.flags } : undefined, - locked: n.locked, - order: n.order, - inputs: n.inputs?.map((i) => ({ ...i })), - outputs: n.outputs?.map((o) => ({ - ...o, - links: o.links ? [...o.links] : null, - })), - })), - links: linksArr.map((l) => [ - l.id, - l.origin_id, - l.origin_slot, - l.target_id, - l.target_slot, - l.type, - ]), - groups: graph._groups.map((g) => ({ - id: g.id, - title: g.title, - bounding: [g.pos[0], g.pos[1], g.size[0], g.size[1]], - })), - }); - - // Preserve I/O nodes if present - if (graph.inputNode) { - cloned.inputNode = { - id: graph.inputNode.id, - pos: [...graph.inputNode.pos], - size: [...graph.inputNode.size], - }; - } - - if (graph.outputNode) { - cloned.outputNode = { - id: graph.outputNode.id, - pos: [...graph.outputNode.pos], - size: [...graph.outputNode.size], - }; - } - - return cloned; -} diff --git a/tests/integration/layout.test.ts b/tests/integration/layout.test.ts deleted file mode 100644 index a688f04..0000000 --- a/tests/integration/layout.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { describe, it, beforeEach, expect } from "vitest"; -import { loadFixture, loadSubgraphFixture, cloneGraph } from "../helpers"; -import { layoutGraph } from "../../src/layout/index"; -import { - assertNoOverlaps, - assertNodesInsideGroups, - assertFiniteCoordinates, - assertTopologicalOrder, - assertIdempotent, - assertGroupMembershipPreserved, -} from "../invariants"; -import type { LGraph } from "../../src/layout/types"; - -/** - * Regression tests for the layout algorithm - * Each fixture is run through all invariants to catch regressions - */ -describe("layoutGraph regression tests", () => { - // Fixtures that currently pass all tests - const passingFixtures = [ - "simple-dag.json", // 7 nodes, no groups, basic txt2img DAG - "nested-groups.json", // Nested groups with node inside inner group - "nested-wrapper.json", // Group Wrapper containing nested groups - "complex-parallel.json", // 26 nodes, parallel KSampler High/Low, 5 groups - ]; - - for (const fixture of passingFixtures) { - describe(fixture, () => { - let graph: LGraph; - let originalGraph: LGraph; - - beforeEach(() => { - graph = loadFixture(fixture); - originalGraph = cloneGraph(graph); // Capture state before layout - layoutGraph(graph); - }); - - it("has no overlapping entities", () => { - assertNoOverlaps(graph); - }); - - it("keeps nodes inside their groups", () => { - assertNodesInsideGroups(graph); - }); - - it("has finite coordinates", () => { - assertFiniteCoordinates(graph); - }); - - it("preserves topological order", () => { - assertTopologicalOrder(graph); - }); - - it("is idempotent (stable after multiple runs)", () => { - assertIdempotent(graph); - }); - - it("preserves group membership", () => { - assertGroupMembershipPreserved(originalGraph, graph); - }); - }); - } -}); - -/** - * Subgraph fixture tests (with I/O nodes) - * Uses loadSubgraphFixture to extract subgraph from workflow JSON - */ -describe("subgraph layout tests", () => { - const subgraphFixtures = [ - "subgraph-io.json", // Subgraph with I/O nodes, 12 nodes, 4 groups - ]; - - for (const fixture of subgraphFixtures) { - describe(fixture, () => { - let graph: LGraph; - let originalGraph: LGraph; - - beforeEach(() => { - graph = loadSubgraphFixture(fixture); - originalGraph = cloneGraph(graph); - layoutGraph(graph); - }); - - it("has no overlapping entities (including I/O nodes)", () => { - assertNoOverlaps(graph); - }); - - it("keeps nodes inside their groups", () => { - assertNodesInsideGroups(graph); - }); - - it("has finite coordinates", () => { - assertFiniteCoordinates(graph); - }); - - it("preserves topological order", () => { - assertTopologicalOrder(graph); - }); - - it("is idempotent (stable after multiple runs)", () => { - assertIdempotent(graph); - }); - - it("preserves group membership", () => { - assertGroupMembershipPreserved(originalGraph, graph); - }); - }); - } -}); - -describe("layout algorithm properties", () => { - it("handles empty graph", () => { - const graph: LGraph = { - _nodes: [], - _groups: [], - links: new Map(), - }; - - const result = layoutGraph(graph); - - expect(result.nodeCount).toBe(0); - expect(result.layerCount).toBe(0); - }); - - it("handles single node", () => { - const graph: LGraph = { - _nodes: [ - { - id: 1, - type: "Note", - title: "Test", - pos: [0, 0], - size: [200, 100], - inputs: [], - outputs: [], - }, - ], - _groups: [], - links: new Map(), - }; - - const result = layoutGraph(graph); - - expect(result.nodeCount).toBe(1); - assertFiniteCoordinates(graph); - }); - - it("handles disconnected nodes", () => { - const graph: LGraph = { - _nodes: [ - { - id: 1, - type: "Note", - title: "Note 1", - pos: [0, 0], - size: [200, 100], - inputs: [], - outputs: [], - }, - { - id: 2, - type: "Note", - title: "Note 2", - pos: [500, 500], - size: [200, 100], - inputs: [], - outputs: [], - }, - ], - _groups: [], - links: new Map(), - }; - - layoutGraph(graph); - - assertNoOverlaps(graph); - assertFiniteCoordinates(graph); - }); -}); diff --git a/tests/integration/selected-groups.test.ts b/tests/integration/selected-groups.test.ts deleted file mode 100644 index f6f5403..0000000 --- a/tests/integration/selected-groups.test.ts +++ /dev/null @@ -1,931 +0,0 @@ -import { describe, it, beforeEach, expect } from "vitest"; -import { loadFixture, capturePositions } from "../helpers"; -import { layoutSelectedGroups } from "../../src/layout/selected-groups"; -import { parseLayoutToken } from "../../src/layout/title-tokens"; -import { - assertNodesInsideGroups, - assertFiniteCoordinates, -} from "../invariants"; -import type { LGraph } from "../../src/layout/types"; - -/** - * Tests for layoutSelectedGroups function - * Note: External overlaps are acceptable per requirements - */ -describe("layoutSelectedGroups", () => { - describe("single group with internal connections", () => { - let graph: LGraph; - - beforeEach(() => { - // nested-groups.json has groups with internal connections - // Group 1 "Step 1 - Load model" contains node 4 (CheckpointLoaderSimple) - graph = loadFixture("nested-groups.json"); - }); - - it("organizes nodes within selected group", () => { - // Select group 1 which contains node 4 - const result = layoutSelectedGroups(graph, new Set([1])); - - expect(result.nodeCount).toBeGreaterThanOrEqual(0); - expect(result.groupCount).toBe(1); - assertFiniteCoordinates(graph); - }); - - it("resizes group to fit contents", () => { - layoutSelectedGroups(graph, new Set([1])); - - const groupAfter = graph._groups.find((g) => g.id === 1); - // Group size should be finite and positive - expect(Number.isFinite(groupAfter!.size[0])).toBe(true); - expect(Number.isFinite(groupAfter!.size[1])).toBe(true); - expect(groupAfter!.size[0]).toBeGreaterThan(0); - expect(groupAfter!.size[1]).toBeGreaterThan(0); - }); - - it("preserves external node positions", () => { - // Capture positions of nodes NOT in group 1 - const nodesBefore = new Map(); - for (const node of graph._nodes) { - // Node 4 is in group 1, all others should be unchanged - if (node.id !== 4) { - nodesBefore.set(node.id, [...node.pos]); - } - } - - layoutSelectedGroups(graph, new Set([1])); - - // Verify external nodes haven't moved - for (const [nodeId, posBefore] of nodesBefore) { - const node = graph._nodes.find((n) => n.id === nodeId); - expect(node).toBeDefined(); - expect(node!.pos[0]).toBe(posBefore[0]); - expect(node!.pos[1]).toBe(posBefore[1]); - } - }); - - it("is idempotent", () => { - layoutSelectedGroups(graph, new Set([1])); - const positions1 = capturePositions(graph); - - layoutSelectedGroups(graph, new Set([1])); - const positions2 = capturePositions(graph); - - // Compare positions with small tolerance - for (const [id, pos1] of positions1) { - const pos2 = positions2.get(id); - if (pos2) { - expect(Math.abs(pos1.x - pos2.x)).toBeLessThan(5); - expect(Math.abs(pos1.y - pos2.y)).toBeLessThan(5); - } - } - }); - }); - - describe("single group with disconnected nodes", () => { - let graph: LGraph; - - beforeEach(() => { - // Create a graph with a group containing disconnected nodes - graph = { - _nodes: [ - { - id: 1, - type: "Note", - title: "Note 1", - pos: [100, 100], - size: [200, 100], - inputs: [], - outputs: [], - }, - { - id: 2, - type: "Note", - title: "Note 2", - pos: [150, 250], - size: [200, 100], - inputs: [], - outputs: [], - }, - { - id: 3, - type: "Note", - title: "External Node", - pos: [500, 500], - size: [200, 100], - inputs: [], - outputs: [], - }, - ], - _groups: [ - { - id: 1, - title: "Test Group", - pos: [50, 50], - size: [350, 350], - }, - ], - links: new Map(), - }; - }); - - it("stacks disconnected nodes vertically", () => { - const result = layoutSelectedGroups(graph, new Set([1])); - - expect(result.nodeCount).toBe(2); - expect(result.groupCount).toBe(1); - - // Nodes should have same X (stacked vertically) - const node1 = graph._nodes.find((n) => n.id === 1)!; - const node2 = graph._nodes.find((n) => n.id === 2)!; - expect(node1.pos[0]).toBe(node2.pos[0]); - - assertFiniteCoordinates(graph); - }); - - it("resizes group to fit stacked nodes", () => { - layoutSelectedGroups(graph, new Set([1])); - - const group = graph._groups.find((g) => g.id === 1)!; - expect(Number.isFinite(group.size[0])).toBe(true); - expect(Number.isFinite(group.size[1])).toBe(true); - expect(group.size[0]).toBeGreaterThan(0); - expect(group.size[1]).toBeGreaterThan(0); - }); - }); - - describe("multiple groups", () => { - let graph: LGraph; - - beforeEach(() => { - graph = loadFixture("nested-groups.json"); - }); - - it("organizes each group independently", () => { - // Select groups 1 and 2 (not nested) - const result = layoutSelectedGroups(graph, new Set([1, 2])); - - expect(result.groupCount).toBeGreaterThanOrEqual(2); - assertFiniteCoordinates(graph); - }); - - it("handles non-overlapping groups", () => { - layoutSelectedGroups(graph, new Set([1, 2])); - - // Both groups should have valid coordinates - const group1 = graph._groups.find((g) => g.id === 1)!; - const group2 = graph._groups.find((g) => g.id === 2)!; - - expect(Number.isFinite(group1.pos[0])).toBe(true); - expect(Number.isFinite(group2.pos[0])).toBe(true); - }); - }); - - describe("nested groups", () => { - let graph: LGraph; - - beforeEach(() => { - // nested-groups.json has Group 3 containing Group 4 (nested) - graph = loadFixture("nested-groups.json"); - }); - - it("auto-includes nested groups when parent selected", () => { - // Select group 3 which contains group 4 - const result = layoutSelectedGroups(graph, new Set([3])); - - // Should include the nested group - expect(result.groupCount).toBeGreaterThanOrEqual(1); - assertFiniteCoordinates(graph); - }); - - it("positions nested groups correctly", () => { - layoutSelectedGroups(graph, new Set([3])); - - // Group 4 should still be inside Group 3 - const group3 = graph._groups.find((g) => g.id === 3)!; - const group4 = graph._groups.find((g) => g.id === 4)!; - - // Check group 4 is within group 3 bounds - expect(group4.pos[0]).toBeGreaterThanOrEqual(group3.pos[0]); - expect(group4.pos[1]).toBeGreaterThanOrEqual(group3.pos[1]); - expect(group4.pos[0] + group4.size[0]).toBeLessThanOrEqual( - group3.pos[0] + group3.size[0] - ); - expect(group4.pos[1] + group4.size[1]).toBeLessThanOrEqual( - group3.pos[1] + group3.size[1] - ); - }); - - it("keeps nodes inside selected groups", () => { - layoutSelectedGroups(graph, new Set([3])); - - // Nodes inside group 3 should remain inside it - assertNodesInsideGroups(graph); - }); - }); - - describe("edge cases", () => { - it("handles empty graph", () => { - const graph: LGraph = { - _nodes: [], - _groups: [], - links: new Map(), - }; - - const result = layoutSelectedGroups(graph, new Set([1])); - - expect(result.nodeCount).toBe(0); - expect(result.groupCount).toBe(0); - }); - - it("handles non-existent group IDs", () => { - const graph = loadFixture("nested-groups.json"); - - const result = layoutSelectedGroups(graph, new Set([999])); - - expect(result.groupCount).toBe(0); - }); - - it("handles empty groups", () => { - const graph: LGraph = { - _nodes: [ - { - id: 1, - type: "Note", - title: "External", - pos: [500, 500], - size: [200, 100], - inputs: [], - outputs: [], - }, - ], - _groups: [ - { - id: 1, - title: "Empty Group", - pos: [50, 50], - size: [200, 200], - }, - ], - links: new Map(), - }; - - // Should not crash - const result = layoutSelectedGroups(graph, new Set([1])); - - // Empty group is still processed, just has no content - expect(result.nodeCount).toBe(0); - assertFiniteCoordinates(graph); - }); - - it("handles locked nodes", () => { - const graph: LGraph = { - _nodes: [ - { - id: 1, - type: "Note", - title: "Locked Node", - pos: [100, 100], - size: [200, 100], - inputs: [], - outputs: [], - locked: true, - }, - { - id: 2, - type: "Note", - title: "Normal Node", - pos: [100, 250], - size: [200, 100], - inputs: [], - outputs: [], - }, - ], - _groups: [ - { - id: 1, - title: "Test Group", - pos: [50, 50], - size: [350, 400], - }, - ], - links: new Map(), - }; - - const lockedPosBefore = [...graph._nodes[0].pos]; - - layoutSelectedGroups(graph, new Set([1])); - - // Locked node should not move - expect(graph._nodes[0].pos[0]).toBe(lockedPosBefore[0]); - expect(graph._nodes[0].pos[1]).toBe(lockedPosBefore[1]); - }); - - it("handles group with only locked nodes", () => { - const graph: LGraph = { - _nodes: [ - { - id: 1, - type: "Note", - title: "Locked Node", - pos: [100, 100], - size: [200, 100], - inputs: [], - outputs: [], - locked: true, - }, - ], - _groups: [ - { - id: 1, - title: "Test Group", - pos: [50, 50], - size: [350, 250], - }, - ], - links: new Map(), - }; - - // Should not crash - const result = layoutSelectedGroups(graph, new Set([1])); - expect(result).toBeDefined(); - }); - }); - - describe("groups with internal connections", () => { - it("uses layer-based layout for connected nodes", () => { - const graph: LGraph = { - _nodes: [ - { - id: 1, - type: "NodeA", - title: "Node A", - pos: [100, 100], - size: [200, 100], - inputs: [], - outputs: [{ name: "out", type: "FLOAT", links: [1] }], - }, - { - id: 2, - type: "NodeB", - title: "Node B", - pos: [100, 300], - size: [200, 100], - inputs: [{ name: "in", type: "FLOAT", link: 1 }], - outputs: [], - }, - ], - _groups: [ - { - id: 1, - title: "Test Group", - pos: [50, 50], - size: [400, 400], - }, - ], - links: new Map([ - [ - 1, - { - id: 1, - origin_id: 1, - origin_slot: 0, - target_id: 2, - target_slot: 0, - type: "FLOAT", - }, - ], - ]), - }; - - layoutSelectedGroups(graph, new Set([1])); - - // With layer-based layout, source (node 1) should be left of target (node 2) - const node1 = graph._nodes.find((n) => n.id === 1)!; - const node2 = graph._nodes.find((n) => n.id === 2)!; - expect(node1.pos[0]).toBeLessThan(node2.pos[0]); - - assertFiniteCoordinates(graph); - }); - }); - - describe("parseLayoutToken", () => { - it("parses [HORIZONTAL] token", () => { - expect(parseLayoutToken("My Group [HORIZONTAL]")).toEqual({ - type: "horizontal", - }); - }); - - it("parses [VERTICAL] token", () => { - expect(parseLayoutToken("[VERTICAL] Group")).toEqual({ type: "vertical" }); - }); - - it("parses [xROW] tokens", () => { - expect(parseLayoutToken("Group [2ROW]")).toEqual({ type: "rows", count: 2 }); - expect(parseLayoutToken("[3ROW] Test")).toEqual({ type: "rows", count: 3 }); - }); - - it("parses [xCOL] tokens", () => { - expect(parseLayoutToken("Group [2COL]")).toEqual({ - type: "columns", - count: 2, - }); - expect(parseLayoutToken("[4COL] Test")).toEqual({ - type: "columns", - count: 4, - }); - }); - - it("treats [1ROW] as horizontal", () => { - expect(parseLayoutToken("Group [1ROW]")).toEqual({ type: "horizontal" }); - }); - - it("treats [1COL] as vertical", () => { - expect(parseLayoutToken("Group [1COL]")).toEqual({ type: "vertical" }); - }); - - it("is case-insensitive", () => { - expect(parseLayoutToken("[horizontal]")).toEqual({ type: "horizontal" }); - expect(parseLayoutToken("[Vertical]")).toEqual({ type: "vertical" }); - expect(parseLayoutToken("[2row]")).toEqual({ type: "rows", count: 2 }); - expect(parseLayoutToken("[3Col]")).toEqual({ type: "columns", count: 3 }); - }); - - it("returns default for no token", () => { - expect(parseLayoutToken("Normal Group")).toEqual({ type: "default" }); - expect(parseLayoutToken("")).toEqual({ type: "default" }); - }); - - it("returns first matching token", () => { - // HORIZONTAL comes before xROW in parsing order - expect(parseLayoutToken("[HORIZONTAL] [2ROW]")).toEqual({ - type: "horizontal", - }); - }); - }); - - describe("groups with layout tokens", () => { - function createTestGraph(groupTitle: string): LGraph { - return { - _nodes: [ - { - id: 1, - type: "Note", - title: "Node 1", - pos: [100, 100], - size: [200, 100], - inputs: [], - outputs: [], - }, - { - id: 2, - type: "Note", - title: "Node 2", - pos: [100, 250], - size: [200, 100], - inputs: [], - outputs: [], - }, - { - id: 3, - type: "Note", - title: "Node 3", - pos: [100, 400], - size: [200, 100], - inputs: [], - outputs: [], - }, - { - id: 4, - type: "Note", - title: "Node 4", - pos: [100, 550], - size: [200, 100], - inputs: [], - outputs: [], - }, - ], - _groups: [ - { - id: 1, - title: groupTitle, - pos: [50, 50], - size: [500, 700], - }, - ], - links: new Map(), - }; - } - - it("[HORIZONTAL] arranges nodes in single row", () => { - const graph = createTestGraph("Test [HORIZONTAL]"); - layoutSelectedGroups(graph, new Set([1])); - - // All nodes should have same Y (single row) - const ys = graph._nodes.map((n) => n.pos[1]); - expect(new Set(ys).size).toBe(1); - - // Nodes should be spread horizontally - const xs = graph._nodes.map((n) => n.pos[0]).sort((a, b) => a - b); - expect(xs[3]).toBeGreaterThan(xs[0]); - - assertFiniteCoordinates(graph); - }); - - it("[VERTICAL] arranges nodes in single column", () => { - const graph = createTestGraph("Test [VERTICAL]"); - layoutSelectedGroups(graph, new Set([1])); - - // All nodes should have same X (single column) - const xs = graph._nodes.map((n) => n.pos[0]); - expect(new Set(xs).size).toBe(1); - - // Nodes should be spread vertically - const ys = graph._nodes.map((n) => n.pos[1]).sort((a, b) => a - b); - expect(ys[3]).toBeGreaterThan(ys[0]); - - assertFiniteCoordinates(graph); - }); - - it("[2ROW] distributes nodes into 2 rows", () => { - const graph = createTestGraph("Test [2ROW]"); - layoutSelectedGroups(graph, new Set([1])); - - // Should have exactly 2 distinct Y values (2 rows) - const ys = new Set(graph._nodes.map((n) => n.pos[1])); - expect(ys.size).toBe(2); - - assertFiniteCoordinates(graph); - }); - - it("[3COL] distributes nodes into 3 columns", () => { - const graph = createTestGraph("Test [3COL]"); - layoutSelectedGroups(graph, new Set([1])); - - // Should have 3 distinct X values (4 nodes into 3 cols = 2,1,1 distribution) - const xs = new Set(graph._nodes.map((n) => n.pos[0])); - expect(xs.size).toBe(3); - - assertFiniteCoordinates(graph); - }); - - it("lowercase token works", () => { - const graph = createTestGraph("Test [horizontal]"); - layoutSelectedGroups(graph, new Set([1])); - - // All nodes should have same Y (single row) - const ys = graph._nodes.map((n) => n.pos[1]); - expect(new Set(ys).size).toBe(1); - - assertFiniteCoordinates(graph); - }); - - it("ignores DAG structure with token", () => { - // Create graph with connected nodes - const graph: LGraph = { - _nodes: [ - { - id: 1, - type: "NodeA", - title: "Node A", - pos: [100, 100], - size: [200, 100], - inputs: [], - outputs: [{ name: "out", type: "FLOAT", links: [1] }], - }, - { - id: 2, - type: "NodeB", - title: "Node B", - pos: [100, 300], - size: [200, 100], - inputs: [{ name: "in", type: "FLOAT", link: 1 }], - outputs: [], - }, - ], - _groups: [ - { - id: 1, - title: "Test [HORIZONTAL]", - pos: [50, 50], - size: [500, 400], - }, - ], - links: new Map([ - [ - 1, - { - id: 1, - origin_id: 1, - origin_slot: 0, - target_id: 2, - target_slot: 0, - type: "FLOAT", - }, - ], - ]), - }; - - layoutSelectedGroups(graph, new Set([1])); - - // With [HORIZONTAL], both nodes should have same Y regardless of connection - const node1 = graph._nodes.find((n) => n.id === 1)!; - const node2 = graph._nodes.find((n) => n.id === 2)!; - expect(node1.pos[1]).toBe(node2.pos[1]); - - assertFiniteCoordinates(graph); - }); - - it("nested groups respect their own tokens", () => { - const graph: LGraph = { - _nodes: [ - // Nodes in outer group (not in inner) - { - id: 1, - type: "Note", - title: "Outer 1", - pos: [100, 100], - size: [100, 100], - inputs: [], - outputs: [], - }, - { - id: 2, - type: "Note", - title: "Outer 2", - pos: [100, 250], - size: [100, 100], - inputs: [], - outputs: [], - }, - // Nodes in inner group - { - id: 3, - type: "Note", - title: "Inner 1", - pos: [350, 150], - size: [100, 100], - inputs: [], - outputs: [], - }, - { - id: 4, - type: "Note", - title: "Inner 2", - pos: [350, 300], - size: [100, 100], - inputs: [], - outputs: [], - }, - ], - _groups: [ - { - id: 1, - title: "Outer [HORIZONTAL]", - pos: [50, 50], - size: [600, 500], - }, - { - id: 2, - title: "Inner [VERTICAL]", - pos: [300, 100], - size: [200, 350], - }, - ], - links: new Map(), - }; - - layoutSelectedGroups(graph, new Set([1])); - - // Inner group nodes should have same X (vertical) - const inner1 = graph._nodes.find((n) => n.id === 3)!; - const inner2 = graph._nodes.find((n) => n.id === 4)!; - expect(inner1.pos[0]).toBe(inner2.pos[0]); - - assertFiniteCoordinates(graph); - }); - - it("resizes group to fit token-arranged contents", () => { - const graph = createTestGraph("Test [HORIZONTAL]"); - layoutSelectedGroups(graph, new Set([1])); - - const group = graph._groups.find((g) => g.id === 1)!; - - // Group should be wider than tall (horizontal layout) - // With 4 nodes of 200px width + gaps, width should be significant - expect(group.size[0]).toBeGreaterThan(600); - - assertFiniteCoordinates(graph); - }); - }); - - describe("nested group + disconnected nodes overlap regression (group-test-simple.json)", () => { - it("positions nested groups below disconnected nodes (no token)", () => { - const graph = loadFixture("group-test-simple.json"); - layoutSelectedGroups(graph, new Set([1])); // "Group Wrapper" - - const innerGroup = graph._groups.find((g) => g.id === 2)!; - const node3 = graph._nodes.find((n) => n.id === 3)!; - const node4 = graph._nodes.find((n) => n.id === 4)!; - - // Inner group should be BELOW the preview images - const node3Bottom = node3.pos[1] + node3.size[1]; - const node4Bottom = node4.pos[1] + node4.size[1]; - const membersBottom = Math.max(node3Bottom, node4Bottom); - expect(innerGroup.pos[1]).toBeGreaterThan(membersBottom); - }); - - it("maintains no overlaps after default layout", () => { - const graph = loadFixture("group-test-simple.json"); - layoutSelectedGroups(graph, new Set([1])); - assertNodesInsideGroups(graph); - assertFiniteCoordinates(graph); - }); - - it("is idempotent with nested groups in default mode", () => { - const graph = loadFixture("group-test-simple.json"); - layoutSelectedGroups(graph, new Set([1])); - const positions1 = capturePositions(graph); - - layoutSelectedGroups(graph, new Set([1])); - const positions2 = capturePositions(graph); - - // Compare positions with small tolerance - for (const [id, pos1] of positions1) { - const pos2 = positions2.get(id); - if (pos2) { - expect(Math.abs(pos1.x - pos2.x)).toBeLessThan(5); - expect(Math.abs(pos1.y - pos2.y)).toBeLessThan(5); - } - } - }); - - it("multiple sibling nested groups stack vertically", () => { - const graph: LGraph = { - _nodes: [ - // Disconnected node in parent - { - id: 1, - type: "Note", - title: "Parent Node", - pos: [100, 100] as [number, number], - size: [200, 100] as [number, number], - inputs: [], - outputs: [], - }, - // Node in first nested group - { - id: 2, - type: "Note", - title: "Nested 1 Node", - pos: [100, 300] as [number, number], - size: [200, 100] as [number, number], - inputs: [], - outputs: [], - }, - // Node in second nested group - { - id: 3, - type: "Note", - title: "Nested 2 Node", - pos: [100, 500] as [number, number], - size: [200, 100] as [number, number], - inputs: [], - outputs: [], - }, - ], - _groups: [ - { - id: 1, - title: "Parent Group", - pos: [50, 50] as [number, number], - size: [400, 600] as [number, number], - }, - { - id: 2, - title: "Nested 1", - pos: [80, 250] as [number, number], - size: [300, 180] as [number, number], - }, - { - id: 3, - title: "Nested 2", - pos: [80, 450] as [number, number], - size: [300, 180] as [number, number], - }, - ], - links: new Map(), - }; - - layoutSelectedGroups(graph, new Set([1])); - - const nested1 = graph._groups.find((g) => g.id === 2)!; - const nested2 = graph._groups.find((g) => g.id === 3)!; - const parentNode = graph._nodes.find((n) => n.id === 1)!; - - // Both nested groups should be below the parent node - const parentNodeBottom = parentNode.pos[1] + parentNode.size[1]; - expect(nested1.pos[1]).toBeGreaterThan(parentNodeBottom); - expect(nested2.pos[1]).toBeGreaterThan(parentNodeBottom); - - // Nested groups should not overlap each other - const nested1Bottom = nested1.pos[1] + nested1.size[1]; - expect(nested2.pos[1]).toBeGreaterThanOrEqual(nested1Bottom); - - assertFiniteCoordinates(graph); - }); - }); - - describe("nested group position update regression (group-test.json)", () => { - it("nested group position updates when parent has token layout", () => { - const graph = loadFixture("group-test.json"); - - // Group 1 "Previews [2ROW]" contains nested Group 3 "Preview Nodes [HORIZONTAL]" - // Nodes 3, 4 are inside Group 3 - layoutSelectedGroups(graph, new Set([1])); - - // Group 3 should be repositioned to contain its member nodes - const group3 = graph._groups.find((g) => g.id === 3)!; - const node3 = graph._nodes.find((n) => n.id === 3)!; - const node4 = graph._nodes.find((n) => n.id === 4)!; - - // Nodes must be inside group bounds (x >= group.x, y >= group.y + title_height) - const GROUP_TITLE_HEIGHT = 50; - expect(node3.pos[0]).toBeGreaterThanOrEqual(group3.pos[0]); - expect(node3.pos[1]).toBeGreaterThanOrEqual(group3.pos[1] + GROUP_TITLE_HEIGHT); - expect(node4.pos[0]).toBeGreaterThanOrEqual(group3.pos[0]); - expect(node4.pos[1]).toBeGreaterThanOrEqual(group3.pos[1] + GROUP_TITLE_HEIGHT); - - // Nodes must be inside group bounds (right/bottom edges) - expect(node3.pos[0] + node3.size[0]).toBeLessThanOrEqual(group3.pos[0] + group3.size[0]); - expect(node3.pos[1] + node3.size[1]).toBeLessThanOrEqual(group3.pos[1] + group3.size[1]); - expect(node4.pos[0] + node4.size[0]).toBeLessThanOrEqual(group3.pos[0] + group3.size[0]); - expect(node4.pos[1] + node4.size[1]).toBeLessThanOrEqual(group3.pos[1] + group3.size[1]); - - assertFiniteCoordinates(graph); - }); - - it("maintains node-group containment after [2ROW] token layout", () => { - const graph = loadFixture("group-test.json"); - layoutSelectedGroups(graph, new Set([1])); - assertNodesInsideGroups(graph); - assertFiniteCoordinates(graph); - }); - - it("updates bounding array when translating nested group", () => { - // Simulate production environment where groups have bounding array - const graph: LGraph = { - _nodes: [ - { - id: 1, - type: "Note", - title: "Note 1", - pos: [180, 180] as [number, number], - size: [200, 100] as [number, number], - inputs: [], - outputs: [], - }, - { - id: 2, - type: "Note", - title: "Note 2", - pos: [500, 180] as [number, number], - size: [200, 100] as [number, number], - inputs: [], - outputs: [], - }, - ], - _groups: [ - { - id: 1, - title: "Outer [HORIZONTAL]", - pos: [100, 50] as [number, number], - size: [700, 300] as [number, number], - }, - { - id: 2, - title: "Inner Group", - pos: [450, 130] as [number, number], - size: [300, 200] as [number, number], - }, - ], - links: new Map(), - }; - - // Add bounding arrays to simulate ComfyUI runtime format - const group1 = graph._groups[0] as typeof graph._groups[0] & { bounding: number[] }; - const group2 = graph._groups[1] as typeof graph._groups[1] & { bounding: number[] }; - group1.bounding = [100, 50, 700, 300]; - group2.bounding = [450, 130, 300, 200]; - - layoutSelectedGroups(graph, new Set([1])); - - // After layout, bounding should be updated to match pos - expect(group2.bounding[0]).toBe(group2.pos[0]); - expect(group2.bounding[1]).toBe(group2.pos[1]); - expect(group2.bounding[2]).toBe(group2.size[0]); - expect(group2.bounding[3]).toBe(group2.size[1]); - - assertFiniteCoordinates(graph); - }); - }); -}); diff --git a/tests/integration/token-layout.test.ts b/tests/integration/token-layout.test.ts deleted file mode 100644 index 0a2ce25..0000000 --- a/tests/integration/token-layout.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { describe, it, expect, beforeEach } from "vitest"; -import { loadFixture } from "../helpers"; -import { layoutSelectedGroups } from "../../src/layout/selected-groups"; -import { layoutGraph } from "../../src/layout/index"; -import { assertFiniteCoordinates } from "../invariants"; -import type { LGraph } from "../../src/layout/types"; - -/** - * Regression tests for token-based layout issues - * Based on real workflow: token-testing.json - */ -describe("token layout regression", () => { - let graph: LGraph; - - beforeEach(() => { - graph = loadFixture("token-testing.json"); - }); - - describe("[1ROW] ordering", () => { - // Group 8 "[1ROW]" contains nodes 41, 42 (GetNode), 43, 44 (PreviewImage), 47 (Note) - // GetNodes are originally at X=3290, PreviewImages at X=3600 - - it("preserves left-to-right order (sources before targets)", () => { - layoutSelectedGroups(graph, new Set([8])); - - const node41 = graph._nodes.find((n) => n.id === 41)!; // GetNode - const node42 = graph._nodes.find((n) => n.id === 42)!; // GetNode - const node43 = graph._nodes.find((n) => n.id === 43)!; // PreviewImage - const node44 = graph._nodes.find((n) => n.id === 44)!; // PreviewImage - - // GetNodes (originally left at X=3290) should remain left of PreviewImages (X=3600) - expect(node41.pos[0]).toBeLessThan(node43.pos[0]); - expect(node42.pos[0]).toBeLessThan(node44.pos[0]); - - assertFiniteCoordinates(graph); - }); - - it("places all nodes at same Y in horizontal row", () => { - layoutSelectedGroups(graph, new Set([8])); - - const groupNodes = graph._nodes.filter((n) => - [41, 42, 43, 44, 47].includes(n.id) - ); - const ys = new Set(groupNodes.map((n) => n.pos[1])); - - // All nodes should have same Y (single row) - expect(ys.size).toBe(1); - }); - }); - - describe("[3COL] with nested groups", () => { - // Group 9 "Full Workflow Group [3COL]" contains nested group 10 "[HORIZONTAL]" - // Group 10 contains nodes 55, 56, 57 (isolated notes) - - it("includes nested groups in column arrangement", () => { - layoutSelectedGroups(graph, new Set([9])); - - const group10 = graph._groups.find((g) => g.id === 10)!; - - // Nested group should be positioned - expect(group10.pos[0]).toBeGreaterThan(0); - expect(group10.pos[1]).toBeGreaterThan(0); - - assertFiniteCoordinates(graph); - }); - - it("nested group respects its own token when selected directly", () => { - // Test group 10 "[HORIZONTAL]" directly - layoutSelectedGroups(graph, new Set([10])); - - const innerNodes = graph._nodes.filter((n) => - [55, 56, 57].includes(n.id) - ); - const ys = new Set(innerNodes.map((n) => n.pos[1])); - - // Inner nodes should all be at same Y (horizontal) - expect(ys.size).toBe(1); - }); - - it("does not overlap nested groups with other content", () => { - layoutSelectedGroups(graph, new Set([9])); - - const group10 = graph._groups.find((g) => g.id === 10)!; - - // Group 10 should have valid bounds - expect(group10.size[0]).toBeGreaterThan(0); - expect(group10.size[1]).toBeGreaterThan(0); - - assertFiniteCoordinates(graph); - }); - }); - - describe("Organize Workflow with tokens", () => { - it("respects group tokens in full workflow layout", () => { - layoutGraph(graph); - - // Group 8 "[1ROW]" - nodes should be at same Y - const group8Nodes = graph._nodes.filter((n) => - [41, 42, 43, 44, 47].includes(n.id) - ); - const g8Ys = new Set(group8Nodes.map((n) => n.pos[1])); - expect(g8Ys.size).toBe(1); - - assertFiniteCoordinates(graph); - }); - - it("preserves ordering in workflow layout", () => { - layoutGraph(graph); - - // In "[1ROW]" group, GetNodes should be left of PreviewImages - const node41 = graph._nodes.find((n) => n.id === 41)!; - const node43 = graph._nodes.find((n) => n.id === 43)!; - - expect(node41.pos[0]).toBeLessThan(node43.pos[0]); - }); - }); - - describe("[HORIZONTAL] token", () => { - // Group 7 "[HORIZONTAL]" contains nodes 32, 33, 34, 35 (PreviewImage nodes) - - it("arranges nodes horizontally", () => { - layoutSelectedGroups(graph, new Set([7])); - - const nodes = graph._nodes.filter((n) => - [32, 33, 34, 35].includes(n.id) - ); - const ys = new Set(nodes.map((n) => n.pos[1])); - - // All nodes should have same Y - expect(ys.size).toBe(1); - - assertFiniteCoordinates(graph); - }); - }); -}); diff --git a/tests/invariants.ts b/tests/invariants.ts deleted file mode 100644 index 411e928..0000000 --- a/tests/invariants.ts +++ /dev/null @@ -1,384 +0,0 @@ -import { expect } from "vitest"; -import type { LGraph, LGraphNode, LGraphGroup } from "../src/layout/types"; -import { capturePositions } from "./helpers"; -import { layoutGraph } from "../src/layout/index"; - -/** Bounding box for collision detection */ -interface BoundingBox { - id: string; - x: number; - y: number; - width: number; - height: number; -} - -/** - * Check if two bounding boxes overlap (AABB collision) - * Uses a small gap tolerance to avoid false positives from floating point - */ -function boxesOverlap(a: BoundingBox, b: BoundingBox, gap = 0): boolean { - return ( - a.x < b.x + b.width + gap && - a.x + a.width + gap > b.x && - a.y < b.y + b.height + gap && - a.y + a.height + gap > b.y - ); -} - -/** - * Get all entities (nodes, groups, and I/O nodes) as bounding boxes - */ -function getAllEntities(graph: LGraph): BoundingBox[] { - const entities: BoundingBox[] = []; - - for (const node of graph._nodes) { - entities.push({ - id: `node_${node.id}`, - x: node.pos[0], - y: node.pos[1], - width: node.size[0], - height: node.size[1], - }); - } - - for (const group of graph._groups) { - entities.push({ - id: `group_${group.id}`, - x: group.pos[0], - y: group.pos[1], - width: group.size[0], - height: group.size[1], - }); - } - - // Include subgraph I/O nodes if present - if (graph.inputNode?.pos && graph.inputNode?.size) { - entities.push({ - id: `io_${graph.inputNode.id}`, - x: graph.inputNode.pos[0], - y: graph.inputNode.pos[1], - width: graph.inputNode.size[0], - height: graph.inputNode.size[1], - }); - } - if (graph.outputNode?.pos && graph.outputNode?.size) { - entities.push({ - id: `io_${graph.outputNode.id}`, - x: graph.outputNode.pos[0], - y: graph.outputNode.pos[1], - width: graph.outputNode.size[0], - height: graph.outputNode.size[1], - }); - } - - return entities; -} - -/** - * Determine which nodes are inside which groups based on position - */ -function getNodesInsideGroups(graph: LGraph): Map { - const nodeToGroup = new Map(); - - for (const node of graph._nodes) { - const nodeX = node.pos[0]; - const nodeY = node.pos[1]; - const nodeRight = nodeX + node.size[0]; - const nodeBottom = nodeY + node.size[1]; - - // Find the smallest group that contains this node - let bestGroup: LGraphGroup | undefined; - let bestArea = Infinity; - - for (const group of graph._groups) { - const groupX = group.pos[0]; - const groupY = group.pos[1]; - const groupRight = groupX + group.size[0]; - const groupBottom = groupY + group.size[1]; - - // Check if node is inside group bounds - if ( - nodeX >= groupX && - nodeY >= groupY && - nodeRight <= groupRight && - nodeBottom <= groupBottom - ) { - const area = group.size[0] * group.size[1]; - if (area < bestArea) { - bestArea = area; - bestGroup = group; - } - } - } - - if (bestGroup) { - nodeToGroup.set(node.id, bestGroup); - } - } - - return nodeToGroup; -} - -/** - * Check if a node bounding box is fully inside a group bounding box - */ -function isNodeInsideGroup(node: BoundingBox, group: BoundingBox): boolean { - return ( - node.x >= group.x && - node.y >= group.y && - node.x + node.width <= group.x + group.width && - node.y + node.height <= group.y + group.height - ); -} - -/** - * Assert no two standalone entities overlap - * Nodes inside groups are excluded from overlap checks with their containing groups - * (including parent groups in nested group hierarchies) - */ -export function assertNoOverlaps(graph: LGraph): void { - const entities = getAllEntities(graph); - const overlaps: string[] = []; - - for (let i = 0; i < entities.length; i++) { - for (let j = i + 1; j < entities.length; j++) { - const a = entities[i]; - const b = entities[j]; - - // Skip node-vs-group overlap if node is fully inside that group - // This handles nested groups: a node inside inner group is also inside outer group - if (a.id.startsWith("node_") && b.id.startsWith("group_")) { - if (isNodeInsideGroup(a, b)) continue; - } - if (b.id.startsWith("node_") && a.id.startsWith("group_")) { - if (isNodeInsideGroup(b, a)) continue; - } - - // Skip group-vs-group overlap if one contains the other (nested groups) - if (a.id.startsWith("group_") && b.id.startsWith("group_")) { - // Check if one fully contains the other - const aContainsB = - a.x <= b.x && - a.y <= b.y && - a.x + a.width >= b.x + b.width && - a.y + a.height >= b.y + b.height; - const bContainsA = - b.x <= a.x && - b.y <= a.y && - b.x + b.width >= a.x + a.width && - b.y + b.height >= a.y + a.height; - if (aContainsB || bContainsA) continue; - } - - if (boxesOverlap(a, b)) { - overlaps.push(`${a.id} overlaps ${b.id}`); - } - } - } - - expect(overlaps, `Found ${overlaps.length} overlapping entities`).toEqual([]); -} - -/** - * Assert all nodes that were inside groups remain inside their groups - * This catches bug 0008 where nodes ended up outside their groups - */ -export function assertNodesInsideGroups(graph: LGraph): void { - const errors: string[] = []; - - for (const group of graph._groups) { - const groupX = group.pos[0]; - const groupY = group.pos[1]; - const groupRight = groupX + group.size[0]; - const groupBottom = groupY + group.size[1]; - - // Find nodes that should be inside this group (based on current containment) - for (const node of graph._nodes) { - const nodeX = node.pos[0]; - const nodeY = node.pos[1]; - const nodeRight = nodeX + node.size[0]; - const nodeBottom = nodeY + node.size[1]; - - // Check if node center is inside group - const nodeCenterX = nodeX + node.size[0] / 2; - const nodeCenterY = nodeY + node.size[1] / 2; - - if ( - nodeCenterX >= groupX && - nodeCenterX <= groupRight && - nodeCenterY >= groupY && - nodeCenterY <= groupBottom - ) { - // Node center is inside group - verify entire node fits - if (nodeX < groupX || nodeY < groupY || nodeRight > groupRight || nodeBottom > groupBottom) { - errors.push( - `Node ${node.id} (${node.title}) extends outside group "${group.title}": ` + - `node [${nodeX}, ${nodeY}, ${nodeRight}, ${nodeBottom}] vs group [${groupX}, ${groupY}, ${groupRight}, ${groupBottom}]` - ); - } - } - } - } - - expect(errors, `Found ${errors.length} nodes outside their groups`).toEqual([]); -} - -/** - * Assert all coordinates are finite numbers - * Note: Negative coordinates are allowed since some workflows may position nodes above/left of origin - * This catches NaN and Infinity bugs - */ -export function assertFiniteCoordinates(graph: LGraph): void { - const errors: string[] = []; - - for (const node of graph._nodes) { - if (!Number.isFinite(node.pos[0]) || !Number.isFinite(node.pos[1])) { - errors.push(`Node ${node.id} has non-finite position: [${node.pos[0]}, ${node.pos[1]}]`); - } - } - - for (const group of graph._groups) { - if (!Number.isFinite(group.pos[0]) || !Number.isFinite(group.pos[1])) { - errors.push(`Group ${group.id} has non-finite position: [${group.pos[0]}, ${group.pos[1]}]`); - } - if (!Number.isFinite(group.size[0]) || !Number.isFinite(group.size[1])) { - errors.push(`Group ${group.id} has non-finite size: [${group.size[0]}, ${group.size[1]}]`); - } - } - - // Check subgraph I/O nodes if present - if (graph.inputNode?.pos) { - if (!Number.isFinite(graph.inputNode.pos[0]) || !Number.isFinite(graph.inputNode.pos[1])) { - errors.push(`I/O node ${graph.inputNode.id} has non-finite position: [${graph.inputNode.pos[0]}, ${graph.inputNode.pos[1]}]`); - } - } - if (graph.outputNode?.pos) { - if (!Number.isFinite(graph.outputNode.pos[0]) || !Number.isFinite(graph.outputNode.pos[1])) { - errors.push(`I/O node ${graph.outputNode.id} has non-finite position: [${graph.outputNode.pos[0]}, ${graph.outputNode.pos[1]}]`); - } - } - - expect(errors, `Found ${errors.length} invalid coordinates`).toEqual([]); -} - -/** - * Assert topological order is preserved: if A→B link exists, A must be positioned left of B - * This catches fundamental DAG ordering bugs - */ -export function assertTopologicalOrder(graph: LGraph): void { - const errors: string[] = []; - const nodeMap = new Map(); - - for (const node of graph._nodes) { - nodeMap.set(node.id, node); - } - - // Check each link - const links = graph.links instanceof Map ? graph.links.values() : Object.values(graph.links); - - for (const link of links) { - const sourceNode = nodeMap.get(link.origin_id); - const targetNode = nodeMap.get(link.target_id); - - if (!sourceNode || !targetNode) continue; - - // Skip reroute nodes (they're positioned along edges) - if (sourceNode.type === "Reroute" || targetNode.type === "Reroute") continue; - - // Source should be left of target (or at same X if in same layer) - const sourceRight = sourceNode.pos[0] + sourceNode.size[0]; - if (sourceRight > targetNode.pos[0] + targetNode.size[0]) { - errors.push( - `Link ${link.id}: source node ${sourceNode.id} (${sourceNode.title}) at X=${sourceNode.pos[0]} ` + - `is right of target node ${targetNode.id} (${targetNode.title}) at X=${targetNode.pos[0]}` - ); - } - } - - expect(errors, `Found ${errors.length} topological order violations`).toEqual([]); -} - -/** - * Assert layout is idempotent: running twice produces same positions - * This catches oscillation bugs like 0011 - */ -export function assertIdempotent(graph: LGraph): void { - // Capture positions after first layout (already run by caller) - const positions1 = capturePositions(graph); - - // Run layout again - layoutGraph(graph); - - // Capture positions after second layout - const positions2 = capturePositions(graph); - - // Compare - const differences: string[] = []; - - for (const [id, pos1] of positions1) { - const pos2 = positions2.get(id); - if (!pos2) { - differences.push(`${id} missing after second layout`); - continue; - } - - // Allow small movements (up to 5 pixels) due to rounding in group resize/centering - const tolerance = 5; - if ( - Math.abs(pos1.x - pos2.x) > tolerance || - Math.abs(pos1.y - pos2.y) > tolerance - ) { - differences.push( - `${id} moved: [${pos1.x.toFixed(1)}, ${pos1.y.toFixed(1)}] → [${pos2.x.toFixed(1)}, ${pos2.y.toFixed(1)}]` - ); - } - } - - expect(differences, `Layout is not idempotent: ${differences.length} entities moved`).toEqual([]); -} - -/** - * Assert that nodes which were inside groups before layout remain inside their groups after - * This catches regressions where layout breaks group containment - * Must be called with graph state BEFORE and AFTER layout - */ -export function assertGroupMembershipPreserved( - originalGraph: LGraph, - layoutedGraph: LGraph -): void { - const errors: string[] = []; - - // Get which nodes were inside which groups BEFORE layout - const beforeMembership = getNodesInsideGroups(originalGraph); - - // For each node that was in a group, verify it's still in that group after layout - for (const [nodeId, originalGroup] of beforeMembership) { - const node = layoutedGraph._nodes.find((n) => n.id === nodeId); - const group = layoutedGraph._groups.find((g) => g.id === originalGroup.id); - - if (!node || !group) continue; - - // Check if node is still fully inside the group - const nodeRight = node.pos[0] + node.size[0]; - const nodeBottom = node.pos[1] + node.size[1]; - const groupRight = group.pos[0] + group.size[0]; - const groupBottom = group.pos[1] + group.size[1]; - - if ( - node.pos[0] < group.pos[0] || - node.pos[1] < group.pos[1] || - nodeRight > groupRight || - nodeBottom > groupBottom - ) { - errors.push( - `Node ${nodeId} (${node.title}) was in group "${originalGroup.title}" but is now outside: ` + - `node [${node.pos[0].toFixed(0)}, ${node.pos[1].toFixed(0)}] vs group [${group.pos[0].toFixed(0)}, ${group.pos[1].toFixed(0)}, ${group.size[0].toFixed(0)}, ${group.size[1].toFixed(0)}]` - ); - } - } - - expect( - errors, - `Found ${errors.length} nodes that moved outside their original groups` - ).toEqual([]); -} diff --git a/tests/unit/adapter.test.ts b/tests/unit/adapter.test.ts new file mode 100644 index 0000000..72b9fcd --- /dev/null +++ b/tests/unit/adapter.test.ts @@ -0,0 +1,744 @@ +/** + * Tests for src/adapter.ts — the bridge between ComfyUI graph objects + * and the pure layout framework types. + */ + +import { describe, it, expect } from "vitest"; +import { + extractLayoutInput, + extractGroupLayoutInput, + applyLayoutOutput, + anchorSelectedGroupLayoutResult, +} from "../../src/adapter"; +import type { GraphLike } from "../../src/adapter"; +import type { + Position, + GroupBounds, + LayoutGroup, + FrameworkResult, +} from "../../src/layout/types"; + +// --------------------------------------------------------------------------- +// Helper: create a minimal graph-like object +// --------------------------------------------------------------------------- + +function makeGraph(opts: { + nodes?: Array<{ + id: number; + type: string; + title: string; + pos: number[]; + size: number[]; + }>; + inputNode?: { + id: number; + pos: number[]; + size: number[]; + }; + outputNode?: { + id: number; + pos: number[]; + size: number[]; + }; + groups?: Array<{ + id: number; + title: string; + pos: number[]; + size: number[]; + childrenIds?: number[]; + includeChildrenSet?: boolean; + }>; + links?: Array<{ + id: number; + origin_id: number; + target_id: number; + }>; +}): GraphLike { + const nodes = (opts.nodes ?? []).map((n) => ({ + id: n.id, + type: n.type, + title: n.title, + pos: [...n.pos], + size: [...n.size], + inputs: [], + outputs: [], + })); + + const groups = (opts.groups ?? []).map((g) => { + const childrenIdSet = new Set(g.childrenIds ?? []); + // _children includes all nodes/groups spatially inside + const children = + g.includeChildrenSet === false + ? undefined + : new Set( + nodes + .filter((n) => isInsideGroup(n, g)) + .map((n) => ({ id: n.id })), + ); + if (children) { + for (const cid of childrenIdSet) { + children.add({ id: cid }); + } + } + return { + id: g.id, + title: g.title, + pos: [...g.pos], + size: [...g.size], + _children: children as ReadonlySet<{ readonly id: number }> | undefined, + }; + }); + + const linksMap = new Map< + number, + { id: number; origin_id: number; target_id: number } + >(); + for (const link of opts.links ?? []) { + linksMap.set(link.id, link); + } + + const graph = { + _nodes: nodes, + _groups: groups, + links: linksMap, + setDirtyCanvas: undefined, + }; + + if (opts.inputNode) { + Object.assign(graph, { + inputNode: { + id: opts.inputNode.id, + pos: [...opts.inputNode.pos], + size: [...opts.inputNode.size], + }, + }); + } + + if (opts.outputNode) { + Object.assign(graph, { + outputNode: { + id: opts.outputNode.id, + pos: [...opts.outputNode.pos], + size: [...opts.outputNode.size], + }, + }); + } + + return graph as GraphLike; +} + +function isInsideGroup( + node: { pos: number[]; size: number[] }, + group: { pos: number[]; size: number[] }, +): boolean { + return ( + node.pos[0] >= group.pos[0] && + node.pos[1] >= group.pos[1] && + node.pos[0] + node.size[0] <= group.pos[0] + group.size[0] && + node.pos[1] + node.size[1] <= group.pos[1] + group.size[1] + ); +} + +// --------------------------------------------------------------------------- +// extractLayoutInput +// --------------------------------------------------------------------------- + +describe("extractLayoutInput", () => { + it("extracts nodes with correct ids and dimensions", () => { + const graph = makeGraph({ + nodes: [ + { id: 1, type: "KSampler", title: "Sampler", pos: [0, 0], size: [200, 100] }, + { id: 2, type: "VAEDecode", title: "Decode", pos: [300, 0], size: [150, 80] }, + ], + }); + + const { nodes } = extractLayoutInput(graph); + + expect(nodes).toHaveLength(2); + expect(nodes[0]).toEqual({ id: "1", width: 200, height: 100 }); + expect(nodes[1]).toEqual({ id: "2", width: 150, height: 80 }); + }); + + it("extracts edges from links map", () => { + const graph = makeGraph({ + nodes: [ + { id: 1, type: "A", title: "A", pos: [0, 0], size: [100, 50] }, + { id: 2, type: "B", title: "B", pos: [200, 0], size: [100, 50] }, + ], + links: [{ id: 1, origin_id: 1, target_id: 2 }], + }); + + const { edges } = extractLayoutInput(graph); + + expect(edges).toHaveLength(1); + expect(edges[0]).toEqual({ source: "1", target: "2" }); + }); + + it("returns empty arrays for an empty graph", () => { + const graph = makeGraph({}); + const { nodes, edges, groups } = extractLayoutInput(graph); + + expect(nodes).toHaveLength(0); + expect(edges).toHaveLength(0); + expect(groups).toHaveLength(0); + }); + + it("builds group hierarchy from spatial containment", () => { + const graph = makeGraph({ + nodes: [ + { id: 1, type: "A", title: "A", pos: [120, 120], size: [100, 50] }, + { id: 2, type: "B", title: "B", pos: [520, 120], size: [100, 50] }, + ], + groups: [ + { id: 100, title: "Outer", pos: [0, 0], size: [1000, 500] }, + { id: 101, title: "Inner", pos: [100, 100], size: [300, 200] }, + ], + }); + + const { groups } = extractLayoutInput(graph); + + // Outer group should list Inner as child + const outer = groups.find((g) => g.id === "group:100"); + expect(outer).toBeDefined(); + expect(outer!.childGroupIds).toContain("group:101"); + + // Inner group should contain node 1, outer should contain node 2 (not in inner) + const inner = groups.find((g) => g.id === "group:101"); + expect(inner).toBeDefined(); + expect(inner!.memberIds).toContain("1"); + expect(inner!.childGroupIds).toHaveLength(0); + + // Node 2 is in outer but not in inner + expect(outer!.memberIds).toContain("2"); + expect(outer!.memberIds).not.toContain("1"); // 1 is in inner, not direct member of outer + }); + + it("infers group members from spatial containment when _children is missing", () => { + const graph = makeGraph({ + nodes: [ + { id: 1, type: "A", title: "A", pos: [120, 120], size: [100, 50] }, + { id: 2, type: "B", title: "B", pos: [520, 120], size: [100, 50] }, + ], + groups: [ + { + id: 100, + title: "Outer", + pos: [0, 0], + size: [1000, 500], + includeChildrenSet: false, + }, + { + id: 101, + title: "Inner", + pos: [100, 100], + size: [300, 200], + includeChildrenSet: false, + }, + ], + }); + + const { groups } = extractLayoutInput(graph); + const outer = groups.find((group) => group.id === "group:100"); + const inner = groups.find((group) => group.id === "group:101"); + + expect(inner?.memberIds).toEqual(["1"]); + expect(outer?.memberIds).toEqual(["2"]); + expect(outer?.childGroupIds).toEqual(["group:101"]); + }); + + it("treats a node whose center is inside a group as a member", () => { + const graph = makeGraph({ + nodes: [ + { + id: 1, + type: "PreviewImage", + title: "Preview", + pos: [120, 50], + size: [120, 120], + }, + ], + groups: [ + { + id: 100, + title: "Wrapper", + pos: [100, 100], + size: [100, 100], + includeChildrenSet: false, + }, + ], + }); + + const { groups } = extractLayoutInput(graph); + + expect(groups[0]?.memberIds).toEqual(["1"]); + }); + + it("excludes a node whose center is outside the group", () => { + const graph = makeGraph({ + nodes: [ + { + id: 1, + type: "PreviewImage", + title: "Preview", + pos: [10, 50], + size: [120, 120], + }, + ], + groups: [ + { + id: 100, + title: "Wrapper", + pos: [100, 100], + size: [100, 100], + includeChildrenSet: false, + }, + ], + }); + + const { groups } = extractLayoutInput(graph); + + expect(groups[0]?.memberIds).toEqual([]); + }); + + it("assigns the nearest containing group as parent", () => { + const graph = makeGraph({ + nodes: [ + { id: 1, type: "A", title: "A", pos: [140, 140], size: [80, 40] }, + ], + groups: [ + { + id: 100, + title: "Outer", + pos: [0, 0], + size: [1000, 1000], + includeChildrenSet: false, + }, + { + id: 101, + title: "Middle", + pos: [100, 100], + size: [400, 400], + includeChildrenSet: false, + }, + { + id: 102, + title: "Inner", + pos: [120, 120], + size: [200, 200], + includeChildrenSet: false, + }, + ], + }); + + const { groups } = extractLayoutInput(graph); + const outer = groups.find((group) => group.id === "group:100"); + const middle = groups.find((group) => group.id === "group:101"); + const inner = groups.find((group) => group.id === "group:102"); + + expect(outer?.childGroupIds).toEqual(["group:101"]); + expect(middle?.childGroupIds).toEqual(["group:102"]); + expect(inner?.memberIds).toEqual(["1"]); + }); + + it("namespaces group ids to avoid collisions with node ids", () => { + const graph = makeGraph({ + nodes: [ + { id: 3, type: "KSampler", title: "KSampler", pos: [500, 100], size: [100, 50] }, + ], + groups: [ + { + id: 3, + title: "Prompt Group", + pos: [0, 0], + size: [1000, 500], + includeChildrenSet: false, + }, + ], + }); + + const { nodes, groups } = extractLayoutInput(graph); + + expect(nodes[0].id).toBe("3"); + expect(groups[0].id).toBe("group:3"); + }); + + it("parses layout tokens from group titles", () => { + const graph = makeGraph({ + nodes: [ + { id: 1, type: "A", title: "A", pos: [20, 20], size: [100, 50] }, + ], + groups: [ + { + id: 100, + title: "My Group [HORIZONTAL]", + pos: [0, 0], + size: [500, 300], + }, + ], + }); + + const { groups } = extractLayoutInput(graph); + + expect(groups[0].token).toBeDefined(); + expect(groups[0].id).toBe("group:100"); + expect(groups[0].token!.mode).toBe("horizontal"); + }); + + it("handles groups with no token in title", () => { + const graph = makeGraph({ + nodes: [ + { id: 1, type: "A", title: "A", pos: [20, 20], size: [100, 50] }, + ], + groups: [ + { id: 100, title: "Plain Group", pos: [0, 0], size: [500, 300] }, + ], + }); + + const { groups } = extractLayoutInput(graph); + + expect(groups[0].token).toBeUndefined(); + }); + + it("handles links stored as Record (old ComfyUI format)", () => { + const nodes = [ + { + id: 1, + type: "A", + title: "A", + pos: [0, 0], + size: [100, 50], + inputs: [] as Array<{ link: number | null }>, + outputs: [] as Array<{ links: number[] | null }>, + }, + { + id: 2, + type: "B", + title: "B", + pos: [200, 0], + size: [100, 50], + inputs: [] as Array<{ link: number | null }>, + outputs: [] as Array<{ links: number[] | null }>, + }, + ]; + // Use Record instead of Map for links + const linksRecord: Record = { + 1: { id: 1, origin_id: 1, target_id: 2 }, + }; + const graph: GraphLike = { + _nodes: nodes, + _groups: [], + links: linksRecord as GraphLike["links"], + }; + + const { edges } = extractLayoutInput(graph); + + expect(edges).toHaveLength(1); + expect(edges[0]).toEqual({ source: "1", target: "2" }); + }); + + it("includes subgraph boundary nodes in extracted layout input", () => { + const graph = makeGraph({ + nodes: [ + { id: 1, type: "A", title: "A", pos: [0, 0], size: [100, 50] }, + ], + inputNode: { id: -10, pos: [-300, 0], size: [120, 220] }, + outputNode: { id: -20, pos: [300, 0], size: [120, 60] }, + links: [ + { id: 1, origin_id: -10, target_id: 1 }, + { id: 2, origin_id: 1, target_id: -20 }, + ], + }); + + const { nodes, edges } = extractLayoutInput(graph); + + expect(nodes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "-10", width: 120, height: 220 }), + expect.objectContaining({ id: "1", width: 100, height: 50 }), + expect.objectContaining({ id: "-20", width: 120, height: 60 }), + ]), + ); + expect(edges).toEqual( + expect.arrayContaining([ + { source: "-10", target: "1" }, + { source: "1", target: "-20" }, + ]), + ); + }); +}); + +// --------------------------------------------------------------------------- +// applyLayoutOutput +// --------------------------------------------------------------------------- + +describe("applyLayoutOutput", () => { + it("applies node positions from layout result", () => { + const graph = makeGraph({ + nodes: [ + { id: 1, type: "A", title: "A", pos: [0, 0], size: [100, 50] }, + { id: 2, type: "B", title: "B", pos: [0, 0], size: [100, 50] }, + ], + }); + + const positions = new Map([ + ["1", { x: 100, y: 200 }], + ["2", { x: 300, y: 400 }], + ]); + const groupBounds = new Map(); + + applyLayoutOutput(graph, { positions, groupBounds }); + + expect(graph._nodes[0].pos[0]).toBe(100); + expect(graph._nodes[0].pos[1]).toBe(200); + expect(graph._nodes[1].pos[0]).toBe(300); + expect(graph._nodes[1].pos[1]).toBe(400); + }); + + it("applies group bounds from layout result", () => { + const graph = makeGraph({ + groups: [ + { id: 100, title: "Group", pos: [0, 0], size: [100, 100] }, + ], + }); + + const positions = new Map(); + const groupBounds = new Map([ + ["group:100", { x: 50, y: 60, width: 500, height: 300 }], + ]); + + applyLayoutOutput(graph, { positions, groupBounds }); + + expect(graph._groups[0].pos[0]).toBe(50); + expect(graph._groups[0].pos[1]).toBe(60); + expect(graph._groups[0].size[0]).toBe(500); + expect(graph._groups[0].size[1]).toBe(300); + }); + + it("mutates Float64Array in-place", () => { + const node = { + id: 1, + type: "A", + title: "A", + pos: new Float64Array([0, 0]), + size: new Float64Array([100, 50]), + inputs: [] as Array<{ link: number | null }>, + outputs: [] as Array<{ links: number[] | null }>, + }; + const graph: GraphLike = { + _nodes: [node], + _groups: [], + links: new Map(), + }; + + const positions = new Map([ + ["1", { x: 42, y: 84 }], + ]); + applyLayoutOutput(graph, { positions, groupBounds: new Map() }); + + // Same Float64Array instance, mutated in-place + expect(node.pos).toBeInstanceOf(Float64Array); + expect(node.pos[0]).toBe(42); + expect(node.pos[1]).toBe(84); + }); + + it("calls setDirtyCanvas when available", () => { + let dirtyCalled = false; + const graph = makeGraph({ + nodes: [ + { id: 1, type: "A", title: "A", pos: [0, 0], size: [100, 50] }, + ], + }); + (graph as { setDirtyCanvas: (fg: boolean, bg: boolean) => void }).setDirtyCanvas = () => { + dirtyCalled = true; + }; + + applyLayoutOutput(graph, { + positions: new Map([["1", { x: 0, y: 0 }]]), + groupBounds: new Map(), + }); + + expect(dirtyCalled).toBe(true); + }); + + it("skips nodes without matching position data", () => { + const graph = makeGraph({ + nodes: [ + { id: 1, type: "A", title: "A", pos: [10, 20], size: [100, 50] }, + ], + }); + + // Empty positions map — node should not be modified + applyLayoutOutput(graph, { + positions: new Map(), + groupBounds: new Map(), + }); + + expect(graph._nodes[0].pos[0]).toBe(10); + expect(graph._nodes[0].pos[1]).toBe(20); + }); + + it("applies positions to subgraph boundary nodes", () => { + const graph = makeGraph({ + nodes: [ + { id: 1, type: "A", title: "A", pos: [10, 20], size: [100, 50] }, + ], + inputNode: { id: -10, pos: [-300, 0], size: [120, 220] }, + outputNode: { id: -20, pos: [300, 0], size: [120, 60] }, + }) as GraphLike & { + inputNode: { pos: number[] }; + outputNode: { pos: number[] }; + }; + + applyLayoutOutput(graph, { + positions: new Map([ + ["-10", { x: -100, y: 40 }], + ["1", { x: 100, y: 200 }], + ["-20", { x: 400, y: 60 }], + ]), + groupBounds: new Map(), + }); + + expect(graph.inputNode.pos).toEqual([-100, 40]); + expect(graph._nodes[0].pos).toEqual([100, 200]); + expect(graph.outputNode.pos).toEqual([400, 60]); + }); +}); + +// --------------------------------------------------------------------------- +// extractGroupLayoutInput +// --------------------------------------------------------------------------- + +describe("extractGroupLayoutInput", () => { + it("filters to only selected groups and their members", () => { + const graph = makeGraph({ + nodes: [ + { id: 1, type: "A", title: "A", pos: [20, 20], size: [100, 50] }, + { id: 2, type: "B", title: "B", pos: [520, 20], size: [100, 50] }, + { id: 3, type: "C", title: "C", pos: [1020, 20], size: [100, 50] }, + ], + groups: [ + { id: 100, title: "Group 1", pos: [0, 0], size: [500, 300] }, + { id: 101, title: "Group 2", pos: [500, 0], size: [500, 300] }, + ], + links: [ + { id: 1, origin_id: 1, target_id: 2 }, + { id: 2, origin_id: 2, target_id: 3 }, + ], + }); + + // Select only group 100 + const { nodes, edges, groups } = extractGroupLayoutInput( + graph, + new Set([100]), + ); + + expect(groups).toHaveLength(1); + expect(groups[0].id).toBe("group:100"); + expect(nodes.some((n) => n.id === "1")).toBe(true); + // Node 2 is in group 101, not selected — should not appear + expect(nodes.some((n) => n.id === "2")).toBe(false); + expect(nodes.some((n) => n.id === "3")).toBe(false); + // Edge from 1->2 crosses group boundaries, target not included + expect(edges).toHaveLength(0); + }); + + it("includes nested child groups when parent is selected", () => { + const graph = makeGraph({ + nodes: [ + { id: 1, type: "A", title: "A", pos: [120, 120], size: [100, 50] }, + { id: 2, type: "B", title: "B", pos: [520, 120], size: [100, 50] }, + ], + groups: [ + { id: 100, title: "Outer", pos: [0, 0], size: [1000, 500] }, + { id: 101, title: "Inner", pos: [100, 100], size: [300, 200] }, + ], + }); + + const { groups } = extractGroupLayoutInput(graph, new Set([100])); + + // Both outer and inner should be included + expect(groups).toHaveLength(2); + const ids = groups.map((g) => g.id); + expect(ids).toContain("group:100"); + expect(ids).toContain("group:101"); + }); + + it("keeps edges between members of selected groups", () => { + const graph = makeGraph({ + nodes: [ + { id: 1, type: "A", title: "A", pos: [20, 20], size: [100, 50] }, + { id: 2, type: "B", title: "B", pos: [200, 20], size: [100, 50] }, + ], + groups: [ + { id: 100, title: "Group", pos: [0, 0], size: [500, 300] }, + ], + links: [{ id: 1, origin_id: 1, target_id: 2 }], + }); + + const { edges } = extractGroupLayoutInput(graph, new Set([100])); + + expect(edges).toHaveLength(1); + expect(edges[0]).toEqual({ source: "1", target: "2" }); + }); +}); + +// --------------------------------------------------------------------------- +// anchorSelectedGroupLayoutResult +// --------------------------------------------------------------------------- + +describe("anchorSelectedGroupLayoutResult", () => { + it("reanchors a selected nested group subtree to its original position", () => { + const graph = makeGraph({ + groups: [ + { id: 100, title: "Outer", pos: [100, 100], size: [600, 400] }, + { id: 101, title: "Inner", pos: [250, 220], size: [250, 180] }, + ], + }); + const groups: LayoutGroup[] = [ + { + id: "group:101", + title: "Inner", + memberIds: ["1"], + childGroupIds: ["group:102"], + }, + { + id: "group:102", + title: "Child", + memberIds: ["2"], + childGroupIds: [], + }, + ]; + const result: FrameworkResult = { + positions: new Map([ + ["1", { x: 30, y: 60 }], + ["2", { x: 80, y: 120 }], + ]), + groupBounds: new Map([ + ["group:101", { x: 0, y: 0, width: 300, height: 220 }], + ["group:102", { x: 40, y: 90, width: 120, height: 100 }], + ]), + }; + + const anchored = anchorSelectedGroupLayoutResult( + graph, + groups, + new Set([101]), + result, + ); + + expect(anchored.groupBounds.get("group:101")).toEqual({ + x: 250, + y: 220, + width: 300, + height: 220, + }); + expect(anchored.groupBounds.get("group:102")).toEqual({ + x: 290, + y: 310, + width: 120, + height: 100, + }); + expect(anchored.positions.get("1")).toEqual({ x: 280, y: 280 }); + expect(anchored.positions.get("2")).toEqual({ x: 330, y: 340 }); + }); +}); diff --git a/tests/unit/compact.test.ts b/tests/unit/compact.test.ts new file mode 100644 index 0000000..97c147c --- /dev/null +++ b/tests/unit/compact.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from "vitest"; +import { + compactVertically, + compactHorizontally, +} from "../../src/layout/compact"; +import type { Position, LayoutNode } from "../../src/layout/types"; + +function node(id: string, width = 100, height = 60): LayoutNode { + return { id, width, height }; +} + +// --------------------------------------------------------------------------- +// compactVertically +// --------------------------------------------------------------------------- + +describe("compactVertically", () => { + it("removes vertical gaps between nodes", () => { + const nodes = [node("A", 100, 50), node("B", 100, 50)]; + const positions = new Map([ + ["A", { x: 0, y: 0 }], + ["B", { x: 0, y: 200 }], // 150px gap after A (A ends at 50) + ]); + + const result = compactVertically(positions, nodes, 10); + + expect(result.get("A")!.y).toBe(0); + // B should be shifted up to A.y + A.height + gap = 0 + 50 + 10 = 60 + expect(result.get("B")!.y).toBe(60); + }); + + it("does not change already-compact layout", () => { + const nodes = [node("A", 100, 50), node("B", 100, 50)]; + const positions = new Map([ + ["A", { x: 0, y: 0 }], + ["B", { x: 0, y: 60 }], // exactly 10px gap after A + ]); + + const result = compactVertically(positions, nodes, 10); + + expect(result.get("A")!.y).toBe(0); + expect(result.get("B")!.y).toBe(60); + }); + + it("handles single node", () => { + const nodes = [node("A")]; + const positions = new Map([["A", { x: 5, y: 100 }]]); + + const result = compactVertically(positions, nodes, 10); + + expect(result.get("A")!.y).toBe(100); + expect(result.get("A")!.x).toBe(5); // X unchanged + }); + + it("returns empty map for empty input", () => { + const result = compactVertically(new Map(), [], 10); + expect(result.size).toBe(0); + }); + + it("preserves X coordinates", () => { + const nodes = [node("A", 100, 50), node("B", 100, 50)]; + const positions = new Map([ + ["A", { x: 100, y: 0 }], + ["B", { x: 200, y: 500 }], + ]); + + const result = compactVertically(positions, nodes, 10); + + expect(result.get("A")!.x).toBe(100); + expect(result.get("B")!.x).toBe(200); + }); +}); + +// --------------------------------------------------------------------------- +// compactHorizontally +// --------------------------------------------------------------------------- + +describe("compactHorizontally", () => { + it("removes horizontal gaps between nodes", () => { + const nodes = [node("A", 80, 50), node("B", 80, 50)]; + const positions = new Map([ + ["A", { x: 0, y: 0 }], + ["B", { x: 300, y: 0 }], // 220px gap after A (A ends at 80) + ]); + + const result = compactHorizontally(positions, nodes, 10); + + expect(result.get("A")!.x).toBe(0); + // B should be shifted left to A.x + A.width + gap = 0 + 80 + 10 = 90 + expect(result.get("B")!.x).toBe(90); + }); + + it("does not change already-compact layout", () => { + const nodes = [node("A", 80, 50), node("B", 80, 50)]; + const positions = new Map([ + ["A", { x: 0, y: 0 }], + ["B", { x: 90, y: 0 }], // exactly 10px gap + ]); + + const result = compactHorizontally(positions, nodes, 10); + + expect(result.get("A")!.x).toBe(0); + expect(result.get("B")!.x).toBe(90); + }); + + it("preserves Y coordinates", () => { + const nodes = [node("A", 80, 50), node("B", 80, 50)]; + const positions = new Map([ + ["A", { x: 0, y: 100 }], + ["B", { x: 500, y: 200 }], + ]); + + const result = compactHorizontally(positions, nodes, 10); + + expect(result.get("A")!.y).toBe(100); + expect(result.get("B")!.y).toBe(200); + }); + + it("returns empty map for empty input", () => { + const result = compactHorizontally(new Map(), [], 10); + expect(result.size).toBe(0); + }); +}); diff --git a/tests/unit/e2e-fixtures.test.ts b/tests/unit/e2e-fixtures.test.ts new file mode 100644 index 0000000..801f1fb --- /dev/null +++ b/tests/unit/e2e-fixtures.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { listRepoFixtures, loadFixture } from "../e2e/fixtures"; + +describe("repo fixture loader", () => { + it("discovers flat checked-in fixtures", () => { + expect(listRepoFixtures()).toContain("simple-dag"); + expect(listRepoFixtures()).toContain("token-testing"); + }); + + it("loads a flat checked-in fixture", () => { + const workflow = loadFixture("simple-dag"); + expect(workflow).toHaveProperty("nodes"); + }); +}); diff --git a/tests/unit/framework.test.ts b/tests/unit/framework.test.ts new file mode 100644 index 0000000..bb09463 --- /dev/null +++ b/tests/unit/framework.test.ts @@ -0,0 +1,616 @@ +import { describe, it, expect } from "vitest"; +import { + layoutWithGroups, + buildGroupHierarchy, + splitDisconnected, + placeDisconnected, + translatePositions, +} from "../../src/layout/framework"; +import type { + LayoutNode, + LayoutEdge, + LayoutGroup, + LayoutAlgorithm, + LayoutInput, + LayoutOutput, + Position, + FrameworkConfig, +} from "../../src/layout/types"; +import { DEFAULT_FRAMEWORK_CONFIG } from "../../src/layout/types"; + +// --------------------------------------------------------------------------- +// Mock algorithm: places nodes in a horizontal line +// --------------------------------------------------------------------------- + +const mockAlgorithm: LayoutAlgorithm = { + name: "mock-horizontal-line", + layout(input: LayoutInput): LayoutOutput { + const positions = new Map(); + // Determine max width for spacing + let maxWidth = 0; + for (const n of input.nodes) { + if (n.width > maxWidth) maxWidth = n.width; + } + const gap = 50; + + // Sort nodes by ID for determinism + const sorted = [...input.nodes].sort((a, b) => + a.id.localeCompare(b.id, undefined, { numeric: true }), + ); + + let x = 0; + for (const n of sorted) { + positions.set(n.id, { x, y: 0 }); + x += n.width + gap; + } + + return { positions }; + }, +}; + +// --------------------------------------------------------------------------- +// Helper to create nodes +// --------------------------------------------------------------------------- + +function node(id: string, width = 100, height = 60): LayoutNode { + return { id, width, height }; +} + +function edge(source: string, target: string): LayoutEdge { + return { source, target }; +} + +// --------------------------------------------------------------------------- +// Tests: layoutWithGroups +// --------------------------------------------------------------------------- + +describe("layoutWithGroups", () => { + it("places a single node", () => { + const nodes = [node("A")]; + const result = layoutWithGroups(nodes, [], [], mockAlgorithm); + + expect(result.positions.size).toBe(1); + const pos = result.positions.get("A"); + expect(pos).toBeDefined(); + expect(Number.isFinite(pos!.x)).toBe(true); + expect(Number.isFinite(pos!.y)).toBe(true); + }); + + it("lays out a simple chain A->B->C", () => { + const nodes = [node("A"), node("B"), node("C")]; + const edges = [edge("A", "B"), edge("B", "C")]; + const result = layoutWithGroups(nodes, edges, [], mockAlgorithm); + + expect(result.positions.size).toBe(3); + // All should have finite coordinates + for (const [, pos] of result.positions) { + expect(Number.isFinite(pos.x)).toBe(true); + expect(Number.isFinite(pos.y)).toBe(true); + } + }); + + it("places disconnected nodes left of DAG", () => { + const nodes = [node("A"), node("B"), node("C"), node("D")]; + const edges = [edge("A", "B")]; // C and D are disconnected + const result = layoutWithGroups(nodes, edges, [], mockAlgorithm); + + expect(result.positions.size).toBe(4); + + const posA = result.positions.get("A")!; + const posC = result.positions.get("C")!; + const posD = result.positions.get("D")!; + + // Disconnected nodes should be left of connected ones + expect(posC.x).toBeLessThan(posA.x); + expect(posD.x).toBeLessThan(posA.x); + + // Disconnected nodes should be stacked vertically + expect(posD.y).toBeGreaterThan(posC.y); + }); + + it("lays out a group containing 3 nodes with correct bounds", () => { + const nodes = [node("A"), node("B"), node("C")]; + const edges = [edge("A", "B")]; + const groups: LayoutGroup[] = [ + { + id: "g1", + title: "Group 1", + memberIds: ["A", "B", "C"], + childGroupIds: [], + }, + ]; + + const result = layoutWithGroups(nodes, edges, groups, mockAlgorithm); + + // All nodes should be positioned + expect(result.positions.size).toBe(3); + + // Group bounds should exist and contain all members + const bounds = result.groupBounds.get("g1"); + expect(bounds).toBeDefined(); + expect(bounds!.width).toBeGreaterThan(0); + expect(bounds!.height).toBeGreaterThan(0); + + // All members should be inside group bounds + for (const mId of ["A", "B", "C"]) { + const pos = result.positions.get(mId)!; + expect(pos.x).toBeGreaterThanOrEqual(bounds!.x); + expect(pos.y).toBeGreaterThanOrEqual(bounds!.y); + const n = nodes.find((nd) => nd.id === mId)!; + expect(pos.x + n.width).toBeLessThanOrEqual(bounds!.x + bounds!.width); + expect(pos.y + n.height).toBeLessThanOrEqual(bounds!.y + bounds!.height); + } + }); + + it("handles nested groups (inner laid out first, then as rectangle in outer)", () => { + const nodes = [node("A"), node("B"), node("C"), node("D")]; + const edges = [edge("A", "B")]; + const groups: LayoutGroup[] = [ + { + id: "inner", + title: "Inner", + memberIds: ["A", "B"], + childGroupIds: [], + }, + { + id: "outer", + title: "Outer", + memberIds: ["C"], + childGroupIds: ["inner"], + }, + ]; + + const result = layoutWithGroups(nodes, edges, groups, mockAlgorithm); + + // All 4 nodes positioned (D is ungrouped, at root) + expect(result.positions.size).toBe(4); + + // Both groups should have bounds + expect(result.groupBounds.has("inner")).toBe(true); + expect(result.groupBounds.has("outer")).toBe(true); + + const innerBounds = result.groupBounds.get("inner")!; + const outerBounds = result.groupBounds.get("outer")!; + + // Inner group should be contained within outer group + expect(innerBounds.x).toBeGreaterThanOrEqual(outerBounds.x); + expect(innerBounds.y).toBeGreaterThanOrEqual(outerBounds.y); + expect(innerBounds.x + innerBounds.width).toBeLessThanOrEqual( + outerBounds.x + outerBounds.width, + ); + expect(innerBounds.y + innerBounds.height).toBeLessThanOrEqual( + outerBounds.y + outerBounds.height, + ); + }); + + it("handles mixed grouped and ungrouped nodes", () => { + const nodes = [node("A"), node("B"), node("C")]; + const edges = [edge("A", "C")]; // A is grouped, C is ungrouped, edge between them + const groups: LayoutGroup[] = [ + { + id: "g1", + title: "Group 1", + memberIds: ["A", "B"], + childGroupIds: [], + }, + ]; + + const result = layoutWithGroups(nodes, edges, groups, mockAlgorithm); + + // All nodes positioned + expect(result.positions.size).toBe(3); + expect(result.groupBounds.size).toBe(1); + + // Ungrouped node C should be positioned + expect(result.positions.has("C")).toBe(true); + }); + + it("returns empty maps for empty input", () => { + const result = layoutWithGroups([], [], [], mockAlgorithm); + expect(result.positions.size).toBe(0); + expect(result.groupBounds.size).toBe(0); + }); + + it("produces idempotent results", () => { + const nodes = [node("A"), node("B"), node("C"), node("D")]; + const edges = [edge("A", "B"), edge("B", "C")]; + const groups: LayoutGroup[] = [ + { + id: "g1", + title: "Group 1", + memberIds: ["A", "B"], + childGroupIds: [], + }, + ]; + + const result1 = layoutWithGroups(nodes, edges, groups, mockAlgorithm); + const result2 = layoutWithGroups(nodes, edges, groups, mockAlgorithm); + + // Positions should be identical + expect(result1.positions.size).toBe(result2.positions.size); + for (const [id, pos1] of result1.positions) { + const pos2 = result2.positions.get(id); + expect(pos2).toBeDefined(); + expect(pos1.x).toBe(pos2!.x); + expect(pos1.y).toBe(pos2!.y); + } + + // Group bounds should be identical + expect(result1.groupBounds.size).toBe(result2.groupBounds.size); + for (const [id, b1] of result1.groupBounds) { + const b2 = result2.groupBounds.get(id); + expect(b2).toBeDefined(); + expect(b1.x).toBe(b2!.x); + expect(b1.y).toBe(b2!.y); + expect(b1.width).toBe(b2!.width); + expect(b1.height).toBe(b2!.height); + } + }); + + it("handles a group with only disconnected nodes", () => { + const nodes = [node("A"), node("B"), node("C")]; + const groups: LayoutGroup[] = [ + { + id: "g1", + title: "Disconnected Group", + memberIds: ["A", "B", "C"], + childGroupIds: [], + }, + ]; + + const result = layoutWithGroups(nodes, [], groups, mockAlgorithm); + + expect(result.positions.size).toBe(3); + expect(result.groupBounds.has("g1")).toBe(true); + + // All nodes should have finite positions + for (const [, pos] of result.positions) { + expect(Number.isFinite(pos.x)).toBe(true); + expect(Number.isFinite(pos.y)).toBe(true); + } + }); + + it("handles multiple top-level groups", () => { + const nodes = [node("A"), node("B"), node("C"), node("D")]; + const edges = [edge("A", "C")]; // Cross-group edge + const groups: LayoutGroup[] = [ + { + id: "g1", + title: "Group 1", + memberIds: ["A", "B"], + childGroupIds: [], + }, + { + id: "g2", + title: "Group 2", + memberIds: ["C", "D"], + childGroupIds: [], + }, + ]; + + const result = layoutWithGroups(nodes, edges, groups, mockAlgorithm); + + // All nodes positioned + expect(result.positions.size).toBe(4); + // Both groups have bounds + expect(result.groupBounds.size).toBe(2); + expect(result.groupBounds.has("g1")).toBe(true); + expect(result.groupBounds.has("g2")).toBe(true); + }); + + it("uses group title tokens to override the default algorithm", () => { + const nodes = [node("A", 100, 60), node("B", 100, 60)]; + const groups: LayoutGroup[] = [ + { + id: "g1", + title: "Horizontal", + memberIds: ["A", "B"], + childGroupIds: [], + token: { mode: "horizontal" }, + }, + ]; + + const result = layoutWithGroups(nodes, [], groups, mockAlgorithm); + const posA = result.positions.get("A")!; + const posB = result.positions.get("B")!; + + expect(posA.y).toBe(posB.y); + expect(posB.x).toBeGreaterThan(posA.x); + }); + + it("accepts partial config overrides", () => { + const nodes = [node("A"), node("B")]; + const edges = [edge("A", "B")]; + const customConfig: Partial = { + groupPadding: 50, + }; + + const result = layoutWithGroups( + nodes, + edges, + [], + mockAlgorithm, + customConfig, + ); + expect(result.positions.size).toBe(2); + }); + + it("uses custom horizontalGap when provided", () => { + // With a wider gap, the disconnected node should be farther from the DAG + const nodes = [node("A"), node("B"), node("Disc")]; + const edges = [edge("A", "B")]; // Disc is disconnected + const groups: LayoutGroup[] = []; + + const defaultResult = layoutWithGroups( + nodes, + edges, + groups, + mockAlgorithm, + ); + const customResult = layoutWithGroups( + nodes, + edges, + groups, + mockAlgorithm, + { disconnectedGap: 500 }, + ); + + const defaultDiscX = defaultResult.positions.get("Disc")!.x; + const customDiscX = customResult.positions.get("Disc")!.x; + + // Larger disconnectedGap should push the disconnected node further left + expect(customDiscX).toBeLessThan(defaultDiscX); + }); + + it("uses custom groupPadding for group bounds", () => { + const nodes = [node("A")]; + const groups: LayoutGroup[] = [ + { id: "g1", title: "G1", memberIds: ["A"], childGroupIds: [] }, + ]; + + const smallPad = layoutWithGroups( + nodes, + [], + groups, + mockAlgorithm, + { groupPadding: 10 }, + ); + const largePad = layoutWithGroups( + nodes, + [], + groups, + mockAlgorithm, + { groupPadding: 80 }, + ); + + const smallBounds = smallPad.groupBounds.get("g1")!; + const largeBounds = largePad.groupBounds.get("g1")!; + + // Larger padding should produce larger group bounds + expect(largeBounds.width).toBeGreaterThan(smallBounds.width); + expect(largeBounds.height).toBeGreaterThan(smallBounds.height); + }); + + it("uses custom verticalGap for disconnected node spacing", () => { + const nodes = [node("Disc1"), node("Disc2")]; + // No edges — both disconnected, stacked vertically + + const tightResult = layoutWithGroups( + nodes, + [], + [], + mockAlgorithm, + { verticalGap: 10 }, + ); + const looseResult = layoutWithGroups( + nodes, + [], + [], + mockAlgorithm, + { verticalGap: 100 }, + ); + + const tightGap = + tightResult.positions.get("Disc2")!.y - + tightResult.positions.get("Disc1")!.y; + const looseGap = + looseResult.positions.get("Disc2")!.y - + looseResult.positions.get("Disc1")!.y; + + expect(looseGap).toBeGreaterThan(tightGap); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: buildGroupHierarchy +// --------------------------------------------------------------------------- + +describe("buildGroupHierarchy", () => { + it("returns empty for no groups", () => { + const { processingOrder, parentMap } = buildGroupHierarchy([]); + expect(processingOrder).toHaveLength(0); + expect(parentMap.size).toBe(0); + }); + + it("returns leaf groups first, then parents", () => { + const groups: LayoutGroup[] = [ + { + id: "parent", + title: "Parent", + memberIds: [], + childGroupIds: ["child"], + }, + { + id: "child", + title: "Child", + memberIds: ["A"], + childGroupIds: [], + }, + ]; + + const { processingOrder, parentMap } = buildGroupHierarchy(groups); + + expect(processingOrder).toHaveLength(2); + expect(processingOrder[0].id).toBe("child"); + expect(processingOrder[1].id).toBe("parent"); + expect(parentMap.get("child")).toBe("parent"); + }); + + it("handles flat groups (no nesting)", () => { + const groups: LayoutGroup[] = [ + { id: "a", title: "A", memberIds: ["1"], childGroupIds: [] }, + { id: "b", title: "B", memberIds: ["2"], childGroupIds: [] }, + ]; + + const { processingOrder } = buildGroupHierarchy(groups); + // Both are leaves, order is deterministic (queue order) + expect(processingOrder).toHaveLength(2); + }); + + it("handles deep nesting (3 levels)", () => { + const groups: LayoutGroup[] = [ + { + id: "root", + title: "Root", + memberIds: [], + childGroupIds: ["mid"], + }, + { + id: "mid", + title: "Mid", + memberIds: [], + childGroupIds: ["leaf"], + }, + { + id: "leaf", + title: "Leaf", + memberIds: ["A"], + childGroupIds: [], + }, + ]; + + const { processingOrder } = buildGroupHierarchy(groups); + expect(processingOrder.map((g) => g.id)).toEqual(["leaf", "mid", "root"]); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: splitDisconnected +// --------------------------------------------------------------------------- + +describe("splitDisconnected", () => { + it("splits nodes with and without edges", () => { + const nodes = [node("A"), node("B"), node("C")]; + const edges = [edge("A", "B")]; + const result = splitDisconnected(nodes, edges); + + expect(result.connected.map((n) => n.id)).toEqual(["A", "B"]); + expect(result.disconnected.map((n) => n.id)).toEqual(["C"]); + expect(result.connectedEdges).toHaveLength(1); + }); + + it("returns all as disconnected when no edges", () => { + const nodes = [node("A"), node("B")]; + const result = splitDisconnected(nodes, []); + expect(result.connected).toHaveLength(0); + expect(result.disconnected).toHaveLength(2); + }); + + it("returns all as connected when all have edges", () => { + const nodes = [node("A"), node("B")]; + const edges = [edge("A", "B")]; + const result = splitDisconnected(nodes, edges); + expect(result.connected).toHaveLength(2); + expect(result.disconnected).toHaveLength(0); + }); + + it("ignores edges referencing unknown nodes", () => { + const nodes = [node("A")]; + const edges = [edge("A", "Z")]; // Z doesn't exist + const result = splitDisconnected(nodes, edges); + expect(result.connected).toHaveLength(0); + expect(result.disconnected).toHaveLength(1); + }); + + it("keeps constrained boundary nodes out of disconnected placement", () => { + const nodes: LayoutNode[] = [ + { id: "in", width: 100, height: 60, layerConstraint: "first" }, + { id: "out", width: 100, height: 60, layerConstraint: "last" }, + ]; + const result = splitDisconnected(nodes, []); + + expect(result.connected.map((n) => n.id)).toEqual(["in", "out"]); + expect(result.disconnected).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: placeDisconnected +// --------------------------------------------------------------------------- + +describe("placeDisconnected", () => { + it("places nodes in vertical stack left of DAG", () => { + const nodes = [node("A", 100, 60), node("B", 80, 40)]; + const dagBounds = { minX: 200, minY: 50, maxX: 500, maxY: 300 }; + const positions = placeDisconnected( + nodes, + dagBounds, + DEFAULT_FRAMEWORK_CONFIG, + ); + + expect(positions.size).toBe(2); + + const posA = positions.get("A")!; + const posB = positions.get("B")!; + + // Should be left of DAG + expect(posA.x + 100).toBeLessThan(dagBounds.minX); + // B should be below A + expect(posB.y).toBeGreaterThan(posA.y); + }); + + it("places at origin when no DAG", () => { + const nodes = [node("A")]; + const positions = placeDisconnected( + nodes, + null, + DEFAULT_FRAMEWORK_CONFIG, + ); + expect(positions.size).toBe(1); + expect(Number.isFinite(positions.get("A")!.x)).toBe(true); + }); + + it("returns empty map for empty input", () => { + const positions = placeDisconnected([], null, DEFAULT_FRAMEWORK_CONFIG); + expect(positions.size).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: translatePositions +// --------------------------------------------------------------------------- + +describe("translatePositions", () => { + it("offsets all positions by dx/dy", () => { + const positions = new Map([ + ["A", { x: 10, y: 20 }], + ["B", { x: 30, y: 40 }], + ]); + + const result = translatePositions(positions, 100, 200); + + expect(result.get("A")).toEqual({ x: 110, y: 220 }); + expect(result.get("B")).toEqual({ x: 130, y: 240 }); + }); + + it("returns empty map for empty input", () => { + const result = translatePositions(new Map(), 10, 20); + expect(result.size).toBe(0); + }); + + it("does not mutate original map", () => { + const original = new Map([["A", { x: 5, y: 10 }]]); + translatePositions(original, 100, 200); + expect(original.get("A")).toEqual({ x: 5, y: 10 }); + }); +}); diff --git a/tests/unit/grid.test.ts b/tests/unit/grid.test.ts new file mode 100644 index 0000000..aa6045b --- /dev/null +++ b/tests/unit/grid.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect } from "vitest"; +import { createGridAlgorithm } from "../../src/layout/algorithms/grid"; +import type { LayoutNode, LayoutInput } from "../../src/layout/types"; + +function node(id: string, width = 100, height = 60): LayoutNode { + return { id, width, height }; +} + +function input(nodes: LayoutNode[]): LayoutInput { + return { nodes, edges: [] }; +} + +// --------------------------------------------------------------------------- +// Grid layout: row-based +// --------------------------------------------------------------------------- + +describe("grid layout (row-based)", () => { + it("[2ROW] with 4 nodes produces 2x2 grid", () => { + const algo = createGridAlgorithm({ rows: 2, gap: 40 }); + const nodes = [node("A"), node("B"), node("C"), node("D")]; + const result = algo.layout(input(nodes)); + + // cols = ceil(4/2) = 2 + // Row 0: A(0,0), B(0,1) + // Row 1: C(1,0), D(1,1) + const posA = result.positions.get("A")!; + const posB = result.positions.get("B")!; + const posC = result.positions.get("C")!; + const posD = result.positions.get("D")!; + + // cellWidth = 100+40 = 140, cellHeight = 60+40 = 100 + expect(posA).toEqual({ x: 0, y: 0 }); + expect(posB).toEqual({ x: 140, y: 0 }); + expect(posC).toEqual({ x: 0, y: 100 }); + expect(posD).toEqual({ x: 140, y: 100 }); + }); + + it("[2ROW] with 1 node places at origin", () => { + const algo = createGridAlgorithm({ rows: 2, gap: 40 }); + const result = algo.layout(input([node("A")])); + + expect(result.positions.get("A")).toEqual({ x: 0, y: 0 }); + }); + + it("[3ROW] with 7 nodes distributes correctly", () => { + const algo = createGridAlgorithm({ rows: 3, gap: 20 }); + const nodes = Array.from({ length: 7 }, (_, i) => node(`N${i}`, 80, 50)); + const result = algo.layout(input(nodes)); + + // cols = ceil(7/3) = 3 + // Row 0: N0, N1, N2 + // Row 1: N3, N4, N5 + // Row 2: N6 + expect(result.positions.size).toBe(7); + + const posN0 = result.positions.get("N0")!; + const posN3 = result.positions.get("N3")!; + const posN6 = result.positions.get("N6")!; + + // cellWidth = 80+20 = 100, cellHeight = 50+20 = 70 + expect(posN0).toEqual({ x: 0, y: 0 }); + expect(posN3).toEqual({ x: 0, y: 70 }); + expect(posN6).toEqual({ x: 0, y: 140 }); + }); +}); + +// --------------------------------------------------------------------------- +// Grid layout: column-based +// --------------------------------------------------------------------------- + +describe("grid layout (column-based)", () => { + it("[3COL] with 9 nodes produces 3x3 grid", () => { + const algo = createGridAlgorithm({ columns: 3, gap: 40 }); + const nodes = Array.from({ length: 9 }, (_, i) => node(`N${i}`)); + const result = algo.layout(input(nodes)); + + // rows = ceil(9/3) = 3 + // Row 0: N0, N1, N2 + // Row 1: N3, N4, N5 + // Row 2: N6, N7, N8 + expect(result.positions.size).toBe(9); + + const posN0 = result.positions.get("N0")!; + const posN4 = result.positions.get("N4")!; + const posN8 = result.positions.get("N8")!; + + expect(posN0).toEqual({ x: 0, y: 0 }); + expect(posN4).toEqual({ x: 140, y: 100 }); // (row=1, col=1) + expect(posN8).toEqual({ x: 280, y: 200 }); // (row=2, col=2) + }); + + it("[2COL] with 5 nodes: 3 rows x 2 columns", () => { + const algo = createGridAlgorithm({ columns: 2, gap: 40 }); + const nodes = Array.from({ length: 5 }, (_, i) => node(`N${i}`)); + const result = algo.layout(input(nodes)); + + // rows = ceil(5/2) = 3 + // Row 0: N0, N1 + // Row 1: N2, N3 + // Row 2: N4 + expect(result.positions.size).toBe(5); + + const posN4 = result.positions.get("N4")!; + expect(posN4).toEqual({ x: 0, y: 200 }); // (row=2, col=0) + }); +}); + +// --------------------------------------------------------------------------- +// Grid layout: mixed node sizes +// --------------------------------------------------------------------------- + +describe("grid layout with different node sizes", () => { + it("uses max dimensions for uniform cell sizing", () => { + const algo = createGridAlgorithm({ columns: 2, gap: 20 }); + const nodes = [ + node("small", 60, 30), + node("wide", 150, 30), + node("tall", 60, 100), + node("big", 150, 100), + ]; + const result = algo.layout(input(nodes)); + + // maxWidth = 150, maxHeight = 100 + // cellWidth = 150+20 = 170, cellHeight = 100+20 = 120 + + const posSmall = result.positions.get("small")!; + const posWide = result.positions.get("wide")!; + const posTall = result.positions.get("tall")!; + const posBig = result.positions.get("big")!; + + // "small" at (0,0): centered in cell → x = (150-60)/2 = 45, y = (100-30)/2 = 35 + expect(posSmall).toEqual({ x: 45, y: 35 }); + + // "wide" at (0,1): x = 170 + (150-150)/2 = 170, y = (100-30)/2 = 35 + expect(posWide).toEqual({ x: 170, y: 35 }); + + // "tall" at (1,0): x = (150-60)/2 = 45, y = 120 + (100-100)/2 = 120 + expect(posTall).toEqual({ x: 45, y: 120 }); + + // "big" at (1,1): x = 170 + 0 = 170, y = 120 + 0 = 120 + expect(posBig).toEqual({ x: 170, y: 120 }); + }); +}); + +// --------------------------------------------------------------------------- +// Grid layout: determinism +// --------------------------------------------------------------------------- + +describe("grid layout determinism", () => { + it("produces identical output for identical input", () => { + const algo = createGridAlgorithm({ rows: 2, gap: 40 }); + const nodes = [node("A"), node("B"), node("C"), node("D"), node("E")]; + + const result1 = algo.layout(input(nodes)); + const result2 = algo.layout(input(nodes)); + + expect(result1.positions.size).toBe(result2.positions.size); + for (const [id, pos1] of result1.positions) { + const pos2 = result2.positions.get(id)!; + expect(pos1.x).toBe(pos2.x); + expect(pos1.y).toBe(pos2.y); + } + }); +}); + +// --------------------------------------------------------------------------- +// Grid layout: default (no rows/columns specified) +// --------------------------------------------------------------------------- + +describe("grid layout default (sqrt columns)", () => { + it("uses sqrt(n) columns when neither rows nor columns given", () => { + const algo = createGridAlgorithm({ gap: 40 }); + const nodes = Array.from({ length: 9 }, (_, i) => node(`N${i}`)); + const result = algo.layout(input(nodes)); + + // sqrt(9) = 3 cols, 3 rows + expect(result.positions.size).toBe(9); + + // N0 at (0,0), N3 at (1,0), N6 at (2,0) + const posN0 = result.positions.get("N0")!; + const posN3 = result.positions.get("N3")!; + const posN6 = result.positions.get("N6")!; + + expect(posN0.y).toBe(0); + expect(posN3.y).toBe(100); // row 1 + expect(posN6.y).toBe(200); // row 2 + }); +}); diff --git a/tests/unit/index.test.ts b/tests/unit/index.test.ts new file mode 100644 index 0000000..58fb601 --- /dev/null +++ b/tests/unit/index.test.ts @@ -0,0 +1,370 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { getCurrentGraph, getSelectedGroups } from "../../src/runtime"; +import { isGroup } from "../../src/utils"; +import { + SETTING_IDS, + SETTINGS_PREFIX, + EXTENSION_NAME, + REPOSITORY_URL, + CURRENT_VERSION, + DEFAULT_ALGORITHM_OPTIONS, +} from "../../src/settings"; +import { DEFAULT_FRAMEWORK_CONFIG } from "../../src/layout/types"; +import type { Position, GroupBounds } from "../../src/layout/types"; +import { computeGraphBounds } from "../../src/bounds"; +import type { LayoutInput, LayoutNode } from "../../src/layout/types"; + +describe("isGroup", () => { + it("identifies a plain group without a type", () => { + expect( + isGroup({ id: 1, title: "Group", pos: [0, 0], size: [100, 100] }), + ).toBe(true); + }); + + it("prefers the LGraphGroup constructor tag when present", () => { + expect( + isGroup({ + id: 1, + title: "Runtime Group", + pos: [0, 0], + constructor: { name: "LGraphGroup" }, + type: "UnexpectedNodeType", + }), + ).toBe(true); + }); + + it("rejects a node with a string type", () => { + expect( + isGroup({ + id: 1, + title: "Sampler", + type: "KSampler", + pos: [0, 0], + size: [100, 100], + }), + ).toBe(false); + }); + + it("rejects a reroute node", () => { + expect( + isGroup({ + id: 1, + title: "Reroute", + type: "Reroute", + pos: [0, 0], + size: [30, 30], + }), + ).toBe(false); + }); + + it("treats null and undefined types as groups", () => { + expect( + isGroup({ id: 1, title: "Null Type", type: null, pos: [0, 0] }), + ).toBe(true); + expect( + isGroup({ id: 2, title: "Undefined Type", type: undefined, pos: [0, 0] }), + ).toBe(true); + }); + + it("rejects items without a string title", () => { + expect(isGroup({ id: 1, pos: [0, 0] })).toBe(false); + expect( + isGroup({ id: 2, title: undefined, pos: [0, 0] }), + ).toBe(false); + }); + + it("rejects an empty-string type because runtime nodes use string types", () => { + expect( + isGroup({ id: 1, title: "Empty Type", type: "", pos: [0, 0] }), + ).toBe(false); + }); +}); + +describe("getCurrentGraph", () => { + it("prefers canvas.getCurrentGraph when available", () => { + const visibleGraph = { _nodes: [], _groups: [], links: new Map() }; + const fallbackCanvasGraph = { _nodes: [1], _groups: [], links: new Map() }; + const fallbackAppGraph = { _nodes: [2], _groups: [], links: new Map() }; + + expect( + getCurrentGraph({ + canvas: { + graph: fallbackCanvasGraph, + getCurrentGraph: () => visibleGraph, + }, + graph: fallbackAppGraph, + }), + ).toBe(visibleGraph); + }); + + it("falls back to canvas.graph, then app.graph", () => { + const canvasGraph = { _nodes: [], _groups: [], links: new Map() }; + const appGraph = { _nodes: [], _groups: [], links: new Map() }; + + expect( + getCurrentGraph({ + canvas: { graph: canvasGraph }, + graph: appGraph, + }), + ).toBe(canvasGraph); + expect( + getCurrentGraph({ + canvas: { graph: null }, + graph: appGraph, + }), + ).toBe(appGraph); + }); + + it("returns null when no graph is available", () => { + expect(getCurrentGraph({ canvas: null })).toBeNull(); + expect(getCurrentGraph({})).toBeNull(); + }); +}); + +describe("SETTING_IDS", () => { + it("has all expected setting IDs with correct prefix", () => { + const expectedKeys = [ + "VERSION", + "KEYBINDINGS", + "DEFAULT_ALGORITHM", + "HORIZONTAL_GAP", + "VERTICAL_GAP", + "GROUP_PADDING", + "DISCONNECTED_GAP", + "FIT_TO_VIEW", + "DEBUG_LOGGING", + ] as const; + + for (const key of expectedKeys) { + expect(SETTING_IDS[key]).toBeDefined(); + expect(SETTING_IDS[key]).toContain(SETTINGS_PREFIX); + } + }); + + it("has unique IDs for all settings", () => { + const values = Object.values(SETTING_IDS); + expect(new Set(values).size).toBe(values.length); + }); + + it("spacing IDs map to correct FrameworkConfig defaults", () => { + expect(DEFAULT_FRAMEWORK_CONFIG.horizontalGap).toBe(100); + expect(DEFAULT_FRAMEWORK_CONFIG.verticalGap).toBe(40); + expect(DEFAULT_FRAMEWORK_CONFIG.groupPadding).toBe(30); + expect(DEFAULT_FRAMEWORK_CONFIG.disconnectedGap).toBe(150); + }); +}); + +describe("settings constants", () => { + const packageVersion = JSON.parse( + readFileSync(new URL("../../package.json", import.meta.url), "utf-8"), + ) as { version: string }; + const pyproject = readFileSync( + new URL("../../pyproject.toml", import.meta.url), + "utf-8", + ); + const pyprojectVersion = + pyproject.match(/^version = "(.+)"$/m)?.[1] ?? null; + const bumpCurrentVersion = + pyproject.match(/^current_version = "(.+)"$/m)?.[1] ?? null; + + it("has a valid extension name", () => { + expect(EXTENSION_NAME).toBe("comfy-node-organizer"); + }); + + it("has a valid repository URL", () => { + expect(REPOSITORY_URL).toContain("github.com"); + expect(REPOSITORY_URL).toContain("comfyui-node-organizer"); + }); + + it("has a semver version string", () => { + expect(CURRENT_VERSION).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it("keeps CURRENT_VERSION in sync with package.json", () => { + expect(CURRENT_VERSION).toBe(packageVersion.version); + }); + + it("keeps pyproject version metadata in sync with CURRENT_VERSION", () => { + expect(pyprojectVersion).toBe(CURRENT_VERSION); + expect(bumpCurrentVersion).toBe(CURRENT_VERSION); + }); + + it("includes sugiyama, horizontal, and vertical algorithms", () => { + expect(DEFAULT_ALGORITHM_OPTIONS).toContain("sugiyama"); + expect(DEFAULT_ALGORITHM_OPTIONS).toContain("horizontal"); + expect(DEFAULT_ALGORITHM_OPTIONS).toContain("vertical"); + expect(DEFAULT_ALGORITHM_OPTIONS.length).toBe(3); + }); +}); + +describe("createConfiguredDefaultAlgorithm", () => { + it("passes spacing config into default algorithm factories", async () => { + const globalScope = globalThis as typeof globalThis & { + app?: { + registerExtension: (extension: unknown) => void; + extensionManager?: { setting?: { get: (_id: string) => T | undefined } }; + canvas?: null; + }; + }; + const originalApp = globalScope.app; + + globalScope.app = { + registerExtension: () => undefined, + extensionManager: { + setting: { + get: () => undefined, + }, + }, + canvas: null, + }; + + try { + const { createConfiguredDefaultAlgorithm } = await import("../../src/index"); + + const twoNodes: LayoutNode[] = [ + { id: "a", width: 10, height: 10 }, + { id: "b", width: 10, height: 10 }, + ]; + const verticalPair: LayoutInput = { + nodes: twoNodes, + edges: [], + }; + const sugiyamaInput: LayoutInput = { + nodes: [ + { id: "left-a", width: 10, height: 10 }, + { id: "left-b", width: 10, height: 10 }, + { id: "right", width: 10, height: 10 }, + ], + edges: [ + { source: "left-a", target: "right" }, + { source: "left-b", target: "right" }, + ], + }; + + const horizontal = createConfiguredDefaultAlgorithm("horizontal", { + horizontalGap: 123, + }); + expect(horizontal.layout(verticalPair).positions.get("b")?.x).toBe(133); + + const vertical = createConfiguredDefaultAlgorithm("vertical", { + verticalGap: 77, + }); + expect(vertical.layout(verticalPair).positions.get("b")?.y).toBe(87); + + const sugiyama = createConfiguredDefaultAlgorithm("sugiyama", { + horizontalGap: 123, + verticalGap: 77, + }); + const sugiyamaPositions = sugiyama.layout(sugiyamaInput).positions; + expect(sugiyamaPositions.get("right")?.x).toBe(133); + expect(sugiyamaPositions.get("left-b")?.y).toBe(87); + } finally { + if (originalApp === undefined) { + delete globalScope.app; + } else { + globalScope.app = originalApp; + } + } + }); +}); + +describe("getSelectedGroups", () => { + it("returns only selected groups", () => { + const selectedItems = [ + { id: 100, title: "Group A", pos: [0, 0] as [number, number] }, + { id: 1, title: "Sampler", type: "KSampler", pos: [10, 10] as [number, number] }, + { id: 101, title: "Group B", pos: [20, 20] as [number, number] }, + ]; + + expect(getSelectedGroups({ graph: null, selectedItems })).toEqual([ + { id: 100, title: "Group A" }, + { id: 101, title: "Group B" }, + ]); + }); + + it("returns an empty array when nothing group-like is selected", () => { + expect(getSelectedGroups(undefined)).toEqual([]); + expect(getSelectedGroups({ graph: null })).toEqual([]); + expect( + getSelectedGroups({ + graph: null, + selectedItems: [{ id: 1, title: "Node", type: "KSampler", pos: [0, 0] }], + }), + ).toEqual([]); + }); +}); + +describe("computeGraphBounds", () => { + const makeNode = (id: number, x: number, y: number, w: number, h: number) => + ({ id, pos: [x, y], size: [w, h] }); + + it("computes bounds from regular nodes", () => { + const nodes = [makeNode(1, 0, 0, 100, 50), makeNode(2, 200, 100, 100, 50)]; + const positions = new Map([ + ["1", { x: 0, y: 0 }], + ["2", { x: 200, y: 100 }], + ]); + + const result = computeGraphBounds(nodes, undefined, undefined, positions, new Map()); + expect(result).toEqual([0, 0, 300, 150]); + }); + + it("returns null when no items have positions", () => { + const result = computeGraphBounds([], undefined, undefined, new Map(), new Map()); + expect(result).toBeNull(); + }); + + it("includes inputNode in bounds", () => { + const nodes = [makeNode(1, 100, 50, 100, 50)]; + const inputNode = makeNode(-10, -200, 0, 50, 30); + const positions = new Map([ + ["1", { x: 100, y: 50 }], + ["-10", { x: -200, y: 0 }], + ]); + + const result = computeGraphBounds(nodes, inputNode, undefined, positions, new Map()); + // Without inputNode: bounds would be [100, 50, 100, 50] + // With inputNode: bounds should extend left to -200 + expect(result).toEqual([-200, 0, 400, 100]); + }); + + it("includes outputNode in bounds", () => { + const nodes = [makeNode(1, 0, 0, 100, 50)]; + const outputNode = makeNode(-20, 500, 0, 50, 30); + const positions = new Map([ + ["1", { x: 0, y: 0 }], + ["-20", { x: 500, y: 0 }], + ]); + + const result = computeGraphBounds(nodes, undefined, outputNode, positions, new Map()); + // Without outputNode: bounds would be [0, 0, 100, 50] + // With outputNode: bounds should extend right to 550 + expect(result).toEqual([0, 0, 550, 50]); + }); + + it("includes both boundary nodes in subgraph scenario", () => { + const nodes = [makeNode(1, 100, 50, 100, 50)]; + const inputNode = makeNode(-10, -200, 0, 50, 30); + const outputNode = makeNode(-20, 500, 0, 50, 30); + const positions = new Map([ + ["1", { x: 100, y: 50 }], + ["-10", { x: -200, y: 0 }], + ["-20", { x: 500, y: 0 }], + ]); + + const result = computeGraphBounds(nodes, inputNode, outputNode, positions, new Map()); + expect(result).toEqual([-200, 0, 750, 100]); + }); + + it("includes group bounds", () => { + const nodes = [makeNode(1, 50, 50, 100, 50)]; + const positions = new Map([["1", { x: 50, y: 50 }]]); + const groupBounds = new Map([ + ["group:1", { x: 0, y: 0, width: 300, height: 200 }], + ]); + + const result = computeGraphBounds(nodes, undefined, undefined, positions, groupBounds); + expect(result).toEqual([0, 0, 300, 200]); + }); +}); diff --git a/tests/unit/installed-templates.test.ts b/tests/unit/installed-templates.test.ts new file mode 100644 index 0000000..6fd43f5 --- /dev/null +++ b/tests/unit/installed-templates.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { + isWorkflowData, + parseInstalledTemplateManifest, +} from "../e2e/installed-templates"; + +describe("installed template parsing", () => { + it("parses raw ids and json paths", () => { + const raw = JSON.stringify([ + { + id: "api_google_gemini_image", + path: "workflow.json", + }, + ]); + + expect(parseInstalledTemplateManifest(raw)).toEqual([ + { + id: "api_google_gemini_image", + path: "workflow.json", + }, + ]); + }); + + it("rejects an empty template manifest", () => { + expect(() => parseInstalledTemplateManifest("[]")).toThrow(/zero templates/i); + }); + + it("recognizes workflow graph data", () => { + expect( + isWorkflowData({ + nodes: [], + }), + ).toBe(true); + }); + + it("rejects non-workflow template metadata", () => { + expect( + isWorkflowData({ + $schema: "https://example.com", + title: "Schema", + }), + ).toBe(false); + }); +}); diff --git a/tests/unit/property.test.ts b/tests/unit/property.test.ts new file mode 100644 index 0000000..4afae43 --- /dev/null +++ b/tests/unit/property.test.ts @@ -0,0 +1,400 @@ +import { describe, expect, it } from "vitest"; +import fc from "fast-check"; +import { createSugiyamaAlgorithm } from "../../src/layout/algorithms/sugiyama"; +import { layoutWithGroups } from "../../src/layout/framework"; +import type { + LayoutEdge, + LayoutGroup, + LayoutNode, + LayoutToken, + Position, + GroupBounds, +} from "../../src/layout/types"; + +const algorithm = createSugiyamaAlgorithm(); + +function makeNodes(nodeCount: number): fc.Arbitrary { + return fc.tuple( + ...Array.from({ length: nodeCount }, (_, index) => + fc.record({ + id: fc.constant(String(index)), + width: fc.integer({ min: 80, max: 320 }), + height: fc.integer({ min: 40, max: 220 }), + }), + ), + ); +} + +function makeDagEdges(nodeIds: string[], maxEdges: number): fc.Arbitrary { + const candidates = nodeIds.flatMap((source, sourceIndex) => + nodeIds + .slice(sourceIndex + 1) + .map((target) => ({ source, target })), + ); + + return fc.subarray(candidates, { + minLength: 0, + maxLength: Math.min(maxEdges, candidates.length), + }); +} + +function dagArbitrary(opts: { + readonly minNodes: number; + readonly maxNodes: number; + readonly maxEdges: number; +}): fc.Arbitrary<{ nodes: LayoutNode[]; edges: LayoutEdge[] }> { + return fc + .integer({ min: opts.minNodes, max: opts.maxNodes }) + .chain((nodeCount) => + makeNodes(nodeCount).chain((nodes) => + makeDagEdges( + nodes.map((node) => node.id), + opts.maxEdges, + ).map((edges) => ({ nodes, edges })), + ), + ); +} + +function groupedDagArbitrary(): fc.Arbitrary<{ + nodes: LayoutNode[]; + edges: LayoutEdge[]; + groups: LayoutGroup[]; +}> { + return fc + .integer({ min: 4, max: 8 }) + .chain((nodeCount) => + makeNodes(nodeCount).chain((nodes) => { + const nodeIds = nodes.map((node) => node.id); + return fc + .tuple( + makeDagEdges(nodeIds, nodeCount * 2), + fc.subarray(nodeIds, { minLength: 2, maxLength: nodeCount - 1 }), + ) + .chain(([edges, innerMemberIds]) => { + const remaining = nodeIds.filter((id) => !innerMemberIds.includes(id)); + return fc + .subarray(remaining, { + minLength: 1, + maxLength: remaining.length, + }) + .map((outerDirectMemberIds) => ({ + nodes, + edges, + groups: [ + { + id: "group:inner", + title: "Inner", + memberIds: innerMemberIds, + childGroupIds: [], + }, + { + id: "group:outer", + title: "Outer", + memberIds: outerDirectMemberIds, + childGroupIds: ["group:inner"], + }, + ], + })); + }); + }), + ); +} + +function tokenArbitrary(): fc.Arbitrary { + return fc.oneof( + fc.constant({ mode: "horizontal" }), + fc.constant({ mode: "vertical" }), + fc.integer({ min: 2, max: 9 }).map((count) => ({ + mode: "grid", + count, + dimension: "row", + })), + fc.integer({ min: 2, max: 9 }).map((count) => ({ + mode: "grid", + count, + dimension: "col", + })), + ); +} + +function tokenizedGroupedDagArbitrary(): fc.Arbitrary<{ + nodes: LayoutNode[]; + edges: LayoutEdge[]; + groups: LayoutGroup[]; +}> { + return fc + .integer({ min: 4, max: 8 }) + .chain((nodeCount) => + makeNodes(nodeCount).chain((nodes) => { + const nodeIds = nodes.map((node) => node.id); + return fc + .tuple( + makeDagEdges(nodeIds, nodeCount * 2), + fc.subarray(nodeIds, { minLength: 2, maxLength: nodeCount - 1 }), + ) + .chain(([edges, innerMemberIds]) => { + const remaining = nodeIds.filter((id) => !innerMemberIds.includes(id)); + return fc + .tuple( + fc.subarray(remaining, { + minLength: 1, + maxLength: remaining.length, + }), + tokenArbitrary(), + tokenArbitrary(), + ) + .map(([outerDirectMemberIds, innerToken, outerToken]) => ({ + nodes, + edges, + groups: [ + { + id: "group:inner", + title: "Inner", + memberIds: innerMemberIds, + childGroupIds: [], + token: innerToken, + }, + { + id: "group:outer", + title: "Outer", + memberIds: outerDirectMemberIds, + childGroupIds: ["group:inner"], + token: outerToken, + }, + ], + })); + }); + }), + ); +} + +function assertFinitePositions( + positions: ReadonlyMap, +): void { + for (const [id, pos] of positions) { + expect(Number.isFinite(pos.x), `Node ${id} has non-finite x`).toBe(true); + expect(Number.isFinite(pos.y), `Node ${id} has non-finite y`).toBe(true); + } +} + +function assertAllNodesPositioned( + nodes: ReadonlyArray, + positions: ReadonlyMap, +): void { + for (const node of nodes) { + expect(positions.has(node.id), `Missing position for node ${node.id}`).toBe( + true, + ); + } +} + +function assertNoOverlaps( + nodes: ReadonlyArray, + positions: ReadonlyMap, +): void { + for (let index = 0; index < nodes.length; index += 1) { + for (let otherIndex = index + 1; otherIndex < nodes.length; otherIndex += 1) { + const left = nodes[index]; + const right = nodes[otherIndex]; + const leftPos = positions.get(left.id); + const rightPos = positions.get(right.id); + expect(leftPos, `Missing position for node ${left.id}`).toBeDefined(); + expect(rightPos, `Missing position for node ${right.id}`).toBeDefined(); + + const overlap = + leftPos!.x < rightPos!.x + right.width && + leftPos!.x + left.width > rightPos!.x && + leftPos!.y < rightPos!.y + right.height && + leftPos!.y + left.height > rightPos!.y; + + expect( + overlap, + `Nodes ${left.id} and ${right.id} overlap: ` + + `[${leftPos!.x}, ${leftPos!.y}, ${left.width}, ${left.height}] vs ` + + `[${rightPos!.x}, ${rightPos!.y}, ${right.width}, ${right.height}]`, + ).toBe(false); + } + } +} + +function assertPositiveBounds( + groupId: string, + bounds: GroupBounds | undefined, +): asserts bounds is GroupBounds { + if (!bounds) { + throw new Error(`Missing bounds for group ${groupId}`); + } + expect(bounds.width, `Group ${groupId} width must be positive`).toBeGreaterThan( + 0, + ); + expect( + bounds.height, + `Group ${groupId} height must be positive`, + ).toBeGreaterThan(0); +} + +function assertGroupContainsMembers( + groups: ReadonlyArray, + nodes: ReadonlyArray, + positions: ReadonlyMap, + boundsByGroup: ReadonlyMap, +): void { + const nodeById = new Map(nodes.map((node) => [node.id, node])); + + for (const group of groups) { + const bounds = boundsByGroup.get(group.id); + assertPositiveBounds(group.id, bounds); + + for (const memberId of group.memberIds) { + const node = nodeById.get(memberId); + const pos = positions.get(memberId); + expect(node, `Missing node ${memberId}`).toBeDefined(); + expect(pos, `Missing position for node ${memberId}`).toBeDefined(); + + expect(pos!.x).toBeGreaterThanOrEqual(bounds.x); + expect(pos!.y).toBeGreaterThanOrEqual(bounds.y); + expect(pos!.x + node!.width).toBeLessThanOrEqual(bounds.x + bounds.width); + expect(pos!.y + node!.height).toBeLessThanOrEqual( + bounds.y + bounds.height, + ); + } + } +} + +function assertIdempotent( + nodes: ReadonlyArray, + edges: ReadonlyArray, + groups: ReadonlyArray, +): void { + const first = layoutWithGroups(nodes, edges, groups, algorithm); + const second = layoutWithGroups(nodes, edges, groups, algorithm); + + expect(second.positions.size).toBe(first.positions.size); + expect(second.groupBounds.size).toBe(first.groupBounds.size); + + for (const [id, firstPos] of first.positions) { + expect(second.positions.get(id)).toEqual(firstPos); + } + + for (const [id, firstBounds] of first.groupBounds) { + expect(second.groupBounds.get(id)).toEqual(firstBounds); + } +} + +describe("property-based layout invariants", () => { + it("produces finite positions and places every node for random DAGs", () => { + fc.assert( + fc.property( + dagArbitrary({ minNodes: 1, maxNodes: 20, maxEdges: 30 }), + ({ nodes, edges }) => { + const result = layoutWithGroups(nodes, edges, [], algorithm); + assertFinitePositions(result.positions); + assertAllNodesPositioned(nodes, result.positions); + }, + ), + { numRuns: 150 }, + ); + }); + + it("avoids node overlaps for random DAGs without groups", () => { + fc.assert( + fc.property( + dagArbitrary({ minNodes: 1, maxNodes: 15, maxEdges: 20 }), + ({ nodes, edges }) => { + const result = layoutWithGroups(nodes, edges, [], algorithm); + assertNoOverlaps(nodes, result.positions); + }, + ), + { numRuns: 100 }, + ); + }); + + it("is idempotent for random DAGs", () => { + fc.assert( + fc.property( + dagArbitrary({ minNodes: 1, maxNodes: 15, maxEdges: 20 }), + ({ nodes, edges }) => { + assertIdempotent(nodes, edges, []); + }, + ), + { numRuns: 100 }, + ); + }); + + it("keeps grouped members inside positive group bounds", () => { + fc.assert( + fc.property(groupedDagArbitrary(), ({ nodes, edges, groups }) => { + const result = layoutWithGroups(nodes, edges, groups, algorithm); + assertFinitePositions(result.positions); + assertAllNodesPositioned(nodes, result.positions); + assertGroupContainsMembers( + groups, + nodes, + result.positions, + result.groupBounds, + ); + assertIdempotent(nodes, edges, groups); + }), + { numRuns: 75 }, + ); + }); +}); + +describe("property-based token layout invariants", () => { + it("produces finite positions and places every node with tokenized groups", () => { + fc.assert( + fc.property( + tokenizedGroupedDagArbitrary(), + ({ nodes, edges, groups }) => { + const result = layoutWithGroups(nodes, edges, groups, algorithm); + assertFinitePositions(result.positions); + assertAllNodesPositioned(nodes, result.positions); + }, + ), + { numRuns: 75 }, + ); + }); + + it("avoids node overlaps with tokenized groups", () => { + fc.assert( + fc.property( + tokenizedGroupedDagArbitrary(), + ({ nodes, edges, groups }) => { + const result = layoutWithGroups(nodes, edges, groups, algorithm); + assertNoOverlaps(nodes, result.positions); + }, + ), + { numRuns: 75 }, + ); + }); + + it("keeps tokenized group members inside positive group bounds", () => { + fc.assert( + fc.property( + tokenizedGroupedDagArbitrary(), + ({ nodes, edges, groups }) => { + const result = layoutWithGroups(nodes, edges, groups, algorithm); + assertGroupContainsMembers( + groups, + nodes, + result.positions, + result.groupBounds, + ); + }, + ), + { numRuns: 75 }, + ); + }); + + it("is idempotent with tokenized groups", () => { + fc.assert( + fc.property( + tokenizedGroupedDagArbitrary(), + ({ nodes, edges, groups }) => { + assertIdempotent(nodes, edges, groups); + }, + ), + { numRuns: 75 }, + ); + }); +}); diff --git a/tests/unit/setup-test-comfy.test.ts b/tests/unit/setup-test-comfy.test.ts new file mode 100644 index 0000000..1cfa969 --- /dev/null +++ b/tests/unit/setup-test-comfy.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { extractPinnedWorkflowTemplatesRequirement } from "../../scripts/setup-test-comfy-helpers"; + +describe("workflow template requirement parsing", () => { + it("extracts the exact pinned requirement", () => { + const requirements = [ + "torchsde", + "comfyui-workflow-templates==0.9.26", + "kornia>=0.7.1", + ].join("\n"); + + expect(extractPinnedWorkflowTemplatesRequirement(requirements)).toBe( + "comfyui-workflow-templates==0.9.26", + ); + }); + + it("fails when the requirement is missing", () => { + expect(() => + extractPinnedWorkflowTemplatesRequirement("torchsde==0.2.6"), + ).toThrow(/comfyui-workflow-templates/i); + }); +}); diff --git a/tests/unit/sugiyama.test.ts b/tests/unit/sugiyama.test.ts new file mode 100644 index 0000000..ea76287 --- /dev/null +++ b/tests/unit/sugiyama.test.ts @@ -0,0 +1,658 @@ +import { describe, it, expect } from "vitest"; +import { createSugiyamaAlgorithm } from "../../src/layout/algorithms/sugiyama"; +import { breakCycles } from "../../src/layout/algorithms/sugiyama/cycle-break"; +import { assignLayers } from "../../src/layout/algorithms/sugiyama/layer-assign"; +import { minimizeCrossings } from "../../src/layout/algorithms/sugiyama/crossing-minimize"; +import { assignCoordinates } from "../../src/layout/algorithms/sugiyama/coordinate-assign"; +import type { LayoutNode, LayoutEdge, Position } from "../../src/layout/types"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function node(id: string, width = 200, height = 100): LayoutNode { + return { id, width, height }; +} + +function edge(source: string, target: string): LayoutEdge { + return { source, target }; +} + +function nodeMap(nodes: LayoutNode[]): ReadonlyMap { + const m = new Map(); + for (const n of nodes) m.set(n.id, n); + return m; +} + +/** Check that no two positioned nodes overlap (AABB collision). */ +function assertNoOverlaps( + positions: ReadonlyMap, + nodes: LayoutNode[], +): void { + const nMap = nodeMap(nodes); + const entries = [...positions.entries()]; + for (let i = 0; i < entries.length; i++) { + for (let j = i + 1; j < entries.length; j++) { + const [idA, posA] = entries[i]; + const [idB, posB] = entries[j]; + const nA = nMap.get(idA)!; + const nB = nMap.get(idB)!; + + const overlapX = + posA.x < posB.x + nB.width && posA.x + nA.width > posB.x; + const overlapY = + posA.y < posB.y + nB.height && posA.y + nA.height > posB.y; + + expect( + overlapX && overlapY, + `Nodes ${idA} and ${idB} overlap`, + ).toBe(false); + } + } +} + +/** Check all coordinates are finite (no NaN/Infinity). */ +function assertFiniteCoordinates( + positions: ReadonlyMap, +): void { + for (const [id, pos] of positions) { + expect(Number.isFinite(pos.x), `${id}.x is not finite: ${pos.x}`).toBe( + true, + ); + expect(Number.isFinite(pos.y), `${id}.y is not finite: ${pos.y}`).toBe( + true, + ); + } +} + +// --------------------------------------------------------------------------- +// breakCycles +// --------------------------------------------------------------------------- + +describe("breakCycles", () => { + it("returns edges unchanged when no cycles exist", () => { + const nodes = [node("A"), node("B"), node("C")]; + const edges = [edge("A", "B"), edge("B", "C")]; + const result = breakCycles(nodes, edges); + + expect(result.reversedEdges.size).toBe(0); + expect(result.edges).toHaveLength(2); + }); + + it("breaks a simple 2-node cycle", () => { + const nodes = [node("A"), node("B")]; + const edges = [edge("A", "B"), edge("B", "A")]; + const result = breakCycles(nodes, edges); + + // One edge should be reversed + expect(result.reversedEdges.size).toBe(1); + expect(result.edges).toHaveLength(2); + + // Result should be acyclic: verify by running layer assignment (which requires DAG) + const layers = assignLayers(nodes, result.edges); + expect(layers.size).toBe(2); + }); + + it("breaks a 3-node cycle A->B->C->A", () => { + const nodes = [node("A"), node("B"), node("C")]; + const edges = [edge("A", "B"), edge("B", "C"), edge("C", "A")]; + const result = breakCycles(nodes, edges); + + // At least one edge reversed + expect(result.reversedEdges.size).toBeGreaterThanOrEqual(1); + + // Result must be acyclic: all nodes should get valid layers + const layers = assignLayers(nodes, result.edges); + expect(layers.size).toBe(3); + + // Verify topological ordering: for every edge, source layer < target layer + for (const e of result.edges) { + expect(layers.get(e.source)!).toBeLessThan(layers.get(e.target)!); + } + }); + + it("handles empty input", () => { + const result = breakCycles([], []); + expect(result.edges).toHaveLength(0); + expect(result.reversedEdges.size).toBe(0); + }); + + it("handles nodes with no edges", () => { + const nodes = [node("A"), node("B")]; + const result = breakCycles(nodes, []); + expect(result.edges).toHaveLength(0); + expect(result.reversedEdges.size).toBe(0); + }); + + it("handles self-loop (edge to same node)", () => { + const nodes = [node("A"), node("B")]; + // Self-loop on A plus a normal edge + const edges = [edge("A", "A"), edge("A", "B")]; + const result = breakCycles(nodes, edges); + + // Self-loop might be reversed or kept — algorithm should handle it + // Just ensure no crash and result is valid + expect(result.edges.length).toBeGreaterThanOrEqual(1); + }); +}); + +// --------------------------------------------------------------------------- +// assignLayers +// --------------------------------------------------------------------------- + +describe("assignLayers", () => { + it("assigns single node to layer 0", () => { + const nodes = [node("A")]; + const layers = assignLayers(nodes, []); + expect(layers.get("A")).toBe(0); + }); + + it("assigns linear chain A->B->C to consecutive layers", () => { + const nodes = [node("A"), node("B"), node("C")]; + const edges = [edge("A", "B"), edge("B", "C")]; + const layers = assignLayers(nodes, edges); + + expect(layers.get("A")).toBe(0); + expect(layers.get("B")).toBe(1); + expect(layers.get("C")).toBe(2); + }); + + it("assigns diamond A->B, A->C, B->D, C->D correctly", () => { + const nodes = [node("A"), node("B"), node("C"), node("D")]; + const edges = [ + edge("A", "B"), + edge("A", "C"), + edge("B", "D"), + edge("C", "D"), + ]; + const layers = assignLayers(nodes, edges); + + expect(layers.get("A")).toBe(0); + expect(layers.get("B")).toBe(1); + expect(layers.get("C")).toBe(1); + expect(layers.get("D")).toBe(2); + }); + + it("uses longest path (not shortest)", () => { + // A->B->C->D and A->D + // D should be at layer 3 (longest path), not layer 1 (shortest) + const nodes = [node("A"), node("B"), node("C"), node("D")]; + const edges = [ + edge("A", "B"), + edge("B", "C"), + edge("C", "D"), + edge("A", "D"), + ]; + const layers = assignLayers(nodes, edges); + + expect(layers.get("A")).toBe(0); + expect(layers.get("D")).toBe(3); // longest path: A->B->C->D + }); + + it("handles multiple sources", () => { + // A->C, B->C + const nodes = [node("A"), node("B"), node("C")]; + const edges = [edge("A", "C"), edge("B", "C")]; + const layers = assignLayers(nodes, edges); + + expect(layers.get("A")).toBe(0); + expect(layers.get("B")).toBe(0); + expect(layers.get("C")).toBe(1); + }); + + it("handles empty input", () => { + const layers = assignLayers([], []); + expect(layers.size).toBe(0); + }); + + it("handles disconnected nodes", () => { + const nodes = [node("A"), node("B")]; + const layers = assignLayers(nodes, []); + expect(layers.get("A")).toBe(0); + expect(layers.get("B")).toBe(0); + }); + + it("preserves monotonic increase along edges", () => { + const nodes = [node("A"), node("B"), node("C"), node("D"), node("E")]; + const edges = [ + edge("A", "B"), + edge("A", "C"), + edge("B", "D"), + edge("C", "D"), + edge("D", "E"), + ]; + const layers = assignLayers(nodes, edges); + + for (const e of edges) { + expect(layers.get(e.source)!).toBeLessThan(layers.get(e.target)!); + } + }); + + it("forces first and last constrained nodes to graph edges", () => { + const nodes: LayoutNode[] = [ + { id: "in", width: 120, height: 40, layerConstraint: "first" }, + node("mid"), + { id: "out", width: 120, height: 40, layerConstraint: "last" }, + ]; + const edges = [edge("in", "mid"), edge("mid", "out")]; + const layers = assignLayers(nodes, edges); + + expect(layers.get("in")).toBe(0); + expect(layers.get("out")).toBeGreaterThan(layers.get("mid")!); + }); + + it("separates disconnected constrained boundary nodes into distinct layers", () => { + const nodes: LayoutNode[] = [ + { id: "in", width: 120, height: 40, layerConstraint: "first" }, + { id: "out", width: 120, height: 40, layerConstraint: "last" }, + ]; + const layers = assignLayers(nodes, []); + + expect(layers.get("in")).toBe(0); + expect(layers.get("out")).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// minimizeCrossings +// --------------------------------------------------------------------------- + +describe("minimizeCrossings", () => { + it("returns single layer unchanged", () => { + const layers = [["A", "B", "C"]]; + const result = minimizeCrossings(layers, [], new Map()); + expect(result).toEqual([["A", "B", "C"]]); + }); + + it("returns empty layers unchanged", () => { + const result = minimizeCrossings([], [], new Map()); + expect(result).toEqual([]); + }); + + it("reduces crossings in a simple case", () => { + // Layer 0: [A, B], Layer 1: [C, D] + // Edges: A->D, B->C (these cross if A is before B and C is before D) + const nodes = [node("A"), node("B"), node("C"), node("D")]; + const layers = [["A", "B"], ["C", "D"]]; + const edges = [edge("A", "D"), edge("B", "C")]; + + const result = minimizeCrossings(layers, edges, nodeMap(nodes)); + + // After minimization, should reorder to eliminate crossing + // Either layer 0 becomes [B, A] or layer 1 becomes [D, C] + // Verify no crossings remain + const l0Pos = new Map(); + const l1Pos = new Map(); + for (let i = 0; i < result[0].length; i++) l0Pos.set(result[0][i], i); + for (let i = 0; i < result[1].length; i++) l1Pos.set(result[1][i], i); + + // Edge A->D and B->C should not cross + const aPosTop = l0Pos.get("A")!; + const bPosTop = l0Pos.get("B")!; + const cPosBot = l1Pos.get("C")!; + const dPosBot = l1Pos.get("D")!; + + // Crossing means (A before B but D after C) or (A after B but D before C) + const crosses = + (aPosTop < bPosTop && dPosBot > cPosBot) || + (aPosTop > bPosTop && dPosBot < cPosBot); + expect(crosses).toBe(false); + }); + + it("preserves order when no crossings exist", () => { + // A->C, B->D (no crossing since A { + it("assigns single node at origin", () => { + const nodes = [node("A", 200, 100)]; + const nMap = nodeMap(nodes); + const result = assignCoordinates([["A"]], nMap, { + horizontalGap: 100, + verticalGap: 40, + }); + + expect(result.get("A")).toEqual({ x: 0, y: 0 }); + }); + + it("stacks nodes vertically within a layer", () => { + const nodes = [node("A", 200, 100), node("B", 200, 80)]; + const nMap = nodeMap(nodes); + const result = assignCoordinates([["A", "B"]], nMap, { + horizontalGap: 100, + verticalGap: 40, + }); + + const posA = result.get("A")!; + const posB = result.get("B")!; + + // Same X + expect(posA.x).toBe(posB.x); + // B below A with gap + expect(posB.y).toBe(posA.y + 100 + 40); // A.height + gap + }); + + it("places layers left to right with horizontal gap", () => { + const nodes = [node("A", 200, 100), node("B", 150, 80)]; + const nMap = nodeMap(nodes); + const result = assignCoordinates([["A"], ["B"]], nMap, { + horizontalGap: 100, + verticalGap: 40, + }); + + const posA = result.get("A")!; + const posB = result.get("B")!; + + expect(posA.x).toBe(0); + expect(posB.x).toBe(200 + 100); // A.width + gap + }); + + it("returns empty map for empty layers", () => { + const result = assignCoordinates([], new Map(), { + horizontalGap: 100, + verticalGap: 40, + }); + expect(result.size).toBe(0); + }); + + it("centers shorter layers vertically", () => { + // Layer 0: two nodes (taller), Layer 1: one node (shorter) + const nodes = [ + node("A", 200, 100), + node("B", 200, 100), + node("C", 200, 100), + ]; + const nMap = nodeMap(nodes); + const result = assignCoordinates([["A", "B"], ["C"]], nMap, { + horizontalGap: 100, + verticalGap: 40, + }); + + const posA = result.get("A")!; + const posC = result.get("C")!; + + // Layer 0 total height: 100 + 40 + 100 = 240 + // Layer 1 total height: 100 + // Layer 1 y offset: (240 - 100) / 2 = 70 + expect(posA.y).toBe(0); + expect(posC.y).toBe(70); + }); +}); + +// --------------------------------------------------------------------------- +// Full Sugiyama algorithm (integration) +// --------------------------------------------------------------------------- + +describe("createSugiyamaAlgorithm", () => { + const sugiyama = createSugiyamaAlgorithm(); + + it("handles single node", () => { + const result = sugiyama.layout({ + nodes: [node("A")], + edges: [], + }); + + expect(result.positions.size).toBe(1); + expect(result.positions.get("A")).toEqual({ x: 0, y: 0 }); + }); + + it("lays out linear chain A->B->C left to right", () => { + const nodes = [node("A"), node("B"), node("C")]; + const edges = [edge("A", "B"), edge("B", "C")]; + const result = sugiyama.layout({ nodes, edges }); + + expect(result.positions.size).toBe(3); + const posA = result.positions.get("A")!; + const posB = result.positions.get("B")!; + const posC = result.positions.get("C")!; + + // X should increase left to right + expect(posA.x).toBeLessThan(posB.x); + expect(posB.x).toBeLessThan(posC.x); + + assertFiniteCoordinates(result.positions); + assertNoOverlaps(result.positions, nodes); + }); + + it("lays out diamond graph correctly", () => { + const nodes = [node("A"), node("B"), node("C"), node("D")]; + const edges = [ + edge("A", "B"), + edge("A", "C"), + edge("B", "D"), + edge("C", "D"), + ]; + const result = sugiyama.layout({ nodes, edges }); + + expect(result.positions.size).toBe(4); + + const posA = result.positions.get("A")!; + const posB = result.positions.get("B")!; + const posC = result.positions.get("C")!; + const posD = result.positions.get("D")!; + + // A at layer 0, B and C at layer 1, D at layer 2 + expect(posA.x).toBeLessThan(posB.x); + expect(posA.x).toBeLessThan(posC.x); + expect(posB.x).toBe(posC.x); // same layer + expect(posB.x).toBeLessThan(posD.x); + + // B and C should be at different Y positions + expect(posB.y).not.toBe(posC.y); + + assertFiniteCoordinates(result.positions); + assertNoOverlaps(result.positions, nodes); + }); + + it("handles cycle A->B->C->A", () => { + const nodes = [node("A"), node("B"), node("C")]; + const edges = [edge("A", "B"), edge("B", "C"), edge("C", "A")]; + const result = sugiyama.layout({ nodes, edges }); + + // All nodes should be positioned + expect(result.positions.size).toBe(3); + assertFiniteCoordinates(result.positions); + assertNoOverlaps(result.positions, nodes); + }); + + it("handles wide graph (many nodes in one layer)", () => { + const nodes = [ + node("root"), + node("A"), + node("B"), + node("C"), + node("D"), + node("E"), + ]; + const edges = [ + edge("root", "A"), + edge("root", "B"), + edge("root", "C"), + edge("root", "D"), + edge("root", "E"), + ]; + const result = sugiyama.layout({ nodes, edges }); + + expect(result.positions.size).toBe(6); + + // All child nodes should have same X (same layer) + const childPositions = ["A", "B", "C", "D", "E"].map( + (id) => result.positions.get(id)!, + ); + const childX = childPositions[0].x; + for (const pos of childPositions) { + expect(pos.x).toBe(childX); + } + + // Root should be at a smaller X + expect(result.positions.get("root")!.x).toBeLessThan(childX); + + assertFiniteCoordinates(result.positions); + assertNoOverlaps(result.positions, nodes); + }); + + it("handles empty input", () => { + const result = sugiyama.layout({ nodes: [], edges: [] }); + expect(result.positions.size).toBe(0); + }); + + it("is idempotent (same input produces same output)", () => { + const nodes = [node("A"), node("B"), node("C"), node("D")]; + const edges = [ + edge("A", "B"), + edge("A", "C"), + edge("B", "D"), + edge("C", "D"), + ]; + + const result1 = sugiyama.layout({ nodes, edges }); + const result2 = sugiyama.layout({ nodes, edges }); + + for (const n of nodes) { + const pos1 = result1.positions.get(n.id)!; + const pos2 = result2.positions.get(n.id)!; + expect(pos1.x).toBe(pos2.x); + expect(pos1.y).toBe(pos2.y); + } + }); + + it("respects custom gap configuration", () => { + const custom = createSugiyamaAlgorithm({ + horizontalGap: 200, + verticalGap: 80, + }); + + const nodes = [node("A", 100, 50), node("B", 100, 50)]; + const edges = [edge("A", "B")]; + const result = custom.layout({ nodes, edges }); + + const posA = result.positions.get("A")!; + const posB = result.positions.get("B")!; + + // Horizontal gap should be respected: B.x = A.x + A.width + gap + expect(posB.x - posA.x).toBe(100 + 200); + }); + + it("handles complex multi-layer graph", () => { + // S -> A, S -> B + // A -> C, A -> D + // B -> D, B -> E + // C -> F, D -> F, E -> F + const nodes = [ + node("S"), + node("A"), + node("B"), + node("C"), + node("D"), + node("E"), + node("F"), + ]; + const edges = [ + edge("S", "A"), + edge("S", "B"), + edge("A", "C"), + edge("A", "D"), + edge("B", "D"), + edge("B", "E"), + edge("C", "F"), + edge("D", "F"), + edge("E", "F"), + ]; + const result = sugiyama.layout({ nodes, edges }); + + expect(result.positions.size).toBe(7); + assertFiniteCoordinates(result.positions); + assertNoOverlaps(result.positions, nodes); + + // S should be leftmost, F should be rightmost + const posS = result.positions.get("S")!; + const posF = result.positions.get("F")!; + expect(posS.x).toBeLessThan(posF.x); + + // Layer ordering should hold for all edges + for (const e of edges) { + const srcX = result.positions.get(e.source)!.x; + const tgtX = result.positions.get(e.target)!.x; + expect(srcX).toBeLessThan(tgtX); + } + }); + + it("produces positions for all nodes even in complex cycles", () => { + // A->B->C->D->A plus A->C (multiple cycles) + const nodes = [node("A"), node("B"), node("C"), node("D")]; + const edges = [ + edge("A", "B"), + edge("B", "C"), + edge("C", "D"), + edge("D", "A"), + edge("A", "C"), + ]; + const result = sugiyama.layout({ nodes, edges }); + + expect(result.positions.size).toBe(4); + assertFiniteCoordinates(result.positions); + assertNoOverlaps(result.positions, nodes); + }); + + it("has name 'sugiyama'", () => { + expect(sugiyama.name).toBe("sugiyama"); + }); + + it("handles nodes with varying sizes", () => { + const nodes = [ + node("tiny", 50, 30), + node("medium", 200, 100), + node("large", 400, 200), + ]; + const edges = [edge("tiny", "medium"), edge("medium", "large")]; + const result = sugiyama.layout({ nodes, edges }); + + expect(result.positions.size).toBe(3); + assertFiniteCoordinates(result.positions); + assertNoOverlaps(result.positions, nodes); + + // X should increase + const positions = nodes.map((n) => result.positions.get(n.id)!); + expect(positions[0].x).toBeLessThan(positions[1].x); + expect(positions[1].x).toBeLessThan(positions[2].x); + }); + + it("handles parallel paths of different lengths", () => { + // A -> B -> C -> D (long path) + // A -> D (short path) + const nodes = [node("A"), node("B"), node("C"), node("D")]; + const edges = [ + edge("A", "B"), + edge("B", "C"), + edge("C", "D"), + edge("A", "D"), + ]; + const result = sugiyama.layout({ nodes, edges }); + + // D should be at layer 3 (longest path), not layer 1 + const posA = result.positions.get("A")!; + const posB = result.positions.get("B")!; + const posC = result.positions.get("C")!; + const posD = result.positions.get("D")!; + + expect(posA.x).toBeLessThan(posB.x); + expect(posB.x).toBeLessThan(posC.x); + expect(posC.x).toBeLessThan(posD.x); + + assertNoOverlaps(result.positions, nodes); + }); +}); diff --git a/tests/unit/tokens.test.ts b/tests/unit/tokens.test.ts new file mode 100644 index 0000000..5d0454d --- /dev/null +++ b/tests/unit/tokens.test.ts @@ -0,0 +1,223 @@ +import { describe, it, expect } from "vitest"; +import { parseLayoutToken, tokenToAlgorithm } from "../../src/layout/tokens"; +import { createHorizontalAlgorithm } from "../../src/layout/algorithms/horizontal"; +import { createVerticalAlgorithm } from "../../src/layout/algorithms/vertical"; +import type { LayoutNode, LayoutInput } from "../../src/layout/types"; + +function node(id: string, width = 100, height = 60): LayoutNode { + return { id, width, height }; +} + +function input(nodes: LayoutNode[]): LayoutInput { + return { nodes, edges: [] }; +} + +// --------------------------------------------------------------------------- +// parseLayoutToken +// --------------------------------------------------------------------------- + +describe("parseLayoutToken", () => { + it("parses [HORIZONTAL] from group title", () => { + const result = parseLayoutToken("My Group [HORIZONTAL]"); + expect(result).toEqual({ mode: "horizontal" }); + }); + + it("parses [vertical] case-insensitively", () => { + const result = parseLayoutToken("stuff [vertical] more"); + expect(result).toEqual({ mode: "vertical" }); + }); + + it("parses [2ROW] as grid with 2 rows", () => { + const result = parseLayoutToken("[2ROW]"); + expect(result).toEqual({ mode: "grid", count: 2, dimension: "row" }); + }); + + it("parses [3col] case-insensitively as grid with 3 columns", () => { + const result = parseLayoutToken("Title [3col]"); + expect(result).toEqual({ mode: "grid", count: 3, dimension: "col" }); + }); + + it("treats [1ROW] as horizontal alias", () => { + const result = parseLayoutToken("[1ROW]"); + expect(result).toEqual({ mode: "horizontal" }); + }); + + it("treats [1COL] as vertical alias", () => { + const result = parseLayoutToken("[1COL]"); + expect(result).toEqual({ mode: "vertical" }); + }); + + it("returns null when no token present", () => { + const result = parseLayoutToken("No token here"); + expect(result).toBeNull(); + }); + + it("returns null for [10ROW] (only 1-9 supported)", () => { + const result = parseLayoutToken("[10ROW]"); + expect(result).toBeNull(); + }); + + it("returns null for [0ROW] (0 not valid)", () => { + const result = parseLayoutToken("[0ROW]"); + expect(result).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Algorithm behavior: horizontal +// --------------------------------------------------------------------------- + +describe("horizontal algorithm", () => { + it("places 3 nodes left-to-right with Y centered", () => { + const nodes = [node("A", 100, 40), node("B", 80, 60), node("C", 120, 20)]; + const algo = createHorizontalAlgorithm(40); + const result = algo.layout(input(nodes)); + + const posA = result.positions.get("A")!; + const posB = result.positions.get("B")!; + const posC = result.positions.get("C")!; + + // Max height is 60 (node B) + // A: y = 60/2 - 40/2 = 10 + // B: y = 60/2 - 60/2 = 0 + // C: y = 60/2 - 20/2 = 20 + expect(posA).toEqual({ x: 0, y: 10 }); + expect(posB).toEqual({ x: 140, y: 0 }); // 0 + 100 + 40 + expect(posC).toEqual({ x: 260, y: 20 }); // 140 + 80 + 40 + }); + + it("returns empty positions for empty input", () => { + const algo = createHorizontalAlgorithm(); + const result = algo.layout(input([])); + expect(result.positions.size).toBe(0); + }); + + it("places single node at origin", () => { + const algo = createHorizontalAlgorithm(); + const result = algo.layout(input([node("A", 100, 60)])); + expect(result.positions.get("A")).toEqual({ x: 0, y: 0 }); + }); +}); + +// --------------------------------------------------------------------------- +// Algorithm behavior: vertical +// --------------------------------------------------------------------------- + +describe("vertical algorithm", () => { + it("places 3 nodes top-to-bottom with X centered", () => { + const nodes = [node("A", 80, 40), node("B", 120, 60), node("C", 60, 30)]; + const algo = createVerticalAlgorithm(40); + const result = algo.layout(input(nodes)); + + const posA = result.positions.get("A")!; + const posB = result.positions.get("B")!; + const posC = result.positions.get("C")!; + + // Max width is 120 (node B) + // A: x = 120/2 - 80/2 = 20 + // B: x = 120/2 - 120/2 = 0 + // C: x = 120/2 - 60/2 = 30 + expect(posA).toEqual({ x: 20, y: 0 }); + expect(posB).toEqual({ x: 0, y: 80 }); // 0 + 40 + 40 + expect(posC).toEqual({ x: 30, y: 180 }); // 80 + 60 + 40 + }); + + it("returns empty positions for empty input", () => { + const algo = createVerticalAlgorithm(); + const result = algo.layout(input([])); + expect(result.positions.size).toBe(0); + }); + + it("places single node at origin", () => { + const algo = createVerticalAlgorithm(); + const result = algo.layout(input([node("A", 100, 60)])); + expect(result.positions.get("A")).toEqual({ x: 0, y: 0 }); + }); +}); + +// --------------------------------------------------------------------------- +// Algorithm behavior: grid +// --------------------------------------------------------------------------- + +describe("grid algorithm via tokenToAlgorithm", () => { + it("[2ROW] with 6 nodes: 2 rows x 3 columns", () => { + const token = parseLayoutToken("[2ROW]")!; + const algo = tokenToAlgorithm(token, 40); + const nodes = [ + node("A", 100, 60), node("B", 100, 60), node("C", 100, 60), + node("D", 100, 60), node("E", 100, 60), node("F", 100, 60), + ]; + const result = algo.layout(input(nodes)); + + expect(result.positions.size).toBe(6); + + // Row 0: A, B, C + // Row 1: D, E, F + const posA = result.positions.get("A")!; + const posB = result.positions.get("B")!; + const posD = result.positions.get("D")!; + + // Same-size nodes, gap=40: cellWidth=140, cellHeight=100 + expect(posA).toEqual({ x: 0, y: 0 }); + expect(posB).toEqual({ x: 140, y: 0 }); + expect(posD).toEqual({ x: 0, y: 100 }); + }); + + it("[3COL] with 6 nodes: 2 rows x 3 columns", () => { + const token = parseLayoutToken("[3COL]")!; + const algo = tokenToAlgorithm(token, 40); + const nodes = [ + node("A", 100, 60), node("B", 100, 60), node("C", 100, 60), + node("D", 100, 60), node("E", 100, 60), node("F", 100, 60), + ]; + const result = algo.layout(input(nodes)); + + expect(result.positions.size).toBe(6); + + // Row 0: A, B, C + // Row 1: D, E, F + const posA = result.positions.get("A")!; + const posD = result.positions.get("D")!; + + expect(posA).toEqual({ x: 0, y: 0 }); + expect(posD).toEqual({ x: 0, y: 100 }); + }); + + it("[2ROW] with 5 nodes: row 0 has 3, row 1 has 2", () => { + const token = parseLayoutToken("[2ROW]")!; + const algo = tokenToAlgorithm(token, 40); + const nodes = [ + node("A", 100, 60), node("B", 100, 60), node("C", 100, 60), + node("D", 100, 60), node("E", 100, 60), + ]; + const result = algo.layout(input(nodes)); + + expect(result.positions.size).toBe(5); + + // cols = ceil(5/2) = 3 + // Row 0: A(0,0), B(0,1), C(0,2) + // Row 1: D(1,0), E(1,1) + const posC = result.positions.get("C")!; + const posD = result.positions.get("D")!; + const posE = result.positions.get("E")!; + + expect(posC.y).toBe(0); // row 0 + expect(posD.y).toBe(100); // row 1 + expect(posE.y).toBe(100); // row 1 + expect(posE.x).toBe(140); // col 1 + }); + + it("empty input returns empty positions", () => { + const token = parseLayoutToken("[2ROW]")!; + const algo = tokenToAlgorithm(token, 40); + const result = algo.layout(input([])); + expect(result.positions.size).toBe(0); + }); + + it("single node placed at origin", () => { + const token = parseLayoutToken("[3COL]")!; + const algo = tokenToAlgorithm(token, 40); + const result = algo.layout(input([node("A", 100, 60)])); + expect(result.positions.get("A")).toEqual({ x: 0, y: 0 }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 4e00dd5..bd212fb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,5 +21,5 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["vite.config.ts", "src", "tests"] + "include": ["vite.config.ts", "src", "tests", "scripts"] } diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..5a07c6b --- /dev/null +++ b/uv.lock @@ -0,0 +1,367 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "bracex" +version = "2.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/9a/fec38644694abfaaeca2798b58e276a8e61de49e2e37494ace423395febc/bracex-2.6.tar.gz", hash = "sha256:98f1347cd77e22ee8d967a30ad4e310b233f7754dbf31ff3fceb76145ba47dc7", size = 26642, upload-time = "2025-06-22T19:12:31.254Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/2a/9186535ce58db529927f6cf5990a849aa9e052eea3e2cfefe20b9e1802da/bracex-2.6-py3-none-any.whl", hash = "sha256:0b0049264e7340b3ec782b5cb99beb325f36c3782a32e36e876452fd49a09952", size = 11508, upload-time = "2025-06-22T19:12:29.781Z" }, +] + +[[package]] +name = "bump-my-version" +version = "1.2.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "questionary" }, + { name = "rich" }, + { name = "rich-click" }, + { name = "tomlkit" }, + { name = "wcmatch" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/11/0f73c652396f86197ea6d509c78e8c44c3483d9a86437ca53ce55edca8e8/bump_my_version-1.2.7.tar.gz", hash = "sha256:d915a10b41e0c9db5a2fa39bde9f45f92e1e4194242d819c9ceb9eca8831cd21", size = 1198071, upload-time = "2026-02-14T13:44:59.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/ed/ad1755f82cd5a0baafe342e7154696a93e57f04f86515402f14e5beceb36/bump_my_version-1.2.7-py3-none-any.whl", hash = "sha256:16f89360f979c0a8eb3249ebe3e13ae4f0cb5481d7bb58e12a9f66996922acfd", size = 60013, upload-time = "2026-02-14T13:44:58.318Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "comfyui-node-organizer" +version = "1.3.0" +source = { virtual = "." } + +[package.dev-dependencies] +dev = [ + { name = "bump-my-version" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [{ name = "bump-my-version", specifier = ">=1.2.2" }] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "questionary" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" }, +] + +[[package]] +name = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, +] + +[[package]] +name = "rich-click" +version = "1.9.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/27/091e140ea834272188e63f8dd6faac1f5c687582b687197b3e0ec3c78ebf/rich_click-1.9.7.tar.gz", hash = "sha256:022997c1e30731995bdbc8ec2f82819340d42543237f033a003c7b1f843fc5dc", size = 74838, upload-time = "2026-01-31T04:29:27.707Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/e5/d708d262b600a352abe01c2ae360d8ff75b0af819b78e9af293191d928e6/rich_click-1.9.7-py3-none-any.whl", hash = "sha256:2f99120fca78f536e07b114d3b60333bc4bb2a0969053b1250869bcdc1b5351b", size = 71491, upload-time = "2026-01-31T04:29:26.777Z" }, +] + +[[package]] +name = "tomlkit" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "wcmatch" +version = "10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bracex" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/3e/c0bdc27cf06f4e47680bd5803a07cb3dfd17de84cde92dd217dcb9e05253/wcmatch-10.1.tar.gz", hash = "sha256:f11f94208c8c8484a16f4f48638a85d771d9513f4ab3f37595978801cb9465af", size = 117421, upload-time = "2025-06-22T19:14:02.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/d8/0d1d2e9d3fabcf5d6840362adcf05f8cf3cd06a73358140c3a97189238ae/wcmatch-10.1-py3-none-any.whl", hash = "sha256:5848ace7dbb0476e5e55ab63c6bbd529745089343427caa5537f230cc01beb8a", size = 39854, upload-time = "2025-06-22T19:14:00.978Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, +] diff --git a/vitest.config.ts b/vitest.config.ts index 66cfc63..17b8ee3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,13 +3,13 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { globals: true, - include: ["tests/**/*.test.ts"], + include: ["tests/unit/**/*.test.ts"], + exclude: ["tests/e2e/**"], coverage: { provider: "v8", include: ["src/**/*.ts"], exclude: [ "src/index.ts", // Extension entry point not testable without ComfyUI - "src/layout/reroute-collapse.ts", // No fixtures with reroute nodes yet ], thresholds: { lines: 70,