Files
ComfyUI_frontend/tests-ui/tests/litegraph/core/LGraphNode.test.ts
Benjamin Lu 2a5e0d231e Decouple link and slot hit-testing out of Litegraph (#5134)
* [feat] TransformPane - Viewport synchronization layer for Vue nodes (#4304)

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Benjamin Lu <benceruleanlu@proton.me>
Co-authored-by: github-actions <github-actions@github.com>

* Update locales [skip ci]

* Update locales [skip ci]

* Add vue node feature flag (#4927)

* feat: Implement CRDT-based layout system for Vue nodes (#4959)

* feat: Implement CRDT-based layout system for Vue nodes

Major refactor to solve snap-back issues and create single source of truth for node positions:

- Add Yjs-based CRDT layout store for conflict-free position management
- Implement layout mutations service with clean API
- Create Vue composables for layout access and node dragging
- Add one-way sync from layout store to LiteGraph
- Disable LiteGraph dragging when Vue nodes mode is enabled
- Add z-index management with bring-to-front on node interaction
- Add comprehensive TypeScript types for layout system
- Include unit tests for layout store operations
- Update documentation to reflect CRDT architecture

This provides a solid foundation for both single-user performance and future real-time collaboration features.

Co-Authored-By: Claude <noreply@anthropic.com>

* style: Apply linter fixes to layout system

* fix: Remove unnecessary README files and revert services README

- Remove unnecessary types/README.md file
- Revert unrelated changes to services/README.md
- Keep only relevant documentation for the layout system implementation

These were issues identified during PR review that needed to be addressed.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: Clean up layout store and implement proper CRDT operations

- Created dedicated layoutOperations.ts with production-grade CRDT interfaces
- Integrated existing QuadTree spatial index instead of simple cache
- Split composables into separate files (useLayout, useNodeLayout, useLayoutSync)
- Cleaned up operation handlers using specific types instead of Extract
- Added proper operation interfaces with type guards and extensibility
- Updated all type references to use new operation structure

The layout store now properly uses the existing QuadTree infrastructure for
efficient spatial queries and follows CRDT best practices with well-defined
operation interfaces.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: Extract services and split composables for better organization

- Created SpatialIndexManager to handle QuadTree operations separately
- Added LayoutAdapter interface for CRDT abstraction (Yjs, mock implementations)
- Split GraphNodeManager into focused composables:
  - useNodeWidgets: Widget state and callback management
  - useNodeChangeDetection: RAF-based geometry change detection
  - useNodeState: Node visibility and reactive state management
- Extracted constants for magic numbers and configuration values
- Updated layout store to use SpatialIndexManager and constants

This improves code organization, testability, and makes it easier to swap
CRDT implementations or mock services for testing.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Add node slots to layout tree

* Revert "Add node slots to layout tree"

This reverts commit 460493a620.

* Remove slots from layoutTypes

* Totally not scuffed renderer and adapter

* Revert "Totally not scuffed renderer and adapter"

This reverts commit 2b9d83efb8.

* Revert "Remove slots from layoutTypes"

This reverts commit 18f78ff786.

* Reapply "Add node slots to layout tree"

This reverts commit 236fecb549.

* Revert "Add node slots to layout tree"

This reverts commit 460493a620.

* docs: Replace architecture docs with comprehensive ADR

- Add ADR-0002 for CRDT-based layout system decision
- Follow established ADR template with persuasive reasoning
- Include performance benefits, collaboration readiness, and architectural advantages
- Update ADR index

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>

* [chore] Extract link rendering out of LGraphCanvas (#4994)

* feat: Implement CRDT-based layout system for Vue nodes

Major refactor to solve snap-back issues and create single source of truth for node positions:

- Add Yjs-based CRDT layout store for conflict-free position management
- Implement layout mutations service with clean API
- Create Vue composables for layout access and node dragging
- Add one-way sync from layout store to LiteGraph
- Disable LiteGraph dragging when Vue nodes mode is enabled
- Add z-index management with bring-to-front on node interaction
- Add comprehensive TypeScript types for layout system
- Include unit tests for layout store operations
- Update documentation to reflect CRDT architecture

This provides a solid foundation for both single-user performance and future real-time collaboration features.

Co-Authored-By: Claude <noreply@anthropic.com>

* style: Apply linter fixes to layout system

* fix: Remove unnecessary README files and revert services README

- Remove unnecessary types/README.md file
- Revert unrelated changes to services/README.md
- Keep only relevant documentation for the layout system implementation

These were issues identified during PR review that needed to be addressed.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: Clean up layout store and implement proper CRDT operations

- Created dedicated layoutOperations.ts with production-grade CRDT interfaces
- Integrated existing QuadTree spatial index instead of simple cache
- Split composables into separate files (useLayout, useNodeLayout, useLayoutSync)
- Cleaned up operation handlers using specific types instead of Extract
- Added proper operation interfaces with type guards and extensibility
- Updated all type references to use new operation structure

The layout store now properly uses the existing QuadTree infrastructure for
efficient spatial queries and follows CRDT best practices with well-defined
operation interfaces.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: Extract services and split composables for better organization

- Created SpatialIndexManager to handle QuadTree operations separately
- Added LayoutAdapter interface for CRDT abstraction (Yjs, mock implementations)
- Split GraphNodeManager into focused composables:
  - useNodeWidgets: Widget state and callback management
  - useNodeChangeDetection: RAF-based geometry change detection
  - useNodeState: Node visibility and reactive state management
- Extracted constants for magic numbers and configuration values
- Updated layout store to use SpatialIndexManager and constants

This improves code organization, testability, and makes it easier to swap
CRDT implementations or mock services for testing.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Add node slots to layout tree

* Revert "Add node slots to layout tree"

This reverts commit 460493a620.

* Remove slots from layoutTypes

* Totally not scuffed renderer and adapter

* Revert "Totally not scuffed renderer and adapter"

This reverts commit 2b9d83efb8.

* Revert "Remove slots from layoutTypes"

This reverts commit 18f78ff786.

* Reapply "Add node slots to layout tree"

This reverts commit 236fecb549.

* Revert "Add node slots to layout tree"

This reverts commit 460493a620.

* docs: Replace architecture docs with comprehensive ADR

- Add ADR-0002 for CRDT-based layout system decision
- Follow established ADR template with persuasive reasoning
- Include performance benefits, collaboration readiness, and architectural advantages
- Update ADR index

* Add node slots to layout tree

* Revert "Add node slots to layout tree"

This reverts commit 460493a620.

* Remove slots from layoutTypes

* Totally not scuffed renderer and adapter

* Remove unused methods in LGLA

* Extract slot position calculations to shared utility

- Create slotCalculations.ts utility for centralized slot position logic
- Update LGraphNode to delegate to helper while maintaining compatibility
- Modify LitegraphLinkAdapter to use layout tree positions when available
- Enable link rendering to use layout system coordinates instead of litegraph positions

This allows the layout tree to control link rendering positions, enabling proper
synchronization between Vue components and canvas rendering.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* [fix] Restore original link rendering behavior after refactor

This commit fixes several rendering discrepancies introduced during the link rendering refactor to ensure exact parity with the original litegraph implementation:

Path Shape Fixes:
- STRAIGHT_LINK: Now correctly applies l=10 offset to create innerA/innerB points and uses midX=(innerA.x+innerB.x)*0.5 for elbow placement, matching the original 6-segment path
- LINEAR_LINK: Restored 4-point path with l=15 directional offsets (start → innerA → innerB → end)

Arrow Rendering:
- computeConnectionPoint: Now always uses bezier math with 0.25 factor spline offsets regardless of render mode, ensuring arrow positions match original
- Arrow positions: Fixed to render at 0.25 and 0.75 positions along the path
- Arrow gating: Moved scale>=0.6 and highQuality checks to adapter layer to maintain PathRenderer purity
- Arrow shape: Restored original triangle dimensions (-5,-3) to (0,+7) to (+5,-3)

Center Marker:
- Fixed 'None' option: Center marker now correctly hidden when LinkMarkerShape.None is selected
- Center point calculation: Updated for all render modes to match original positions
- STRAIGHT_LINK center: Uses midX and average of innerA/innerB y-coordinates
- LINEAR_LINK center: Uses midpoint between innerA and innerB control points

These fixes ensure backward compatibility while maintaining the clean separation between the pure PathRenderer and litegraph-specific LitegraphLinkAdapter.

Fixes #Issue-Number

---------

Co-authored-by: bymyself <cbyrne@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>

* feat: Add slot registration and spatial indexing for hit detection

- Implement slot registration for all nodes (Vue and LiteGraph)
- Add spatial indexes for slots and reroutes to improve hit detection performance
- Register slots when nodes are drawn via new registerSlots() method
- Update LayoutStore to use spatial indexing for O(log n) queries instead of O(n)

Resolves #5125

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Revert "feat: Add slot registration and spatial indexing for hit detection"

This reverts commit 70fbfd0f5e.

* feat: Add slot registration and spatial indexing for hit detection

- Implement slot registration for all nodes (Vue and LiteGraph)
- Add spatial indexes for slots and reroutes to improve hit detection performance
- Register slots when nodes are drawn via new registerSlots() method
- Update LayoutStore to use spatial indexing for O(log n) queries instead of O(n)

Resolves #5125

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* relocate slot update to layoutstore

* Revert "relocate slot update to layoutstore"

This reverts commit 0b17ef148bdded35cb231bef25b8d5c77dc14c1f.

* add useSlotLayoutSync

* feat: Extend Layout Store with CRDT support for links and reroutes

Move links and reroutes to be first-class CRDT entities in the Layout Store,
eliminating per-frame registration during rendering. This provides a ~100x
reduction in spatial index operations by using event-driven updates instead
of polling.

Key changes:
- Add CRDT maps for links and reroutes with automatic observers
- Add mutation operations for link/reroute lifecycle management
- Update LiteGraph to use mutations instead of direct store calls
- Remove per-frame updateLinkLayout and updateRerouteLayout calls

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Scuffed diff, change to dirty later

* Fix reroute move desync

* Terrible reroute fixes

* Use LinkId for LinkLayout

* refactor: Remove unused duplicate layout type files

Deleted src/types/layoutTypes.ts and src/types/layoutOperations.ts
which were duplicates of src/renderer/core/layout/types.ts. These
files had zero imports and were creating confusion in the codebase.

The active types are in src/renderer/core/layout/types.ts which
is properly integrated with the current architecture.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: Extract layout source strings into LayoutSource enum

Replace hardcoded 'canvas' | 'vue' | 'external' string literals with a proper TypeScript enum for better type safety and maintainability. This change provides a single source of truth for layout source types and makes future modifications easier.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: Unify CRDT layout operations under type-safe entity bases

Replace node-centric BaseOperation with a clean hierarchy:
- Add OperationMeta base containing common fields (timestamp, actor, source, type)
- Introduce entity-specific bases (NodeOpBase, LinkOpBase, RerouteOpBase)
- Each operation now extends its appropriate entity base with proper typing
- Add entity discriminator field for runtime type narrowing

Benefits:
- Eliminates duplicate meta fields across link/reroute operations
- Provides type-safe discriminated unions for each entity type
- Enables clean extension path for future operation types
- Zero breaking changes - type-only refactor with no runtime impact

Also adds helper functions:
- getAffectedNodeIds() to extract node IDs affected by any operation
- Entity-specific helper checks for operation classification

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix initial link seeding

* fix: Fix reroute hit detection and type consistency issues

- Use instanceof Reroute type guard instead of structural 'linkIds' check
- Remove unnecessary Number() conversions for reroute IDs (already numeric)
- Fix parentId truthiness bug (0 is valid parent ID)
- Pass numeric IDs directly in GraphCanvas seeding
- Add missing link/reroute methods to LayoutMutations interface
- Make hit test tolerance scale-aware using ctx.lineWidth and DPI

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Add debug logs

* Add missing reroute path

* cleanup

* feat: Implement event-driven link layout sync

Remove layout store writes from render loop and update link geometry only on
actual changes (node move/resize, link/reroute operations, collapse toggles).

Key improvements:
- No layout writes during canvas render (decoupled from draw cycle)
- Link layouts update only on causal events via useLinkLayoutSync
- Hit testing remains precise using stored Path2D objects
- Optimized adapter: calculations only when enableLayoutStoreWrites=true
- Store-level deduplication prevents spatial index churn

Performance impact:
- Render path: Zero layout work, no equality checks, no store writes
- Event path: Direct writes with cheap store-level dedup
- Significant CPU savings per frame on complex graphs

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: Implement DOM-based slot registration with unified position system

- Add centralized getSlotPosition() function in SlotCalculations
- Create SlotIdentifier utilities for consistent slot key generation
- Implement DOM-based slot registration composable with performance optimizations:
  - Cache slot offsets to avoid DOM reads during drag operations
  - Batch measurements via requestAnimationFrame
  - Skip redundant updates when bounds unchanged
- Update Vue slot components to register DOM positions
- Fix widget-to-input index mapping in NodeWidgets
- Prevent double registration when Vue nodes enabled

This improves slot hit-detection accuracy by using actual DOM positions
while maintaining performance through intelligent caching and batching.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>

* Remove unused files

* Remove duplicated markdown file

* Remove duplicated files and address knip concerns

* Remove outdated test

* warning comment

* Update test snapshots

---------

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-09-01 17:01:17 -04:00

626 lines
20 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, vi } from 'vitest'
import type { INodeInputSlot, Point } from '@/lib/litegraph/src/litegraph'
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { LGraph } from '@/lib/litegraph/src/litegraph'
import { NodeInputSlot } from '@/lib/litegraph/src/litegraph'
import { NodeOutputSlot } from '@/lib/litegraph/src/litegraph'
import type { ISerialisedNode } from '@/lib/litegraph/src/litegraph'
import { test } from './fixtures/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
})
})
})