Compare commits

...

1 Commits

Author SHA1 Message Date
huang47
543c830478 test: cover litegraph node, subgraph, and util branches
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 10:29:49 -07:00
12 changed files with 2768 additions and 1 deletions

View File

@@ -0,0 +1,447 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
import type { DefaultConnectionColors } from '@/lib/litegraph/src/interfaces'
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { SlotType } from '@/lib/litegraph/src/draw'
import {
LinkDirection,
RenderShape
} from '@/lib/litegraph/src/types/globalEnums'
import { toLinkId } from '@/types/linkId'
import { createMockCanvasRenderingContext2D } from '@/utils/__tests__/litegraphTestUtils'
import { createTestSubgraph } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { NodeInputSlot } from './NodeInputSlot'
import { NodeOutputSlot } from './NodeOutputSlot'
function createColorContext(): DefaultConnectionColors {
return {
getConnectedColor: vi.fn(() => '#0f0'),
getDisconnectedColor: vi.fn(() => '#f00')
}
}
function createNode(): LGraphNode {
const node = new LGraphNode('Test Node')
node.pos = [0, 0]
return node
}
function createInputSlot(
overrides: Partial<NodeInputSlot> = {},
node = createNode()
): NodeInputSlot {
return new NodeInputSlot(
fromAny({
name: 'in',
type: 'STRING',
link: null,
boundingRect: [10, 20, 20, 20],
...overrides
}),
node
)
}
function createOutputSlot(
overrides: Partial<NodeOutputSlot> = {},
node = createNode()
): NodeOutputSlot {
return new NodeOutputSlot(
fromAny({
name: 'out',
type: 'STRING',
links: null,
boundingRect: [10, 20, 20, 20],
...overrides
}),
node
)
}
describe('NodeSlot rendering', () => {
let ctx: CanvasRenderingContext2D
let colorContext: DefaultConnectionColors
beforeEach(() => {
ctx = createMockCanvasRenderingContext2D()
colorContext = createColorContext()
})
describe('draw', () => {
it('draws a disconnected circle slot with its label', () => {
const slot = createInputSlot()
slot.draw(ctx, { colorContext })
expect(colorContext.getDisconnectedColor).toHaveBeenCalledWith('STRING')
expect(ctx.arc).toHaveBeenCalledWith(
expect.any(Number),
expect.any(Number),
4,
0,
Math.PI * 2
)
expect(ctx.fill).toHaveBeenCalled()
expect(ctx.fillText).toHaveBeenCalledWith(
'in',
expect.any(Number),
expect.any(Number)
)
})
it('uses the connected colour and a larger radius when highlighted', () => {
const slot = createInputSlot({ link: toLinkId(1) })
slot.draw(ctx, { colorContext, highlight: true })
expect(colorContext.getConnectedColor).toHaveBeenCalledWith('STRING')
expect(ctx.arc).toHaveBeenCalledWith(
expect.any(Number),
expect.any(Number),
5,
0,
Math.PI * 2
)
})
it('prefers color_on over the colour context when connected', () => {
const slot = createInputSlot({ link: toLinkId(1), color_on: '#abc' })
let fillStyleAtFill: typeof ctx.fillStyle | undefined
vi.mocked(ctx.fill).mockImplementation(() => {
fillStyleAtFill = ctx.fillStyle
})
slot.draw(ctx, { colorContext })
expect(fillStyleAtFill).toBe('#abc')
expect(ctx.fillStyle).not.toBe('#abc') // restored after draw
})
it('draws a box for event slots', () => {
const slot = createInputSlot({ type: SlotType.Event })
slot.draw(ctx, { colorContext })
expect(ctx.rect).toHaveBeenCalledTimes(1)
expect(ctx.arc).not.toHaveBeenCalled()
})
it('draws a box for box-shaped slots', () => {
const slot = createInputSlot({ shape: RenderShape.BOX })
slot.draw(ctx, { colorContext })
expect(ctx.rect).toHaveBeenCalledTimes(1)
})
it('draws a triangle for arrow-shaped slots', () => {
const slot = createInputSlot({ shape: RenderShape.ARROW })
slot.draw(ctx, { colorContext })
expect(ctx.moveTo).toHaveBeenCalledTimes(1)
expect(ctx.lineTo).toHaveBeenCalledTimes(2)
expect(ctx.closePath).toHaveBeenCalled()
})
it('draws a 3x3 grid for array-typed slots', () => {
const slot = createInputSlot({ type: SlotType.Array })
slot.draw(ctx, { colorContext })
expect(ctx.rect).toHaveBeenCalledTimes(9)
})
it('draws a simple rect and no label in low quality mode', () => {
const slot = createInputSlot()
slot.draw(ctx, { colorContext, lowQuality: true })
expect(ctx.rect).toHaveBeenCalledTimes(1)
expect(ctx.fillText).not.toHaveBeenCalled()
})
it('clips hollow circle slots to a ring', () => {
const arc = vi.fn()
vi.stubGlobal(
'Path2D',
class {
arc = arc
}
)
try {
const slot = createInputSlot({ shape: RenderShape.HollowCircle })
slot.draw(ctx, { colorContext, highlight: true })
slot.draw(ctx, { colorContext })
expect(ctx.clip).toHaveBeenCalledTimes(2)
// Inner radius is larger while highlighted.
expect(arc).toHaveBeenCalledWith(
expect.any(Number),
expect.any(Number),
2.5,
0,
Math.PI * 2
)
expect(arc).toHaveBeenCalledWith(
expect.any(Number),
expect.any(Number),
1.5,
0,
Math.PI * 2
)
} finally {
vi.unstubAllGlobals()
}
})
it('draws one pie segment per type for multi-type slots', () => {
const slot = createInputSlot({ type: 'STRING,INT' })
slot.draw(ctx, { colorContext })
// Once for the base slot colour, then once per type in the pie.
expect(colorContext.getDisconnectedColor).toHaveBeenCalledTimes(3)
// One filled arc per type, plus the final outline arc.
expect(ctx.arc).toHaveBeenCalledTimes(3)
expect(ctx.stroke).toHaveBeenCalled()
})
it('hides the label for widget input slots', () => {
const slot = createInputSlot({ widget: { name: 'in' } })
slot.draw(ctx, { colorContext })
expect(ctx.fillText).not.toHaveBeenCalled()
})
it('skips the label when there is no text to render', () => {
const slot = createInputSlot({ name: '' })
slot.draw(ctx, { colorContext })
expect(ctx.fillText).not.toHaveBeenCalled()
})
it('draws input labels above the slot when directed up', () => {
const slot = createInputSlot({ dir: LinkDirection.UP })
slot.draw(ctx, { colorContext })
const [, x, y] = vi.mocked(ctx.fillText).mock.calls[0]
const slotCentre = [
slot.boundingRect[0] + 10 - slot.node.pos[0],
slot.boundingRect[1] + 10 - slot.node.pos[1]
]
expect(x).toBe(slotCentre[0])
expect(y).toBeLessThan(slotCentre[1])
})
it('draws output labels to the left of the slot', () => {
const slot = createOutputSlot()
slot.draw(ctx, { colorContext })
const [, x] = vi.mocked(ctx.fillText).mock.calls[0]
const slotCentreX = slot.boundingRect[0] + 10 - slot.node.pos[0]
expect(x).toBeLessThan(slotCentreX)
})
it('draws output labels above the slot when directed down', () => {
const slot = createOutputSlot({ dir: LinkDirection.DOWN })
slot.draw(ctx, { colorContext })
const [, , y] = vi.mocked(ctx.fillText).mock.calls[0]
const slotCentreY = slot.boundingRect[1] + 10 - slot.node.pos[1]
expect(y).toBeLessThan(slotCentreY)
})
it('strokes output slots in normal quality', () => {
const slot = createOutputSlot()
slot.draw(ctx, { colorContext })
expect(ctx.stroke).toHaveBeenCalled()
})
it('does not stroke output slots in low quality', () => {
const slot = createOutputSlot()
slot.draw(ctx, { colorContext, lowQuality: true })
expect(ctx.stroke).not.toHaveBeenCalled()
})
it('rings the slot in red when it has errors', () => {
const slot = createInputSlot({ hasErrors: true })
slot.draw(ctx, { colorContext })
expect(ctx.arc).toHaveBeenCalledWith(
expect.any(Number),
expect.any(Number),
12,
0,
Math.PI * 2
)
expect(ctx.stroke).toHaveBeenCalled()
})
})
describe('highlightColor', () => {
const original = {
highlight: LiteGraph.NODE_TEXT_HIGHLIGHT_COLOR,
selectedTitle: LiteGraph.NODE_SELECTED_TITLE_COLOR
}
afterEach(() => {
LiteGraph.NODE_TEXT_HIGHLIGHT_COLOR = original.highlight
LiteGraph.NODE_SELECTED_TITLE_COLOR = original.selectedTitle
})
it('prefers the dedicated text highlight colour', () => {
expect(createInputSlot().highlightColor).toBe(
LiteGraph.NODE_TEXT_HIGHLIGHT_COLOR
)
})
it('falls back to the selected title colour, then text colour', () => {
LiteGraph.NODE_TEXT_HIGHLIGHT_COLOR = fromAny(undefined)
expect(createInputSlot().highlightColor).toBe(
LiteGraph.NODE_SELECTED_TITLE_COLOR
)
LiteGraph.NODE_SELECTED_TITLE_COLOR = fromAny(undefined)
expect(createInputSlot().highlightColor).toBe(LiteGraph.NODE_TEXT_COLOR)
})
})
describe('renderingLabel', () => {
it.for<[string, Partial<NodeInputSlot>, string]>([
['label', { label: 'A Label', localized_name: 'Localized' }, 'A Label'],
['localized_name', { localized_name: 'Localized' }, 'Localized'],
['name', {}, 'in'],
['empty string', { name: '' }, '']
])('falls back through %s', ([, overrides, expected]) => {
expect(createInputSlot(overrides).renderingLabel).toBe(expected)
})
})
describe('drawCollapsed', () => {
it('draws a box for event slots', () => {
createInputSlot({ type: SlotType.Event }).drawCollapsed(ctx)
expect(ctx.rect).toHaveBeenCalledTimes(1)
expect(ctx.fill).toHaveBeenCalled()
})
it('draws a box for box-shaped slots', () => {
createInputSlot({ shape: RenderShape.BOX }).drawCollapsed(ctx)
expect(ctx.rect).toHaveBeenCalledTimes(1)
})
it('draws an input-facing arrow for arrow-shaped input slots', () => {
createInputSlot({ shape: RenderShape.ARROW }).drawCollapsed(ctx)
expect(ctx.moveTo).toHaveBeenCalledWith(8, expect.any(Number))
expect(ctx.closePath).toHaveBeenCalled()
})
it('draws an output-facing arrow for arrow-shaped output slots', () => {
const node = createNode()
node._collapsed_width = 60
createOutputSlot({ shape: RenderShape.ARROW }, node).drawCollapsed(ctx)
expect(ctx.moveTo).toHaveBeenCalledWith(66, expect.any(Number))
})
it('draws a circle by default', () => {
createInputSlot().drawCollapsed(ctx)
expect(ctx.arc).toHaveBeenCalledWith(
expect.any(Number),
expect.any(Number),
4,
0,
Math.PI * 2
)
})
})
describe('collapsedPos', () => {
it('places output slots at the collapsed node width', () => {
const node = createNode()
node._collapsed_width = 42
expect(createOutputSlot({}, node).collapsedPos[0]).toBe(42)
})
it('falls back to the default collapsed width', () => {
const slot = createOutputSlot()
expect(slot.collapsedPos[0]).toBe(LiteGraph.NODE_COLLAPSED_WIDTH)
})
it('places input slots at the node origin', () => {
expect(createInputSlot().collapsedPos[0]).toBe(0)
})
})
describe('isValidTarget', () => {
it('validates input slots against output slots', () => {
const input = createInputSlot()
const output = createOutputSlot()
expect(input.isValidTarget(output)).toBe(true)
expect(output.isValidTarget(input)).toBe(true)
})
it('rejects connections between incompatible slot types', () => {
const input = createInputSlot()
const output = createOutputSlot({ type: 'INT' })
expect(input.isValidTarget(output)).toBe(false)
})
it('validates output slots against subgraph outputs', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'value', type: 'STRING' }]
})
const subgraphOutput = subgraph.outputNode.slots[0]
expect(createOutputSlot().isValidTarget(subgraphOutput)).toBe(true)
expect(createOutputSlot({ type: 'INT' }).isValidTarget(subgraphOutput)) //
.toBe(false)
})
it('validates input slots against subgraph inputs', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'STRING' }]
})
const subgraphInput = subgraph.inputNode.slots[0]
expect(createInputSlot().isValidTarget(subgraphInput)).toBe(true)
})
it('rejects unknown slot shapes', () => {
const input = createInputSlot()
const output = createOutputSlot()
expect(input.isValidTarget(fromPartial({ type: 'STRING' }))).toBe(false)
expect(output.isValidTarget(fromPartial({ type: 'STRING' }))).toBe(false)
})
})
describe('isConnected', () => {
it('reports output connectivity from the links array', () => {
expect(createOutputSlot().isConnected).toBe(false)
expect(createOutputSlot({ links: [] }).isConnected).toBe(false)
expect(createOutputSlot({ links: [toLinkId(1)] }).isConnected).toBe(true)
})
})
})

View File

@@ -33,4 +33,13 @@ describe('outputAsSerialisable', () => {
const serialised = outputAsSerialisable(output as OutputSlotParam)
expect(serialised.links).toBeNull()
})
it('serialises only the widget name for outputs with widgets', () => {
const node = new LGraphNode('test')
const output = node.addOutput('out', 'number') as OutputSlotParam
output.widget = { name: 'my-widget', type: 'number' } as IWidget
const serialised = outputAsSerialisable(output)
expect(serialised.widget).toEqual({ name: 'my-widget' })
})
})

View File

@@ -8,8 +8,10 @@ import {
LGraphEventMode,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import { NullGraphError } from '@/lib/litegraph/src/infrastructure/NullGraphError'
import { toLinkId } from '@/types/linkId'
import { toNodeId } from '@/types/nodeId'
import { widgetId } from '@/types/widgetId'
import {
createNestedSubgraphs,
@@ -714,3 +716,373 @@ describe('ExecutableNodeDTO Scale Testing', () => {
}
})
})
describe('ExecutableNodeDTO error and edge branches', () => {
it('throws NullGraphError when the node has no graph', () => {
const orphan = new LGraphNode('Orphan')
expect(() => new ExecutableNodeDTO(orphan, [], new Map())).toThrow(
NullGraphError
)
})
it('returns itself from getInnerNodes for regular nodes', () => {
const graph = new LGraph()
const node = new LGraphNode('Plain')
graph.add(node)
const dto = new ExecutableNodeDTO(node, [], new Map(), undefined)
expect(dto.getInnerNodes()).toEqual([dto])
})
it('throws InvalidLinkError for dangling input link ids', () => {
const graph = new LGraph()
const node = new LGraphNode('Dangling')
node.addInput('in', 'IMAGE')
graph.add(node)
node.inputs[0].link = toLinkId(999)
const dto = new ExecutableNodeDTO(node, [], new Map(), undefined)
expect(() => dto.resolveInput(0)).toThrow('No link found in parent graph')
})
function createBypassCycle() {
const graph = new LGraph()
const a = new LGraphNode('A')
a.addInput('in', 'IMAGE')
a.addOutput('out', 'IMAGE')
a.mode = LGraphEventMode.BYPASS
const b = new LGraphNode('B')
b.addInput('in', 'IMAGE')
b.addOutput('out', 'IMAGE')
b.mode = LGraphEventMode.BYPASS
graph.add(a)
graph.add(b)
a.connect(0, b, 0)
b.connect(0, a, 0)
const map = new Map()
const dtoA = new ExecutableNodeDTO(a, [], map, undefined)
const dtoB = new ExecutableNodeDTO(b, [], map, undefined)
map.set(dtoA.id, dtoA)
map.set(dtoB.id, dtoB)
return { dtoA }
}
it('throws a RecursionError when input resolution loops', () => {
const { dtoA } = createBypassCycle()
expect(() => dtoA.resolveInput(0)).toThrow('Circular reference detected')
})
it('throws a RecursionError when output resolution loops', () => {
const { dtoA } = createBypassCycle()
expect(() => dtoA.resolveOutput(0, 'IMAGE', new Set())).toThrow(
'Circular reference detected'
)
})
it('includes the subgraph path in recursion errors', () => {
const graph = new LGraph()
const a = new LGraphNode('A')
a.addInput('in', 'IMAGE')
a.addOutput('out', 'IMAGE')
a.mode = LGraphEventMode.BYPASS
const b = new LGraphNode('B')
b.addInput('in', 'IMAGE')
b.addOutput('out', 'IMAGE')
b.mode = LGraphEventMode.BYPASS
graph.add(a)
graph.add(b)
a.connect(0, b, 0)
b.connect(0, a, 0)
const map = new Map()
const dtoA = new ExecutableNodeDTO(a, ['7'], map, undefined)
const dtoB = new ExecutableNodeDTO(b, ['7'], map, undefined)
map.set(dtoA.id, dtoA)
map.set(dtoB.id, dtoB)
expect(() => dtoA.resolveInput(0)).toThrow('at path 7')
})
describe('subgraph boundary resolution', () => {
function createBoundarySetup(options: { connectOuter?: boolean } = {}) {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'IMAGE' }],
outputs: [{ name: 'result', type: 'IMAGE' }]
})
const inner = new LGraphNode('Inner')
inner.addInput('in', 'IMAGE')
inner.addOutput('out', 'IMAGE')
subgraph.add(inner)
subgraph.inputs[0].connect(inner.inputs[0], inner)
subgraph.outputs[0].connect(inner.outputs[0], inner)
const subgraphNode = createTestSubgraphNode(subgraph)
subgraph.rootGraph.add(subgraphNode)
// DTOs snapshot their input links, so wire the outer graph first.
let outer: LGraphNode | undefined
if (options.connectOuter) {
outer = new LGraphNode('Outer')
outer.addOutput('out', 'IMAGE')
subgraph.rootGraph.add(outer)
outer.connect(0, subgraphNode, 0)
}
const map = new Map()
if (outer) {
const outerDto = new ExecutableNodeDTO(outer, [], map)
map.set(outerDto.id, outerDto)
}
const subgraphNodeDto = new ExecutableNodeDTO(subgraphNode, [], map)
map.set(subgraphNodeDto.id, subgraphNodeDto)
const innerDto = new ExecutableNodeDTO(
inner,
[String(subgraphNode.id)],
map,
subgraphNode
)
map.set(innerDto.id, innerDto)
return {
subgraph,
inner,
outer,
subgraphNode,
map,
subgraphNodeDto,
innerDto
}
}
it('resolves inner node inputs through to the outer graph', () => {
const { outer, innerDto } = createBoundarySetup({ connectOuter: true })
const resolved = innerDto.resolveInput(0)
expect(resolved?.origin_id).toBe(String(outer!.id))
expect(resolved?.origin_slot).toBe(0)
})
it('returns undefined for unconnected subgraph inputs without widgets', () => {
const { innerDto } = createBoundarySetup()
expect(innerDto.resolveInput(0)).toBeUndefined()
})
it('returns the promoted widget value for widget-backed subgraph inputs', () => {
const { subgraphNode, innerDto } = createBoundarySetup()
subgraphNode.inputs[0].widgetId = widgetId(
subgraphNode.graph!.id,
subgraphNode.id,
'value'
)
const resolved = innerDto.resolveInput(0)
expect(resolved?.origin_slot).toBe(-1)
expect(resolved?.widgetInfo).toBeDefined()
expect(resolved?.origin_id).toBe(innerDto.id)
})
it('throws SlotIndexError when the subgraph node lacks the input slot', () => {
const { subgraphNode, innerDto } = createBoundarySetup()
// Characterises corruption handling: the subgraph node lost its slots.
subgraphNode.inputs.length = 0
expect(() => innerDto.resolveInput(0)).toThrow('No input found for slot')
})
it('resolves subgraph node outputs through the inner node', () => {
const { inner, subgraphNode, subgraphNodeDto } = createBoundarySetup()
const resolved = subgraphNodeDto.resolveOutput(0, 'IMAGE', new Set())
expect(resolved?.origin_id).toBe(`${subgraphNode.id}:${inner.id}`)
expect(resolved?.origin_slot).toBe(0)
})
it('throws SlotIndexError for missing subgraph output slots', () => {
const { subgraphNodeDto } = createBoundarySetup()
expect(() =>
subgraphNodeDto.resolveOutput(5, 'IMAGE', new Set())
).toThrow('No output found for flattened id')
})
it('returns undefined when the subgraph output has no internal link', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'result', type: 'IMAGE' }]
})
const subgraphNode = createTestSubgraphNode(subgraph)
subgraph.rootGraph.add(subgraphNode)
const map = new Map()
const dto = new ExecutableNodeDTO(subgraphNode, [], map)
map.set(dto.id, dto)
expect(dto.resolveOutput(0, 'IMAGE', new Set())).toBeUndefined()
})
})
describe('bypass slot matching', () => {
it('matches by slot index for wildcard target types', () => {
const graph = new LGraph()
const node = new LGraphNode('Bypass')
node.addInput('in', 'IMAGE')
node.addOutput('out0', 'IMAGE')
node.addOutput('out1', 'IMAGE')
node.mode = LGraphEventMode.BYPASS
graph.add(node)
const map = new Map()
const dto = new ExecutableNodeDTO(node, [], map)
map.set(dto.id, dto)
// Both resolve through unconnected inputs, so the result is undefined,
// but neither is rejected as a failed type match.
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
expect(dto.resolveOutput(0, '*', new Set())).toBeUndefined()
expect(dto.resolveOutput(1, '*', new Set())).toBeUndefined()
expect(warn).not.toHaveBeenCalled()
warn.mockRestore()
})
it('prefers an exact type match over the opposite slot index', () => {
const graph = new LGraph()
const source = new LGraphNode('Source')
source.addOutput('mask', 'MASK')
const node = new LGraphNode('Bypass')
node.addInput('image', 'IMAGE')
node.addInput('mask', 'MASK')
node.addOutput('mask', 'MASK')
node.mode = LGraphEventMode.BYPASS
graph.add(source)
graph.add(node)
source.connect(0, node, 1)
const map = new Map()
const sourceDto = new ExecutableNodeDTO(source, [], map)
map.set(sourceDto.id, sourceDto)
const dto = new ExecutableNodeDTO(node, [], map)
map.set(dto.id, dto)
const resolved = dto.resolveOutput(0, 'MASK', new Set())
expect(resolved?.origin_id).toBe(String(source.id))
})
it('warns and returns undefined when no input type matches', () => {
const graph = new LGraph()
const node = new LGraphNode('Bypass')
node.addInput('in', 'IMAGE')
node.addOutput('out', 'IMAGE')
node.mode = LGraphEventMode.BYPASS
graph.add(node)
const map = new Map()
const dto = new ExecutableNodeDTO(node, [], map)
map.set(dto.id, dto)
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
expect(dto.resolveOutput(0, 'MASK', new Set())).toBeUndefined()
expect(warn).toHaveBeenCalled()
warn.mockRestore()
})
})
it('resolves virtual nodes through their input link', () => {
const graph = new LGraph()
const source = new LGraphNode('Source')
source.addOutput('out', 'IMAGE')
const virtualNode = new LGraphNode('Virtual Passthrough')
virtualNode.addInput('in', 'IMAGE')
virtualNode.addOutput('out', 'IMAGE')
virtualNode.isVirtualNode = true
graph.add(source)
graph.add(virtualNode)
source.connect(0, virtualNode, 0)
const map = new Map()
const sourceDto = new ExecutableNodeDTO(source, [], map)
map.set(sourceDto.id, sourceDto)
const virtualDto = new ExecutableNodeDTO(virtualNode, [], map)
map.set(virtualDto.id, virtualDto)
const resolved = virtualDto.resolveOutput(0, 'IMAGE', new Set())
expect(resolved?.origin_id).toBe(String(source.id))
expect(resolved?.origin_slot).toBe(0)
})
})
describe('ExecutableNodeDTO missing DTO map entries', () => {
it('throws when the upstream node DTO is missing from the map', () => {
const graph = new LGraph()
const source = new LGraphNode('Source')
source.addOutput('out', 'IMAGE')
const target = new LGraphNode('Target')
target.addInput('in', 'IMAGE')
graph.add(source)
graph.add(target)
source.connect(0, target, 0)
const map = new Map()
const dto = new ExecutableNodeDTO(target, [], map)
map.set(dto.id, dto)
expect(() => dto.resolveInput(0)).toThrow('No output node DTO found')
})
it('throws when the containing subgraph node DTO is missing from the map', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'IMAGE' }]
})
const inner = new LGraphNode('Inner')
inner.addInput('in', 'IMAGE')
subgraph.add(inner)
subgraph.inputs[0].connect(inner.inputs[0], inner)
const subgraphNode = createTestSubgraphNode(subgraph)
subgraph.rootGraph.add(subgraphNode)
const outer = new LGraphNode('Outer')
outer.addOutput('out', 'IMAGE')
subgraph.rootGraph.add(outer)
outer.connect(0, subgraphNode, 0)
const map = new Map()
const innerDto = new ExecutableNodeDTO(
inner,
[String(subgraphNode.id)],
map,
subgraphNode
)
map.set(innerDto.id, innerDto)
expect(() => innerDto.resolveInput(0)).toThrow('No subgraph node DTO found')
})
it('throws when a virtual node input DTO is missing from the map', () => {
const graph = new LGraph()
const source = new LGraphNode('Source')
source.addOutput('out', 'IMAGE')
const virtualNode = new LGraphNode('Virtual')
virtualNode.addInput('in', 'IMAGE')
virtualNode.addOutput('out', 'IMAGE')
virtualNode.isVirtualNode = true
graph.add(source)
graph.add(virtualNode)
source.connect(0, virtualNode, 0)
// The virtual node resolves through its own input, whose DTO is missing.
const map = new Map()
const virtualDto = new ExecutableNodeDTO(virtualNode, [], map)
expect(() => virtualDto.resolveOutput(0, 'IMAGE', new Set())).toThrow(
'No input node DTO found'
)
})
})

View File

@@ -0,0 +1,403 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
import { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import type { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
import type {
IContextMenuOptions,
IContextMenuValue
} from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import { CanvasItem } from '@/lib/litegraph/src/types/globalEnums'
import {
createTestSubgraph,
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
function pointerEvent(
canvasX: number,
canvasY: number,
button = 0
): CanvasPointerEvent {
return fromPartial({ canvasX, canvasY, button })
}
function createArrangedInputNode() {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'STRING' }]
})
const inputNode = subgraph.inputNode
inputNode.configure({ id: inputNode.id, bounding: [0, 0, 150, 100] })
inputNode.arrange()
return { subgraph, inputNode }
}
function slotCentre(slot: {
boundingRect: ArrayLike<number>
}): [number, number] {
const [x, y, width, height] = Array.from(slot.boundingRect)
return [x + width / 2, y + height / 2]
}
describe('SubgraphIONodeBase', () => {
beforeEach(() => {
resetSubgraphFixtureState()
LGraphCanvas._measureText = (text: string) => text.length * 8
})
afterEach(() => {
LGraphCanvas._measureText = undefined
vi.restoreAllMocks()
})
describe('pointer hover', () => {
it('tracks pointer enter, slot hover, and leave', () => {
const { inputNode } = createArrangedInputNode()
const [slotX, slotY] = slotCentre(inputNode.slots[0])
const overSlot = inputNode.onPointerMove(pointerEvent(slotX, slotY))
expect(inputNode.isPointerOver).toBe(true)
expect(overSlot & CanvasItem.SubgraphIoNode).toBeTruthy()
expect(overSlot & CanvasItem.SubgraphIoSlot).toBeTruthy()
expect(inputNode.slots[0].isPointerOver).toBe(true)
// Move within the node but off the slot
const overNode = inputNode.onPointerMove(pointerEvent(1, 1))
expect(overNode).toBe(CanvasItem.SubgraphIoNode)
// Leave the node entirely
const outside = inputNode.onPointerMove(pointerEvent(500, 500))
expect(outside).toBe(CanvasItem.Nothing)
expect(inputNode.isPointerOver).toBe(false)
expect(inputNode.slots[0].isPointerOver).toBe(false)
// Moving outside while already outside stays a no-op
expect(inputNode.onPointerMove(pointerEvent(500, 500))).toBe(
CanvasItem.Nothing
)
})
it('reports whether a point is inside the node', () => {
const { inputNode } = createArrangedInputNode()
expect(inputNode.containsPoint([1, 1])).toBe(true)
expect(inputNode.containsPoint([500, 500])).toBe(false)
})
})
describe('snapToGrid', () => {
it('does not snap pinned nodes', () => {
const { inputNode } = createArrangedInputNode()
inputNode.pinned = true
expect(inputNode.snapToGrid(10)).toBe(false)
})
it('snaps unpinned nodes to the grid', () => {
const { inputNode } = createArrangedInputNode()
inputNode.pos = [7, 13]
expect(inputNode.snapToGrid(10)).toBe(true)
expect([inputNode.pos[0], inputNode.pos[1]]).toEqual([10, 10])
})
})
describe('getSlotInPosition', () => {
it('returns the slot at the given canvas position', () => {
const { inputNode } = createArrangedInputNode()
const [slotX, slotY] = slotCentre(inputNode.slots[0])
expect(inputNode.getSlotInPosition(slotX, slotY)).toBe(inputNode.slots[0])
})
it('returns undefined when no slot contains the position', () => {
const { inputNode } = createArrangedInputNode()
expect(inputNode.getSlotInPosition(500, 500)).toBeUndefined()
})
})
describe('slot context menu', () => {
interface CapturedMenu {
options: (IContextMenuValue | null)[]
opts: IContextMenuOptions
}
let captured: CapturedMenu | undefined
const OriginalContextMenu = LiteGraph.ContextMenu
beforeEach(() => {
captured = undefined
LiteGraph.ContextMenu = fromAny(
class {
constructor(
options: (IContextMenuValue | null)[],
opts: IContextMenuOptions
) {
captured = { options, opts }
}
}
)
})
afterEach(() => {
LiteGraph.ContextMenu = OriginalContextMenu
})
it('offers disconnect, rename, and remove for connected slots', () => {
const { subgraph, inputNode } = createArrangedInputNode()
const slot = inputNode.slots[0]
slot.linkIds.push(fromAny(1))
const [slotX, slotY] = slotCentre(slot)
inputNode.onPointerDown(
pointerEvent(slotX, slotY, 2),
fromPartial<CanvasPointer>({}),
fromPartial<LinkConnector>({})
)
expect(captured).toBeDefined()
expect(captured?.options.map((o) => o?.value)).toEqual([
'disconnect',
'rename',
undefined,
'remove'
])
// Disconnect action clears the slot's links.
void captured?.opts.callback?.(
fromPartial({ value: 'disconnect' }),
fromAny({}),
fromAny({}),
fromAny({})
)
expect(slot.linkIds).toHaveLength(0)
// Remove action deletes the slot from the subgraph.
void captured?.opts.callback?.(
fromPartial({ value: 'remove' }),
fromAny({}),
fromAny({}),
fromAny({})
)
expect(subgraph.inputs).toHaveLength(0)
})
it('renames the slot through the canvas prompt', () => {
const { subgraph, inputNode } = createArrangedInputNode()
const slot = inputNode.slots[0]
const prompt = vi.fn(
(
_title: string,
_value: unknown,
callback: (value: string) => void
) => {
callback('renamed')
}
)
subgraph.list_of_graphcanvas = [
fromPartial<LGraphCanvas>({ prompt, setDirty: vi.fn() })
]
const [slotX, slotY] = slotCentre(slot)
inputNode.onPointerDown(
pointerEvent(slotX, slotY, 2),
fromPartial<CanvasPointer>({}),
fromPartial<LinkConnector>({})
)
void captured?.opts.callback?.(
fromPartial({ value: 'rename' }),
fromAny({}),
fromAny({}),
fromAny({})
)
expect(prompt).toHaveBeenCalledWith(
'Slot name',
'value',
expect.any(Function),
expect.anything()
)
// Renaming an input updates its display label.
expect(subgraph.inputs[0].displayName).toBe('renamed')
})
it('does not show a menu for the empty slot', () => {
const { inputNode } = createArrangedInputNode()
const [slotX, slotY] = slotCentre(inputNode.emptySlot)
inputNode.onPointerDown(
pointerEvent(slotX, slotY, 2),
fromPartial<CanvasPointer>({}),
fromPartial<LinkConnector>({})
)
expect(captured).toBeUndefined()
})
it('ignores right-clicks outside all slots', () => {
const { inputNode } = createArrangedInputNode()
inputNode.onPointerDown(
pointerEvent(500, 500, 2),
fromPartial<CanvasPointer>({}),
fromPartial<LinkConnector>({})
)
expect(captured).toBeUndefined()
})
})
describe('left-click drag and double-click', () => {
it('wires up drag handlers when a slot is clicked', () => {
const { subgraph, inputNode } = createArrangedInputNode()
const slot = inputNode.slots[0]
const pointer = fromPartial<CanvasPointer>({})
const linkConnector = fromPartial<LinkConnector>({
dragNewFromSubgraphInput: vi.fn(),
dropLinks: vi.fn(),
reset: vi.fn()
})
const [slotX, slotY] = slotCentre(slot)
inputNode.onPointerDown(
pointerEvent(slotX, slotY),
pointer,
linkConnector
)
pointer.onDragStart?.(fromAny({}))
expect(linkConnector.dragNewFromSubgraphInput).toHaveBeenCalledWith(
subgraph,
inputNode,
slot
)
pointer.onDragEnd?.(fromAny({}))
expect(linkConnector.dropLinks).toHaveBeenCalled()
pointer.finally?.()
expect(linkConnector.reset).toHaveBeenCalledWith(true)
})
it('prompts to rename on double-click of a regular slot', () => {
const { subgraph, inputNode } = createArrangedInputNode()
const slot = inputNode.slots[0]
const prompt = vi.fn()
subgraph.list_of_graphcanvas = [fromPartial<LGraphCanvas>({ prompt })]
const pointer = fromPartial<CanvasPointer>({})
const [slotX, slotY] = slotCentre(slot)
inputNode.onPointerDown(
pointerEvent(slotX, slotY),
pointer,
fromPartial<LinkConnector>({})
)
pointer.onDoubleClick?.(fromAny({}))
expect(prompt).toHaveBeenCalled()
})
it('does not prompt to rename on double-click of the empty slot', () => {
const { subgraph, inputNode } = createArrangedInputNode()
const prompt = vi.fn()
subgraph.list_of_graphcanvas = [fromPartial<LGraphCanvas>({ prompt })]
const pointer = fromPartial<CanvasPointer>({})
const [slotX, slotY] = slotCentre(inputNode.emptySlot)
inputNode.onPointerDown(
pointerEvent(slotX, slotY),
pointer,
fromPartial<LinkConnector>({})
)
pointer.onDoubleClick?.(fromAny({}))
expect(prompt).not.toHaveBeenCalled()
})
})
describe('arrange', () => {
it('sizes the node to fit its widest slot', () => {
LGraphCanvas._measureText = () => 300
const { inputNode } = createArrangedInputNode()
inputNode.arrange()
// Slot width (300 + slot height) exceeds the minimum width of 100.
expect(inputNode.size[0]).toBeGreaterThan(300)
})
it('falls back to zero-width labels without a text measurer', () => {
LGraphCanvas._measureText = undefined
const { inputNode } = createArrangedInputNode()
inputNode.arrange()
expect(inputNode.size[0]).toBeGreaterThan(0)
})
})
describe('serialisation', () => {
it('round-trips pinned state', () => {
const { inputNode } = createArrangedInputNode()
inputNode.configure({
id: inputNode.id,
bounding: [5, 6, 150, 100],
pinned: true
})
expect(inputNode.pinned).toBe(true)
expect(inputNode.asSerialisable().pinned).toBe(true)
inputNode.configure({ id: inputNode.id, bounding: [5, 6, 150, 100] })
expect(inputNode.pinned).toBe(false)
expect(inputNode.asSerialisable().pinned).toBeUndefined()
})
})
describe('draw', () => {
it('draws with hover-dependent stroke styling and restores context state', () => {
const { inputNode } = createArrangedInputNode()
const strokeStyles: unknown[] = []
const ctx = fromPartial<CanvasRenderingContext2D>({
getTransform: vi.fn(() => fromAny({})),
setTransform: vi.fn(),
translate: vi.fn(),
beginPath: vi.fn(),
arc: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(() => {
strokeStyles.push(ctx.strokeStyle)
}),
fill: vi.fn(),
rect: vi.fn(),
fillText: vi.fn(),
lineWidth: 1,
strokeStyle: 'original',
fillStyle: 'original',
font: 'original',
textBaseline: 'alphabetic'
})
const colorContext = {
getConnectedColor: () => '#0f0',
getDisconnectedColor: () => '#f00'
}
inputNode.draw(ctx, colorContext)
const [defaultStroke] = strokeStyles
inputNode.onPointerEnter()
inputNode.draw(ctx, colorContext)
const [, hoverStroke] = strokeStyles
expect(defaultStroke).not.toBe(hoverStroke)
expect(ctx.strokeStyle).toBe('original')
expect(ctx.fillStyle).toBe('original')
expect(ctx.font).toBe('original')
})
})
})

View File

@@ -0,0 +1,655 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
import type { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
import type { Subgraph } from '@/lib/litegraph/src/LGraph'
import type {
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import { RenderShape } from '@/lib/litegraph/src/types/globalEnums'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { toLinkId } from '@/types/linkId'
import { createMockCanvasRenderingContext2D } from '@/utils/__tests__/litegraphTestUtils'
import { SubgraphInput } from './SubgraphInput'
import {
createTestSubgraph,
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
afterEach(() => {
vi.restoreAllMocks()
})
function createIoSubgraph() {
return createTestSubgraph({
inputs: [{ name: 'in', type: 'STRING' }],
outputs: [{ name: 'out', type: 'STRING' }]
})
}
function addInnerNode(subgraph: Subgraph, type = 'STRING') {
const node = new LGraphNode('Inner')
node.addInput('in', type)
node.addOutput('out', type)
subgraph.add(node)
return node
}
const colorContext = {
getConnectedColor: () => '#0f0',
getDisconnectedColor: () => '#f00'
}
describe('SubgraphOutput.connect', () => {
it('rejects type-incompatible connections', () => {
const subgraph = createIoSubgraph()
const node = addInnerNode(subgraph, 'INT')
const link = subgraph.outputs[0].connect(node.outputs[0], node)
expect(link).toBeUndefined()
expect(subgraph.outputs[0].linkIds).toHaveLength(0)
})
it('throws when the slot does not belong to the given node', () => {
const subgraph = createIoSubgraph()
const node = addInnerNode(subgraph)
const otherNode = addInnerNode(subgraph)
expect(() =>
subgraph.outputs[0].connect(otherNode.outputs[0], node)
).toThrow('Slot is not an output of the given node')
})
it('lets nodes veto the connection via onConnectOutput', () => {
const subgraph = createIoSubgraph()
const node = addInnerNode(subgraph)
node.onConnectOutput = () => false
expect(subgraph.outputs[0].connect(node.outputs[0], node)).toBeUndefined()
})
it('replaces an existing connection', () => {
const subgraph = createIoSubgraph()
const first = addInnerNode(subgraph)
const second = addInnerNode(subgraph)
subgraph.outputs[0].connect(first.outputs[0], first)
const replacement = subgraph.outputs[0].connect(second.outputs[0], second)
expect(replacement).toBeDefined()
expect(subgraph.outputs[0].linkIds).toEqual([replacement?.id])
expect(first.outputs[0].links).toEqual([])
expect(second.outputs[0].links).toEqual([replacement?.id])
})
})
describe('SubgraphOutput.disconnect', () => {
it('skips dangling link ids', () => {
const subgraph = createIoSubgraph()
subgraph.outputs[0].linkIds.push(toLinkId(999))
subgraph.outputs[0].disconnect()
expect(subgraph.outputs[0].linkIds).toHaveLength(0)
})
it('removes link references from the origin output', () => {
const subgraph = createIoSubgraph()
const node = addInnerNode(subgraph)
const onConnectionsChange = vi.fn()
node.onConnectionsChange = onConnectionsChange
subgraph.outputs[0].connect(node.outputs[0], node)
subgraph.outputs[0].disconnect()
expect(node.outputs[0].links).toEqual([])
expect(onConnectionsChange).toHaveBeenLastCalledWith(
expect.anything(),
0,
false,
expect.anything(),
subgraph.outputs[0]
)
})
})
describe('SubgraphOutput.isValidTarget', () => {
it('accepts a compatible subgraph input as source', () => {
const subgraph = createIoSubgraph()
expect(subgraph.outputs[0].isValidTarget(subgraph.inputs[0])).toBe(true)
})
})
describe('SubgraphInput.connect', () => {
it('lets nodes veto the connection via onConnectInput', () => {
const subgraph = createIoSubgraph()
const node = addInnerNode(subgraph)
node.onConnectInput = () => false
expect(subgraph.inputs[0].connect(node.inputs[0], node)).toBeUndefined()
})
it('disconnects an existing link on the target input first', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'a', type: 'STRING' },
{ name: 'b', type: 'STRING' }
]
})
const node = addInnerNode(subgraph)
subgraph.inputs[0].connect(node.inputs[0], node)
const replacement = subgraph.inputs[1].connect(node.inputs[0], node)
expect(replacement).toBeDefined()
expect(node.inputs[0].link).toBe(replacement?.id)
expect(subgraph.inputs[0].linkIds).toHaveLength(0)
})
it('rejects widget inputs that do not match the bound widget', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const subgraph = createIoSubgraph()
const textNode = new LGraphNode('Text')
const textInput = textNode.addInput('value', 'STRING')
textNode.addWidget('text', 'value', '', () => {})
textInput.widget = { name: 'value' }
subgraph.add(textNode)
const numberNode = new LGraphNode('Number')
const numberInput = numberNode.addInput('value', 'STRING')
numberNode.addWidget('number', 'value', 0, () => {})
numberInput.widget = { name: 'value' }
subgraph.add(numberNode)
const first = subgraph.inputs[0].connect(textInput, textNode)
const second = subgraph.inputs[0].connect(numberInput, numberNode)
expect(first).toBeDefined()
expect(second).toBeUndefined()
expect(warn).toHaveBeenCalledWith(
'Target input has invalid widget.',
numberInput,
numberNode
)
})
})
describe('SubgraphInput.matchesWidget', () => {
it('accepts any widget when none is bound', () => {
const subgraph = createIoSubgraph()
expect(
subgraph.inputs[0].matchesWidget(
fromPartial({ type: 'text', options: {} })
)
).toBe(true)
})
it('compares type and numeric constraint options', () => {
const subgraph = createIoSubgraph()
const node = new LGraphNode('Widget Host')
const input = node.addInput('value', 'STRING')
node.addWidget('number', 'value', 0, () => {}, { min: 0, max: 10 })
input.widget = { name: 'value' }
subgraph.add(node)
subgraph.inputs[0].connect(input, node)
const boundOptions = { min: 0, max: 10 }
expect(
subgraph.inputs[0].matchesWidget(
fromPartial({ type: 'number', options: { ...boundOptions } })
)
).toBe(true)
expect(
subgraph.inputs[0].matchesWidget(
fromPartial({ type: 'number', options: { ...boundOptions, min: 5 } })
)
).toBe(false)
expect(
subgraph.inputs[0].matchesWidget(
fromPartial({ type: 'text', options: { ...boundOptions } })
)
).toBe(false)
})
})
describe('SubgraphInput.getConnectedWidgets', () => {
it('reports an error for dangling link ids', () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
const subgraph = createIoSubgraph()
subgraph.inputs[0].linkIds.push(toLinkId(999))
expect(subgraph.inputs[0].getConnectedWidgets()).toEqual([])
expect(error).toHaveBeenCalledWith('Link not found', 999)
})
it('skips inputs without widgets', () => {
const subgraph = createIoSubgraph()
const node = addInnerNode(subgraph)
node.addWidget('text', 'unrelated', '', () => {})
subgraph.inputs[0].connect(node.inputs[0], node)
expect(subgraph.inputs[0].getConnectedWidgets()).toEqual([])
})
it('warns when the referenced widget cannot be found', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const subgraph = createIoSubgraph()
const node = new LGraphNode('Widget Host')
const input = node.addInput('value', 'STRING')
node.addWidget('text', 'value', '', () => {})
input.widget = { name: 'value' }
subgraph.add(node)
subgraph.inputs[0].connect(input, node)
input.widget = { name: 'missing' }
expect(subgraph.inputs[0].getConnectedWidgets()).toEqual([])
expect(warn).toHaveBeenCalledWith('Widget not found', { name: 'missing' })
input.widget = fromAny({ name: '' })
expect(subgraph.inputs[0].getConnectedWidgets()).toEqual([])
expect(warn).toHaveBeenCalledWith('Invalid widget name', { name: '' })
})
it('returns widgets for connected widget inputs', () => {
const subgraph = createIoSubgraph()
const node = new LGraphNode('Widget Host')
const input = node.addInput('value', 'STRING')
const widget = node.addWidget('text', 'value', '', () => {})
input.widget = { name: 'value' }
subgraph.add(node)
subgraph.inputs[0].connect(input, node)
expect(subgraph.inputs[0].getConnectedWidgets()).toEqual([widget])
})
})
describe('SubgraphInput.isValidTarget', () => {
it('accepts a compatible subgraph output as source', () => {
const subgraph = createIoSubgraph()
expect(subgraph.inputs[0].isValidTarget(subgraph.outputs[0])).toBe(true)
})
})
describe('SubgraphSlot base behaviour', () => {
it('ignores malformed positions', () => {
const subgraph = createIoSubgraph()
const slot = subgraph.inputs[0]
slot.pos = [3, 4]
slot.pos = fromAny([5])
expect([slot.pos[0], slot.pos[1]]).toEqual([3, 4])
})
it('generates an id when the serialised slot has none', () => {
const subgraph = createIoSubgraph()
const slot = new SubgraphInput(
fromAny({ name: 'anon', type: 'STRING', linkIds: [] }),
subgraph.inputNode
)
expect(slot.id).toEqual(expect.any(String))
expect(slot.id.length).toBeGreaterThan(0)
})
it('skips dangling link ids in getLinks', () => {
const subgraph = createIoSubgraph()
subgraph.inputs[0].linkIds.push(toLinkId(999))
expect(subgraph.inputs[0].getLinks()).toEqual([])
})
it('decrements link slot indices and warns on dangling ids', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const subgraph = createIoSubgraph()
const node = addInnerNode(subgraph)
const link = subgraph.outputs[0].connect(node.outputs[0], node)
const slot = subgraph.outputs[0]
slot.decrementSlots('outputs')
expect(link?.target_slot).toBe(-1)
slot.linkIds.push(toLinkId(999))
slot.decrementSlots('outputs')
expect(warn).toHaveBeenCalledWith('decrementSlots: link ID not found', 999)
})
describe('draw', () => {
it('draws a simple square in low quality', () => {
const subgraph = createIoSubgraph()
const ctx = createMockCanvasRenderingContext2D()
subgraph.inputs[0].draw({ ctx, colorContext, lowQuality: true })
expect(ctx.rect).toHaveBeenCalledTimes(1)
expect(ctx.arc).not.toHaveBeenCalled()
})
it('strokes hollow circles with a hover-dependent radius', () => {
const subgraph = createIoSubgraph()
const slot = subgraph.inputs[0]
slot.shape = RenderShape.HollowCircle
const ctx = createMockCanvasRenderingContext2D()
slot.draw({ ctx, colorContext })
slot.isPointerOver = true
slot.draw({ ctx, colorContext })
expect(ctx.arc).toHaveBeenNthCalledWith(1, 0, 0, 3, 0, Math.PI * 2)
expect(ctx.arc).toHaveBeenNthCalledWith(2, 0, 0, 4, 0, Math.PI * 2)
expect(ctx.stroke).toHaveBeenCalledTimes(2)
})
it('enlarges the highlighted filled circle', () => {
const subgraph = createIoSubgraph()
const slot = subgraph.inputs[0]
slot.isPointerOver = true
const ctx = createMockCanvasRenderingContext2D()
slot.draw({ ctx, colorContext })
expect(ctx.arc).toHaveBeenCalledWith(0, 0, 5, 0, Math.PI * 2)
})
it('dims slots that are invalid targets for the dragged link', () => {
const subgraph = createIoSubgraph()
const slot = subgraph.inputs[0]
const alphas: number[] = []
const ctx = createMockCanvasRenderingContext2D({
fill: vi.fn(() => {
alphas.push(ctx.globalAlpha)
})
})
// Dragging from an incompatible output slot.
const incompatible = fromPartial<INodeOutputSlot>({
name: 'other',
type: 'INT',
links: null,
boundingRect: [0, 0, 0, 0]
})
slot.draw({ ctx, colorContext, fromSlot: incompatible })
expect(alphas[0]).toBeCloseTo(0.4)
})
it('falls back to the default label colour when unset', () => {
const originalColor = LiteGraph.NODE_TEXT_COLOR
try {
LiteGraph.NODE_TEXT_COLOR = fromAny('')
const subgraph = createIoSubgraph()
const fillStyles: unknown[] = []
const ctx = createMockCanvasRenderingContext2D({
fillText: vi.fn(() => {
fillStyles.push(ctx.fillStyle)
})
})
subgraph.inputs[0].draw({ ctx, colorContext })
expect(fillStyles).toEqual(['#AAA'])
} finally {
LiteGraph.NODE_TEXT_COLOR = originalColor
}
})
})
})
describe('SubgraphOutputNode interaction', () => {
function createArrangedOutputNode() {
const subgraph = createIoSubgraph()
const outputNode = subgraph.outputNode
outputNode.configure({ id: outputNode.id, bounding: [0, 0, 150, 100] })
outputNode.arrange()
return { subgraph, outputNode }
}
function slotCentre(slot: { boundingRect: ArrayLike<number> }) {
const [x, y, width, height] = Array.from(slot.boundingRect)
return [x + width / 2, y + height / 2] as const
}
it('wires drag handlers on left-click over a slot', () => {
const { subgraph, outputNode } = createArrangedOutputNode()
const slot = outputNode.slots[0]
const pointer = fromPartial<CanvasPointer>({})
const linkConnector = fromPartial<LinkConnector>({
dragNewFromSubgraphOutput: vi.fn(),
dropLinks: vi.fn(),
reset: vi.fn()
})
const [x, y] = slotCentre(slot)
outputNode.onPointerDown(
fromPartial<CanvasPointerEvent>({ canvasX: x, canvasY: y, button: 0 }),
pointer,
linkConnector
)
pointer.onDragStart?.(fromAny({}))
expect(linkConnector.dragNewFromSubgraphOutput).toHaveBeenCalledWith(
subgraph,
outputNode,
slot
)
pointer.onDragEnd?.(fromAny({}))
expect(linkConnector.dropLinks).toHaveBeenCalled()
pointer.finally?.()
expect(linkConnector.reset).toHaveBeenCalledWith(true)
})
it('shows the slot context menu on right-click', () => {
const { outputNode } = createArrangedOutputNode()
const OriginalContextMenu = LiteGraph.ContextMenu
let constructed = false
LiteGraph.ContextMenu = fromAny(
class {
constructor() {
constructed = true
}
}
)
try {
const [x, y] = slotCentre(outputNode.slots[0])
outputNode.onPointerDown(
fromPartial<CanvasPointerEvent>({ canvasX: x, canvasY: y, button: 2 }),
fromPartial<CanvasPointer>({}),
fromPartial<LinkConnector>({})
)
expect(constructed).toBe(true)
constructed = false
outputNode.onPointerDown(
fromPartial<CanvasPointerEvent>({
canvasX: 500,
canvasY: 500,
button: 2
}),
fromPartial<CanvasPointer>({}),
fromPartial<LinkConnector>({})
)
expect(constructed).toBe(false)
} finally {
LiteGraph.ContextMenu = OriginalContextMenu
}
})
it('connects by type through connectByTypeOutput', () => {
const { subgraph, outputNode } = createArrangedOutputNode()
const node = addInnerNode(subgraph)
const link = outputNode.connectByTypeOutput(0, node, 'STRING')
expect(link).toBeDefined()
expect(subgraph.outputs[0].linkIds).toEqual([link?.id])
})
it('returns undefined when no output of the requested type exists', () => {
const { outputNode } = createArrangedOutputNode()
const node = new LGraphNode('No Outputs')
expect(outputNode.connectByTypeOutput(0, node, 'STRING')).toBeUndefined()
})
})
describe('SubgraphInputNode connections', () => {
it('throws for invalid slot indices in connectSlots', () => {
const subgraph = createIoSubgraph()
const node = addInnerNode(subgraph)
expect(() =>
subgraph.inputNode.connectSlots(
fromAny({}),
node,
node.inputs[0],
undefined
)
).toThrow('Invalid slot indices.')
})
it('creates links via connectSlots, preferring the input type', () => {
const subgraph = createIoSubgraph()
const node = addInnerNode(subgraph)
const link = subgraph.inputNode.connectSlots(
subgraph.inputs[0],
node,
node.inputs[0],
undefined
)
expect(link.type).toBe('STRING')
expect(String(link.origin_id)).toBe(String(subgraph.inputNode.id))
})
it('falls back to the subgraph slot type for untyped inputs', () => {
const subgraph = createIoSubgraph()
const node = new LGraphNode('Untyped')
node.addInput('in', fromAny(''))
subgraph.add(node)
const link = subgraph.inputNode.connectSlots(
subgraph.inputs[0],
node,
node.inputs[0],
undefined
)
expect(link.type).toBe('STRING')
})
it('connects an existing slot directly via connectByType', () => {
const subgraph = createIoSubgraph()
const node = addInnerNode(subgraph)
const link = subgraph.inputNode.connectByType(0, node, 'STRING')
expect(link).toBeDefined()
expect(subgraph.inputs[0].linkIds).toEqual([link?.id])
})
it('returns undefined from connectByType when no input matches', () => {
const subgraph = createIoSubgraph()
const node = new LGraphNode('No Inputs')
expect(subgraph.inputNode.connectByType(0, node, 'STRING')).toBeUndefined()
})
it('finds output slots by name and type', () => {
const subgraph = createIoSubgraph()
expect(subgraph.inputNode.findOutputSlot('in')).toBe(subgraph.inputs[0])
expect(subgraph.inputNode.findOutputSlot('nope')).toBeUndefined()
expect(subgraph.inputNode.findOutputByType('STRING')).toBe(
subgraph.inputs[0]
)
})
describe('_disconnectNodeInput corruption handling', () => {
it('clears the input without a link', () => {
const subgraph = createIoSubgraph()
const node = addInnerNode(subgraph)
subgraph.inputs[0].connect(node.inputs[0], node)
subgraph.inputNode._disconnectNodeInput(node, node.inputs[0], undefined)
expect(node.inputs[0].link).toBeNull()
})
it('warns when the link references a missing subgraph input slot', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const subgraph = createIoSubgraph()
const node = addInnerNode(subgraph)
const link = subgraph.inputs[0].connect(node.inputs[0], node)
if (!link) throw new Error('Failed to connect')
// Characterises corruption handling: link points at a nonexistent slot.
link.origin_slot = 99
subgraph.inputNode._disconnectNodeInput(node, node.inputs[0], link)
expect(warn).toHaveBeenCalledWith(
'disconnectNodeInput: subgraphInput not found',
subgraph.inputNode,
99
)
})
it('warns when the slot does not list the link id', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const subgraph = createIoSubgraph()
const node = addInnerNode(subgraph)
const link = subgraph.inputs[0].connect(node.inputs[0], node)
if (!link) throw new Error('Failed to connect')
// Characterises corruption handling: the slot lost its link id.
subgraph.inputs[0].linkIds.length = 0
subgraph.inputNode._disconnectNodeInput(node, node.inputs[0], link)
expect(warn).toHaveBeenCalledWith(
'disconnectNodeInput: link ID not found in subgraphInput linkIds',
link.id
)
})
it('skips connection callbacks for foreign inputs', () => {
const subgraph = createIoSubgraph()
const node = addInnerNode(subgraph)
const onConnectionsChange = vi.fn()
node.onConnectionsChange = onConnectionsChange
const link = subgraph.inputs[0].connect(node.inputs[0], node)
if (!link) throw new Error('Failed to connect')
const foreignInput = fromPartial<INodeInputSlot>({
name: 'foreign',
type: 'STRING',
link: null,
boundingRect: [0, 0, 0, 0]
})
subgraph.inputNode._disconnectNodeInput(node, foreignInput, link)
expect(onConnectionsChange).not.toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
false,
expect.anything(),
expect.anything()
)
})
})
})

View File

@@ -1,20 +1,62 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Positionable } from '@/lib/litegraph/src/interfaces'
import {
LGraph,
LGraphGroup,
LGraphNode,
LLink,
LiteGraph,
findUsedSubgraphIds,
getDirectSubgraphIds
} from '@/lib/litegraph/src/litegraph'
import type { UUID } from '@/lib/litegraph/src/litegraph'
import type { ResolvedConnection } from '@/lib/litegraph/src/LLink'
import type { Reroute } from '@/lib/litegraph/src/Reroute'
import type { SerialisableLLink } from '@/lib/litegraph/src/types/serialisation'
import { toRerouteId } from '@/types/rerouteId'
import { createMockPositionable } from '@/utils/__tests__/litegraphTestUtils'
import {
getBoundaryLinks,
groupResolvedByOutput,
mapSubgraphInputsAndLinks,
mapSubgraphOutputsAndLinks,
multiClone,
reorderSubgraphInputs,
splitPositionables
} from './subgraphUtils'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
/** Creates a graph with three chained nodes: a -> b -> c. */
function createChainedGraph() {
const graph = new LGraph()
const a = new LGraphNode('A')
a.addOutput('out', 'number')
const b = new LGraphNode('B')
b.addInput('in', 'number')
b.addOutput('out', 'number')
const c = new LGraphNode('C')
c.addInput('in', 'number')
graph.add(a)
graph.add(b)
graph.add(c)
const linkAb = a.connect(0, b, 0)
const linkBc = b.connect(0, c, 0)
if (!linkAb || !linkBc) throw new Error('Failed to connect test nodes')
return { graph, a, b, c, linkAb, linkBc }
}
describe('subgraphUtils', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
@@ -145,4 +187,392 @@ describe('subgraphUtils', () => {
expect(result.has(subgraph2.id)).toBe(true) // Still found, just can't recurse into it
})
})
describe('splitPositionables', () => {
it('splits items into typed buckets', () => {
const { graph, a, linkAb } = createChainedGraph()
const group = new LGraphGroup('Test Group')
const reroute = graph.createReroute([0, 0], linkAb)
if (!reroute) throw new Error('Failed to create reroute')
const subgraph = createTestSubgraph()
const unknown = createMockPositionable()
const result = splitPositionables([
a,
group,
reroute,
subgraph.inputNode,
subgraph.outputNode,
unknown
])
expect(result.nodes).toEqual(new Set([a]))
expect(result.groups).toEqual(new Set([group]))
expect(result.reroutes).toEqual(new Set([reroute]))
expect(result.subgraphInputNodes).toEqual(new Set([subgraph.inputNode]))
expect(result.subgraphOutputNodes).toEqual(new Set([subgraph.outputNode]))
expect(result.unknown).toEqual(new Set([unknown]))
})
})
describe('getBoundaryLinks', () => {
it('classifies links crossing into and out of the item set', () => {
const { graph, b, linkAb, linkBc } = createChainedGraph()
const result = getBoundaryLinks(graph, new Set<Positionable>([b]))
expect(result.boundaryInputLinks.map((l) => l.id)).toEqual([linkAb.id])
expect(result.boundaryOutputLinks.map((l) => l.id)).toEqual([linkBc.id])
expect(result.internalLinks).toEqual([])
})
it('classifies links between selected nodes as internal', () => {
const { graph, a, b, linkAb, linkBc } = createChainedGraph()
const result = getBoundaryLinks(graph, new Set<Positionable>([a, b]))
expect(result.internalLinks.map((l) => l.id)).toEqual([linkAb.id])
expect(result.boundaryInputLinks).toEqual([])
expect(result.boundaryOutputLinks.map((l) => l.id)).toEqual([linkBc.id])
})
it('treats subgraph IO links as boundary links', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'in', type: 'number' }],
outputs: [{ name: 'out', type: 'number' }]
})
const node = new LGraphNode('Inner')
node.addInput('in', 'number')
node.addOutput('out', 'number')
subgraph.add(node)
subgraph.inputs[0].connect(node.inputs[0], node)
subgraph.outputs[0].connect(node.outputs[0], node)
const result = getBoundaryLinks(subgraph, new Set<Positionable>([node]))
expect(result.boundaryInputLinks).toHaveLength(1)
expect(result.boundaryOutputLinks).toHaveLength(1)
})
it('marks reroute links as boundary when an endpoint is outside the set', () => {
const { graph, a, b, linkAb } = createChainedGraph()
const reroute = graph.createReroute([0, 0], linkAb)
if (!reroute) throw new Error('Failed to create reroute')
const boundary = getBoundaryLinks(graph, new Set<Positionable>([reroute]))
expect(boundary.boundaryLinks.map((l) => l.id)).toEqual([linkAb.id])
const contained = getBoundaryLinks(
graph,
new Set<Positionable>([a, b, reroute])
)
expect(contained.boundaryLinks).toEqual([])
})
it('collects floating links whose reroutes cross the boundary', () => {
const { graph, a, b, linkAb } = createChainedGraph()
const reroute = graph.createReroute([0, 0], linkAb)
if (!reroute) throw new Error('Failed to create reroute')
// Removing the output side turns the link into a floating link.
graph.remove(a)
expect(graph.floatingLinks.size).toBe(1)
// The floating link's reroute is outside the item set.
const crossing = getBoundaryLinks(graph, new Set<Positionable>([b]))
expect(crossing.boundaryFloatingLinks).toHaveLength(1)
// With the reroute inside the set, the floating link does not cross.
const contained = getBoundaryLinks(
graph,
new Set<Positionable>([b, reroute])
)
expect(contained.boundaryFloatingLinks).toEqual([])
})
})
describe('multiClone', () => {
class CloneTestNode extends LGraphNode {
constructor() {
super('CloneTest')
this.addInput('in', 'number')
}
}
beforeEach(() => {
LiteGraph.registerNodeType('test/CloneTest', CloneTestNode)
})
afterEach(() => {
LiteGraph.unregisterNodeType('test/CloneTest')
vi.restoreAllMocks()
})
it('clones registered nodes preserving ids', () => {
const graph = new LGraph()
const node = LiteGraph.createNode('test/CloneTest')
if (!node) throw new Error('Failed to create node')
graph.add(node)
const cloned = multiClone([node])
expect(cloned).toHaveLength(1)
expect(String(cloned[0].id)).toBe(String(node.id))
expect(cloned[0].type).toBe('test/CloneTest')
})
it('falls back to serialised data for unregistered node types', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const graph = new LGraph()
const node = new LGraphNode('Mystery')
node.type = 'test/UnregisteredType'
graph.add(node)
const cloned = multiClone([node])
expect(cloned).toHaveLength(1)
expect(cloned[0].type).toBe('test/UnregisteredType')
expect(warn).toHaveBeenCalled()
})
})
describe('groupResolvedByOutput', () => {
it('groups connections sharing an output and isolates unresolvable ones', () => {
const output = fromPartial<ResolvedConnection['output']>({
name: 'shared'
})
const first = fromPartial<ResolvedConnection>({ output })
const second = fromPartial<ResolvedConnection>({ output })
const bySubgraphInput = fromPartial<ResolvedConnection>({
subgraphInput: fromAny({ name: 'sub' })
})
const unresolvable = fromPartial<ResolvedConnection>({})
const grouped = groupResolvedByOutput([
first,
second,
bySubgraphInput,
unresolvable
])
expect(grouped.size).toBe(3)
expect(grouped.get(fromAny(output))).toEqual([first, second])
})
})
describe('mapSubgraphInputsAndLinks', () => {
function createResolvedInput(
linkId: number,
inputOverrides: Record<string, unknown> = {}
): { resolved: ResolvedConnection; link: LLink } {
const link = new LLink(
fromAny(linkId),
'number',
fromAny(1),
0,
fromAny(2),
0
)
const resolved = fromPartial<ResolvedConnection>({
link,
input: fromAny({ name: 'in', type: 'number', ...inputOverrides }),
output: fromAny({ name: 'out', type: 'number' })
})
return { resolved, link }
}
it('creates one subgraph input per distinct output', () => {
const { resolved } = createResolvedInput(1)
const links: SerialisableLLink[] = []
const inputs = mapSubgraphInputsAndLinks([resolved], links, new Map())
expect(inputs).toHaveLength(1)
expect(inputs[0].name).toBe('in')
expect(inputs[0].localized_name).toBeUndefined()
expect(links).toHaveLength(1)
expect(links[0].origin_slot).toBe(0)
})
it('deduplicates names and localised names across inputs', () => {
const first = createResolvedInput(1, { localized_name: 'In' })
const second = createResolvedInput(2, { localized_name: 'In' })
const links: SerialisableLLink[] = []
const inputs = mapSubgraphInputsAndLinks(
[first.resolved, second.resolved],
links,
new Map()
)
expect(inputs.map((i) => i.name)).toEqual(['in', 'in_1'])
expect(inputs.map((i) => i.localized_name)).toEqual(['In', 'In_1'])
})
it('skips connections without a resolved input', () => {
const link = new LLink(fromAny(1), 'number', fromAny(1), 0, fromAny(2), 0)
const resolved = fromPartial<ResolvedConnection>({
link,
output: fromAny({ name: 'out', type: 'number' })
})
const inputs = mapSubgraphInputsAndLinks([resolved], [], new Map())
expect(inputs).toEqual([])
})
it('rewires reroute parents to the last reroute outside the subgraph', () => {
const { resolved, link } = createResolvedInput(1)
link.parentId = toRerouteId(10)
const insideReroute = fromPartial<Reroute>({ parentId: toRerouteId(99) })
const reroutes = new Map<ReturnType<typeof toRerouteId>, Reroute>([
[toRerouteId(10), insideReroute]
])
mapSubgraphInputsAndLinks([resolved], [], reroutes)
// The chain terminated at reroute 99, which is not in the map.
expect(link.parentId).toBe(toRerouteId(99))
expect(insideReroute.parentId).toBeUndefined()
})
})
describe('mapSubgraphOutputsAndLinks', () => {
function createResolvedOutput(
linkId: number,
outputOverrides: Record<string, unknown> = {}
): { resolved: ResolvedConnection; link: LLink } {
const link = new LLink(
fromAny(linkId),
'number',
fromAny(1),
0,
fromAny(2),
0
)
const resolved = fromPartial<ResolvedConnection>({
link,
input: fromAny({ name: 'in', type: 'number' }),
output: fromAny({ name: 'out', type: 'number', ...outputOverrides })
})
return { resolved, link }
}
it('creates one subgraph output per distinct output slot', () => {
const { resolved } = createResolvedOutput(1)
const links: SerialisableLLink[] = []
const outputs = mapSubgraphOutputsAndLinks([resolved], links, new Map())
expect(outputs).toHaveLength(1)
expect(outputs[0].name).toBe('out')
expect(outputs[0].localized_name).toBeUndefined()
expect(links).toHaveLength(1)
expect(links[0].target_slot).toBe(0)
})
it('deduplicates localised names across outputs', () => {
const first = createResolvedOutput(1, { localized_name: 'Out' })
const second = createResolvedOutput(2, { localized_name: 'Out' })
const outputs = mapSubgraphOutputsAndLinks(
[first.resolved, second.resolved],
[],
new Map()
)
expect(outputs.map((o) => o.name)).toEqual(['out', 'out_1'])
expect(outputs.map((o) => o.localized_name)).toEqual(['Out', 'Out_1'])
})
it('skips connections without a resolved output', () => {
const link = new LLink(fromAny(1), 'number', fromAny(1), 0, fromAny(2), 0)
const resolved = fromPartial<ResolvedConnection>({
link,
input: fromAny({ name: 'in', type: 'number' })
})
const outputs = mapSubgraphOutputsAndLinks([resolved], [], new Map())
expect(outputs).toEqual([])
})
})
describe('reorderSubgraphInputs', () => {
it('returns silently when the node has no subgraph', () => {
expect(() =>
reorderSubgraphInputs(fromAny({ subgraph: undefined }), [])
).not.toThrow()
})
it('rejects indices that are not a permutation', () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
const subgraph = createTestSubgraph({ inputCount: 2 })
const subgraphNode = createTestSubgraphNode(subgraph)
const originalOrder = subgraph.inputs.map((i) => i.id)
reorderSubgraphInputs(subgraphNode, [0]) // wrong length
reorderSubgraphInputs(subgraphNode, [0, 0]) // duplicate
reorderSubgraphInputs(subgraphNode, [0, 2]) // out of range
expect(error).toHaveBeenCalledTimes(3)
expect(subgraph.inputs.map((i) => i.id)).toEqual(originalOrder)
vi.restoreAllMocks()
})
it('does not dispatch an event for an identity permutation', () => {
const subgraph = createTestSubgraph({ inputCount: 2 })
const subgraphNode = createTestSubgraphNode(subgraph)
const listener = vi.fn()
subgraph.events.addEventListener('inputs-reordered', listener)
reorderSubgraphInputs(subgraphNode, [0, 1])
expect(listener).not.toHaveBeenCalled()
})
it('reorders slots and updates link slot indices', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'alpha', type: 'number' },
{ name: 'beta', type: 'number' }
]
})
const inner = new LGraphNode('Inner')
inner.addInput('a', 'number')
inner.addInput('b', 'number')
subgraph.add(inner)
subgraph.inputs[0].connect(inner.inputs[0], inner)
subgraph.inputs[1].connect(inner.inputs[1], inner)
const subgraphNode = createTestSubgraphNode(subgraph)
subgraph.rootGraph.add(subgraphNode)
const outer = new LGraphNode('Outer')
outer.addOutput('out', 'number')
subgraph.rootGraph.add(outer)
outer.connect(0, subgraphNode, 0)
const listener = vi.fn()
subgraph.events.addEventListener('inputs-reordered', listener)
reorderSubgraphInputs(subgraphNode, [1, 0])
expect(subgraph.inputs.map((i) => i.name)).toEqual(['beta', 'alpha'])
// Inner links follow their reordered slots.
const innerLinkSlots = subgraph.inputs.map((input) =>
input.linkIds.map((id) => subgraph.getLink(id)?.origin_slot)
)
expect(innerLinkSlots).toEqual([[0], [1]])
// The outer link now targets the moved slot.
const outerLinkId = subgraphNode.inputs.find(
(input) => input.link != null
)?.link
expect(outerLinkId).not.toBeNull()
const outerLink = subgraph.rootGraph.getLink(outerLinkId!)
expect(outerLink?.target_slot).toBe(1)
expect(listener).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -0,0 +1,136 @@
import { describe, expect, it } from 'vitest'
import { fromAny } from '@total-typescript/shoehorn'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { Direction } from '@/lib/litegraph/src/interfaces'
import { alignNodes, distributeNodes, getBoundaryNodes } from './arrange'
function createNode(x: number, y: number, width: number, height: number) {
const node = new LGraphNode('Test Node')
node.pos = [x, y]
node.size = [width, height]
return node
}
describe('getBoundaryNodes', () => {
it('returns null when no nodes are supplied', () => {
expect(getBoundaryNodes([])).toBeNull()
expect(getBoundaryNodes(fromAny(undefined))).toBeNull()
})
it('returns null when all nodes are falsy', () => {
expect(getBoundaryNodes(fromAny([undefined, null]))).toBeNull()
})
it('returns the same node for all edges with a single node', () => {
const node = createNode(10, 20, 100, 50)
expect(getBoundaryNodes([node])).toEqual({
top: node,
right: node,
bottom: node,
left: node
})
})
it('finds the farthest node in each direction', () => {
const topLeft = createNode(0, 0, 10, 10)
const bottomRight = createNode(200, 200, 50, 50)
const middle = createNode(100, 100, 10, 10)
const boundary = getBoundaryNodes([middle, topLeft, bottomRight])
expect(boundary).not.toBeNull()
expect(boundary?.top).toBe(topLeft)
expect(boundary?.left).toBe(topLeft)
expect(boundary?.right).toBe(bottomRight)
expect(boundary?.bottom).toBe(bottomRight)
})
it('skips falsy entries while finding boundaries', () => {
const node = createNode(5, 5, 10, 10)
const other = createNode(50, 50, 10, 10)
const boundary = getBoundaryNodes(fromAny([node, undefined, other]))
expect(boundary?.left).toBe(node)
expect(boundary?.right).toBe(other)
})
})
describe('distributeNodes', () => {
it('returns an empty array when fewer than two nodes are supplied', () => {
expect(distributeNodes([])).toEqual([])
expect(distributeNodes([createNode(0, 0, 10, 10)])).toEqual([])
expect(distributeNodes(fromAny(undefined))).toEqual([])
})
it('distributes nodes evenly along the horizontal plane', () => {
const first = createNode(0, 0, 10, 10)
const last = createNode(100, 0, 20, 10)
const middle = createNode(30, 0, 10, 10)
const positions = distributeNodes([first, last, middle], true)
expect(positions.map(({ node }) => node)).toEqual([first, middle, last])
// Total span 0..120, widths 10 + 10 + 20 = 40, gap = (120 - 40) / 2 = 40
expect(first.pos[0]).toBe(0)
expect(middle.pos[0]).toBe(50)
expect(last.pos[0]).toBe(100)
expect(positions.map(({ newPos }) => newPos.x)).toEqual([0, 50, 100])
})
it('distributes nodes evenly along the vertical plane by default', () => {
const first = createNode(0, 0, 10, 10)
const last = createNode(0, 100, 10, 20)
const middle = createNode(0, 30, 10, 10)
const positions = distributeNodes([first, last, middle])
// Total span 0..120, heights 10 + 10 + 20 = 40, gap = (120 - 40) / 2 = 40
expect(first.pos[1]).toBe(0)
expect(middle.pos[1]).toBe(50)
expect(last.pos[1]).toBe(100)
expect(positions.map(({ newPos }) => newPos.y)).toEqual([0, 50, 100])
})
})
describe('alignNodes', () => {
it('returns an empty array when nodes are not supplied', () => {
expect(alignNodes(fromAny(undefined), 'left')).toEqual([])
})
it('returns an empty array when boundary nodes cannot be determined', () => {
expect(alignNodes([], 'left')).toEqual([])
})
it.for<[Direction, [number, number], [number, number]]>([
// Anchor is at [100, 100] with size [50, 50]; node is at [0, 0] size [10, 10].
['left', [100, 0], [100, 100]],
['right', [140, 0], [100, 100]],
['top', [0, 100], [100, 100]],
['bottom', [0, 140], [100, 100]]
])(
'aligns nodes to the %s edge of the anchor node',
([direction, expectedNodePos, expectedAnchorPos]) => {
const node = createNode(0, 0, 10, 10)
const anchor = createNode(100, 100, 50, 50)
const positions = alignNodes([node, anchor], direction, anchor)
expect(positions).toHaveLength(2)
expect([node.pos[0], node.pos[1]]).toEqual(expectedNodePos)
expect([anchor.pos[0], anchor.pos[1]]).toEqual(expectedAnchorPos)
}
)
it('uses boundary nodes when no anchor is supplied', () => {
const left = createNode(0, 0, 10, 10)
const right = createNode(100, 50, 20, 10)
alignNodes([left, right], 'left')
expect(left.pos[0]).toBe(0)
expect(right.pos[0]).toBe(0)
})
})

View File

@@ -0,0 +1,157 @@
import { describe, expect, it } from 'vitest'
import { fromAny } from '@total-typescript/shoehorn'
import type { Positionable } from '@/lib/litegraph/src/interfaces'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { createMockPositionable } from '@/utils/__tests__/litegraphTestUtils'
import {
findFirstNode,
findFreeSlotOfType,
getAllNestedItems
} from './collections'
describe('getAllNestedItems', () => {
it('returns an empty set when items are not supplied', () => {
expect(getAllNestedItems(fromAny(undefined)).size).toBe(0)
})
it('excludes pinned items', () => {
const pinned = createMockPositionable({ pinned: true })
const unpinned = createMockPositionable()
const result = getAllNestedItems(new Set([pinned, unpinned]))
expect(result.has(pinned)).toBe(false)
expect(result.has(unpinned)).toBe(true)
})
it('recurses into children and deduplicates shared children', () => {
const shared = createMockPositionable()
const parentA: Positionable = createMockPositionable({
children: new Set([shared])
})
const parentB: Positionable = createMockPositionable({
children: new Set([shared])
})
const result = getAllNestedItems(new Set([parentA, parentB]))
expect(result).toEqual(new Set([parentA, parentB, shared]))
})
it('does not recurse into pinned children', () => {
const pinnedChild = createMockPositionable({ pinned: true })
const parent: Positionable = createMockPositionable({
children: new Set([pinnedChild])
})
const result = getAllNestedItems(new Set([parent]))
expect(result).toEqual(new Set([parent]))
})
})
describe('findFirstNode', () => {
it('returns the first LGraphNode in the collection', () => {
const notANode = createMockPositionable()
const node = new LGraphNode('Test Node')
const otherNode = new LGraphNode('Other Node')
expect(findFirstNode([notANode, node, otherNode])).toBe(node)
})
it('returns undefined when the collection has no nodes', () => {
expect(findFirstNode([createMockPositionable()])).toBeUndefined()
expect(findFirstNode([])).toBeUndefined()
})
})
describe('findFreeSlotOfType', () => {
interface TestSlot {
type: string
free: boolean
}
const hasNoLinks = (slot: TestSlot) => slot.free
it('returns undefined when no slots are supplied', () => {
expect(findFreeSlotOfType([], 'A', hasNoLinks)).toBeUndefined()
expect(
findFreeSlotOfType(
fromAny<TestSlot[], undefined>(undefined),
'A',
hasNoLinks
)
).toBeUndefined()
})
it('returns the first free slot with an exact type match', () => {
const slots: TestSlot[] = [
{ type: 'a', free: false },
{ type: 'a', free: true }
]
expect(findFreeSlotOfType(slots, 'A', hasNoLinks)).toEqual({
index: 1,
slot: slots[1]
})
})
it('falls back to an occupied slot with a matching type', () => {
const slots: TestSlot[] = [{ type: 'a', free: false }]
expect(findFreeSlotOfType(slots, 'A', hasNoLinks)).toEqual({
index: 0,
slot: slots[0]
})
})
it('falls back to a free wildcard slot when no types match', () => {
const slots: TestSlot[] = [
{ type: 'b', free: true },
{ type: '*', free: true }
]
expect(findFreeSlotOfType(slots, 'A', hasNoLinks)).toEqual({
index: 1,
slot: slots[1]
})
})
it('falls back to an occupied wildcard slot as a last resort', () => {
const slots: TestSlot[] = [{ type: '*', free: false }]
expect(findFreeSlotOfType(slots, 'A', hasNoLinks)).toEqual({
index: 0,
slot: slots[0]
})
})
it('matches wildcard search types against occupied concrete slots', () => {
const slots: TestSlot[] = [{ type: 'b', free: false }]
expect(findFreeSlotOfType(slots, '*', hasNoLinks)).toEqual({
index: 0,
slot: slots[0]
})
})
it('returns undefined when nothing matches', () => {
const slots: TestSlot[] = [{ type: 'b', free: true }]
expect(findFreeSlotOfType(slots, 'A', hasNoLinks)).toBeUndefined()
})
it('matches any comma-delimited type in the search list', () => {
const slots: TestSlot[] = [
{ type: 'c', free: true },
{ type: 'b,c', free: true }
]
expect(findFreeSlotOfType(slots, 'A,B', hasNoLinks)).toEqual({
index: 1,
slot: slots[1]
})
})
})

View File

@@ -0,0 +1,70 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { defineDeprecatedProperty, warnDeprecated } from './feedback'
let messageId = 0
/** Unique message per test; warnDeprecated deduplicates per session. */
function uniqueMessage(): string {
messageId += 1
return `test deprecation message ${messageId}`
}
describe('warnDeprecated', () => {
const originalAlwaysRepeat = LiteGraph.alwaysRepeatWarnings
afterEach(() => {
LiteGraph.alwaysRepeatWarnings = originalAlwaysRepeat
LiteGraph.onDeprecationWarning.length = 0
})
it('notifies callbacks once per unique message', () => {
const callback = vi.fn()
LiteGraph.onDeprecationWarning.push(callback)
const message = uniqueMessage()
const source = {}
warnDeprecated(message, source)
warnDeprecated(message, source)
expect(callback).toHaveBeenCalledTimes(1)
expect(callback).toHaveBeenCalledWith(message, source)
})
it('repeats warnings when alwaysRepeatWarnings is enabled', () => {
const callback = vi.fn()
LiteGraph.onDeprecationWarning.push(callback)
LiteGraph.alwaysRepeatWarnings = true
const message = uniqueMessage()
warnDeprecated(message)
warnDeprecated(message)
expect(callback).toHaveBeenCalledTimes(2)
})
})
describe('defineDeprecatedProperty', () => {
afterEach(() => {
LiteGraph.onDeprecationWarning.length = 0
})
it('proxies reads and writes to the current property with a warning', () => {
const callback = vi.fn()
LiteGraph.onDeprecationWarning.push(callback)
const message = uniqueMessage()
const target: { current: number } & Record<string, unknown> = { current: 1 }
defineDeprecatedProperty(target, 'legacy', 'current', message)
expect(target.legacy).toBe(1)
target.legacy = 2
expect(target.current).toBe(2)
expect(callback).toHaveBeenCalledTimes(1)
expect(callback).toHaveBeenCalledWith(message, undefined)
})
})

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { resolveConnectingLinkColor } from './linkColors'
describe('resolveConnectingLinkColor', () => {
it('uses the event link colour for event slots', () => {
expect(resolveConnectingLinkColor(LiteGraph.EVENT)).toBe(
LiteGraph.EVENT_LINK_COLOR
)
})
it('uses the connecting link colour for other slot types', () => {
expect(resolveConnectingLinkColor('STRING')).toBe(
LiteGraph.CONNECTING_LINK_COLOR
)
expect(resolveConnectingLinkColor(undefined)).toBe(
LiteGraph.CONNECTING_LINK_COLOR
)
})
})

View File

@@ -89,6 +89,13 @@ describe('evaluateMathExpression', () => {
}
)
test.for(['-', '2*', '3/-'])(
'dangling operator returns undefined: "%s"',
(input) => {
expect(evaluateMathExpression(input)).toBeUndefined()
}
)
test('division by zero returns Infinity', () => {
expect(evaluateMathExpression('1/0')).toBe(Infinity)
})

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest'
import { commonType, isColorable, isNodeBindable } from './type'
describe('isColorable', () => {
it.for<[string, unknown]>([
['a primitive', 42],
['null', null],
['an object without setColorOption', { getColorOption: () => null }],
['an object without getColorOption', { setColorOption: () => {} }]
])('returns false for %s', ([, value]) => {
expect(isColorable(value)).toBe(false)
})
it('returns true for an object with both color option methods', () => {
const colorable = {
setColorOption: () => {},
getColorOption: () => null
}
expect(isColorable(colorable)).toBe(true)
})
})
describe('isNodeBindable', () => {
it.for<[string, unknown]>([
['a primitive', 'widget'],
['null', null],
['an object without setNodeId', {}],
['an object with a non-function setNodeId', { setNodeId: true }]
])('returns false for %s', ([, value]) => {
expect(isNodeBindable(value)).toBe(false)
})
it('returns true for an object with a setNodeId function', () => {
expect(isNodeBindable({ setNodeId: () => {} })).toBe(true)
})
})
describe('commonType', () => {
it('returns undefined when any type is not a string', () => {
expect(commonType('STRING', -1)).toBeUndefined()
})
it('returns the wildcard when all types are wildcards', () => {
expect(commonType('*', '*')).toBe('*')
})
it('ignores wildcards when other types are present', () => {
expect(commonType('*', 'STRING')).toBe('STRING')
})
it('returns the intersection of comma-delimited type lists', () => {
expect(commonType('A,B', 'B,C')).toBe('B')
})
it('returns undefined when types do not intersect', () => {
expect(commonType('A', 'B')).toBeUndefined()
})
})