Compare commits
17 Commits
5252-keybo
...
fix/small-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e1018aecf | ||
|
|
661885f5e5 | ||
|
|
5cff131144 | ||
|
|
038ed27107 | ||
|
|
139ae87983 | ||
|
|
b994608506 | ||
|
|
7b1cce1d0e | ||
|
|
39eca0bc0a | ||
|
|
2970692176 | ||
|
|
4b1c165d43 | ||
|
|
720de8cc8a | ||
|
|
48335475dc | ||
|
|
0d3d258995 | ||
|
|
3818ba5d17 | ||
|
|
5090f41028 | ||
|
|
7e4c756258 | ||
|
|
37fab21daf |
@@ -294,7 +294,6 @@ echo "Last stable release: $LAST_STABLE"
|
||||
1. Run complete test suite:
|
||||
```bash
|
||||
pnpm test:unit
|
||||
pnpm test:component
|
||||
```
|
||||
2. Run type checking:
|
||||
```bash
|
||||
|
||||
@@ -120,7 +120,6 @@ echo "Available commands:"
|
||||
echo " pnpm dev - Start development server"
|
||||
echo " pnpm build - Build for production"
|
||||
echo " pnpm test:unit - Run unit tests"
|
||||
echo " pnpm test:component - Run component tests"
|
||||
echo " pnpm typecheck - Run TypeScript checks"
|
||||
echo " pnpm lint - Run ESLint"
|
||||
echo " pnpm format - Format code with Prettier"
|
||||
|
||||
2
.github/workflows/lint-and-format.yaml
vendored
@@ -67,7 +67,7 @@ jobs:
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
git add .
|
||||
git commit -m "[auto-fix] Apply ESLint and Prettier fixes"
|
||||
git commit -m "[automated] Apply ESLint and Prettier fixes"
|
||||
git push
|
||||
|
||||
- name: Final validation
|
||||
|
||||
@@ -39,6 +39,6 @@ jobs:
|
||||
git fetch origin ${{ github.head_ref }}
|
||||
git checkout -B ${{ github.head_ref }} origin/${{ github.head_ref }}
|
||||
git add browser_tests
|
||||
git commit -m "Update test expectations [skip ci]"
|
||||
git commit -m "[automated] Update test expectations"
|
||||
git push origin HEAD:${{ github.head_ref }}
|
||||
working-directory: ComfyUI_frontend
|
||||
|
||||
58
.github/workflows/vitest-tests.yaml
vendored
@@ -2,45 +2,43 @@ name: Vitest Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master, dev*, core/*, desktop/* ]
|
||||
branches: [main, master, dev*, core/*, desktop/*]
|
||||
pull_request:
|
||||
branches-ignore: [ wip/*, draft/*, temp/* ]
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
cache: 'pnpm'
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Cache tool outputs
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
.cache
|
||||
coverage
|
||||
.vitest-cache
|
||||
key: vitest-cache-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.{ts,vue,js}', 'vitest.config.*', 'tsconfig.json') }}
|
||||
restore-keys: |
|
||||
vitest-cache-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}-
|
||||
vitest-cache-${{ runner.os }}-
|
||||
test-tools-cache-${{ runner.os }}-
|
||||
- name: Cache tool outputs
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
.cache
|
||||
coverage
|
||||
.vitest-cache
|
||||
key: vitest-cache-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.{ts,vue,js}', 'vitest.config.*', 'tsconfig.json') }}
|
||||
restore-keys: |
|
||||
vitest-cache-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}-
|
||||
vitest-cache-${{ runner.os }}-
|
||||
test-tools-cache-${{ runner.os }}-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run Vitest tests
|
||||
run: |
|
||||
pnpm test:component
|
||||
pnpm test:unit
|
||||
- name: Run Vitest tests
|
||||
run: pnpm test:unit
|
||||
|
||||
1
.gitignore
vendored
@@ -31,6 +31,7 @@ CLAUDE.local.md
|
||||
*.code-workspace
|
||||
!.vscode/extensions.json
|
||||
!.vscode/tailwind.json
|
||||
!.vscode/custom-css.json
|
||||
!.vscode/settings.json.default
|
||||
!.vscode/launch.json.default
|
||||
.idea
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
- `pnpm storybook`: Start Storybook development server
|
||||
- `pnpm build-storybook`: Build static Storybook
|
||||
- `pnpm test:component`: Run component tests (includes Storybook components)
|
||||
- `pnpm test:unit`: Run unit tests (includes Storybook components)
|
||||
|
||||
## Development Workflow for Storybook
|
||||
|
||||
|
||||
50
.vscode/custom-css.json
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"version": 1.1,
|
||||
"properties": [
|
||||
{
|
||||
"name": "app-region",
|
||||
"description": "Electron-specific CSS property that defines draggable regions in custom title bar windows. Setting 'drag' marks a rectangular area as draggable for moving the window; 'no-drag' excludes areas from the draggable region.",
|
||||
"values": [
|
||||
{
|
||||
"name": "drag",
|
||||
"description": "Marks the element as draggable for moving the Electron window"
|
||||
},
|
||||
{
|
||||
"name": "no-drag",
|
||||
"description": "Excludes the element from being used to drag the Electron window"
|
||||
}
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"name": "Electron Window Customization",
|
||||
"url": "https://www.electronjs.org/docs/latest/tutorial/window-customization"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "speak",
|
||||
"description": "Deprecated CSS2 aural stylesheet property for controlling screen reader speech. Use ARIA attributes instead.",
|
||||
"values": [
|
||||
{
|
||||
"name": "auto",
|
||||
"description": "Content is read aurally if element is not a block and is visible"
|
||||
},
|
||||
{
|
||||
"name": "never",
|
||||
"description": "Content will not be read aurally"
|
||||
},
|
||||
{
|
||||
"name": "always",
|
||||
"description": "Content will be read aurally regardless of display settings"
|
||||
}
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"name": "CSS-Tricks Reference",
|
||||
"url": "https://css-tricks.com/almanac/properties/s/speak/"
|
||||
}
|
||||
],
|
||||
"status": "obsolete"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
.vscode/settings.json.default
vendored
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"css.customData": [
|
||||
".vscode/tailwind.json"
|
||||
".vscode/tailwind.json",
|
||||
".vscode/custom-css.json"
|
||||
]
|
||||
}
|
||||
|
||||
36
.vscode/tailwind.json
vendored
@@ -7,7 +7,7 @@
|
||||
"references": [
|
||||
{
|
||||
"name": "Tailwind Documentation",
|
||||
"url": "https://tailwindcss.com/docs/functions-and-directives#import"
|
||||
"url": "https://tailwindcss.com/docs/functions-and-directives#import-directive"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -17,7 +17,7 @@
|
||||
"references": [
|
||||
{
|
||||
"name": "Tailwind Documentation",
|
||||
"url": "https://tailwindcss.com/docs/functions-and-directives#theme"
|
||||
"url": "https://tailwindcss.com/docs/functions-and-directives#theme-directive"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -27,17 +27,17 @@
|
||||
"references": [
|
||||
{
|
||||
"name": "Tailwind Documentation",
|
||||
"url": "https://tailwindcss.com/docs/functions-and-directives#layer"
|
||||
"url": "https://tailwindcss.com/docs/theme#layers"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "@apply",
|
||||
"description": "Use the `@apply` directive to inline any existing utility classes into your own custom CSS. This is useful when you find a common utility pattern in your HTML that you’d like to extract to a new component.",
|
||||
"description": "DO NOT USE. IF YOU ARE CAUGHT USING @apply YOU WILL FACE SEVERE CONSEQUENCES.",
|
||||
"references": [
|
||||
{
|
||||
"name": "Tailwind Documentation",
|
||||
"url": "https://tailwindcss.com/docs/functions-and-directives#apply"
|
||||
"url": "https://tailwindcss.com/docs/functions-and-directives#apply-directive"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -47,7 +47,7 @@
|
||||
"references": [
|
||||
{
|
||||
"name": "Tailwind Documentation",
|
||||
"url": "https://tailwindcss.com/docs/functions-and-directives#config"
|
||||
"url": "https://tailwindcss.com/docs/functions-and-directives#config-directive"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -57,7 +57,7 @@
|
||||
"references": [
|
||||
{
|
||||
"name": "Tailwind Documentation",
|
||||
"url": "https://tailwindcss.com/docs/functions-and-directives#reference"
|
||||
"url": "https://tailwindcss.com/docs/functions-and-directives#reference-directive"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -67,7 +67,27 @@
|
||||
"references": [
|
||||
{
|
||||
"name": "Tailwind Documentation",
|
||||
"url": "https://tailwindcss.com/docs/functions-and-directives#plugin"
|
||||
"url": "https://tailwindcss.com/docs/functions-and-directives#plugin-directive"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "@custom-variant",
|
||||
"description": "Use the `@custom-variant` directive to add a custom variant to your project. Custom variants can be used with utilities like `hover`, `focus`, and responsive breakpoints. Use `@slot` inside the variant to indicate where the utility's styles should be inserted.",
|
||||
"references": [
|
||||
{
|
||||
"name": "Tailwind Documentation",
|
||||
"url": "https://tailwindcss.com/docs/adding-custom-styles#adding-custom-variants"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "@utility",
|
||||
"description": "Use the `@utility` directive to add custom utilities to your project. Custom utilities work with all variants like `hover`, `focus`, and responsive variants. Use `--value()` to create functional utilities that accept arguments.",
|
||||
"references": [
|
||||
{
|
||||
"name": "Tailwind Documentation",
|
||||
"url": "https://tailwindcss.com/docs/adding-custom-styles#adding-custom-utilities"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -12,8 +12,7 @@
|
||||
- `pnpm dev:electron`: Dev server with Electron API mocks.
|
||||
- `pnpm build`: Type-check then production build to `dist/`.
|
||||
- `pnpm preview`: Preview the production build locally.
|
||||
- `pnpm test:unit`: Run Vitest unit tests (`tests-ui/`).
|
||||
- `pnpm test:component`: Run component tests (`src/components/`).
|
||||
- `pnpm test:unit`: Run Vitest unit tests.
|
||||
- `pnpm test:browser`: Run Playwright E2E tests (`browser_tests/`).
|
||||
- `pnpm lint` / `pnpm lint:fix`: Lint (ESLint). `pnpm format` / `format:check`: Prettier.
|
||||
- `pnpm typecheck`: Vue TSC type checking.
|
||||
|
||||
@@ -18,7 +18,6 @@ This bootstraps the monorepo with dependencies, builds, tests, and dev server ve
|
||||
- `pnpm build`: Build for production (via nx)
|
||||
- `pnpm lint`: Linting (via nx)
|
||||
- `pnpm format`: Prettier formatting
|
||||
- `pnpm test:component`: Run component tests with browser environment
|
||||
- `pnpm test:unit`: Run all unit tests
|
||||
- `pnpm test:browser`: Run E2E tests via Playwright
|
||||
- `pnpm test:unit -- tests-ui/tests/example.test.ts`: Run single test file
|
||||
|
||||
@@ -213,12 +213,6 @@ Here's how Claude Code can use the Playwright MCP server to inspect the interfac
|
||||
- `pnpm i` to install all dependencies
|
||||
- `pnpm test:unit` to execute all unit tests
|
||||
|
||||
### Component Tests
|
||||
|
||||
Component tests verify Vue components in `src/components/`.
|
||||
|
||||
- `pnpm test:component` to execute all component tests
|
||||
|
||||
### Playwright Tests
|
||||
|
||||
Playwright tests verify the whole app. See [browser_tests/README.md](browser_tests/README.md) for details.
|
||||
@@ -229,7 +223,6 @@ Before submitting a PR, ensure all tests pass:
|
||||
|
||||
```bash
|
||||
pnpm test:unit
|
||||
pnpm test:component
|
||||
pnpm test:browser
|
||||
pnpm typecheck
|
||||
pnpm lint
|
||||
|
||||
@@ -151,7 +151,8 @@ class NodeSlotReference {
|
||||
const convertedPos =
|
||||
window['app'].canvas.ds.convertOffsetToCanvas(rawPos)
|
||||
|
||||
// Debug logging - convert Float32Arrays to regular arrays for visibility
|
||||
// Debug logging - convert Float64Arrays to regular arrays for visibility
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`NodeSlotReference debug for ${type} slot ${index} on node ${id}:`,
|
||||
{
|
||||
|
||||
@@ -53,6 +53,10 @@ test.describe('DOM Widget', () => {
|
||||
})
|
||||
|
||||
test('should reposition when layout changes', async ({ comfyPage }) => {
|
||||
test.skip(
|
||||
true,
|
||||
'Only recalculates when the Canvas size changes, need to recheck the logic'
|
||||
)
|
||||
// --- setup ---
|
||||
|
||||
const textareaWidget = comfyPage.page
|
||||
|
||||
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 50 KiB |
67
browser_tests/tests/vueNodes/interactions/node/move.spec.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
type ComfyPage,
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../../fixtures/ComfyPage'
|
||||
import type { Position } from '../../../../fixtures/types'
|
||||
|
||||
test.describe('Vue Node Moving', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
const getLoadCheckpointHeaderPos = async (comfyPage: ComfyPage) => {
|
||||
const loadCheckpointHeaderPos = await comfyPage.page
|
||||
.getByText('Load Checkpoint')
|
||||
.boundingBox()
|
||||
|
||||
if (!loadCheckpointHeaderPos)
|
||||
throw new Error('Load Checkpoint header not found')
|
||||
|
||||
return loadCheckpointHeaderPos
|
||||
}
|
||||
|
||||
const expectPosChanged = async (pos1: Position, pos2: Position) => {
|
||||
const diffX = Math.abs(pos2.x - pos1.x)
|
||||
const diffY = Math.abs(pos2.y - pos1.y)
|
||||
expect(diffX).toBeGreaterThan(0)
|
||||
expect(diffY).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
test('should allow moving nodes by dragging', async ({ comfyPage }) => {
|
||||
const loadCheckpointHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
await comfyPage.dragAndDrop(loadCheckpointHeaderPos, {
|
||||
x: 256,
|
||||
y: 256
|
||||
})
|
||||
|
||||
const newHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
await expectPosChanged(loadCheckpointHeaderPos, newHeaderPos)
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('vue-node-moved-node.png')
|
||||
})
|
||||
|
||||
test('@mobile should allow moving nodes by dragging on touch devices', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Disable minimap (gets in way of the node on small screens)
|
||||
await comfyPage.setSetting('Comfy.Minimap.Visible', false)
|
||||
|
||||
const loadCheckpointHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
await comfyPage.panWithTouch(
|
||||
{
|
||||
x: 64,
|
||||
y: 64
|
||||
},
|
||||
loadCheckpointHeaderPos
|
||||
)
|
||||
|
||||
const newHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
await expectPosChanged(loadCheckpointHeaderPos, newHeaderPos)
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-node-moved-node-touch.png'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
1039
docs/keybinding.md
@@ -37,7 +37,9 @@ export default defineConfig([
|
||||
allowDefaultProject: [
|
||||
'vite.config.mts',
|
||||
'vite.electron.config.mts',
|
||||
'vite.types.config.mts'
|
||||
'vite.types.config.mts',
|
||||
'playwright.config.ts',
|
||||
'playwright.i18n.config.ts'
|
||||
]
|
||||
},
|
||||
tsConfigRootDir: import.meta.dirname,
|
||||
|
||||
49
package.json
@@ -1,45 +1,42 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.28.4",
|
||||
"version": "1.28.5",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"license": "GPL-3.0-only",
|
||||
"scripts": {
|
||||
"dev": "nx serve",
|
||||
"dev:electron": "nx serve --config vite.electron.config.mts",
|
||||
"build": "pnpm typecheck && nx build",
|
||||
"build-storybook": "storybook build",
|
||||
"build:types": "nx build --config vite.types.config.mts && node scripts/prepare-types.js",
|
||||
"zipdist": "node scripts/zipdist.js",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"format": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --cache --list-different",
|
||||
"build": "pnpm typecheck && nx build",
|
||||
"collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts",
|
||||
"dev:electron": "nx serve --config vite.electron.config.mts",
|
||||
"dev": "nx serve",
|
||||
"devtools:pycheck": "python3 -m compileall -q tools/devtools",
|
||||
"format:check:no-cache": "prettier --check './**/*.{js,ts,tsx,vue,mts}'",
|
||||
"format:check": "prettier --check './**/*.{js,ts,tsx,vue,mts}' --cache",
|
||||
"format:no-cache": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --list-different",
|
||||
"format:check:no-cache": "prettier --check './**/*.{js,ts,tsx,vue,mts}'",
|
||||
"test:all": "nx run test",
|
||||
"test:browser": "pnpm exec nx e2e",
|
||||
"test:component": "nx run test src/components/",
|
||||
"test:litegraph": "vitest run --config vitest.litegraph.config.ts",
|
||||
"test:unit": "nx run test tests-ui/tests",
|
||||
"format": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --cache --list-different",
|
||||
"json-schema": "tsx scripts/generate-json-schema.ts",
|
||||
"knip:no-cache": "knip",
|
||||
"knip": "knip --cache",
|
||||
"lint:fix:no-cache": "eslint src --fix",
|
||||
"lint:fix": "eslint src --cache --fix",
|
||||
"lint:no-cache": "eslint src",
|
||||
"lint:unstaged:fix": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache --fix",
|
||||
"lint:unstaged": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache",
|
||||
"lint": "eslint src --cache",
|
||||
"locale": "lobe-i18n locale",
|
||||
"preinstall": "pnpm dlx only-allow pnpm",
|
||||
"prepare": "husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true",
|
||||
"preview": "nx preview",
|
||||
"lint": "eslint src --cache",
|
||||
"lint:fix": "eslint src --cache --fix",
|
||||
"lint:unstaged": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache",
|
||||
"lint:unstaged:fix": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache --fix",
|
||||
"lint:no-cache": "eslint src",
|
||||
"lint:fix:no-cache": "eslint src --fix",
|
||||
"knip": "knip --cache",
|
||||
"knip:no-cache": "knip",
|
||||
"locale": "lobe-i18n locale",
|
||||
"collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts",
|
||||
"json-schema": "tsx scripts/generate-json-schema.ts",
|
||||
"storybook": "nx storybook -p 6006",
|
||||
"build-storybook": "storybook build",
|
||||
"devtools:pycheck": "python3 -m compileall -q tools/devtools"
|
||||
"test:browser": "pnpm exec nx e2e",
|
||||
"test:unit": "nx run test",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"zipdist": "node scripts/zipdist.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "catalog:",
|
||||
|
||||
@@ -172,7 +172,6 @@
|
||||
v-for="template in isLoading ? [] : displayTemplates"
|
||||
:key="template.name"
|
||||
ref="cardRefs"
|
||||
v-memo="[template.name, hoveredTemplate === template.name]"
|
||||
ratio="smallSquare"
|
||||
type="workflow-template-card"
|
||||
:data-testid="`template-workflow-${template.name}`"
|
||||
|
||||
@@ -237,7 +237,7 @@ async function removeKeybinding(commandData: ICommandData) {
|
||||
async function captureKeybinding(event: KeyboardEvent) {
|
||||
// Allow the use of keyboard shortcuts when adding keyboard shortcuts
|
||||
if (!event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey) {
|
||||
switch (event.code) {
|
||||
switch (event.key) {
|
||||
case 'Escape':
|
||||
cancelEdit()
|
||||
return
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
id="graph-canvas"
|
||||
ref="canvasRef"
|
||||
tabindex="1"
|
||||
class="align-top w-full h-full touch-none"
|
||||
class="absolute inset-0 size-full touch-none"
|
||||
/>
|
||||
|
||||
<!-- TransformPane for Vue node rendering -->
|
||||
@@ -43,7 +43,6 @@
|
||||
v-for="nodeData in allNodes"
|
||||
:key="nodeData.id"
|
||||
:node-data="nodeData"
|
||||
:readonly="false"
|
||||
:error="
|
||||
executionStore.lastExecutionError?.node_id === nodeData.id
|
||||
? 'Execution error'
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
<ColorPickerButton v-if="showColorPicker" />
|
||||
<FrameNodes v-if="showFrameNodes" />
|
||||
<ConvertToSubgraphButton v-if="showConvertToSubgraph" />
|
||||
<PublishSubgraphButton v-if="showPublishSubgraph" />
|
||||
<ConfigureSubgraph v-if="showSubgraphButtons" />
|
||||
<PublishSubgraphButton v-if="showSubgraphButtons" />
|
||||
<MaskEditorButton v-if="showMaskEditor" />
|
||||
<VerticalDivider
|
||||
v-if="showAnyPrimaryActions && showAnyControlActions"
|
||||
@@ -50,6 +51,7 @@ import { computed, ref } from 'vue'
|
||||
|
||||
import BypassButton from '@/components/graph/selectionToolbox/BypassButton.vue'
|
||||
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
|
||||
import ConfigureSubgraph from '@/components/graph/selectionToolbox/ConfigureSubgraph.vue'
|
||||
import ConvertToSubgraphButton from '@/components/graph/selectionToolbox/ConvertToSubgraphButton.vue'
|
||||
import DeleteButton from '@/components/graph/selectionToolbox/DeleteButton.vue'
|
||||
import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue'
|
||||
@@ -112,7 +114,7 @@ const showInfoButton = computed(() => !!nodeDef.value)
|
||||
const showColorPicker = computed(() => hasAnySelection.value)
|
||||
const showConvertToSubgraph = computed(() => hasAnySelection.value)
|
||||
const showFrameNodes = computed(() => hasMultipleSelection.value)
|
||||
const showPublishSubgraph = computed(() => isSingleSubgraph.value)
|
||||
const showSubgraphButtons = computed(() => isSingleSubgraph.value)
|
||||
|
||||
const showBypass = computed(
|
||||
() =>
|
||||
@@ -130,7 +132,7 @@ const showAnyPrimaryActions = computed(
|
||||
showColorPicker.value ||
|
||||
showConvertToSubgraph.value ||
|
||||
showFrameNodes.value ||
|
||||
showPublishSubgraph.value
|
||||
showSubgraphButtons.value
|
||||
)
|
||||
|
||||
const showAnyControlActions = computed(() => showBypass.value)
|
||||
|
||||
17
src/components/graph/selectionToolbox/ConfigureSubgraph.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<Button
|
||||
v-tooltip.top="{
|
||||
value: $t('Edit Subgraph Widgets'),
|
||||
showDelay: 1000
|
||||
}"
|
||||
severity="secondary"
|
||||
text
|
||||
icon="icon-[lucide--settings-2]"
|
||||
@click="showSubgraphNodeDialog"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
|
||||
import { showSubgraphNodeDialog } from '@/core/graph/subgraph/useSubgraphNodeDialog'
|
||||
</script>
|
||||
@@ -68,7 +68,7 @@ const updateDomClipping = () => {
|
||||
return
|
||||
}
|
||||
|
||||
const isSelected = selectedNode === widget.node
|
||||
const isSelected = selectedNode === widgetState.widget.node
|
||||
const renderArea = selectedNode?.renderArea
|
||||
const offset = lgCanvas.ds.offset
|
||||
const scale = lgCanvas.ds.scale
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { Ref } from 'vue'
|
||||
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
|
||||
@@ -89,7 +89,7 @@ export function useSelectionToolboxPosition(
|
||||
}
|
||||
} else {
|
||||
// Fallback to LiteGraph bounds for regular nodes or non-string IDs
|
||||
if (item instanceof LGraphNode) {
|
||||
if (item instanceof LGraphNode || item instanceof LGraphGroup) {
|
||||
const bounds = item.getBounding()
|
||||
allBounds.push([bounds[0], bounds[1], bounds[2], bounds[3]] as const)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
DEFAULT_DARK_COLOR_PALETTE,
|
||||
DEFAULT_LIGHT_COLOR_PALETTE
|
||||
} from '@/constants/coreColorPalettes'
|
||||
import { promoteRecommendedWidgets } from '@/core/graph/subgraph/proxyWidgetUtils'
|
||||
import { t } from '@/i18n'
|
||||
import {
|
||||
LGraphEventMode,
|
||||
@@ -909,6 +910,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
|
||||
const { node } = res
|
||||
canvas.select(node)
|
||||
promoteRecommendedWidgets(node)
|
||||
canvasStore.updateSelectedItems()
|
||||
}
|
||||
},
|
||||
|
||||
@@ -26,58 +26,58 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
key: 'KeyR'
|
||||
key: 'r'
|
||||
},
|
||||
commandId: 'Comfy.RefreshNodeDefinitions'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
key: 'KeyQ'
|
||||
key: 'q'
|
||||
},
|
||||
commandId: 'Workspace.ToggleSidebarTab.queue'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
key: 'KeyW'
|
||||
key: 'w'
|
||||
},
|
||||
commandId: 'Workspace.ToggleSidebarTab.workflows'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
key: 'KeyN'
|
||||
key: 'n'
|
||||
},
|
||||
commandId: 'Workspace.ToggleSidebarTab.node-library'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
key: 'KeyM'
|
||||
key: 'm'
|
||||
},
|
||||
commandId: 'Workspace.ToggleSidebarTab.model-library'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
key: 'KeyS',
|
||||
key: 's',
|
||||
ctrl: true
|
||||
},
|
||||
commandId: 'Comfy.SaveWorkflow'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
key: 'KeyO',
|
||||
key: 'o',
|
||||
ctrl: true
|
||||
},
|
||||
commandId: 'Comfy.OpenWorkflow'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
key: 'KeyG',
|
||||
key: 'g',
|
||||
ctrl: true
|
||||
},
|
||||
commandId: 'Comfy.Graph.GroupSelectedNodes'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
key: 'Comma',
|
||||
key: ',',
|
||||
ctrl: true
|
||||
},
|
||||
commandId: 'Comfy.ShowSettingsDialog'
|
||||
@@ -85,7 +85,7 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
|
||||
// For '=' both holding shift and not holding shift
|
||||
{
|
||||
combo: {
|
||||
key: 'Equal',
|
||||
key: '=',
|
||||
alt: true
|
||||
},
|
||||
commandId: 'Comfy.Canvas.ZoomIn',
|
||||
@@ -93,7 +93,7 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
key: 'Equal',
|
||||
key: '+',
|
||||
alt: true,
|
||||
shift: true
|
||||
},
|
||||
@@ -103,7 +103,7 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
|
||||
// For number pad '+'
|
||||
{
|
||||
combo: {
|
||||
key: 'NumpadAdd',
|
||||
key: '+',
|
||||
alt: true
|
||||
},
|
||||
commandId: 'Comfy.Canvas.ZoomIn',
|
||||
@@ -111,7 +111,7 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
key: 'Minus',
|
||||
key: '-',
|
||||
alt: true
|
||||
},
|
||||
commandId: 'Comfy.Canvas.ZoomOut',
|
||||
@@ -119,21 +119,21 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
key: 'Period'
|
||||
key: '.'
|
||||
},
|
||||
commandId: 'Comfy.Canvas.FitView',
|
||||
targetElementId: 'graph-canvas-container'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
key: 'KeyP'
|
||||
key: 'p'
|
||||
},
|
||||
commandId: 'Comfy.Canvas.ToggleSelected.Pin',
|
||||
targetElementId: 'graph-canvas-container'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
key: 'KeyC',
|
||||
key: 'c',
|
||||
alt: true
|
||||
},
|
||||
commandId: 'Comfy.Canvas.ToggleSelectedNodes.Collapse',
|
||||
@@ -141,7 +141,7 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
key: 'KeyB',
|
||||
key: 'b',
|
||||
ctrl: true
|
||||
},
|
||||
commandId: 'Comfy.Canvas.ToggleSelectedNodes.Bypass',
|
||||
@@ -149,7 +149,7 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
key: 'KeyM',
|
||||
key: 'm',
|
||||
ctrl: true
|
||||
},
|
||||
commandId: 'Comfy.Canvas.ToggleSelectedNodes.Mute',
|
||||
@@ -157,20 +157,20 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
key: 'Backquote',
|
||||
key: '`',
|
||||
ctrl: true
|
||||
},
|
||||
commandId: 'Workspace.ToggleBottomPanelTab.logs-terminal'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
key: 'KeyF'
|
||||
key: 'f'
|
||||
},
|
||||
commandId: 'Workspace.ToggleFocusMode'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
key: 'KeyE',
|
||||
key: 'e',
|
||||
ctrl: true,
|
||||
shift: true
|
||||
},
|
||||
@@ -178,7 +178,7 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
key: 'KeyM',
|
||||
key: 'm',
|
||||
alt: true
|
||||
},
|
||||
commandId: 'Comfy.Canvas.ToggleMinimap'
|
||||
@@ -187,19 +187,19 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
|
||||
combo: {
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
key: 'KeyK'
|
||||
key: 'k'
|
||||
},
|
||||
commandId: 'Workspace.ToggleBottomPanel.Shortcuts'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
key: 'KeyV'
|
||||
key: 'v'
|
||||
},
|
||||
commandId: 'Comfy.Canvas.Unlock'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
key: 'KeyH'
|
||||
key: 'h'
|
||||
},
|
||||
commandId: 'Comfy.Canvas.Lock'
|
||||
},
|
||||
|
||||
315
src/core/graph/subgraph/SubgraphNode.vue
Normal file
@@ -0,0 +1,315 @@
|
||||
<script setup lang="ts">
|
||||
import { refDebounced, watchDebounced } from '@vueuse/core'
|
||||
import {
|
||||
computed,
|
||||
customRef,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
triggerRef
|
||||
} from 'vue'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import SubgraphNodeWidget from '@/core/graph/subgraph/SubgraphNodeWidget.vue'
|
||||
import {
|
||||
type WidgetItem,
|
||||
demoteWidget,
|
||||
isRecommendedWidget,
|
||||
matchesPropertyItem,
|
||||
matchesWidgetItem,
|
||||
promoteWidget,
|
||||
widgetItemToProperty
|
||||
} from '@/core/graph/subgraph/proxyWidgetUtils'
|
||||
import {
|
||||
type ProxyWidgetsProperty,
|
||||
parseProxyWidgets
|
||||
} from '@/core/schemas/proxyWidget'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { DraggableList } from '@/scripts/ui/draggableList'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const draggableList = ref<DraggableList | undefined>(undefined)
|
||||
const draggableItems = ref()
|
||||
const searchQuery = ref<string>('')
|
||||
const debouncedQuery = refDebounced(searchQuery, 200)
|
||||
const proxyWidgets = customRef<ProxyWidgetsProperty>((track, trigger) => ({
|
||||
get() {
|
||||
track()
|
||||
const node = activeNode.value
|
||||
if (!node) return []
|
||||
return parseProxyWidgets(node.properties.proxyWidgets)
|
||||
},
|
||||
set(value?: ProxyWidgetsProperty) {
|
||||
trigger()
|
||||
const node = activeNode.value
|
||||
if (!value) return
|
||||
if (!node) {
|
||||
console.error('Attempted to toggle widgets with no node selected')
|
||||
return
|
||||
}
|
||||
node.properties.proxyWidgets = value
|
||||
}
|
||||
}))
|
||||
|
||||
const activeNode = computed(() => {
|
||||
const node = canvasStore.selectedItems[0]
|
||||
if (node instanceof SubgraphNode) return node
|
||||
useDialogStore().closeDialog()
|
||||
return undefined
|
||||
})
|
||||
|
||||
const activeWidgets = computed<WidgetItem[]>({
|
||||
get() {
|
||||
const node = activeNode.value
|
||||
if (!node) return []
|
||||
return proxyWidgets.value.flatMap(([id, name]: [string, string]) => {
|
||||
const wNode = node.subgraph._nodes_by_id[id]
|
||||
if (!wNode?.widgets) return []
|
||||
const w = wNode.widgets.find((w) => w.name === name)
|
||||
if (!w) return []
|
||||
return [[wNode, w]]
|
||||
})
|
||||
},
|
||||
set(value: WidgetItem[]) {
|
||||
const node = activeNode.value
|
||||
if (!node) {
|
||||
console.error('Attempted to toggle widgets with no node selected')
|
||||
return
|
||||
}
|
||||
//map back to id/name
|
||||
const widgets: ProxyWidgetsProperty = value.map(widgetItemToProperty)
|
||||
proxyWidgets.value = widgets
|
||||
}
|
||||
})
|
||||
|
||||
const interiorWidgets = computed<WidgetItem[]>(() => {
|
||||
const node = activeNode.value
|
||||
if (!node) return []
|
||||
const { updatePreviews } = useLitegraphService()
|
||||
const interiorNodes = node.subgraph.nodes
|
||||
for (const node of interiorNodes) {
|
||||
node.updateComputedDisabled()
|
||||
updatePreviews(node)
|
||||
}
|
||||
return interiorNodes
|
||||
.flatMap(nodeWidgets)
|
||||
.filter(([_, w]: WidgetItem) => !w.computedDisabled)
|
||||
})
|
||||
|
||||
const candidateWidgets = computed<WidgetItem[]>(() => {
|
||||
const node = activeNode.value
|
||||
if (!node) return []
|
||||
const widgets = proxyWidgets.value
|
||||
return interiorWidgets.value.filter(
|
||||
(widgetItem: WidgetItem) => !widgets.some(matchesPropertyItem(widgetItem))
|
||||
)
|
||||
})
|
||||
const filteredCandidates = computed<WidgetItem[]>(() => {
|
||||
const query = debouncedQuery.value.toLowerCase()
|
||||
if (!query) return candidateWidgets.value
|
||||
return candidateWidgets.value.filter(
|
||||
([n, w]: WidgetItem) =>
|
||||
n.title.toLowerCase().includes(query) ||
|
||||
w.name.toLowerCase().includes(query)
|
||||
)
|
||||
})
|
||||
|
||||
const recommendedWidgets = computed(() => {
|
||||
const node = activeNode.value
|
||||
if (!node) return [] //Not reachable
|
||||
return filteredCandidates.value.filter(isRecommendedWidget)
|
||||
})
|
||||
|
||||
const filteredActive = computed<WidgetItem[]>(() => {
|
||||
const query = debouncedQuery.value.toLowerCase()
|
||||
if (!query) return activeWidgets.value
|
||||
return activeWidgets.value.filter(
|
||||
([n, w]: WidgetItem) =>
|
||||
n.title.toLowerCase().includes(query) ||
|
||||
w.name.toLowerCase().includes(query)
|
||||
)
|
||||
})
|
||||
|
||||
function toKey(item: WidgetItem) {
|
||||
return `${item[0].id}: ${item[1].name}`
|
||||
}
|
||||
function nodeWidgets(n: LGraphNode): WidgetItem[] {
|
||||
if (!n.widgets) return []
|
||||
return n.widgets.map((w: IBaseWidget) => [n, w])
|
||||
}
|
||||
function demote([node, widget]: WidgetItem) {
|
||||
const subgraphNode = activeNode.value
|
||||
if (!subgraphNode) return []
|
||||
demoteWidget(node, widget, [subgraphNode])
|
||||
triggerRef(proxyWidgets)
|
||||
}
|
||||
function promote([node, widget]: WidgetItem) {
|
||||
const subgraphNode = activeNode.value
|
||||
if (!subgraphNode) return []
|
||||
promoteWidget(node, widget, [subgraphNode])
|
||||
triggerRef(proxyWidgets)
|
||||
}
|
||||
function showAll() {
|
||||
const node = activeNode.value
|
||||
if (!node) return //Not reachable
|
||||
const widgets = proxyWidgets.value
|
||||
const toAdd: ProxyWidgetsProperty =
|
||||
filteredCandidates.value.map(widgetItemToProperty)
|
||||
widgets.push(...toAdd)
|
||||
proxyWidgets.value = widgets
|
||||
}
|
||||
function hideAll() {
|
||||
const node = activeNode.value
|
||||
if (!node) return //Not reachable
|
||||
//Not great from a nesting perspective, but path is cold
|
||||
//and it cleans up potential error states
|
||||
proxyWidgets.value = proxyWidgets.value.filter(
|
||||
(widgetItem) => !filteredActive.value.some(matchesWidgetItem(widgetItem))
|
||||
)
|
||||
}
|
||||
function showRecommended() {
|
||||
const node = activeNode.value
|
||||
if (!node) return //Not reachable
|
||||
const widgets = proxyWidgets.value
|
||||
const toAdd: ProxyWidgetsProperty =
|
||||
recommendedWidgets.value.map(widgetItemToProperty)
|
||||
//TODO: Add sort step here
|
||||
//Input should always be before output by default
|
||||
widgets.push(...toAdd)
|
||||
proxyWidgets.value = widgets
|
||||
}
|
||||
|
||||
function setDraggableState() {
|
||||
draggableList.value?.dispose()
|
||||
if (debouncedQuery.value || !draggableItems.value?.children?.length) return
|
||||
draggableList.value = new DraggableList(
|
||||
draggableItems.value,
|
||||
'.draggable-item'
|
||||
)
|
||||
//Original implementation plays really poorly with vue,
|
||||
//It has been modified to not add/remove elements
|
||||
draggableList.value.applyNewItemsOrder = function () {
|
||||
const reorderedItems = []
|
||||
|
||||
let oldPosition = -1
|
||||
this.getAllItems().forEach((item, index) => {
|
||||
if (item === this.draggableItem) {
|
||||
oldPosition = index
|
||||
return
|
||||
}
|
||||
if (!this.isItemToggled(item)) {
|
||||
reorderedItems[index] = item
|
||||
return
|
||||
}
|
||||
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1
|
||||
reorderedItems[newIndex] = item
|
||||
})
|
||||
|
||||
for (let index = 0; index < this.getAllItems().length; index++) {
|
||||
const item = reorderedItems[index]
|
||||
if (typeof item === 'undefined') {
|
||||
reorderedItems[index] = this.draggableItem
|
||||
}
|
||||
}
|
||||
const newPosition = reorderedItems.indexOf(this.draggableItem)
|
||||
const aw = activeWidgets.value
|
||||
const [w] = aw.splice(oldPosition, 1)
|
||||
aw.splice(newPosition, 0, w)
|
||||
activeWidgets.value = aw
|
||||
}
|
||||
}
|
||||
watchDebounced(
|
||||
filteredActive,
|
||||
() => {
|
||||
setDraggableState()
|
||||
},
|
||||
{ debounce: 100 }
|
||||
)
|
||||
onMounted(() => {
|
||||
setDraggableState()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
draggableList.value?.dispose()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<SearchBox
|
||||
v-model:model-value="searchQuery"
|
||||
class="p-2"
|
||||
:placeholder="$t('g.search') + '...'"
|
||||
/>
|
||||
<div
|
||||
v-if="filteredActive.length"
|
||||
class="pt-1 pb-4 border-b-1 border-sand-100 dark-theme:border-charcoal-600"
|
||||
>
|
||||
<div class="flex py-0 px-4 justify-between">
|
||||
<div class="text-slate-100 text-[9px] font-semibold uppercase">
|
||||
{{ $t('subgraphStore.shown') }}
|
||||
</div>
|
||||
<a
|
||||
class="cursor-pointer text-right text-blue-100 text-[11px] font-normal"
|
||||
@click.stop="hideAll"
|
||||
>
|
||||
{{ $t('subgraphStore.hideAll') }}</a
|
||||
>
|
||||
</div>
|
||||
<div ref="draggableItems">
|
||||
<div
|
||||
v-for="[node, widget] in filteredActive"
|
||||
:key="toKey([node, widget])"
|
||||
class="w-full draggable-item"
|
||||
style=""
|
||||
>
|
||||
<SubgraphNodeWidget
|
||||
:node-title="node.title"
|
||||
:widget-name="widget.name"
|
||||
:is-shown="true"
|
||||
:is-draggable="!debouncedQuery"
|
||||
@toggle-visibility="demote([node, widget])"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="filteredCandidates.length" class="pt-1 pb-4">
|
||||
<div class="flex py-0 px-4 justify-between">
|
||||
<div class="text-slate-100 text-[9px] font-semibold uppercase">
|
||||
{{ $t('subgraphStore.hidden') }}
|
||||
</div>
|
||||
<a
|
||||
class="cursor-pointer text-right text-blue-100 text-[11px] font-normal"
|
||||
@click.stop="showAll"
|
||||
>
|
||||
{{ $t('subgraphStore.showAll') }}</a
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
v-for="[node, widget] in filteredCandidates"
|
||||
:key="toKey([node, widget])"
|
||||
class="w-full"
|
||||
>
|
||||
<SubgraphNodeWidget
|
||||
:node-title="node.title"
|
||||
:widget-name="widget.name"
|
||||
@toggle-visibility="promote([node, widget])"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="recommendedWidgets.length"
|
||||
class="justify-center flex py-4 border-t-1 border-sand-100 dark-theme:border-charcoal-600"
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
class="rounded border-none px-3 py-0.5"
|
||||
@click.stop="showRecommended"
|
||||
>
|
||||
{{ $t('subgraphStore.showRecommended') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
48
src/core/graph/subgraph/SubgraphNodeWidget.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
nodeTitle: string
|
||||
widgetName: string
|
||||
isShown?: boolean
|
||||
isDraggable?: boolean
|
||||
}>()
|
||||
defineEmits<{
|
||||
(e: 'toggleVisibility'): void
|
||||
}>()
|
||||
|
||||
function classes() {
|
||||
return cn(
|
||||
'flex py-1 pr-4 pl-0 break-all rounded items-center gap-1',
|
||||
'bg-pure-white dark-theme:bg-charcoal-800',
|
||||
props.isDraggable
|
||||
? 'drag-handle cursor-grab [.is-draggable]:cursor-grabbing'
|
||||
: ''
|
||||
)
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div :class="classes()">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'size-4 pointer-events-none',
|
||||
isDraggable ? 'icon-[lucide--grip-vertical]' : ''
|
||||
)
|
||||
"
|
||||
/>
|
||||
<div class="flex-1 pointer-events-none">
|
||||
<div class="text-slate-100 text-[10px]">{{ nodeTitle }}</div>
|
||||
<div class="text-xs">{{ widgetName }}</div>
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
text
|
||||
:icon="isDraggable ? 'icon-[lucide--eye]' : 'icon-[lucide--eye-off]'"
|
||||
severity="secondary"
|
||||
@click.stop="$emit('toggleVisibility')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,13 +1,18 @@
|
||||
import { useNodeImage } from '@/composables/node/useNodeImage'
|
||||
import { demoteWidget } from '@/core/graph/subgraph/proxyWidgetUtils'
|
||||
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
LGraph,
|
||||
LGraphCanvas,
|
||||
LGraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { ISerialisedNode } from '@/lib/litegraph/src/types/serialisation'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets.ts'
|
||||
import { disconnectedWidget } from '@/lib/litegraph/src/widgets/DisconnectedWidget'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { DOMWidgetImpl } from '@/scripts/domWidget'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
|
||||
/**
|
||||
@@ -43,14 +48,33 @@ function isProxyWidget(w: IBaseWidget): w is ProxyWidget {
|
||||
return (w as { _overlay?: Overlay })?._overlay?.isProxyWidget ?? false
|
||||
}
|
||||
|
||||
export function registerProxyWidgets(canvas: LGraphCanvas) {
|
||||
//NOTE: canvasStore hasn't been initialized yet
|
||||
canvas.canvas.addEventListener<'subgraph-opened'>('subgraph-opened', (e) => {
|
||||
const { subgraph, fromNode } = e.detail
|
||||
const proxyWidgets = parseProxyWidgets(fromNode.properties.proxyWidgets)
|
||||
for (const node of subgraph.nodes) {
|
||||
for (const widget of node.widgets ?? []) {
|
||||
widget.promoted = proxyWidgets.some(
|
||||
([n, w]) => node.id == n && widget.name == w
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
SubgraphNode.prototype.onConfigure = onConfigure
|
||||
}
|
||||
|
||||
const originalOnConfigure = SubgraphNode.prototype.onConfigure
|
||||
SubgraphNode.prototype.onConfigure = function (serialisedNode) {
|
||||
const onConfigure = function (
|
||||
this: LGraphNode,
|
||||
serialisedNode: ISerialisedNode
|
||||
) {
|
||||
if (!this.isSubgraphNode())
|
||||
throw new Error("Can't add proxyWidgets to non-subgraphNode")
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
//Must give value to proxyWidgets prior to defining or it won't serialize
|
||||
this.properties.proxyWidgets ??= '[]'
|
||||
this.properties.proxyWidgets ??= []
|
||||
let proxyWidgets = this.properties.proxyWidgets
|
||||
|
||||
originalOnConfigure?.call(this, serialisedNode)
|
||||
@@ -62,13 +86,16 @@ SubgraphNode.prototype.onConfigure = function (serialisedNode) {
|
||||
set: (property: string) => {
|
||||
const parsed = parseProxyWidgets(property)
|
||||
const { deactivateWidget, setWidget } = useDomWidgetStore()
|
||||
for (const w of this.widgets.filter((w) => isProxyWidget(w))) {
|
||||
if (w instanceof DOMWidgetImpl) deactivateWidget(w.id)
|
||||
const isActiveGraph = useCanvasStore().canvas?.graph === this.graph
|
||||
if (isActiveGraph) {
|
||||
for (const w of this.widgets.filter((w) => isProxyWidget(w))) {
|
||||
if (w instanceof DOMWidgetImpl) deactivateWidget(w.id)
|
||||
}
|
||||
}
|
||||
this.widgets = this.widgets.filter((w) => !isProxyWidget(w))
|
||||
for (const [nodeId, widgetName] of parsed) {
|
||||
const w = addProxyWidget(this, `${nodeId}`, widgetName)
|
||||
if (w instanceof DOMWidgetImpl) setWidget(w)
|
||||
if (isActiveGraph && w instanceof DOMWidgetImpl) setWidget(w)
|
||||
}
|
||||
proxyWidgets = property
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
@@ -86,19 +113,23 @@ function addProxyWidget(
|
||||
) {
|
||||
const name = `${nodeId}: ${widgetName}`
|
||||
const overlay = {
|
||||
//items specific for proxy management
|
||||
nodeId,
|
||||
widgetName,
|
||||
graph: subgraphNode.subgraph,
|
||||
name,
|
||||
label: name,
|
||||
isProxyWidget: true,
|
||||
y: 0,
|
||||
last_y: undefined,
|
||||
width: undefined,
|
||||
computedHeight: undefined,
|
||||
widgetName,
|
||||
//Items which normally exist on widgets
|
||||
afterQueued: undefined,
|
||||
computedHeight: undefined,
|
||||
isProxyWidget: true,
|
||||
label: name,
|
||||
last_y: undefined,
|
||||
name,
|
||||
node: subgraphNode,
|
||||
onRemove: undefined,
|
||||
node: subgraphNode
|
||||
promoted: undefined,
|
||||
serialize: false,
|
||||
width: undefined,
|
||||
y: 0
|
||||
}
|
||||
return addProxyFromOverlay(subgraphNode, overlay)
|
||||
}
|
||||
@@ -110,23 +141,20 @@ function resolveLinkedWidget(
|
||||
if (!n) return [undefined, undefined]
|
||||
return [n, n.widgets?.find((w: IBaseWidget) => w.name === widgetName)]
|
||||
}
|
||||
|
||||
function addProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
|
||||
const { updatePreviews } = useLitegraphService()
|
||||
let [linkedNode, linkedWidget] = resolveLinkedWidget(overlay)
|
||||
let backingWidget = linkedWidget ?? disconnectedWidget
|
||||
if (overlay.widgetName == '$$canvas-image-preview')
|
||||
if (overlay.widgetName.startsWith('$$')) {
|
||||
overlay.node = new Proxy(subgraphNode, {
|
||||
get(_t, p) {
|
||||
if (p !== 'imgs') return Reflect.get(subgraphNode, p)
|
||||
if (!linkedNode) return []
|
||||
const images =
|
||||
useNodeOutputStore().getNodeOutputs(linkedNode)?.images ?? []
|
||||
if (images !== linkedNode.images) {
|
||||
linkedNode.images = images
|
||||
useNodeImage(linkedNode).showPreview()
|
||||
}
|
||||
return linkedNode.imgs
|
||||
}
|
||||
})
|
||||
}
|
||||
/**
|
||||
* A set of handlers which define widget interaction
|
||||
* Many arguments are shared between function calls
|
||||
@@ -155,6 +183,12 @@ function addProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
|
||||
let redirectedReceiver = receiver
|
||||
if (property == 'value') redirectedReceiver = backingWidget
|
||||
else if (property == 'computedHeight') {
|
||||
if (overlay.widgetName.startsWith('$$') && linkedNode) {
|
||||
updatePreviews(linkedNode)
|
||||
}
|
||||
if (linkedNode && linkedWidget?.computedDisabled) {
|
||||
demoteWidget(linkedNode, linkedWidget, [subgraphNode])
|
||||
}
|
||||
//update linkage regularly, but no more than once per frame
|
||||
;[linkedNode, linkedWidget] = resolveLinkedWidget(overlay)
|
||||
backingWidget = linkedWidget ?? disconnectedWidget
|
||||
|
||||
132
src/core/graph/subgraph/proxyWidgetUtils.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
type ProxyWidgetsProperty,
|
||||
parseProxyWidgets
|
||||
} from '@/core/schemas/proxyWidget'
|
||||
import type {
|
||||
IContextMenuValue,
|
||||
LGraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets.ts'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
|
||||
export type WidgetItem = [LGraphNode, IBaseWidget]
|
||||
|
||||
function getProxyWidgets(node: SubgraphNode) {
|
||||
return parseProxyWidgets(node.properties.proxyWidgets)
|
||||
}
|
||||
export function promoteWidget(
|
||||
node: LGraphNode,
|
||||
widget: IBaseWidget,
|
||||
parents: SubgraphNode[]
|
||||
) {
|
||||
for (const parent of parents) {
|
||||
const proxyWidgets = [
|
||||
...getProxyWidgets(parent),
|
||||
widgetItemToProperty([node, widget])
|
||||
]
|
||||
parent.properties.proxyWidgets = proxyWidgets
|
||||
}
|
||||
widget.promoted = true
|
||||
}
|
||||
|
||||
export function demoteWidget(
|
||||
node: LGraphNode,
|
||||
widget: IBaseWidget,
|
||||
parents: SubgraphNode[]
|
||||
) {
|
||||
for (const parent of parents) {
|
||||
const proxyWidgets = getProxyWidgets(parent).filter(
|
||||
(widgetItem) => !matchesPropertyItem([node, widget])(widgetItem)
|
||||
)
|
||||
parent.properties.proxyWidgets = proxyWidgets
|
||||
}
|
||||
widget.promoted = false
|
||||
}
|
||||
|
||||
export function matchesWidgetItem([nodeId, widgetName]: [string, string]) {
|
||||
return ([n, w]: WidgetItem) => n.id == nodeId && w.name === widgetName
|
||||
}
|
||||
export function matchesPropertyItem([n, w]: WidgetItem) {
|
||||
return ([nodeId, widgetName]: [string, string]) =>
|
||||
n.id == nodeId && w.name === widgetName
|
||||
}
|
||||
export function widgetItemToProperty([n, w]: WidgetItem): [string, string] {
|
||||
return [`${n.id}`, w.name]
|
||||
}
|
||||
|
||||
function getParentNodes(): SubgraphNode[] {
|
||||
//NOTE: support for determining parents of a subgraph is limited
|
||||
//This function will require rework to properly support linked subgraphs
|
||||
//Either by including actual parents in the navigation stack,
|
||||
//or by adding a new event for parent listeners to collect from
|
||||
const { navigationStack } = useSubgraphNavigationStore()
|
||||
const subgraph = navigationStack.at(-1)
|
||||
if (!subgraph) throw new Error("Can't promote widget when not in subgraph")
|
||||
const parentGraph = navigationStack.at(-2) ?? subgraph.rootGraph
|
||||
return parentGraph.nodes.filter(
|
||||
(node): node is SubgraphNode =>
|
||||
node.type === subgraph.id && node.isSubgraphNode()
|
||||
)
|
||||
}
|
||||
|
||||
export function addWidgetPromotionOptions(
|
||||
options: (IContextMenuValue<unknown> | null)[],
|
||||
widget: IBaseWidget,
|
||||
node: LGraphNode
|
||||
) {
|
||||
const parents = getParentNodes()
|
||||
const promotableParents = parents.filter(
|
||||
(s) => !getProxyWidgets(s).some(matchesPropertyItem([node, widget]))
|
||||
)
|
||||
if (promotableParents.length > 0)
|
||||
options.unshift({
|
||||
content: `Promote Widget: ${widget.label ?? widget.name}`,
|
||||
callback: () => {
|
||||
promoteWidget(node, widget, promotableParents)
|
||||
}
|
||||
})
|
||||
else {
|
||||
options.unshift({
|
||||
content: `Un-Promote Widget: ${widget.label ?? widget.name}`,
|
||||
callback: () => {
|
||||
demoteWidget(node, widget, parents)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
const recommendedNodes = [
|
||||
'CLIPTextEncode',
|
||||
'LoadImage',
|
||||
'SaveImage',
|
||||
'PreviewImage'
|
||||
]
|
||||
const recommendedWidgetNames = ['seed']
|
||||
export function isRecommendedWidget([node, widget]: WidgetItem) {
|
||||
return (
|
||||
!widget.computedDisabled &&
|
||||
(recommendedNodes.includes(node.type) ||
|
||||
recommendedWidgetNames.includes(widget.name))
|
||||
)
|
||||
}
|
||||
|
||||
function nodeWidgets(n: LGraphNode): WidgetItem[] {
|
||||
return n.widgets?.map((w: IBaseWidget) => [n, w]) ?? []
|
||||
}
|
||||
export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
|
||||
const { updatePreviews } = useLitegraphService()
|
||||
const interiorNodes = subgraphNode.subgraph.nodes
|
||||
for (const node of interiorNodes) {
|
||||
node.updateComputedDisabled()
|
||||
//NOTE: Since this operation is async, previews still don't exist after the single frame
|
||||
//Add an onLoad callback to updatePreviews?
|
||||
updatePreviews(node)
|
||||
}
|
||||
const filteredWidgets: WidgetItem[] = interiorNodes
|
||||
.flatMap(nodeWidgets)
|
||||
.filter(isRecommendedWidget)
|
||||
const proxyWidgets: ProxyWidgetsProperty =
|
||||
filteredWidgets.map(widgetItemToProperty)
|
||||
subgraphNode.properties.proxyWidgets = proxyWidgets
|
||||
}
|
||||
26
src/core/graph/subgraph/useSubgraphNodeDialog.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import SubgraphNode from '@/core/graph/subgraph/SubgraphNode.vue'
|
||||
import { type DialogComponentProps, useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const key = 'global-subgraph-node-config'
|
||||
|
||||
export function showSubgraphNodeDialog() {
|
||||
const dialogStore = useDialogStore()
|
||||
const dialogComponentProps: DialogComponentProps = {
|
||||
modal: false,
|
||||
position: 'topright',
|
||||
pt: {
|
||||
root: {
|
||||
class: 'bg-pure-white dark-theme:bg-charcoal-800 mt-22'
|
||||
},
|
||||
header: {
|
||||
class: 'h-8 text-xs ml-3'
|
||||
}
|
||||
}
|
||||
}
|
||||
dialogStore.showDialog({
|
||||
title: 'Parameters',
|
||||
key,
|
||||
component: SubgraphNode,
|
||||
dialogComponentProps
|
||||
})
|
||||
}
|
||||
@@ -4,18 +4,12 @@ import { fromZodError } from 'zod-validation-error'
|
||||
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
const proxyWidgetsPropertySchema = z.array(z.tuple([z.string(), z.string()]))
|
||||
type ProxyWidgetsProperty = z.infer<typeof proxyWidgetsPropertySchema>
|
||||
export type ProxyWidgetsProperty = z.infer<typeof proxyWidgetsPropertySchema>
|
||||
|
||||
export function parseProxyWidgets(
|
||||
property: NodeProperty | undefined
|
||||
): ProxyWidgetsProperty {
|
||||
if (typeof property !== 'string') {
|
||||
throw new Error(
|
||||
'Invalid assignment for properties.proxyWidgets:\nValue must be a string'
|
||||
)
|
||||
}
|
||||
const parsed = JSON.parse(property)
|
||||
const result = proxyWidgetsPropertySchema.safeParse(parsed)
|
||||
const result = proxyWidgetsPropertySchema.safeParse(property)
|
||||
if (result.success) return result.data
|
||||
|
||||
const error = fromZodError(result.error)
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { toString } from 'es-toolkit/compat'
|
||||
|
||||
import { PREFIX, SEPARATOR } from '@/constants/groupNodeConstants'
|
||||
import { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
|
||||
import {
|
||||
type LinkRenderContext,
|
||||
LitegraphLinkAdapter
|
||||
} from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
|
||||
import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculations'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
|
||||
import { CanvasPointer } from './CanvasPointer'
|
||||
import type { ContextMenu } from './ContextMenu'
|
||||
@@ -17,6 +18,7 @@ import { LGraphGroup } from './LGraphGroup'
|
||||
import { LGraphNode, type NodeId, type NodeProperty } from './LGraphNode'
|
||||
import { LLink, type LinkId } from './LLink'
|
||||
import { Reroute, type RerouteId } from './Reroute'
|
||||
import { LinkConnector } from './canvas/LinkConnector'
|
||||
import { isOverNodeInput, isOverNodeOutput } from './canvas/measureSlots'
|
||||
import { strokeShape } from './draw'
|
||||
import type {
|
||||
@@ -25,6 +27,7 @@ import type {
|
||||
} from './infrastructure/CustomEventTarget'
|
||||
import type { LGraphCanvasEventMap } from './infrastructure/LGraphCanvasEventMap'
|
||||
import { NullGraphError } from './infrastructure/NullGraphError'
|
||||
import { Rectangle } from './infrastructure/Rectangle'
|
||||
import type {
|
||||
CanvasColour,
|
||||
ColorOption,
|
||||
@@ -47,12 +50,11 @@ import type {
|
||||
NullableProperties,
|
||||
Point,
|
||||
Positionable,
|
||||
ReadOnlyPoint,
|
||||
ReadOnlyRect,
|
||||
Rect,
|
||||
Size
|
||||
} from './interfaces'
|
||||
import { LiteGraph, Rectangle, SubgraphNode, createUuidv4 } from './litegraph'
|
||||
import { LiteGraph } from './litegraph'
|
||||
import {
|
||||
containsRect,
|
||||
createBounds,
|
||||
@@ -67,6 +69,7 @@ import { NodeInputSlot } from './node/NodeInputSlot'
|
||||
import type { Subgraph } from './subgraph/Subgraph'
|
||||
import { SubgraphIONodeBase } from './subgraph/SubgraphIONodeBase'
|
||||
import type { SubgraphInputNode } from './subgraph/SubgraphInputNode'
|
||||
import { SubgraphNode } from './subgraph/SubgraphNode'
|
||||
import type { SubgraphOutputNode } from './subgraph/SubgraphOutputNode'
|
||||
import type {
|
||||
CanvasPointerEvent,
|
||||
@@ -88,6 +91,7 @@ import type { IBaseWidget } from './types/widgets'
|
||||
import { alignNodes, distributeNodes, getBoundaryNodes } from './utils/arrange'
|
||||
import { findFirstNode, getAllNestedItems } from './utils/collections'
|
||||
import { resolveConnectingLinkColor } from './utils/linkColors'
|
||||
import { createUuidv4 } from './utils/uuid'
|
||||
import type { UUID } from './utils/uuid'
|
||||
import { BaseWidget } from './widgets/BaseWidget'
|
||||
import { toConcreteWidget } from './widgets/widgetMap'
|
||||
@@ -228,6 +232,12 @@ const cursors = {
|
||||
NW: 'nwse-resize'
|
||||
} as const
|
||||
|
||||
// Optimised buffers used during rendering
|
||||
const temp = new Rectangle()
|
||||
const temp_vec2: Point = [0, 0]
|
||||
const tmp_area = new Rectangle()
|
||||
const margin_area = new Rectangle()
|
||||
const link_bounding = new Rectangle()
|
||||
/**
|
||||
* This class is in charge of rendering one graph inside a canvas. And provides all the interaction required.
|
||||
* Valid callbacks are: onNodeSelected, onNodeDeselected, onShowNodePanel, onNodeDblClicked
|
||||
@@ -235,13 +245,6 @@ const cursors = {
|
||||
export class LGraphCanvas
|
||||
implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
{
|
||||
// Optimised buffers used during rendering
|
||||
static #temp = new Float32Array(4)
|
||||
static #temp_vec2 = new Float32Array(2)
|
||||
static #tmp_area = new Float32Array(4)
|
||||
static #margin_area = new Float32Array(4)
|
||||
static #link_bounding = new Float32Array(4)
|
||||
|
||||
static DEFAULT_BACKGROUND_IMAGE =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAQBJREFUeNrs1rEKwjAUhlETUkj3vP9rdmr1Ysammk2w5wdxuLgcMHyptfawuZX4pJSWZTnfnu/lnIe/jNNxHHGNn//HNbbv+4dr6V+11uF527arU7+u63qfa/bnmh8sWLBgwYJlqRf8MEptXPBXJXa37BSl3ixYsGDBMliwFLyCV/DeLIMFCxYsWLBMwSt4Be/NggXLYMGCBUvBK3iNruC9WbBgwYJlsGApeAWv4L1ZBgsWLFiwYJmCV/AK3psFC5bBggULloJX8BpdwXuzYMGCBctgwVLwCl7Be7MMFixYsGDBsu8FH1FaSmExVfAxBa/gvVmwYMGCZbBg/W4vAQYA5tRF9QYlv/QAAAAASUVORK5CYII='
|
||||
|
||||
@@ -628,7 +631,7 @@ export class LGraphCanvas
|
||||
dirty_area?: Rect | null
|
||||
/** @deprecated Unused */
|
||||
node_in_panel?: LGraphNode | null
|
||||
last_mouse: ReadOnlyPoint = [0, 0]
|
||||
last_mouse: Readonly<Point> = [0, 0]
|
||||
last_mouseclick: number = 0
|
||||
graph: LGraph | Subgraph | null
|
||||
get _graph(): LGraph | Subgraph {
|
||||
@@ -1862,13 +1865,13 @@ export class LGraphCanvas
|
||||
this.#dirty()
|
||||
}
|
||||
|
||||
openSubgraph(subgraph: Subgraph): void {
|
||||
openSubgraph(subgraph: Subgraph, fromNode: SubgraphNode): void {
|
||||
const { graph } = this
|
||||
if (!graph) throw new NullGraphError()
|
||||
|
||||
const options = {
|
||||
bubbles: true,
|
||||
detail: { subgraph, closingGraph: graph },
|
||||
detail: { subgraph, closingGraph: graph, fromNode },
|
||||
cancelable: true
|
||||
}
|
||||
const mayContinue = this.canvas.dispatchEvent(
|
||||
@@ -2634,7 +2637,7 @@ export class LGraphCanvas
|
||||
pointer: CanvasPointer,
|
||||
node?: LGraphNode | undefined
|
||||
): void {
|
||||
const dragRect = new Float32Array(4)
|
||||
const dragRect: Rect = [0, 0, 0, 0]
|
||||
|
||||
dragRect[0] = e.canvasX
|
||||
dragRect[1] = e.canvasY
|
||||
@@ -2794,7 +2797,7 @@ export class LGraphCanvas
|
||||
if (pos[1] < 0 && !inCollapse) {
|
||||
node.onNodeTitleDblClick?.(e, pos, this)
|
||||
} else if (node instanceof SubgraphNode) {
|
||||
this.openSubgraph(node.subgraph)
|
||||
this.openSubgraph(node.subgraph, node)
|
||||
}
|
||||
|
||||
node.onDblClick?.(e, pos, this)
|
||||
@@ -3174,7 +3177,7 @@ export class LGraphCanvas
|
||||
|
||||
LGraphCanvas.active_canvas = this
|
||||
this.adjustMouseEvent(e)
|
||||
const mouse: ReadOnlyPoint = [e.clientX, e.clientY]
|
||||
const mouse: Readonly<Point> = [e.clientX, e.clientY]
|
||||
this.mouse[0] = mouse[0]
|
||||
this.mouse[1] = mouse[1]
|
||||
const delta = [mouse[0] - this.last_mouse[0], mouse[1] - this.last_mouse[1]]
|
||||
@@ -3430,8 +3433,13 @@ export class LGraphCanvas
|
||||
|
||||
const deltaX = delta[0] / this.ds.scale
|
||||
const deltaY = delta[1] / this.ds.scale
|
||||
for (const item of allItems) {
|
||||
item.move(deltaX, deltaY, true)
|
||||
|
||||
if (LiteGraph.vueNodesMode) {
|
||||
this.moveChildNodesInGroupVueMode(allItems, deltaX, deltaY)
|
||||
} else {
|
||||
for (const item of allItems) {
|
||||
item.move(deltaX, deltaY, true)
|
||||
}
|
||||
}
|
||||
|
||||
this.#dirty()
|
||||
@@ -4077,7 +4085,7 @@ export class LGraphCanvas
|
||||
this.setDirty(true)
|
||||
}
|
||||
|
||||
#handleMultiSelect(e: CanvasPointerEvent, dragRect: Float32Array) {
|
||||
#handleMultiSelect(e: CanvasPointerEvent, dragRect: Rect) {
|
||||
// Process drag
|
||||
// Convert Point pair (pos, offset) to Rect
|
||||
const { graph, selectedItems, subgraph } = this
|
||||
@@ -4848,7 +4856,7 @@ export class LGraphCanvas
|
||||
}
|
||||
|
||||
/** Get the target snap / highlight point in graph space */
|
||||
#getHighlightPosition(): ReadOnlyPoint {
|
||||
#getHighlightPosition(): Readonly<Point> {
|
||||
return LiteGraph.snaps_for_comfy
|
||||
? this.linkConnector.state.snapLinksPos ??
|
||||
this._highlight_pos ??
|
||||
@@ -4863,7 +4871,7 @@ export class LGraphCanvas
|
||||
*/
|
||||
#renderSnapHighlight(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
highlightPos: ReadOnlyPoint
|
||||
highlightPos: Readonly<Point>
|
||||
): void {
|
||||
const linkConnectorSnap = !!this.linkConnector.state.snapLinksPos
|
||||
if (!this._highlight_pos && !linkConnectorSnap) return
|
||||
@@ -5204,8 +5212,9 @@ export class LGraphCanvas
|
||||
|
||||
// clip if required (mask)
|
||||
const shape = node._shape || RenderShape.BOX
|
||||
const size = LGraphCanvas.#temp_vec2
|
||||
size.set(node.renderingSize)
|
||||
const size = temp_vec2
|
||||
size[0] = node.renderingSize[0]
|
||||
size[1] = node.renderingSize[1]
|
||||
|
||||
if (node.collapsed) {
|
||||
ctx.font = this.inner_text_font
|
||||
@@ -5399,7 +5408,7 @@ export class LGraphCanvas
|
||||
: true
|
||||
|
||||
// Normalised node dimensions
|
||||
const area = LGraphCanvas.#tmp_area
|
||||
const area = tmp_area
|
||||
area.set(node.boundingRect)
|
||||
area[0] -= node.pos[0]
|
||||
area[1] -= node.pos[1]
|
||||
@@ -5501,7 +5510,7 @@ export class LGraphCanvas
|
||||
item: Positionable,
|
||||
shape = RenderShape.ROUND
|
||||
) {
|
||||
const snapGuide = LGraphCanvas.#temp
|
||||
const snapGuide = temp
|
||||
snapGuide.set(item.boundingRect)
|
||||
|
||||
// Not all items have pos equal to top-left of bounds
|
||||
@@ -5548,10 +5557,10 @@ export class LGraphCanvas
|
||||
|
||||
const now = LiteGraph.getTime()
|
||||
const { visible_area } = this
|
||||
LGraphCanvas.#margin_area[0] = visible_area[0] - 20
|
||||
LGraphCanvas.#margin_area[1] = visible_area[1] - 20
|
||||
LGraphCanvas.#margin_area[2] = visible_area[2] + 40
|
||||
LGraphCanvas.#margin_area[3] = visible_area[3] + 40
|
||||
margin_area[0] = visible_area[0] - 20
|
||||
margin_area[1] = visible_area[1] - 20
|
||||
margin_area[2] = visible_area[2] + 40
|
||||
margin_area[3] = visible_area[3] + 40
|
||||
|
||||
// draw connections
|
||||
ctx.lineWidth = this.connections_width
|
||||
@@ -5772,18 +5781,13 @@ export class LGraphCanvas
|
||||
// Bounding box of all points (bezier overshoot on long links will be cut)
|
||||
const pointsX = points.map((x) => x[0])
|
||||
const pointsY = points.map((x) => x[1])
|
||||
LGraphCanvas.#link_bounding[0] = Math.min(...pointsX)
|
||||
LGraphCanvas.#link_bounding[1] = Math.min(...pointsY)
|
||||
LGraphCanvas.#link_bounding[2] =
|
||||
Math.max(...pointsX) - LGraphCanvas.#link_bounding[0]
|
||||
LGraphCanvas.#link_bounding[3] =
|
||||
Math.max(...pointsY) - LGraphCanvas.#link_bounding[1]
|
||||
link_bounding[0] = Math.min(...pointsX)
|
||||
link_bounding[1] = Math.min(...pointsY)
|
||||
link_bounding[2] = Math.max(...pointsX) - link_bounding[0]
|
||||
link_bounding[3] = Math.max(...pointsY) - link_bounding[1]
|
||||
|
||||
// skip links outside of the visible area of the canvas
|
||||
if (
|
||||
!overlapBounding(LGraphCanvas.#link_bounding, LGraphCanvas.#margin_area)
|
||||
)
|
||||
return
|
||||
if (!overlapBounding(link_bounding, margin_area)) return
|
||||
|
||||
const start_dir = startDirection || LinkDirection.RIGHT
|
||||
const end_dir = endDirection || LinkDirection.LEFT
|
||||
@@ -5942,8 +5946,8 @@ export class LGraphCanvas
|
||||
*/
|
||||
renderLink(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
a: ReadOnlyPoint,
|
||||
b: ReadOnlyPoint,
|
||||
a: Readonly<Point>,
|
||||
b: Readonly<Point>,
|
||||
link: LLink | null,
|
||||
skip_border: boolean,
|
||||
flow: number | null,
|
||||
@@ -5960,9 +5964,9 @@ export class LGraphCanvas
|
||||
/** When defined, render data will be saved to this reroute instead of the {@link link}. */
|
||||
reroute?: Reroute
|
||||
/** Offset of the bezier curve control point from {@link a point a} (output side) */
|
||||
startControl?: ReadOnlyPoint
|
||||
startControl?: Readonly<Point>
|
||||
/** Offset of the bezier curve control point from {@link b point b} (input side) */
|
||||
endControl?: ReadOnlyPoint
|
||||
endControl?: Readonly<Point>
|
||||
/** Number of sublines (useful to represent vec3 or rgb) @todo If implemented, refactor calculations out of the loop */
|
||||
num_sublines?: number
|
||||
/** Whether this is a floating link segment */
|
||||
@@ -8007,7 +8011,7 @@ export class LGraphCanvas
|
||||
if (Object.keys(this.selected_nodes).length > 1) {
|
||||
options.push(
|
||||
{
|
||||
content: 'Convert to Subgraph 🆕',
|
||||
content: 'Convert to Subgraph',
|
||||
callback: () => {
|
||||
if (!this.selectedItems.size)
|
||||
throw new Error('Convert to Subgraph: Nothing selected.')
|
||||
@@ -8042,7 +8046,7 @@ export class LGraphCanvas
|
||||
} else {
|
||||
options = [
|
||||
{
|
||||
content: 'Convert to Subgraph 🆕',
|
||||
content: 'Convert to Subgraph',
|
||||
callback: () => {
|
||||
// find groupnodes, degroup and select children
|
||||
if (this.selectedItems.size) {
|
||||
@@ -8455,4 +8459,120 @@ export class LGraphCanvas
|
||||
const setDirty = () => this.setDirty(true, true)
|
||||
this.ds.animateToBounds(bounds, setDirty, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate new position with delta
|
||||
*/
|
||||
private calculateNewPosition(
|
||||
node: LGraphNode,
|
||||
deltaX: number,
|
||||
deltaY: number
|
||||
): { x: number; y: number } {
|
||||
return {
|
||||
x: node.pos[0] + deltaX,
|
||||
y: node.pos[1] + deltaY
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply batched node position updates
|
||||
*/
|
||||
private applyNodePositionUpdates(
|
||||
nodesToMove: Array<{ node: LGraphNode; newPos: { x: number; y: number } }>,
|
||||
mutations: ReturnType<typeof useLayoutMutations>
|
||||
): void {
|
||||
for (const { node, newPos } of nodesToMove) {
|
||||
// Update LiteGraph position first so next drag uses correct base position
|
||||
node.pos[0] = newPos.x
|
||||
node.pos[1] = newPos.y
|
||||
// Then update layout store which will update Vue nodes
|
||||
mutations.moveNode(node.id, newPos)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize layout mutations with Canvas source
|
||||
*/
|
||||
private initLayoutMutations(): ReturnType<typeof useLayoutMutations> {
|
||||
const mutations = useLayoutMutations()
|
||||
mutations.setSource(LayoutSource.Canvas)
|
||||
return mutations
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all nodes that are children of groups in the selection
|
||||
*/
|
||||
private collectNodesInGroups(items: Set<Positionable>): Set<LGraphNode> {
|
||||
const nodesInGroups = new Set<LGraphNode>()
|
||||
for (const item of items) {
|
||||
if (item instanceof LGraphGroup) {
|
||||
for (const child of item._children) {
|
||||
if (child instanceof LGraphNode) {
|
||||
nodesInGroups.add(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nodesInGroups
|
||||
}
|
||||
|
||||
/**
|
||||
* Move group children (both nodes and non-nodes)
|
||||
*/
|
||||
private moveGroupChildren(
|
||||
group: LGraphGroup,
|
||||
deltaX: number,
|
||||
deltaY: number,
|
||||
nodesToMove: Array<{ node: LGraphNode; newPos: { x: number; y: number } }>
|
||||
): void {
|
||||
for (const child of group._children) {
|
||||
if (child instanceof LGraphNode) {
|
||||
const node = child as LGraphNode
|
||||
nodesToMove.push({
|
||||
node,
|
||||
newPos: this.calculateNewPosition(node, deltaX, deltaY)
|
||||
})
|
||||
} else {
|
||||
// Non-node children (nested groups, reroutes)
|
||||
child.move(deltaX, deltaY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
moveChildNodesInGroupVueMode(
|
||||
allItems: Set<Positionable>,
|
||||
deltaX: number,
|
||||
deltaY: number
|
||||
) {
|
||||
const mutations = this.initLayoutMutations()
|
||||
const nodesInMovingGroups = this.collectNodesInGroups(allItems)
|
||||
const nodesToMove: Array<{
|
||||
node: LGraphNode
|
||||
newPos: { x: number; y: number }
|
||||
}> = []
|
||||
|
||||
// First, collect all the moves we need to make
|
||||
for (const item of allItems) {
|
||||
const isNode = item instanceof LGraphNode
|
||||
if (isNode) {
|
||||
const node = item as LGraphNode
|
||||
if (nodesInMovingGroups.has(node)) {
|
||||
continue
|
||||
}
|
||||
nodesToMove.push({
|
||||
node,
|
||||
newPos: this.calculateNewPosition(node, deltaX, deltaY)
|
||||
})
|
||||
} else if (item instanceof LGraphGroup) {
|
||||
item.move(deltaX, deltaY, true)
|
||||
this.moveGroupChildren(item, deltaX, deltaY, nodesToMove)
|
||||
} else {
|
||||
// Other items (reroutes, etc.)
|
||||
item.move(deltaX, deltaY, true)
|
||||
}
|
||||
}
|
||||
|
||||
// Now apply all the node moves at once
|
||||
this.applyNodePositionUpdates(nodesToMove, mutations)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
Positionable,
|
||||
Size
|
||||
} from './interfaces'
|
||||
import { LiteGraph } from './litegraph'
|
||||
import { LiteGraph, Rectangle } from './litegraph'
|
||||
import {
|
||||
containsCentre,
|
||||
containsRect,
|
||||
@@ -40,15 +40,10 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
|
||||
title: string
|
||||
font?: string
|
||||
font_size: number = LiteGraph.DEFAULT_GROUP_FONT || 24
|
||||
_bounding: Float32Array = new Float32Array([
|
||||
10,
|
||||
10,
|
||||
LGraphGroup.minWidth,
|
||||
LGraphGroup.minHeight
|
||||
])
|
||||
_bounding = new Rectangle(10, 10, LGraphGroup.minWidth, LGraphGroup.minHeight)
|
||||
|
||||
_pos: Point = this._bounding.subarray(0, 2)
|
||||
_size: Size = this._bounding.subarray(2, 4)
|
||||
_pos: Point = this._bounding.pos
|
||||
_size: Size = this._bounding.size
|
||||
/** @deprecated See {@link _children} */
|
||||
_nodes: LGraphNode[] = []
|
||||
_children: Set<Positionable> = new Set()
|
||||
@@ -111,6 +106,10 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
|
||||
return this._bounding
|
||||
}
|
||||
|
||||
getBounding() {
|
||||
return this._bounding
|
||||
}
|
||||
|
||||
get nodes() {
|
||||
return this._nodes
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ import type {
|
||||
ISlotType,
|
||||
Point,
|
||||
Positionable,
|
||||
ReadOnlyPoint,
|
||||
ReadOnlyRect,
|
||||
Rect,
|
||||
Size
|
||||
@@ -413,7 +412,7 @@ export class LGraphNode
|
||||
}
|
||||
|
||||
/** @inheritdoc {@link renderArea} */
|
||||
#renderArea: Float32Array = new Float32Array(4)
|
||||
#renderArea = new Rectangle()
|
||||
/**
|
||||
* Rect describing the node area, including shadows and any protrusions.
|
||||
* Determines if the node is visible. Calculated once at the start of every frame.
|
||||
@@ -434,7 +433,7 @@ export class LGraphNode
|
||||
}
|
||||
|
||||
/** The offset from {@link pos} to the top-left of {@link boundingRect}. */
|
||||
get boundingOffset(): ReadOnlyPoint {
|
||||
get boundingOffset(): Readonly<Point> {
|
||||
const {
|
||||
pos: [posX, posY],
|
||||
boundingRect: [bX, bY]
|
||||
@@ -442,10 +441,10 @@ export class LGraphNode
|
||||
return [posX - bX, posY - bY]
|
||||
}
|
||||
|
||||
/** {@link pos} and {@link size} values are backed by this {@link Rect}. */
|
||||
_posSize: Float32Array = new Float32Array(4)
|
||||
_pos: Point = this._posSize.subarray(0, 2)
|
||||
_size: Size = this._posSize.subarray(2, 4)
|
||||
/** {@link pos} and {@link size} values are backed by this {@link Rectangle}. */
|
||||
_posSize = new Rectangle()
|
||||
_pos: Point = this._posSize.pos
|
||||
_size: Size = this._posSize.size
|
||||
|
||||
public get pos() {
|
||||
return this._pos
|
||||
@@ -1653,7 +1652,7 @@ export class LGraphNode
|
||||
inputs ? inputs.filter((input) => !isWidgetInputSlot(input)).length : 1,
|
||||
outputs ? outputs.length : 1
|
||||
)
|
||||
const size = out || new Float32Array([0, 0])
|
||||
const size = out ?? [0, 0]
|
||||
rows = Math.max(rows, 1)
|
||||
// although it should be graphcanvas.inner_text_font size
|
||||
const font_size = LiteGraph.NODE_TEXT_SIZE
|
||||
@@ -2004,13 +2003,13 @@ export class LGraphNode
|
||||
|
||||
/**
|
||||
* returns the bounding of the object, used for rendering purposes
|
||||
* @param out {Float32Array[4]?} [optional] a place to store the output, to free garbage
|
||||
* @param out {Rect?} [optional] a place to store the output, to free garbage
|
||||
* @param includeExternal {boolean?} [optional] set to true to
|
||||
* include the shadow and connection points in the bounding calculation
|
||||
* @returns the bounding box in format of [topleft_cornerx, topleft_cornery, width, height]
|
||||
*/
|
||||
getBounding(out?: Rect, includeExternal?: boolean): Rect {
|
||||
out ||= new Float32Array(4)
|
||||
out ||= [0, 0, 0, 0]
|
||||
|
||||
const rect = includeExternal ? this.renderArea : this.boundingRect
|
||||
out[0] = rect[0]
|
||||
@@ -3169,7 +3168,7 @@ export class LGraphNode
|
||||
* @returns the position
|
||||
*/
|
||||
getConnectionPos(is_input: boolean, slot_number: number, out?: Point): Point {
|
||||
out ||= new Float32Array(2)
|
||||
out ||= [0, 0]
|
||||
|
||||
const {
|
||||
pos: [nodeX, nodeY],
|
||||
@@ -3749,6 +3748,13 @@ export class LGraphNode
|
||||
return !isHidden
|
||||
}
|
||||
|
||||
updateComputedDisabled() {
|
||||
if (!this.widgets) return
|
||||
for (const widget of this.widgets)
|
||||
widget.computedDisabled =
|
||||
widget.disabled || this.getSlotFromWidget(widget)?.link != null
|
||||
}
|
||||
|
||||
drawWidgets(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
{ lowQuality = false, editorAlpha = 1 }: DrawWidgetsOptions
|
||||
@@ -3762,6 +3768,7 @@ export class LGraphNode
|
||||
ctx.save()
|
||||
ctx.globalAlpha = editorAlpha
|
||||
|
||||
this.updateComputedDisabled()
|
||||
for (const widget of widgets) {
|
||||
if (!this.isWidgetVisible(widget)) continue
|
||||
|
||||
@@ -3771,9 +3778,6 @@ export class LGraphNode
|
||||
: LiteGraph.WIDGET_OUTLINE_COLOR
|
||||
|
||||
widget.last_y = y
|
||||
// Disable widget if it is disabled or if the value is passed from socket connection.
|
||||
widget.computedDisabled =
|
||||
widget.disabled || this.getSlotFromWidget(widget)?.link != null
|
||||
|
||||
ctx.strokeStyle = outlineColour
|
||||
ctx.fillStyle = '#222'
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
ISlotType,
|
||||
LinkNetwork,
|
||||
LinkSegment,
|
||||
Point,
|
||||
ReadonlyLinkNetwork
|
||||
} from './interfaces'
|
||||
import type {
|
||||
@@ -109,7 +110,7 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
|
||||
data?: number | string | boolean | { toToolTip?(): string }
|
||||
_data?: unknown
|
||||
/** Centre point of the link, calculated during render only - can be inaccurate */
|
||||
_pos: Float32Array
|
||||
_pos: Point
|
||||
/** @todo Clean up - never implemented in comfy. */
|
||||
_last_time?: number
|
||||
/** The last canvas 2D path that was used to render this link */
|
||||
@@ -171,7 +172,7 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
|
||||
|
||||
this._data = null
|
||||
// center
|
||||
this._pos = new Float32Array(2)
|
||||
this._pos = [0, 0]
|
||||
}
|
||||
|
||||
/** @deprecated Use {@link LLink.create} */
|
||||
|
||||
@@ -70,6 +70,7 @@ export class LiteGraphGlobal {
|
||||
|
||||
WIDGET_BGCOLOR = '#222'
|
||||
WIDGET_OUTLINE_COLOR = '#666'
|
||||
WIDGET_PROMOTED_OUTLINE_COLOR = '#BF00FF'
|
||||
WIDGET_ADVANCED_OUTLINE_COLOR = 'rgba(56, 139, 253, 0.8)'
|
||||
WIDGET_TEXT_COLOR = '#DDD'
|
||||
WIDGET_SECONDARY_TEXT_COLOR = '#999'
|
||||
|
||||
@@ -49,8 +49,6 @@ export class Reroute
|
||||
return Reroute.radius + gap + Reroute.slotRadius
|
||||
}
|
||||
|
||||
#malloc = new Float32Array(8)
|
||||
|
||||
/** The network this reroute belongs to. Contains all valid links and reroutes. */
|
||||
#network: WeakRef<LinkNetwork>
|
||||
|
||||
@@ -73,7 +71,7 @@ export class Reroute
|
||||
/** This property is only defined on the last reroute of a floating reroute chain (closest to input end). */
|
||||
floating?: FloatingRerouteSlot
|
||||
|
||||
#pos = this.#malloc.subarray(0, 2)
|
||||
#pos: Point = [0, 0]
|
||||
/** @inheritdoc */
|
||||
get pos(): Point {
|
||||
return this.#pos
|
||||
@@ -126,14 +124,14 @@ export class Reroute
|
||||
sin: number = 0
|
||||
|
||||
/** Bezier curve control point for the "target" (input) side of the link */
|
||||
controlPoint: Point = this.#malloc.subarray(4, 6)
|
||||
controlPoint: Point = [0, 0]
|
||||
|
||||
/** @inheritdoc */
|
||||
path?: Path2D
|
||||
/** @inheritdoc */
|
||||
_centreAngle?: number
|
||||
/** @inheritdoc */
|
||||
_pos: Float32Array = this.#malloc.subarray(6, 8)
|
||||
_pos: Point = [0, 0]
|
||||
|
||||
/** @inheritdoc */
|
||||
_dragging?: boolean
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Rectangle } from './infrastructure/Rectangle'
|
||||
import type { CanvasColour, Rect } from './interfaces'
|
||||
import type { CanvasColour } from './interfaces'
|
||||
import { LiteGraph } from './litegraph'
|
||||
import { RenderShape, TitleMode } from './types/globalEnums'
|
||||
|
||||
@@ -67,7 +67,7 @@ interface IDrawTextInAreaOptions {
|
||||
*/
|
||||
export function strokeShape(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
area: Rect,
|
||||
area: Rectangle,
|
||||
{
|
||||
shape = RenderShape.BOX,
|
||||
round_radius,
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { clamp } from 'es-toolkit/compat'
|
||||
|
||||
import type {
|
||||
ReadOnlyRect,
|
||||
ReadOnlySize,
|
||||
Size
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import type { ReadOnlyRect, Size } from '@/lib/litegraph/src/interfaces'
|
||||
|
||||
/**
|
||||
* Basic width and height, with min/max constraints.
|
||||
@@ -55,7 +51,7 @@ export class ConstrainedSize {
|
||||
this.desiredHeight = height
|
||||
}
|
||||
|
||||
static fromSize(size: ReadOnlySize): ConstrainedSize {
|
||||
static fromSize(size: Readonly<Size>): ConstrainedSize {
|
||||
return new ConstrainedSize(size[0], size[1])
|
||||
}
|
||||
|
||||
@@ -63,7 +59,7 @@ export class ConstrainedSize {
|
||||
return new ConstrainedSize(rect[2], rect[3])
|
||||
}
|
||||
|
||||
setSize(size: ReadOnlySize): void {
|
||||
setSize(size: Readonly<Size>): void {
|
||||
this.desiredWidth = size[0]
|
||||
this.desiredHeight = size[1]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { ConnectingLink } from '@/lib/litegraph/src/interfaces'
|
||||
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
|
||||
export interface LGraphCanvasEventMap {
|
||||
@@ -14,6 +15,11 @@ export interface LGraphCanvasEventMap {
|
||||
/** The old active graph, or `null` if there was no active graph. */
|
||||
oldGraph: LGraph | Subgraph | null | undefined
|
||||
}
|
||||
'subgraph-opened': {
|
||||
subgraph: Subgraph
|
||||
closingGraph: LGraph
|
||||
fromNode: SubgraphNode
|
||||
}
|
||||
|
||||
'litegraph:canvas':
|
||||
| { subType: 'before-change' | 'after-change' }
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type {
|
||||
CompassCorners,
|
||||
Point,
|
||||
ReadOnlyPoint,
|
||||
ReadOnlyRect,
|
||||
ReadOnlySize,
|
||||
ReadOnlyTypedArray,
|
||||
Size
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
@@ -21,8 +19,8 @@ import { isInRectangle } from '@/lib/litegraph/src/measure'
|
||||
* - {@link size}: The size of the rectangle.
|
||||
*/
|
||||
export class Rectangle extends Float64Array {
|
||||
#pos: Point | undefined
|
||||
#size: Size | undefined
|
||||
#pos: Float64Array<ArrayBuffer> | undefined
|
||||
#size: Float64Array<ArrayBuffer> | undefined
|
||||
|
||||
constructor(
|
||||
x: number = 0,
|
||||
@@ -50,7 +48,7 @@ export class Rectangle extends Float64Array {
|
||||
* @returns A new rectangle whose centre is at {@link x}
|
||||
*/
|
||||
static fromCentre(
|
||||
[x, y]: ReadOnlyPoint,
|
||||
[x, y]: Readonly<Point>,
|
||||
width: number,
|
||||
height = width
|
||||
): Rectangle {
|
||||
@@ -81,10 +79,10 @@ export class Rectangle extends Float64Array {
|
||||
*/
|
||||
get pos(): Point {
|
||||
this.#pos ??= this.subarray(0, 2)
|
||||
return this.#pos!
|
||||
return this.#pos! as unknown as Point
|
||||
}
|
||||
|
||||
set pos(value: ReadOnlyPoint) {
|
||||
set pos(value: Readonly<Point>) {
|
||||
this[0] = value[0]
|
||||
this[1] = value[1]
|
||||
}
|
||||
@@ -96,10 +94,10 @@ export class Rectangle extends Float64Array {
|
||||
*/
|
||||
get size(): Size {
|
||||
this.#size ??= this.subarray(2, 4)
|
||||
return this.#size!
|
||||
return this.#size! as unknown as Size
|
||||
}
|
||||
|
||||
set size(value: ReadOnlySize) {
|
||||
set size(value: Readonly<Size>) {
|
||||
this[2] = value[0]
|
||||
this[3] = value[1]
|
||||
}
|
||||
@@ -215,7 +213,7 @@ export class Rectangle extends Float64Array {
|
||||
* @param point The point to check
|
||||
* @returns `true` if {@link point} is inside this rectangle, otherwise `false`.
|
||||
*/
|
||||
containsPoint([x, y]: ReadOnlyPoint): boolean {
|
||||
containsPoint([x, y]: Readonly<Point>): boolean {
|
||||
const [left, top, width, height] = this
|
||||
return x >= left && x < left + width && y >= top && y < top + height
|
||||
}
|
||||
@@ -384,12 +382,12 @@ export class Rectangle extends Float64Array {
|
||||
}
|
||||
|
||||
/** @returns The offset from the top-left of this rectangle to the point [{@link x}, {@link y}], as a new {@link Point}. */
|
||||
getOffsetTo([x, y]: ReadOnlyPoint): Point {
|
||||
getOffsetTo([x, y]: Readonly<Point>): Point {
|
||||
return [x - this[0], y - this[1]]
|
||||
}
|
||||
|
||||
/** @returns The offset from the point [{@link x}, {@link y}] to the top-left of this rectangle, as a new {@link Point}. */
|
||||
getOffsetFrom([x, y]: ReadOnlyPoint): Point {
|
||||
getOffsetFrom([x, y]: Readonly<Point>): Point {
|
||||
return [this[0] - x, this[1] - y]
|
||||
}
|
||||
|
||||
|
||||
@@ -194,7 +194,7 @@ export interface LinkSegment {
|
||||
/** The last canvas 2D path that was used to render this segment */
|
||||
path?: Path2D
|
||||
/** Centre point of the {@link path}. Calculated during render only - can be inaccurate */
|
||||
readonly _pos: Float32Array
|
||||
readonly _pos: Point
|
||||
/**
|
||||
* Y-forward along the {@link path} from its centre point, in radians.
|
||||
* `undefined` if using circles for link centres.
|
||||
@@ -226,52 +226,25 @@ export interface IFoundSlot extends IInputOrOutput {
|
||||
}
|
||||
|
||||
/** A point represented as `[x, y]` co-ordinates */
|
||||
export type Point = [x: number, y: number] | Float32Array | Float64Array
|
||||
export type Point = [x: number, y: number]
|
||||
|
||||
/** A size represented as `[width, height]` */
|
||||
export type Size = [width: number, height: number] | Float32Array | Float64Array
|
||||
|
||||
/** A very firm array */
|
||||
type ArRect = [x: number, y: number, width: number, height: number]
|
||||
export type Size = [width: number, height: number]
|
||||
|
||||
/** A rectangle starting at top-left coordinates `[x, y, width, height]` */
|
||||
export type Rect = ArRect | Float32Array | Float64Array
|
||||
|
||||
/** A point represented as `[x, y]` co-ordinates that will not be modified */
|
||||
export type ReadOnlyPoint =
|
||||
| readonly [x: number, y: number]
|
||||
| ReadOnlyTypedArray<Float32Array>
|
||||
| ReadOnlyTypedArray<Float64Array>
|
||||
|
||||
/** A size represented as `[width, height]` that will not be modified */
|
||||
export type ReadOnlySize =
|
||||
| readonly [width: number, height: number]
|
||||
| ReadOnlyTypedArray<Float32Array>
|
||||
| ReadOnlyTypedArray<Float64Array>
|
||||
export type Rect =
|
||||
| [x: number, y: number, width: number, height: number]
|
||||
| Float64Array
|
||||
|
||||
/** A rectangle starting at top-left coordinates `[x, y, width, height]` that will not be modified */
|
||||
export type ReadOnlyRect =
|
||||
| readonly [x: number, y: number, width: number, height: number]
|
||||
| ReadOnlyTypedArray<Float32Array>
|
||||
| ReadOnlyTypedArray<Float64Array>
|
||||
|
||||
type TypedArrays =
|
||||
| Int8Array
|
||||
| Uint8Array
|
||||
| Uint8ClampedArray
|
||||
| Int16Array
|
||||
| Uint16Array
|
||||
| Int32Array
|
||||
| Uint32Array
|
||||
| Float32Array
|
||||
| Float64Array
|
||||
|
||||
type TypedBigIntArrays = BigInt64Array | BigUint64Array
|
||||
export type ReadOnlyTypedArray<T extends TypedArrays | TypedBigIntArrays> =
|
||||
Omit<
|
||||
Readonly<T>,
|
||||
'fill' | 'copyWithin' | 'reverse' | 'set' | 'sort' | 'subarray'
|
||||
>
|
||||
export type ReadOnlyTypedArray<T extends Float64Array> = Omit<
|
||||
Readonly<T>,
|
||||
'fill' | 'copyWithin' | 'reverse' | 'set' | 'sort' | 'subarray'
|
||||
>
|
||||
|
||||
/** Union of property names that are of type Match */
|
||||
type KeysOfType<T, Match> = Exclude<
|
||||
@@ -330,7 +303,7 @@ export interface INodeSlot extends HasBoundingRect {
|
||||
nameLocked?: boolean
|
||||
pos?: Point
|
||||
/** @remarks Automatically calculated; not included in serialisation. */
|
||||
boundingRect: Rect
|
||||
boundingRect: ReadOnlyRect
|
||||
/**
|
||||
* A list of floating link IDs that are connected to this slot.
|
||||
* This is calculated at runtime; it is **not** serialized.
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import type {
|
||||
HasBoundingRect,
|
||||
Point,
|
||||
ReadOnlyPoint,
|
||||
ReadOnlyRect,
|
||||
Rect
|
||||
} from './interfaces'
|
||||
import type { HasBoundingRect, Point, ReadOnlyRect, Rect } from './interfaces'
|
||||
import { Alignment, LinkDirection, hasFlag } from './types/globalEnums'
|
||||
|
||||
/**
|
||||
@@ -13,7 +7,7 @@ import { Alignment, LinkDirection, hasFlag } from './types/globalEnums'
|
||||
* @param b Point b as `x, y`
|
||||
* @returns Distance between point {@link a} & {@link b}
|
||||
*/
|
||||
export function distance(a: ReadOnlyPoint, b: ReadOnlyPoint): number {
|
||||
export function distance(a: Readonly<Point>, b: Readonly<Point>): number {
|
||||
return Math.sqrt(
|
||||
(b[0] - a[0]) * (b[0] - a[0]) + (b[1] - a[1]) * (b[1] - a[1])
|
||||
)
|
||||
@@ -62,7 +56,7 @@ export function isInRectangle(
|
||||
* @returns `true` if the point is inside the rect, otherwise `false`
|
||||
*/
|
||||
export function isPointInRect(
|
||||
point: ReadOnlyPoint,
|
||||
point: Readonly<Point>,
|
||||
rect: ReadOnlyRect
|
||||
): boolean {
|
||||
return (
|
||||
@@ -289,8 +283,8 @@ export function rotateLink(
|
||||
* the right
|
||||
*/
|
||||
export function getOrientation(
|
||||
lineStart: ReadOnlyPoint,
|
||||
lineEnd: ReadOnlyPoint,
|
||||
lineStart: Readonly<Point>,
|
||||
lineEnd: Readonly<Point>,
|
||||
x: number,
|
||||
y: number
|
||||
): number {
|
||||
@@ -310,10 +304,10 @@ export function getOrientation(
|
||||
*/
|
||||
export function findPointOnCurve(
|
||||
out: Point,
|
||||
a: ReadOnlyPoint,
|
||||
b: ReadOnlyPoint,
|
||||
controlA: ReadOnlyPoint,
|
||||
controlB: ReadOnlyPoint,
|
||||
a: Readonly<Point>,
|
||||
b: Readonly<Point>,
|
||||
controlA: Readonly<Point>,
|
||||
controlB: Readonly<Point>,
|
||||
t: number = 0.5
|
||||
): void {
|
||||
const iT = 1 - t
|
||||
@@ -331,7 +325,7 @@ export function createBounds(
|
||||
objects: Iterable<HasBoundingRect>,
|
||||
padding: number = 10
|
||||
): ReadOnlyRect | null {
|
||||
const bounds = new Float32Array([Infinity, Infinity, -Infinity, -Infinity])
|
||||
const bounds: Rect = [Infinity, Infinity, -Infinity, -Infinity]
|
||||
|
||||
for (const obj of objects) {
|
||||
const rect = obj.boundingRect
|
||||
@@ -382,7 +376,7 @@ export function alignToContainer(
|
||||
rect: Rect,
|
||||
anchors: Alignment,
|
||||
[containerX, containerY, containerWidth, containerHeight]: ReadOnlyRect,
|
||||
[insetX, insetY]: ReadOnlyPoint = [0, 0]
|
||||
[insetX, insetY]: Readonly<Point> = [0, 0]
|
||||
): Rect {
|
||||
if (hasFlag(anchors, Alignment.Left)) {
|
||||
// Left
|
||||
@@ -425,7 +419,7 @@ export function alignOutsideContainer(
|
||||
rect: Rect,
|
||||
anchors: Alignment,
|
||||
[otherX, otherY, otherWidth, otherHeight]: ReadOnlyRect,
|
||||
[outsetX, outsetY]: ReadOnlyPoint = [0, 0]
|
||||
[outsetX, outsetY]: Readonly<Point> = [0, 0]
|
||||
): Rect {
|
||||
if (hasFlag(anchors, Alignment.Left)) {
|
||||
// Left
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot,
|
||||
OptionalProps,
|
||||
ReadOnlyPoint
|
||||
Point
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { type IDrawOptions, NodeSlot } from '@/lib/litegraph/src/node/NodeSlot'
|
||||
@@ -32,7 +32,7 @@ export class NodeInputSlot extends NodeSlot implements INodeInputSlot {
|
||||
this.#widget = widget ? new WeakRef(widget) : undefined
|
||||
}
|
||||
|
||||
get collapsedPos(): ReadOnlyPoint {
|
||||
get collapsedPos(): Readonly<Point> {
|
||||
return [0, LiteGraph.NODE_TITLE_HEIGHT * -0.5]
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot,
|
||||
OptionalProps,
|
||||
ReadOnlyPoint
|
||||
Point
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { type IDrawOptions, NodeSlot } from '@/lib/litegraph/src/node/NodeSlot'
|
||||
@@ -24,7 +24,7 @@ export class NodeOutputSlot extends NodeSlot implements INodeOutputSlot {
|
||||
return false
|
||||
}
|
||||
|
||||
get collapsedPos(): ReadOnlyPoint {
|
||||
get collapsedPos(): Readonly<Point> {
|
||||
return [
|
||||
this.#node._collapsed_width ?? LiteGraph.NODE_COLLAPSED_WIDTH,
|
||||
LiteGraph.NODE_TITLE_HEIGHT * -0.5
|
||||
|
||||
@@ -8,8 +8,7 @@ import type {
|
||||
INodeSlot,
|
||||
ISubgraphInput,
|
||||
OptionalProps,
|
||||
Point,
|
||||
ReadOnlyPoint
|
||||
Point
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import { LiteGraph, Rectangle } from '@/lib/litegraph/src/litegraph'
|
||||
import { getCentre } from '@/lib/litegraph/src/measure'
|
||||
@@ -36,7 +35,7 @@ export abstract class NodeSlot extends SlotBase implements INodeSlot {
|
||||
pos?: Point
|
||||
|
||||
/** The offset from the parent node to the centre point of this slot. */
|
||||
get #centreOffset(): ReadOnlyPoint {
|
||||
get #centreOffset(): Readonly<Point> {
|
||||
const nodePos = this.node.pos
|
||||
const { boundingRect } = this
|
||||
|
||||
@@ -52,7 +51,7 @@ export abstract class NodeSlot extends SlotBase implements INodeSlot {
|
||||
}
|
||||
|
||||
/** The center point of this slot when the node is collapsed. */
|
||||
abstract get collapsedPos(): ReadOnlyPoint
|
||||
abstract get collapsedPos(): Readonly<Point>
|
||||
|
||||
#node: LGraphNode
|
||||
get node(): LGraphNode {
|
||||
|
||||
@@ -168,7 +168,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
canvas: LGraphCanvas
|
||||
): void {
|
||||
if (button.name === 'enter_subgraph') {
|
||||
canvas.openSubgraph(this.subgraph)
|
||||
canvas.openSubgraph(this.subgraph, this)
|
||||
} else {
|
||||
super.onTitleButtonClick(button, canvas)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
INodeOutputSlot,
|
||||
Point,
|
||||
ReadOnlyRect,
|
||||
ReadOnlySize
|
||||
Size
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { SlotBase } from '@/lib/litegraph/src/node/SlotBase'
|
||||
@@ -45,7 +45,7 @@ export abstract class SubgraphSlot
|
||||
return LiteGraph.NODE_SLOT_HEIGHT
|
||||
}
|
||||
|
||||
readonly #pos: Point = new Float32Array(2)
|
||||
readonly #pos: Point = [0, 0]
|
||||
|
||||
readonly measurement: ConstrainedSize = new ConstrainedSize(
|
||||
SubgraphSlot.defaultHeight,
|
||||
@@ -133,7 +133,7 @@ export abstract class SubgraphSlot
|
||||
}
|
||||
}
|
||||
|
||||
measure(): ReadOnlySize {
|
||||
measure(): Readonly<Size> {
|
||||
const width = LGraphCanvas._measureText?.(this.displayName) ?? 0
|
||||
|
||||
const { defaultHeight } = SubgraphSlot
|
||||
|
||||
@@ -308,6 +308,13 @@ export interface IBaseWidget<
|
||||
|
||||
hidden?: boolean
|
||||
advanced?: boolean
|
||||
/**
|
||||
* This property is automatically computed on graph change
|
||||
* and should not be changed.
|
||||
* Promoted widgets have a colored border
|
||||
* @see /core/graph/subgraph/proxyWidget.registerProxyWidgets
|
||||
*/
|
||||
promoted?: boolean
|
||||
|
||||
tooltip?: string
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
|
||||
computedDisabled?: boolean
|
||||
hidden?: boolean
|
||||
advanced?: boolean
|
||||
promoted?: boolean
|
||||
tooltip?: string
|
||||
element?: HTMLElement
|
||||
callback?(
|
||||
@@ -146,6 +147,7 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
|
||||
}
|
||||
|
||||
get outline_color() {
|
||||
if (this.promoted) return LiteGraph.WIDGET_PROMOTED_OUTLINE_COLOR
|
||||
return this.advanced
|
||||
? LiteGraph.WIDGET_ADVANCED_OUTLINE_COLOR
|
||||
: LiteGraph.WIDGET_OUTLINE_COLOR
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { describe } from 'vitest'
|
||||
|
||||
import { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { dirtyTest } from './testExtensions'
|
||||
|
||||
describe('LGraph configure()', () => {
|
||||
dirtyTest(
|
||||
'LGraph matches previous snapshot (normal configure() usage)',
|
||||
({ expect, minimalSerialisableGraph, basicSerialisableGraph }) => {
|
||||
const configuredMinGraph = new LGraph()
|
||||
configuredMinGraph.configure(minimalSerialisableGraph)
|
||||
expect(configuredMinGraph).toMatchSnapshot('configuredMinGraph')
|
||||
|
||||
const configuredBasicGraph = new LGraph()
|
||||
configuredBasicGraph.configure(basicSerialisableGraph)
|
||||
expect(configuredBasicGraph).toMatchSnapshot('configuredBasicGraph')
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -1,144 +0,0 @@
|
||||
import { describe } from 'vitest'
|
||||
|
||||
import { LGraph, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { test } from './testExtensions'
|
||||
|
||||
describe('LGraph', () => {
|
||||
test('can be instantiated', ({ expect }) => {
|
||||
// @ts-expect-error Intentional - extra holds any / all consumer data that should be serialised
|
||||
const graph = new LGraph({ extra: 'TestGraph' })
|
||||
expect(graph).toBeInstanceOf(LGraph)
|
||||
expect(graph.extra).toBe('TestGraph')
|
||||
expect(graph.extra).toBe('TestGraph')
|
||||
})
|
||||
|
||||
test('is exactly the same type', async ({ expect }) => {
|
||||
const directImport = await import('@/lib/litegraph/src/LGraph')
|
||||
const entryPointImport = await import('@/lib/litegraph/src/litegraph')
|
||||
|
||||
expect(LiteGraph.LGraph).toBe(directImport.LGraph)
|
||||
expect(LiteGraph.LGraph).toBe(entryPointImport.LGraph)
|
||||
})
|
||||
|
||||
test('populates optional values', ({ expect, minimalSerialisableGraph }) => {
|
||||
const dGraph = new LGraph(minimalSerialisableGraph)
|
||||
expect(dGraph.links).toBeInstanceOf(Map)
|
||||
expect(dGraph.nodes).toBeInstanceOf(Array)
|
||||
expect(dGraph.groups).toBeInstanceOf(Array)
|
||||
})
|
||||
|
||||
test('supports schema v0.4 graphs', ({ expect, oldSchemaGraph }) => {
|
||||
const fromOldSchema = new LGraph(oldSchemaGraph)
|
||||
expect(fromOldSchema).toMatchSnapshot('oldSchemaGraph')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Floating Links / Reroutes', () => {
|
||||
test('Floating reroute should be removed when node and link are removed', ({
|
||||
expect,
|
||||
floatingLinkGraph
|
||||
}) => {
|
||||
const graph = new LGraph(floatingLinkGraph)
|
||||
expect(graph.nodes.length).toBe(1)
|
||||
graph.remove(graph.nodes[0])
|
||||
expect(graph.nodes.length).toBe(0)
|
||||
expect(graph.links.size).toBe(0)
|
||||
expect(graph.floatingLinks.size).toBe(0)
|
||||
expect(graph.reroutes.size).toBe(0)
|
||||
})
|
||||
|
||||
test('Can add reroute to existing link', ({ expect, linkedNodesGraph }) => {
|
||||
const graph = new LGraph(linkedNodesGraph)
|
||||
expect(graph.nodes.length).toBe(2)
|
||||
expect(graph.links.size).toBe(1)
|
||||
expect(graph.reroutes.size).toBe(0)
|
||||
|
||||
graph.createReroute([0, 0], graph.links.values().next().value!)
|
||||
expect(graph.links.size).toBe(1)
|
||||
expect(graph.reroutes.size).toBe(1)
|
||||
})
|
||||
|
||||
test('Create floating reroute when one side of node is removed', ({
|
||||
expect,
|
||||
linkedNodesGraph
|
||||
}) => {
|
||||
const graph = new LGraph(linkedNodesGraph)
|
||||
graph.createReroute([0, 0], graph.links.values().next().value!)
|
||||
graph.remove(graph.nodes[0])
|
||||
|
||||
expect(graph.links.size).toBe(0)
|
||||
expect(graph.floatingLinks.size).toBe(1)
|
||||
expect(graph.reroutes.size).toBe(1)
|
||||
expect(graph.reroutes.values().next().value!.floating).not.toBeUndefined()
|
||||
})
|
||||
|
||||
test('Create floating reroute when one side of link is removed', ({
|
||||
expect,
|
||||
linkedNodesGraph
|
||||
}) => {
|
||||
const graph = new LGraph(linkedNodesGraph)
|
||||
graph.createReroute([0, 0], graph.links.values().next().value!)
|
||||
graph.nodes[0].disconnectOutput(0)
|
||||
|
||||
expect(graph.links.size).toBe(0)
|
||||
expect(graph.floatingLinks.size).toBe(1)
|
||||
expect(graph.reroutes.size).toBe(1)
|
||||
expect(graph.reroutes.values().next().value!.floating).not.toBeUndefined()
|
||||
})
|
||||
|
||||
test('Reroutes and branches should be retained when the input node is removed', ({
|
||||
expect,
|
||||
floatingBranchGraph: graph
|
||||
}) => {
|
||||
expect(graph.nodes.length).toBe(3)
|
||||
graph.remove(graph.nodes[2])
|
||||
expect(graph.nodes.length).toBe(2)
|
||||
expect(graph.links.size).toBe(1)
|
||||
expect(graph.floatingLinks.size).toBe(1)
|
||||
expect(graph.reroutes.size).toBe(4)
|
||||
graph.remove(graph.nodes[1])
|
||||
expect(graph.nodes.length).toBe(1)
|
||||
expect(graph.links.size).toBe(0)
|
||||
expect(graph.floatingLinks.size).toBe(2)
|
||||
expect(graph.reroutes.size).toBe(4)
|
||||
})
|
||||
|
||||
test('Floating reroutes should be removed when neither input nor output is connected', ({
|
||||
expect,
|
||||
floatingBranchGraph: graph
|
||||
}) => {
|
||||
// Remove output node
|
||||
graph.remove(graph.nodes[0])
|
||||
expect(graph.nodes.length).toBe(2)
|
||||
expect(graph.links.size).toBe(0)
|
||||
expect(graph.floatingLinks.size).toBe(2)
|
||||
// The original floating reroute should be removed
|
||||
expect(graph.reroutes.size).toBe(3)
|
||||
graph.remove(graph.nodes[0])
|
||||
expect(graph.nodes.length).toBe(1)
|
||||
expect(graph.links.size).toBe(0)
|
||||
expect(graph.floatingLinks.size).toBe(1)
|
||||
expect(graph.reroutes.size).toBe(3)
|
||||
graph.remove(graph.nodes[0])
|
||||
expect(graph.nodes.length).toBe(0)
|
||||
expect(graph.links.size).toBe(0)
|
||||
expect(graph.floatingLinks.size).toBe(0)
|
||||
expect(graph.reroutes.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Legacy LGraph Compatibility Layer', () => {
|
||||
test('can be extended via prototype', ({ expect, minimalGraph }) => {
|
||||
// @ts-expect-error Should always be an error.
|
||||
LGraph.prototype.newMethod = function () {
|
||||
return 'New method added via prototype'
|
||||
}
|
||||
// @ts-expect-error Should always be an error.
|
||||
expect(minimalGraph.newMethod()).toBe('New method added via prototype')
|
||||
})
|
||||
|
||||
test('is correctly assigned to LiteGraph', ({ expect }) => {
|
||||
expect(LiteGraph.LGraph).toBe(LGraph)
|
||||
})
|
||||
})
|
||||
@@ -1,195 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphButton } from '@/lib/litegraph/src/LGraphButton'
|
||||
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
|
||||
|
||||
describe('LGraphButton', () => {
|
||||
describe('Constructor', () => {
|
||||
it('should create a button with default options', () => {
|
||||
// @ts-expect-error TODO: Fix after merge - LGraphButton constructor type issues
|
||||
const button = new LGraphButton({})
|
||||
expect(button).toBeInstanceOf(LGraphButton)
|
||||
expect(button.name).toBeUndefined()
|
||||
expect(button._last_area).toBeInstanceOf(Rectangle)
|
||||
})
|
||||
|
||||
it('should create a button with custom name', () => {
|
||||
// @ts-expect-error TODO: Fix after merge - LGraphButton constructor type issues
|
||||
const button = new LGraphButton({ name: 'test_button' })
|
||||
expect(button.name).toBe('test_button')
|
||||
})
|
||||
|
||||
it('should inherit badge properties', () => {
|
||||
const button = new LGraphButton({
|
||||
text: 'Test',
|
||||
fgColor: '#FF0000',
|
||||
bgColor: '#0000FF',
|
||||
fontSize: 16
|
||||
})
|
||||
expect(button.text).toBe('Test')
|
||||
expect(button.fgColor).toBe('#FF0000')
|
||||
expect(button.bgColor).toBe('#0000FF')
|
||||
expect(button.fontSize).toBe(16)
|
||||
expect(button.visible).toBe(true) // visible is computed based on text length
|
||||
})
|
||||
})
|
||||
|
||||
describe('draw', () => {
|
||||
it('should not draw if not visible', () => {
|
||||
const button = new LGraphButton({ text: '' }) // Empty text makes it invisible
|
||||
const ctx = {
|
||||
measureText: vi.fn().mockReturnValue({ width: 100 })
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
const superDrawSpy = vi.spyOn(
|
||||
Object.getPrototypeOf(Object.getPrototypeOf(button)),
|
||||
'draw'
|
||||
)
|
||||
|
||||
button.draw(ctx, 50, 100)
|
||||
|
||||
expect(superDrawSpy).not.toHaveBeenCalled()
|
||||
expect(button._last_area.width).toBe(0) // Rectangle default width
|
||||
})
|
||||
|
||||
it('should draw and update last area when visible', () => {
|
||||
const button = new LGraphButton({
|
||||
text: 'Click',
|
||||
xOffset: 5,
|
||||
yOffset: 10
|
||||
})
|
||||
|
||||
const ctx = {
|
||||
measureText: vi.fn().mockReturnValue({ width: 60 }),
|
||||
fillRect: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
roundRect: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
font: '',
|
||||
fillStyle: '',
|
||||
globalAlpha: 1
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
const mockGetWidth = vi.fn().mockReturnValue(80)
|
||||
button.getWidth = mockGetWidth
|
||||
|
||||
const x = 100
|
||||
const y = 50
|
||||
|
||||
button.draw(ctx, x, y)
|
||||
|
||||
// Check that last area was updated correctly
|
||||
expect(button._last_area[0]).toBe(x + button.xOffset) // 100 + 5 = 105
|
||||
expect(button._last_area[1]).toBe(y + button.yOffset) // 50 + 10 = 60
|
||||
expect(button._last_area[2]).toBe(80)
|
||||
expect(button._last_area[3]).toBe(button.height)
|
||||
})
|
||||
|
||||
it('should calculate last area without offsets', () => {
|
||||
const button = new LGraphButton({
|
||||
text: 'Test'
|
||||
})
|
||||
|
||||
const ctx = {
|
||||
measureText: vi.fn().mockReturnValue({ width: 40 }),
|
||||
fillRect: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
roundRect: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
font: '',
|
||||
fillStyle: '',
|
||||
globalAlpha: 1
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
const mockGetWidth = vi.fn().mockReturnValue(50)
|
||||
button.getWidth = mockGetWidth
|
||||
|
||||
button.draw(ctx, 200, 100)
|
||||
|
||||
expect(button._last_area[0]).toBe(200)
|
||||
expect(button._last_area[1]).toBe(100)
|
||||
expect(button._last_area[2]).toBe(50)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPointInside', () => {
|
||||
it('should return true when point is inside button area', () => {
|
||||
const button = new LGraphButton({ text: 'Test' })
|
||||
// Set the last area manually
|
||||
button._last_area[0] = 100
|
||||
button._last_area[1] = 50
|
||||
button._last_area[2] = 80
|
||||
button._last_area[3] = 20
|
||||
|
||||
// Test various points inside
|
||||
expect(button.isPointInside(100, 50)).toBe(true) // Top-left corner
|
||||
expect(button.isPointInside(179, 69)).toBe(true) // Bottom-right corner
|
||||
expect(button.isPointInside(140, 60)).toBe(true) // Center
|
||||
})
|
||||
|
||||
it('should return false when point is outside button area', () => {
|
||||
const button = new LGraphButton({ text: 'Test' })
|
||||
// Set the last area manually
|
||||
button._last_area[0] = 100
|
||||
button._last_area[1] = 50
|
||||
button._last_area[2] = 80
|
||||
button._last_area[3] = 20
|
||||
|
||||
// Test various points outside
|
||||
expect(button.isPointInside(99, 50)).toBe(false) // Just left
|
||||
expect(button.isPointInside(181, 50)).toBe(false) // Just right
|
||||
expect(button.isPointInside(100, 49)).toBe(false) // Just above
|
||||
expect(button.isPointInside(100, 71)).toBe(false) // Just below
|
||||
expect(button.isPointInside(0, 0)).toBe(false) // Far away
|
||||
})
|
||||
|
||||
it('should work with buttons that have not been drawn yet', () => {
|
||||
const button = new LGraphButton({ text: 'Test' })
|
||||
// _last_area has default values (0, 0, 0, 0)
|
||||
|
||||
expect(button.isPointInside(10, 10)).toBe(false)
|
||||
expect(button.isPointInside(0, 0)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Integration with LGraphBadge', () => {
|
||||
it('should properly inherit and use badge functionality', () => {
|
||||
const button = new LGraphButton({
|
||||
text: '→',
|
||||
fontSize: 20,
|
||||
// @ts-expect-error TODO: Fix after merge - color property not defined in type
|
||||
color: '#FFFFFF',
|
||||
backgroundColor: '#333333',
|
||||
xOffset: -10,
|
||||
yOffset: 5
|
||||
})
|
||||
|
||||
const ctx = {
|
||||
measureText: vi.fn().mockReturnValue({ width: 20 }),
|
||||
fillRect: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
roundRect: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
font: '',
|
||||
fillStyle: '',
|
||||
globalAlpha: 1
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
// Draw the button
|
||||
button.draw(ctx, 100, 50)
|
||||
|
||||
// Verify button draws text without background
|
||||
expect(ctx.beginPath).not.toHaveBeenCalled() // No background
|
||||
expect(ctx.roundRect).not.toHaveBeenCalled() // No background
|
||||
expect(ctx.fill).not.toHaveBeenCalled() // No background
|
||||
expect(ctx.fillText).toHaveBeenCalledWith(
|
||||
'→',
|
||||
expect.any(Number),
|
||||
expect.any(Number)
|
||||
) // Just text
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,290 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
describe('LGraphCanvas Title Button Rendering', () => {
|
||||
let canvas: LGraphCanvas
|
||||
let ctx: CanvasRenderingContext2D
|
||||
let node: LGraphNode
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a mock canvas element
|
||||
const canvasElement = document.createElement('canvas')
|
||||
ctx = {
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
translate: vi.fn(),
|
||||
scale: vi.fn(),
|
||||
fillRect: vi.fn(),
|
||||
strokeRect: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
measureText: vi.fn().mockReturnValue({ width: 50 }),
|
||||
beginPath: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
closePath: vi.fn(),
|
||||
arc: vi.fn(),
|
||||
rect: vi.fn(),
|
||||
clip: vi.fn(),
|
||||
clearRect: vi.fn(),
|
||||
setTransform: vi.fn(),
|
||||
roundRect: vi.fn(),
|
||||
font: '',
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 1,
|
||||
globalAlpha: 1,
|
||||
textAlign: 'left' as CanvasTextAlign,
|
||||
textBaseline: 'alphabetic' as CanvasTextBaseline
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
canvasElement.getContext = vi.fn().mockReturnValue(ctx)
|
||||
|
||||
// @ts-expect-error TODO: Fix after merge - LGraphCanvas constructor type issues
|
||||
canvas = new LGraphCanvas(canvasElement, null, {
|
||||
skip_render: true,
|
||||
skip_events: true
|
||||
})
|
||||
|
||||
node = new LGraphNode('Test Node')
|
||||
node.pos = [100, 200]
|
||||
node.size = [200, 100]
|
||||
|
||||
// Mock required methods
|
||||
node.drawTitleBarBackground = vi.fn()
|
||||
// @ts-expect-error Property 'drawTitleBarText' does not exist on type 'LGraphNode'
|
||||
node.drawTitleBarText = vi.fn()
|
||||
node.drawBadges = vi.fn()
|
||||
// @ts-expect-error TODO: Fix after merge - drawToggles not defined in type
|
||||
node.drawToggles = vi.fn()
|
||||
// @ts-expect-error TODO: Fix after merge - drawNodeShape not defined in type
|
||||
node.drawNodeShape = vi.fn()
|
||||
node.drawSlots = vi.fn()
|
||||
// @ts-expect-error TODO: Fix after merge - drawContent not defined in type
|
||||
node.drawContent = vi.fn()
|
||||
node.drawWidgets = vi.fn()
|
||||
node.drawCollapsedSlots = vi.fn()
|
||||
node.drawTitleBox = vi.fn()
|
||||
node.drawTitleText = vi.fn()
|
||||
node.drawProgressBar = vi.fn()
|
||||
node._setConcreteSlots = vi.fn()
|
||||
node.arrange = vi.fn()
|
||||
// @ts-expect-error TODO: Fix after merge - isSelectable not defined in type
|
||||
node.isSelectable = vi.fn().mockReturnValue(true)
|
||||
})
|
||||
|
||||
describe('drawNode title button rendering', () => {
|
||||
it('should render visible title buttons', () => {
|
||||
const button1 = node.addTitleButton({
|
||||
name: 'button1',
|
||||
text: 'A',
|
||||
// @ts-expect-error TODO: Fix after merge - visible property not in LGraphButtonOptions
|
||||
visible: true
|
||||
})
|
||||
|
||||
const button2 = node.addTitleButton({
|
||||
name: 'button2',
|
||||
text: 'B',
|
||||
// @ts-expect-error TODO: Fix after merge - visible property not in LGraphButtonOptions
|
||||
visible: true
|
||||
})
|
||||
|
||||
// Mock button methods
|
||||
const getWidth1 = vi.fn().mockReturnValue(20)
|
||||
const getWidth2 = vi.fn().mockReturnValue(25)
|
||||
const draw1 = vi.spyOn(button1, 'draw')
|
||||
const draw2 = vi.spyOn(button2, 'draw')
|
||||
|
||||
button1.getWidth = getWidth1
|
||||
button2.getWidth = getWidth2
|
||||
|
||||
// Draw the node (this is a simplified version of what drawNode does)
|
||||
canvas.drawNode(node, ctx)
|
||||
|
||||
// Verify both buttons' getWidth was called
|
||||
expect(getWidth1).toHaveBeenCalledWith(ctx)
|
||||
expect(getWidth2).toHaveBeenCalledWith(ctx)
|
||||
|
||||
// Verify both buttons were drawn
|
||||
expect(draw1).toHaveBeenCalled()
|
||||
expect(draw2).toHaveBeenCalled()
|
||||
|
||||
// Check draw positions (right-aligned from node width)
|
||||
// First button (rightmost): 200 - 5 = 195, then subtract width
|
||||
// Second button: first button position - 5 - button width
|
||||
const titleHeight = LiteGraph.NODE_TITLE_HEIGHT
|
||||
const buttonY = -titleHeight + (titleHeight - 20) / 2 // Centered
|
||||
expect(draw1).toHaveBeenCalledWith(ctx, 180, buttonY) // 200 - 20
|
||||
expect(draw2).toHaveBeenCalledWith(ctx, 153, buttonY) // 180 - 2 - 25
|
||||
})
|
||||
|
||||
it('should skip invisible title buttons', () => {
|
||||
const visibleButton = node.addTitleButton({
|
||||
name: 'visible',
|
||||
text: 'V',
|
||||
// @ts-expect-error TODO: Fix after merge - visible property not in LGraphButtonOptions
|
||||
visible: true
|
||||
})
|
||||
|
||||
const invisibleButton = node.addTitleButton({
|
||||
name: 'invisible',
|
||||
text: '' // Empty text makes it invisible
|
||||
})
|
||||
|
||||
const getWidthVisible = vi.fn().mockReturnValue(30)
|
||||
const getWidthInvisible = vi.fn().mockReturnValue(30)
|
||||
const drawVisible = vi.spyOn(visibleButton, 'draw')
|
||||
const drawInvisible = vi.spyOn(invisibleButton, 'draw')
|
||||
|
||||
visibleButton.getWidth = getWidthVisible
|
||||
invisibleButton.getWidth = getWidthInvisible
|
||||
|
||||
canvas.drawNode(node, ctx)
|
||||
|
||||
// Only visible button should be measured and drawn
|
||||
expect(getWidthVisible).toHaveBeenCalledWith(ctx)
|
||||
expect(getWidthInvisible).not.toHaveBeenCalled()
|
||||
|
||||
expect(drawVisible).toHaveBeenCalled()
|
||||
expect(drawInvisible).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle nodes without title buttons', () => {
|
||||
// Node has no title buttons
|
||||
expect(node.title_buttons).toHaveLength(0)
|
||||
|
||||
// Should draw without errors
|
||||
expect(() => canvas.drawNode(node, ctx)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should position multiple buttons with correct spacing', () => {
|
||||
const buttons = []
|
||||
const drawSpies = []
|
||||
|
||||
// Add 3 buttons
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const button = node.addTitleButton({
|
||||
name: `button${i}`,
|
||||
text: String(i),
|
||||
// @ts-expect-error TODO: Fix after merge - visible property not in LGraphButtonOptions
|
||||
visible: true
|
||||
})
|
||||
button.getWidth = vi.fn().mockReturnValue(15) // All same width for simplicity
|
||||
const spy = vi.spyOn(button, 'draw')
|
||||
buttons.push(button)
|
||||
drawSpies.push(spy)
|
||||
}
|
||||
|
||||
canvas.drawNode(node, ctx)
|
||||
|
||||
const titleHeight = LiteGraph.NODE_TITLE_HEIGHT
|
||||
|
||||
// Check positions are correctly spaced (right to left)
|
||||
// Starting position: 200
|
||||
const buttonY = -titleHeight + (titleHeight - 20) / 2 // Button height is 20 (default)
|
||||
expect(drawSpies[0]).toHaveBeenCalledWith(ctx, 185, buttonY) // 200 - 15
|
||||
expect(drawSpies[1]).toHaveBeenCalledWith(ctx, 168, buttonY) // 185 - 2 - 15
|
||||
expect(drawSpies[2]).toHaveBeenCalledWith(ctx, 151, buttonY) // 168 - 2 - 15
|
||||
})
|
||||
|
||||
it('should render buttons in low quality mode', () => {
|
||||
const button = node.addTitleButton({
|
||||
name: 'test',
|
||||
text: 'T',
|
||||
// @ts-expect-error TODO: Fix after merge - visible property not in LGraphButtonOptions
|
||||
visible: true
|
||||
})
|
||||
|
||||
button.getWidth = vi.fn().mockReturnValue(20)
|
||||
const drawSpy = vi.spyOn(button, 'draw')
|
||||
|
||||
// Set low quality rendering
|
||||
// @ts-expect-error TODO: Fix after merge - lowQualityRenderingRequired not defined in type
|
||||
canvas.lowQualityRenderingRequired = true
|
||||
|
||||
canvas.drawNode(node, ctx)
|
||||
|
||||
// Buttons should still be rendered in low quality mode
|
||||
const buttonY =
|
||||
-LiteGraph.NODE_TITLE_HEIGHT + (LiteGraph.NODE_TITLE_HEIGHT - 20) / 2
|
||||
expect(drawSpy).toHaveBeenCalledWith(ctx, 180, buttonY)
|
||||
})
|
||||
|
||||
it('should handle buttons with different widths', () => {
|
||||
const smallButton = node.addTitleButton({
|
||||
name: 'small',
|
||||
text: 'S',
|
||||
// @ts-expect-error TODO: Fix after merge - visible property not in LGraphButtonOptions
|
||||
visible: true
|
||||
})
|
||||
|
||||
const largeButton = node.addTitleButton({
|
||||
name: 'large',
|
||||
text: 'LARGE',
|
||||
// @ts-expect-error TODO: Fix after merge - visible property not in LGraphButtonOptions
|
||||
visible: true
|
||||
})
|
||||
|
||||
smallButton.getWidth = vi.fn().mockReturnValue(15)
|
||||
largeButton.getWidth = vi.fn().mockReturnValue(50)
|
||||
|
||||
const drawSmall = vi.spyOn(smallButton, 'draw')
|
||||
const drawLarge = vi.spyOn(largeButton, 'draw')
|
||||
|
||||
canvas.drawNode(node, ctx)
|
||||
|
||||
const titleHeight = LiteGraph.NODE_TITLE_HEIGHT
|
||||
|
||||
// Small button (rightmost): 200 - 15 = 185
|
||||
const buttonY = -titleHeight + (titleHeight - 20) / 2
|
||||
expect(drawSmall).toHaveBeenCalledWith(ctx, 185, buttonY)
|
||||
|
||||
// Large button: 185 - 2 - 50 = 133
|
||||
expect(drawLarge).toHaveBeenCalledWith(ctx, 133, buttonY)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Integration with node properties', () => {
|
||||
it('should respect node size for button positioning', () => {
|
||||
node.size = [300, 150] // Wider node
|
||||
|
||||
const button = node.addTitleButton({
|
||||
name: 'test',
|
||||
text: 'X',
|
||||
// @ts-expect-error TODO: Fix after merge - visible property not in LGraphButtonOptions
|
||||
visible: true
|
||||
})
|
||||
|
||||
button.getWidth = vi.fn().mockReturnValue(20)
|
||||
const drawSpy = vi.spyOn(button, 'draw')
|
||||
|
||||
canvas.drawNode(node, ctx)
|
||||
|
||||
const titleHeight = LiteGraph.NODE_TITLE_HEIGHT
|
||||
// Should use new width: 300 - 20 = 280
|
||||
const buttonY = -titleHeight + (titleHeight - 20) / 2
|
||||
expect(drawSpy).toHaveBeenCalledWith(ctx, 280, buttonY)
|
||||
})
|
||||
|
||||
it('should NOT render buttons on collapsed nodes', () => {
|
||||
node.flags.collapsed = true
|
||||
|
||||
const button = node.addTitleButton({
|
||||
name: 'test',
|
||||
text: 'C'
|
||||
})
|
||||
|
||||
button.getWidth = vi.fn().mockReturnValue(20)
|
||||
const drawSpy = vi.spyOn(button, 'draw')
|
||||
|
||||
canvas.drawNode(node, ctx)
|
||||
|
||||
// Title buttons should NOT be rendered on collapsed nodes
|
||||
expect(drawSpy).not.toHaveBeenCalled()
|
||||
expect(button.getWidth).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,12 +0,0 @@
|
||||
import { describe, expect } from 'vitest'
|
||||
|
||||
import { LGraphGroup } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { test } from './testExtensions'
|
||||
|
||||
describe('LGraphGroup', () => {
|
||||
test('serializes to the existing format', () => {
|
||||
const link = new LGraphGroup('title', 929)
|
||||
expect(link.serialize()).toMatchSnapshot('Basic')
|
||||
})
|
||||
})
|
||||
@@ -1,131 +0,0 @@
|
||||
import { beforeEach, describe, expect } from 'vitest'
|
||||
|
||||
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { test } from './testExtensions'
|
||||
|
||||
describe('LGraphNode resize functionality', () => {
|
||||
let node: LGraphNode
|
||||
|
||||
beforeEach(() => {
|
||||
// Set up LiteGraph constants needed for measure
|
||||
LiteGraph.NODE_TITLE_HEIGHT = 20
|
||||
|
||||
node = new LGraphNode('Test Node')
|
||||
node.pos = [100, 100]
|
||||
node.size = [200, 150]
|
||||
|
||||
// Create a mock canvas context for updateArea
|
||||
const mockCtx = {} as CanvasRenderingContext2D
|
||||
|
||||
// Call updateArea to populate boundingRect
|
||||
node.updateArea(mockCtx)
|
||||
})
|
||||
|
||||
describe('findResizeDirection', () => {
|
||||
describe('corners', () => {
|
||||
test('should detect NW (top-left) corner', () => {
|
||||
// With title bar, top is at y=80 (100 - 20)
|
||||
// Corner is from (100, 80) to (100 + 15, 80 + 15)
|
||||
expect(node.findResizeDirection(100, 80)).toBe('NW')
|
||||
expect(node.findResizeDirection(110, 90)).toBe('NW')
|
||||
expect(node.findResizeDirection(114, 94)).toBe('NW')
|
||||
})
|
||||
|
||||
test('should detect NE (top-right) corner', () => {
|
||||
// Corner is from (300 - 15, 80) to (300, 80 + 15)
|
||||
expect(node.findResizeDirection(285, 80)).toBe('NE')
|
||||
expect(node.findResizeDirection(290, 90)).toBe('NE')
|
||||
expect(node.findResizeDirection(299, 94)).toBe('NE')
|
||||
})
|
||||
|
||||
test('should detect SW (bottom-left) corner', () => {
|
||||
// Bottom is at y=250 (100 + 150)
|
||||
// Corner is from (100, 250 - 15) to (100 + 15, 250)
|
||||
expect(node.findResizeDirection(100, 235)).toBe('SW')
|
||||
expect(node.findResizeDirection(110, 240)).toBe('SW')
|
||||
expect(node.findResizeDirection(114, 249)).toBe('SW')
|
||||
})
|
||||
|
||||
test('should detect SE (bottom-right) corner', () => {
|
||||
// Corner is from (300 - 15, 250 - 15) to (300, 250)
|
||||
expect(node.findResizeDirection(285, 235)).toBe('SE')
|
||||
expect(node.findResizeDirection(290, 240)).toBe('SE')
|
||||
expect(node.findResizeDirection(299, 249)).toBe('SE')
|
||||
})
|
||||
})
|
||||
|
||||
describe('priority', () => {
|
||||
test('corners should have priority over edges', () => {
|
||||
// These points are technically on both corner and edge
|
||||
// Corner should win
|
||||
expect(node.findResizeDirection(100, 84)).toBe('NW') // Not "W"
|
||||
expect(node.findResizeDirection(104, 80)).toBe('NW') // Not "N"
|
||||
})
|
||||
})
|
||||
|
||||
describe('negative cases', () => {
|
||||
test('should return undefined when outside node bounds', () => {
|
||||
expect(node.findResizeDirection(50, 50)).toBeUndefined()
|
||||
expect(node.findResizeDirection(350, 300)).toBeUndefined()
|
||||
expect(node.findResizeDirection(99, 150)).toBeUndefined()
|
||||
expect(node.findResizeDirection(301, 150)).toBeUndefined()
|
||||
})
|
||||
|
||||
test('should return undefined when inside node but not on resize areas', () => {
|
||||
// Center of node (accounting for title bar offset)
|
||||
expect(node.findResizeDirection(200, 165)).toBeUndefined()
|
||||
// Just inside the edge threshold
|
||||
expect(node.findResizeDirection(106, 150)).toBeUndefined()
|
||||
expect(node.findResizeDirection(294, 150)).toBeUndefined()
|
||||
expect(node.findResizeDirection(150, 86)).toBeUndefined() // 80 + 6
|
||||
expect(node.findResizeDirection(150, 244)).toBeUndefined()
|
||||
})
|
||||
|
||||
test('should return undefined when node is not resizable', () => {
|
||||
node.resizable = false
|
||||
expect(node.findResizeDirection(100, 100)).toBeUndefined()
|
||||
expect(node.findResizeDirection(300, 250)).toBeUndefined()
|
||||
expect(node.findResizeDirection(150, 100)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
test('should handle nodes at origin', () => {
|
||||
node.pos = [0, 0]
|
||||
node.size = [100, 100]
|
||||
|
||||
// Update boundingRect with new position/size
|
||||
const mockCtx = {} as CanvasRenderingContext2D
|
||||
node.updateArea(mockCtx)
|
||||
|
||||
expect(node.findResizeDirection(0, -20)).toBe('NW') // Account for title bar
|
||||
expect(node.findResizeDirection(99, 99)).toBe('SE') // Bottom-right corner (100-1, 100-1)
|
||||
})
|
||||
|
||||
test('should handle very small nodes', () => {
|
||||
node.size = [20, 20] // Smaller than corner size
|
||||
|
||||
// Update boundingRect with new size
|
||||
const mockCtx = {} as CanvasRenderingContext2D
|
||||
node.updateArea(mockCtx)
|
||||
|
||||
// Corners still work (accounting for title bar offset)
|
||||
expect(node.findResizeDirection(100, 80)).toBe('NW')
|
||||
expect(node.findResizeDirection(119, 119)).toBe('SE')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('resizeEdgeSize static property', () => {
|
||||
test('should have default value of 5', () => {
|
||||
expect(LGraphNode.resizeEdgeSize).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resizeHandleSize static property', () => {
|
||||
test('should have default value of 15', () => {
|
||||
expect(LGraphNode.resizeHandleSize).toBe(15)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,774 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, vi } from 'vitest'
|
||||
|
||||
import type { INodeInputSlot, Point } from '@/lib/litegraph/src/interfaces'
|
||||
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { NodeInputSlot } from '@/lib/litegraph/src/node/NodeInputSlot'
|
||||
import { NodeOutputSlot } from '@/lib/litegraph/src/node/NodeOutputSlot'
|
||||
import type { ISerialisedNode } from '@/lib/litegraph/src/types/serialisation'
|
||||
|
||||
import { test } from './testExtensions'
|
||||
|
||||
function getMockISerialisedNode(
|
||||
data: Partial<ISerialisedNode>
|
||||
): ISerialisedNode {
|
||||
return Object.assign(
|
||||
{
|
||||
id: 0,
|
||||
flags: {},
|
||||
type: 'TestNode',
|
||||
pos: [100, 100],
|
||||
size: [100, 100],
|
||||
order: 0,
|
||||
mode: 0
|
||||
},
|
||||
data
|
||||
)
|
||||
}
|
||||
|
||||
describe('LGraphNode', () => {
|
||||
let node: LGraphNode
|
||||
let origLiteGraph: typeof LiteGraph
|
||||
|
||||
beforeEach(() => {
|
||||
origLiteGraph = Object.assign({}, LiteGraph)
|
||||
// @ts-expect-error TODO: Fix after merge - Classes property not in type
|
||||
delete origLiteGraph.Classes
|
||||
|
||||
Object.assign(LiteGraph, {
|
||||
NODE_TITLE_HEIGHT: 20,
|
||||
NODE_SLOT_HEIGHT: 15,
|
||||
NODE_TEXT_SIZE: 14,
|
||||
DEFAULT_SHADOW_COLOR: 'rgba(0,0,0,0.5)',
|
||||
DEFAULT_GROUP_FONT_SIZE: 24,
|
||||
isValidConnection: vi.fn().mockReturnValue(true)
|
||||
})
|
||||
node = new LGraphNode('Test Node')
|
||||
node.pos = [100, 200]
|
||||
node.size = [150, 100] // Example size
|
||||
|
||||
// Reset mocks if needed
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.assign(LiteGraph, origLiteGraph)
|
||||
})
|
||||
|
||||
test('should serialize position/size correctly', () => {
|
||||
const node = new LGraphNode('TestNode')
|
||||
node.pos = [10, 20]
|
||||
node.size = [30, 40]
|
||||
const json = node.serialize()
|
||||
expect(json.pos).toEqual([10, 20])
|
||||
expect(json.size).toEqual([30, 40])
|
||||
|
||||
const configureData: ISerialisedNode = {
|
||||
id: node.id,
|
||||
type: node.type,
|
||||
pos: [50, 60],
|
||||
size: [70, 80],
|
||||
flags: {},
|
||||
order: node.order,
|
||||
mode: node.mode,
|
||||
inputs: node.inputs?.map((i) => ({
|
||||
name: i.name,
|
||||
type: i.type,
|
||||
link: i.link
|
||||
})),
|
||||
outputs: node.outputs?.map((o) => ({
|
||||
name: o.name,
|
||||
type: o.type,
|
||||
links: o.links,
|
||||
slot_index: o.slot_index
|
||||
}))
|
||||
}
|
||||
node.configure(configureData)
|
||||
expect(node.pos).toEqual(new Float32Array([50, 60]))
|
||||
expect(node.size).toEqual(new Float32Array([70, 80]))
|
||||
})
|
||||
|
||||
test('should configure inputs correctly', () => {
|
||||
const node = new LGraphNode('TestNode')
|
||||
node.configure(
|
||||
getMockISerialisedNode({
|
||||
id: 0,
|
||||
inputs: [{ name: 'TestInput', type: 'number', link: null }]
|
||||
})
|
||||
)
|
||||
expect(node.inputs.length).toEqual(1)
|
||||
expect(node.inputs[0].name).toEqual('TestInput')
|
||||
expect(node.inputs[0].link).toEqual(null)
|
||||
expect(node.inputs[0]).instanceOf(NodeInputSlot)
|
||||
|
||||
// Should not override existing inputs
|
||||
node.configure(getMockISerialisedNode({ id: 1 }))
|
||||
expect(node.id).toEqual(1)
|
||||
expect(node.inputs.length).toEqual(1)
|
||||
})
|
||||
|
||||
test('should configure outputs correctly', () => {
|
||||
const node = new LGraphNode('TestNode')
|
||||
node.configure(
|
||||
getMockISerialisedNode({
|
||||
id: 0,
|
||||
outputs: [{ name: 'TestOutput', type: 'number', links: [] }]
|
||||
})
|
||||
)
|
||||
expect(node.outputs.length).toEqual(1)
|
||||
expect(node.outputs[0].name).toEqual('TestOutput')
|
||||
expect(node.outputs[0].type).toEqual('number')
|
||||
expect(node.outputs[0].links).toEqual([])
|
||||
expect(node.outputs[0]).instanceOf(NodeOutputSlot)
|
||||
|
||||
// Should not override existing outputs
|
||||
node.configure(getMockISerialisedNode({ id: 1 }))
|
||||
expect(node.id).toEqual(1)
|
||||
expect(node.outputs.length).toEqual(1)
|
||||
})
|
||||
|
||||
describe('Disconnect I/O Slots', () => {
|
||||
test('should disconnect input correctly', () => {
|
||||
const node1 = new LGraphNode('SourceNode')
|
||||
const node2 = new LGraphNode('TargetNode')
|
||||
|
||||
// Configure nodes with input/output slots
|
||||
node1.configure(
|
||||
getMockISerialisedNode({
|
||||
id: 1,
|
||||
outputs: [{ name: 'Output1', type: 'number', links: [] }]
|
||||
})
|
||||
)
|
||||
node2.configure(
|
||||
getMockISerialisedNode({
|
||||
id: 2,
|
||||
inputs: [{ name: 'Input1', type: 'number', link: null }]
|
||||
})
|
||||
)
|
||||
|
||||
// Create a graph and add nodes to it
|
||||
const graph = new LGraph()
|
||||
graph.add(node1)
|
||||
graph.add(node2)
|
||||
|
||||
// Connect the nodes
|
||||
const link = node1.connect(0, node2, 0)
|
||||
expect(link).not.toBeNull()
|
||||
expect(node2.inputs[0].link).toBe(link?.id)
|
||||
expect(node1.outputs[0].links).toContain(link?.id)
|
||||
|
||||
// Test disconnecting by slot number
|
||||
const disconnected = node2.disconnectInput(0)
|
||||
expect(disconnected).toBe(true)
|
||||
expect(node2.inputs[0].link).toBeNull()
|
||||
expect(node1.outputs[0].links?.length).toBe(0)
|
||||
expect(graph._links.has(link?.id ?? -1)).toBe(false)
|
||||
|
||||
// Test disconnecting by slot name
|
||||
node1.connect(0, node2, 0)
|
||||
const disconnectedByName = node2.disconnectInput('Input1')
|
||||
expect(disconnectedByName).toBe(true)
|
||||
expect(node2.inputs[0].link).toBeNull()
|
||||
|
||||
// Test disconnecting non-existent slot
|
||||
const invalidDisconnect = node2.disconnectInput(999)
|
||||
expect(invalidDisconnect).toBe(false)
|
||||
|
||||
// Test disconnecting already disconnected input
|
||||
const alreadyDisconnected = node2.disconnectInput(0)
|
||||
expect(alreadyDisconnected).toBe(true)
|
||||
})
|
||||
|
||||
test('should disconnect output correctly', () => {
|
||||
const sourceNode = new LGraphNode('SourceNode')
|
||||
const targetNode1 = new LGraphNode('TargetNode1')
|
||||
const targetNode2 = new LGraphNode('TargetNode2')
|
||||
|
||||
// Configure nodes with input/output slots
|
||||
sourceNode.configure(
|
||||
getMockISerialisedNode({
|
||||
id: 1,
|
||||
outputs: [
|
||||
{ name: 'Output1', type: 'number', links: [] },
|
||||
{ name: 'Output2', type: 'number', links: [] }
|
||||
]
|
||||
})
|
||||
)
|
||||
targetNode1.configure(
|
||||
getMockISerialisedNode({
|
||||
id: 2,
|
||||
inputs: [{ name: 'Input1', type: 'number', link: null }]
|
||||
})
|
||||
)
|
||||
targetNode2.configure(
|
||||
getMockISerialisedNode({
|
||||
id: 3,
|
||||
inputs: [{ name: 'Input1', type: 'number', link: null }]
|
||||
})
|
||||
)
|
||||
|
||||
// Create a graph and add nodes to it
|
||||
const graph = new LGraph()
|
||||
graph.add(sourceNode)
|
||||
graph.add(targetNode1)
|
||||
graph.add(targetNode2)
|
||||
|
||||
// Connect multiple nodes to the same output
|
||||
const link1 = sourceNode.connect(0, targetNode1, 0)
|
||||
const link2 = sourceNode.connect(0, targetNode2, 0)
|
||||
expect(link1).not.toBeNull()
|
||||
expect(link2).not.toBeNull()
|
||||
expect(sourceNode.outputs[0].links?.length).toBe(2)
|
||||
|
||||
// Test disconnecting specific target node
|
||||
const disconnectedSpecific = sourceNode.disconnectOutput(0, targetNode1)
|
||||
expect(disconnectedSpecific).toBe(true)
|
||||
expect(targetNode1.inputs[0].link).toBeNull()
|
||||
expect(sourceNode.outputs[0].links?.length).toBe(1)
|
||||
expect(graph._links.has(link1?.id ?? -1)).toBe(false)
|
||||
expect(graph._links.has(link2?.id ?? -1)).toBe(true)
|
||||
|
||||
// Test disconnecting by slot name
|
||||
const link3 = sourceNode.connect(1, targetNode1, 0)
|
||||
expect(link3).not.toBeNull()
|
||||
const disconnectedByName = sourceNode.disconnectOutput(
|
||||
'Output2',
|
||||
targetNode1
|
||||
)
|
||||
expect(disconnectedByName).toBe(true)
|
||||
expect(targetNode1.inputs[0].link).toBeNull()
|
||||
expect(sourceNode.outputs[1].links?.length).toBe(0)
|
||||
|
||||
// Test disconnecting all connections from an output
|
||||
const link4 = sourceNode.connect(0, targetNode1, 0)
|
||||
expect(link4).not.toBeNull()
|
||||
expect(sourceNode.outputs[0].links?.length).toBe(2)
|
||||
const disconnectedAll = sourceNode.disconnectOutput(0)
|
||||
expect(disconnectedAll).toBe(true)
|
||||
expect(sourceNode.outputs[0].links).toBeNull()
|
||||
expect(targetNode1.inputs[0].link).toBeNull()
|
||||
expect(targetNode2.inputs[0].link).toBeNull()
|
||||
expect(graph._links.has(link2?.id ?? -1)).toBe(false)
|
||||
expect(graph._links.has(link4?.id ?? -1)).toBe(false)
|
||||
|
||||
// Test disconnecting non-existent slot
|
||||
const invalidDisconnect = sourceNode.disconnectOutput(999)
|
||||
expect(invalidDisconnect).toBe(false)
|
||||
|
||||
// Test disconnecting already disconnected output
|
||||
const alreadyDisconnected = sourceNode.disconnectOutput(0)
|
||||
expect(alreadyDisconnected).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getInputPos and getOutputPos', () => {
|
||||
test('should handle collapsed nodes correctly', () => {
|
||||
const node = new LGraphNode('TestNode') as unknown as Omit<
|
||||
LGraphNode,
|
||||
'boundingRect'
|
||||
> & { boundingRect: Float32Array }
|
||||
node.pos = [100, 100]
|
||||
node.size = [100, 100]
|
||||
node.boundingRect[0] = 100
|
||||
node.boundingRect[1] = 100
|
||||
node.boundingRect[2] = 100
|
||||
node.boundingRect[3] = 100
|
||||
node.configure(
|
||||
getMockISerialisedNode({
|
||||
id: 1,
|
||||
inputs: [{ name: 'Input1', type: 'number', link: null }],
|
||||
outputs: [{ name: 'Output1', type: 'number', links: [] }]
|
||||
})
|
||||
)
|
||||
|
||||
// Collapse the node
|
||||
node.flags.collapsed = true
|
||||
|
||||
// Get positions in collapsed state
|
||||
const inputPos = node.getInputPos(0)
|
||||
const outputPos = node.getOutputPos(0)
|
||||
|
||||
expect(inputPos).toEqual([100, 90])
|
||||
expect(outputPos).toEqual([180, 90])
|
||||
})
|
||||
|
||||
test('should return correct positions for input and output slots', () => {
|
||||
const node = new LGraphNode('TestNode')
|
||||
node.pos = [100, 100]
|
||||
node.size = [100, 100]
|
||||
node.configure(
|
||||
getMockISerialisedNode({
|
||||
id: 1,
|
||||
inputs: [{ name: 'Input1', type: 'number', link: null }],
|
||||
outputs: [{ name: 'Output1', type: 'number', links: [] }]
|
||||
})
|
||||
)
|
||||
|
||||
const inputPos = node.getInputPos(0)
|
||||
const outputPos = node.getOutputPos(0)
|
||||
|
||||
expect(inputPos).toEqual([107.5, 110.5])
|
||||
expect(outputPos).toEqual([193.5, 110.5])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSlotOnPos', () => {
|
||||
test('should return undefined when point is outside node bounds', () => {
|
||||
const node = new LGraphNode('TestNode')
|
||||
node.pos = [100, 100]
|
||||
node.size = [100, 100]
|
||||
node.configure(
|
||||
getMockISerialisedNode({
|
||||
id: 1,
|
||||
inputs: [{ name: 'Input1', type: 'number', link: null }],
|
||||
outputs: [{ name: 'Output1', type: 'number', links: [] }]
|
||||
})
|
||||
)
|
||||
|
||||
// Test point far outside node bounds
|
||||
expect(node.getSlotOnPos([0, 0])).toBeUndefined()
|
||||
// Test point just outside node bounds
|
||||
expect(node.getSlotOnPos([99, 99])).toBeUndefined()
|
||||
})
|
||||
|
||||
test('should detect input slots correctly', () => {
|
||||
const node = new LGraphNode('TestNode') as unknown as Omit<
|
||||
LGraphNode,
|
||||
'boundingRect'
|
||||
> & { boundingRect: Float32Array }
|
||||
node.pos = [100, 100]
|
||||
node.size = [100, 100]
|
||||
node.boundingRect[0] = 100
|
||||
node.boundingRect[1] = 100
|
||||
node.boundingRect[2] = 200
|
||||
node.boundingRect[3] = 200
|
||||
node.configure(
|
||||
getMockISerialisedNode({
|
||||
id: 1,
|
||||
inputs: [
|
||||
{ name: 'Input1', type: 'number', link: null },
|
||||
{ name: 'Input2', type: 'string', link: null }
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
// Get position of first input slot
|
||||
const inputPos = node.getInputPos(0)
|
||||
// Test point directly on input slot
|
||||
const slot = node.getSlotOnPos(inputPos)
|
||||
expect(slot).toBeDefined()
|
||||
expect(slot?.name).toBe('Input1')
|
||||
|
||||
// Test point near but not on input slot
|
||||
expect(node.getSlotOnPos([inputPos[0] - 15, inputPos[1]])).toBeUndefined()
|
||||
})
|
||||
|
||||
test('should detect output slots correctly', () => {
|
||||
const node = new LGraphNode('TestNode') as unknown as Omit<
|
||||
LGraphNode,
|
||||
'boundingRect'
|
||||
> & { boundingRect: Float32Array }
|
||||
node.pos = [100, 100]
|
||||
node.size = [100, 100]
|
||||
node.boundingRect[0] = 100
|
||||
node.boundingRect[1] = 100
|
||||
node.boundingRect[2] = 200
|
||||
node.boundingRect[3] = 200
|
||||
node.configure(
|
||||
getMockISerialisedNode({
|
||||
id: 1,
|
||||
outputs: [
|
||||
{ name: 'Output1', type: 'number', links: [] },
|
||||
{ name: 'Output2', type: 'string', links: [] }
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
// Get position of first output slot
|
||||
const outputPos = node.getOutputPos(0)
|
||||
// Test point directly on output slot
|
||||
const slot = node.getSlotOnPos(outputPos)
|
||||
expect(slot).toBeDefined()
|
||||
expect(slot?.name).toBe('Output1')
|
||||
|
||||
// Test point near but not on output slot
|
||||
const gotslot = node.getSlotOnPos([outputPos[0] + 30, outputPos[1]])
|
||||
expect(gotslot).toBeUndefined()
|
||||
})
|
||||
|
||||
test('should prioritize input slots over output slots', () => {
|
||||
const node = new LGraphNode('TestNode') as unknown as Omit<
|
||||
LGraphNode,
|
||||
'boundingRect'
|
||||
> & { boundingRect: Float32Array }
|
||||
node.pos = [100, 100]
|
||||
node.size = [100, 100]
|
||||
node.boundingRect[0] = 100
|
||||
node.boundingRect[1] = 100
|
||||
node.boundingRect[2] = 200
|
||||
node.boundingRect[3] = 200
|
||||
node.configure(
|
||||
getMockISerialisedNode({
|
||||
id: 1,
|
||||
inputs: [{ name: 'Input1', type: 'number', link: null }],
|
||||
outputs: [{ name: 'Output1', type: 'number', links: [] }]
|
||||
})
|
||||
)
|
||||
|
||||
// Get positions of first input and output slots
|
||||
const inputPos = node.getInputPos(0)
|
||||
|
||||
// Test point that could theoretically hit both slots
|
||||
// Should return the input slot due to priority
|
||||
const slot = node.getSlotOnPos(inputPos)
|
||||
expect(slot).toBeDefined()
|
||||
expect(slot?.name).toBe('Input1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('LGraphNode slot positioning', () => {
|
||||
test('should correctly position slots with absolute coordinates', () => {
|
||||
// Setup
|
||||
const node = new LGraphNode('test')
|
||||
node.pos = [100, 100]
|
||||
|
||||
// Add input/output with absolute positions
|
||||
node.addInput('abs-input', 'number')
|
||||
node.inputs[0].pos = [10, 20]
|
||||
|
||||
node.addOutput('abs-output', 'number')
|
||||
node.outputs[0].pos = [50, 30]
|
||||
|
||||
// Test
|
||||
const inputPos = node.getInputPos(0)
|
||||
const outputPos = node.getOutputPos(0)
|
||||
|
||||
// Absolute positions should be relative to node position
|
||||
expect(inputPos).toEqual([110, 120]) // node.pos + slot.pos
|
||||
expect(outputPos).toEqual([150, 130]) // node.pos + slot.pos
|
||||
})
|
||||
|
||||
test('should correctly position default vertical slots', () => {
|
||||
// Setup
|
||||
const node = new LGraphNode('test')
|
||||
node.pos = [100, 100]
|
||||
|
||||
// Add multiple inputs/outputs without absolute positions
|
||||
node.addInput('input1', 'number')
|
||||
node.addInput('input2', 'number')
|
||||
node.addOutput('output1', 'number')
|
||||
node.addOutput('output2', 'number')
|
||||
|
||||
// Calculate expected positions
|
||||
const slotOffset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
|
||||
const slotSpacing = LiteGraph.NODE_SLOT_HEIGHT
|
||||
const nodeWidth = node.size[0]
|
||||
|
||||
// Test input positions
|
||||
expect(node.getInputPos(0)).toEqual([
|
||||
100 + slotOffset,
|
||||
100 + (0 + 0.7) * slotSpacing
|
||||
])
|
||||
expect(node.getInputPos(1)).toEqual([
|
||||
100 + slotOffset,
|
||||
100 + (1 + 0.7) * slotSpacing
|
||||
])
|
||||
|
||||
// Test output positions
|
||||
expect(node.getOutputPos(0)).toEqual([
|
||||
100 + nodeWidth + 1 - slotOffset,
|
||||
100 + (0 + 0.7) * slotSpacing
|
||||
])
|
||||
expect(node.getOutputPos(1)).toEqual([
|
||||
100 + nodeWidth + 1 - slotOffset,
|
||||
100 + (1 + 0.7) * slotSpacing
|
||||
])
|
||||
})
|
||||
|
||||
test('should skip absolute positioned slots when calculating vertical positions', () => {
|
||||
// Setup
|
||||
const node = new LGraphNode('test')
|
||||
node.pos = [100, 100]
|
||||
|
||||
// Add mix of absolute and default positioned slots
|
||||
node.addInput('abs-input', 'number')
|
||||
node.inputs[0].pos = [10, 20]
|
||||
node.addInput('default-input1', 'number')
|
||||
node.addInput('default-input2', 'number')
|
||||
|
||||
const slotOffset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
|
||||
const slotSpacing = LiteGraph.NODE_SLOT_HEIGHT
|
||||
|
||||
// Test: default positioned slots should be consecutive, ignoring absolute positioned ones
|
||||
expect(node.getInputPos(1)).toEqual([
|
||||
100 + slotOffset,
|
||||
100 + (0 + 0.7) * slotSpacing // First default slot starts at index 0
|
||||
])
|
||||
expect(node.getInputPos(2)).toEqual([
|
||||
100 + slotOffset,
|
||||
100 + (1 + 0.7) * slotSpacing // Second default slot at index 1
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('widget serialization', () => {
|
||||
test('should only serialize widgets with serialize flag not set to false', () => {
|
||||
const node = new LGraphNode('TestNode')
|
||||
node.serialize_widgets = true
|
||||
|
||||
// Add widgets with different serialization settings
|
||||
node.addWidget('number', 'serializable1', 1, null)
|
||||
node.addWidget('number', 'serializable2', 2, null)
|
||||
node.addWidget('number', 'non-serializable', 3, null)
|
||||
expect(node.widgets?.length).toBe(3)
|
||||
|
||||
// Set serialize flag to false for the last widget
|
||||
node.widgets![2].serialize = false
|
||||
|
||||
// Set some widget values
|
||||
node.widgets![0].value = 10
|
||||
node.widgets![1].value = 20
|
||||
node.widgets![2].value = 30
|
||||
|
||||
// Serialize the node
|
||||
const serialized = node.serialize()
|
||||
|
||||
// Check that only serializable widgets' values are included
|
||||
expect(serialized.widgets_values).toEqual([10, 20])
|
||||
expect(serialized.widgets_values).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('should only configure widgets with serialize flag not set to false', () => {
|
||||
const node = new LGraphNode('TestNode')
|
||||
node.serialize_widgets = true
|
||||
|
||||
node.addWidget('number', 'non-serializable', 1, null)
|
||||
node.addWidget('number', 'serializable1', 2, null)
|
||||
expect(node.widgets?.length).toBe(2)
|
||||
|
||||
node.widgets![0].serialize = false
|
||||
node.configure(
|
||||
getMockISerialisedNode({
|
||||
id: 1,
|
||||
type: 'TestNode',
|
||||
pos: [100, 100],
|
||||
size: [100, 100],
|
||||
properties: {},
|
||||
widgets_values: [100]
|
||||
})
|
||||
)
|
||||
|
||||
expect(node.widgets![0].value).toBe(1)
|
||||
expect(node.widgets![1].value).toBe(100)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getInputSlotPos', () => {
|
||||
let inputSlot: INodeInputSlot
|
||||
|
||||
beforeEach(() => {
|
||||
inputSlot = {
|
||||
name: 'test_in',
|
||||
type: 'string',
|
||||
link: null,
|
||||
boundingRect: new Float32Array([0, 0, 0, 0])
|
||||
}
|
||||
})
|
||||
test('should return position based on title height when collapsed', () => {
|
||||
node.flags.collapsed = true
|
||||
const expectedPos: Point = [100, 200 - LiteGraph.NODE_TITLE_HEIGHT * 0.5]
|
||||
expect(node.getInputSlotPos(inputSlot)).toEqual(expectedPos)
|
||||
})
|
||||
|
||||
test('should return position based on input.pos when defined and not collapsed', () => {
|
||||
node.flags.collapsed = false
|
||||
inputSlot.pos = [10, 50]
|
||||
node.inputs = [inputSlot]
|
||||
const expectedPos: Point = [100 + 10, 200 + 50]
|
||||
expect(node.getInputSlotPos(inputSlot)).toEqual(expectedPos)
|
||||
})
|
||||
|
||||
test('should return default vertical position when input.pos is undefined and not collapsed', () => {
|
||||
node.flags.collapsed = false
|
||||
const inputSlot2 = {
|
||||
name: 'test_in_2',
|
||||
type: 'number',
|
||||
link: null,
|
||||
boundingRect: new Float32Array([0, 0, 0, 0])
|
||||
}
|
||||
node.inputs = [inputSlot, inputSlot2]
|
||||
const slotIndex = 0
|
||||
const nodeOffsetY = (node.constructor as any).slot_start_y || 0
|
||||
const expectedY =
|
||||
200 + (slotIndex + 0.7) * LiteGraph.NODE_SLOT_HEIGHT + nodeOffsetY
|
||||
const expectedX = 100 + LiteGraph.NODE_SLOT_HEIGHT * 0.5
|
||||
expect(node.getInputSlotPos(inputSlot)).toEqual([expectedX, expectedY])
|
||||
const slotIndex2 = 1
|
||||
const expectedY2 =
|
||||
200 + (slotIndex2 + 0.7) * LiteGraph.NODE_SLOT_HEIGHT + nodeOffsetY
|
||||
expect(node.getInputSlotPos(inputSlot2)).toEqual([expectedX, expectedY2])
|
||||
})
|
||||
|
||||
test('should return default vertical position including slot_start_y when defined', () => {
|
||||
;(node.constructor as any).slot_start_y = 25
|
||||
node.flags.collapsed = false
|
||||
node.inputs = [inputSlot]
|
||||
const slotIndex = 0
|
||||
const nodeOffsetY = 25
|
||||
const expectedY =
|
||||
200 + (slotIndex + 0.7) * LiteGraph.NODE_SLOT_HEIGHT + nodeOffsetY
|
||||
const expectedX = 100 + LiteGraph.NODE_SLOT_HEIGHT * 0.5
|
||||
expect(node.getInputSlotPos(inputSlot)).toEqual([expectedX, expectedY])
|
||||
delete (node.constructor as any).slot_start_y
|
||||
})
|
||||
})
|
||||
|
||||
describe('getInputPos', () => {
|
||||
test('should call getInputSlotPos with the correct input slot from inputs array', () => {
|
||||
const input0: INodeInputSlot = {
|
||||
name: 'in0',
|
||||
type: 'string',
|
||||
link: null,
|
||||
boundingRect: new Float32Array([0, 0, 0, 0])
|
||||
}
|
||||
const input1: INodeInputSlot = {
|
||||
name: 'in1',
|
||||
type: 'number',
|
||||
link: null,
|
||||
boundingRect: new Float32Array([0, 0, 0, 0]),
|
||||
pos: [5, 45]
|
||||
}
|
||||
node.inputs = [input0, input1]
|
||||
const spy = vi.spyOn(node, 'getInputSlotPos')
|
||||
node.getInputPos(1)
|
||||
expect(spy).toHaveBeenCalledWith(input1)
|
||||
const expectedPos: Point = [100 + 5, 200 + 45]
|
||||
expect(node.getInputPos(1)).toEqual(expectedPos)
|
||||
spy.mockClear()
|
||||
node.getInputPos(0)
|
||||
expect(spy).toHaveBeenCalledWith(input0)
|
||||
const slotIndex = 0
|
||||
const nodeOffsetY = (node.constructor as any).slot_start_y || 0
|
||||
const expectedDefaultY =
|
||||
200 + (slotIndex + 0.7) * LiteGraph.NODE_SLOT_HEIGHT + nodeOffsetY
|
||||
const expectedDefaultX = 100 + LiteGraph.NODE_SLOT_HEIGHT * 0.5
|
||||
expect(node.getInputPos(0)).toEqual([expectedDefaultX, expectedDefaultY])
|
||||
spy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeInput/removeOutput on copied nodes', () => {
|
||||
beforeEach(() => {
|
||||
// Register a test node type so clone() can work
|
||||
LiteGraph.registerNodeType('TestNode', LGraphNode)
|
||||
})
|
||||
|
||||
test('should NOT throw error when calling removeInput on a copied node without graph', () => {
|
||||
// Create a node with an input
|
||||
const originalNode = new LGraphNode('Test Node')
|
||||
originalNode.type = 'TestNode'
|
||||
originalNode.addInput('input1', 'number')
|
||||
|
||||
// Clone the node (which creates a node without graph reference)
|
||||
const copiedNode = originalNode.clone()
|
||||
|
||||
// This should NOT throw anymore - we can remove inputs on nodes without graph
|
||||
expect(() => copiedNode!.removeInput(0)).not.toThrow()
|
||||
expect(copiedNode!.inputs).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('should NOT throw error when calling removeOutput on a copied node without graph', () => {
|
||||
// Create a node with an output
|
||||
const originalNode = new LGraphNode('Test Node')
|
||||
originalNode.type = 'TestNode'
|
||||
originalNode.addOutput('output1', 'number')
|
||||
|
||||
// Clone the node (which creates a node without graph reference)
|
||||
const copiedNode = originalNode.clone()
|
||||
|
||||
// This should NOT throw anymore - we can remove outputs on nodes without graph
|
||||
expect(() => copiedNode!.removeOutput(0)).not.toThrow()
|
||||
expect(copiedNode!.outputs).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('should skip disconnectInput/disconnectOutput when node has no graph', () => {
|
||||
// Create nodes with input/output
|
||||
const nodeWithInput = new LGraphNode('Test Node')
|
||||
nodeWithInput.type = 'TestNode'
|
||||
nodeWithInput.addInput('input1', 'number')
|
||||
|
||||
const nodeWithOutput = new LGraphNode('Test Node')
|
||||
nodeWithOutput.type = 'TestNode'
|
||||
nodeWithOutput.addOutput('output1', 'number')
|
||||
|
||||
// Clone nodes (no graph reference)
|
||||
const clonedInput = nodeWithInput.clone()
|
||||
const clonedOutput = nodeWithOutput.clone()
|
||||
|
||||
// Mock disconnect methods to verify they're not called
|
||||
clonedInput!.disconnectInput = vi.fn()
|
||||
clonedOutput!.disconnectOutput = vi.fn()
|
||||
|
||||
// Remove input/output - disconnect methods should NOT be called
|
||||
clonedInput!.removeInput(0)
|
||||
clonedOutput!.removeOutput(0)
|
||||
|
||||
expect(clonedInput!.disconnectInput).not.toHaveBeenCalled()
|
||||
expect(clonedOutput!.disconnectOutput).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should be able to removeInput on a copied node after adding to graph', () => {
|
||||
// Create a graph and a node with an input
|
||||
const graph = new LGraph()
|
||||
const originalNode = new LGraphNode('Test Node')
|
||||
originalNode.type = 'TestNode'
|
||||
originalNode.addInput('input1', 'number')
|
||||
|
||||
// Clone the node and add to graph
|
||||
const copiedNode = originalNode.clone()
|
||||
expect(copiedNode).not.toBeNull()
|
||||
graph.add(copiedNode!)
|
||||
|
||||
// This should work now that the node has a graph reference
|
||||
expect(() => copiedNode!.removeInput(0)).not.toThrow()
|
||||
expect(copiedNode!.inputs).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('should be able to removeOutput on a copied node after adding to graph', () => {
|
||||
// Create a graph and a node with an output
|
||||
const graph = new LGraph()
|
||||
const originalNode = new LGraphNode('Test Node')
|
||||
originalNode.type = 'TestNode'
|
||||
originalNode.addOutput('output1', 'number')
|
||||
|
||||
// Clone the node and add to graph
|
||||
const copiedNode = originalNode.clone()
|
||||
expect(copiedNode).not.toBeNull()
|
||||
graph.add(copiedNode!)
|
||||
|
||||
// This should work now that the node has a graph reference
|
||||
expect(() => copiedNode!.removeOutput(0)).not.toThrow()
|
||||
expect(copiedNode!.outputs).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('RerouteNode clone scenario - should be able to removeOutput and addOutput on cloned node', () => {
|
||||
// This simulates the RerouteNode clone method behavior
|
||||
const originalNode = new LGraphNode('Reroute')
|
||||
originalNode.type = 'TestNode'
|
||||
originalNode.addOutput('*', '*')
|
||||
|
||||
// Clone the node (simulating RerouteNode.clone)
|
||||
const clonedNode = originalNode.clone()
|
||||
expect(clonedNode).not.toBeNull()
|
||||
|
||||
// This should not throw - we should be able to modify outputs on a cloned node
|
||||
expect(() => {
|
||||
clonedNode!.removeOutput(0)
|
||||
clonedNode!.addOutput('renamed', '*')
|
||||
}).not.toThrow()
|
||||
|
||||
expect(clonedNode!.outputs).toHaveLength(1)
|
||||
expect(clonedNode!.outputs[0].name).toBe('renamed')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,298 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphButton } from '@/lib/litegraph/src/LGraphButton'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
describe('LGraphNode Title Buttons', () => {
|
||||
describe('addTitleButton', () => {
|
||||
it('should add a title button to the node', () => {
|
||||
const node = new LGraphNode('Test Node')
|
||||
|
||||
const button = node.addTitleButton({
|
||||
name: 'test_button',
|
||||
text: 'X',
|
||||
fgColor: '#FF0000'
|
||||
})
|
||||
|
||||
expect(button).toBeInstanceOf(LGraphButton)
|
||||
expect(button.name).toBe('test_button')
|
||||
expect(button.text).toBe('X')
|
||||
expect(button.fgColor).toBe('#FF0000')
|
||||
expect(node.title_buttons).toHaveLength(1)
|
||||
expect(node.title_buttons[0]).toBe(button)
|
||||
})
|
||||
|
||||
it('should add multiple title buttons', () => {
|
||||
const node = new LGraphNode('Test Node')
|
||||
|
||||
const button1 = node.addTitleButton({ name: 'button1', text: 'A' })
|
||||
const button2 = node.addTitleButton({ name: 'button2', text: 'B' })
|
||||
const button3 = node.addTitleButton({ name: 'button3', text: 'C' })
|
||||
|
||||
expect(node.title_buttons).toHaveLength(3)
|
||||
expect(node.title_buttons[0]).toBe(button1)
|
||||
expect(node.title_buttons[1]).toBe(button2)
|
||||
expect(node.title_buttons[2]).toBe(button3)
|
||||
})
|
||||
|
||||
it('should create buttons with default options', () => {
|
||||
const node = new LGraphNode('Test Node')
|
||||
|
||||
// @ts-expect-error TODO: Fix after merge - addTitleButton type issues
|
||||
const button = node.addTitleButton({})
|
||||
|
||||
expect(button).toBeInstanceOf(LGraphButton)
|
||||
expect(button.name).toBeUndefined()
|
||||
expect(node.title_buttons).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('onMouseDown with title buttons', () => {
|
||||
it('should handle click on title button', () => {
|
||||
const node = new LGraphNode('Test Node')
|
||||
node.pos = [100, 200]
|
||||
node.size = [180, 60]
|
||||
|
||||
const button = node.addTitleButton({
|
||||
name: 'close_button',
|
||||
text: 'X',
|
||||
// @ts-expect-error TODO: Fix after merge - visible property not defined in type
|
||||
visible: true
|
||||
})
|
||||
|
||||
// Mock button dimensions
|
||||
button.getWidth = vi.fn().mockReturnValue(20)
|
||||
button.height = 16
|
||||
|
||||
// Simulate button being drawn to populate _last_area
|
||||
// Button is drawn at node-relative coordinates
|
||||
// Button x: node.size[0] - 5 - button_width = 180 - 5 - 20 = 155
|
||||
// Button y: -LiteGraph.NODE_TITLE_HEIGHT = -30
|
||||
button._last_area[0] = 155
|
||||
button._last_area[1] = -30
|
||||
button._last_area[2] = 20
|
||||
button._last_area[3] = 16
|
||||
|
||||
const canvas = {
|
||||
ctx: {} as CanvasRenderingContext2D,
|
||||
dispatch: vi.fn()
|
||||
} as unknown as LGraphCanvas
|
||||
|
||||
const event = {
|
||||
canvasX: 265, // node.pos[0] + node.size[0] - 5 - button_width = 100 + 180 - 5 - 20 = 255, click in middle = 265
|
||||
canvasY: 178 // node.pos[1] - LiteGraph.NODE_TITLE_HEIGHT + 8 = 200 - 30 + 8 = 178
|
||||
} as any
|
||||
|
||||
// Calculate node-relative position for the click
|
||||
const clickPosRelativeToNode: [number, number] = [
|
||||
265 - node.pos[0], // 265 - 100 = 165
|
||||
178 - node.pos[1] // 178 - 200 = -22
|
||||
]
|
||||
|
||||
// Simulate the click - onMouseDown should detect button click
|
||||
// @ts-expect-error TODO: Fix after merge - onMouseDown method type issues
|
||||
const handled = node.onMouseDown(event, clickPosRelativeToNode, canvas)
|
||||
|
||||
expect(handled).toBe(true)
|
||||
expect(canvas.dispatch).toHaveBeenCalledWith(
|
||||
'litegraph:node-title-button-clicked',
|
||||
{
|
||||
node: node,
|
||||
button: button
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should not handle click outside title buttons', () => {
|
||||
const node = new LGraphNode('Test Node')
|
||||
node.pos = [100, 200]
|
||||
node.size = [180, 60]
|
||||
|
||||
const button = node.addTitleButton({
|
||||
name: 'test_button',
|
||||
text: 'T',
|
||||
// @ts-expect-error TODO: Fix after merge - visible property not defined in type
|
||||
visible: true
|
||||
})
|
||||
|
||||
button.getWidth = vi.fn().mockReturnValue(20)
|
||||
button.height = 16
|
||||
|
||||
// Simulate button being drawn at node-relative coordinates
|
||||
button._last_area[0] = 155 // 180 - 5 - 20
|
||||
button._last_area[1] = -30 // -NODE_TITLE_HEIGHT
|
||||
button._last_area[2] = 20
|
||||
button._last_area[3] = 16
|
||||
|
||||
const canvas = {
|
||||
ctx: {} as CanvasRenderingContext2D,
|
||||
dispatch: vi.fn()
|
||||
} as unknown as LGraphCanvas
|
||||
|
||||
const event = {
|
||||
canvasX: 150, // Click in the middle of the node, not on button
|
||||
canvasY: 180
|
||||
} as any
|
||||
|
||||
// Calculate node-relative position
|
||||
const clickPosRelativeToNode: [number, number] = [
|
||||
150 - node.pos[0], // 150 - 100 = 50
|
||||
180 - node.pos[1] // 180 - 200 = -20
|
||||
]
|
||||
|
||||
// @ts-expect-error TODO: Fix after merge - onMouseDown method type issues
|
||||
const handled = node.onMouseDown(event, clickPosRelativeToNode, canvas)
|
||||
|
||||
expect(handled).toBe(false)
|
||||
expect(canvas.dispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle multiple buttons correctly', () => {
|
||||
const node = new LGraphNode('Test Node')
|
||||
node.pos = [100, 200]
|
||||
node.size = [200, 60]
|
||||
|
||||
const button1 = node.addTitleButton({
|
||||
name: 'button1',
|
||||
text: 'A',
|
||||
// @ts-expect-error TODO: Fix after merge - visible property not defined in type
|
||||
visible: true
|
||||
})
|
||||
|
||||
const button2 = node.addTitleButton({
|
||||
name: 'button2',
|
||||
text: 'B',
|
||||
// @ts-expect-error TODO: Fix after merge - visible property not defined in type
|
||||
visible: true
|
||||
})
|
||||
|
||||
// Mock button dimensions
|
||||
button1.getWidth = vi.fn().mockReturnValue(20)
|
||||
button2.getWidth = vi.fn().mockReturnValue(20)
|
||||
button1.height = button2.height = 16
|
||||
|
||||
// Simulate buttons being drawn at node-relative coordinates
|
||||
// First button (rightmost): 200 - 5 - 20 = 175
|
||||
button1._last_area[0] = 175
|
||||
button1._last_area[1] = -30 // -NODE_TITLE_HEIGHT
|
||||
button1._last_area[2] = 20
|
||||
button1._last_area[3] = 16
|
||||
|
||||
// Second button: 175 - 5 - 20 = 150
|
||||
button2._last_area[0] = 150
|
||||
button2._last_area[1] = -30 // -NODE_TITLE_HEIGHT
|
||||
button2._last_area[2] = 20
|
||||
button2._last_area[3] = 16
|
||||
|
||||
const canvas = {
|
||||
ctx: {} as CanvasRenderingContext2D,
|
||||
dispatch: vi.fn()
|
||||
} as unknown as LGraphCanvas
|
||||
|
||||
// Click on second button (leftmost, since they're right-aligned)
|
||||
const titleY = 170 + 8 // node.pos[1] - NODE_TITLE_HEIGHT + 8 = 200 - 30 + 8 = 178
|
||||
const event = {
|
||||
canvasX: 255, // First button at: 100 + 200 - 5 - 20 = 275, Second button at: 275 - 5 - 20 = 250, click in middle = 255
|
||||
canvasY: titleY
|
||||
} as any
|
||||
|
||||
// Calculate node-relative position
|
||||
const clickPosRelativeToNode: [number, number] = [
|
||||
255 - node.pos[0], // 255 - 100 = 155
|
||||
titleY - node.pos[1] // 178 - 200 = -22
|
||||
]
|
||||
|
||||
// @ts-expect-error onMouseDown possibly undefined
|
||||
const handled = node.onMouseDown(event, clickPosRelativeToNode, canvas)
|
||||
|
||||
expect(handled).toBe(true)
|
||||
expect(canvas.dispatch).toHaveBeenCalledWith(
|
||||
'litegraph:node-title-button-clicked',
|
||||
{
|
||||
node: node,
|
||||
button: button2
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should skip invisible buttons', () => {
|
||||
const node = new LGraphNode('Test Node')
|
||||
node.pos = [100, 200]
|
||||
node.size = [180, 60]
|
||||
|
||||
const button1 = node.addTitleButton({
|
||||
name: 'invisible_button',
|
||||
text: '' // Empty text makes it invisible
|
||||
})
|
||||
|
||||
const button2 = node.addTitleButton({
|
||||
name: 'visible_button',
|
||||
text: 'V'
|
||||
})
|
||||
|
||||
button1.getWidth = vi.fn().mockReturnValue(20)
|
||||
button2.getWidth = vi.fn().mockReturnValue(20)
|
||||
button1.height = button2.height = 16
|
||||
|
||||
// Simulate buttons being drawn at node-relative coordinates
|
||||
// Only visible button gets drawn area
|
||||
button2._last_area[0] = 155 // 180 - 5 - 20
|
||||
button2._last_area[1] = -30 // -NODE_TITLE_HEIGHT
|
||||
button2._last_area[2] = 20
|
||||
button2._last_area[3] = 16
|
||||
|
||||
const canvas = {
|
||||
ctx: {} as CanvasRenderingContext2D,
|
||||
dispatch: vi.fn()
|
||||
} as unknown as LGraphCanvas
|
||||
|
||||
// Click where the visible button is (invisible button is skipped)
|
||||
const titleY = 178 // node.pos[1] - NODE_TITLE_HEIGHT + 8 = 200 - 30 + 8 = 178
|
||||
const event = {
|
||||
canvasX: 265, // Visible button at: 100 + 180 - 5 - 20 = 255, click in middle = 265
|
||||
canvasY: titleY
|
||||
} as any
|
||||
|
||||
// Calculate node-relative position
|
||||
const clickPosRelativeToNode: [number, number] = [
|
||||
265 - node.pos[0], // 265 - 100 = 165
|
||||
titleY - node.pos[1] // 178 - 200 = -22
|
||||
]
|
||||
|
||||
// @ts-expect-error onMouseDown possibly undefined
|
||||
const handled = node.onMouseDown(event, clickPosRelativeToNode, canvas)
|
||||
|
||||
expect(handled).toBe(true)
|
||||
expect(canvas.dispatch).toHaveBeenCalledWith(
|
||||
'litegraph:node-title-button-clicked',
|
||||
{
|
||||
node: node,
|
||||
button: button2 // Should click visible button, not invisible
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('onTitleButtonClick', () => {
|
||||
it('should dispatch litegraph:node-title-button-clicked event', () => {
|
||||
const node = new LGraphNode('Test Node')
|
||||
// @ts-expect-error TODO: Fix after merge - LGraphButton constructor type issues
|
||||
const button = new LGraphButton({ name: 'test_button' })
|
||||
|
||||
const canvas = {
|
||||
dispatch: vi.fn()
|
||||
} as unknown as LGraphCanvas
|
||||
|
||||
node.onTitleButtonClick(button, canvas)
|
||||
|
||||
expect(canvas.dispatch).toHaveBeenCalledWith(
|
||||
'litegraph:node-title-button-clicked',
|
||||
{
|
||||
node: node,
|
||||
button: button
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,18 +0,0 @@
|
||||
import { describe } from 'vitest'
|
||||
|
||||
import { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { dirtyTest } from './testExtensions'
|
||||
|
||||
describe('LGraph (constructor only)', () => {
|
||||
dirtyTest(
|
||||
'Matches previous snapshot',
|
||||
({ expect, minimalSerialisableGraph, basicSerialisableGraph }) => {
|
||||
const minLGraph = new LGraph(minimalSerialisableGraph)
|
||||
expect(minLGraph).toMatchSnapshot('minLGraph')
|
||||
|
||||
const basicLGraph = new LGraph(basicSerialisableGraph)
|
||||
expect(basicLGraph).toMatchSnapshot('basicLGraph')
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -1,97 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraph, LGraphNode, LLink } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { test } from './testExtensions'
|
||||
|
||||
describe('LLink', () => {
|
||||
test('matches previous snapshot', () => {
|
||||
const link = new LLink(1, 'float', 4, 2, 5, 3)
|
||||
expect(link.serialize()).toMatchSnapshot('Basic')
|
||||
})
|
||||
|
||||
test('serializes to the previous snapshot', () => {
|
||||
const link = new LLink(1, 'float', 4, 2, 5, 3)
|
||||
expect(link.serialize()).toMatchSnapshot('Basic')
|
||||
})
|
||||
|
||||
describe('disconnect', () => {
|
||||
it('should clear the target input link reference when disconnecting', () => {
|
||||
// Create a graph and nodes
|
||||
const graph = new LGraph()
|
||||
const sourceNode = new LGraphNode('Source')
|
||||
const targetNode = new LGraphNode('Target')
|
||||
|
||||
// Add nodes to graph
|
||||
graph.add(sourceNode)
|
||||
graph.add(targetNode)
|
||||
|
||||
// Add slots
|
||||
sourceNode.addOutput('out', 'number')
|
||||
targetNode.addInput('in', 'number')
|
||||
|
||||
// Connect the nodes
|
||||
const link = sourceNode.connect(0, targetNode, 0)
|
||||
expect(link).toBeDefined()
|
||||
expect(targetNode.inputs[0].link).toBe(link?.id)
|
||||
|
||||
// Mock setDirtyCanvas
|
||||
const setDirtyCanvasSpy = vi.spyOn(targetNode, 'setDirtyCanvas')
|
||||
|
||||
// Disconnect the link
|
||||
link?.disconnect(graph)
|
||||
|
||||
// Verify the target input's link reference is cleared
|
||||
expect(targetNode.inputs[0].link).toBeNull()
|
||||
|
||||
// Verify setDirtyCanvas was called
|
||||
expect(setDirtyCanvasSpy).toHaveBeenCalledWith(true, false)
|
||||
})
|
||||
|
||||
it('should handle disconnecting when target node is not found', () => {
|
||||
// Create a link with invalid target
|
||||
const graph = new LGraph()
|
||||
const link = new LLink(1, 'number', 1, 0, 999, 0) // Invalid target id
|
||||
|
||||
// Should not throw when disconnecting
|
||||
expect(() => link.disconnect(graph)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should only clear link reference if it matches the current link id', () => {
|
||||
// Create a graph and nodes
|
||||
const graph = new LGraph()
|
||||
const sourceNode1 = new LGraphNode('Source1')
|
||||
const sourceNode2 = new LGraphNode('Source2')
|
||||
const targetNode = new LGraphNode('Target')
|
||||
|
||||
// Add nodes to graph
|
||||
graph.add(sourceNode1)
|
||||
graph.add(sourceNode2)
|
||||
graph.add(targetNode)
|
||||
|
||||
// Add slots
|
||||
sourceNode1.addOutput('out', 'number')
|
||||
sourceNode2.addOutput('out', 'number')
|
||||
targetNode.addInput('in', 'number')
|
||||
|
||||
// Create first connection
|
||||
const link1 = sourceNode1.connect(0, targetNode, 0)
|
||||
expect(link1).toBeDefined()
|
||||
|
||||
// Disconnect first connection
|
||||
targetNode.disconnectInput(0)
|
||||
|
||||
// Create second connection
|
||||
const link2 = sourceNode2.connect(0, targetNode, 0)
|
||||
expect(link2).toBeDefined()
|
||||
expect(targetNode.inputs[0].link).toBe(link2?.id)
|
||||
|
||||
// Try to disconnect the first link (which is already disconnected)
|
||||
// It should not affect the current connection
|
||||
link1?.disconnect(graph)
|
||||
|
||||
// The input should still have the second link
|
||||
expect(targetNode.inputs[0].link).toBe(link2?.id)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,325 +0,0 @@
|
||||
import { test as baseTest, describe, expect, vi } from 'vitest'
|
||||
|
||||
import { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
|
||||
import type { MovingInputLink } from '@/lib/litegraph/src/canvas/MovingInputLink'
|
||||
import { ToInputRenderLink } from '@/lib/litegraph/src/canvas/ToInputRenderLink'
|
||||
import type { LinkNetwork } from '@/lib/litegraph/src/interfaces'
|
||||
import type { ISlotType } from '@/lib/litegraph/src/interfaces'
|
||||
import {
|
||||
LGraph,
|
||||
LGraphNode,
|
||||
LLink,
|
||||
Reroute,
|
||||
type RerouteId
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
|
||||
|
||||
interface TestContext {
|
||||
network: LinkNetwork & { add(node: LGraphNode): void }
|
||||
connector: LinkConnector
|
||||
setConnectingLinks: ReturnType<typeof vi.fn>
|
||||
createTestNode: (id: number, slotType?: ISlotType) => LGraphNode
|
||||
createTestLink: (
|
||||
id: number,
|
||||
sourceId: number,
|
||||
targetId: number,
|
||||
slotType?: ISlotType
|
||||
) => LLink
|
||||
}
|
||||
|
||||
const test = baseTest.extend<TestContext>({
|
||||
// eslint-disable-next-line no-empty-pattern
|
||||
network: async ({}, use) => {
|
||||
const graph = new LGraph()
|
||||
const floatingLinks = new Map<number, LLink>()
|
||||
const reroutes = new Map<number, Reroute>()
|
||||
|
||||
await use({
|
||||
links: new Map<number, LLink>(),
|
||||
reroutes,
|
||||
floatingLinks,
|
||||
getLink: graph.getLink.bind(graph),
|
||||
getNodeById: (id: number) => graph.getNodeById(id),
|
||||
addFloatingLink: (link: LLink) => {
|
||||
floatingLinks.set(link.id, link)
|
||||
return link
|
||||
},
|
||||
removeFloatingLink: (link: LLink) => floatingLinks.delete(link.id),
|
||||
getReroute: ((id: RerouteId | null | undefined) =>
|
||||
id == null ? undefined : reroutes.get(id)) as LinkNetwork['getReroute'],
|
||||
removeReroute: (id: number) => reroutes.delete(id),
|
||||
add: (node: LGraphNode) => graph.add(node)
|
||||
})
|
||||
},
|
||||
|
||||
setConnectingLinks: async (
|
||||
// eslint-disable-next-line no-empty-pattern
|
||||
{},
|
||||
use: (mock: ReturnType<typeof vi.fn>) => Promise<void>
|
||||
) => {
|
||||
const mock = vi.fn()
|
||||
await use(mock)
|
||||
},
|
||||
connector: async ({ setConnectingLinks }, use) => {
|
||||
const connector = new LinkConnector(setConnectingLinks)
|
||||
await use(connector)
|
||||
},
|
||||
|
||||
createTestNode: async ({ network }, use) => {
|
||||
await use((id: number): LGraphNode => {
|
||||
const node = new LGraphNode('test')
|
||||
node.id = id
|
||||
network.add(node)
|
||||
return node
|
||||
})
|
||||
},
|
||||
createTestLink: async ({ network }, use) => {
|
||||
await use(
|
||||
(
|
||||
id: number,
|
||||
sourceId: number,
|
||||
targetId: number,
|
||||
slotType: ISlotType = 'number'
|
||||
): LLink => {
|
||||
const link = new LLink(id, slotType, sourceId, 0, targetId, 0)
|
||||
network.links.set(link.id, link)
|
||||
return link
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
describe('LinkConnector', () => {
|
||||
test('should initialize with default state', ({ connector }) => {
|
||||
expect(connector.state).toEqual({
|
||||
connectingTo: undefined,
|
||||
multi: false,
|
||||
draggingExistingLinks: false
|
||||
})
|
||||
expect(connector.renderLinks).toEqual([])
|
||||
expect(connector.inputLinks).toEqual([])
|
||||
expect(connector.outputLinks).toEqual([])
|
||||
expect(connector.hiddenReroutes.size).toBe(0)
|
||||
})
|
||||
|
||||
describe('Moving Input Links', () => {
|
||||
test('should handle moving input links', ({
|
||||
network,
|
||||
connector,
|
||||
createTestNode
|
||||
}) => {
|
||||
const sourceNode = createTestNode(1)
|
||||
const targetNode = createTestNode(2)
|
||||
|
||||
const slotType: ISlotType = 'number'
|
||||
sourceNode.addOutput('out', slotType)
|
||||
targetNode.addInput('in', slotType)
|
||||
|
||||
const link = new LLink(1, slotType, 1, 0, 2, 0)
|
||||
network.links.set(link.id, link)
|
||||
targetNode.inputs[0].link = link.id
|
||||
|
||||
connector.moveInputLink(network, targetNode.inputs[0])
|
||||
|
||||
expect(connector.state.connectingTo).toBe('input')
|
||||
expect(connector.state.draggingExistingLinks).toBe(true)
|
||||
expect(connector.inputLinks).toContain(link)
|
||||
expect(link._dragging).toBe(true)
|
||||
})
|
||||
|
||||
test('should not move input link if already connecting', ({
|
||||
connector,
|
||||
network
|
||||
}) => {
|
||||
connector.state.connectingTo = 'input'
|
||||
|
||||
expect(() => {
|
||||
connector.moveInputLink(network, { link: 1 } as any)
|
||||
}).toThrow('Already dragging links.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Moving Output Links', () => {
|
||||
test('should handle moving output links', ({
|
||||
network,
|
||||
connector,
|
||||
createTestNode
|
||||
}) => {
|
||||
const sourceNode = createTestNode(1)
|
||||
const targetNode = createTestNode(2)
|
||||
|
||||
const slotType: ISlotType = 'number'
|
||||
sourceNode.addOutput('out', slotType)
|
||||
targetNode.addInput('in', slotType)
|
||||
|
||||
const link = new LLink(1, slotType, 1, 0, 2, 0)
|
||||
network.links.set(link.id, link)
|
||||
sourceNode.outputs[0].links = [link.id]
|
||||
|
||||
connector.moveOutputLink(network, sourceNode.outputs[0])
|
||||
|
||||
expect(connector.state.connectingTo).toBe('output')
|
||||
expect(connector.state.draggingExistingLinks).toBe(true)
|
||||
expect(connector.state.multi).toBe(true)
|
||||
expect(connector.outputLinks).toContain(link)
|
||||
expect(link._dragging).toBe(true)
|
||||
})
|
||||
|
||||
test('should not move output link if already connecting', ({
|
||||
connector,
|
||||
network
|
||||
}) => {
|
||||
connector.state.connectingTo = 'output'
|
||||
|
||||
expect(() => {
|
||||
connector.moveOutputLink(network, { links: [1] } as any)
|
||||
}).toThrow('Already dragging links.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dragging New Links', () => {
|
||||
test('should handle dragging new link from output', ({
|
||||
network,
|
||||
connector,
|
||||
createTestNode
|
||||
}) => {
|
||||
const sourceNode = createTestNode(1)
|
||||
const slotType: ISlotType = 'number'
|
||||
sourceNode.addOutput('out', slotType)
|
||||
|
||||
connector.dragNewFromOutput(network, sourceNode, sourceNode.outputs[0])
|
||||
|
||||
expect(connector.state.connectingTo).toBe('input')
|
||||
expect(connector.renderLinks.length).toBe(1)
|
||||
expect(connector.state.draggingExistingLinks).toBe(false)
|
||||
})
|
||||
|
||||
test('should handle dragging new link from input', ({
|
||||
network,
|
||||
connector,
|
||||
createTestNode
|
||||
}) => {
|
||||
const targetNode = createTestNode(1)
|
||||
const slotType: ISlotType = 'number'
|
||||
targetNode.addInput('in', slotType)
|
||||
|
||||
connector.dragNewFromInput(network, targetNode, targetNode.inputs[0])
|
||||
|
||||
expect(connector.state.connectingTo).toBe('output')
|
||||
expect(connector.renderLinks.length).toBe(1)
|
||||
expect(connector.state.draggingExistingLinks).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dragging from reroutes', () => {
|
||||
test('should handle dragging from reroutes', ({
|
||||
network,
|
||||
connector,
|
||||
createTestNode,
|
||||
createTestLink
|
||||
}) => {
|
||||
const originNode = createTestNode(1)
|
||||
const targetNode = createTestNode(2)
|
||||
|
||||
const output = originNode.addOutput('out', 'number')
|
||||
targetNode.addInput('in', 'number')
|
||||
|
||||
const link = createTestLink(1, 1, 2)
|
||||
const reroute = new Reroute(1, network, [0, 0], undefined, [link.id])
|
||||
network.reroutes.set(reroute.id, reroute)
|
||||
link.parentId = reroute.id
|
||||
|
||||
connector.dragFromReroute(network, reroute)
|
||||
|
||||
expect(connector.state.connectingTo).toBe('input')
|
||||
expect(connector.state.draggingExistingLinks).toBe(false)
|
||||
expect(connector.renderLinks.length).toBe(1)
|
||||
|
||||
const renderLink = connector.renderLinks[0]
|
||||
expect(renderLink instanceof ToInputRenderLink).toBe(true)
|
||||
expect(renderLink.toType).toEqual('input')
|
||||
expect(renderLink.node).toEqual(originNode)
|
||||
expect(renderLink.fromSlot).toEqual(output)
|
||||
expect(renderLink.fromReroute).toEqual(reroute)
|
||||
expect(renderLink.fromDirection).toEqual(LinkDirection.NONE)
|
||||
expect(renderLink.network).toEqual(network)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Reset', () => {
|
||||
test('should reset state and clear links', ({ network, connector }) => {
|
||||
connector.state.connectingTo = 'input'
|
||||
connector.state.multi = true
|
||||
connector.state.draggingExistingLinks = true
|
||||
|
||||
const link = new LLink(1, 'number', 1, 0, 2, 0)
|
||||
link._dragging = true
|
||||
connector.inputLinks.push(link)
|
||||
|
||||
const reroute = new Reroute(1, network)
|
||||
reroute.pos = [0, 0]
|
||||
reroute._dragging = true
|
||||
connector.hiddenReroutes.add(reroute)
|
||||
|
||||
connector.reset()
|
||||
|
||||
expect(connector.state).toEqual({
|
||||
connectingTo: undefined,
|
||||
multi: false,
|
||||
draggingExistingLinks: false
|
||||
})
|
||||
expect(connector.renderLinks).toEqual([])
|
||||
expect(connector.inputLinks).toEqual([])
|
||||
expect(connector.outputLinks).toEqual([])
|
||||
expect(connector.hiddenReroutes.size).toBe(0)
|
||||
expect(link._dragging).toBeUndefined()
|
||||
expect(reroute._dragging).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Event Handling', () => {
|
||||
test('should handle event listeners until reset', ({
|
||||
connector,
|
||||
createTestNode
|
||||
}) => {
|
||||
const listener = vi.fn()
|
||||
connector.listenUntilReset('input-moved', listener)
|
||||
|
||||
const sourceNode = createTestNode(1)
|
||||
|
||||
const mockRenderLink = {
|
||||
node: sourceNode,
|
||||
fromSlot: { name: 'out', type: 'number' },
|
||||
fromPos: [0, 0],
|
||||
fromDirection: LinkDirection.RIGHT,
|
||||
toType: 'input',
|
||||
link: new LLink(1, 'number', 1, 0, 2, 0)
|
||||
} as MovingInputLink
|
||||
|
||||
connector.events.dispatch('input-moved', mockRenderLink)
|
||||
expect(listener).toHaveBeenCalled()
|
||||
|
||||
connector.reset()
|
||||
connector.events.dispatch('input-moved', mockRenderLink)
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Export', () => {
|
||||
test('should export current state', ({ network, connector }) => {
|
||||
connector.state.connectingTo = 'input'
|
||||
connector.state.multi = true
|
||||
|
||||
const link = new LLink(1, 'number', 1, 0, 2, 0)
|
||||
connector.inputLinks.push(link)
|
||||
|
||||
const exported = connector.export(network)
|
||||
|
||||
expect(exported.state).toEqual(connector.state)
|
||||
expect(exported.inputLinks).toEqual(connector.inputLinks)
|
||||
expect(exported.outputLinks).toEqual(connector.outputLinks)
|
||||
expect(exported.renderLinks).toEqual(connector.renderLinks)
|
||||
expect(exported.network).toBe(network)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,83 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import {
|
||||
inputAsSerialisable,
|
||||
outputAsSerialisable
|
||||
} from '@/lib/litegraph/src/node/slotUtils'
|
||||
|
||||
describe('NodeSlot', () => {
|
||||
describe('inputAsSerialisable', () => {
|
||||
it('removes _data from serialized slot', () => {
|
||||
// @ts-expect-error Missing boundingRect property for test
|
||||
const slot: INodeOutputSlot = {
|
||||
_data: 'test data',
|
||||
name: 'test-id',
|
||||
type: 'STRING',
|
||||
links: []
|
||||
}
|
||||
// @ts-expect-error Argument type mismatch for test
|
||||
const serialized = outputAsSerialisable(slot)
|
||||
expect(serialized).not.toHaveProperty('_data')
|
||||
})
|
||||
|
||||
it('removes pos from widget input slots', () => {
|
||||
const widgetInputSlot: INodeInputSlot = {
|
||||
name: 'test-id',
|
||||
pos: [10, 20],
|
||||
type: 'STRING',
|
||||
link: null,
|
||||
widget: {
|
||||
name: 'test-widget',
|
||||
// @ts-expect-error TODO: Fix after merge - type property not in IWidgetLocator
|
||||
type: 'combo',
|
||||
value: 'test-value-1',
|
||||
options: {
|
||||
values: ['test-value-1', 'test-value-2']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const serialized = inputAsSerialisable(widgetInputSlot)
|
||||
expect(serialized).not.toHaveProperty('pos')
|
||||
})
|
||||
|
||||
it('preserves pos for non-widget input slots', () => {
|
||||
// @ts-expect-error TODO: Fix after merge - missing boundingRect property for test
|
||||
const normalSlot: INodeInputSlot = {
|
||||
name: 'test-id',
|
||||
type: 'STRING',
|
||||
pos: [10, 20],
|
||||
link: null
|
||||
}
|
||||
const serialized = inputAsSerialisable(normalSlot)
|
||||
expect(serialized).toHaveProperty('pos')
|
||||
})
|
||||
|
||||
it('preserves only widget name during serialization', () => {
|
||||
const widgetInputSlot: INodeInputSlot = {
|
||||
name: 'test-id',
|
||||
type: 'STRING',
|
||||
link: null,
|
||||
widget: {
|
||||
name: 'test-widget',
|
||||
// @ts-expect-error TODO: Fix after merge - type property not in IWidgetLocator
|
||||
type: 'combo',
|
||||
value: 'test-value-1',
|
||||
options: {
|
||||
values: ['test-value-1', 'test-value-2']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const serialized = inputAsSerialisable(widgetInputSlot)
|
||||
expect(serialized.widget).toEqual({ name: 'test-widget' })
|
||||
expect(serialized.widget).not.toHaveProperty('type')
|
||||
expect(serialized.widget).not.toHaveProperty('value')
|
||||
expect(serialized.widget).not.toHaveProperty('options')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,96 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ToOutputRenderLink } from '@/lib/litegraph/src/canvas/ToOutputRenderLink'
|
||||
import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
|
||||
|
||||
describe('ToOutputRenderLink', () => {
|
||||
describe('connectToOutput', () => {
|
||||
it('should return early if inputNode is null', () => {
|
||||
// Setup
|
||||
const mockNetwork = {}
|
||||
const mockFromSlot = {}
|
||||
const mockNode = {
|
||||
id: 'test-id',
|
||||
inputs: [mockFromSlot],
|
||||
getInputPos: vi.fn().mockReturnValue([0, 0])
|
||||
}
|
||||
|
||||
const renderLink = new ToOutputRenderLink(
|
||||
mockNetwork as any,
|
||||
mockNode as any,
|
||||
mockFromSlot as any,
|
||||
undefined,
|
||||
LinkDirection.CENTER
|
||||
)
|
||||
|
||||
// Override the node property to simulate null case
|
||||
Object.defineProperty(renderLink, 'node', {
|
||||
value: null
|
||||
})
|
||||
|
||||
const mockTargetNode = {
|
||||
connectSlots: vi.fn()
|
||||
}
|
||||
const mockEvents = {
|
||||
dispatch: vi.fn()
|
||||
}
|
||||
|
||||
// Act
|
||||
renderLink.connectToOutput(
|
||||
mockTargetNode as any,
|
||||
{} as any,
|
||||
mockEvents as any
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(mockTargetNode.connectSlots).not.toHaveBeenCalled()
|
||||
expect(mockEvents.dispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should create connection and dispatch event when inputNode exists', () => {
|
||||
// Setup
|
||||
const mockNetwork = {}
|
||||
const mockFromSlot = {}
|
||||
const mockNode = {
|
||||
id: 'test-id',
|
||||
inputs: [mockFromSlot],
|
||||
getInputPos: vi.fn().mockReturnValue([0, 0])
|
||||
}
|
||||
|
||||
const renderLink = new ToOutputRenderLink(
|
||||
mockNetwork as any,
|
||||
mockNode as any,
|
||||
mockFromSlot as any,
|
||||
undefined,
|
||||
LinkDirection.CENTER
|
||||
)
|
||||
|
||||
const mockNewLink = { id: 'new-link' }
|
||||
const mockTargetNode = {
|
||||
connectSlots: vi.fn().mockReturnValue(mockNewLink)
|
||||
}
|
||||
const mockEvents = {
|
||||
dispatch: vi.fn()
|
||||
}
|
||||
|
||||
// Act
|
||||
renderLink.connectToOutput(
|
||||
mockTargetNode as any,
|
||||
{} as any,
|
||||
mockEvents as any
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(mockTargetNode.connectSlots).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
mockNode,
|
||||
mockFromSlot,
|
||||
undefined
|
||||
)
|
||||
expect(mockEvents.dispatch).toHaveBeenCalledWith(
|
||||
'link-created',
|
||||
mockNewLink
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,328 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`LGraph configure() > LGraph matches previous snapshot (normal configure() usage) > configuredBasicGraph 1`] = `
|
||||
LGraph {
|
||||
"_groups": [
|
||||
LGraphGroup {
|
||||
"_bounding": Float32Array [
|
||||
20,
|
||||
20,
|
||||
1,
|
||||
3,
|
||||
],
|
||||
"_children": Set {},
|
||||
"_nodes": [],
|
||||
"_pos": Float32Array [
|
||||
20,
|
||||
20,
|
||||
],
|
||||
"_size": Float32Array [
|
||||
1,
|
||||
3,
|
||||
],
|
||||
"color": "#6029aa",
|
||||
"flags": {},
|
||||
"font": undefined,
|
||||
"font_size": 14,
|
||||
"graph": [Circular],
|
||||
"id": 123,
|
||||
"isPointInside": [Function],
|
||||
"selected": undefined,
|
||||
"setDirtyCanvas": [Function],
|
||||
"title": "A group to test with",
|
||||
},
|
||||
],
|
||||
"_input_nodes": undefined,
|
||||
"_last_trigger_time": undefined,
|
||||
"_links": Map {},
|
||||
"_nodes": [
|
||||
LGraphNode {
|
||||
"_collapsed_width": undefined,
|
||||
"_level": undefined,
|
||||
"_pos": Float32Array [
|
||||
10,
|
||||
10,
|
||||
],
|
||||
"_posSize": Float32Array [
|
||||
10,
|
||||
10,
|
||||
140,
|
||||
60,
|
||||
],
|
||||
"_relative_id": undefined,
|
||||
"_shape": undefined,
|
||||
"_size": Float32Array [
|
||||
140,
|
||||
60,
|
||||
],
|
||||
"action_call": undefined,
|
||||
"action_triggered": undefined,
|
||||
"badgePosition": "top-left",
|
||||
"badges": [],
|
||||
"bgcolor": undefined,
|
||||
"block_delete": undefined,
|
||||
"boxcolor": undefined,
|
||||
"changeTracker": undefined,
|
||||
"clip_area": undefined,
|
||||
"clonable": undefined,
|
||||
"color": undefined,
|
||||
"console": undefined,
|
||||
"exec_version": undefined,
|
||||
"execute_triggered": undefined,
|
||||
"flags": {},
|
||||
"freeWidgetSpace": undefined,
|
||||
"gotFocusAt": undefined,
|
||||
"graph": [Circular],
|
||||
"has_errors": undefined,
|
||||
"id": 1,
|
||||
"ignore_remove": undefined,
|
||||
"inputs": [],
|
||||
"last_serialization": undefined,
|
||||
"locked": undefined,
|
||||
"lostFocusAt": undefined,
|
||||
"mode": 0,
|
||||
"mouseOver": undefined,
|
||||
"order": 0,
|
||||
"outputs": [],
|
||||
"progress": undefined,
|
||||
"properties": {},
|
||||
"properties_info": [],
|
||||
"redraw_on_mouse": undefined,
|
||||
"removable": undefined,
|
||||
"resizable": undefined,
|
||||
"selected": undefined,
|
||||
"serialize_widgets": undefined,
|
||||
"showAdvanced": undefined,
|
||||
"strokeStyles": {
|
||||
"error": [Function],
|
||||
"selected": [Function],
|
||||
},
|
||||
"title": "LGraphNode",
|
||||
"type": "mustBeSet",
|
||||
"widgets": undefined,
|
||||
"widgets_start_y": undefined,
|
||||
"widgets_up": undefined,
|
||||
},
|
||||
],
|
||||
"_nodes_by_id": {
|
||||
"1": LGraphNode {
|
||||
"_collapsed_width": undefined,
|
||||
"_level": undefined,
|
||||
"_pos": Float32Array [
|
||||
10,
|
||||
10,
|
||||
],
|
||||
"_posSize": Float32Array [
|
||||
10,
|
||||
10,
|
||||
140,
|
||||
60,
|
||||
],
|
||||
"_relative_id": undefined,
|
||||
"_shape": undefined,
|
||||
"_size": Float32Array [
|
||||
140,
|
||||
60,
|
||||
],
|
||||
"action_call": undefined,
|
||||
"action_triggered": undefined,
|
||||
"badgePosition": "top-left",
|
||||
"badges": [],
|
||||
"bgcolor": undefined,
|
||||
"block_delete": undefined,
|
||||
"boxcolor": undefined,
|
||||
"changeTracker": undefined,
|
||||
"clip_area": undefined,
|
||||
"clonable": undefined,
|
||||
"color": undefined,
|
||||
"console": undefined,
|
||||
"exec_version": undefined,
|
||||
"execute_triggered": undefined,
|
||||
"flags": {},
|
||||
"freeWidgetSpace": undefined,
|
||||
"gotFocusAt": undefined,
|
||||
"graph": [Circular],
|
||||
"has_errors": undefined,
|
||||
"id": 1,
|
||||
"ignore_remove": undefined,
|
||||
"inputs": [],
|
||||
"last_serialization": undefined,
|
||||
"locked": undefined,
|
||||
"lostFocusAt": undefined,
|
||||
"mode": 0,
|
||||
"mouseOver": undefined,
|
||||
"order": 0,
|
||||
"outputs": [],
|
||||
"progress": undefined,
|
||||
"properties": {},
|
||||
"properties_info": [],
|
||||
"redraw_on_mouse": undefined,
|
||||
"removable": undefined,
|
||||
"resizable": undefined,
|
||||
"selected": undefined,
|
||||
"serialize_widgets": undefined,
|
||||
"showAdvanced": undefined,
|
||||
"strokeStyles": {
|
||||
"error": [Function],
|
||||
"selected": [Function],
|
||||
},
|
||||
"title": "LGraphNode",
|
||||
"type": "mustBeSet",
|
||||
"widgets": undefined,
|
||||
"widgets_start_y": undefined,
|
||||
"widgets_up": undefined,
|
||||
},
|
||||
},
|
||||
"_nodes_executable": [],
|
||||
"_nodes_in_order": [
|
||||
LGraphNode {
|
||||
"_collapsed_width": undefined,
|
||||
"_level": undefined,
|
||||
"_pos": Float32Array [
|
||||
10,
|
||||
10,
|
||||
],
|
||||
"_posSize": Float32Array [
|
||||
10,
|
||||
10,
|
||||
140,
|
||||
60,
|
||||
],
|
||||
"_relative_id": undefined,
|
||||
"_shape": undefined,
|
||||
"_size": Float32Array [
|
||||
140,
|
||||
60,
|
||||
],
|
||||
"action_call": undefined,
|
||||
"action_triggered": undefined,
|
||||
"badgePosition": "top-left",
|
||||
"badges": [],
|
||||
"bgcolor": undefined,
|
||||
"block_delete": undefined,
|
||||
"boxcolor": undefined,
|
||||
"changeTracker": undefined,
|
||||
"clip_area": undefined,
|
||||
"clonable": undefined,
|
||||
"color": undefined,
|
||||
"console": undefined,
|
||||
"exec_version": undefined,
|
||||
"execute_triggered": undefined,
|
||||
"flags": {},
|
||||
"freeWidgetSpace": undefined,
|
||||
"gotFocusAt": undefined,
|
||||
"graph": [Circular],
|
||||
"has_errors": undefined,
|
||||
"id": 1,
|
||||
"ignore_remove": undefined,
|
||||
"inputs": [],
|
||||
"last_serialization": undefined,
|
||||
"locked": undefined,
|
||||
"lostFocusAt": undefined,
|
||||
"mode": 0,
|
||||
"mouseOver": undefined,
|
||||
"order": 0,
|
||||
"outputs": [],
|
||||
"progress": undefined,
|
||||
"properties": {},
|
||||
"properties_info": [],
|
||||
"redraw_on_mouse": undefined,
|
||||
"removable": undefined,
|
||||
"resizable": undefined,
|
||||
"selected": undefined,
|
||||
"serialize_widgets": undefined,
|
||||
"showAdvanced": undefined,
|
||||
"strokeStyles": {
|
||||
"error": [Function],
|
||||
"selected": [Function],
|
||||
},
|
||||
"title": "LGraphNode",
|
||||
"type": "mustBeSet",
|
||||
"widgets": undefined,
|
||||
"widgets_start_y": undefined,
|
||||
"widgets_up": undefined,
|
||||
},
|
||||
],
|
||||
"_subgraphs": Map {},
|
||||
"_version": 3,
|
||||
"catch_errors": true,
|
||||
"config": {},
|
||||
"elapsed_time": 0.01,
|
||||
"errors_in_execution": undefined,
|
||||
"events": CustomEventTarget {},
|
||||
"execution_time": undefined,
|
||||
"execution_timer_id": undefined,
|
||||
"extra": {},
|
||||
"filter": undefined,
|
||||
"fixedtime": 0,
|
||||
"fixedtime_lapse": 0.01,
|
||||
"globaltime": 0,
|
||||
"id": "ca9da7d8-fddd-4707-ad32-67be9be13140",
|
||||
"iteration": 0,
|
||||
"last_update_time": 0,
|
||||
"links": Map {},
|
||||
"list_of_graphcanvas": null,
|
||||
"nodes_actioning": [],
|
||||
"nodes_executedAction": [],
|
||||
"nodes_executing": [],
|
||||
"revision": 0,
|
||||
"runningtime": 0,
|
||||
"starttime": 0,
|
||||
"state": {
|
||||
"lastGroupId": 123,
|
||||
"lastLinkId": 0,
|
||||
"lastNodeId": 1,
|
||||
"lastRerouteId": 0,
|
||||
},
|
||||
"status": 1,
|
||||
"vars": {},
|
||||
"version": 1,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`LGraph configure() > LGraph matches previous snapshot (normal configure() usage) > configuredMinGraph 1`] = `
|
||||
LGraph {
|
||||
"_groups": [],
|
||||
"_input_nodes": undefined,
|
||||
"_last_trigger_time": undefined,
|
||||
"_links": Map {},
|
||||
"_nodes": [],
|
||||
"_nodes_by_id": {},
|
||||
"_nodes_executable": [],
|
||||
"_nodes_in_order": [],
|
||||
"_subgraphs": Map {},
|
||||
"_version": 0,
|
||||
"catch_errors": true,
|
||||
"config": {},
|
||||
"elapsed_time": 0.01,
|
||||
"errors_in_execution": undefined,
|
||||
"events": CustomEventTarget {},
|
||||
"execution_time": undefined,
|
||||
"execution_timer_id": undefined,
|
||||
"extra": {},
|
||||
"filter": undefined,
|
||||
"fixedtime": 0,
|
||||
"fixedtime_lapse": 0.01,
|
||||
"globaltime": 0,
|
||||
"id": "d175890f-716a-4ece-ba33-1d17a513b7be",
|
||||
"iteration": 0,
|
||||
"last_update_time": 0,
|
||||
"links": Map {},
|
||||
"list_of_graphcanvas": null,
|
||||
"nodes_actioning": [],
|
||||
"nodes_executedAction": [],
|
||||
"nodes_executing": [],
|
||||
"revision": 0,
|
||||
"runningtime": 0,
|
||||
"starttime": 0,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastLinkId": 0,
|
||||
"lastNodeId": 0,
|
||||
"lastRerouteId": 0,
|
||||
},
|
||||
"status": 1,
|
||||
"vars": {},
|
||||
"version": 1,
|
||||
}
|
||||
`;
|
||||
@@ -1,290 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`LGraph > supports schema v0.4 graphs > oldSchemaGraph 1`] = `
|
||||
LGraph {
|
||||
"_groups": [
|
||||
LGraphGroup {
|
||||
"_bounding": Float32Array [
|
||||
20,
|
||||
20,
|
||||
1,
|
||||
3,
|
||||
],
|
||||
"_children": Set {},
|
||||
"_nodes": [],
|
||||
"_pos": Float32Array [
|
||||
20,
|
||||
20,
|
||||
],
|
||||
"_size": Float32Array [
|
||||
1,
|
||||
3,
|
||||
],
|
||||
"color": "#6029aa",
|
||||
"flags": {},
|
||||
"font": undefined,
|
||||
"font_size": 14,
|
||||
"graph": [Circular],
|
||||
"id": 123,
|
||||
"isPointInside": [Function],
|
||||
"selected": undefined,
|
||||
"setDirtyCanvas": [Function],
|
||||
"title": "A group to test with",
|
||||
},
|
||||
],
|
||||
"_input_nodes": undefined,
|
||||
"_last_trigger_time": undefined,
|
||||
"_links": Map {},
|
||||
"_nodes": [
|
||||
LGraphNode {
|
||||
"_collapsed_width": undefined,
|
||||
"_level": undefined,
|
||||
"_pos": Float32Array [
|
||||
10,
|
||||
10,
|
||||
],
|
||||
"_posSize": Float32Array [
|
||||
10,
|
||||
10,
|
||||
140,
|
||||
60,
|
||||
],
|
||||
"_relative_id": undefined,
|
||||
"_shape": undefined,
|
||||
"_size": Float32Array [
|
||||
140,
|
||||
60,
|
||||
],
|
||||
"action_call": undefined,
|
||||
"action_triggered": undefined,
|
||||
"badgePosition": "top-left",
|
||||
"badges": [],
|
||||
"bgcolor": undefined,
|
||||
"block_delete": undefined,
|
||||
"boxcolor": undefined,
|
||||
"changeTracker": undefined,
|
||||
"clip_area": undefined,
|
||||
"clonable": undefined,
|
||||
"color": undefined,
|
||||
"console": undefined,
|
||||
"exec_version": undefined,
|
||||
"execute_triggered": undefined,
|
||||
"flags": {},
|
||||
"freeWidgetSpace": undefined,
|
||||
"gotFocusAt": undefined,
|
||||
"graph": [Circular],
|
||||
"has_errors": true,
|
||||
"id": 1,
|
||||
"ignore_remove": undefined,
|
||||
"inputs": [],
|
||||
"last_serialization": {
|
||||
"id": 1,
|
||||
},
|
||||
"locked": undefined,
|
||||
"lostFocusAt": undefined,
|
||||
"mode": 0,
|
||||
"mouseOver": undefined,
|
||||
"order": 0,
|
||||
"outputs": [],
|
||||
"progress": undefined,
|
||||
"properties": {},
|
||||
"properties_info": [],
|
||||
"redraw_on_mouse": undefined,
|
||||
"removable": undefined,
|
||||
"resizable": undefined,
|
||||
"selected": undefined,
|
||||
"serialize_widgets": undefined,
|
||||
"showAdvanced": undefined,
|
||||
"strokeStyles": {
|
||||
"error": [Function],
|
||||
"selected": [Function],
|
||||
},
|
||||
"title": undefined,
|
||||
"title_buttons": [],
|
||||
"type": "",
|
||||
"widgets": undefined,
|
||||
"widgets_start_y": undefined,
|
||||
"widgets_up": undefined,
|
||||
},
|
||||
],
|
||||
"_nodes_by_id": {
|
||||
"1": LGraphNode {
|
||||
"_collapsed_width": undefined,
|
||||
"_level": undefined,
|
||||
"_pos": Float32Array [
|
||||
10,
|
||||
10,
|
||||
],
|
||||
"_posSize": Float32Array [
|
||||
10,
|
||||
10,
|
||||
140,
|
||||
60,
|
||||
],
|
||||
"_relative_id": undefined,
|
||||
"_shape": undefined,
|
||||
"_size": Float32Array [
|
||||
140,
|
||||
60,
|
||||
],
|
||||
"action_call": undefined,
|
||||
"action_triggered": undefined,
|
||||
"badgePosition": "top-left",
|
||||
"badges": [],
|
||||
"bgcolor": undefined,
|
||||
"block_delete": undefined,
|
||||
"boxcolor": undefined,
|
||||
"changeTracker": undefined,
|
||||
"clip_area": undefined,
|
||||
"clonable": undefined,
|
||||
"color": undefined,
|
||||
"console": undefined,
|
||||
"exec_version": undefined,
|
||||
"execute_triggered": undefined,
|
||||
"flags": {},
|
||||
"freeWidgetSpace": undefined,
|
||||
"gotFocusAt": undefined,
|
||||
"graph": [Circular],
|
||||
"has_errors": true,
|
||||
"id": 1,
|
||||
"ignore_remove": undefined,
|
||||
"inputs": [],
|
||||
"last_serialization": {
|
||||
"id": 1,
|
||||
},
|
||||
"locked": undefined,
|
||||
"lostFocusAt": undefined,
|
||||
"mode": 0,
|
||||
"mouseOver": undefined,
|
||||
"order": 0,
|
||||
"outputs": [],
|
||||
"progress": undefined,
|
||||
"properties": {},
|
||||
"properties_info": [],
|
||||
"redraw_on_mouse": undefined,
|
||||
"removable": undefined,
|
||||
"resizable": undefined,
|
||||
"selected": undefined,
|
||||
"serialize_widgets": undefined,
|
||||
"showAdvanced": undefined,
|
||||
"strokeStyles": {
|
||||
"error": [Function],
|
||||
"selected": [Function],
|
||||
},
|
||||
"title": undefined,
|
||||
"title_buttons": [],
|
||||
"type": "",
|
||||
"widgets": undefined,
|
||||
"widgets_start_y": undefined,
|
||||
"widgets_up": undefined,
|
||||
},
|
||||
},
|
||||
"_nodes_executable": [],
|
||||
"_nodes_in_order": [
|
||||
LGraphNode {
|
||||
"_collapsed_width": undefined,
|
||||
"_level": undefined,
|
||||
"_pos": Float32Array [
|
||||
10,
|
||||
10,
|
||||
],
|
||||
"_posSize": Float32Array [
|
||||
10,
|
||||
10,
|
||||
140,
|
||||
60,
|
||||
],
|
||||
"_relative_id": undefined,
|
||||
"_shape": undefined,
|
||||
"_size": Float32Array [
|
||||
140,
|
||||
60,
|
||||
],
|
||||
"action_call": undefined,
|
||||
"action_triggered": undefined,
|
||||
"badgePosition": "top-left",
|
||||
"badges": [],
|
||||
"bgcolor": undefined,
|
||||
"block_delete": undefined,
|
||||
"boxcolor": undefined,
|
||||
"changeTracker": undefined,
|
||||
"clip_area": undefined,
|
||||
"clonable": undefined,
|
||||
"color": undefined,
|
||||
"console": undefined,
|
||||
"exec_version": undefined,
|
||||
"execute_triggered": undefined,
|
||||
"flags": {},
|
||||
"freeWidgetSpace": undefined,
|
||||
"gotFocusAt": undefined,
|
||||
"graph": [Circular],
|
||||
"has_errors": true,
|
||||
"id": 1,
|
||||
"ignore_remove": undefined,
|
||||
"inputs": [],
|
||||
"last_serialization": {
|
||||
"id": 1,
|
||||
},
|
||||
"locked": undefined,
|
||||
"lostFocusAt": undefined,
|
||||
"mode": 0,
|
||||
"mouseOver": undefined,
|
||||
"order": 0,
|
||||
"outputs": [],
|
||||
"progress": undefined,
|
||||
"properties": {},
|
||||
"properties_info": [],
|
||||
"redraw_on_mouse": undefined,
|
||||
"removable": undefined,
|
||||
"resizable": undefined,
|
||||
"selected": undefined,
|
||||
"serialize_widgets": undefined,
|
||||
"showAdvanced": undefined,
|
||||
"strokeStyles": {
|
||||
"error": [Function],
|
||||
"selected": [Function],
|
||||
},
|
||||
"title": undefined,
|
||||
"title_buttons": [],
|
||||
"type": "",
|
||||
"widgets": undefined,
|
||||
"widgets_start_y": undefined,
|
||||
"widgets_up": undefined,
|
||||
},
|
||||
],
|
||||
"_subgraphs": Map {},
|
||||
"_version": 3,
|
||||
"catch_errors": true,
|
||||
"config": {},
|
||||
"elapsed_time": 0.01,
|
||||
"errors_in_execution": undefined,
|
||||
"events": CustomEventTarget {},
|
||||
"execution_time": undefined,
|
||||
"execution_timer_id": undefined,
|
||||
"extra": {},
|
||||
"filter": undefined,
|
||||
"fixedtime": 0,
|
||||
"fixedtime_lapse": 0.01,
|
||||
"globaltime": 0,
|
||||
"id": "b4e984f1-b421-4d24-b8b4-ff895793af13",
|
||||
"iteration": 0,
|
||||
"last_update_time": 0,
|
||||
"links": Map {},
|
||||
"list_of_graphcanvas": null,
|
||||
"nodes_actioning": [],
|
||||
"nodes_executedAction": [],
|
||||
"nodes_executing": [],
|
||||
"revision": 0,
|
||||
"runningtime": 0,
|
||||
"starttime": 0,
|
||||
"state": {
|
||||
"lastGroupId": 123,
|
||||
"lastLinkId": 0,
|
||||
"lastNodeId": 1,
|
||||
"lastRerouteId": 0,
|
||||
},
|
||||
"status": 1,
|
||||
"vars": {},
|
||||
"version": 0.4,
|
||||
}
|
||||
`;
|
||||
@@ -1,17 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`LGraphGroup > serializes to the existing format > Basic 1`] = `
|
||||
{
|
||||
"bounding": [
|
||||
10,
|
||||
10,
|
||||
140,
|
||||
80,
|
||||
],
|
||||
"color": "#3f789e",
|
||||
"flags": {},
|
||||
"font_size": 24,
|
||||
"id": 929,
|
||||
"title": "title",
|
||||
}
|
||||
`;
|
||||
@@ -1,331 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`LGraph (constructor only) > Matches previous snapshot > basicLGraph 1`] = `
|
||||
LGraph {
|
||||
"_groups": [
|
||||
LGraphGroup {
|
||||
"_bounding": Float32Array [
|
||||
20,
|
||||
20,
|
||||
1,
|
||||
3,
|
||||
],
|
||||
"_children": Set {},
|
||||
"_nodes": [],
|
||||
"_pos": Float32Array [
|
||||
20,
|
||||
20,
|
||||
],
|
||||
"_size": Float32Array [
|
||||
1,
|
||||
3,
|
||||
],
|
||||
"color": "#6029aa",
|
||||
"flags": {},
|
||||
"font": undefined,
|
||||
"font_size": 14,
|
||||
"graph": [Circular],
|
||||
"id": 123,
|
||||
"isPointInside": [Function],
|
||||
"selected": undefined,
|
||||
"setDirtyCanvas": [Function],
|
||||
"title": "A group to test with",
|
||||
},
|
||||
],
|
||||
"_input_nodes": undefined,
|
||||
"_last_trigger_time": undefined,
|
||||
"_links": Map {},
|
||||
"_nodes": [
|
||||
LGraphNode {
|
||||
"_collapsed_width": undefined,
|
||||
"_level": undefined,
|
||||
"_pos": Float32Array [
|
||||
10,
|
||||
10,
|
||||
],
|
||||
"_posSize": Float32Array [
|
||||
10,
|
||||
10,
|
||||
140,
|
||||
60,
|
||||
],
|
||||
"_relative_id": undefined,
|
||||
"_shape": undefined,
|
||||
"_size": Float32Array [
|
||||
140,
|
||||
60,
|
||||
],
|
||||
"action_call": undefined,
|
||||
"action_triggered": undefined,
|
||||
"badgePosition": "top-left",
|
||||
"badges": [],
|
||||
"bgcolor": undefined,
|
||||
"block_delete": undefined,
|
||||
"boxcolor": undefined,
|
||||
"changeTracker": undefined,
|
||||
"clip_area": undefined,
|
||||
"clonable": undefined,
|
||||
"color": undefined,
|
||||
"console": undefined,
|
||||
"exec_version": undefined,
|
||||
"execute_triggered": undefined,
|
||||
"flags": {},
|
||||
"freeWidgetSpace": undefined,
|
||||
"gotFocusAt": undefined,
|
||||
"graph": [Circular],
|
||||
"has_errors": undefined,
|
||||
"id": 1,
|
||||
"ignore_remove": undefined,
|
||||
"inputs": [],
|
||||
"last_serialization": undefined,
|
||||
"locked": undefined,
|
||||
"lostFocusAt": undefined,
|
||||
"mode": 0,
|
||||
"mouseOver": undefined,
|
||||
"order": 0,
|
||||
"outputs": [],
|
||||
"progress": undefined,
|
||||
"properties": {},
|
||||
"properties_info": [],
|
||||
"redraw_on_mouse": undefined,
|
||||
"removable": undefined,
|
||||
"resizable": undefined,
|
||||
"selected": undefined,
|
||||
"serialize_widgets": undefined,
|
||||
"showAdvanced": undefined,
|
||||
"strokeStyles": {
|
||||
"error": [Function],
|
||||
"selected": [Function],
|
||||
},
|
||||
"title": "LGraphNode",
|
||||
"title_buttons": [],
|
||||
"type": "mustBeSet",
|
||||
"widgets": undefined,
|
||||
"widgets_start_y": undefined,
|
||||
"widgets_up": undefined,
|
||||
},
|
||||
],
|
||||
"_nodes_by_id": {
|
||||
"1": LGraphNode {
|
||||
"_collapsed_width": undefined,
|
||||
"_level": undefined,
|
||||
"_pos": Float32Array [
|
||||
10,
|
||||
10,
|
||||
],
|
||||
"_posSize": Float32Array [
|
||||
10,
|
||||
10,
|
||||
140,
|
||||
60,
|
||||
],
|
||||
"_relative_id": undefined,
|
||||
"_shape": undefined,
|
||||
"_size": Float32Array [
|
||||
140,
|
||||
60,
|
||||
],
|
||||
"action_call": undefined,
|
||||
"action_triggered": undefined,
|
||||
"badgePosition": "top-left",
|
||||
"badges": [],
|
||||
"bgcolor": undefined,
|
||||
"block_delete": undefined,
|
||||
"boxcolor": undefined,
|
||||
"changeTracker": undefined,
|
||||
"clip_area": undefined,
|
||||
"clonable": undefined,
|
||||
"color": undefined,
|
||||
"console": undefined,
|
||||
"exec_version": undefined,
|
||||
"execute_triggered": undefined,
|
||||
"flags": {},
|
||||
"freeWidgetSpace": undefined,
|
||||
"gotFocusAt": undefined,
|
||||
"graph": [Circular],
|
||||
"has_errors": undefined,
|
||||
"id": 1,
|
||||
"ignore_remove": undefined,
|
||||
"inputs": [],
|
||||
"last_serialization": undefined,
|
||||
"locked": undefined,
|
||||
"lostFocusAt": undefined,
|
||||
"mode": 0,
|
||||
"mouseOver": undefined,
|
||||
"order": 0,
|
||||
"outputs": [],
|
||||
"progress": undefined,
|
||||
"properties": {},
|
||||
"properties_info": [],
|
||||
"redraw_on_mouse": undefined,
|
||||
"removable": undefined,
|
||||
"resizable": undefined,
|
||||
"selected": undefined,
|
||||
"serialize_widgets": undefined,
|
||||
"showAdvanced": undefined,
|
||||
"strokeStyles": {
|
||||
"error": [Function],
|
||||
"selected": [Function],
|
||||
},
|
||||
"title": "LGraphNode",
|
||||
"title_buttons": [],
|
||||
"type": "mustBeSet",
|
||||
"widgets": undefined,
|
||||
"widgets_start_y": undefined,
|
||||
"widgets_up": undefined,
|
||||
},
|
||||
},
|
||||
"_nodes_executable": [],
|
||||
"_nodes_in_order": [
|
||||
LGraphNode {
|
||||
"_collapsed_width": undefined,
|
||||
"_level": undefined,
|
||||
"_pos": Float32Array [
|
||||
10,
|
||||
10,
|
||||
],
|
||||
"_posSize": Float32Array [
|
||||
10,
|
||||
10,
|
||||
140,
|
||||
60,
|
||||
],
|
||||
"_relative_id": undefined,
|
||||
"_shape": undefined,
|
||||
"_size": Float32Array [
|
||||
140,
|
||||
60,
|
||||
],
|
||||
"action_call": undefined,
|
||||
"action_triggered": undefined,
|
||||
"badgePosition": "top-left",
|
||||
"badges": [],
|
||||
"bgcolor": undefined,
|
||||
"block_delete": undefined,
|
||||
"boxcolor": undefined,
|
||||
"changeTracker": undefined,
|
||||
"clip_area": undefined,
|
||||
"clonable": undefined,
|
||||
"color": undefined,
|
||||
"console": undefined,
|
||||
"exec_version": undefined,
|
||||
"execute_triggered": undefined,
|
||||
"flags": {},
|
||||
"freeWidgetSpace": undefined,
|
||||
"gotFocusAt": undefined,
|
||||
"graph": [Circular],
|
||||
"has_errors": undefined,
|
||||
"id": 1,
|
||||
"ignore_remove": undefined,
|
||||
"inputs": [],
|
||||
"last_serialization": undefined,
|
||||
"locked": undefined,
|
||||
"lostFocusAt": undefined,
|
||||
"mode": 0,
|
||||
"mouseOver": undefined,
|
||||
"order": 0,
|
||||
"outputs": [],
|
||||
"progress": undefined,
|
||||
"properties": {},
|
||||
"properties_info": [],
|
||||
"redraw_on_mouse": undefined,
|
||||
"removable": undefined,
|
||||
"resizable": undefined,
|
||||
"selected": undefined,
|
||||
"serialize_widgets": undefined,
|
||||
"showAdvanced": undefined,
|
||||
"strokeStyles": {
|
||||
"error": [Function],
|
||||
"selected": [Function],
|
||||
},
|
||||
"title": "LGraphNode",
|
||||
"title_buttons": [],
|
||||
"type": "mustBeSet",
|
||||
"widgets": undefined,
|
||||
"widgets_start_y": undefined,
|
||||
"widgets_up": undefined,
|
||||
},
|
||||
],
|
||||
"_subgraphs": Map {},
|
||||
"_version": 3,
|
||||
"catch_errors": true,
|
||||
"config": {},
|
||||
"elapsed_time": 0.01,
|
||||
"errors_in_execution": undefined,
|
||||
"events": CustomEventTarget {},
|
||||
"execution_time": undefined,
|
||||
"execution_timer_id": undefined,
|
||||
"extra": {},
|
||||
"filter": undefined,
|
||||
"fixedtime": 0,
|
||||
"fixedtime_lapse": 0.01,
|
||||
"globaltime": 0,
|
||||
"id": "ca9da7d8-fddd-4707-ad32-67be9be13140",
|
||||
"iteration": 0,
|
||||
"last_update_time": 0,
|
||||
"links": Map {},
|
||||
"list_of_graphcanvas": null,
|
||||
"nodes_actioning": [],
|
||||
"nodes_executedAction": [],
|
||||
"nodes_executing": [],
|
||||
"revision": 0,
|
||||
"runningtime": 0,
|
||||
"starttime": 0,
|
||||
"state": {
|
||||
"lastGroupId": 123,
|
||||
"lastLinkId": 0,
|
||||
"lastNodeId": 1,
|
||||
"lastRerouteId": 0,
|
||||
},
|
||||
"status": 1,
|
||||
"vars": {},
|
||||
"version": 1,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`LGraph (constructor only) > Matches previous snapshot > minLGraph 1`] = `
|
||||
LGraph {
|
||||
"_groups": [],
|
||||
"_input_nodes": undefined,
|
||||
"_last_trigger_time": undefined,
|
||||
"_links": Map {},
|
||||
"_nodes": [],
|
||||
"_nodes_by_id": {},
|
||||
"_nodes_executable": [],
|
||||
"_nodes_in_order": [],
|
||||
"_subgraphs": Map {},
|
||||
"_version": 0,
|
||||
"catch_errors": true,
|
||||
"config": {},
|
||||
"elapsed_time": 0.01,
|
||||
"errors_in_execution": undefined,
|
||||
"events": CustomEventTarget {},
|
||||
"execution_time": undefined,
|
||||
"execution_timer_id": undefined,
|
||||
"extra": {},
|
||||
"filter": undefined,
|
||||
"fixedtime": 0,
|
||||
"fixedtime_lapse": 0.01,
|
||||
"globaltime": 0,
|
||||
"id": "d175890f-716a-4ece-ba33-1d17a513b7be",
|
||||
"iteration": 0,
|
||||
"last_update_time": 0,
|
||||
"links": Map {},
|
||||
"list_of_graphcanvas": null,
|
||||
"nodes_actioning": [],
|
||||
"nodes_executedAction": [],
|
||||
"nodes_executing": [],
|
||||
"revision": 0,
|
||||
"runningtime": 0,
|
||||
"starttime": 0,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastLinkId": 0,
|
||||
"lastNodeId": 0,
|
||||
"lastRerouteId": 0,
|
||||
},
|
||||
"status": 1,
|
||||
"vars": {},
|
||||
"version": 1,
|
||||
}
|
||||
`;
|
||||
@@ -1,23 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`LLink > matches previous snapshot > Basic 1`] = `
|
||||
[
|
||||
1,
|
||||
4,
|
||||
2,
|
||||
5,
|
||||
3,
|
||||
"float",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`LLink > serializes to the previous snapshot > Basic 1`] = `
|
||||
[
|
||||
1,
|
||||
4,
|
||||
2,
|
||||
5,
|
||||
3,
|
||||
"float",
|
||||
]
|
||||
`;
|
||||
@@ -1,203 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Litegraph module > has the same structure > minLGraph 1`] = `
|
||||
LiteGraphGlobal {
|
||||
"ACTION": -1,
|
||||
"ALWAYS": 0,
|
||||
"ARROW_SHAPE": 5,
|
||||
"AUTOHIDE_TITLE": 3,
|
||||
"BOX_SHAPE": 1,
|
||||
"CANVAS_GRID_SIZE": 10,
|
||||
"CARD_SHAPE": 4,
|
||||
"CENTER": 5,
|
||||
"CIRCLE_SHAPE": 3,
|
||||
"CONNECTING_LINK_COLOR": "#AFA",
|
||||
"Classes": {
|
||||
"InputIndicators": [Function],
|
||||
"Rectangle": [Function],
|
||||
"SubgraphIONodeBase": [Function],
|
||||
"SubgraphSlot": [Function],
|
||||
},
|
||||
"ContextMenu": [Function],
|
||||
"CurveEditor": [Function],
|
||||
"DEFAULT_FONT": "Arial",
|
||||
"DEFAULT_GROUP_FONT": 24,
|
||||
"DEFAULT_GROUP_FONT_SIZE": undefined,
|
||||
"DEFAULT_POSITION": [
|
||||
100,
|
||||
100,
|
||||
],
|
||||
"DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.5)",
|
||||
"DOWN": 2,
|
||||
"DragAndScale": [Function],
|
||||
"EVENT": -1,
|
||||
"EVENT_LINK_COLOR": "#A86",
|
||||
"GRID_SHAPE": 6,
|
||||
"GROUP_FONT": "Arial",
|
||||
"Globals": {},
|
||||
"HIDDEN_LINK": -1,
|
||||
"INPUT": 1,
|
||||
"LEFT": 3,
|
||||
"LGraph": [Function],
|
||||
"LGraphCanvas": [Function],
|
||||
"LGraphGroup": [Function],
|
||||
"LGraphNode": [Function],
|
||||
"LINEAR_LINK": 1,
|
||||
"LINK_COLOR": "#9A9",
|
||||
"LINK_RENDER_MODES": [
|
||||
"Straight",
|
||||
"Linear",
|
||||
"Spline",
|
||||
],
|
||||
"LLink": [Function],
|
||||
"LabelPosition": {
|
||||
"Left": "left",
|
||||
"Right": "right",
|
||||
},
|
||||
"MAX_NUMBER_OF_NODES": 10000,
|
||||
"NEVER": 2,
|
||||
"NODE_BOX_OUTLINE_COLOR": "#FFF",
|
||||
"NODE_COLLAPSED_RADIUS": 10,
|
||||
"NODE_COLLAPSED_WIDTH": 80,
|
||||
"NODE_DEFAULT_BGCOLOR": "#353535",
|
||||
"NODE_DEFAULT_BOXCOLOR": "#666",
|
||||
"NODE_DEFAULT_COLOR": "#333",
|
||||
"NODE_DEFAULT_SHAPE": 2,
|
||||
"NODE_ERROR_COLOUR": "#E00",
|
||||
"NODE_FONT": "Arial",
|
||||
"NODE_MIN_WIDTH": 50,
|
||||
"NODE_MODES": [
|
||||
"Always",
|
||||
"On Event",
|
||||
"Never",
|
||||
"On Trigger",
|
||||
],
|
||||
"NODE_MODES_COLORS": [
|
||||
"#666",
|
||||
"#422",
|
||||
"#333",
|
||||
"#224",
|
||||
"#626",
|
||||
],
|
||||
"NODE_SELECTED_TITLE_COLOR": "#FFF",
|
||||
"NODE_SLOT_HEIGHT": 20,
|
||||
"NODE_SUBTEXT_SIZE": 12,
|
||||
"NODE_TEXT_COLOR": "#AAA",
|
||||
"NODE_TEXT_HIGHLIGHT_COLOR": "#EEE",
|
||||
"NODE_TEXT_SIZE": 14,
|
||||
"NODE_TITLE_COLOR": "#999",
|
||||
"NODE_TITLE_HEIGHT": 30,
|
||||
"NODE_TITLE_TEXT_Y": 20,
|
||||
"NODE_WIDGET_HEIGHT": 20,
|
||||
"NODE_WIDTH": 140,
|
||||
"NORMAL_TITLE": 0,
|
||||
"NO_TITLE": 1,
|
||||
"Nodes": {},
|
||||
"ON_EVENT": 1,
|
||||
"ON_TRIGGER": 3,
|
||||
"OUTPUT": 2,
|
||||
"RIGHT": 4,
|
||||
"ROUND_RADIUS": 8,
|
||||
"ROUND_SHAPE": 2,
|
||||
"Reroute": [Function],
|
||||
"SPLINE_LINK": 2,
|
||||
"STRAIGHT_LINK": 0,
|
||||
"SlotDirection": {
|
||||
"1": "Up",
|
||||
"2": "Down",
|
||||
"3": "Left",
|
||||
"4": "Right",
|
||||
"Down": 2,
|
||||
"Left": 3,
|
||||
"Right": 4,
|
||||
"Up": 1,
|
||||
},
|
||||
"SlotShape": {
|
||||
"1": "Box",
|
||||
"3": "Circle",
|
||||
"5": "Arrow",
|
||||
"6": "Grid",
|
||||
"7": "HollowCircle",
|
||||
"Arrow": 5,
|
||||
"Box": 1,
|
||||
"Circle": 3,
|
||||
"Grid": 6,
|
||||
"HollowCircle": 7,
|
||||
},
|
||||
"SlotType": {
|
||||
"-1": "Event",
|
||||
"Array": "array",
|
||||
"Event": -1,
|
||||
},
|
||||
"TRANSPARENT_TITLE": 2,
|
||||
"UP": 1,
|
||||
"VALID_SHAPES": [
|
||||
"default",
|
||||
"box",
|
||||
"round",
|
||||
"card",
|
||||
],
|
||||
"VERSION": 0.4,
|
||||
"VERTICAL_LAYOUT": "vertical",
|
||||
"WIDGET_ADVANCED_OUTLINE_COLOR": "rgba(56, 139, 253, 0.8)",
|
||||
"WIDGET_BGCOLOR": "#222",
|
||||
"WIDGET_DISABLED_TEXT_COLOR": "#666",
|
||||
"WIDGET_OUTLINE_COLOR": "#666",
|
||||
"WIDGET_SECONDARY_TEXT_COLOR": "#999",
|
||||
"WIDGET_TEXT_COLOR": "#DDD",
|
||||
"allow_multi_output_for_events": true,
|
||||
"allow_scripts": false,
|
||||
"alt_drag_do_clone_nodes": false,
|
||||
"alwaysRepeatWarnings": false,
|
||||
"alwaysSnapToGrid": undefined,
|
||||
"auto_load_slot_types": false,
|
||||
"canvasNavigationMode": "legacy",
|
||||
"catch_exceptions": true,
|
||||
"click_do_break_link_to": false,
|
||||
"context_menu_scaling": false,
|
||||
"ctrl_alt_click_do_break_link": true,
|
||||
"ctrl_shift_v_paste_connect_unselected_outputs": true,
|
||||
"debug": false,
|
||||
"dialog_close_on_mouse_leave": false,
|
||||
"dialog_close_on_mouse_leave_delay": 500,
|
||||
"distance": [Function],
|
||||
"do_add_triggers_slots": false,
|
||||
"highlight_selected_group": true,
|
||||
"isInsideRectangle": [Function],
|
||||
"macGesturesRequireMac": true,
|
||||
"macTrackpadGestures": false,
|
||||
"middle_click_slot_add_default_node": false,
|
||||
"node_box_coloured_by_mode": false,
|
||||
"node_box_coloured_when_on": false,
|
||||
"node_images_path": "",
|
||||
"node_types_by_file_extension": {},
|
||||
"onDeprecationWarning": [
|
||||
[Function],
|
||||
],
|
||||
"overlapBounding": [Function],
|
||||
"pointerevents_method": "pointer",
|
||||
"proxy": null,
|
||||
"registered_node_types": {},
|
||||
"registered_slot_in_types": {},
|
||||
"registered_slot_out_types": {},
|
||||
"release_link_on_empty_shows_menu": false,
|
||||
"saveViewportWithGraph": true,
|
||||
"search_filter_enabled": false,
|
||||
"search_hide_on_mouse_leave": true,
|
||||
"search_show_all_on_open": true,
|
||||
"searchbox_extras": {},
|
||||
"shift_click_do_break_link_from": false,
|
||||
"slot_types_default_in": {},
|
||||
"slot_types_default_out": {},
|
||||
"slot_types_in": [],
|
||||
"slot_types_out": [],
|
||||
"snapToGrid": undefined,
|
||||
"snap_highlights_node": true,
|
||||
"snaps_for_comfy": true,
|
||||
"throw_errors": true,
|
||||
"truncateWidgetTextEvenly": false,
|
||||
"truncateWidgetValuesFirst": false,
|
||||
"use_uuids": false,
|
||||
"uuidv4": [Function],
|
||||
}
|
||||
`;
|
||||
@@ -1,123 +0,0 @@
|
||||
{
|
||||
"id": "e5ffd5e1-1c01-45ac-90dd-b7d83a206b0f",
|
||||
"revision": 0,
|
||||
"last_node_id": 3,
|
||||
"last_link_id": 3,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "InvertMask",
|
||||
"pos": [100, 130],
|
||||
"size": [140, 26],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "mask",
|
||||
"name": "mask",
|
||||
"type": "MASK",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "MASK",
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": [2, 3]
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "InvertMask" },
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "InvertMask",
|
||||
"pos": [400, 220],
|
||||
"size": [140, 26],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "localized_name": "mask", "name": "mask", "type": "MASK", "link": 3 }
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "MASK",
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "InvertMask" },
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "InvertMask",
|
||||
"pos": [400, 130],
|
||||
"size": [140, 26],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "localized_name": "mask", "name": "mask", "type": "MASK", "link": 2 }
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "MASK",
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "InvertMask" },
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[2, 1, 0, 2, 0, "MASK"],
|
||||
[3, 1, 0, 3, 0, "MASK"]
|
||||
],
|
||||
"floatingLinks": [
|
||||
{
|
||||
"id": 6,
|
||||
"origin_id": 1,
|
||||
"origin_slot": 0,
|
||||
"target_id": -1,
|
||||
"target_slot": -1,
|
||||
"type": "MASK",
|
||||
"parentId": 1
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1.2100000000000002,
|
||||
"offset": [319.8264462809916, 109.2148760330578]
|
||||
},
|
||||
"linkExtensions": [
|
||||
{ "id": 2, "parentId": 3 },
|
||||
{ "id": 3, "parentId": 3 }
|
||||
],
|
||||
"reroutes": [
|
||||
{
|
||||
"id": 1,
|
||||
"parentId": 2,
|
||||
"pos": [350, 110],
|
||||
"linkIds": [],
|
||||
"floating": { "slotType": "output" }
|
||||
},
|
||||
{ "id": 2, "parentId": 4, "pos": [310, 150], "linkIds": [2, 3] },
|
||||
{ "id": 3, "parentId": 2, "pos": [360, 170], "linkIds": [2, 3] },
|
||||
{
|
||||
"id": 4,
|
||||
"pos": [271.9090881347656, 146.9834747314453],
|
||||
"linkIds": [2, 3]
|
||||
}
|
||||
]
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
{
|
||||
"id": "d175890f-716a-4ece-ba33-1d17a513b7be",
|
||||
"revision": 0,
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "VAEDecode",
|
||||
"pos": [63.44815444946289, 178.71633911132812],
|
||||
"size": [210, 46],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": []
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEDecode"
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"floatingLinks": [
|
||||
{
|
||||
"id": 4,
|
||||
"origin_id": 2,
|
||||
"origin_slot": 0,
|
||||
"target_id": -1,
|
||||
"target_slot": -1,
|
||||
"type": "IMAGE",
|
||||
"parentId": 1
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"linkExtensions": [],
|
||||
"reroutes": [
|
||||
{
|
||||
"id": 1,
|
||||
"pos": [393.2383117675781, 194.61941528320312],
|
||||
"linkIds": [],
|
||||
"floating": {
|
||||
"slotType": "output"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
{
|
||||
"id": "26a34f13-1767-4847-b25f-a21dedf6840d",
|
||||
"revision": 0,
|
||||
"last_node_id": 3,
|
||||
"last_link_id": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "VAEDecode",
|
||||
"pos": [
|
||||
63.44815444946289,
|
||||
178.71633911132812
|
||||
],
|
||||
"size": [
|
||||
210,
|
||||
46
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
2
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEDecode"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "SaveImage",
|
||||
"pos": [
|
||||
419.36920166015625,
|
||||
179.71388244628906
|
||||
],
|
||||
"size": [
|
||||
226.3714141845703,
|
||||
58
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"ComfyUI"
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
3,
|
||||
0,
|
||||
"IMAGE"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"linkExtensions": [
|
||||
{
|
||||
"id": 2,
|
||||
"parentId": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{"id":"e5ffd5e1-1c01-45ac-90dd-b7d83a206b0f","revision":0,"last_node_id":9,"last_link_id":12,"nodes":[{"id":3,"type":"InvertMask","pos":[390,270],"size":[140,26],"flags":{},"order":8,"mode":0,"inputs":[{"localized_name":"mask","name":"mask","type":"MASK","link":3}],"outputs":[{"localized_name":"MASK","name":"MASK","type":"MASK","links":null}],"properties":{"Node name for S&R":"InvertMask"},"widgets_values":[]},{"id":7,"type":"InvertMask","pos":[390,560],"size":[140,26],"flags":{},"order":4,"mode":0,"inputs":[{"localized_name":"mask","name":"mask","type":"MASK","link":10}],"outputs":[{"localized_name":"MASK","name":"MASK","type":"MASK","links":null}],"properties":{"Node name for S&R":"InvertMask"},"widgets_values":[]},{"id":8,"type":"InvertMask","pos":[390,640],"size":[140,26],"flags":{},"order":3,"mode":0,"inputs":[{"localized_name":"mask","name":"mask","type":"MASK","link":9}],"outputs":[{"localized_name":"MASK","name":"MASK","type":"MASK","links":null}],"properties":{"Node name for S&R":"InvertMask"},"widgets_values":[]},{"id":5,"type":"InvertMask","pos":[390,480],"size":[140,26],"flags":{"collapsed":false},"order":5,"mode":0,"inputs":[{"localized_name":"mask","name":"mask","type":"MASK","link":11}],"outputs":[{"localized_name":"MASK","name":"MASK","type":"MASK","links":null}],"properties":{"Node name for S&R":"InvertMask"},"widgets_values":[]},{"id":6,"type":"InvertMask","pos":[390,400],"size":[140,26],"flags":{},"order":6,"mode":0,"inputs":[{"localized_name":"mask","name":"mask","type":"MASK","link":12}],"outputs":[{"localized_name":"MASK","name":"MASK","type":"MASK","links":null}],"properties":{"Node name for S&R":"InvertMask"},"widgets_values":[]},{"id":4,"type":"InvertMask","pos":[50,640],"size":[140,26],"flags":{},"order":0,"mode":0,"inputs":[{"localized_name":"mask","name":"mask","type":"MASK","link":null}],"outputs":[{"localized_name":"MASK","name":"MASK","type":"MASK","links":[9,10,11,12]}],"properties":{"Node name for S&R":"InvertMask"},"widgets_values":[]},{"id":2,"type":"InvertMask","pos":[390,180],"size":[140,26],"flags":{},"order":7,"mode":0,"inputs":[{"localized_name":"mask","name":"mask","type":"MASK","link":2}],"outputs":[{"localized_name":"MASK","name":"MASK","type":"MASK","links":null}],"properties":{"Node name for S&R":"InvertMask"},"widgets_values":[]},{"id":1,"type":"InvertMask","pos":[50,170],"size":[140,26],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"mask","name":"mask","type":"MASK","link":null}],"outputs":[{"localized_name":"MASK","name":"MASK","type":"MASK","links":[2,3]}],"properties":{"Node name for S&R":"InvertMask"},"widgets_values":[]},{"id":9,"type":"InvertMask","pos":[50,410],"size":[140,26],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"mask","name":"mask","type":"MASK","link":null}],"outputs":[{"localized_name":"MASK","name":"MASK","type":"MASK","links":[]}],"properties":{"Node name for S&R":"InvertMask"},"widgets_values":[]}],"links":[[2,1,0,2,0,"MASK"],[3,1,0,3,0,"MASK"],[9,4,0,8,0,"MASK"],[10,4,0,7,0,"MASK"],[11,4,0,5,0,"MASK"],[12,4,0,6,0,"MASK"]],"floatingLinks":[{"id":6,"origin_id":1,"origin_slot":0,"target_id":-1,"target_slot":-1,"type":"MASK","parentId":1}],"groups":[],"config":{},"extra":{"ds":{"scale":1,"offset":[0,0]},"linkExtensions":[{"id":2,"parentId":3},{"id":3,"parentId":3},{"id":9,"parentId":12},{"id":10,"parentId":15},{"id":11,"parentId":7},{"id":12,"parentId":7}],"reroutes":[{"id":1,"parentId":2,"pos":[340,160],"linkIds":[],"floating":{"slotType":"output"}},{"id":2,"parentId":4,"pos":[290,190],"linkIds":[2,3]},{"id":3,"parentId":2,"pos":[350,220],"linkIds":[2,3]},{"id":4,"pos":[250,190],"linkIds":[2,3]},{"id":6,"parentId":8,"pos":[300,450],"linkIds":[11,12]},{"id":7,"parentId":6,"pos":[350,450],"linkIds":[11,12]},{"id":8,"parentId":13,"pos":[250,450],"linkIds":[11,12]},{"id":10,"pos":[250,650],"linkIds":[9,10,11,12]},{"id":11,"parentId":10,"pos":[300,650],"linkIds":[9]},{"id":12,"parentId":11,"pos":[350,650],"linkIds":[9]},{"id":13,"parentId":10,"pos":[250,570],"linkIds":[10,11,12]},{"id":14,"parentId":13,"pos":[300,570],"linkIds":[10]},{"id":15,"parentId":14,"pos":[350,570],"linkIds":[10]}]},"version":0.4}
|
||||
@@ -1,75 +0,0 @@
|
||||
import type {
|
||||
ISerialisedGraph,
|
||||
SerialisableGraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
export const oldSchemaGraph: ISerialisedGraph = {
|
||||
id: 'b4e984f1-b421-4d24-b8b4-ff895793af13',
|
||||
revision: 0,
|
||||
version: 0.4,
|
||||
config: {},
|
||||
last_node_id: 0,
|
||||
last_link_id: 0,
|
||||
groups: [
|
||||
{
|
||||
id: 123,
|
||||
bounding: [20, 20, 1, 3],
|
||||
color: '#6029aa',
|
||||
font_size: 14,
|
||||
title: 'A group to test with'
|
||||
}
|
||||
],
|
||||
nodes: [
|
||||
// @ts-expect-error TODO: Fix after merge - missing required properties for test
|
||||
{
|
||||
id: 1
|
||||
}
|
||||
],
|
||||
links: []
|
||||
}
|
||||
|
||||
export const minimalSerialisableGraph: SerialisableGraph = {
|
||||
id: 'd175890f-716a-4ece-ba33-1d17a513b7be',
|
||||
revision: 0,
|
||||
version: 1,
|
||||
config: {},
|
||||
state: {
|
||||
lastNodeId: 0,
|
||||
lastLinkId: 0,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
nodes: [],
|
||||
links: [],
|
||||
groups: []
|
||||
}
|
||||
|
||||
export const basicSerialisableGraph: SerialisableGraph = {
|
||||
id: 'ca9da7d8-fddd-4707-ad32-67be9be13140',
|
||||
revision: 0,
|
||||
version: 1,
|
||||
config: {},
|
||||
state: {
|
||||
lastNodeId: 0,
|
||||
lastLinkId: 0,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
groups: [
|
||||
{
|
||||
id: 123,
|
||||
bounding: [20, 20, 1, 3],
|
||||
color: '#6029aa',
|
||||
font_size: 14,
|
||||
title: 'A group to test with'
|
||||
}
|
||||
],
|
||||
nodes: [
|
||||
// @ts-expect-error TODO: Fix after merge - missing required properties for test
|
||||
{
|
||||
id: 1,
|
||||
type: 'mustBeSet'
|
||||
}
|
||||
],
|
||||
links: []
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import type { INodeInputSlot, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
// We don't strictly need RenderLink interface import for the mock
|
||||
import { LinkConnector } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
// Mocks
|
||||
const mockSetConnectingLinks = vi.fn()
|
||||
|
||||
// Mock a structure that has the needed method
|
||||
function mockRenderLinkImpl(canConnect: boolean) {
|
||||
return {
|
||||
canConnectToInput: vi.fn().mockReturnValue(canConnect)
|
||||
// Add other properties if they become necessary for tests
|
||||
}
|
||||
}
|
||||
|
||||
const mockNode = {} as LGraphNode
|
||||
const mockInput = {} as INodeInputSlot
|
||||
|
||||
describe('LinkConnector', () => {
|
||||
let connector: LinkConnector
|
||||
|
||||
beforeEach(() => {
|
||||
connector = new LinkConnector(mockSetConnectingLinks)
|
||||
// Clear the array directly before each test
|
||||
connector.renderLinks.length = 0
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('isInputValidDrop', () => {
|
||||
test('should return false if there are no render links', () => {
|
||||
expect(connector.isInputValidDrop(mockNode, mockInput)).toBe(false)
|
||||
})
|
||||
|
||||
test('should return true if at least one render link can connect', () => {
|
||||
const link1 = mockRenderLinkImpl(false)
|
||||
const link2 = mockRenderLinkImpl(true)
|
||||
// Cast to any to satisfy the push requirement, as we only need the canConnectToInput method
|
||||
connector.renderLinks.push(link1 as any, link2 as any)
|
||||
expect(connector.isInputValidDrop(mockNode, mockInput)).toBe(true)
|
||||
expect(link1.canConnectToInput).toHaveBeenCalledWith(mockNode, mockInput)
|
||||
expect(link2.canConnectToInput).toHaveBeenCalledWith(mockNode, mockInput)
|
||||
})
|
||||
|
||||
test('should return false if no render links can connect', () => {
|
||||
const link1 = mockRenderLinkImpl(false)
|
||||
const link2 = mockRenderLinkImpl(false)
|
||||
connector.renderLinks.push(link1 as any, link2 as any)
|
||||
expect(connector.isInputValidDrop(mockNode, mockInput)).toBe(false)
|
||||
expect(link1.canConnectToInput).toHaveBeenCalledWith(mockNode, mockInput)
|
||||
expect(link2.canConnectToInput).toHaveBeenCalledWith(mockNode, mockInput)
|
||||
})
|
||||
|
||||
test('should call canConnectToInput on each render link until one returns true', () => {
|
||||
const link1 = mockRenderLinkImpl(false)
|
||||
const link2 = mockRenderLinkImpl(true) // This one can connect
|
||||
const link3 = mockRenderLinkImpl(false)
|
||||
connector.renderLinks.push(link1 as any, link2 as any, link3 as any)
|
||||
|
||||
expect(connector.isInputValidDrop(mockNode, mockInput)).toBe(true)
|
||||
|
||||
expect(link1.canConnectToInput).toHaveBeenCalledTimes(1)
|
||||
expect(link2.canConnectToInput).toHaveBeenCalledTimes(1) // Stops here
|
||||
expect(link3.canConnectToInput).not.toHaveBeenCalled() // Should not be called
|
||||
})
|
||||
})
|
||||
|
||||
describe('listenUntilReset', () => {
|
||||
test('should add listener for the specified event and for reset', () => {
|
||||
const listener = vi.fn()
|
||||
const addEventListenerSpy = vi.spyOn(connector.events, 'addEventListener')
|
||||
|
||||
connector.listenUntilReset('before-drop-links', listener)
|
||||
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith(
|
||||
'before-drop-links',
|
||||
listener,
|
||||
undefined
|
||||
)
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith(
|
||||
'reset',
|
||||
expect.any(Function),
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
|
||||
test('should call the listener when the event is dispatched before reset', () => {
|
||||
const listener = vi.fn()
|
||||
const eventData = { renderLinks: [], event: {} as any } // Mock event data
|
||||
connector.listenUntilReset('before-drop-links', listener)
|
||||
|
||||
connector.events.dispatch('before-drop-links', eventData)
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
expect(listener).toHaveBeenCalledWith(
|
||||
new CustomEvent('before-drop-links')
|
||||
)
|
||||
})
|
||||
|
||||
test('should remove the listener when reset is dispatched', () => {
|
||||
const listener = vi.fn()
|
||||
const removeEventListenerSpy = vi.spyOn(
|
||||
connector.events,
|
||||
'removeEventListener'
|
||||
)
|
||||
|
||||
connector.listenUntilReset('before-drop-links', listener)
|
||||
|
||||
// Simulate the reset event being dispatched
|
||||
connector.events.dispatch('reset', false)
|
||||
|
||||
// Check if removeEventListener was called correctly for the original listener
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
'before-drop-links',
|
||||
listener
|
||||
)
|
||||
})
|
||||
|
||||
test('should not call the listener after reset is dispatched', () => {
|
||||
const listener = vi.fn()
|
||||
const eventData = { renderLinks: [], event: {} as any }
|
||||
connector.listenUntilReset('before-drop-links', listener)
|
||||
|
||||
// Dispatch reset first
|
||||
connector.events.dispatch('reset', false)
|
||||
|
||||
// Then dispatch the original event
|
||||
connector.events.dispatch('before-drop-links', eventData)
|
||||
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should pass options to addEventListener', () => {
|
||||
const listener = vi.fn()
|
||||
const options = { once: true }
|
||||
const addEventListenerSpy = vi.spyOn(connector.events, 'addEventListener')
|
||||
|
||||
connector.listenUntilReset('after-drop-links', listener, options)
|
||||
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith(
|
||||
'after-drop-links',
|
||||
listener,
|
||||
options
|
||||
)
|
||||
// Still adds the reset listener
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith(
|
||||
'reset',
|
||||
expect.any(Function),
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,310 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
|
||||
import { MovingOutputLink } from '@/lib/litegraph/src/canvas/MovingOutputLink'
|
||||
import { ToOutputRenderLink } from '@/lib/litegraph/src/canvas/ToOutputRenderLink'
|
||||
import { LGraphNode, LLink } from '@/lib/litegraph/src/litegraph'
|
||||
import type { NodeInputSlot } from '@/lib/litegraph/src/node/NodeInputSlot'
|
||||
|
||||
import { createTestSubgraph } from '../subgraph/fixtures/subgraphHelpers'
|
||||
|
||||
describe('LinkConnector SubgraphInput connection validation', () => {
|
||||
let connector: LinkConnector
|
||||
const mockSetConnectingLinks = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
connector = new LinkConnector(mockSetConnectingLinks)
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('MovingOutputLink validation', () => {
|
||||
it('should implement canConnectToSubgraphInput method', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'number_input', type: 'number' }]
|
||||
})
|
||||
|
||||
const sourceNode = new LGraphNode('SourceNode')
|
||||
sourceNode.addOutput('number_out', 'number')
|
||||
subgraph.add(sourceNode)
|
||||
|
||||
const targetNode = new LGraphNode('TargetNode')
|
||||
targetNode.addInput('number_in', 'number')
|
||||
subgraph.add(targetNode)
|
||||
|
||||
const link = new LLink(1, 'number', sourceNode.id, 0, targetNode.id, 0)
|
||||
subgraph._links.set(link.id, link)
|
||||
|
||||
const movingLink = new MovingOutputLink(subgraph, link)
|
||||
|
||||
// Verify the method exists
|
||||
expect(typeof movingLink.canConnectToSubgraphInput).toBe('function')
|
||||
})
|
||||
|
||||
it('should validate type compatibility correctly', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'number_input', type: 'number' }]
|
||||
})
|
||||
|
||||
const sourceNode = new LGraphNode('SourceNode')
|
||||
sourceNode.addOutput('number_out', 'number')
|
||||
sourceNode.addOutput('string_out', 'string')
|
||||
subgraph.add(sourceNode)
|
||||
|
||||
const targetNode = new LGraphNode('TargetNode')
|
||||
targetNode.addInput('number_in', 'number')
|
||||
targetNode.addInput('string_in', 'string')
|
||||
subgraph.add(targetNode)
|
||||
|
||||
// Create valid link (number -> number)
|
||||
const validLink = new LLink(
|
||||
1,
|
||||
'number',
|
||||
sourceNode.id,
|
||||
0,
|
||||
targetNode.id,
|
||||
0
|
||||
)
|
||||
subgraph._links.set(validLink.id, validLink)
|
||||
const validMovingLink = new MovingOutputLink(subgraph, validLink)
|
||||
|
||||
// Create invalid link (string -> number)
|
||||
const invalidLink = new LLink(
|
||||
2,
|
||||
'string',
|
||||
sourceNode.id,
|
||||
1,
|
||||
targetNode.id,
|
||||
1
|
||||
)
|
||||
subgraph._links.set(invalidLink.id, invalidLink)
|
||||
const invalidMovingLink = new MovingOutputLink(subgraph, invalidLink)
|
||||
|
||||
const numberInput = subgraph.inputs[0]
|
||||
|
||||
// Test validation
|
||||
expect(validMovingLink.canConnectToSubgraphInput(numberInput)).toBe(true)
|
||||
expect(invalidMovingLink.canConnectToSubgraphInput(numberInput)).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle wildcard types', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'wildcard_input', type: '*' }]
|
||||
})
|
||||
|
||||
const sourceNode = new LGraphNode('SourceNode')
|
||||
sourceNode.addOutput('number_out', 'number')
|
||||
subgraph.add(sourceNode)
|
||||
|
||||
const targetNode = new LGraphNode('TargetNode')
|
||||
targetNode.addInput('number_in', 'number')
|
||||
subgraph.add(targetNode)
|
||||
|
||||
const link = new LLink(1, 'number', sourceNode.id, 0, targetNode.id, 0)
|
||||
subgraph._links.set(link.id, link)
|
||||
const movingLink = new MovingOutputLink(subgraph, link)
|
||||
|
||||
const wildcardInput = subgraph.inputs[0]
|
||||
|
||||
// Wildcard should accept any type
|
||||
expect(movingLink.canConnectToSubgraphInput(wildcardInput)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ToOutputRenderLink validation', () => {
|
||||
it('should implement canConnectToSubgraphInput method', () => {
|
||||
// Create a minimal valid setup
|
||||
const subgraph = createTestSubgraph()
|
||||
const node = new LGraphNode('TestNode')
|
||||
node.id = 1
|
||||
node.addInput('test_in', 'number')
|
||||
subgraph.add(node)
|
||||
|
||||
const slot = node.inputs[0] as NodeInputSlot
|
||||
const renderLink = new ToOutputRenderLink(subgraph, node, slot)
|
||||
|
||||
// Verify the method exists
|
||||
expect(typeof renderLink.canConnectToSubgraphInput).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dropOnIoNode validation', () => {
|
||||
it('should prevent invalid connections when dropping on SubgraphInputNode', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'number_input', type: 'number' }]
|
||||
})
|
||||
|
||||
const sourceNode = new LGraphNode('SourceNode')
|
||||
sourceNode.addOutput('string_out', 'string')
|
||||
subgraph.add(sourceNode)
|
||||
|
||||
const targetNode = new LGraphNode('TargetNode')
|
||||
targetNode.addInput('string_in', 'string')
|
||||
subgraph.add(targetNode)
|
||||
|
||||
// Create an invalid link (string output -> string input, but subgraph expects number)
|
||||
const link = new LLink(1, 'string', sourceNode.id, 0, targetNode.id, 0)
|
||||
subgraph._links.set(link.id, link)
|
||||
const movingLink = new MovingOutputLink(subgraph, link)
|
||||
|
||||
// Mock console.warn to verify it's called
|
||||
const consoleWarnSpy = vi
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
// Add the link to the connector
|
||||
connector.renderLinks.push(movingLink)
|
||||
connector.state.connectingTo = 'output'
|
||||
|
||||
// Create mock event
|
||||
const mockEvent = {
|
||||
canvasX: 100,
|
||||
canvasY: 100
|
||||
} as any
|
||||
|
||||
// Mock the getSlotInPosition to return the subgraph input
|
||||
const mockGetSlotInPosition = vi.fn().mockReturnValue(subgraph.inputs[0])
|
||||
subgraph.inputNode.getSlotInPosition = mockGetSlotInPosition
|
||||
|
||||
// Spy on connectToSubgraphInput to ensure it's NOT called
|
||||
const connectSpy = vi.spyOn(movingLink, 'connectToSubgraphInput')
|
||||
|
||||
// Drop on the SubgraphInputNode
|
||||
connector.dropOnIoNode(subgraph.inputNode, mockEvent)
|
||||
|
||||
// Verify that the invalid connection was skipped
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'Invalid connection type',
|
||||
'string',
|
||||
'->',
|
||||
'number'
|
||||
)
|
||||
expect(connectSpy).not.toHaveBeenCalled()
|
||||
|
||||
consoleWarnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should allow valid connections when dropping on SubgraphInputNode', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'number_input', type: 'number' }]
|
||||
})
|
||||
|
||||
const sourceNode = new LGraphNode('SourceNode')
|
||||
sourceNode.addOutput('number_out', 'number')
|
||||
subgraph.add(sourceNode)
|
||||
|
||||
const targetNode = new LGraphNode('TargetNode')
|
||||
targetNode.addInput('number_in', 'number')
|
||||
subgraph.add(targetNode)
|
||||
|
||||
// Create a valid link (number -> number)
|
||||
const link = new LLink(1, 'number', sourceNode.id, 0, targetNode.id, 0)
|
||||
subgraph._links.set(link.id, link)
|
||||
const movingLink = new MovingOutputLink(subgraph, link)
|
||||
|
||||
// Add the link to the connector
|
||||
connector.renderLinks.push(movingLink)
|
||||
connector.state.connectingTo = 'output'
|
||||
|
||||
// Create mock event
|
||||
const mockEvent = {
|
||||
canvasX: 100,
|
||||
canvasY: 100
|
||||
} as any
|
||||
|
||||
// Mock the getSlotInPosition to return the subgraph input
|
||||
const mockGetSlotInPosition = vi.fn().mockReturnValue(subgraph.inputs[0])
|
||||
subgraph.inputNode.getSlotInPosition = mockGetSlotInPosition
|
||||
|
||||
// Spy on connectToSubgraphInput to ensure it IS called
|
||||
const connectSpy = vi.spyOn(movingLink, 'connectToSubgraphInput')
|
||||
|
||||
// Drop on the SubgraphInputNode
|
||||
connector.dropOnIoNode(subgraph.inputNode, mockEvent)
|
||||
|
||||
// Verify that the valid connection was made
|
||||
expect(connectSpy).toHaveBeenCalledWith(
|
||||
subgraph.inputs[0],
|
||||
connector.events
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSubgraphInputValidDrop', () => {
|
||||
it('should check if render links can connect to SubgraphInput', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'number_input', type: 'number' }]
|
||||
})
|
||||
|
||||
const sourceNode = new LGraphNode('SourceNode')
|
||||
sourceNode.addOutput('number_out', 'number')
|
||||
sourceNode.addOutput('string_out', 'string')
|
||||
subgraph.add(sourceNode)
|
||||
|
||||
const targetNode = new LGraphNode('TargetNode')
|
||||
targetNode.addInput('number_in', 'number')
|
||||
targetNode.addInput('string_in', 'string')
|
||||
subgraph.add(targetNode)
|
||||
|
||||
// Create valid and invalid links
|
||||
const validLink = new LLink(
|
||||
1,
|
||||
'number',
|
||||
sourceNode.id,
|
||||
0,
|
||||
targetNode.id,
|
||||
0
|
||||
)
|
||||
const invalidLink = new LLink(
|
||||
2,
|
||||
'string',
|
||||
sourceNode.id,
|
||||
1,
|
||||
targetNode.id,
|
||||
1
|
||||
)
|
||||
subgraph._links.set(validLink.id, validLink)
|
||||
subgraph._links.set(invalidLink.id, invalidLink)
|
||||
|
||||
const validMovingLink = new MovingOutputLink(subgraph, validLink)
|
||||
const invalidMovingLink = new MovingOutputLink(subgraph, invalidLink)
|
||||
|
||||
const subgraphInput = subgraph.inputs[0]
|
||||
|
||||
// Test with only invalid link
|
||||
connector.renderLinks.length = 0
|
||||
connector.renderLinks.push(invalidMovingLink)
|
||||
expect(connector.isSubgraphInputValidDrop(subgraphInput)).toBe(false)
|
||||
|
||||
// Test with valid link
|
||||
connector.renderLinks.length = 0
|
||||
connector.renderLinks.push(validMovingLink)
|
||||
expect(connector.isSubgraphInputValidDrop(subgraphInput)).toBe(true)
|
||||
|
||||
// Test with mixed links
|
||||
connector.renderLinks.length = 0
|
||||
connector.renderLinks.push(invalidMovingLink, validMovingLink)
|
||||
expect(connector.isSubgraphInputValidDrop(subgraphInput)).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle render links without canConnectToSubgraphInput method', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'number_input', type: 'number' }]
|
||||
})
|
||||
|
||||
// Create a mock render link without the method
|
||||
const mockLink = {
|
||||
fromSlot: { type: 'number' }
|
||||
// No canConnectToSubgraphInput method
|
||||
} as any
|
||||
|
||||
connector.renderLinks.push(mockLink)
|
||||
|
||||
const subgraphInput = subgraph.inputs[0]
|
||||
|
||||
// Should return false as the link doesn't have the method
|
||||
expect(connector.isSubgraphInputValidDrop(subgraphInput)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,144 +0,0 @@
|
||||
import { beforeEach, describe, expect, test } from 'vitest'
|
||||
|
||||
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
|
||||
|
||||
describe('Rectangle resize functionality', () => {
|
||||
let rect: Rectangle
|
||||
|
||||
beforeEach(() => {
|
||||
rect = new Rectangle(100, 200, 300, 400) // x, y, width, height
|
||||
// So: left=100, top=200, right=400, bottom=600
|
||||
})
|
||||
|
||||
describe('findContainingCorner', () => {
|
||||
const cornerSize = 15
|
||||
|
||||
test('should detect NW (top-left) corner', () => {
|
||||
expect(rect.findContainingCorner(100, 200, cornerSize)).toBe('NW')
|
||||
expect(rect.findContainingCorner(110, 210, cornerSize)).toBe('NW')
|
||||
expect(rect.findContainingCorner(114, 214, cornerSize)).toBe('NW')
|
||||
})
|
||||
|
||||
test('should detect NE (top-right) corner', () => {
|
||||
// Top-right corner starts at (right - cornerSize, top) = (385, 200)
|
||||
expect(rect.findContainingCorner(385, 200, cornerSize)).toBe('NE')
|
||||
expect(rect.findContainingCorner(390, 210, cornerSize)).toBe('NE')
|
||||
expect(rect.findContainingCorner(399, 214, cornerSize)).toBe('NE')
|
||||
})
|
||||
|
||||
test('should detect SW (bottom-left) corner', () => {
|
||||
// Bottom-left corner starts at (left, bottom - cornerSize) = (100, 585)
|
||||
expect(rect.findContainingCorner(100, 585, cornerSize)).toBe('SW')
|
||||
expect(rect.findContainingCorner(110, 590, cornerSize)).toBe('SW')
|
||||
expect(rect.findContainingCorner(114, 599, cornerSize)).toBe('SW')
|
||||
})
|
||||
|
||||
test('should detect SE (bottom-right) corner', () => {
|
||||
// Bottom-right corner starts at (right - cornerSize, bottom - cornerSize) = (385, 585)
|
||||
expect(rect.findContainingCorner(385, 585, cornerSize)).toBe('SE')
|
||||
expect(rect.findContainingCorner(390, 590, cornerSize)).toBe('SE')
|
||||
expect(rect.findContainingCorner(399, 599, cornerSize)).toBe('SE')
|
||||
})
|
||||
|
||||
test('should return undefined when not in any corner', () => {
|
||||
// Middle of rectangle
|
||||
expect(rect.findContainingCorner(250, 400, cornerSize)).toBeUndefined()
|
||||
// On edge but not in corner
|
||||
expect(rect.findContainingCorner(200, 200, cornerSize)).toBeUndefined()
|
||||
expect(rect.findContainingCorner(100, 400, cornerSize)).toBeUndefined()
|
||||
// Outside rectangle
|
||||
expect(rect.findContainingCorner(50, 150, cornerSize)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('corner detection methods', () => {
|
||||
const cornerSize = 20
|
||||
|
||||
describe('isInTopLeftCorner', () => {
|
||||
test('should return true when point is in top-left corner', () => {
|
||||
expect(rect.isInTopLeftCorner(100, 200, cornerSize)).toBe(true)
|
||||
expect(rect.isInTopLeftCorner(110, 210, cornerSize)).toBe(true)
|
||||
expect(rect.isInTopLeftCorner(119, 219, cornerSize)).toBe(true)
|
||||
})
|
||||
|
||||
test('should return false when point is outside top-left corner', () => {
|
||||
expect(rect.isInTopLeftCorner(120, 200, cornerSize)).toBe(false)
|
||||
expect(rect.isInTopLeftCorner(100, 220, cornerSize)).toBe(false)
|
||||
expect(rect.isInTopLeftCorner(99, 200, cornerSize)).toBe(false)
|
||||
expect(rect.isInTopLeftCorner(100, 199, cornerSize)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isInTopRightCorner', () => {
|
||||
test('should return true when point is in top-right corner', () => {
|
||||
// Top-right corner area is from (right - cornerSize, top) to (right, top + cornerSize)
|
||||
// That's (380, 200) to (400, 220)
|
||||
expect(rect.isInTopRightCorner(380, 200, cornerSize)).toBe(true)
|
||||
expect(rect.isInTopRightCorner(390, 210, cornerSize)).toBe(true)
|
||||
expect(rect.isInTopRightCorner(399, 219, cornerSize)).toBe(true)
|
||||
})
|
||||
|
||||
test('should return false when point is outside top-right corner', () => {
|
||||
expect(rect.isInTopRightCorner(379, 200, cornerSize)).toBe(false)
|
||||
expect(rect.isInTopRightCorner(400, 220, cornerSize)).toBe(false)
|
||||
expect(rect.isInTopRightCorner(401, 200, cornerSize)).toBe(false)
|
||||
expect(rect.isInTopRightCorner(400, 199, cornerSize)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isInBottomLeftCorner', () => {
|
||||
test('should return true when point is in bottom-left corner', () => {
|
||||
// Bottom-left corner area is from (left, bottom - cornerSize) to (left + cornerSize, bottom)
|
||||
// That's (100, 580) to (120, 600)
|
||||
expect(rect.isInBottomLeftCorner(100, 580, cornerSize)).toBe(true)
|
||||
expect(rect.isInBottomLeftCorner(110, 590, cornerSize)).toBe(true)
|
||||
expect(rect.isInBottomLeftCorner(119, 599, cornerSize)).toBe(true)
|
||||
})
|
||||
|
||||
test('should return false when point is outside bottom-left corner', () => {
|
||||
expect(rect.isInBottomLeftCorner(120, 600, cornerSize)).toBe(false)
|
||||
expect(rect.isInBottomLeftCorner(100, 579, cornerSize)).toBe(false)
|
||||
expect(rect.isInBottomLeftCorner(99, 600, cornerSize)).toBe(false)
|
||||
expect(rect.isInBottomLeftCorner(100, 601, cornerSize)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isInBottomRightCorner', () => {
|
||||
test('should return true when point is in bottom-right corner', () => {
|
||||
// Bottom-right corner area is from (right - cornerSize, bottom - cornerSize) to (right, bottom)
|
||||
// That's (380, 580) to (400, 600)
|
||||
expect(rect.isInBottomRightCorner(380, 580, cornerSize)).toBe(true)
|
||||
expect(rect.isInBottomRightCorner(390, 590, cornerSize)).toBe(true)
|
||||
expect(rect.isInBottomRightCorner(399, 599, cornerSize)).toBe(true)
|
||||
})
|
||||
|
||||
test('should return false when point is outside bottom-right corner', () => {
|
||||
expect(rect.isInBottomRightCorner(379, 600, cornerSize)).toBe(false)
|
||||
expect(rect.isInBottomRightCorner(400, 579, cornerSize)).toBe(false)
|
||||
expect(rect.isInBottomRightCorner(401, 600, cornerSize)).toBe(false)
|
||||
expect(rect.isInBottomRightCorner(400, 601, cornerSize)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
test('should handle zero-sized corner areas', () => {
|
||||
expect(rect.findContainingCorner(100, 200, 0)).toBeUndefined()
|
||||
expect(rect.isInTopLeftCorner(100, 200, 0)).toBe(false)
|
||||
})
|
||||
|
||||
test('should handle rectangles at origin', () => {
|
||||
const originRect = new Rectangle(0, 0, 100, 100)
|
||||
expect(originRect.findContainingCorner(0, 0, 10)).toBe('NW')
|
||||
// Bottom-right corner is at (90, 90) to (100, 100)
|
||||
expect(originRect.findContainingCorner(90, 90, 10)).toBe('SE')
|
||||
})
|
||||
|
||||
test('should handle negative coordinates', () => {
|
||||
const negRect = new Rectangle(-50, -50, 100, 100)
|
||||
expect(negRect.findContainingCorner(-50, -50, 10)).toBe('NW')
|
||||
// Bottom-right corner is at (40, 40) to (50, 50)
|
||||
expect(negRect.findContainingCorner(40, 40, 10)).toBe('SE')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,545 +0,0 @@
|
||||
import { test as baseTest, describe, expect, vi } from 'vitest'
|
||||
|
||||
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
|
||||
import type { Point, Size } from '@/lib/litegraph/src/interfaces'
|
||||
|
||||
// TODO: If there's a common test context, use it here
|
||||
// For now, we'll define a simple context for Rectangle tests
|
||||
const test = baseTest.extend<{ rect: Rectangle }>({
|
||||
// eslint-disable-next-line no-empty-pattern
|
||||
rect: async ({}, use) => {
|
||||
await use(new Rectangle())
|
||||
}
|
||||
})
|
||||
|
||||
describe('Rectangle', () => {
|
||||
describe('constructor and basic properties', () => {
|
||||
test('should create a default rectangle', ({ rect }) => {
|
||||
expect(rect.x).toBe(0)
|
||||
expect(rect.y).toBe(0)
|
||||
expect(rect.width).toBe(0)
|
||||
expect(rect.height).toBe(0)
|
||||
expect(rect.length).toBe(4)
|
||||
})
|
||||
|
||||
test('should create a rectangle with specified values', () => {
|
||||
const rect = new Rectangle(1, 2, 3, 4)
|
||||
expect(rect.x).toBe(1)
|
||||
expect(rect.y).toBe(2)
|
||||
expect(rect.width).toBe(3)
|
||||
expect(rect.height).toBe(4)
|
||||
})
|
||||
|
||||
test('should update the rectangle values', ({ rect }) => {
|
||||
const newValues: [number, number, number, number] = [1, 2, 3, 4]
|
||||
rect.updateTo(newValues)
|
||||
expect(rect.x).toBe(1)
|
||||
expect(rect.y).toBe(2)
|
||||
expect(rect.width).toBe(3)
|
||||
expect(rect.height).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('array operations', () => {
|
||||
test('should return a Float64Array representing the subarray', () => {
|
||||
const rect = new Rectangle(10, 20, 30, 40)
|
||||
const sub = rect.subarray(1, 3)
|
||||
expect(sub).toBeInstanceOf(Float64Array)
|
||||
expect(sub.length).toBe(2)
|
||||
expect(sub[0]).toBe(20) // y
|
||||
expect(sub[1]).toBe(30) // width
|
||||
})
|
||||
|
||||
test('should return a Float64Array for the entire array if no args', () => {
|
||||
const rect = new Rectangle(10, 20, 30, 40)
|
||||
const sub = rect.subarray()
|
||||
expect(sub).toBeInstanceOf(Float64Array)
|
||||
expect(sub.length).toBe(4)
|
||||
expect(sub[0]).toBe(10)
|
||||
expect(sub[1]).toBe(20)
|
||||
expect(sub[2]).toBe(30)
|
||||
expect(sub[3]).toBe(40)
|
||||
})
|
||||
|
||||
test('should return an array with [x, y, width, height]', () => {
|
||||
const rect = new Rectangle(1, 2, 3, 4)
|
||||
const arr = rect.toArray()
|
||||
expect(arr).toEqual([1, 2, 3, 4])
|
||||
expect(Array.isArray(arr)).toBe(true)
|
||||
expect(arr).not.toBeInstanceOf(Float64Array)
|
||||
|
||||
const exported = rect.export()
|
||||
expect(exported).toEqual([1, 2, 3, 4])
|
||||
expect(Array.isArray(exported)).toBe(true)
|
||||
expect(exported).not.toBeInstanceOf(Float64Array)
|
||||
})
|
||||
})
|
||||
|
||||
describe('position and size properties', () => {
|
||||
test('should get the position', ({ rect }) => {
|
||||
rect.x = 10
|
||||
rect.y = 20
|
||||
const pos = rect.pos
|
||||
expect(pos[0]).toBe(10)
|
||||
expect(pos[1]).toBe(20)
|
||||
expect(pos.length).toBe(2)
|
||||
})
|
||||
|
||||
test('should set the position', ({ rect }) => {
|
||||
const newPos: Point = [5, 15]
|
||||
rect.pos = newPos
|
||||
expect(rect.x).toBe(5)
|
||||
expect(rect.y).toBe(15)
|
||||
})
|
||||
|
||||
test('should update the rectangle when the returned pos object is modified', ({
|
||||
rect
|
||||
}) => {
|
||||
rect.x = 1
|
||||
rect.y = 2
|
||||
const pos = rect.pos
|
||||
pos[0] = 100
|
||||
pos[1] = 200
|
||||
expect(rect.x).toBe(100)
|
||||
expect(rect.y).toBe(200)
|
||||
})
|
||||
|
||||
test('should get the size', ({ rect }) => {
|
||||
rect.width = 30
|
||||
rect.height = 40
|
||||
const size = rect.size
|
||||
expect(size[0]).toBe(30)
|
||||
expect(size[1]).toBe(40)
|
||||
expect(size.length).toBe(2)
|
||||
})
|
||||
|
||||
test('should set the size', ({ rect }) => {
|
||||
const newSize: Size = [35, 45]
|
||||
rect.size = newSize
|
||||
expect(rect.width).toBe(35)
|
||||
expect(rect.height).toBe(45)
|
||||
})
|
||||
|
||||
test('should update the rectangle when the returned size object is modified', ({
|
||||
rect
|
||||
}) => {
|
||||
rect.width = 3
|
||||
rect.height = 4
|
||||
const size = rect.size
|
||||
size[0] = 300
|
||||
size[1] = 400
|
||||
expect(rect.width).toBe(300)
|
||||
expect(rect.height).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge properties', () => {
|
||||
test('should get x', ({ rect }) => {
|
||||
rect[0] = 5
|
||||
expect(rect.x).toBe(5)
|
||||
})
|
||||
|
||||
test('should set x', ({ rect }) => {
|
||||
rect.x = 10
|
||||
expect(rect[0]).toBe(10)
|
||||
})
|
||||
|
||||
test('should get y', ({ rect }) => {
|
||||
rect[1] = 6
|
||||
expect(rect.y).toBe(6)
|
||||
})
|
||||
|
||||
test('should set y', ({ rect }) => {
|
||||
rect.y = 11
|
||||
expect(rect[1]).toBe(11)
|
||||
})
|
||||
|
||||
test('should get width', ({ rect }) => {
|
||||
rect[2] = 7
|
||||
expect(rect.width).toBe(7)
|
||||
})
|
||||
|
||||
test('should set width', ({ rect }) => {
|
||||
rect.width = 12
|
||||
expect(rect[2]).toBe(12)
|
||||
})
|
||||
|
||||
test('should get height', ({ rect }) => {
|
||||
rect[3] = 8
|
||||
expect(rect.height).toBe(8)
|
||||
})
|
||||
|
||||
test('should set height', ({ rect }) => {
|
||||
rect.height = 13
|
||||
expect(rect[3]).toBe(13)
|
||||
})
|
||||
|
||||
test('should get left', ({ rect }) => {
|
||||
rect[0] = 1
|
||||
expect(rect.left).toBe(1)
|
||||
})
|
||||
|
||||
test('should set left', ({ rect }) => {
|
||||
rect.left = 2
|
||||
expect(rect[0]).toBe(2)
|
||||
})
|
||||
|
||||
test('should get top', ({ rect }) => {
|
||||
rect[1] = 3
|
||||
expect(rect.top).toBe(3)
|
||||
})
|
||||
|
||||
test('should set top', ({ rect }) => {
|
||||
rect.top = 4
|
||||
expect(rect[1]).toBe(4)
|
||||
})
|
||||
|
||||
test('should get right', ({ rect }) => {
|
||||
rect[0] = 1
|
||||
rect[2] = 10
|
||||
expect(rect.right).toBe(11)
|
||||
})
|
||||
|
||||
test('should set right', ({ rect }) => {
|
||||
rect.x = 1
|
||||
rect.width = 10 // right is 11
|
||||
rect.right = 20 // new right
|
||||
expect(rect.x).toBe(10) // x = right - width = 20 - 10
|
||||
expect(rect.width).toBe(10)
|
||||
})
|
||||
|
||||
test('should get bottom', ({ rect }) => {
|
||||
rect[1] = 2
|
||||
rect[3] = 20
|
||||
expect(rect.bottom).toBe(22)
|
||||
})
|
||||
|
||||
test('should set bottom', ({ rect }) => {
|
||||
rect.y = 2
|
||||
rect.height = 20 // bottom is 22
|
||||
rect.bottom = 30 // new bottom
|
||||
expect(rect.y).toBe(10) // y = bottom - height = 30 - 20
|
||||
expect(rect.height).toBe(20)
|
||||
})
|
||||
|
||||
test('should get centreX', () => {
|
||||
const rect = new Rectangle(0, 0, 10, 0)
|
||||
expect(rect.centreX).toBe(5)
|
||||
rect.x = 5
|
||||
expect(rect.centreX).toBe(10)
|
||||
rect.width = 20
|
||||
expect(rect.centreX).toBe(15) // 5 + (20 * 0.5)
|
||||
})
|
||||
|
||||
test('should get centreY', () => {
|
||||
const rect = new Rectangle(0, 0, 0, 10)
|
||||
expect(rect.centreY).toBe(5)
|
||||
rect.y = 5
|
||||
expect(rect.centreY).toBe(10)
|
||||
rect.height = 20
|
||||
expect(rect.centreY).toBe(15) // 5 + (20 * 0.5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('geometric operations', () => {
|
||||
test('should return the centre point', () => {
|
||||
const rect = new Rectangle(10, 20, 30, 40) // centreX = 10 + 15 = 25, centreY = 20 + 20 = 40
|
||||
const centre = rect.getCentre()
|
||||
expect(centre[0]).toBe(25)
|
||||
expect(centre[1]).toBe(40)
|
||||
expect(centre).not.toBe(rect.pos) // Should be a new Point
|
||||
})
|
||||
|
||||
test('should return the area', () => {
|
||||
expect(new Rectangle(0, 0, 5, 10).getArea()).toBe(50)
|
||||
expect(new Rectangle(1, 1, 0, 10).getArea()).toBe(0)
|
||||
})
|
||||
|
||||
test('should return the perimeter', () => {
|
||||
expect(new Rectangle(0, 0, 5, 10).getPerimeter()).toBe(30) // 2 * (5+10)
|
||||
expect(new Rectangle(0, 0, 0, 0).getPerimeter()).toBe(0)
|
||||
})
|
||||
|
||||
test('should return the top-left point', () => {
|
||||
const rect = new Rectangle(1, 2, 3, 4)
|
||||
const tl = rect.getTopLeft()
|
||||
expect(tl[0]).toBe(1)
|
||||
expect(tl[1]).toBe(2)
|
||||
expect(tl).not.toBe(rect.pos)
|
||||
})
|
||||
|
||||
test('should return the bottom-right point', () => {
|
||||
const rect = new Rectangle(1, 2, 10, 20) // right=11, bottom=22
|
||||
const br = rect.getBottomRight()
|
||||
expect(br[0]).toBe(11)
|
||||
expect(br[1]).toBe(22)
|
||||
})
|
||||
|
||||
test('should return the size', () => {
|
||||
const rect = new Rectangle(1, 2, 30, 40)
|
||||
const s = rect.getSize()
|
||||
expect(s[0]).toBe(30)
|
||||
expect(s[1]).toBe(40)
|
||||
expect(s).not.toBe(rect.size)
|
||||
})
|
||||
|
||||
test('should return the offset from top-left to the point', () => {
|
||||
const rect = new Rectangle(10, 20, 5, 5)
|
||||
const offset = rect.getOffsetTo([12, 23])
|
||||
expect(offset[0]).toBe(2) // 12 - 10
|
||||
expect(offset[1]).toBe(3) // 23 - 20
|
||||
})
|
||||
|
||||
test('should return the offset from the point to the top-left', () => {
|
||||
const rect = new Rectangle(10, 20, 5, 5)
|
||||
const offset = rect.getOffsetFrom([12, 23])
|
||||
expect(offset[0]).toBe(-2) // 10 - 12
|
||||
expect(offset[1]).toBe(-3) // 20 - 23
|
||||
})
|
||||
})
|
||||
|
||||
describe('containment and overlap', () => {
|
||||
const rect = new Rectangle(10, 10, 20, 20) // x: 10, y: 10, right: 30, bottom: 30
|
||||
|
||||
test.each([
|
||||
[10, 10, true], // top-left corner
|
||||
[29, 29, true], // bottom-right corner
|
||||
[15, 15, true], // inside
|
||||
[5, 15, false], // outside left
|
||||
[30, 15, false], // outside right
|
||||
[15, 5, false], // outside top
|
||||
[15, 30, false], // outside bottom
|
||||
[10, 29, true], // on bottom edge
|
||||
[29, 10, true] // on right edge
|
||||
])(
|
||||
'when checking if (%s, %s) is inside, should return %s',
|
||||
(x, y, expected) => {
|
||||
expect(rect.containsXy(x, y)).toBe(expected)
|
||||
}
|
||||
)
|
||||
|
||||
test.each([
|
||||
[[0, 0] as Point, true],
|
||||
[[9, 9] as Point, true],
|
||||
[[5, 5] as Point, true],
|
||||
[[-1, 5] as Point, false],
|
||||
[[11, 5] as Point, false],
|
||||
[[5, -1] as Point, false],
|
||||
[[5, 11] as Point, false]
|
||||
])('should return %s for point %j', (point: Point, expected: boolean) => {
|
||||
rect.updateTo([0, 0, 10, 10])
|
||||
expect(rect.containsPoint(point)).toBe(expected)
|
||||
})
|
||||
|
||||
test.each([
|
||||
// Completely inside
|
||||
[new Rectangle(10, 10, 10, 10), true],
|
||||
// Touching edges
|
||||
[new Rectangle(0, 0, 10, 10), true],
|
||||
[new Rectangle(90, 90, 10, 10), true],
|
||||
// Partially outside
|
||||
[new Rectangle(-10, 10, 20, 20), false],
|
||||
[new Rectangle(10, -10, 20, 20), false],
|
||||
[new Rectangle(90, 10, 20, 20), false],
|
||||
[new Rectangle(10, 90, 20, 20), false],
|
||||
// Completely outside
|
||||
[new Rectangle(200, 200, 10, 10), false],
|
||||
// Outer rectangle is smaller
|
||||
[new Rectangle(0, 0, 5, 5), new Rectangle(0, 0, 10, 10), true],
|
||||
// Same size
|
||||
[new Rectangle(0, 0, 99, 99), true]
|
||||
])(
|
||||
'should return %s when checking if %s is inside outer rect',
|
||||
(
|
||||
inner: Rectangle,
|
||||
expectedOrOuter: boolean | Rectangle,
|
||||
expectedIfThreeArgs?: boolean
|
||||
) => {
|
||||
let testOuter = rect
|
||||
rect.updateTo([0, 0, 100, 100])
|
||||
|
||||
let testExpected = expectedOrOuter as boolean
|
||||
if (typeof expectedOrOuter !== 'boolean') {
|
||||
testOuter = expectedOrOuter as Rectangle
|
||||
testExpected = expectedIfThreeArgs as boolean
|
||||
}
|
||||
expect(testOuter.containsRect(inner)).toBe(testExpected)
|
||||
}
|
||||
)
|
||||
|
||||
test.each([
|
||||
// Completely overlapping
|
||||
[new Rectangle(15, 15, 10, 10), true], // r2 inside r1
|
||||
// Partially overlapping
|
||||
[new Rectangle(0, 0, 15, 15), true], // r2 top-left of r1
|
||||
[new Rectangle(20, 0, 15, 15), true], // r2 top-right of r1
|
||||
[new Rectangle(0, 20, 15, 15), true], // r2 bottom-left of r1
|
||||
[new Rectangle(20, 20, 15, 15), true], // r2 bottom-right of r1
|
||||
[new Rectangle(15, 5, 10, 30), true], // r2 overlaps vertically
|
||||
[new Rectangle(5, 15, 30, 10), true], // r2 overlaps horizontally
|
||||
// Touching (not overlapping by definition used)
|
||||
[new Rectangle(30, 10, 10, 10), false], // r2 to the right, touching
|
||||
[new Rectangle(0, 10, 10, 10), false], // r2 to the left, touching
|
||||
[new Rectangle(10, 30, 10, 10), false], // r2 below, touching
|
||||
[new Rectangle(10, 0, 10, 10), false], // r2 above, touching
|
||||
// Not overlapping
|
||||
[new Rectangle(100, 100, 5, 5), false], // r2 far away
|
||||
[new Rectangle(0, 0, 5, 5), false], // r2 outside top-left
|
||||
// rect1 inside rect2
|
||||
[new Rectangle(0, 0, 100, 100), true]
|
||||
])('should return %s for overlap with %s', (rect2, expected) => {
|
||||
const rect = new Rectangle(10, 10, 20, 20) // 10,10 to 30,30
|
||||
|
||||
expect(rect.overlaps(rect2)).toBe(expected)
|
||||
// Overlap should be commutative
|
||||
expect(rect2.overlaps(rect)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resize operations', () => {
|
||||
test('should resize from top-left corner while maintaining bottom-right', ({
|
||||
rect
|
||||
}) => {
|
||||
rect.updateTo([10, 10, 20, 20]) // x: 10, y: 10, width: 20, height: 20
|
||||
rect.resizeTopLeft(5, 5)
|
||||
expect(rect.x).toBe(5)
|
||||
expect(rect.y).toBe(5)
|
||||
expect(rect.width).toBe(25) // 20 + (10 - 5)
|
||||
expect(rect.height).toBe(25) // 20 + (10 - 5)
|
||||
})
|
||||
|
||||
test('should handle negative coordinates for top-left resize', ({
|
||||
rect
|
||||
}) => {
|
||||
rect.updateTo([10, 10, 20, 20])
|
||||
rect.resizeTopLeft(-5, -5)
|
||||
expect(rect.x).toBe(-5)
|
||||
expect(rect.y).toBe(-5)
|
||||
expect(rect.width).toBe(35) // 20 + (10 - (-5))
|
||||
expect(rect.height).toBe(35) // 20 + (10 - (-5))
|
||||
})
|
||||
|
||||
test('should resize from bottom-left corner while maintaining top-right', ({
|
||||
rect
|
||||
}) => {
|
||||
rect.updateTo([10, 10, 20, 20])
|
||||
rect.resizeBottomLeft(5, 35)
|
||||
expect(rect.x).toBe(5)
|
||||
expect(rect.y).toBe(10)
|
||||
expect(rect.width).toBe(25) // 20 + (10 - 5)
|
||||
expect(rect.height).toBe(25) // 35 - 10
|
||||
})
|
||||
|
||||
test('should handle negative coordinates for bottom-left resize', ({
|
||||
rect
|
||||
}) => {
|
||||
rect.updateTo([10, 10, 20, 20])
|
||||
rect.resizeBottomLeft(-5, 35)
|
||||
expect(rect.x).toBe(-5)
|
||||
expect(rect.y).toBe(10)
|
||||
expect(rect.width).toBe(35) // 20 + (10 - (-5))
|
||||
expect(rect.height).toBe(25) // 35 - 10
|
||||
})
|
||||
|
||||
test('should resize from top-right corner while maintaining bottom-left', ({
|
||||
rect
|
||||
}) => {
|
||||
rect.updateTo([10, 10, 20, 20])
|
||||
rect.resizeTopRight(35, 5)
|
||||
expect(rect.x).toBe(10)
|
||||
expect(rect.y).toBe(5)
|
||||
expect(rect.width).toBe(25) // 35 - 10
|
||||
expect(rect.height).toBe(25) // 20 + (10 - 5)
|
||||
})
|
||||
|
||||
test('should handle negative coordinates for top-right resize', ({
|
||||
rect
|
||||
}) => {
|
||||
rect.updateTo([10, 10, 20, 20])
|
||||
rect.resizeTopRight(35, -5)
|
||||
expect(rect.x).toBe(10)
|
||||
expect(rect.y).toBe(-5)
|
||||
expect(rect.width).toBe(25) // 35 - 10
|
||||
expect(rect.height).toBe(35) // 20 + (10 - (-5))
|
||||
})
|
||||
|
||||
test('should resize from bottom-right corner while maintaining top-left', ({
|
||||
rect
|
||||
}) => {
|
||||
rect.updateTo([10, 10, 20, 20])
|
||||
rect.resizeBottomRight(35, 35)
|
||||
expect(rect.x).toBe(10)
|
||||
expect(rect.y).toBe(10)
|
||||
expect(rect.width).toBe(25) // 35 - 10
|
||||
expect(rect.height).toBe(25) // 35 - 10
|
||||
})
|
||||
|
||||
test('should handle negative coordinates for bottom-right resize', ({
|
||||
rect
|
||||
}) => {
|
||||
rect.updateTo([10, 10, 20, 20])
|
||||
rect.resizeBottomRight(35, -5)
|
||||
expect(rect.x).toBe(10)
|
||||
expect(rect.y).toBe(10)
|
||||
expect(rect.width).toBe(25) // 35 - 10
|
||||
expect(rect.height).toBe(-15) // -5 - 10
|
||||
})
|
||||
|
||||
test('should set width, anchoring the right edge', () => {
|
||||
const rect = new Rectangle(10, 0, 20, 0) // x:10, width:20 -> right:30
|
||||
rect.setWidthRightAnchored(15) // new width 15
|
||||
expect(rect.width).toBe(15)
|
||||
expect(rect.x).toBe(15) // x = oldX + (oldWidth - newWidth) = 10 + (20 - 15) = 15
|
||||
expect(rect.right).toBe(30) // right should remain 30 (15+15)
|
||||
})
|
||||
|
||||
test('should set height, anchoring the bottom edge', () => {
|
||||
const rect = new Rectangle(0, 10, 0, 20) // y:10, height:20 -> bottom:30
|
||||
rect.setHeightBottomAnchored(15) // new height 15
|
||||
expect(rect.height).toBe(15)
|
||||
expect(rect.y).toBe(15) // y = oldY + (oldHeight - newHeight) = 10 + (20-15) = 15
|
||||
expect(rect.bottom).toBe(30) // bottom should remain 30 (15+15)
|
||||
})
|
||||
})
|
||||
|
||||
describe('debug drawing', () => {
|
||||
test('should call canvas context methods', () => {
|
||||
const rect = new Rectangle(10, 20, 30, 40)
|
||||
const mockCtx = {
|
||||
strokeStyle: 'black',
|
||||
lineWidth: 1,
|
||||
beginPath: vi.fn(),
|
||||
strokeRect: vi.fn()
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
rect._drawDebug(mockCtx, 'blue')
|
||||
|
||||
expect(mockCtx.beginPath).toHaveBeenCalledOnce()
|
||||
expect(mockCtx.strokeRect).toHaveBeenCalledWith(10, 20, 30, 40)
|
||||
expect(mockCtx.strokeStyle).toBe('black') // Restored
|
||||
expect(mockCtx.lineWidth).toBe(1) // Restored
|
||||
|
||||
// Check if it was set during the call
|
||||
// This is a bit tricky as it's restored in finally.
|
||||
// We'd need to spy on the setter or check the calls in order.
|
||||
// For simplicity, we're assuming the implementation is correct if strokeRect was called with correct params.
|
||||
// A more robust test could involve spying on property assignments if vitest supports it easily.
|
||||
})
|
||||
|
||||
test('should use default color if not provided', () => {
|
||||
const rect = new Rectangle(1, 2, 3, 4)
|
||||
const mockCtx = {
|
||||
strokeStyle: 'black',
|
||||
lineWidth: 1,
|
||||
beginPath: vi.fn(),
|
||||
strokeRect: vi.fn()
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
rect._drawDebug(mockCtx)
|
||||
// Check if strokeStyle was "red" at the time of strokeRect
|
||||
// This requires a more complex mock or observing calls.
|
||||
// A simple check is that it ran without error and values were restored.
|
||||
expect(mockCtx.strokeRect).toHaveBeenCalledWith(1, 2, 3, 4)
|
||||
expect(mockCtx.strokeStyle).toBe('black')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,45 +0,0 @@
|
||||
import { clamp } from 'es-toolkit/compat'
|
||||
import { beforeEach, describe, expect, vi } from 'vitest'
|
||||
|
||||
import { LiteGraphGlobal } from '@/lib/litegraph/src/LiteGraphGlobal'
|
||||
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { test } from './testExtensions'
|
||||
|
||||
describe('Litegraph module', () => {
|
||||
test('contains a global export', ({ expect }) => {
|
||||
expect(LiteGraph).toBeInstanceOf(LiteGraphGlobal)
|
||||
expect(LiteGraph.LGraphCanvas).toBe(LGraphCanvas)
|
||||
})
|
||||
|
||||
test('has the same structure', ({ expect }) => {
|
||||
const lgGlobal = new LiteGraphGlobal()
|
||||
expect(lgGlobal).toMatchSnapshot('minLGraph')
|
||||
})
|
||||
|
||||
test('clamps values', () => {
|
||||
expect(clamp(-1.124, 13, 24)).toStrictEqual(13)
|
||||
expect(clamp(Infinity, 18, 29)).toStrictEqual(29)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Import order dependency', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
test('Imports without error when entry point is imported first', async ({
|
||||
expect
|
||||
}) => {
|
||||
async function importNormally() {
|
||||
const entryPointImport = await import('@/lib/litegraph/src/litegraph')
|
||||
const directImport = await import('@/lib/litegraph/src/LGraph')
|
||||
|
||||
// Sanity check that imports were cleared.
|
||||
expect(Object.is(LiteGraph, entryPointImport.LiteGraph)).toBe(false)
|
||||
expect(Object.is(LiteGraph.LGraph, directImport.LGraph)).toBe(false)
|
||||
}
|
||||
|
||||
await expect(importNormally()).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,299 +0,0 @@
|
||||
import { test as baseTest } from 'vitest'
|
||||
|
||||
import type { Point, Rect } from '../src/interfaces'
|
||||
import {
|
||||
addDirectionalOffset,
|
||||
containsCentre,
|
||||
containsRect,
|
||||
createBounds,
|
||||
dist2,
|
||||
distance,
|
||||
findPointOnCurve,
|
||||
getOrientation,
|
||||
isInRect,
|
||||
isInRectangle,
|
||||
isInsideRectangle,
|
||||
isPointInRect,
|
||||
overlapBounding,
|
||||
rotateLink,
|
||||
snapPoint
|
||||
} from '../src/measure'
|
||||
import { LinkDirection } from '../src/types/globalEnums'
|
||||
|
||||
const test = baseTest.extend({})
|
||||
|
||||
test('distance calculates correct distance between two points', ({
|
||||
expect
|
||||
}) => {
|
||||
expect(distance([0, 0], [3, 4])).toBe(5) // 3-4-5 triangle
|
||||
expect(distance([1, 1], [4, 5])).toBe(5) // Same triangle, shifted
|
||||
expect(distance([0, 0], [0, 0])).toBe(0) // Same point
|
||||
})
|
||||
|
||||
test('dist2 calculates squared distance between points', ({ expect }) => {
|
||||
expect(dist2(0, 0, 3, 4)).toBe(25) // 3-4-5 triangle squared
|
||||
expect(dist2(1, 1, 4, 5)).toBe(25) // Same triangle, shifted
|
||||
expect(dist2(0, 0, 0, 0)).toBe(0) // Same point
|
||||
})
|
||||
|
||||
test('isInRectangle correctly identifies points inside rectangle', ({
|
||||
expect
|
||||
}) => {
|
||||
// Test points inside
|
||||
expect(isInRectangle(5, 5, 0, 0, 10, 10)).toBe(true)
|
||||
// Test points on edges (should be true)
|
||||
expect(isInRectangle(0, 5, 0, 0, 10, 10)).toBe(true)
|
||||
expect(isInRectangle(5, 0, 0, 0, 10, 10)).toBe(true)
|
||||
// Test points outside
|
||||
expect(isInRectangle(-1, 5, 0, 0, 10, 10)).toBe(false)
|
||||
expect(isInRectangle(11, 5, 0, 0, 10, 10)).toBe(false)
|
||||
})
|
||||
|
||||
test('isPointInRect correctly identifies points inside rectangle', ({
|
||||
expect
|
||||
}) => {
|
||||
const rect: Rect = [0, 0, 10, 10]
|
||||
expect(isPointInRect([5, 5], rect)).toBe(true)
|
||||
expect(isPointInRect([-1, 5], rect)).toBe(false)
|
||||
})
|
||||
|
||||
test('overlapBounding correctly identifies overlapping rectangles', ({
|
||||
expect
|
||||
}) => {
|
||||
const rect1: Rect = [0, 0, 10, 10]
|
||||
const rect2: Rect = [5, 5, 10, 10]
|
||||
const rect3: Rect = [20, 20, 10, 10]
|
||||
|
||||
expect(overlapBounding(rect1, rect2)).toBe(true)
|
||||
expect(overlapBounding(rect1, rect3)).toBe(false)
|
||||
})
|
||||
|
||||
test('containsCentre correctly identifies if rectangle contains center of another', ({
|
||||
expect
|
||||
}) => {
|
||||
const container: Rect = [0, 0, 20, 20]
|
||||
const inside: Rect = [5, 5, 10, 10] // Center at 10,10
|
||||
const outside: Rect = [15, 15, 10, 10] // Center at 20,20
|
||||
|
||||
expect(containsCentre(container, inside)).toBe(true)
|
||||
expect(containsCentre(container, outside)).toBe(false)
|
||||
})
|
||||
|
||||
test('addDirectionalOffset correctly adds offsets', ({ expect }) => {
|
||||
const point: Point = [10, 10]
|
||||
|
||||
// Test each direction
|
||||
addDirectionalOffset(5, LinkDirection.RIGHT, point)
|
||||
expect(point).toEqual([15, 10])
|
||||
|
||||
point[0] = 10 // Reset X
|
||||
addDirectionalOffset(5, LinkDirection.LEFT, point)
|
||||
expect(point).toEqual([5, 10])
|
||||
|
||||
point[0] = 10 // Reset X
|
||||
addDirectionalOffset(5, LinkDirection.DOWN, point)
|
||||
expect(point).toEqual([10, 15])
|
||||
|
||||
point[1] = 10 // Reset Y
|
||||
addDirectionalOffset(5, LinkDirection.UP, point)
|
||||
expect(point).toEqual([10, 5])
|
||||
})
|
||||
|
||||
test('findPointOnCurve correctly interpolates curve points', ({ expect }) => {
|
||||
const out: Point = [0, 0]
|
||||
const start: Point = [0, 0]
|
||||
const end: Point = [10, 10]
|
||||
const controlA: Point = [0, 10]
|
||||
const controlB: Point = [10, 0]
|
||||
|
||||
// Test midpoint
|
||||
findPointOnCurve(out, start, end, controlA, controlB, 0.5)
|
||||
expect(out[0]).toBeCloseTo(5)
|
||||
expect(out[1]).toBeCloseTo(5)
|
||||
})
|
||||
|
||||
test('snapPoint correctly snaps points to grid', ({ expect }) => {
|
||||
const point: Point = [12.3, 18.7]
|
||||
|
||||
// Snap to 5
|
||||
snapPoint(point, 5)
|
||||
expect(point).toEqual([10, 20])
|
||||
|
||||
// Test with no snap
|
||||
const point2: Point = [12.3, 18.7]
|
||||
expect(snapPoint(point2, 0)).toBe(false)
|
||||
expect(point2).toEqual([12.3, 18.7])
|
||||
|
||||
const point3: Point = [15, 24.499]
|
||||
expect(snapPoint(point3, 10)).toBe(true)
|
||||
expect(point3).toEqual([20, 20])
|
||||
})
|
||||
|
||||
test('createBounds correctly creates bounding box', ({ expect }) => {
|
||||
const objects = [
|
||||
{ boundingRect: [0, 0, 10, 10] as Rect },
|
||||
{ boundingRect: [5, 5, 10, 10] as Rect }
|
||||
]
|
||||
|
||||
const defaultBounds = createBounds(objects)
|
||||
expect(defaultBounds).toEqual([-10, -10, 35, 35])
|
||||
|
||||
const bounds = createBounds(objects, 5)
|
||||
expect(bounds).toEqual([-5, -5, 25, 25])
|
||||
|
||||
// Test empty set
|
||||
expect(createBounds([])).toBe(null)
|
||||
})
|
||||
|
||||
test('isInsideRectangle handles edge cases differently from isInRectangle', ({
|
||||
expect
|
||||
}) => {
|
||||
// isInsideRectangle returns false when point is exactly on left or top edge
|
||||
expect(isInsideRectangle(0, 5, 0, 0, 10, 10)).toBe(false)
|
||||
expect(isInsideRectangle(5, 0, 0, 0, 10, 10)).toBe(false)
|
||||
|
||||
// Points just inside
|
||||
expect(isInsideRectangle(0.1, 5, 0, 0, 10, 10)).toBe(true)
|
||||
expect(isInsideRectangle(5, 0.1, 0, 0, 10, 10)).toBe(true)
|
||||
|
||||
// Points clearly inside
|
||||
expect(isInsideRectangle(5, 5, 0, 0, 10, 10)).toBe(true)
|
||||
|
||||
// Points outside
|
||||
expect(isInsideRectangle(-1, 5, 0, 0, 10, 10)).toBe(false)
|
||||
expect(isInsideRectangle(11, 5, 0, 0, 10, 10)).toBe(false)
|
||||
})
|
||||
|
||||
test('containsRect correctly identifies nested rectangles', ({ expect }) => {
|
||||
const container: Rect = [0, 0, 20, 20]
|
||||
|
||||
// Fully contained rectangle
|
||||
const inside: Rect = [5, 5, 10, 10]
|
||||
expect(containsRect(container, inside)).toBe(true)
|
||||
|
||||
// Partially overlapping rectangle
|
||||
const partial: Rect = [15, 15, 10, 10]
|
||||
expect(containsRect(container, partial)).toBe(false)
|
||||
|
||||
// Completely outside rectangle
|
||||
const outside: Rect = [30, 30, 10, 10]
|
||||
expect(containsRect(container, outside)).toBe(false)
|
||||
|
||||
// Same size rectangle at same position (should return false)
|
||||
const identical: Rect = [0, 0, 20, 20]
|
||||
expect(containsRect(container, identical)).toBe(false)
|
||||
|
||||
// Larger rectangle (should return false)
|
||||
const larger: Rect = [-5, -5, 30, 30]
|
||||
expect(containsRect(container, larger)).toBe(false)
|
||||
})
|
||||
|
||||
test('rotateLink correctly rotates offsets between directions', ({
|
||||
expect
|
||||
}) => {
|
||||
const testCases = [
|
||||
{
|
||||
offset: [10, 5] as Point,
|
||||
from: LinkDirection.LEFT,
|
||||
to: LinkDirection.RIGHT,
|
||||
expected: [-10, -5]
|
||||
},
|
||||
{
|
||||
offset: [10, 5] as Point,
|
||||
from: LinkDirection.LEFT,
|
||||
to: LinkDirection.UP,
|
||||
expected: [5, -10]
|
||||
},
|
||||
{
|
||||
offset: [10, 5] as Point,
|
||||
from: LinkDirection.LEFT,
|
||||
to: LinkDirection.DOWN,
|
||||
expected: [-5, 10]
|
||||
},
|
||||
{
|
||||
offset: [10, 5] as Point,
|
||||
from: LinkDirection.RIGHT,
|
||||
to: LinkDirection.LEFT,
|
||||
expected: [-10, -5]
|
||||
},
|
||||
{
|
||||
offset: [10, 5] as Point,
|
||||
from: LinkDirection.UP,
|
||||
to: LinkDirection.DOWN,
|
||||
expected: [-10, -5]
|
||||
}
|
||||
]
|
||||
|
||||
for (const { offset, from, to, expected } of testCases) {
|
||||
const testOffset = [...offset] as Point
|
||||
rotateLink(testOffset, from, to)
|
||||
expect(testOffset).toEqual(expected)
|
||||
}
|
||||
|
||||
// Test no rotation when directions are the same
|
||||
const sameDir = [10, 5] as Point
|
||||
rotateLink(sameDir, LinkDirection.LEFT, LinkDirection.LEFT)
|
||||
expect(sameDir).toEqual([10, 5])
|
||||
|
||||
// Test center/none cases
|
||||
const centerCase = [10, 5] as Point
|
||||
rotateLink(centerCase, LinkDirection.LEFT, LinkDirection.CENTER)
|
||||
expect(centerCase).toEqual([10, 5])
|
||||
|
||||
const noneCase = [10, 5] as Point
|
||||
rotateLink(noneCase, LinkDirection.LEFT, LinkDirection.NONE)
|
||||
expect(noneCase).toEqual([10, 5])
|
||||
})
|
||||
|
||||
test('getOrientation correctly determines point position relative to line', ({
|
||||
expect
|
||||
}) => {
|
||||
const lineStart: Point = [0, 0]
|
||||
const lineEnd: Point = [10, 10]
|
||||
|
||||
// Point to the left of the line
|
||||
expect(getOrientation(lineStart, lineEnd, 0, 10)).toBeLessThan(0)
|
||||
|
||||
// Point to the right of the line
|
||||
expect(getOrientation(lineStart, lineEnd, 10, 0)).toBeGreaterThan(0)
|
||||
|
||||
// Point on the line
|
||||
expect(getOrientation(lineStart, lineEnd, 5, 5)).toBe(0)
|
||||
|
||||
// Test with horizontal line
|
||||
const hLineEnd: Point = [10, 0]
|
||||
expect(getOrientation(lineStart, hLineEnd, 5, 5)).toBeLessThan(0) // Above line
|
||||
expect(getOrientation(lineStart, hLineEnd, 5, -5)).toBeGreaterThan(0) // Below line
|
||||
|
||||
// Test with vertical line
|
||||
const vLineEnd: Point = [0, 10]
|
||||
expect(getOrientation(lineStart, vLineEnd, 5, 5)).toBeGreaterThan(0) // Right of line
|
||||
expect(getOrientation(lineStart, vLineEnd, -5, 5)).toBeLessThan(0) // Left of line
|
||||
})
|
||||
|
||||
test('isInRect correctly identifies if point coordinates are inside rectangle', ({
|
||||
expect
|
||||
}) => {
|
||||
const rect: Rect = [0, 0, 10, 10]
|
||||
|
||||
// Points inside
|
||||
expect(isInRect(5, 5, rect)).toBe(true)
|
||||
|
||||
// Points on edges (should be true for left/top, false for right/bottom)
|
||||
expect(isInRect(0, 5, rect)).toBe(true) // Left edge
|
||||
expect(isInRect(5, 0, rect)).toBe(true) // Top edge
|
||||
expect(isInRect(10, 5, rect)).toBe(false) // Right edge
|
||||
expect(isInRect(5, 10, rect)).toBe(false) // Bottom edge
|
||||
|
||||
// Points at corners
|
||||
expect(isInRect(0, 0, rect)).toBe(true) // Top-left
|
||||
expect(isInRect(10, 0, rect)).toBe(false) // Top-right
|
||||
expect(isInRect(0, 10, rect)).toBe(false) // Bottom-left
|
||||
expect(isInRect(10, 10, rect)).toBe(false) // Bottom-right
|
||||
|
||||
// Points outside
|
||||
expect(isInRect(-1, 5, rect)).toBe(false)
|
||||
expect(isInRect(11, 5, rect)).toBe(false)
|
||||
expect(isInRect(5, -1, rect)).toBe(false)
|
||||
expect(isInRect(5, 11, rect)).toBe(false)
|
||||
})
|
||||
@@ -1,29 +0,0 @@
|
||||
import { describe } from 'vitest'
|
||||
|
||||
import { LGraph, LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ISerialisedGraph } from '@/lib/litegraph/src/types/serialisation'
|
||||
|
||||
import { test } from './testExtensions'
|
||||
|
||||
describe('LGraph Serialisation', () => {
|
||||
test('can (de)serialise node / group titles', ({ expect, minimalGraph }) => {
|
||||
const nodeTitle = 'Test Node'
|
||||
const groupTitle = 'Test Group'
|
||||
|
||||
minimalGraph.add(new LGraphNode(nodeTitle))
|
||||
minimalGraph.add(new LGraphGroup(groupTitle))
|
||||
|
||||
expect(minimalGraph.nodes.length).toBe(1)
|
||||
expect(minimalGraph.nodes[0].title).toEqual(nodeTitle)
|
||||
|
||||
expect(minimalGraph.groups.length).toBe(1)
|
||||
expect(minimalGraph.groups[0].title).toEqual(groupTitle)
|
||||
|
||||
const serialised = JSON.stringify(minimalGraph.serialize())
|
||||
const deserialised = JSON.parse(serialised) as ISerialisedGraph
|
||||
|
||||
const copied = new LGraph(deserialised)
|
||||
expect(copied.nodes.length).toBe(1)
|
||||
expect(copied.groups.length).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -1,471 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { ExecutableNodeDTO } from '@/lib/litegraph/src/subgraph/ExecutableNodeDTO'
|
||||
|
||||
import {
|
||||
createNestedSubgraphs,
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from './fixtures/subgraphHelpers'
|
||||
|
||||
describe('ExecutableNodeDTO Creation', () => {
|
||||
it('should create DTO from regular node', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('Test Node')
|
||||
node.addInput('in', 'number')
|
||||
node.addOutput('out', 'string')
|
||||
graph.add(node)
|
||||
|
||||
const executableNodes = new Map()
|
||||
const dto = new ExecutableNodeDTO(node, [], executableNodes, undefined)
|
||||
|
||||
expect(dto.node).toBe(node)
|
||||
expect(dto.subgraphNodePath).toEqual([])
|
||||
expect(dto.subgraphNode).toBeUndefined()
|
||||
expect(dto.id).toBe(node.id.toString())
|
||||
})
|
||||
|
||||
it('should create DTO with subgraph path', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('Inner Node')
|
||||
node.id = 42
|
||||
graph.add(node)
|
||||
const subgraphPath = ['10', '20'] as const
|
||||
|
||||
const dto = new ExecutableNodeDTO(node, subgraphPath, new Map(), undefined)
|
||||
|
||||
expect(dto.subgraphNodePath).toBe(subgraphPath)
|
||||
expect(dto.id).toBe('10:20:42')
|
||||
})
|
||||
|
||||
it('should clone input slot data', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('Test Node')
|
||||
node.addInput('input1', 'number')
|
||||
node.addInput('input2', 'string')
|
||||
node.inputs[0].link = 123 // Simulate connected input
|
||||
graph.add(node)
|
||||
|
||||
const dto = new ExecutableNodeDTO(node, [], new Map(), undefined)
|
||||
|
||||
expect(dto.inputs).toHaveLength(2)
|
||||
expect(dto.inputs[0].name).toBe('input1')
|
||||
expect(dto.inputs[0].type).toBe('number')
|
||||
expect(dto.inputs[0].linkId).toBe(123)
|
||||
expect(dto.inputs[1].name).toBe('input2')
|
||||
expect(dto.inputs[1].type).toBe('string')
|
||||
expect(dto.inputs[1].linkId).toBeNull()
|
||||
|
||||
// Should be a copy, not reference
|
||||
expect(dto.inputs).not.toBe(node.inputs)
|
||||
})
|
||||
|
||||
it('should inherit graph reference', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('Test Node')
|
||||
graph.add(node)
|
||||
|
||||
const dto = new ExecutableNodeDTO(node, [], new Map(), undefined)
|
||||
|
||||
expect(dto.graph).toBe(graph)
|
||||
})
|
||||
|
||||
it('should wrap applyToGraph method if present', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('Test Node')
|
||||
const mockApplyToGraph = vi.fn()
|
||||
Object.assign(node, { applyToGraph: mockApplyToGraph })
|
||||
graph.add(node)
|
||||
|
||||
const dto = new ExecutableNodeDTO(node, [], new Map(), undefined)
|
||||
|
||||
expect(dto.applyToGraph).toBeDefined()
|
||||
|
||||
// Test that wrapper calls original method
|
||||
const args = ['arg1', 'arg2']
|
||||
// @ts-expect-error TODO: Fix after merge - applyToGraph expects different arguments
|
||||
dto.applyToGraph!(args[0], args[1])
|
||||
|
||||
expect(mockApplyToGraph).toHaveBeenCalledWith(args[0], args[1])
|
||||
})
|
||||
|
||||
it("should not create applyToGraph wrapper if method doesn't exist", () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('Test Node')
|
||||
graph.add(node)
|
||||
|
||||
const dto = new ExecutableNodeDTO(node, [], new Map(), undefined)
|
||||
|
||||
expect(dto.applyToGraph).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ExecutableNodeDTO Path-Based IDs', () => {
|
||||
it('should generate simple ID for root node', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('Root Node')
|
||||
node.id = 5
|
||||
graph.add(node)
|
||||
|
||||
const dto = new ExecutableNodeDTO(node, [], new Map(), undefined)
|
||||
|
||||
expect(dto.id).toBe('5')
|
||||
})
|
||||
|
||||
it('should generate path-based ID for nested node', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('Nested Node')
|
||||
node.id = 3
|
||||
graph.add(node)
|
||||
const path = ['1', '2'] as const
|
||||
|
||||
const dto = new ExecutableNodeDTO(node, path, new Map(), undefined)
|
||||
|
||||
expect(dto.id).toBe('1:2:3')
|
||||
})
|
||||
|
||||
it('should handle deep nesting paths', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('Deep Node')
|
||||
node.id = 99
|
||||
graph.add(node)
|
||||
const path = ['1', '2', '3', '4', '5'] as const
|
||||
|
||||
const dto = new ExecutableNodeDTO(node, path, new Map(), undefined)
|
||||
|
||||
expect(dto.id).toBe('1:2:3:4:5:99')
|
||||
})
|
||||
|
||||
it('should handle string and number IDs consistently', () => {
|
||||
const graph = new LGraph()
|
||||
const node1 = new LGraphNode('Node 1')
|
||||
node1.id = 10
|
||||
graph.add(node1)
|
||||
|
||||
const node2 = new LGraphNode('Node 2')
|
||||
node2.id = 20
|
||||
graph.add(node2)
|
||||
|
||||
const dto1 = new ExecutableNodeDTO(node1, ['5'], new Map(), undefined)
|
||||
const dto2 = new ExecutableNodeDTO(node2, ['5'], new Map(), undefined)
|
||||
|
||||
expect(dto1.id).toBe('5:10')
|
||||
expect(dto2.id).toBe('5:20')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ExecutableNodeDTO Input Resolution', () => {
|
||||
it('should return undefined for unconnected inputs', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('Test Node')
|
||||
node.addInput('in', 'number')
|
||||
graph.add(node)
|
||||
|
||||
const dto = new ExecutableNodeDTO(node, [], new Map(), undefined)
|
||||
|
||||
// Unconnected input should return undefined
|
||||
const resolved = dto.resolveInput(0)
|
||||
expect(resolved).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should throw for non-existent input slots', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('No Input Node')
|
||||
graph.add(node)
|
||||
|
||||
const dto = new ExecutableNodeDTO(node, [], new Map(), undefined)
|
||||
|
||||
// Should throw SlotIndexError for non-existent input
|
||||
expect(() => dto.resolveInput(0)).toThrow('No input found for flattened id')
|
||||
})
|
||||
|
||||
it('should handle subgraph boundary inputs', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'input1', type: 'number' }],
|
||||
nodeCount: 1
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
// Get the inner node and create DTO
|
||||
const innerNode = subgraph.nodes[0]
|
||||
const dto = new ExecutableNodeDTO(innerNode, ['1'], new Map(), subgraphNode)
|
||||
|
||||
// Should return undefined for unconnected input
|
||||
const resolved = dto.resolveInput(0)
|
||||
expect(resolved).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ExecutableNodeDTO Output Resolution', () => {
|
||||
it('should resolve outputs for simple nodes', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('Test Node')
|
||||
node.addOutput('out', 'string')
|
||||
graph.add(node)
|
||||
|
||||
const dto = new ExecutableNodeDTO(node, [], new Map(), undefined)
|
||||
|
||||
// resolveOutput requires type and visited parameters
|
||||
const resolved = dto.resolveOutput(0, 'string', new Set())
|
||||
|
||||
expect(resolved).toBeDefined()
|
||||
expect(resolved?.node).toBe(dto)
|
||||
expect(resolved?.origin_id).toBe(dto.id)
|
||||
expect(resolved?.origin_slot).toBe(0)
|
||||
})
|
||||
|
||||
it('should resolve cross-boundary outputs in subgraphs', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
outputs: [{ name: 'output1', type: 'string' }],
|
||||
nodeCount: 1
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
// Get the inner node and create DTO
|
||||
const innerNode = subgraph.nodes[0]
|
||||
const dto = new ExecutableNodeDTO(innerNode, ['1'], new Map(), subgraphNode)
|
||||
|
||||
const resolved = dto.resolveOutput(0, 'string', new Set())
|
||||
|
||||
expect(resolved).toBeDefined()
|
||||
})
|
||||
|
||||
it('should handle nodes with no outputs', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('No Output Node')
|
||||
graph.add(node)
|
||||
|
||||
const dto = new ExecutableNodeDTO(node, [], new Map(), undefined)
|
||||
|
||||
// For regular nodes, resolveOutput returns the node itself even if no outputs
|
||||
// This tests the current implementation behavior
|
||||
const resolved = dto.resolveOutput(0, 'string', new Set())
|
||||
expect(resolved).toBeDefined()
|
||||
expect(resolved?.node).toBe(dto)
|
||||
expect(resolved?.origin_slot).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ExecutableNodeDTO Properties', () => {
|
||||
it('should provide access to basic properties', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('Test Node')
|
||||
node.id = 42
|
||||
node.addInput('input', 'number')
|
||||
node.addOutput('output', 'string')
|
||||
graph.add(node)
|
||||
|
||||
const dto = new ExecutableNodeDTO(node, ['1', '2'], new Map(), undefined)
|
||||
|
||||
expect(dto.id).toBe('1:2:42')
|
||||
expect(dto.type).toBe(node.type)
|
||||
expect(dto.title).toBe(node.title)
|
||||
expect(dto.mode).toBe(node.mode)
|
||||
expect(dto.isVirtualNode).toBe(node.isVirtualNode)
|
||||
})
|
||||
|
||||
it('should provide access to input information', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('Test Node')
|
||||
node.addInput('testInput', 'number')
|
||||
node.inputs[0].link = 999 // Simulate connection
|
||||
graph.add(node)
|
||||
|
||||
const dto = new ExecutableNodeDTO(node, [], new Map(), undefined)
|
||||
|
||||
expect(dto.inputs).toBeDefined()
|
||||
expect(dto.inputs).toHaveLength(1)
|
||||
expect(dto.inputs[0].name).toBe('testInput')
|
||||
expect(dto.inputs[0].type).toBe('number')
|
||||
expect(dto.inputs[0].linkId).toBe(999)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ExecutableNodeDTO Memory Efficiency', () => {
|
||||
it('should create lightweight objects', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('Test Node')
|
||||
node.addInput('in1', 'number')
|
||||
node.addInput('in2', 'string')
|
||||
node.addOutput('out1', 'number')
|
||||
node.addOutput('out2', 'string')
|
||||
graph.add(node)
|
||||
|
||||
const dto = new ExecutableNodeDTO(node, ['1'], new Map(), undefined)
|
||||
|
||||
// DTO should be lightweight - only essential properties
|
||||
expect(dto.node).toBe(node) // Reference, not copy
|
||||
expect(dto.subgraphNodePath).toEqual(['1']) // Reference to path
|
||||
expect(dto.inputs).toHaveLength(2) // Copied input data only
|
||||
|
||||
// Should not duplicate heavy node data
|
||||
// eslint-disable-next-line no-prototype-builtins
|
||||
expect(dto.hasOwnProperty('outputs')).toBe(false) // Outputs not copied
|
||||
// eslint-disable-next-line no-prototype-builtins
|
||||
expect(dto.hasOwnProperty('widgets')).toBe(false) // Widgets not copied
|
||||
})
|
||||
|
||||
it('should handle disposal without memory leaks', () => {
|
||||
const graph = new LGraph()
|
||||
const nodes: ExecutableNodeDTO[] = []
|
||||
|
||||
// Create DTOs
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const node = new LGraphNode(`Node ${i}`)
|
||||
node.id = i
|
||||
graph.add(node)
|
||||
const dto = new ExecutableNodeDTO(node, ['parent'], new Map(), undefined)
|
||||
nodes.push(dto)
|
||||
}
|
||||
|
||||
expect(nodes).toHaveLength(100)
|
||||
|
||||
// Clear references
|
||||
nodes.length = 0
|
||||
|
||||
// DTOs should be eligible for garbage collection
|
||||
// (No explicit disposal needed - they're lightweight wrappers)
|
||||
expect(nodes).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should not retain unnecessary references', () => {
|
||||
const subgraph = createTestSubgraph({ nodeCount: 1 })
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const innerNode = subgraph.nodes[0]
|
||||
|
||||
const dto = new ExecutableNodeDTO(innerNode, ['1'], new Map(), subgraphNode)
|
||||
|
||||
// Should hold necessary references
|
||||
expect(dto.node).toBe(innerNode)
|
||||
expect(dto.subgraphNode).toBe(subgraphNode)
|
||||
expect(dto.graph).toBe(innerNode.graph)
|
||||
|
||||
// Should not hold heavy references that prevent GC
|
||||
// eslint-disable-next-line no-prototype-builtins
|
||||
expect(dto.hasOwnProperty('parentGraph')).toBe(false)
|
||||
// eslint-disable-next-line no-prototype-builtins
|
||||
expect(dto.hasOwnProperty('rootGraph')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ExecutableNodeDTO Integration', () => {
|
||||
it('should work with SubgraphNode flattening', () => {
|
||||
const subgraph = createTestSubgraph({ nodeCount: 3 })
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
const flattened = subgraphNode.getInnerNodes(new Map())
|
||||
|
||||
expect(flattened).toHaveLength(3)
|
||||
expect(flattened[0]).toBeInstanceOf(ExecutableNodeDTO)
|
||||
expect(flattened[0].id).toMatch(/^1:\d+$/)
|
||||
})
|
||||
|
||||
it.skip('should handle nested subgraph flattening', () => {
|
||||
// FIXME: Complex nested structure requires proper parent graph setup
|
||||
// This test needs investigation of how resolveSubgraphIdPath works
|
||||
// Skip for now - will implement in edge cases test file
|
||||
const nested = createNestedSubgraphs({
|
||||
depth: 2,
|
||||
nodesPerLevel: 1
|
||||
})
|
||||
|
||||
const rootSubgraphNode = nested.subgraphNodes[0]
|
||||
const executableNodes = new Map()
|
||||
const flattened = rootSubgraphNode.getInnerNodes(executableNodes)
|
||||
|
||||
expect(flattened.length).toBeGreaterThan(0)
|
||||
const hierarchicalIds = flattened.filter((dto) => dto.id.includes(':'))
|
||||
expect(hierarchicalIds.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should preserve original node properties through DTO', () => {
|
||||
const graph = new LGraph()
|
||||
const originalNode = new LGraphNode('Original')
|
||||
originalNode.id = 123
|
||||
originalNode.addInput('test', 'number')
|
||||
originalNode.properties = { value: 42 }
|
||||
graph.add(originalNode)
|
||||
|
||||
const dto = new ExecutableNodeDTO(
|
||||
originalNode,
|
||||
['parent'],
|
||||
new Map(),
|
||||
undefined
|
||||
)
|
||||
|
||||
// DTO should provide access to original node properties
|
||||
expect(dto.node.id).toBe(123)
|
||||
expect(dto.node.inputs).toHaveLength(1)
|
||||
expect(dto.node.properties.value).toBe(42)
|
||||
|
||||
// But DTO ID should be path-based
|
||||
expect(dto.id).toBe('parent:123')
|
||||
})
|
||||
|
||||
it('should handle execution context correctly', () => {
|
||||
const subgraph = createTestSubgraph({ nodeCount: 1 })
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 99 })
|
||||
const innerNode = subgraph.nodes[0]
|
||||
innerNode.id = 55
|
||||
|
||||
const dto = new ExecutableNodeDTO(
|
||||
innerNode,
|
||||
['99'],
|
||||
new Map(),
|
||||
subgraphNode
|
||||
)
|
||||
|
||||
// DTO provides execution context
|
||||
expect(dto.id).toBe('99:55') // Path-based execution ID
|
||||
expect(dto.node.id).toBe(55) // Original node ID preserved
|
||||
expect(dto.subgraphNode?.id).toBe(99) // Subgraph context
|
||||
})
|
||||
})
|
||||
|
||||
describe('ExecutableNodeDTO Scale Testing', () => {
|
||||
it('should create DTOs at scale', () => {
|
||||
const graph = new LGraph()
|
||||
const dtos: ExecutableNodeDTO[] = []
|
||||
|
||||
// Create DTOs to test performance
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const node = new LGraphNode(`Node ${i}`)
|
||||
node.id = i
|
||||
node.addInput('in', 'number')
|
||||
graph.add(node)
|
||||
|
||||
const dto = new ExecutableNodeDTO(node, ['parent'], new Map(), undefined)
|
||||
dtos.push(dto)
|
||||
}
|
||||
|
||||
expect(dtos).toHaveLength(1000)
|
||||
// Test deterministic properties instead of flaky timing
|
||||
expect(dtos[0].id).toBe('parent:0')
|
||||
expect(dtos[999].id).toBe('parent:999')
|
||||
expect(dtos.every((dto, i) => dto.id === `parent:${i}`)).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle complex path generation correctly', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('Deep Node')
|
||||
node.id = 999
|
||||
graph.add(node)
|
||||
|
||||
// Test deterministic path generation behavior
|
||||
const testCases = [
|
||||
{ depth: 1, expectedId: '1:999' },
|
||||
{ depth: 3, expectedId: '1:2:3:999' },
|
||||
{ depth: 5, expectedId: '1:2:3:4:5:999' },
|
||||
{ depth: 10, expectedId: '1:2:3:4:5:6:7:8:9:10:999' }
|
||||
]
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const path = Array.from({ length: testCase.depth }, (_, i) =>
|
||||
(i + 1).toString()
|
||||
)
|
||||
const dto = new ExecutableNodeDTO(node, path, new Map(), undefined)
|
||||
expect(dto.id).toBe(testCase.expectedId)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,326 +0,0 @@
|
||||
/**
|
||||
* Core Subgraph Tests
|
||||
*
|
||||
* This file implements fundamental tests for the Subgraph class that establish
|
||||
* patterns for the rest of the testing team. These tests cover construction,
|
||||
* basic I/O management, and known issues.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { RecursionError } from '@/lib/litegraph/src/infrastructure/RecursionError'
|
||||
import { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { createUuidv4 } from '@/lib/litegraph/src/utils/uuid'
|
||||
|
||||
import { subgraphTest } from './fixtures/subgraphFixtures'
|
||||
import {
|
||||
assertSubgraphStructure,
|
||||
createTestSubgraph,
|
||||
createTestSubgraphData
|
||||
} from './fixtures/subgraphHelpers'
|
||||
|
||||
describe('Subgraph Construction', () => {
|
||||
it('should create a subgraph with minimal data', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
|
||||
assertSubgraphStructure(subgraph, {
|
||||
inputCount: 0,
|
||||
outputCount: 0,
|
||||
nodeCount: 0,
|
||||
name: 'Test Subgraph'
|
||||
})
|
||||
|
||||
expect(subgraph.id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
)
|
||||
expect(subgraph.inputNode).toBeDefined()
|
||||
expect(subgraph.outputNode).toBeDefined()
|
||||
expect(subgraph.inputNode.id).toBe(-10)
|
||||
expect(subgraph.outputNode.id).toBe(-20)
|
||||
})
|
||||
|
||||
it('should require a root graph', () => {
|
||||
const subgraphData = createTestSubgraphData()
|
||||
|
||||
expect(() => {
|
||||
// @ts-expect-error Testing invalid null parameter
|
||||
new Subgraph(null, subgraphData)
|
||||
}).toThrow('Root graph is required')
|
||||
})
|
||||
|
||||
it('should accept custom name and ID', () => {
|
||||
const customId = createUuidv4()
|
||||
const customName = 'My Custom Subgraph'
|
||||
|
||||
const subgraph = createTestSubgraph({
|
||||
id: customId,
|
||||
name: customName
|
||||
})
|
||||
|
||||
expect(subgraph.id).toBe(customId)
|
||||
expect(subgraph.name).toBe(customName)
|
||||
})
|
||||
|
||||
it('should initialize with empty inputs and outputs', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
|
||||
expect(subgraph.inputs).toHaveLength(0)
|
||||
expect(subgraph.outputs).toHaveLength(0)
|
||||
expect(subgraph.widgets).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should have properly configured input and output nodes', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
|
||||
// Input node should be positioned on the left
|
||||
expect(subgraph.inputNode.pos[0]).toBeLessThan(100)
|
||||
|
||||
// Output node should be positioned on the right
|
||||
expect(subgraph.outputNode.pos[0]).toBeGreaterThan(300)
|
||||
|
||||
// Both should reference the subgraph
|
||||
expect(subgraph.inputNode.subgraph).toBe(subgraph)
|
||||
expect(subgraph.outputNode.subgraph).toBe(subgraph)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Subgraph Input/Output Management', () => {
|
||||
subgraphTest('should add a single input', ({ emptySubgraph }) => {
|
||||
const input = emptySubgraph.addInput('test_input', 'number')
|
||||
|
||||
expect(emptySubgraph.inputs).toHaveLength(1)
|
||||
expect(input.name).toBe('test_input')
|
||||
expect(input.type).toBe('number')
|
||||
expect(emptySubgraph.inputs.indexOf(input)).toBe(0)
|
||||
})
|
||||
|
||||
subgraphTest('should add a single output', ({ emptySubgraph }) => {
|
||||
const output = emptySubgraph.addOutput('test_output', 'string')
|
||||
|
||||
expect(emptySubgraph.outputs).toHaveLength(1)
|
||||
expect(output.name).toBe('test_output')
|
||||
expect(output.type).toBe('string')
|
||||
expect(emptySubgraph.outputs.indexOf(output)).toBe(0)
|
||||
})
|
||||
|
||||
subgraphTest(
|
||||
'should maintain correct indices when adding multiple inputs',
|
||||
({ emptySubgraph }) => {
|
||||
const input1 = emptySubgraph.addInput('input_1', 'number')
|
||||
const input2 = emptySubgraph.addInput('input_2', 'string')
|
||||
const input3 = emptySubgraph.addInput('input_3', 'boolean')
|
||||
|
||||
expect(emptySubgraph.inputs.indexOf(input1)).toBe(0)
|
||||
expect(emptySubgraph.inputs.indexOf(input2)).toBe(1)
|
||||
expect(emptySubgraph.inputs.indexOf(input3)).toBe(2)
|
||||
expect(emptySubgraph.inputs).toHaveLength(3)
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest(
|
||||
'should maintain correct indices when adding multiple outputs',
|
||||
({ emptySubgraph }) => {
|
||||
const output1 = emptySubgraph.addOutput('output_1', 'number')
|
||||
const output2 = emptySubgraph.addOutput('output_2', 'string')
|
||||
const output3 = emptySubgraph.addOutput('output_3', 'boolean')
|
||||
|
||||
expect(emptySubgraph.outputs.indexOf(output1)).toBe(0)
|
||||
expect(emptySubgraph.outputs.indexOf(output2)).toBe(1)
|
||||
expect(emptySubgraph.outputs.indexOf(output3)).toBe(2)
|
||||
expect(emptySubgraph.outputs).toHaveLength(3)
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest('should remove inputs correctly', ({ simpleSubgraph }) => {
|
||||
// Add a second input first
|
||||
simpleSubgraph.addInput('second_input', 'string')
|
||||
expect(simpleSubgraph.inputs).toHaveLength(2)
|
||||
|
||||
// Remove the first input
|
||||
const firstInput = simpleSubgraph.inputs[0]
|
||||
simpleSubgraph.removeInput(firstInput)
|
||||
|
||||
expect(simpleSubgraph.inputs).toHaveLength(1)
|
||||
expect(simpleSubgraph.inputs[0].name).toBe('second_input')
|
||||
// Verify it's at index 0 in the array
|
||||
expect(simpleSubgraph.inputs.indexOf(simpleSubgraph.inputs[0])).toBe(0)
|
||||
})
|
||||
|
||||
subgraphTest('should remove outputs correctly', ({ simpleSubgraph }) => {
|
||||
// Add a second output first
|
||||
simpleSubgraph.addOutput('second_output', 'string')
|
||||
expect(simpleSubgraph.outputs).toHaveLength(2)
|
||||
|
||||
// Remove the first output
|
||||
const firstOutput = simpleSubgraph.outputs[0]
|
||||
simpleSubgraph.removeOutput(firstOutput)
|
||||
|
||||
expect(simpleSubgraph.outputs).toHaveLength(1)
|
||||
expect(simpleSubgraph.outputs[0].name).toBe('second_output')
|
||||
// Verify it's at index 0 in the array
|
||||
expect(simpleSubgraph.outputs.indexOf(simpleSubgraph.outputs[0])).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Subgraph Serialization', () => {
|
||||
subgraphTest('should serialize empty subgraph', ({ emptySubgraph }) => {
|
||||
const serialized = emptySubgraph.asSerialisable()
|
||||
|
||||
expect(serialized.version).toBe(1)
|
||||
expect(serialized.id).toBeTruthy()
|
||||
expect(serialized.name).toBe('Empty Test Subgraph')
|
||||
expect(serialized.inputs).toHaveLength(0)
|
||||
expect(serialized.outputs).toHaveLength(0)
|
||||
expect(serialized.nodes).toHaveLength(0)
|
||||
expect(typeof serialized.links).toBe('object')
|
||||
})
|
||||
|
||||
subgraphTest(
|
||||
'should serialize subgraph with inputs and outputs',
|
||||
({ simpleSubgraph }) => {
|
||||
const serialized = simpleSubgraph.asSerialisable()
|
||||
|
||||
expect(serialized.inputs).toHaveLength(1)
|
||||
expect(serialized.outputs).toHaveLength(1)
|
||||
// @ts-expect-error TODO: Fix after merge - serialized.inputs possibly undefined
|
||||
expect(serialized.inputs[0].name).toBe('input')
|
||||
// @ts-expect-error TODO: Fix after merge - serialized.inputs possibly undefined
|
||||
expect(serialized.inputs[0].type).toBe('number')
|
||||
// @ts-expect-error TODO: Fix after merge - serialized.outputs possibly undefined
|
||||
expect(serialized.outputs[0].name).toBe('output')
|
||||
// @ts-expect-error TODO: Fix after merge - serialized.outputs possibly undefined
|
||||
expect(serialized.outputs[0].type).toBe('number')
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest(
|
||||
'should include input and output nodes in serialization',
|
||||
({ emptySubgraph }) => {
|
||||
const serialized = emptySubgraph.asSerialisable()
|
||||
|
||||
expect(serialized.inputNode).toBeDefined()
|
||||
expect(serialized.outputNode).toBeDefined()
|
||||
expect(serialized.inputNode.id).toBe(-10)
|
||||
expect(serialized.outputNode.id).toBe(-20)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('Subgraph Known Issues', () => {
|
||||
it.todo('should enforce MAX_NESTED_SUBGRAPHS limit', () => {
|
||||
// This test documents that MAX_NESTED_SUBGRAPHS = 1000 is defined
|
||||
// but not actually enforced anywhere in the code.
|
||||
//
|
||||
// Expected behavior: Should throw error when nesting exceeds limit
|
||||
// Actual behavior: No validation is performed
|
||||
//
|
||||
// This safety limit should be implemented to prevent runaway recursion.
|
||||
})
|
||||
|
||||
it('should provide MAX_NESTED_SUBGRAPHS constant', () => {
|
||||
expect(Subgraph.MAX_NESTED_SUBGRAPHS).toBe(1000)
|
||||
})
|
||||
|
||||
it('should have recursion detection in place', () => {
|
||||
// Verify that RecursionError is available and can be thrown
|
||||
expect(() => {
|
||||
throw new RecursionError('test recursion')
|
||||
}).toThrow(RecursionError)
|
||||
|
||||
expect(() => {
|
||||
throw new RecursionError('test recursion')
|
||||
}).toThrow('test recursion')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Subgraph Root Graph Relationship', () => {
|
||||
it('should maintain reference to root graph', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const subgraphData = createTestSubgraphData()
|
||||
const subgraph = new Subgraph(rootGraph, subgraphData)
|
||||
|
||||
expect(subgraph.rootGraph).toBe(rootGraph)
|
||||
})
|
||||
|
||||
it('should inherit root graph in nested subgraphs', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const parentData = createTestSubgraphData({
|
||||
name: 'Parent Subgraph'
|
||||
})
|
||||
const parentSubgraph = new Subgraph(rootGraph, parentData)
|
||||
|
||||
// Create a nested subgraph
|
||||
const nestedData = createTestSubgraphData({
|
||||
name: 'Nested Subgraph'
|
||||
})
|
||||
const nestedSubgraph = new Subgraph(rootGraph, nestedData)
|
||||
|
||||
expect(nestedSubgraph.rootGraph).toBe(rootGraph)
|
||||
expect(parentSubgraph.rootGraph).toBe(rootGraph)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Subgraph Error Handling', () => {
|
||||
subgraphTest(
|
||||
'should handle removing non-existent input gracefully',
|
||||
({ emptySubgraph }) => {
|
||||
// Create a fake input that doesn't belong to this subgraph
|
||||
const fakeInput = emptySubgraph.addInput('temp', 'number')
|
||||
emptySubgraph.removeInput(fakeInput) // Remove it first
|
||||
|
||||
// Now try to remove it again
|
||||
expect(() => {
|
||||
emptySubgraph.removeInput(fakeInput)
|
||||
}).toThrow('Input not found')
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest(
|
||||
'should handle removing non-existent output gracefully',
|
||||
({ emptySubgraph }) => {
|
||||
// Create a fake output that doesn't belong to this subgraph
|
||||
const fakeOutput = emptySubgraph.addOutput('temp', 'number')
|
||||
emptySubgraph.removeOutput(fakeOutput) // Remove it first
|
||||
|
||||
// Now try to remove it again
|
||||
expect(() => {
|
||||
emptySubgraph.removeOutput(fakeOutput)
|
||||
}).toThrow('Output not found')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('Subgraph Integration', () => {
|
||||
it("should work with LGraph's node management", () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
nodeCount: 3
|
||||
})
|
||||
|
||||
// Verify nodes were added to the subgraph
|
||||
expect(subgraph.nodes).toHaveLength(3)
|
||||
|
||||
// Verify we can access nodes by ID
|
||||
const firstNode = subgraph.getNodeById(1)
|
||||
expect(firstNode).toBeDefined()
|
||||
expect(firstNode?.title).toContain('Test Node')
|
||||
})
|
||||
|
||||
it('should maintain link integrity', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
nodeCount: 2
|
||||
})
|
||||
|
||||
const node1 = subgraph.nodes[0]
|
||||
const node2 = subgraph.nodes[1]
|
||||
|
||||
// Connect the nodes
|
||||
node1.connect(0, node2, 0)
|
||||
|
||||
// Verify link was created
|
||||
expect(subgraph.links.size).toBe(1)
|
||||
|
||||
// Verify link integrity
|
||||
const link = Array.from(subgraph.links.values())[0]
|
||||
expect(link.origin_id).toBe(node1.id)
|
||||
expect(link.target_id).toBe(node2.id)
|
||||
})
|
||||
})
|
||||
@@ -1,199 +0,0 @@
|
||||
import { assert, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ISlotType, LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
LGraphGroup,
|
||||
LGraphNode,
|
||||
LiteGraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from './fixtures/subgraphHelpers'
|
||||
|
||||
function createNode(
|
||||
graph: LGraph,
|
||||
inputs: ISlotType[] = [],
|
||||
outputs: ISlotType[] = [],
|
||||
title?: string
|
||||
) {
|
||||
const type = JSON.stringify({ inputs, outputs })
|
||||
if (!LiteGraph.registered_node_types[type]) {
|
||||
class testnode extends LGraphNode {
|
||||
constructor(title: string) {
|
||||
super(title)
|
||||
let i_count = 0
|
||||
for (const input of inputs) this.addInput('input_' + i_count++, input)
|
||||
let o_count = 0
|
||||
for (const output of outputs)
|
||||
this.addOutput('output_' + o_count++, output)
|
||||
}
|
||||
}
|
||||
LiteGraph.registered_node_types[type] = testnode
|
||||
}
|
||||
const node = LiteGraph.createNode(type, title)
|
||||
if (!node) {
|
||||
throw new Error('Failed to create node')
|
||||
}
|
||||
graph.add(node)
|
||||
return node
|
||||
}
|
||||
describe('SubgraphConversion', () => {
|
||||
describe('Subgraph Unpacking Functionality', () => {
|
||||
it('Should keep interior nodes and links', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const graph = subgraphNode.graph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
const node1 = createNode(subgraph, [], ['number'])
|
||||
const node2 = createNode(subgraph, ['number'])
|
||||
node1.connect(0, node2, 0)
|
||||
|
||||
graph.unpackSubgraph(subgraphNode)
|
||||
|
||||
expect(graph.nodes.length).toBe(2)
|
||||
expect(graph.links.size).toBe(1)
|
||||
})
|
||||
it('Should merge boundary links', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }],
|
||||
outputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const graph = subgraphNode.graph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
const innerNode1 = createNode(subgraph, [], ['number'])
|
||||
const innerNode2 = createNode(subgraph, ['number'], [])
|
||||
subgraph.inputNode.slots[0].connect(innerNode2.inputs[0], innerNode2)
|
||||
subgraph.outputNode.slots[0].connect(innerNode1.outputs[0], innerNode1)
|
||||
|
||||
const outerNode1 = createNode(graph, [], ['number'])
|
||||
const outerNode2 = createNode(graph, ['number'])
|
||||
outerNode1.connect(0, subgraphNode, 0)
|
||||
subgraphNode.connect(0, outerNode2, 0)
|
||||
|
||||
graph.unpackSubgraph(subgraphNode)
|
||||
|
||||
expect(graph.nodes.length).toBe(4)
|
||||
expect(graph.links.size).toBe(2)
|
||||
})
|
||||
it('Should keep reroutes and groups', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
outputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const graph = subgraphNode.graph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
const inner = createNode(subgraph, [], ['number'])
|
||||
const innerLink = subgraph.outputNode.slots[0].connect(
|
||||
inner.outputs[0],
|
||||
inner
|
||||
)
|
||||
assert(innerLink)
|
||||
|
||||
const outer = createNode(graph, ['number'])
|
||||
const outerLink = subgraphNode.connect(0, outer, 0)
|
||||
assert(outerLink)
|
||||
subgraph.add(new LGraphGroup())
|
||||
|
||||
subgraph.createReroute([10, 10], innerLink)
|
||||
graph.createReroute([10, 10], outerLink)
|
||||
|
||||
graph.unpackSubgraph(subgraphNode)
|
||||
|
||||
expect(graph.reroutes.size).toBe(2)
|
||||
expect(graph.groups.length).toBe(1)
|
||||
})
|
||||
it('Should map reroutes onto split outputs', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
outputs: [
|
||||
{ name: 'value1', type: 'number' },
|
||||
{ name: 'value2', type: 'number' }
|
||||
]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const graph = subgraphNode.graph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
const inner = createNode(subgraph, [], ['number', 'number'])
|
||||
const innerLink1 = subgraph.outputNode.slots[0].connect(
|
||||
inner.outputs[0],
|
||||
inner
|
||||
)
|
||||
const innerLink2 = subgraph.outputNode.slots[1].connect(
|
||||
inner.outputs[1],
|
||||
inner
|
||||
)
|
||||
const outer1 = createNode(graph, ['number'])
|
||||
const outer2 = createNode(graph, ['number'])
|
||||
const outer3 = createNode(graph, ['number'])
|
||||
const outerLink1 = subgraphNode.connect(0, outer1, 0)
|
||||
assert(innerLink1 && innerLink2 && outerLink1)
|
||||
subgraphNode.connect(0, outer2, 0)
|
||||
subgraphNode.connect(1, outer3, 0)
|
||||
|
||||
subgraph.createReroute([10, 10], innerLink1)
|
||||
subgraph.createReroute([10, 20], innerLink2)
|
||||
graph.createReroute([10, 10], outerLink1)
|
||||
|
||||
graph.unpackSubgraph(subgraphNode)
|
||||
|
||||
expect(graph.reroutes.size).toBe(3)
|
||||
expect(graph.links.size).toBe(3)
|
||||
let linkRefCount = 0
|
||||
for (const reroute of graph.reroutes.values()) {
|
||||
linkRefCount += reroute.linkIds.size
|
||||
}
|
||||
expect(linkRefCount).toBe(4)
|
||||
})
|
||||
it('Should map reroutes onto split inputs', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'value1', type: 'number' },
|
||||
{ name: 'value2', type: 'number' }
|
||||
]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const graph = subgraphNode.graph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
const inner1 = createNode(subgraph, ['number', 'number'])
|
||||
const inner2 = createNode(subgraph, ['number'])
|
||||
const innerLink1 = subgraph.inputNode.slots[0].connect(
|
||||
inner1.inputs[0],
|
||||
inner1
|
||||
)
|
||||
const innerLink2 = subgraph.inputNode.slots[1].connect(
|
||||
inner1.inputs[1],
|
||||
inner1
|
||||
)
|
||||
const innerLink3 = subgraph.inputNode.slots[1].connect(
|
||||
inner2.inputs[0],
|
||||
inner2
|
||||
)
|
||||
assert(innerLink1 && innerLink2 && innerLink3)
|
||||
const outer = createNode(graph, [], ['number'])
|
||||
const outerLink1 = outer.connect(0, subgraphNode, 0)
|
||||
const outerLink2 = outer.connect(0, subgraphNode, 1)
|
||||
assert(outerLink1 && outerLink2)
|
||||
|
||||
graph.createReroute([10, 10], outerLink1)
|
||||
graph.createReroute([10, 20], outerLink2)
|
||||
subgraph.createReroute([10, 10], innerLink1)
|
||||
|
||||
graph.unpackSubgraph(subgraphNode)
|
||||
|
||||
expect(graph.reroutes.size).toBe(3)
|
||||
expect(graph.links.size).toBe(3)
|
||||
let linkRefCount = 0
|
||||
for (const reroute of graph.reroutes.values()) {
|
||||
linkRefCount += reroute.linkIds.size
|
||||
}
|
||||
expect(linkRefCount).toBe(4)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,377 +0,0 @@
|
||||
/**
|
||||
* SubgraphEdgeCases Tests
|
||||
*
|
||||
* Tests for edge cases, error handling, and boundary conditions in the subgraph system.
|
||||
* This covers unusual scenarios, invalid states, and stress testing.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { LGraph, LGraphNode, Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import {
|
||||
createNestedSubgraphs,
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from './fixtures/subgraphHelpers'
|
||||
|
||||
describe('SubgraphEdgeCases - Recursion Detection', () => {
|
||||
it('should handle circular subgraph references without crashing', () => {
|
||||
const sub1 = createTestSubgraph({ name: 'Sub1' })
|
||||
const sub2 = createTestSubgraph({ name: 'Sub2' })
|
||||
|
||||
// Create circular reference
|
||||
const node1 = createTestSubgraphNode(sub1, { id: 1 })
|
||||
const node2 = createTestSubgraphNode(sub2, { id: 2 })
|
||||
|
||||
sub1.add(node2)
|
||||
sub2.add(node1)
|
||||
|
||||
// Should not crash or hang - currently throws path resolution error due to circular structure
|
||||
expect(() => {
|
||||
const executableNodes = new Map()
|
||||
node1.getInnerNodes(executableNodes)
|
||||
}).toThrow(/Node \[\d+\] not found/) // Current behavior: path resolution fails
|
||||
})
|
||||
|
||||
it('should handle deep nesting scenarios', () => {
|
||||
// Test with reasonable depth to avoid timeout
|
||||
const nested = createNestedSubgraphs({ depth: 10, nodesPerLevel: 1 })
|
||||
|
||||
// Should create nested structure without errors
|
||||
expect(nested.subgraphs).toHaveLength(10)
|
||||
expect(nested.subgraphNodes).toHaveLength(10)
|
||||
|
||||
// First level should exist and be accessible
|
||||
const firstLevel = nested.rootGraph.nodes[0]
|
||||
expect(firstLevel).toBeDefined()
|
||||
expect(firstLevel.isSubgraphNode()).toBe(true)
|
||||
})
|
||||
|
||||
it.todo('should use WeakSet for cycle detection', () => {
|
||||
// TODO: This test is currently skipped because cycle detection has a bug
|
||||
// The fix is to pass 'visited' directly instead of 'new Set(visited)' in SubgraphNode.ts:299
|
||||
const subgraph = createTestSubgraph({ nodeCount: 1 })
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
// Add to own subgraph to create cycle
|
||||
subgraph.add(subgraphNode)
|
||||
|
||||
// Should throw due to cycle detection
|
||||
const executableNodes = new Map()
|
||||
expect(() => {
|
||||
subgraphNode.getInnerNodes(executableNodes)
|
||||
}).toThrow(/while flattening subgraph/i)
|
||||
})
|
||||
|
||||
it('should respect MAX_NESTED_SUBGRAPHS constant', () => {
|
||||
// Verify the constant exists and is a reasonable positive number
|
||||
expect(Subgraph.MAX_NESTED_SUBGRAPHS).toBeDefined()
|
||||
expect(typeof Subgraph.MAX_NESTED_SUBGRAPHS).toBe('number')
|
||||
expect(Subgraph.MAX_NESTED_SUBGRAPHS).toBeGreaterThan(0)
|
||||
expect(Subgraph.MAX_NESTED_SUBGRAPHS).toBeLessThanOrEqual(10_000) // Reasonable upper bound
|
||||
|
||||
// Note: Currently not enforced in implementation
|
||||
// This test documents the intended behavior
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubgraphEdgeCases - Invalid States', () => {
|
||||
it('should handle removing non-existent inputs gracefully', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const fakeInput = {
|
||||
name: 'fake',
|
||||
type: 'number',
|
||||
disconnect: () => {}
|
||||
} as any
|
||||
|
||||
// Should throw appropriate error for non-existent input
|
||||
expect(() => {
|
||||
subgraph.removeInput(fakeInput)
|
||||
}).toThrow(/Input not found/) // Expected error
|
||||
})
|
||||
|
||||
it('should handle removing non-existent outputs gracefully', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const fakeOutput = {
|
||||
name: 'fake',
|
||||
type: 'number',
|
||||
disconnect: () => {}
|
||||
} as any
|
||||
|
||||
expect(() => {
|
||||
subgraph.removeOutput(fakeOutput)
|
||||
}).toThrow(/Output not found/) // Expected error
|
||||
})
|
||||
|
||||
it('should handle null/undefined input names', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
|
||||
// ISSUE: Current implementation allows null/undefined names which may cause runtime errors
|
||||
// TODO: Consider adding validation to prevent null/undefined names
|
||||
// This test documents the current permissive behavior
|
||||
expect(() => {
|
||||
subgraph.addInput(null as any, 'number')
|
||||
}).not.toThrow() // Current behavior: allows null
|
||||
|
||||
expect(() => {
|
||||
subgraph.addInput(undefined as any, 'number')
|
||||
}).not.toThrow() // Current behavior: allows undefined
|
||||
})
|
||||
|
||||
it('should handle null/undefined output names', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
|
||||
// ISSUE: Current implementation allows null/undefined names which may cause runtime errors
|
||||
// TODO: Consider adding validation to prevent null/undefined names
|
||||
// This test documents the current permissive behavior
|
||||
expect(() => {
|
||||
subgraph.addOutput(null as any, 'number')
|
||||
}).not.toThrow() // Current behavior: allows null
|
||||
|
||||
expect(() => {
|
||||
subgraph.addOutput(undefined as any, 'number')
|
||||
}).not.toThrow() // Current behavior: allows undefined
|
||||
})
|
||||
|
||||
it('should handle empty string names', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
|
||||
// Current implementation may allow empty strings
|
||||
// Document the actual behavior
|
||||
expect(() => {
|
||||
subgraph.addInput('', 'number')
|
||||
}).not.toThrow() // Current behavior: allows empty strings
|
||||
|
||||
expect(() => {
|
||||
subgraph.addOutput('', 'number')
|
||||
}).not.toThrow() // Current behavior: allows empty strings
|
||||
})
|
||||
|
||||
it('should handle undefined types gracefully', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
|
||||
// Undefined type should not crash but may have default behavior
|
||||
expect(() => {
|
||||
subgraph.addInput('test', undefined as any)
|
||||
}).not.toThrow()
|
||||
|
||||
expect(() => {
|
||||
subgraph.addOutput('test', undefined as any)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle duplicate slot names', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
|
||||
// Add first input
|
||||
subgraph.addInput('duplicate', 'number')
|
||||
|
||||
// Adding duplicate should not crash (current behavior allows it)
|
||||
expect(() => {
|
||||
subgraph.addInput('duplicate', 'string')
|
||||
}).not.toThrow()
|
||||
|
||||
// Should now have 2 inputs with same name
|
||||
expect(subgraph.inputs.length).toBe(2)
|
||||
expect(subgraph.inputs[0].name).toBe('duplicate')
|
||||
expect(subgraph.inputs[1].name).toBe('duplicate')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubgraphEdgeCases - Boundary Conditions', () => {
|
||||
it('should handle empty subgraphs (no nodes, no IO)', () => {
|
||||
const subgraph = createTestSubgraph({ nodeCount: 0 })
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
// Should handle empty subgraph without errors
|
||||
const executableNodes = new Map()
|
||||
const flattened = subgraphNode.getInnerNodes(executableNodes)
|
||||
|
||||
expect(flattened).toHaveLength(0)
|
||||
expect(subgraph.inputs).toHaveLength(0)
|
||||
expect(subgraph.outputs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should handle single input/output subgraphs', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'single_in', type: 'number' }],
|
||||
outputs: [{ name: 'single_out', type: 'number' }],
|
||||
nodeCount: 1
|
||||
})
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
expect(subgraphNode.inputs).toHaveLength(1)
|
||||
expect(subgraphNode.outputs).toHaveLength(1)
|
||||
expect(subgraphNode.inputs[0].name).toBe('single_in')
|
||||
expect(subgraphNode.outputs[0].name).toBe('single_out')
|
||||
})
|
||||
|
||||
it('should handle subgraphs with many slots', () => {
|
||||
const subgraph = createTestSubgraph({ nodeCount: 1 })
|
||||
|
||||
// Add many inputs (test with 20 to keep test fast)
|
||||
for (let i = 0; i < 20; i++) {
|
||||
subgraph.addInput(`input_${i}`, 'number')
|
||||
}
|
||||
|
||||
// Add many outputs
|
||||
for (let i = 0; i < 20; i++) {
|
||||
subgraph.addOutput(`output_${i}`, 'number')
|
||||
}
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
expect(subgraph.inputs).toHaveLength(20)
|
||||
expect(subgraph.outputs).toHaveLength(20)
|
||||
expect(subgraphNode.inputs).toHaveLength(20)
|
||||
expect(subgraphNode.outputs).toHaveLength(20)
|
||||
|
||||
// Should still flatten correctly
|
||||
const executableNodes = new Map()
|
||||
const flattened = subgraphNode.getInnerNodes(executableNodes)
|
||||
expect(flattened).toHaveLength(1) // Original node count
|
||||
})
|
||||
|
||||
it('should handle very long slot names', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const longName = 'a'.repeat(1000) // 1000 character name
|
||||
|
||||
expect(() => {
|
||||
subgraph.addInput(longName, 'number')
|
||||
subgraph.addOutput(longName, 'string')
|
||||
}).not.toThrow()
|
||||
|
||||
expect(subgraph.inputs[0].name).toBe(longName)
|
||||
expect(subgraph.outputs[0].name).toBe(longName)
|
||||
})
|
||||
|
||||
it('should handle Unicode characters in names', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const unicodeName = '测试_🚀_تست_тест'
|
||||
|
||||
expect(() => {
|
||||
subgraph.addInput(unicodeName, 'number')
|
||||
subgraph.addOutput(unicodeName, 'string')
|
||||
}).not.toThrow()
|
||||
|
||||
expect(subgraph.inputs[0].name).toBe(unicodeName)
|
||||
expect(subgraph.outputs[0].name).toBe(unicodeName)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubgraphEdgeCases - Type Validation', () => {
|
||||
it('should allow connecting mismatched types (no validation currently)', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const subgraph = createTestSubgraph()
|
||||
|
||||
subgraph.addInput('num', 'number')
|
||||
subgraph.addOutput('str', 'string')
|
||||
|
||||
// Create a basic node manually since createNode is not available
|
||||
const numberNode = new LGraphNode('basic/const')
|
||||
numberNode.addOutput('value', 'number')
|
||||
rootGraph.add(numberNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
rootGraph.add(subgraphNode)
|
||||
|
||||
// Currently allows mismatched connections (no type validation)
|
||||
expect(() => {
|
||||
numberNode.connect(0, subgraphNode, 0)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle invalid type strings', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
|
||||
// These should not crash (current behavior)
|
||||
expect(() => {
|
||||
subgraph.addInput('test1', 'invalid_type')
|
||||
subgraph.addInput('test2', '')
|
||||
subgraph.addInput('test3', '123')
|
||||
subgraph.addInput('test4', 'special!@#$%')
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle complex type strings', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
|
||||
expect(() => {
|
||||
subgraph.addInput('array', 'array<number>')
|
||||
subgraph.addInput('object', 'object<{x: number, y: string}>')
|
||||
subgraph.addInput('union', 'number|string')
|
||||
}).not.toThrow()
|
||||
|
||||
expect(subgraph.inputs).toHaveLength(3)
|
||||
expect(subgraph.inputs[0].type).toBe('array<number>')
|
||||
expect(subgraph.inputs[1].type).toBe('object<{x: number, y: string}>')
|
||||
expect(subgraph.inputs[2].type).toBe('number|string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubgraphEdgeCases - Performance and Scale', () => {
|
||||
it('should handle large numbers of nodes in subgraph', () => {
|
||||
// Create subgraph with many nodes (keep reasonable for test speed)
|
||||
const subgraph = createTestSubgraph({ nodeCount: 50 })
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
const executableNodes = new Map()
|
||||
const flattened = subgraphNode.getInnerNodes(executableNodes)
|
||||
|
||||
expect(flattened).toHaveLength(50)
|
||||
|
||||
// Performance is acceptable for 50 nodes (typically < 1ms)
|
||||
})
|
||||
|
||||
it('should handle rapid IO changes', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
|
||||
// Rapidly add and remove inputs/outputs
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const input = subgraph.addInput(`rapid_${i}`, 'number')
|
||||
const output = subgraph.addOutput(`rapid_${i}`, 'number')
|
||||
|
||||
// Remove them immediately
|
||||
subgraph.removeInput(input)
|
||||
subgraph.removeOutput(output)
|
||||
}
|
||||
|
||||
// Should end up with no inputs/outputs
|
||||
expect(subgraph.inputs).toHaveLength(0)
|
||||
expect(subgraph.outputs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should handle concurrent modifications safely', () => {
|
||||
// This test ensures the system doesn't crash under concurrent access
|
||||
// Note: JavaScript is single-threaded, so this tests rapid sequential access
|
||||
const subgraph = createTestSubgraph({ nodeCount: 5 })
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
// Simulate concurrent operations
|
||||
// @ts-expect-error TODO: Fix after merge - operations implicitly has any[] type
|
||||
const operations = []
|
||||
for (let i = 0; i < 20; i++) {
|
||||
operations.push(
|
||||
() => {
|
||||
const executableNodes = new Map()
|
||||
subgraphNode.getInnerNodes(executableNodes)
|
||||
},
|
||||
() => {
|
||||
subgraph.addInput(`concurrent_${i}`, 'number')
|
||||
},
|
||||
() => {
|
||||
if (subgraph.inputs.length > 0) {
|
||||
subgraph.removeInput(subgraph.inputs[0])
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Execute all operations - should not crash
|
||||
expect(() => {
|
||||
// @ts-expect-error TODO: Fix after merge - operations implicitly has any[] type
|
||||
for (const op of operations) op()
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -1,518 +0,0 @@
|
||||
import { describe, expect, vi } from 'vitest'
|
||||
|
||||
import { subgraphTest } from './fixtures/subgraphFixtures'
|
||||
import { verifyEventSequence } from './fixtures/subgraphHelpers'
|
||||
|
||||
describe('SubgraphEvents - Event Payload Verification', () => {
|
||||
subgraphTest(
|
||||
'dispatches input-added with correct payload',
|
||||
({ eventCapture }) => {
|
||||
const { subgraph, capture } = eventCapture
|
||||
|
||||
const input = subgraph.addInput('test_input', 'number')
|
||||
|
||||
const addedEvents = capture.getEventsByType('input-added')
|
||||
expect(addedEvents).toHaveLength(1)
|
||||
|
||||
expect(addedEvents[0].detail).toEqual({
|
||||
input: expect.objectContaining({
|
||||
name: 'test_input',
|
||||
type: 'number'
|
||||
})
|
||||
})
|
||||
|
||||
// @ts-expect-error TODO: Fix after merge - detail is of type unknown
|
||||
expect(addedEvents[0].detail.input).toBe(input)
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest(
|
||||
'dispatches output-added with correct payload',
|
||||
({ eventCapture }) => {
|
||||
const { subgraph, capture } = eventCapture
|
||||
|
||||
const output = subgraph.addOutput('test_output', 'string')
|
||||
|
||||
const addedEvents = capture.getEventsByType('output-added')
|
||||
expect(addedEvents).toHaveLength(1)
|
||||
|
||||
expect(addedEvents[0].detail).toEqual({
|
||||
output: expect.objectContaining({
|
||||
name: 'test_output',
|
||||
type: 'string'
|
||||
})
|
||||
})
|
||||
|
||||
// @ts-expect-error TODO: Fix after merge - detail is of type unknown
|
||||
expect(addedEvents[0].detail.output).toBe(output)
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest(
|
||||
'dispatches removing-input with correct payload',
|
||||
({ eventCapture }) => {
|
||||
const { subgraph, capture } = eventCapture
|
||||
|
||||
const input = subgraph.addInput('to_remove', 'boolean')
|
||||
|
||||
capture.clear()
|
||||
|
||||
subgraph.removeInput(input)
|
||||
|
||||
const removingEvents = capture.getEventsByType('removing-input')
|
||||
expect(removingEvents).toHaveLength(1)
|
||||
|
||||
expect(removingEvents[0].detail).toEqual({
|
||||
input: expect.objectContaining({
|
||||
name: 'to_remove',
|
||||
type: 'boolean'
|
||||
}),
|
||||
index: 0
|
||||
})
|
||||
|
||||
// @ts-expect-error TODO: Fix after merge - detail is of type unknown
|
||||
expect(removingEvents[0].detail.input).toBe(input)
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest(
|
||||
'dispatches removing-output with correct payload',
|
||||
({ eventCapture }) => {
|
||||
const { subgraph, capture } = eventCapture
|
||||
|
||||
const output = subgraph.addOutput('to_remove', 'number')
|
||||
|
||||
capture.clear()
|
||||
|
||||
subgraph.removeOutput(output)
|
||||
|
||||
const removingEvents = capture.getEventsByType('removing-output')
|
||||
expect(removingEvents).toHaveLength(1)
|
||||
|
||||
expect(removingEvents[0].detail).toEqual({
|
||||
output: expect.objectContaining({
|
||||
name: 'to_remove',
|
||||
type: 'number'
|
||||
}),
|
||||
index: 0
|
||||
})
|
||||
|
||||
// @ts-expect-error TODO: Fix after merge - detail is of type unknown
|
||||
expect(removingEvents[0].detail.output).toBe(output)
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest(
|
||||
'dispatches renaming-input with correct payload',
|
||||
({ eventCapture }) => {
|
||||
const { subgraph, capture } = eventCapture
|
||||
|
||||
const input = subgraph.addInput('old_name', 'string')
|
||||
|
||||
capture.clear()
|
||||
|
||||
subgraph.renameInput(input, 'new_name')
|
||||
|
||||
const renamingEvents = capture.getEventsByType('renaming-input')
|
||||
expect(renamingEvents).toHaveLength(1)
|
||||
|
||||
expect(renamingEvents[0].detail).toEqual({
|
||||
input: expect.objectContaining({
|
||||
type: 'string'
|
||||
}),
|
||||
index: 0,
|
||||
oldName: 'old_name',
|
||||
newName: 'new_name'
|
||||
})
|
||||
|
||||
// @ts-expect-error TODO: Fix after merge - detail is of type unknown
|
||||
expect(renamingEvents[0].detail.input).toBe(input)
|
||||
|
||||
// Verify the label was updated after the event (renameInput sets label, not name)
|
||||
expect(input.label).toBe('new_name')
|
||||
expect(input.displayName).toBe('new_name')
|
||||
expect(input.name).toBe('old_name')
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest(
|
||||
'dispatches renaming-output with correct payload',
|
||||
({ eventCapture }) => {
|
||||
const { subgraph, capture } = eventCapture
|
||||
|
||||
const output = subgraph.addOutput('old_name', 'number')
|
||||
|
||||
capture.clear()
|
||||
|
||||
subgraph.renameOutput(output, 'new_name')
|
||||
|
||||
const renamingEvents = capture.getEventsByType('renaming-output')
|
||||
expect(renamingEvents).toHaveLength(1)
|
||||
|
||||
expect(renamingEvents[0].detail).toEqual({
|
||||
output: expect.objectContaining({
|
||||
name: 'old_name', // Should still have the old name when event is dispatched
|
||||
type: 'number'
|
||||
}),
|
||||
index: 0,
|
||||
oldName: 'old_name',
|
||||
newName: 'new_name'
|
||||
})
|
||||
|
||||
// @ts-expect-error TODO: Fix after merge - detail is of type unknown
|
||||
expect(renamingEvents[0].detail.output).toBe(output)
|
||||
|
||||
// Verify the label was updated after the event
|
||||
expect(output.label).toBe('new_name')
|
||||
expect(output.displayName).toBe('new_name')
|
||||
expect(output.name).toBe('old_name')
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest(
|
||||
'dispatches adding-input with correct payload',
|
||||
({ eventCapture }) => {
|
||||
const { subgraph, capture } = eventCapture
|
||||
|
||||
subgraph.addInput('test_input', 'number')
|
||||
|
||||
const addingEvents = capture.getEventsByType('adding-input')
|
||||
expect(addingEvents).toHaveLength(1)
|
||||
|
||||
expect(addingEvents[0].detail).toEqual({
|
||||
name: 'test_input',
|
||||
type: 'number'
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest(
|
||||
'dispatches adding-output with correct payload',
|
||||
({ eventCapture }) => {
|
||||
const { subgraph, capture } = eventCapture
|
||||
|
||||
subgraph.addOutput('test_output', 'string')
|
||||
|
||||
const addingEvents = capture.getEventsByType('adding-output')
|
||||
expect(addingEvents).toHaveLength(1)
|
||||
|
||||
expect(addingEvents[0].detail).toEqual({
|
||||
name: 'test_output',
|
||||
type: 'string'
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('SubgraphEvents - Event Handler Isolation', () => {
|
||||
subgraphTest(
|
||||
'continues dispatching if handler throws',
|
||||
({ emptySubgraph }) => {
|
||||
const handler1 = vi.fn(() => {
|
||||
throw new Error('Handler 1 error')
|
||||
})
|
||||
const handler2 = vi.fn()
|
||||
const handler3 = vi.fn()
|
||||
|
||||
emptySubgraph.events.addEventListener('input-added', handler1)
|
||||
emptySubgraph.events.addEventListener('input-added', handler2)
|
||||
emptySubgraph.events.addEventListener('input-added', handler3)
|
||||
|
||||
// The operation itself should not throw (error is isolated)
|
||||
expect(() => {
|
||||
emptySubgraph.addInput('test', 'number')
|
||||
}).not.toThrow()
|
||||
|
||||
// Verify all handlers were called despite the first one throwing
|
||||
expect(handler1).toHaveBeenCalled()
|
||||
expect(handler2).toHaveBeenCalled()
|
||||
expect(handler3).toHaveBeenCalled()
|
||||
|
||||
// Verify the throwing handler actually received the event
|
||||
expect(handler1).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'input-added'
|
||||
})
|
||||
)
|
||||
|
||||
// Verify other handlers received correct event data
|
||||
expect(handler2).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'input-added',
|
||||
detail: expect.objectContaining({
|
||||
input: expect.objectContaining({
|
||||
name: 'test',
|
||||
type: 'number'
|
||||
})
|
||||
})
|
||||
})
|
||||
)
|
||||
expect(handler3).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'input-added'
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest('maintains handler execution order', ({ emptySubgraph }) => {
|
||||
const executionOrder: number[] = []
|
||||
|
||||
const handler1 = vi.fn(() => executionOrder.push(1))
|
||||
const handler2 = vi.fn(() => executionOrder.push(2))
|
||||
const handler3 = vi.fn(() => executionOrder.push(3))
|
||||
|
||||
emptySubgraph.events.addEventListener('input-added', handler1)
|
||||
emptySubgraph.events.addEventListener('input-added', handler2)
|
||||
emptySubgraph.events.addEventListener('input-added', handler3)
|
||||
|
||||
emptySubgraph.addInput('test', 'number')
|
||||
|
||||
expect(executionOrder).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
subgraphTest(
|
||||
'prevents handler accumulation with proper cleanup',
|
||||
({ emptySubgraph }) => {
|
||||
const handler = vi.fn()
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
emptySubgraph.events.addEventListener('input-added', handler)
|
||||
emptySubgraph.events.removeEventListener('input-added', handler)
|
||||
}
|
||||
|
||||
emptySubgraph.events.addEventListener('input-added', handler)
|
||||
|
||||
emptySubgraph.addInput('test', 'number')
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest(
|
||||
'supports AbortController cleanup patterns',
|
||||
({ emptySubgraph }) => {
|
||||
const abortController = new AbortController()
|
||||
const { signal } = abortController
|
||||
|
||||
const handler = vi.fn()
|
||||
|
||||
emptySubgraph.events.addEventListener('input-added', handler, { signal })
|
||||
|
||||
emptySubgraph.addInput('test1', 'number')
|
||||
expect(handler).toHaveBeenCalledTimes(1)
|
||||
|
||||
abortController.abort()
|
||||
|
||||
emptySubgraph.addInput('test2', 'number')
|
||||
expect(handler).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('SubgraphEvents - Event Sequence Testing', () => {
|
||||
subgraphTest(
|
||||
'maintains correct event sequence for inputs',
|
||||
({ eventCapture }) => {
|
||||
const { subgraph, capture } = eventCapture
|
||||
|
||||
subgraph.addInput('input1', 'number')
|
||||
|
||||
verifyEventSequence(capture.events, ['adding-input', 'input-added'])
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest(
|
||||
'maintains correct event sequence for outputs',
|
||||
({ eventCapture }) => {
|
||||
const { subgraph, capture } = eventCapture
|
||||
|
||||
subgraph.addOutput('output1', 'string')
|
||||
|
||||
verifyEventSequence(capture.events, ['adding-output', 'output-added'])
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest(
|
||||
'maintains correct event sequence for rapid operations',
|
||||
({ eventCapture }) => {
|
||||
const { subgraph, capture } = eventCapture
|
||||
|
||||
subgraph.addInput('input1', 'number')
|
||||
subgraph.addInput('input2', 'string')
|
||||
subgraph.addOutput('output1', 'boolean')
|
||||
subgraph.addOutput('output2', 'number')
|
||||
|
||||
verifyEventSequence(capture.events, [
|
||||
'adding-input',
|
||||
'input-added',
|
||||
'adding-input',
|
||||
'input-added',
|
||||
'adding-output',
|
||||
'output-added',
|
||||
'adding-output',
|
||||
'output-added'
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest('handles concurrent event handling', ({ eventCapture }) => {
|
||||
const { subgraph, capture } = eventCapture
|
||||
|
||||
const handler1 = vi.fn(() => {
|
||||
return new Promise((resolve) => setTimeout(resolve, 1))
|
||||
})
|
||||
|
||||
const handler2 = vi.fn()
|
||||
const handler3 = vi.fn()
|
||||
|
||||
subgraph.events.addEventListener('input-added', handler1)
|
||||
subgraph.events.addEventListener('input-added', handler2)
|
||||
subgraph.events.addEventListener('input-added', handler3)
|
||||
|
||||
subgraph.addInput('test', 'number')
|
||||
|
||||
expect(handler1).toHaveBeenCalled()
|
||||
expect(handler2).toHaveBeenCalled()
|
||||
expect(handler3).toHaveBeenCalled()
|
||||
|
||||
const addedEvents = capture.getEventsByType('input-added')
|
||||
expect(addedEvents).toHaveLength(1)
|
||||
})
|
||||
|
||||
subgraphTest(
|
||||
'validates event timestamps are properly ordered',
|
||||
({ eventCapture }) => {
|
||||
const { subgraph, capture } = eventCapture
|
||||
|
||||
subgraph.addInput('input1', 'number')
|
||||
subgraph.addInput('input2', 'string')
|
||||
subgraph.addOutput('output1', 'boolean')
|
||||
|
||||
for (let i = 1; i < capture.events.length; i++) {
|
||||
expect(capture.events[i].timestamp).toBeGreaterThanOrEqual(
|
||||
capture.events[i - 1].timestamp
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('SubgraphEvents - Event Cancellation', () => {
|
||||
subgraphTest(
|
||||
'supports preventDefault() for cancellable events',
|
||||
({ emptySubgraph }) => {
|
||||
const preventHandler = vi.fn((event: Event) => {
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
emptySubgraph.events.addEventListener('removing-input', preventHandler)
|
||||
|
||||
const input = emptySubgraph.addInput('test', 'number')
|
||||
|
||||
emptySubgraph.removeInput(input)
|
||||
|
||||
expect(emptySubgraph.inputs).toContain(input)
|
||||
expect(preventHandler).toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest(
|
||||
'supports preventDefault() for output removal',
|
||||
({ emptySubgraph }) => {
|
||||
const preventHandler = vi.fn((event: Event) => {
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
emptySubgraph.events.addEventListener('removing-output', preventHandler)
|
||||
|
||||
const output = emptySubgraph.addOutput('test', 'number')
|
||||
|
||||
emptySubgraph.removeOutput(output)
|
||||
|
||||
expect(emptySubgraph.outputs).toContain(output)
|
||||
expect(preventHandler).toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest('allows removal when not prevented', ({ emptySubgraph }) => {
|
||||
const allowHandler = vi.fn()
|
||||
|
||||
emptySubgraph.events.addEventListener('removing-input', allowHandler)
|
||||
|
||||
const input = emptySubgraph.addInput('test', 'number')
|
||||
|
||||
emptySubgraph.removeInput(input)
|
||||
|
||||
expect(emptySubgraph.inputs).not.toContain(input)
|
||||
expect(emptySubgraph.inputs).toHaveLength(0)
|
||||
expect(allowHandler).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubgraphEvents - Event Detail Structure Validation', () => {
|
||||
subgraphTest(
|
||||
'validates all event detail structures match TypeScript types',
|
||||
({ eventCapture }) => {
|
||||
const { subgraph, capture } = eventCapture
|
||||
|
||||
const input = subgraph.addInput('test_input', 'number')
|
||||
subgraph.renameInput(input, 'renamed_input')
|
||||
subgraph.removeInput(input)
|
||||
|
||||
const output = subgraph.addOutput('test_output', 'string')
|
||||
subgraph.renameOutput(output, 'renamed_output')
|
||||
subgraph.removeOutput(output)
|
||||
|
||||
const addingInputEvent = capture.getEventsByType('adding-input')[0]
|
||||
expect(addingInputEvent.detail).toEqual({
|
||||
name: expect.any(String),
|
||||
type: expect.any(String)
|
||||
})
|
||||
|
||||
const inputAddedEvent = capture.getEventsByType('input-added')[0]
|
||||
expect(inputAddedEvent.detail).toEqual({
|
||||
input: expect.any(Object)
|
||||
})
|
||||
|
||||
const renamingInputEvent = capture.getEventsByType('renaming-input')[0]
|
||||
expect(renamingInputEvent.detail).toEqual({
|
||||
input: expect.any(Object),
|
||||
index: expect.any(Number),
|
||||
oldName: expect.any(String),
|
||||
newName: expect.any(String)
|
||||
})
|
||||
|
||||
const removingInputEvent = capture.getEventsByType('removing-input')[0]
|
||||
expect(removingInputEvent.detail).toEqual({
|
||||
input: expect.any(Object),
|
||||
index: expect.any(Number)
|
||||
})
|
||||
|
||||
const addingOutputEvent = capture.getEventsByType('adding-output')[0]
|
||||
expect(addingOutputEvent.detail).toEqual({
|
||||
name: expect.any(String),
|
||||
type: expect.any(String)
|
||||
})
|
||||
|
||||
const outputAddedEvent = capture.getEventsByType('output-added')[0]
|
||||
expect(outputAddedEvent.detail).toEqual({
|
||||
output: expect.any(Object)
|
||||
})
|
||||
|
||||
const renamingOutputEvent = capture.getEventsByType('renaming-output')[0]
|
||||
expect(renamingOutputEvent.detail).toEqual({
|
||||
output: expect.any(Object),
|
||||
index: expect.any(Number),
|
||||
oldName: expect.any(String),
|
||||
newName: expect.any(String)
|
||||
})
|
||||
|
||||
const removingOutputEvent = capture.getEventsByType('removing-output')[0]
|
||||
expect(removingOutputEvent.detail).toEqual({
|
||||
output: expect.any(Object),
|
||||
index: expect.any(Number)
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -1,441 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { subgraphTest } from './fixtures/subgraphFixtures'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from './fixtures/subgraphHelpers'
|
||||
|
||||
describe('SubgraphIO - Input Slot Dual-Nature Behavior', () => {
|
||||
subgraphTest(
|
||||
'input accepts external connections from parent graph',
|
||||
({ subgraphWithNode }) => {
|
||||
const { subgraph, subgraphNode, parentGraph } = subgraphWithNode
|
||||
|
||||
subgraph.addInput('test_input', 'number')
|
||||
|
||||
const externalNode = new LGraphNode('External Source')
|
||||
externalNode.addOutput('out', 'number')
|
||||
parentGraph.add(externalNode)
|
||||
|
||||
expect(() => {
|
||||
externalNode.connect(0, subgraphNode, 0)
|
||||
}).not.toThrow()
|
||||
|
||||
expect(
|
||||
// @ts-expect-error TODO: Fix after merge - link can be null
|
||||
externalNode.outputs[0].links?.includes(subgraphNode.inputs[0].link)
|
||||
).toBe(true)
|
||||
expect(subgraphNode.inputs[0].link).not.toBe(null)
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest(
|
||||
'empty input slot creation enables dynamic IO',
|
||||
({ simpleSubgraph }) => {
|
||||
const initialInputCount = simpleSubgraph.inputs.length
|
||||
|
||||
// Create empty input slot
|
||||
simpleSubgraph.addInput('', '*')
|
||||
|
||||
// Should create new input
|
||||
expect(simpleSubgraph.inputs.length).toBe(initialInputCount + 1)
|
||||
|
||||
// The empty slot should be configurable
|
||||
const emptyInput = simpleSubgraph.inputs.at(-1)
|
||||
// @ts-expect-error TODO: Fix after merge - emptyInput possibly undefined
|
||||
expect(emptyInput.name).toBe('')
|
||||
// @ts-expect-error TODO: Fix after merge - emptyInput possibly undefined
|
||||
expect(emptyInput.type).toBe('*')
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest(
|
||||
'handles slot removal with active connections',
|
||||
({ subgraphWithNode }) => {
|
||||
const { subgraph, subgraphNode, parentGraph } = subgraphWithNode
|
||||
|
||||
const externalNode = new LGraphNode('External Source')
|
||||
externalNode.addOutput('out', '*')
|
||||
parentGraph.add(externalNode)
|
||||
|
||||
externalNode.connect(0, subgraphNode, 0)
|
||||
|
||||
// Verify connection exists
|
||||
expect(subgraphNode.inputs[0].link).not.toBe(null)
|
||||
|
||||
// Remove the existing input (fixture creates one input)
|
||||
const inputToRemove = subgraph.inputs[0]
|
||||
subgraph.removeInput(inputToRemove)
|
||||
|
||||
// Connection should be cleaned up
|
||||
expect(subgraphNode.inputs.length).toBe(0)
|
||||
expect(externalNode.outputs[0].links).toHaveLength(0)
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest(
|
||||
'handles slot renaming with active connections',
|
||||
({ subgraphWithNode }) => {
|
||||
const { subgraph, subgraphNode, parentGraph } = subgraphWithNode
|
||||
|
||||
const externalNode = new LGraphNode('External Source')
|
||||
externalNode.addOutput('out', '*')
|
||||
parentGraph.add(externalNode)
|
||||
|
||||
externalNode.connect(0, subgraphNode, 0)
|
||||
|
||||
// Verify connection exists
|
||||
expect(subgraphNode.inputs[0].link).not.toBe(null)
|
||||
|
||||
// Rename the existing input (fixture creates input named "input")
|
||||
const inputToRename = subgraph.inputs[0]
|
||||
subgraph.renameInput(inputToRename, 'new_name')
|
||||
|
||||
// Connection should persist and subgraph definition should be updated
|
||||
expect(subgraphNode.inputs[0].link).not.toBe(null)
|
||||
expect(subgraph.inputs[0].label).toBe('new_name')
|
||||
expect(subgraph.inputs[0].displayName).toBe('new_name')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('SubgraphIO - Output Slot Dual-Nature Behavior', () => {
|
||||
subgraphTest(
|
||||
'output provides connections to parent graph',
|
||||
({ subgraphWithNode }) => {
|
||||
const { subgraph, subgraphNode, parentGraph } = subgraphWithNode
|
||||
|
||||
// Add an output to the subgraph
|
||||
subgraph.addOutput('test_output', 'number')
|
||||
|
||||
const externalNode = new LGraphNode('External Target')
|
||||
externalNode.addInput('in', 'number')
|
||||
parentGraph.add(externalNode)
|
||||
|
||||
// External connection from subgraph output should work
|
||||
expect(() => {
|
||||
subgraphNode.connect(0, externalNode, 0)
|
||||
}).not.toThrow()
|
||||
|
||||
expect(
|
||||
// @ts-expect-error TODO: Fix after merge - link can be null
|
||||
subgraphNode.outputs[0].links?.includes(externalNode.inputs[0].link)
|
||||
).toBe(true)
|
||||
expect(externalNode.inputs[0].link).not.toBe(null)
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest(
|
||||
'empty output slot creation enables dynamic IO',
|
||||
({ simpleSubgraph }) => {
|
||||
const initialOutputCount = simpleSubgraph.outputs.length
|
||||
|
||||
// Create empty output slot
|
||||
simpleSubgraph.addOutput('', '*')
|
||||
|
||||
// Should create new output
|
||||
expect(simpleSubgraph.outputs.length).toBe(initialOutputCount + 1)
|
||||
|
||||
// The empty slot should be configurable
|
||||
const emptyOutput = simpleSubgraph.outputs.at(-1)
|
||||
// @ts-expect-error TODO: Fix after merge - emptyOutput possibly undefined
|
||||
expect(emptyOutput.name).toBe('')
|
||||
// @ts-expect-error TODO: Fix after merge - emptyOutput possibly undefined
|
||||
expect(emptyOutput.type).toBe('*')
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest(
|
||||
'handles slot removal with active connections',
|
||||
({ subgraphWithNode }) => {
|
||||
const { subgraph, subgraphNode, parentGraph } = subgraphWithNode
|
||||
|
||||
const externalNode = new LGraphNode('External Target')
|
||||
externalNode.addInput('in', '*')
|
||||
parentGraph.add(externalNode)
|
||||
|
||||
subgraphNode.connect(0, externalNode, 0)
|
||||
|
||||
// Verify connection exists
|
||||
expect(externalNode.inputs[0].link).not.toBe(null)
|
||||
|
||||
// Remove the existing output (fixture creates one output)
|
||||
const outputToRemove = subgraph.outputs[0]
|
||||
subgraph.removeOutput(outputToRemove)
|
||||
|
||||
// Connection should be cleaned up
|
||||
expect(subgraphNode.outputs.length).toBe(0)
|
||||
expect(externalNode.inputs[0].link).toBe(null)
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest(
|
||||
'handles slot renaming updates all references',
|
||||
({ subgraphWithNode }) => {
|
||||
const { subgraph, subgraphNode, parentGraph } = subgraphWithNode
|
||||
|
||||
const externalNode = new LGraphNode('External Target')
|
||||
externalNode.addInput('in', '*')
|
||||
parentGraph.add(externalNode)
|
||||
|
||||
subgraphNode.connect(0, externalNode, 0)
|
||||
|
||||
// Verify connection exists
|
||||
expect(externalNode.inputs[0].link).not.toBe(null)
|
||||
|
||||
// Rename the existing output (fixture creates output named "output")
|
||||
const outputToRename = subgraph.outputs[0]
|
||||
subgraph.renameOutput(outputToRename, 'new_name')
|
||||
|
||||
// Connection should persist and subgraph definition should be updated
|
||||
expect(externalNode.inputs[0].link).not.toBe(null)
|
||||
expect(subgraph.outputs[0].label).toBe('new_name')
|
||||
expect(subgraph.outputs[0].displayName).toBe('new_name')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('SubgraphIO - Boundary Connection Management', () => {
|
||||
subgraphTest(
|
||||
'verifies cross-boundary link resolution',
|
||||
({ complexSubgraph }) => {
|
||||
const subgraphNode = createTestSubgraphNode(complexSubgraph)
|
||||
const parentGraph = subgraphNode.graph!
|
||||
|
||||
const externalSource = new LGraphNode('External Source')
|
||||
externalSource.addOutput('out', 'number')
|
||||
parentGraph.add(externalSource)
|
||||
|
||||
const externalTarget = new LGraphNode('External Target')
|
||||
externalTarget.addInput('in', 'number')
|
||||
parentGraph.add(externalTarget)
|
||||
|
||||
externalSource.connect(0, subgraphNode, 0)
|
||||
subgraphNode.connect(0, externalTarget, 0)
|
||||
|
||||
expect(subgraphNode.inputs[0].link).not.toBe(null)
|
||||
expect(externalTarget.inputs[0].link).not.toBe(null)
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest(
|
||||
'handles bypass nodes that pass through data',
|
||||
({ simpleSubgraph }) => {
|
||||
const subgraphNode = createTestSubgraphNode(simpleSubgraph)
|
||||
const parentGraph = subgraphNode.graph!
|
||||
|
||||
const externalSource = new LGraphNode('External Source')
|
||||
externalSource.addOutput('out', 'number')
|
||||
parentGraph.add(externalSource)
|
||||
|
||||
const externalTarget = new LGraphNode('External Target')
|
||||
externalTarget.addInput('in', 'number')
|
||||
parentGraph.add(externalTarget)
|
||||
|
||||
externalSource.connect(0, subgraphNode, 0)
|
||||
subgraphNode.connect(0, externalTarget, 0)
|
||||
|
||||
expect(subgraphNode.inputs[0].link).not.toBe(null)
|
||||
expect(externalTarget.inputs[0].link).not.toBe(null)
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest(
|
||||
'tests link integrity across subgraph boundaries',
|
||||
({ subgraphWithNode }) => {
|
||||
const { subgraphNode, parentGraph } = subgraphWithNode
|
||||
|
||||
const externalSource = new LGraphNode('External Source')
|
||||
externalSource.addOutput('out', '*')
|
||||
parentGraph.add(externalSource)
|
||||
|
||||
const externalTarget = new LGraphNode('External Target')
|
||||
externalTarget.addInput('in', '*')
|
||||
parentGraph.add(externalTarget)
|
||||
|
||||
externalSource.connect(0, subgraphNode, 0)
|
||||
subgraphNode.connect(0, externalTarget, 0)
|
||||
|
||||
const inputBoundaryLink = subgraphNode.inputs[0].link
|
||||
const outputBoundaryLink = externalTarget.inputs[0].link
|
||||
|
||||
expect(inputBoundaryLink).toBeTruthy()
|
||||
expect(outputBoundaryLink).toBeTruthy()
|
||||
|
||||
// Links should exist in parent graph
|
||||
expect(inputBoundaryLink).toBeTruthy()
|
||||
expect(outputBoundaryLink).toBeTruthy()
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest(
|
||||
'verifies proper link cleanup on slot removal',
|
||||
({ complexSubgraph }) => {
|
||||
const subgraphNode = createTestSubgraphNode(complexSubgraph)
|
||||
const parentGraph = subgraphNode.graph!
|
||||
|
||||
const externalSource = new LGraphNode('External Source')
|
||||
externalSource.addOutput('out', 'number')
|
||||
parentGraph.add(externalSource)
|
||||
|
||||
const externalTarget = new LGraphNode('External Target')
|
||||
externalTarget.addInput('in', 'number')
|
||||
parentGraph.add(externalTarget)
|
||||
|
||||
externalSource.connect(0, subgraphNode, 0)
|
||||
subgraphNode.connect(0, externalTarget, 0)
|
||||
|
||||
expect(subgraphNode.inputs[0].link).not.toBe(null)
|
||||
expect(externalTarget.inputs[0].link).not.toBe(null)
|
||||
|
||||
const inputToRemove = complexSubgraph.inputs[0]
|
||||
complexSubgraph.removeInput(inputToRemove)
|
||||
|
||||
expect(subgraphNode.inputs.findIndex((i) => i.name === 'data')).toBe(-1)
|
||||
expect(externalSource.outputs[0].links).toHaveLength(0)
|
||||
|
||||
const outputToRemove = complexSubgraph.outputs[0]
|
||||
complexSubgraph.removeOutput(outputToRemove)
|
||||
|
||||
expect(subgraphNode.outputs.findIndex((o) => o.name === 'result')).toBe(
|
||||
-1
|
||||
)
|
||||
expect(externalTarget.inputs[0].link).toBe(null)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('SubgraphIO - Advanced Scenarios', () => {
|
||||
it('handles multiple inputs and outputs with complex connections', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
name: 'Complex IO Test',
|
||||
inputs: [
|
||||
{ name: 'input1', type: 'number' },
|
||||
{ name: 'input2', type: 'string' },
|
||||
{ name: 'input3', type: 'boolean' }
|
||||
],
|
||||
outputs: [
|
||||
{ name: 'output1', type: 'number' },
|
||||
{ name: 'output2', type: 'string' }
|
||||
]
|
||||
})
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
// Should have correct number of slots
|
||||
expect(subgraphNode.inputs.length).toBe(3)
|
||||
expect(subgraphNode.outputs.length).toBe(2)
|
||||
|
||||
// Each slot should have correct type
|
||||
expect(subgraphNode.inputs[0].type).toBe('number')
|
||||
expect(subgraphNode.inputs[1].type).toBe('string')
|
||||
expect(subgraphNode.inputs[2].type).toBe('boolean')
|
||||
expect(subgraphNode.outputs[0].type).toBe('number')
|
||||
expect(subgraphNode.outputs[1].type).toBe('string')
|
||||
})
|
||||
|
||||
it('handles dynamic slot creation and removal', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
name: 'Dynamic IO Test'
|
||||
})
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
// Start with no slots
|
||||
expect(subgraphNode.inputs.length).toBe(0)
|
||||
expect(subgraphNode.outputs.length).toBe(0)
|
||||
|
||||
// Add slots dynamically
|
||||
subgraph.addInput('dynamic_input', 'number')
|
||||
subgraph.addOutput('dynamic_output', 'string')
|
||||
|
||||
// SubgraphNode should automatically update
|
||||
expect(subgraphNode.inputs.length).toBe(1)
|
||||
expect(subgraphNode.outputs.length).toBe(1)
|
||||
expect(subgraphNode.inputs[0].name).toBe('dynamic_input')
|
||||
expect(subgraphNode.outputs[0].name).toBe('dynamic_output')
|
||||
|
||||
// Remove slots
|
||||
subgraph.removeInput(subgraph.inputs[0])
|
||||
subgraph.removeOutput(subgraph.outputs[0])
|
||||
|
||||
// SubgraphNode should automatically update
|
||||
expect(subgraphNode.inputs.length).toBe(0)
|
||||
expect(subgraphNode.outputs.length).toBe(0)
|
||||
})
|
||||
|
||||
it('maintains slot synchronization across multiple instances', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
name: 'Multi-Instance Test',
|
||||
inputs: [{ name: 'shared_input', type: 'number' }],
|
||||
outputs: [{ name: 'shared_output', type: 'number' }]
|
||||
})
|
||||
|
||||
// Create multiple instances
|
||||
const instance1 = createTestSubgraphNode(subgraph)
|
||||
const instance2 = createTestSubgraphNode(subgraph)
|
||||
const instance3 = createTestSubgraphNode(subgraph)
|
||||
|
||||
// All instances should have same slots
|
||||
expect(instance1.inputs.length).toBe(1)
|
||||
expect(instance2.inputs.length).toBe(1)
|
||||
expect(instance3.inputs.length).toBe(1)
|
||||
|
||||
// Modify the subgraph definition
|
||||
subgraph.addInput('new_input', 'string')
|
||||
subgraph.addOutput('new_output', 'boolean')
|
||||
|
||||
// All instances should automatically update
|
||||
expect(instance1.inputs.length).toBe(2)
|
||||
expect(instance2.inputs.length).toBe(2)
|
||||
expect(instance3.inputs.length).toBe(2)
|
||||
expect(instance1.outputs.length).toBe(2)
|
||||
expect(instance2.outputs.length).toBe(2)
|
||||
expect(instance3.outputs.length).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubgraphIO - Empty Slot Connection', () => {
|
||||
subgraphTest(
|
||||
'creates new input and connects when dragging from empty slot inside subgraph',
|
||||
({ subgraphWithNode }) => {
|
||||
const { subgraph, subgraphNode } = subgraphWithNode
|
||||
|
||||
// Create a node inside the subgraph that will receive the connection
|
||||
const internalNode = new LGraphNode('Internal Node')
|
||||
internalNode.addInput('in', 'string')
|
||||
subgraph.add(internalNode)
|
||||
|
||||
// Simulate the connection process from the empty slot to an internal node
|
||||
// The -1 indicates a connection from the "empty" slot
|
||||
subgraph.inputNode.connectByType(-1, internalNode, 'string')
|
||||
|
||||
// 1. A new input should have been created on the subgraph
|
||||
expect(subgraph.inputs.length).toBe(2) // Fixture adds one input already
|
||||
const newInput = subgraph.inputs[1]
|
||||
expect(newInput.name).toBe('in')
|
||||
expect(newInput.type).toBe('string')
|
||||
|
||||
// 2. The subgraph node should now have a corresponding real input slot
|
||||
expect(subgraphNode.inputs.length).toBe(2)
|
||||
const subgraphInputSlot = subgraphNode.inputs[1]
|
||||
expect(subgraphInputSlot.name).toBe('in')
|
||||
|
||||
// 3. A link should be established inside the subgraph
|
||||
expect(internalNode.inputs[0].link).not.toBe(null)
|
||||
const link = subgraph.links.get(internalNode.inputs[0].link!)
|
||||
expect(link).toBeDefined()
|
||||
// @ts-expect-error TODO: Fix after merge - link possibly undefined
|
||||
expect(link.target_id).toBe(internalNode.id)
|
||||
// @ts-expect-error TODO: Fix after merge - link possibly undefined
|
||||
expect(link.target_slot).toBe(0)
|
||||
// @ts-expect-error TODO: Fix after merge - link possibly undefined
|
||||
expect(link.origin_id).toBe(subgraph.inputNode.id)
|
||||
// @ts-expect-error TODO: Fix after merge - link possibly undefined
|
||||
expect(link.origin_slot).toBe(1) // Should be the second slot
|
||||
}
|
||||
)
|
||||
})
|
||||