mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-07 15:47:53 +00:00
Compare commits
7 Commits
codex/cove
...
codex/cove
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8405717a6f | ||
|
|
59eb3ed538 | ||
|
|
6533961422 | ||
|
|
686bb8a9fe | ||
|
|
c52d212796 | ||
|
|
f916a3f8a7 | ||
|
|
ccd33bb505 |
Binary file not shown.
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
@@ -1,3 +1,3 @@
|
||||
<svg width="20" height="32" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="#F2FF59"/>
|
||||
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path id="Vector" d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="var(--fill-0, #F2FF59)"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 279 B After Width: | Height: | Size: 380 B |
@@ -3799,7 +3799,7 @@ export class LGraphNode
|
||||
const title = String(rawTitle) + (this.pinned ? '📌' : '')
|
||||
if (title) {
|
||||
if (selected) {
|
||||
ctx.fillStyle = LiteGraph.NODE_SELECTED_TITLE_COLOR ?? '#FFF'
|
||||
ctx.fillStyle = LiteGraph.NODE_SELECTED_TITLE_COLOR
|
||||
} else {
|
||||
ctx.fillStyle = this.constructor.title_text_color || default_title_color
|
||||
}
|
||||
|
||||
@@ -54,10 +54,10 @@ export class LiteGraphGlobal {
|
||||
NODE_COLLAPSED_RADIUS = 10
|
||||
NODE_COLLAPSED_WIDTH = 80
|
||||
NODE_TITLE_COLOR = '#999'
|
||||
NODE_SELECTED_TITLE_COLOR: string | undefined = '#FFF'
|
||||
NODE_SELECTED_TITLE_COLOR = '#FFF'
|
||||
NODE_TEXT_SIZE = 14
|
||||
NODE_TEXT_COLOR = '#AAA'
|
||||
NODE_TEXT_HIGHLIGHT_COLOR: string | undefined = '#EEE'
|
||||
NODE_TEXT_HIGHLIGHT_COLOR = '#EEE'
|
||||
NODE_SUBTEXT_SIZE = 12
|
||||
NODE_DEFAULT_COLOR = '#333'
|
||||
NODE_DEFAULT_BGCOLOR = '#353535'
|
||||
|
||||
@@ -1,451 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
|
||||
import type {
|
||||
DefaultConnectionColors,
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot
|
||||
} 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<INodeInputSlot> = {},
|
||||
node = createNode()
|
||||
): NodeInputSlot {
|
||||
return new NodeInputSlot(
|
||||
{
|
||||
name: 'in',
|
||||
type: 'STRING',
|
||||
link: null,
|
||||
boundingRect: [10, 20, 20, 20] as const,
|
||||
...overrides
|
||||
},
|
||||
node
|
||||
)
|
||||
}
|
||||
|
||||
function createOutputSlot(
|
||||
overrides: Partial<INodeOutputSlot> = {},
|
||||
node = createNode()
|
||||
): NodeOutputSlot {
|
||||
return new NodeOutputSlot(
|
||||
{
|
||||
name: 'out',
|
||||
type: 'STRING',
|
||||
links: null,
|
||||
boundingRect: [10, 20, 20, 20] as const,
|
||||
...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 = undefined
|
||||
expect(createInputSlot().highlightColor).toBe(
|
||||
LiteGraph.NODE_SELECTED_TITLE_COLOR
|
||||
)
|
||||
|
||||
LiteGraph.NODE_SELECTED_TITLE_COLOR = 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { INodeOutputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import type { IWidget } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
|
||||
import { outputAsSerialisable } from './slotUtils'
|
||||
@@ -34,18 +33,4 @@ 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 = node.addWidget(
|
||||
'number',
|
||||
'my-widget',
|
||||
0,
|
||||
null
|
||||
) as INumericWidget
|
||||
|
||||
const serialised = outputAsSerialisable(output)
|
||||
expect(serialised.widget).toEqual({ name: 'my-widget' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,10 +8,8 @@ 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,
|
||||
@@ -716,373 +714,3 @@ 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,389 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { 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 { toLinkId } from '@/types/linkId'
|
||||
|
||||
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 = fromPartial<typeof LiteGraph.ContextMenu>(
|
||||
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(toLinkId(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' }))
|
||||
expect(slot.linkIds).toHaveLength(0)
|
||||
|
||||
// Remove action deletes the slot from the subgraph.
|
||||
void captured?.opts.callback?.(fromPartial({ value: 'remove' }))
|
||||
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' }))
|
||||
|
||||
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?.(pointer)
|
||||
expect(linkConnector.dragNewFromSubgraphInput).toHaveBeenCalledWith(
|
||||
subgraph,
|
||||
inputNode,
|
||||
slot
|
||||
)
|
||||
|
||||
pointer.onDragEnd?.(fromPartial<CanvasPointerEvent>({}))
|
||||
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?.(fromPartial<CanvasPointerEvent>({}))
|
||||
|
||||
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?.(fromPartial<CanvasPointerEvent>({}))
|
||||
|
||||
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(() => new DOMMatrix()),
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,657 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { 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 {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot,
|
||||
Point
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import { RenderShape } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import type { SubgraphIO } from '@/lib/litegraph/src/types/serialisation'
|
||||
import type { Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
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 = { 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 = fromPartial<Point>([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(
|
||||
fromPartial<SubgraphIO>({ 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 = ''
|
||||
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?.(pointer)
|
||||
expect(linkConnector.dragNewFromSubgraphOutput).toHaveBeenCalledWith(
|
||||
subgraph,
|
||||
outputNode,
|
||||
slot
|
||||
)
|
||||
pointer.onDragEnd?.(fromPartial<CanvasPointerEvent>({}))
|
||||
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 = fromPartial<typeof LiteGraph.ContextMenu>(
|
||||
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(
|
||||
fromPartial<SubgraphInput>({}),
|
||||
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', '')
|
||||
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()
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,69 +1,20 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot,
|
||||
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 { toLinkId } from '@/types/linkId'
|
||||
import { createMockPositionable } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
import type { SubgraphInput } from './SubgraphInput'
|
||||
|
||||
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 }))
|
||||
@@ -194,389 +145,4 @@ 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: fromPartial<SubgraphInput>({ name: 'sub' })
|
||||
})
|
||||
const unresolvable = fromPartial<ResolvedConnection>({})
|
||||
|
||||
const grouped = groupResolvedByOutput([
|
||||
first,
|
||||
second,
|
||||
bySubgraphInput,
|
||||
unresolvable
|
||||
])
|
||||
|
||||
expect(grouped.size).toBe(3)
|
||||
expect(grouped.get(output!)).toEqual([first, second])
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapSubgraphInputsAndLinks', () => {
|
||||
function createResolvedInput(
|
||||
linkId: number,
|
||||
inputOverrides: Record<string, unknown> = {}
|
||||
): { resolved: ResolvedConnection; link: LLink } {
|
||||
const link = new LLink(toLinkId(linkId), 'number', 1, 0, 2, 0)
|
||||
const resolved = fromPartial<ResolvedConnection>({
|
||||
link,
|
||||
input: fromPartial<INodeInputSlot>({
|
||||
name: 'in',
|
||||
type: 'number',
|
||||
...inputOverrides
|
||||
}),
|
||||
output: fromPartial<INodeOutputSlot>({ 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(toLinkId(1), 'number', 1, 0, 2, 0)
|
||||
const resolved = fromPartial<ResolvedConnection>({
|
||||
link,
|
||||
output: fromPartial<INodeOutputSlot>({ 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(toLinkId(linkId), 'number', 1, 0, 2, 0)
|
||||
const resolved = fromPartial<ResolvedConnection>({
|
||||
link,
|
||||
input: fromPartial<INodeInputSlot>({ name: 'in', type: 'number' }),
|
||||
output: fromPartial<INodeOutputSlot>({
|
||||
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(toLinkId(1), 'number', 1, 0, 2, 0)
|
||||
const resolved = fromPartial<ResolvedConnection>({
|
||||
link,
|
||||
input: fromPartial<INodeInputSlot>({ name: 'in', type: 'number' })
|
||||
})
|
||||
|
||||
const outputs = mapSubgraphOutputsAndLinks([resolved], [], new Map())
|
||||
|
||||
expect(outputs).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('reorderSubgraphInputs', () => {
|
||||
it('returns silently when the node has no subgraph', () => {
|
||||
const subgraphNode = createTestSubgraphNode(createTestSubgraph())
|
||||
Object.defineProperty(subgraphNode, 'subgraph', {
|
||||
value: undefined,
|
||||
configurable: true
|
||||
})
|
||||
expect(() => reorderSubgraphInputs(subgraphNode, [])).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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { isEqual } from 'es-toolkit'
|
||||
import type { LGraph, SubgraphId } from '@/lib/litegraph/src/LGraph'
|
||||
import type { SubgraphNode } from './SubgraphNode'
|
||||
import { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LLink } from '@/lib/litegraph/src/LLink'
|
||||
@@ -29,6 +28,7 @@ import type {
|
||||
import type { GraphOrSubgraph } from './Subgraph'
|
||||
import type { SubgraphInput } from './SubgraphInput'
|
||||
import { SubgraphInputNode } from './SubgraphInputNode'
|
||||
import type { SubgraphNode } from './SubgraphNode'
|
||||
import type { SubgraphOutput } from './SubgraphOutput'
|
||||
import { SubgraphOutputNode } from './SubgraphOutputNode'
|
||||
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
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(undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when all nodes are falsy', () => {
|
||||
expect(getBoundaryNodes([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([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(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(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)
|
||||
})
|
||||
})
|
||||
@@ -7,11 +7,8 @@ import type { Direction, IBoundaryNodes, NewNodePosition } from '../interfaces'
|
||||
* @returns An object listing the furthest node (edge) in all four directions.
|
||||
* `null` if no nodes were supplied or the first node was falsy.
|
||||
*/
|
||||
export function getBoundaryNodes(
|
||||
nodes: Array<LGraphNode | null | undefined> | undefined
|
||||
): IBoundaryNodes | null {
|
||||
if (!nodes) return null
|
||||
const valid = nodes.find((x) => x)
|
||||
export function getBoundaryNodes(nodes: LGraphNode[]): IBoundaryNodes | null {
|
||||
const valid = nodes?.find((x) => x)
|
||||
if (!valid) return null
|
||||
|
||||
let top = valid
|
||||
@@ -44,11 +41,11 @@ export function getBoundaryNodes(
|
||||
* @param horizontal If true, distributes along the horizontal plane. Otherwise, the vertical plane.
|
||||
*/
|
||||
export function distributeNodes(
|
||||
nodes: LGraphNode[] | undefined,
|
||||
nodes: LGraphNode[],
|
||||
horizontal?: boolean
|
||||
): NewNodePosition[] {
|
||||
if (!nodes || nodes.length <= 1) return []
|
||||
const nodeCount = nodes.length
|
||||
const nodeCount = nodes?.length
|
||||
if (!(nodeCount > 1)) return []
|
||||
|
||||
const index = horizontal ? 0 : 1
|
||||
|
||||
@@ -90,7 +87,7 @@ export function distributeNodes(
|
||||
* @param align_to The node to align all other nodes to. If undefined, the farthest node will be used.
|
||||
*/
|
||||
export function alignNodes(
|
||||
nodes: LGraphNode[] | undefined,
|
||||
nodes: LGraphNode[],
|
||||
direction: Direction,
|
||||
align_to?: LGraphNode
|
||||
): NewNodePosition[] {
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
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(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(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]
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -11,7 +11,7 @@ import type { ISlotType, Positionable } from '../interfaces'
|
||||
* @returns All unpinned items in the original set, and recursively, their children
|
||||
*/
|
||||
export function getAllNestedItems(
|
||||
items: ReadonlySet<Positionable> | undefined
|
||||
items: ReadonlySet<Positionable>
|
||||
): Set<Positionable> {
|
||||
const allItems = new Set<Positionable>()
|
||||
if (items) {
|
||||
@@ -61,7 +61,7 @@ type FreeSlotResult<T extends { type: ISlotType }> =
|
||||
* @returns The index and slot if found, otherwise `undefined`.
|
||||
*/
|
||||
export function findFreeSlotOfType<T extends { type: ISlotType }>(
|
||||
slots: T[] | undefined,
|
||||
slots: T[],
|
||||
type: ISlotType,
|
||||
hasNoLinks: (slot: T) => boolean
|
||||
) {
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,22 +0,0 @@
|
||||
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
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -89,13 +89,6 @@ 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)
|
||||
})
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
142
src/platform/secrets/api/secretsApi.test.ts
Normal file
142
src/platform/secrets/api/secretsApi.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
import {
|
||||
createSecret,
|
||||
deleteSecret,
|
||||
listSecrets,
|
||||
updateSecret
|
||||
} from './secretsApi'
|
||||
import type { SecretsApiError } from './secretsApi'
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fetchApi: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
const fetchApi = vi.mocked(api.fetchApi)
|
||||
|
||||
const secret = {
|
||||
id: 'secret-1',
|
||||
name: 'HF token',
|
||||
provider: 'huggingface' as const,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z'
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, init: ResponseInit = {}) {
|
||||
const headers = new Headers(init.headers)
|
||||
if (!headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
...init,
|
||||
headers
|
||||
})
|
||||
}
|
||||
|
||||
describe('secretsApi', () => {
|
||||
beforeEach(() => {
|
||||
fetchApi.mockReset()
|
||||
})
|
||||
|
||||
it('lists secrets from the API response data field', async () => {
|
||||
fetchApi.mockResolvedValue(jsonResponse({ data: [secret] }))
|
||||
|
||||
await expect(listSecrets()).resolves.toEqual([secret])
|
||||
expect(fetchApi).toHaveBeenCalledWith('/secrets')
|
||||
})
|
||||
|
||||
it('creates secrets with a JSON payload', async () => {
|
||||
fetchApi.mockResolvedValue(jsonResponse(secret))
|
||||
|
||||
await expect(
|
||||
createSecret({
|
||||
name: 'HF token',
|
||||
secret_value: 'token',
|
||||
provider: 'huggingface'
|
||||
})
|
||||
).resolves.toEqual(secret)
|
||||
expect(fetchApi).toHaveBeenCalledWith('/secrets', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'HF token',
|
||||
secret_value: 'token',
|
||||
provider: 'huggingface'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('updates secrets with a JSON payload', async () => {
|
||||
fetchApi.mockResolvedValue(jsonResponse(secret))
|
||||
|
||||
await expect(
|
||||
updateSecret('secret-1', { name: 'New name' })
|
||||
).resolves.toEqual(secret)
|
||||
expect(fetchApi).toHaveBeenCalledWith('/secrets/secret-1', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'New name' })
|
||||
})
|
||||
})
|
||||
|
||||
it('deletes secrets without reading a success body', async () => {
|
||||
fetchApi.mockResolvedValue(new Response(null, { status: 204 }))
|
||||
|
||||
await expect(deleteSecret('secret-1')).resolves.toBeUndefined()
|
||||
expect(fetchApi).toHaveBeenCalledWith('/secrets/secret-1', {
|
||||
method: 'DELETE'
|
||||
})
|
||||
})
|
||||
|
||||
it('throws a typed error for known API error codes', async () => {
|
||||
fetchApi.mockResolvedValue(
|
||||
jsonResponse(
|
||||
{ message: 'Duplicate provider', code: 'DUPLICATE_PROVIDER' },
|
||||
{ status: 409, statusText: 'Conflict' }
|
||||
)
|
||||
)
|
||||
|
||||
await expect(listSecrets()).rejects.toMatchObject({
|
||||
name: 'SecretsApiError',
|
||||
message: 'Duplicate provider',
|
||||
status: 409,
|
||||
code: 'DUPLICATE_PROVIDER'
|
||||
} satisfies Partial<SecretsApiError>)
|
||||
})
|
||||
|
||||
it('falls back to status text for non-JSON error responses', async () => {
|
||||
fetchApi.mockResolvedValue(
|
||||
new Response('not-json', {
|
||||
status: 500,
|
||||
statusText: 'Server Error'
|
||||
})
|
||||
)
|
||||
|
||||
await expect(listSecrets()).rejects.toMatchObject({
|
||||
message: 'Server Error',
|
||||
status: 500,
|
||||
code: undefined
|
||||
} satisfies Partial<SecretsApiError>)
|
||||
})
|
||||
|
||||
it('ignores unknown API error codes', async () => {
|
||||
fetchApi.mockResolvedValue(
|
||||
jsonResponse(
|
||||
{ message: 'Unexpected', code: 'SOMETHING_ELSE' },
|
||||
{ status: 400, statusText: 'Bad Request' }
|
||||
)
|
||||
)
|
||||
|
||||
await expect(listSecrets()).rejects.toMatchObject({
|
||||
message: 'Unexpected',
|
||||
status: 400,
|
||||
code: undefined
|
||||
} satisfies Partial<SecretsApiError>)
|
||||
})
|
||||
})
|
||||
24
src/platform/secrets/providers.test.ts
Normal file
24
src/platform/secrets/providers.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getProviderLabel, getProviderLogo } from './providers'
|
||||
|
||||
import type { SecretProvider } from './types'
|
||||
|
||||
describe('secret providers', () => {
|
||||
it('returns empty display values when provider is missing', () => {
|
||||
expect(getProviderLabel(undefined)).toBe('')
|
||||
expect(getProviderLogo(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns configured labels and logos', () => {
|
||||
expect(getProviderLabel('huggingface')).toBe('HuggingFace')
|
||||
expect(getProviderLogo('civitai')).toBe('/assets/images/civitai.svg')
|
||||
})
|
||||
|
||||
it('falls back to the provider value for unknown labels', () => {
|
||||
const provider = 'custom' as SecretProvider
|
||||
|
||||
expect(getProviderLabel(provider)).toBe('custom')
|
||||
expect(getProviderLogo(provider)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
142
src/stores/aboutPanelStore.test.ts
Normal file
142
src/stores/aboutPanelStore.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { SystemStats } from '@/schemas/apiSchema'
|
||||
import type { AboutPageBadge } from '@/types/comfy'
|
||||
import { useAboutPanelStore } from '@/stores/aboutPanelStore'
|
||||
|
||||
type SystemInfo = Partial<SystemStats['system']>
|
||||
|
||||
const { dist, stats, exts } = vi.hoisted(() => ({
|
||||
dist: { isCloud: false, isDesktop: false },
|
||||
stats: { system: {} as SystemInfo },
|
||||
exts: { list: [] as { aboutPageBadges?: AboutPageBadge[] }[] }
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return dist.isCloud
|
||||
},
|
||||
get isDesktop() {
|
||||
return dist.isDesktop
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useExternalLink', () => ({
|
||||
useExternalLink: () => ({
|
||||
staticUrls: {
|
||||
github: 'https://github.com/comfyanonymous/ComfyUI',
|
||||
githubFrontend: 'https://github.com/Comfy-Org/ComfyUI_frontend',
|
||||
comfyOrg: 'https://comfy.org',
|
||||
discord: 'https://discord.com'
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/envUtil', () => ({
|
||||
electronAPI: () => ({ getComfyUIVersion: () => '9.9.9' })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/extensionStore', () => ({
|
||||
useExtensionStore: () => ({ extensions: exts.list })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/systemStatsStore', () => ({
|
||||
useSystemStatsStore: () => ({ systemStats: stats })
|
||||
}))
|
||||
|
||||
function label(badges: AboutPageBadge[], includes: string) {
|
||||
return badges.find((b) => b.label.includes(includes))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
dist.isCloud = false
|
||||
dist.isDesktop = false
|
||||
stats.system = {}
|
||||
exts.list = []
|
||||
})
|
||||
|
||||
describe('aboutPanelStore', () => {
|
||||
it('builds the default desktop-less, non-cloud core badges', () => {
|
||||
stats.system = { comfyui_version: 'abc1234' }
|
||||
const store = useAboutPanelStore()
|
||||
|
||||
const core = label(store.badges, 'ComfyUI ')!
|
||||
expect(core.icon).toBe('pi pi-github')
|
||||
expect(core.url).toContain('github.com/comfyanonymous')
|
||||
expect(label(store.badges, 'ComfyUI_frontend')).toBeDefined()
|
||||
expect(label(store.badges, 'Discord')).toBeDefined()
|
||||
expect(label(store.badges, 'Templates')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses cloud url and icon for the core badge when running on cloud', () => {
|
||||
dist.isCloud = true
|
||||
const store = useAboutPanelStore()
|
||||
|
||||
const core = label(store.badges, 'ComfyUI ')!
|
||||
expect(core.icon).toBe('pi pi-cloud')
|
||||
expect(core.url).toBe('https://comfy.org')
|
||||
})
|
||||
|
||||
it('uses the electron-reported version label on desktop', () => {
|
||||
dist.isDesktop = true
|
||||
const store = useAboutPanelStore()
|
||||
|
||||
expect(label(store.badges, 'ComfyUI v9.9.9')).toBeDefined()
|
||||
})
|
||||
|
||||
it('adds a danger templates badge when the installed version is outdated', () => {
|
||||
stats.system = {
|
||||
installed_templates_version: '1.0.0',
|
||||
required_templates_version: '1.1.0'
|
||||
}
|
||||
const store = useAboutPanelStore()
|
||||
|
||||
const templates = label(store.badges, 'Templates v1.0.0')!
|
||||
expect(templates.severity).toBe('danger')
|
||||
})
|
||||
|
||||
it('adds a templates badge without severity when versions match', () => {
|
||||
stats.system = {
|
||||
installed_templates_version: '1.1.0',
|
||||
required_templates_version: '1.1.0'
|
||||
}
|
||||
const store = useAboutPanelStore()
|
||||
|
||||
const templates = label(store.badges, 'Templates v1.1.0')!
|
||||
expect(templates.severity).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not mark templates outdated when the required version is missing', () => {
|
||||
stats.system = {
|
||||
installed_templates_version: '1.1.0'
|
||||
}
|
||||
const store = useAboutPanelStore()
|
||||
|
||||
const templates = label(store.badges, 'Templates v1.1.0')!
|
||||
expect(templates.severity).toBeUndefined()
|
||||
})
|
||||
|
||||
it('omits the templates badge when the installed version is missing', () => {
|
||||
stats.system = {
|
||||
required_templates_version: '1.1.0'
|
||||
}
|
||||
const store = useAboutPanelStore()
|
||||
|
||||
expect(label(store.badges, 'Templates')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('appends extension badges and tolerates extensions without any', () => {
|
||||
exts.list = [
|
||||
{
|
||||
aboutPageBadges: [{ label: 'My Ext', url: 'https://ext', icon: 'pi' }]
|
||||
},
|
||||
{}
|
||||
]
|
||||
const store = useAboutPanelStore()
|
||||
|
||||
expect(label(store.badges, 'My Ext')).toBeDefined()
|
||||
})
|
||||
})
|
||||
134
src/stores/apiKeyAuthStore.test.ts
Normal file
134
src/stores/apiKeyAuthStore.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
||||
|
||||
const authStoreMock = vi.hoisted(() => ({
|
||||
createCustomer: vi.fn()
|
||||
}))
|
||||
|
||||
const toastStoreMock = vi.hoisted(() => ({
|
||||
add: vi.fn()
|
||||
}))
|
||||
|
||||
const errorHandlingMock = vi.hoisted(() => ({
|
||||
toastErrorHandler: vi.fn(),
|
||||
forceGenericFailure: false,
|
||||
forceStorageFailure: false
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => authStoreMock
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => toastStoreMock
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useErrorHandling', () => ({
|
||||
useErrorHandling: () => ({
|
||||
toastErrorHandler: errorHandlingMock.toastErrorHandler,
|
||||
wrapWithErrorHandlingAsync:
|
||||
(
|
||||
fn: (value?: string) => Promise<boolean>,
|
||||
onError: (e: unknown) => void
|
||||
) =>
|
||||
async (value?: string) => {
|
||||
try {
|
||||
if (errorHandlingMock.forceStorageFailure) {
|
||||
throw new Error('STORAGE_FAILED')
|
||||
}
|
||||
if (errorHandlingMock.forceGenericFailure) {
|
||||
throw new Error('OTHER_FAILED')
|
||||
}
|
||||
return await fn(value)
|
||||
} catch (e) {
|
||||
onError(e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
describe('apiKeyAuthStore', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
authStoreMock.createCustomer.mockReset()
|
||||
toastStoreMock.add.mockClear()
|
||||
errorHandlingMock.toastErrorHandler.mockClear()
|
||||
errorHandlingMock.forceGenericFailure = false
|
||||
errorHandlingMock.forceStorageFailure = false
|
||||
})
|
||||
|
||||
it('stores an API key, initializes the user, and returns an auth header', async () => {
|
||||
authStoreMock.createCustomer.mockResolvedValue({ id: 'user-1' })
|
||||
const store = useApiKeyAuthStore()
|
||||
|
||||
await expect(store.storeApiKey('secret')).resolves.toBe(true)
|
||||
await vi.waitFor(() => expect(store.currentUser).toEqual({ id: 'user-1' }))
|
||||
|
||||
expect(store.isAuthenticated).toBe(true)
|
||||
expect(store.getApiKey()).toBe('secret')
|
||||
expect(store.getAuthHeader()).toEqual({ 'X-API-KEY': 'secret' })
|
||||
expect(toastStoreMock.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'success' })
|
||||
)
|
||||
})
|
||||
|
||||
it('clears the API key when user initialization cannot create a customer', async () => {
|
||||
authStoreMock.createCustomer.mockResolvedValue(undefined)
|
||||
const store = useApiKeyAuthStore()
|
||||
|
||||
await expect(store.storeApiKey('secret')).resolves.toBe(true)
|
||||
|
||||
await vi.waitFor(() => expect(store.getApiKey()).toBeNull())
|
||||
expect(store.currentUser).toBeNull()
|
||||
expect(errorHandlingMock.toastErrorHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: 'auth.login.noAssociatedUser' })
|
||||
)
|
||||
})
|
||||
|
||||
it('clears the user when the API key is cleared', async () => {
|
||||
authStoreMock.createCustomer.mockResolvedValue({ id: 'user-1' })
|
||||
const store = useApiKeyAuthStore()
|
||||
|
||||
await store.storeApiKey('secret')
|
||||
await vi.waitFor(() => expect(store.currentUser).toEqual({ id: 'user-1' }))
|
||||
await expect(store.clearStoredApiKey()).resolves.toBe(true)
|
||||
|
||||
expect(store.currentUser).toBeNull()
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
expect(store.getAuthHeader()).toBeNull()
|
||||
})
|
||||
|
||||
it('reports storage failures through the API-key toast copy', async () => {
|
||||
errorHandlingMock.forceStorageFailure = true
|
||||
const store = useApiKeyAuthStore()
|
||||
|
||||
await expect(store.storeApiKey('secret')).resolves.toBe(false)
|
||||
|
||||
expect(toastStoreMock.add).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'auth.apiKey.storageFailed',
|
||||
detail: 'auth.apiKey.storageFailedDetail'
|
||||
})
|
||||
expect(errorHandlingMock.toastErrorHandler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports non-storage failures through the generic toast handler', async () => {
|
||||
errorHandlingMock.forceGenericFailure = true
|
||||
const store = useApiKeyAuthStore()
|
||||
|
||||
await expect(store.storeApiKey('secret')).resolves.toBe(false)
|
||||
|
||||
expect(errorHandlingMock.toastErrorHandler).toHaveBeenCalledWith(
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -37,20 +37,6 @@ export const useApiKeyAuthStore = defineStore('apiKeyAuth', () => {
|
||||
currentUser.value = createCustomerResponse
|
||||
}
|
||||
|
||||
watch(
|
||||
apiKey,
|
||||
() => {
|
||||
if (apiKey.value) {
|
||||
// IF API key is set, initialize user
|
||||
void initializeUserFromApiKey()
|
||||
} else {
|
||||
// IF API key is cleared, clear user
|
||||
currentUser.value = null
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const reportError = (error: unknown) => {
|
||||
if (error instanceof Error && error.message === 'STORAGE_FAILED') {
|
||||
toastStore.add({
|
||||
@@ -63,6 +49,18 @@ export const useApiKeyAuthStore = defineStore('apiKeyAuth', () => {
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
apiKey,
|
||||
() => {
|
||||
if (apiKey.value) {
|
||||
initializeUserFromApiKey().catch(reportError)
|
||||
} else {
|
||||
currentUser.value = null
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const storeApiKey = wrapWithErrorHandlingAsync(async (newApiKey: string) => {
|
||||
apiKey.value = newApiKey
|
||||
toastStore.add({
|
||||
|
||||
190
src/stores/executionError.test.ts
Normal file
190
src/stores/executionError.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { ComfyApiWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
|
||||
const {
|
||||
handlers,
|
||||
openSet,
|
||||
errorStore,
|
||||
dist,
|
||||
resolvePrecondition,
|
||||
classifyCloud
|
||||
} = vi.hoisted(() => ({
|
||||
handlers: {} as Record<string, (e: { detail: unknown }) => void>,
|
||||
openSet: new Set<unknown>(),
|
||||
errorStore: {
|
||||
clearExecutionStartErrors: () => {},
|
||||
clearPromptError: () => {}
|
||||
} as Record<string, unknown>,
|
||||
dist: { isCloud: false },
|
||||
resolvePrecondition: vi.fn(),
|
||||
classifyCloud: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({ app: { rootGraph: {} } }))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
addEventListener: (name: string, fn: (e: { detail: unknown }) => void) => {
|
||||
handlers[name] = fn
|
||||
},
|
||||
removeEventListener: () => {}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
isOpen: (workflow: unknown) => openSet.has(workflow),
|
||||
openWorkflows: [],
|
||||
nodeLocatorIdToNodeExecutionId: () => null
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({ canvas: undefined })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/executionErrorStore', () => ({
|
||||
useExecutionErrorStore: () => errorStore
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({ mode: ref('default'), isAppMode: ref(false) })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({ useTelemetry: () => undefined }))
|
||||
|
||||
vi.mock('@/utils/appMode', () => ({
|
||||
getWorkflowMode: () => 'workflow',
|
||||
isAppModeValue: () => false
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return dist.isCloud
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/errorCatalog/accountPreconditionRouting', () => ({
|
||||
resolveAccountPrecondition: resolvePrecondition
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/executionErrorUtil', () => ({
|
||||
classifyCloudValidationError: classifyCloud
|
||||
}))
|
||||
|
||||
function workflow(path: string): ComfyWorkflow {
|
||||
return fromPartial<ComfyWorkflow>({ path })
|
||||
}
|
||||
|
||||
function promptOutput(): ComfyApiWorkflow {
|
||||
return {}
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const store = useExecutionStore()
|
||||
store.bindExecutionEvents()
|
||||
return store
|
||||
}
|
||||
|
||||
function fireError(detail: Record<string, unknown>) {
|
||||
handlers['execution_error']?.({ detail })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
for (const key of Object.keys(handlers)) delete handlers[key]
|
||||
openSet.clear()
|
||||
dist.isCloud = false
|
||||
resolvePrecondition.mockReturnValue(null)
|
||||
classifyCloud.mockReturnValue(null)
|
||||
for (const key of ['lastExecutionError', 'lastPromptError', 'lastNodeErrors'])
|
||||
delete errorStore[key]
|
||||
})
|
||||
|
||||
describe('executionStore error handling', () => {
|
||||
it('marks an open workflow failed and records the raw execution error', () => {
|
||||
const store = setup()
|
||||
const wf = workflow('a.json')
|
||||
openSet.add(wf)
|
||||
store.storeJob({
|
||||
nodes: [],
|
||||
id: 'job-1',
|
||||
promptOutput: promptOutput(),
|
||||
workflow: wf
|
||||
})
|
||||
|
||||
const detail = {
|
||||
prompt_id: 'job-1',
|
||||
node_id: '5',
|
||||
exception_message: 'boom'
|
||||
}
|
||||
fireError(detail)
|
||||
|
||||
expect(store.getWorkflowStatus(wf)).toBe('failed')
|
||||
expect(errorStore.lastExecutionError).toBe(detail)
|
||||
})
|
||||
|
||||
it('routes account-precondition errors away from the failed badge', () => {
|
||||
resolvePrecondition.mockReturnValue({ type: 'credits' })
|
||||
const store = setup()
|
||||
const wf = workflow('b.json')
|
||||
openSet.add(wf)
|
||||
store.storeJob({
|
||||
nodes: [],
|
||||
id: 'job-2',
|
||||
promptOutput: promptOutput(),
|
||||
workflow: wf
|
||||
})
|
||||
|
||||
fireError({ prompt_id: 'job-2', exception_type: 'AccountError' })
|
||||
|
||||
expect(store.getWorkflowStatus(wf)).toBeUndefined()
|
||||
expect(errorStore.lastExecutionError).toBeUndefined()
|
||||
})
|
||||
|
||||
it('records a node-less service-level error as a prompt error', () => {
|
||||
setup()
|
||||
|
||||
fireError({
|
||||
prompt_id: 'job-3',
|
||||
exception_type: 'StagnationError',
|
||||
exception_message: 'stuck',
|
||||
traceback: ['line1', 'line2']
|
||||
})
|
||||
|
||||
expect(errorStore.lastPromptError).toEqual({
|
||||
type: 'StagnationError',
|
||||
message: 'StagnationError: stuck',
|
||||
details: 'line1\nline2'
|
||||
})
|
||||
})
|
||||
|
||||
it('records classified cloud validation node errors without a failed badge', () => {
|
||||
dist.isCloud = true
|
||||
classifyCloud.mockReturnValue({
|
||||
kind: 'nodeErrors',
|
||||
nodeErrors: { '5': { errors: [] } }
|
||||
})
|
||||
const store = setup()
|
||||
const wf = workflow('c.json')
|
||||
openSet.add(wf)
|
||||
store.storeJob({
|
||||
nodes: [],
|
||||
id: 'job-4',
|
||||
promptOutput: promptOutput(),
|
||||
workflow: wf
|
||||
})
|
||||
|
||||
fireError({ prompt_id: 'job-4', exception_message: '{"nodeErrors":{}}' })
|
||||
|
||||
expect(store.getWorkflowStatus(wf)).toBeUndefined()
|
||||
expect(errorStore.lastNodeErrors).toEqual({ '5': { errors: [] } })
|
||||
})
|
||||
})
|
||||
231
src/stores/nodeBookmarkStore.test.ts
Normal file
231
src/stores/nodeBookmarkStore.test.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
const BOOKMARK_ID = 'Comfy.NodeLibrary.Bookmarks.V2'
|
||||
const CUSTOMIZATION_ID = 'Comfy.NodeLibrary.BookmarksCustomization'
|
||||
|
||||
const { settings, setSpy, nodeDefs } = vi.hoisted(() => ({
|
||||
settings: {} as Record<string, unknown>,
|
||||
setSpy: vi.fn(),
|
||||
nodeDefs: {} as Record<string, unknown>
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', async () => {
|
||||
const { reactive } = await import('vue')
|
||||
const reactiveSettings = reactive(settings)
|
||||
setSpy.mockImplementation(async (id: string, value: unknown) => {
|
||||
reactiveSettings[id] = value
|
||||
})
|
||||
return {
|
||||
useSettingStore: () => ({
|
||||
get: (id: string) => reactiveSettings[id],
|
||||
set: setSpy
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
useNodeDefStore: () => ({ allNodeDefsByName: nodeDefs }),
|
||||
buildNodeDefTree: (defs: unknown[]) => ({ key: 'root', children: defs }),
|
||||
createDummyFolderNodeDef: (path: string) => ({
|
||||
isDummyFolder: true,
|
||||
nodePath: path,
|
||||
name: path
|
||||
})
|
||||
}))
|
||||
|
||||
function folderNode(nodePath: string): ComfyNodeDefImpl {
|
||||
return fromPartial<ComfyNodeDefImpl>({
|
||||
isDummyFolder: true,
|
||||
nodePath,
|
||||
category: nodePath.replace(/\/$/, ''),
|
||||
name: nodePath
|
||||
})
|
||||
}
|
||||
|
||||
function leafNode(name: string, nodePath = name): ComfyNodeDefImpl {
|
||||
return fromPartial<ComfyNodeDefImpl>({
|
||||
isDummyFolder: false,
|
||||
name,
|
||||
nodePath,
|
||||
category: ''
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
for (const key of Object.keys(settings)) delete settings[key]
|
||||
for (const key of Object.keys(nodeDefs)) delete nodeDefs[key]
|
||||
settings[BOOKMARK_ID] = []
|
||||
settings[CUSTOMIZATION_ID] = {}
|
||||
setSpy.mockClear()
|
||||
})
|
||||
|
||||
describe('nodeBookmarkStore', () => {
|
||||
it('reports isBookmarked by either nodePath or top-level name', () => {
|
||||
settings[BOOKMARK_ID] = ['sampling/KSampler', 'LoadImage']
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
expect(store.isBookmarked(leafNode('KSampler', 'sampling/KSampler'))).toBe(
|
||||
true
|
||||
)
|
||||
expect(store.isBookmarked(leafNode('LoadImage'))).toBe(true)
|
||||
expect(store.isBookmarked(leafNode('VAEDecode'))).toBe(false)
|
||||
})
|
||||
|
||||
it('adds a bookmark by appending to the current list', async () => {
|
||||
settings[BOOKMARK_ID] = ['A']
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
await store.addBookmark('B')
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith(BOOKMARK_ID, ['A', 'B'])
|
||||
})
|
||||
|
||||
it('toggles an un-bookmarked node by adding its name', async () => {
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
await store.toggleBookmark(leafNode('KSampler'))
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith(BOOKMARK_ID, ['KSampler'])
|
||||
})
|
||||
|
||||
it('toggles a bookmarked node by deleting both nodePath and name', async () => {
|
||||
settings[BOOKMARK_ID] = ['sampling/KSampler', 'KSampler']
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
await store.toggleBookmark(leafNode('KSampler', 'sampling/KSampler'))
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith(BOOKMARK_ID, ['KSampler'])
|
||||
expect(setSpy).toHaveBeenLastCalledWith(BOOKMARK_ID, [])
|
||||
expect(store.bookmarks).toEqual([])
|
||||
})
|
||||
|
||||
it('creates a folder under a parent and at the root', async () => {
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
const rootPath = await store.addNewBookmarkFolder(undefined, 'Favorites')
|
||||
expect(rootPath).toBe('Favorites/')
|
||||
|
||||
const childPath = await store.addNewBookmarkFolder(
|
||||
folderNode('Favorites/'),
|
||||
'Nested'
|
||||
)
|
||||
expect(childPath).toBe('Favorites/Nested/')
|
||||
})
|
||||
|
||||
it('builds the bookmark tree, dropping unknown node defs', () => {
|
||||
nodeDefs['KSampler'] = leafNode('KSampler')
|
||||
settings[BOOKMARK_ID] = ['sampling/KSampler', 'sampling/Unknown', 'Folder/']
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
const children = (store.bookmarkedRoot as { children: unknown[] }).children
|
||||
expect(children).toHaveLength(2)
|
||||
})
|
||||
|
||||
describe('renameBookmarkFolder', () => {
|
||||
it('rejects renaming a non-folder node', async () => {
|
||||
const store = useNodeBookmarkStore()
|
||||
await expect(
|
||||
store.renameBookmarkFolder(leafNode('KSampler'), 'New')
|
||||
).rejects.toThrow('Cannot rename non-folder node')
|
||||
})
|
||||
|
||||
it('rejects a name containing a slash', async () => {
|
||||
const store = useNodeBookmarkStore()
|
||||
await expect(
|
||||
store.renameBookmarkFolder(folderNode('Old/'), 'a/b')
|
||||
).rejects.toThrow('cannot contain')
|
||||
})
|
||||
|
||||
it('rejects a rename that collides with an existing folder', async () => {
|
||||
settings[BOOKMARK_ID] = ['Taken/']
|
||||
const store = useNodeBookmarkStore()
|
||||
await expect(
|
||||
store.renameBookmarkFolder(folderNode('Old/'), 'Taken')
|
||||
).rejects.toThrow('already exists')
|
||||
})
|
||||
|
||||
it('rewrites matching bookmark paths on a valid rename', async () => {
|
||||
settings[BOOKMARK_ID] = ['Old/', 'Old/KSampler', 'Other/Node']
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
await store.renameBookmarkFolder(folderNode('Old/'), 'New')
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith(BOOKMARK_ID, [
|
||||
'New/',
|
||||
'New/KSampler',
|
||||
'Other/Node'
|
||||
])
|
||||
})
|
||||
|
||||
it('does nothing when the folder keeps the same path', async () => {
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
await store.renameBookmarkFolder(folderNode('Old/'), 'Old')
|
||||
|
||||
expect(setSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('deletes a folder and all its descendants', async () => {
|
||||
settings[BOOKMARK_ID] = ['Old/', 'Old/KSampler', 'Keep/Node']
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
await store.deleteBookmarkFolder(folderNode('Old/'))
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith(BOOKMARK_ID, ['Keep/Node'])
|
||||
})
|
||||
|
||||
it('rejects deleting a non-folder node', async () => {
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
await expect(
|
||||
store.deleteBookmarkFolder(leafNode('KSampler'))
|
||||
).rejects.toThrow('Cannot delete non-folder node')
|
||||
})
|
||||
|
||||
describe('updateBookmarkCustomization', () => {
|
||||
it('persists a non-default customization', async () => {
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
await store.updateBookmarkCustomization('Folder/', {
|
||||
color: '#ff0000',
|
||||
icon: 'pi-star'
|
||||
})
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith(CUSTOMIZATION_ID, {
|
||||
'Folder/': { color: '#ff0000', icon: 'pi-star' }
|
||||
})
|
||||
})
|
||||
|
||||
it('drops attributes set to their default values', async () => {
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
await store.updateBookmarkCustomization('Folder/', {
|
||||
color: store.defaultBookmarkColor,
|
||||
icon: store.defaultBookmarkIcon
|
||||
})
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith(CUSTOMIZATION_ID, {
|
||||
'Folder/': undefined
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('renames a customization entry, moving the old key to the new one', async () => {
|
||||
settings[CUSTOMIZATION_ID] = { 'Old/': { color: '#abc' } }
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
await store.renameBookmarkCustomization('Old/', 'New/')
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith(CUSTOMIZATION_ID, {
|
||||
'New/': { color: '#abc' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,8 @@ import type {
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ComfyApp } from '@/scripts/app'
|
||||
import * as jobOutputCache from '@/services/jobOutputCache'
|
||||
import type { TaskOutput } from '@/schemas/apiSchema'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { TaskItemImpl } from '@/stores/queueStore'
|
||||
|
||||
vi.mock('@/services/extensionService', () => ({
|
||||
@@ -44,7 +46,9 @@ const mockJobDetail = {
|
||||
}
|
||||
},
|
||||
outputs: {
|
||||
'1': { images: [{ filename: 'test.png', subfolder: '', type: 'output' }] }
|
||||
'1': {
|
||||
images: [{ filename: 'test.png', subfolder: '', type: 'output' as const }]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,4 +141,98 @@ describe('TaskItemImpl.loadWorkflow - workflow fetching', () => {
|
||||
expect(jobOutputCache.getJobDetail).toHaveBeenCalled()
|
||||
expect(mockApp.loadGraphData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should load full outputs for history tasks', async () => {
|
||||
const job = createHistoryJob('test-job-id')
|
||||
const task = new TaskItemImpl(job)
|
||||
vi.spyOn(jobOutputCache, 'getJobDetail').mockResolvedValue(
|
||||
mockJobDetail as JobDetail
|
||||
)
|
||||
|
||||
const loaded = await task.loadFullOutputs()
|
||||
|
||||
expect(loaded).not.toBe(task)
|
||||
expect(loaded.flatOutputs[0].filename).toBe('test.png')
|
||||
})
|
||||
|
||||
it('should not load full outputs for running tasks', async () => {
|
||||
const job = createRunningJob('test-job-id')
|
||||
const task = new TaskItemImpl(job)
|
||||
const detailSpy = vi.spyOn(jobOutputCache, 'getJobDetail')
|
||||
|
||||
const loaded = await task.loadFullOutputs()
|
||||
|
||||
expect(loaded).toBe(task)
|
||||
expect(detailSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should keep history tasks when full outputs are unavailable', async () => {
|
||||
const job = createHistoryJob('test-job-id')
|
||||
const task = new TaskItemImpl(job)
|
||||
vi.spyOn(jobOutputCache, 'getJobDetail').mockResolvedValue(
|
||||
fromPartial<JobDetail>({ id: 'test-job-id', status: 'completed' })
|
||||
)
|
||||
|
||||
const loaded = await task.loadFullOutputs()
|
||||
|
||||
expect(loaded).toBe(task)
|
||||
})
|
||||
|
||||
it('should load workflow outputs from the task when job detail has none', async () => {
|
||||
const job = createHistoryJob('test-job-id')
|
||||
const task = new TaskItemImpl(job, mockJobDetail.outputs)
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
const setOutputsSpy = vi.spyOn(
|
||||
nodeOutputStore,
|
||||
'setNodeOutputsByExecutionId'
|
||||
)
|
||||
vi.spyOn(jobOutputCache, 'getJobDetail').mockResolvedValue(
|
||||
fromPartial<JobDetail>({ ...mockJobDetail, outputs: undefined })
|
||||
)
|
||||
|
||||
await task.loadWorkflow(mockApp)
|
||||
|
||||
expect(mockApp.loadGraphData).toHaveBeenCalledWith(mockWorkflow)
|
||||
expect(setOutputsSpy).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('should skip workflow output loading when no outputs exist', async () => {
|
||||
const job = createHistoryJob('test-job-id')
|
||||
const task = new TaskItemImpl(job, undefined)
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
const setOutputsSpy = vi.spyOn(
|
||||
nodeOutputStore,
|
||||
'setNodeOutputsByExecutionId'
|
||||
)
|
||||
vi.spyOn(jobOutputCache, 'getJobDetail').mockResolvedValue(
|
||||
fromPartial<JobDetail>({ ...mockJobDetail, outputs: undefined })
|
||||
)
|
||||
|
||||
await task.loadWorkflow(mockApp)
|
||||
|
||||
expect(mockApp.loadGraphData).toHaveBeenCalledWith(mockWorkflow)
|
||||
expect(setOutputsSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should skip invalid node execution ids while loading outputs', async () => {
|
||||
const job = createHistoryJob('test-job-id')
|
||||
const outputs = fromPartial<TaskOutput>({
|
||||
'': { images: [{ filename: 'skip.png', subfolder: '', type: 'output' }] },
|
||||
'1': { images: [{ filename: 'keep.png', subfolder: '', type: 'output' }] }
|
||||
})
|
||||
const task = new TaskItemImpl(job, outputs)
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
const setOutputsSpy = vi.spyOn(
|
||||
nodeOutputStore,
|
||||
'setNodeOutputsByExecutionId'
|
||||
)
|
||||
vi.spyOn(jobOutputCache, 'getJobDetail').mockResolvedValue(
|
||||
fromPartial<JobDetail>({ ...mockJobDetail, outputs: undefined })
|
||||
)
|
||||
|
||||
await task.loadWorkflow(mockApp)
|
||||
|
||||
expect(setOutputsSpy).toHaveBeenCalledOnce()
|
||||
expect(setOutputsSpy).toHaveBeenCalledWith('1', outputs['1'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,10 +3,17 @@ import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import type { TaskOutput } from '@/schemas/apiSchema'
|
||||
import type { StatusWsMessageStatus, TaskOutput } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
|
||||
import {
|
||||
isInstantMode,
|
||||
isInstantRunningMode,
|
||||
ResultItemImpl,
|
||||
TaskItemImpl,
|
||||
useQueuePendingTaskCountStore,
|
||||
useQueueStore
|
||||
} from '@/stores/queueStore'
|
||||
|
||||
// Fixture factory for JobListItem
|
||||
function createJob(
|
||||
@@ -67,6 +74,72 @@ vi.mock('@/scripts/api', () => ({
|
||||
}))
|
||||
|
||||
describe('TaskItemImpl', () => {
|
||||
it('should use the raw URL as preview URL for non-images', () => {
|
||||
const output = new ResultItemImpl({
|
||||
nodeId: 'node-1',
|
||||
mediaType: 'video',
|
||||
filename: 'clip.webm',
|
||||
type: 'output',
|
||||
subfolder: ''
|
||||
})
|
||||
|
||||
expect(output.previewUrl).toBe(output.url)
|
||||
})
|
||||
|
||||
it('should recognize VHS mp4 and unsupported video formats', () => {
|
||||
const webm = new ResultItemImpl({
|
||||
nodeId: 'node-1',
|
||||
mediaType: 'gifs',
|
||||
filename: 'clip',
|
||||
type: 'output',
|
||||
subfolder: '',
|
||||
format: 'video/webm',
|
||||
frame_rate: 24
|
||||
})
|
||||
const mp4 = new ResultItemImpl({
|
||||
nodeId: 'node-1',
|
||||
mediaType: 'gifs',
|
||||
filename: 'clip',
|
||||
type: 'output',
|
||||
subfolder: '',
|
||||
format: 'video/mp4',
|
||||
frame_rate: 24
|
||||
})
|
||||
const avi = new ResultItemImpl({
|
||||
nodeId: 'node-1',
|
||||
mediaType: 'gifs',
|
||||
filename: 'clip',
|
||||
type: 'output',
|
||||
subfolder: '',
|
||||
format: 'video/avi',
|
||||
frame_rate: 24
|
||||
})
|
||||
|
||||
expect(webm.htmlVideoType).toBe('video/webm')
|
||||
expect(mp4.htmlVideoType).toBe('video/mp4')
|
||||
expect(avi.htmlVideoType).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should detect image media type without an image suffix', () => {
|
||||
const image = new ResultItemImpl({
|
||||
nodeId: 'node-1',
|
||||
mediaType: 'images',
|
||||
filename: 'generated',
|
||||
type: 'output',
|
||||
subfolder: ''
|
||||
})
|
||||
const audioFile = new ResultItemImpl({
|
||||
nodeId: 'node-1',
|
||||
mediaType: 'images',
|
||||
filename: 'generated.wav',
|
||||
type: 'output',
|
||||
subfolder: ''
|
||||
})
|
||||
|
||||
expect(image.isImage).toBe(true)
|
||||
expect(audioFile.isImage).toBe(false)
|
||||
})
|
||||
|
||||
it('should exclude animated from flatOutputs', () => {
|
||||
const job = createHistoryJob(0, 'job-id')
|
||||
const taskItem = new TaskItemImpl(job, {
|
||||
@@ -259,6 +332,38 @@ describe('TaskItemImpl', () => {
|
||||
expect(taskItem.executionError).toEqual(errorDetail)
|
||||
})
|
||||
})
|
||||
|
||||
it('should expose queue API task type for running tasks', () => {
|
||||
const task = new TaskItemImpl(createRunningJob(1, 'run-1'))
|
||||
|
||||
expect(task.apiTaskType).toBe('queue')
|
||||
})
|
||||
|
||||
it('should return empty flat outputs when outputs are missing', () => {
|
||||
const task = new TaskItemImpl(createHistoryJob(0, 'job-id'), undefined)
|
||||
|
||||
expect(task.calculateFlatOutputs()).toEqual([])
|
||||
})
|
||||
|
||||
it('should calculate execution time in seconds', () => {
|
||||
const task = new TaskItemImpl({
|
||||
...createHistoryJob(0, 'job-id'),
|
||||
execution_start_time: 1000,
|
||||
execution_end_time: 3500
|
||||
})
|
||||
|
||||
expect(task.executionStartTimestamp).toBe(1000)
|
||||
expect(task.executionEndTimestamp).toBe(3500)
|
||||
expect(task.executionTime).toBe(2500)
|
||||
expect(task.executionTimeInSeconds).toBe(2.5)
|
||||
})
|
||||
|
||||
it('should return undefined execution seconds without both timestamps', () => {
|
||||
const task = new TaskItemImpl(createHistoryJob(0, 'job-id'))
|
||||
|
||||
expect(task.executionTime).toBeUndefined()
|
||||
expect(task.executionTimeInSeconds).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useQueueStore', () => {
|
||||
@@ -314,6 +419,23 @@ describe('useQueueStore', () => {
|
||||
expect(store.pendingTasks[1].jobId).toBe('pend-1')
|
||||
})
|
||||
|
||||
it('should register workflow ids for active jobs', async () => {
|
||||
const executionStore = useExecutionStore()
|
||||
const registerSpy = vi.spyOn(
|
||||
executionStore,
|
||||
'registerJobWorkflowIdMapping'
|
||||
)
|
||||
mockGetQueue.mockResolvedValue({
|
||||
Running: [{ ...createRunningJob(1, 'run-1'), workflow_id: 'wf-1' }],
|
||||
Pending: []
|
||||
})
|
||||
mockGetHistory.mockResolvedValue([])
|
||||
|
||||
await store.update()
|
||||
|
||||
expect(registerSpy).toHaveBeenCalledWith('run-1', 'wf-1')
|
||||
})
|
||||
|
||||
it('should load history tasks from API', async () => {
|
||||
const historyJob1 = createHistoryJob(5, 'hist-1')
|
||||
const historyJob2 = createHistoryJob(4, 'hist-2')
|
||||
@@ -1115,3 +1237,43 @@ describe('useQueueStore', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useQueuePendingTaskCountStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('updates from status websocket messages', () => {
|
||||
const store = useQueuePendingTaskCountStore()
|
||||
|
||||
store.update(
|
||||
new CustomEvent<StatusWsMessageStatus>('status', {
|
||||
detail: { exec_info: { queue_remaining: 3 } }
|
||||
})
|
||||
)
|
||||
|
||||
expect(store.count).toBe(3)
|
||||
})
|
||||
|
||||
it('falls back to zero when status details are missing', () => {
|
||||
const store = useQueuePendingTaskCountStore()
|
||||
store.count = 3
|
||||
|
||||
store.update(new CustomEvent<StatusWsMessageStatus>('status'))
|
||||
|
||||
expect(store.count).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('queue mode helpers', () => {
|
||||
it('detect instant queue modes', () => {
|
||||
expect(isInstantMode('instant-idle')).toBe(true)
|
||||
expect(isInstantMode('instant-running')).toBe(true)
|
||||
expect(isInstantMode('change')).toBe(false)
|
||||
})
|
||||
|
||||
it('detect instant running mode', () => {
|
||||
expect(isInstantRunningMode('instant-running')).toBe(true)
|
||||
expect(isInstantRunningMode('instant-idle')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user