mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-05 13:10:24 +00:00
## Summary Fix multiple issues with promoted widget resolution in nested subgraphs, ensuring correct value propagation, slot matching, and rendering for deeply nested promoted widgets. ## Changes - **What**: Stabilize nested subgraph promoted widget resolution chain - Use deep source keys for promoted widget values in Vue rendering mode - Resolve effective widget options from the source widget instead of the promoted view - Stabilize slot resolution for nested promoted widgets - Preserve combo value rendering for promoted subgraph widgets - Prevent subgraph definition deletion while other nodes still reference the same type - Clean up unused exported resolution types ## Review Focus - `resolveConcretePromotedWidget.ts` — new recursive resolution logic for deeply nested promoted widgets - `useGraphNodeManager.ts` — option extraction now uses `effectiveWidget` for promoted widgets - `SubgraphNode.ts` — unpack no longer force-deletes definitions referenced by other nodes ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9282-fix-stabilize-nested-subgraph-promoted-widget-resolution-3146d73d365081208a4fe931bb7569cf) by [Unito](https://www.unito.io) --------- Co-authored-by: Amp <amp@ampcode.com> Co-authored-by: GitHub Action <action@github.com>
1464 lines
47 KiB
TypeScript
1464 lines
47 KiB
TypeScript
import { createTestingPinia } from '@pinia/testing'
|
|
import { setActivePinia } from 'pinia'
|
|
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
|
|
|
// Barrel import must come first to avoid circular dependency
|
|
// (promotedWidgetView → widgetMap → BaseWidget → LegacyWidget → barrel)
|
|
import {
|
|
CanvasPointer,
|
|
LGraphNode,
|
|
LiteGraph
|
|
} from '@/lib/litegraph/src/litegraph'
|
|
import type {
|
|
CanvasPointerEvent,
|
|
SubgraphNode
|
|
} from '@/lib/litegraph/src/litegraph'
|
|
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
|
|
|
import { createPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
|
|
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
|
|
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
|
|
import { usePromotionStore } from '@/stores/promotionStore'
|
|
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
|
|
|
import {
|
|
createTestSubgraph,
|
|
createTestSubgraphNode
|
|
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
|
|
|
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
|
useCanvasStore: () => ({})
|
|
}))
|
|
const mockDomWidgetStore = vi.hoisted(() => ({
|
|
widgetStates: new Map(),
|
|
setPositionOverride: vi.fn(),
|
|
clearPositionOverride: vi.fn()
|
|
}))
|
|
vi.mock('@/stores/domWidgetStore', () => ({
|
|
useDomWidgetStore: () => mockDomWidgetStore
|
|
}))
|
|
vi.mock('@/services/litegraphService', () => ({
|
|
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
|
}))
|
|
|
|
function setupSubgraph(
|
|
innerNodeCount: number = 0
|
|
): [SubgraphNode, LGraphNode[]] {
|
|
const subgraph = createTestSubgraph()
|
|
const subgraphNode = createTestSubgraphNode(subgraph)
|
|
subgraphNode._internalConfigureAfterSlots()
|
|
const graph = subgraphNode.graph!
|
|
graph.add(subgraphNode)
|
|
const innerNodes: LGraphNode[] = []
|
|
for (let i = 0; i < innerNodeCount; i++) {
|
|
const innerNode = new LGraphNode(`InnerNode${i}`)
|
|
subgraph.add(innerNode)
|
|
innerNodes.push(innerNode)
|
|
}
|
|
return [subgraphNode, innerNodes]
|
|
}
|
|
|
|
function setPromotions(
|
|
subgraphNode: SubgraphNode,
|
|
entries: [string, string][]
|
|
) {
|
|
usePromotionStore().setPromotions(
|
|
subgraphNode.rootGraph.id,
|
|
subgraphNode.id,
|
|
entries.map(([interiorNodeId, widgetName]) => ({
|
|
interiorNodeId,
|
|
widgetName
|
|
}))
|
|
)
|
|
}
|
|
|
|
function firstInnerNode(innerNodes: LGraphNode[]): LGraphNode {
|
|
const innerNode = innerNodes[0]
|
|
if (!innerNode) throw new Error('Expected at least one inner node')
|
|
return innerNode
|
|
}
|
|
|
|
describe(createPromotedWidgetView, () => {
|
|
beforeEach(() => {
|
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
|
mockDomWidgetStore.widgetStates.clear()
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
test('exposes sourceNodeId and sourceWidgetName', () => {
|
|
const [subgraphNode] = setupSubgraph()
|
|
const view = createPromotedWidgetView(subgraphNode, '42', 'myWidget')
|
|
expect(view.sourceNodeId).toBe('42')
|
|
expect(view.sourceWidgetName).toBe('myWidget')
|
|
})
|
|
|
|
test('name defaults to widgetName when no displayName given', () => {
|
|
const [subgraphNode] = setupSubgraph()
|
|
const view = createPromotedWidgetView(subgraphNode, '1', 'myWidget')
|
|
expect(view.name).toBe('myWidget')
|
|
})
|
|
|
|
test('name uses displayName when provided', () => {
|
|
const [subgraphNode] = setupSubgraph()
|
|
const view = createPromotedWidgetView(
|
|
subgraphNode,
|
|
'1',
|
|
'myWidget',
|
|
'Custom Label'
|
|
)
|
|
expect(view.name).toBe('Custom Label')
|
|
})
|
|
|
|
test('node getter returns the subgraphNode', () => {
|
|
const [subgraphNode] = setupSubgraph()
|
|
const view = createPromotedWidgetView(subgraphNode, '1', 'myWidget')
|
|
// node is defined via Object.defineProperty at runtime but not on the TS interface
|
|
expect((view as unknown as Record<string, unknown>).node).toBe(subgraphNode)
|
|
})
|
|
|
|
test('serialize is false', () => {
|
|
const [subgraphNode] = setupSubgraph()
|
|
const view = createPromotedWidgetView(subgraphNode, '1', 'myWidget')
|
|
expect(view.serialize).toBe(false)
|
|
})
|
|
|
|
test('computedDisabled defaults to false and accepts boolean values', () => {
|
|
const [subgraphNode] = setupSubgraph()
|
|
const view = createPromotedWidgetView(subgraphNode, '1', 'myWidget')
|
|
expect(view.computedDisabled).toBe(false)
|
|
view.computedDisabled = true
|
|
expect(view.computedDisabled).toBe(true)
|
|
})
|
|
|
|
test('computedDisabled treats undefined as false', () => {
|
|
const [subgraphNode] = setupSubgraph()
|
|
const view = createPromotedWidgetView(subgraphNode, '1', 'myWidget')
|
|
view.computedDisabled = true
|
|
view.computedDisabled = undefined
|
|
expect(view.computedDisabled).toBe(false)
|
|
})
|
|
|
|
test('positional properties are writable and independent', () => {
|
|
const [subgraphNode] = setupSubgraph()
|
|
const view = createPromotedWidgetView(subgraphNode, '1', 'myWidget')
|
|
expect(view.y).toBe(0)
|
|
|
|
view.y = 100
|
|
view.last_y = 90
|
|
view.computedHeight = 30
|
|
|
|
expect(view.y).toBe(100)
|
|
expect(view.last_y).toBe(90)
|
|
expect(view.computedHeight).toBe(30)
|
|
})
|
|
|
|
test('type delegates to interior widget', () => {
|
|
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
|
const innerNode = firstInnerNode(innerNodes)
|
|
innerNode.addWidget('combo', 'picker', 'a', () => {}, {
|
|
values: ['a', 'b']
|
|
})
|
|
const view = createPromotedWidgetView(
|
|
subgraphNode,
|
|
String(innerNode.id),
|
|
'picker'
|
|
)
|
|
expect(view.type).toBe('combo')
|
|
})
|
|
|
|
test('type falls back to button when interior widget is missing', () => {
|
|
const [subgraphNode] = setupSubgraph()
|
|
const view = createPromotedWidgetView(subgraphNode, '999', 'missing')
|
|
expect(view.type).toBe('button')
|
|
})
|
|
|
|
test('options delegates to interior widget', () => {
|
|
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
|
const innerNode = firstInnerNode(innerNodes)
|
|
const opts = { values: ['a', 'b'] as string[] }
|
|
innerNode.addWidget('combo', 'picker', 'a', () => {}, opts)
|
|
const view = createPromotedWidgetView(
|
|
subgraphNode,
|
|
String(innerNode.id),
|
|
'picker'
|
|
)
|
|
expect(view.options).toBe(opts)
|
|
})
|
|
|
|
test('options falls back to empty object when interior widget is missing', () => {
|
|
const [subgraphNode] = setupSubgraph()
|
|
const view = createPromotedWidgetView(subgraphNode, '999', 'missing')
|
|
expect(view.options).toEqual({})
|
|
})
|
|
|
|
test('linkedWidgets delegates to interior widget', () => {
|
|
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
|
const innerNode = firstInnerNode(innerNodes)
|
|
const seedWidget = innerNode.addWidget('number', 'seed', 1, () => {})
|
|
const controlWidget = innerNode.addWidget(
|
|
'combo',
|
|
'control_after_generate',
|
|
'randomize',
|
|
() => {},
|
|
{
|
|
values: ['fixed', 'increment', 'decrement', 'randomize']
|
|
}
|
|
)
|
|
seedWidget.linkedWidgets = [controlWidget]
|
|
|
|
const view = createPromotedWidgetView(
|
|
subgraphNode,
|
|
String(innerNode.id),
|
|
'seed'
|
|
)
|
|
|
|
expect(view.linkedWidgets).toBe(seedWidget.linkedWidgets)
|
|
expect(view.linkedWidgets?.[0].name).toBe('control_after_generate')
|
|
})
|
|
|
|
test('value is store-backed via widgetValueStore', () => {
|
|
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
|
const innerNode = firstInnerNode(innerNodes)
|
|
innerNode.addWidget('text', 'myWidget', 'initial', () => {})
|
|
const view = createPromotedWidgetView(
|
|
subgraphNode,
|
|
String(innerNode.id),
|
|
'myWidget'
|
|
)
|
|
|
|
// Value should read from the store (which was populated by addWidget)
|
|
expect(view.value).toBe('initial')
|
|
|
|
// Setting value through the view updates the store
|
|
view.value = 'updated'
|
|
expect(view.value).toBe('updated')
|
|
|
|
// The interior widget reads from the same store
|
|
expect(innerNode.widgets![0].value).toBe('updated')
|
|
})
|
|
|
|
test('value falls back to interior widget when store entry is missing', () => {
|
|
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
|
const innerNode = firstInnerNode(innerNodes)
|
|
const fallbackWidgetShape = {
|
|
name: 'myWidget',
|
|
type: 'text',
|
|
value: 'initial',
|
|
options: {}
|
|
} satisfies Pick<IBaseWidget, 'name' | 'type' | 'value' | 'options'>
|
|
const fallbackWidget = fallbackWidgetShape as unknown as IBaseWidget
|
|
innerNode.widgets = [fallbackWidget]
|
|
|
|
const widgetValueStore = useWidgetValueStore()
|
|
vi.spyOn(widgetValueStore, 'getWidget').mockReturnValue(undefined)
|
|
|
|
const view = createPromotedWidgetView(
|
|
subgraphNode,
|
|
String(innerNode.id),
|
|
'myWidget'
|
|
)
|
|
|
|
expect(view.value).toBe('initial')
|
|
view.value = 'updated'
|
|
expect(fallbackWidget.value).toBe('updated')
|
|
})
|
|
|
|
test('label falls back to displayName then widgetName', () => {
|
|
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
|
const innerNode = firstInnerNode(innerNodes)
|
|
innerNode.addWidget('text', 'myWidget', 'val', () => {})
|
|
const store = useWidgetValueStore()
|
|
const bareId = String(innerNode.id)
|
|
|
|
// No displayName → falls back to widgetName
|
|
const view1 = createPromotedWidgetView(subgraphNode, bareId, 'myWidget')
|
|
// Store label is undefined → falls back to displayName/widgetName
|
|
const state = store.getWidget(
|
|
subgraphNode.rootGraph.id,
|
|
bareId as never,
|
|
'myWidget'
|
|
)
|
|
state!.label = undefined
|
|
expect(view1.label).toBe('myWidget')
|
|
|
|
// With displayName → falls back to displayName
|
|
const view2 = createPromotedWidgetView(
|
|
subgraphNode,
|
|
bareId,
|
|
'myWidget',
|
|
'Custom'
|
|
)
|
|
expect(view2.label).toBe('Custom')
|
|
})
|
|
|
|
test('callback forwards to interior widget', () => {
|
|
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
|
const innerNode = firstInnerNode(innerNodes)
|
|
const callbackSpy = vi.fn()
|
|
innerNode.addWidget('text', 'myWidget', 'val', callbackSpy)
|
|
const view = createPromotedWidgetView(
|
|
subgraphNode,
|
|
String(innerNode.id),
|
|
'myWidget'
|
|
)
|
|
|
|
view.callback!('newVal')
|
|
expect(callbackSpy).toHaveBeenCalled()
|
|
expect(callbackSpy.mock.calls[0][0]).toBe('newVal')
|
|
})
|
|
|
|
test('callback is safe when interior widget is missing', () => {
|
|
const [subgraphNode] = setupSubgraph()
|
|
const view = createPromotedWidgetView(subgraphNode, '999', 'missing')
|
|
expect(() => view.callback!('val')).not.toThrow()
|
|
})
|
|
|
|
test('computeSize delegates to interior widget computeSize', () => {
|
|
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
|
const innerNode = firstInnerNode(innerNodes)
|
|
const widget = innerNode.addWidget('text', 'legacySize', 'val', () => {})
|
|
const computeSize = vi.fn<(width?: number) => [number, number]>(
|
|
(width?: number) => [width ?? 0, 37]
|
|
)
|
|
widget.computeSize = computeSize
|
|
|
|
const view = createPromotedWidgetView(
|
|
subgraphNode,
|
|
String(innerNode.id),
|
|
'legacySize'
|
|
)
|
|
|
|
expect(typeof view.computeSize).toBe('function')
|
|
expect(view.computeSize?.(210)).toEqual([210, 37])
|
|
expect(computeSize).toHaveBeenCalledWith(210)
|
|
})
|
|
|
|
test('onPointerDown falls back to legacy mouse callback', () => {
|
|
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
|
subgraphNode.pos = [10, 20]
|
|
const innerNode = firstInnerNode(innerNodes)
|
|
const mouse = vi.fn(() => true)
|
|
const legacyWidget = {
|
|
name: 'legacyMouse',
|
|
type: 'mystery-legacy',
|
|
value: 'val',
|
|
options: {},
|
|
mouse
|
|
} as unknown as IBaseWidget
|
|
innerNode.widgets = [legacyWidget]
|
|
|
|
const view = createPromotedWidgetView(
|
|
subgraphNode,
|
|
String(innerNode.id),
|
|
'legacyMouse'
|
|
)
|
|
|
|
const pointer = new CanvasPointer(document.createElement('div'))
|
|
pointer.eDown = {
|
|
canvasX: 110,
|
|
canvasY: 120,
|
|
deltaX: 0,
|
|
deltaY: 0,
|
|
safeOffsetX: 0,
|
|
safeOffsetY: 0
|
|
} as CanvasPointerEvent
|
|
|
|
const handled = view.onPointerDown?.(
|
|
pointer,
|
|
subgraphNode,
|
|
{} as Parameters<NonNullable<typeof view.onPointerDown>>[2]
|
|
)
|
|
|
|
expect(handled).toBe(true)
|
|
expect(mouse).toHaveBeenCalledWith(pointer.eDown, [100, 100], subgraphNode)
|
|
|
|
pointer.eUp = {
|
|
canvasX: 130,
|
|
canvasY: 140,
|
|
deltaX: 0,
|
|
deltaY: 0,
|
|
safeOffsetX: 0,
|
|
safeOffsetY: 0
|
|
} as CanvasPointerEvent
|
|
pointer.finally?.()
|
|
|
|
expect(mouse).toHaveBeenCalledWith(pointer.eUp, [120, 120], subgraphNode)
|
|
})
|
|
})
|
|
|
|
describe('SubgraphNode.widgets getter', () => {
|
|
beforeEach(() => {
|
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
|
})
|
|
|
|
test('defers promotions while subgraph node id is -1 and flushes on add', () => {
|
|
const subgraph = createTestSubgraph({
|
|
inputs: [{ name: 'picker_input', type: '*' }]
|
|
})
|
|
const subgraphNode = createTestSubgraphNode(subgraph, { id: -1 })
|
|
|
|
const innerNode = new LGraphNode('InnerNode')
|
|
const innerInput = innerNode.addInput('picker_input', '*')
|
|
innerNode.addWidget('combo', 'picker', 'a', () => {}, {
|
|
values: ['a', 'b']
|
|
})
|
|
innerInput.widget = { name: 'picker' }
|
|
subgraph.add(innerNode)
|
|
|
|
subgraph.inputNode.slots[0].connect(innerInput, innerNode)
|
|
subgraphNode._internalConfigureAfterSlots()
|
|
|
|
const store = usePromotionStore()
|
|
expect(store.getPromotions(subgraphNode.rootGraph.id, -1)).toStrictEqual([])
|
|
|
|
subgraphNode.graph?.add(subgraphNode)
|
|
expect(subgraphNode.id).not.toBe(-1)
|
|
expect(
|
|
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
|
|
).toStrictEqual([
|
|
{
|
|
interiorNodeId: String(innerNode.id),
|
|
widgetName: 'picker'
|
|
}
|
|
])
|
|
})
|
|
|
|
test('rebinds one input to latest source without stale disconnected views', () => {
|
|
const subgraph = createTestSubgraph({
|
|
inputs: [{ name: 'picker_input', type: '*' }]
|
|
})
|
|
const subgraphNode = createTestSubgraphNode(subgraph, { id: 41 })
|
|
subgraphNode.graph?.add(subgraphNode)
|
|
|
|
const firstNode = new LGraphNode('FirstNode')
|
|
const firstInput = firstNode.addInput('picker_input', '*')
|
|
firstNode.addWidget('combo', 'picker', 'a', () => {}, {
|
|
values: ['a', 'b']
|
|
})
|
|
firstInput.widget = { name: 'picker' }
|
|
subgraph.add(firstNode)
|
|
const subgraphInputSlot = subgraph.inputNode.slots[0]
|
|
subgraphInputSlot.connect(firstInput, firstNode)
|
|
|
|
// Mirror user-driven rebind behavior: move the slot connection from first
|
|
// source to second source, rather than keeping both links connected.
|
|
subgraphInputSlot.disconnect()
|
|
|
|
const secondNode = new LGraphNode('SecondNode')
|
|
const secondInput = secondNode.addInput('picker_input', '*')
|
|
secondNode.addWidget('combo', 'picker', 'b', () => {}, {
|
|
values: ['a', 'b']
|
|
})
|
|
secondInput.widget = { name: 'picker' }
|
|
subgraph.add(secondNode)
|
|
subgraphInputSlot.connect(secondInput, secondNode)
|
|
|
|
const promotions = usePromotionStore().getPromotions(
|
|
subgraphNode.rootGraph.id,
|
|
subgraphNode.id
|
|
)
|
|
|
|
expect(promotions).toHaveLength(1)
|
|
expect(promotions[0]).toStrictEqual({
|
|
interiorNodeId: String(secondNode.id),
|
|
widgetName: 'picker'
|
|
})
|
|
expect(subgraphNode.widgets).toHaveLength(1)
|
|
expect(subgraphNode.widgets[0].value).toBe('b')
|
|
})
|
|
|
|
test('preserves distinct promoted display names when two inputs share one concrete widget name', () => {
|
|
const subgraph = createTestSubgraph({
|
|
inputs: [
|
|
{ name: 'strength_model', type: '*' },
|
|
{ name: 'strength_model_1', type: '*' }
|
|
]
|
|
})
|
|
const subgraphNode = createTestSubgraphNode(subgraph, { id: 90 })
|
|
subgraphNode.graph?.add(subgraphNode)
|
|
|
|
const innerNode = new LGraphNode('InnerNumberNode')
|
|
const firstInput = innerNode.addInput('strength_model', '*')
|
|
const secondInput = innerNode.addInput('strength_model_1', '*')
|
|
innerNode.addWidget('number', 'strength_model', 1, () => {})
|
|
firstInput.widget = { name: 'strength_model' }
|
|
secondInput.widget = { name: 'strength_model' }
|
|
subgraph.add(innerNode)
|
|
|
|
subgraph.inputNode.slots[0].connect(firstInput, innerNode)
|
|
subgraph.inputNode.slots[1].connect(secondInput, innerNode)
|
|
|
|
expect(subgraphNode.widgets).toHaveLength(2)
|
|
expect(subgraphNode.widgets.map((widget) => widget.name)).toStrictEqual([
|
|
'strength_model',
|
|
'strength_model_1'
|
|
])
|
|
})
|
|
|
|
test('returns empty array when no proxyWidgets', () => {
|
|
const [subgraphNode] = setupSubgraph()
|
|
expect(subgraphNode.widgets).toEqual([])
|
|
})
|
|
|
|
test('widgets getter prefers live linked entries over stale store entries', () => {
|
|
const subgraph = createTestSubgraph({
|
|
inputs: [{ name: 'widgetA', type: '*' }]
|
|
})
|
|
const subgraphNode = createTestSubgraphNode(subgraph, { id: 91 })
|
|
subgraphNode.graph?.add(subgraphNode)
|
|
|
|
const liveNode = new LGraphNode('LiveNode')
|
|
const liveInput = liveNode.addInput('widgetA', '*')
|
|
liveNode.addWidget('text', 'widgetA', 'a', () => {})
|
|
liveInput.widget = { name: 'widgetA' }
|
|
subgraph.add(liveNode)
|
|
subgraph.inputNode.slots[0].connect(liveInput, liveNode)
|
|
|
|
setPromotions(subgraphNode, [
|
|
[String(liveNode.id), 'widgetA'],
|
|
['9999', 'missingWidget']
|
|
])
|
|
|
|
expect(subgraphNode.widgets).toHaveLength(1)
|
|
expect(subgraphNode.widgets[0].name).toBe('widgetA')
|
|
})
|
|
|
|
test('partial linked coverage does not destructively prune unresolved store promotions', () => {
|
|
const subgraph = createTestSubgraph({
|
|
inputs: [
|
|
{ name: 'widgetA', type: '*' },
|
|
{ name: 'widgetB', type: '*' }
|
|
]
|
|
})
|
|
const subgraphNode = createTestSubgraphNode(subgraph, { id: 92 })
|
|
subgraphNode.graph?.add(subgraphNode)
|
|
|
|
const liveNode = new LGraphNode('LiveNode')
|
|
const liveInput = liveNode.addInput('widgetA', '*')
|
|
liveNode.addWidget('text', 'widgetA', 'a', () => {})
|
|
liveInput.widget = { name: 'widgetA' }
|
|
subgraph.add(liveNode)
|
|
subgraph.inputNode.slots[0].connect(liveInput, liveNode)
|
|
|
|
setPromotions(subgraphNode, [
|
|
[String(liveNode.id), 'widgetA'],
|
|
['9999', 'widgetB']
|
|
])
|
|
|
|
// Trigger widgets getter reconciliation in partial-linked state.
|
|
void subgraphNode.widgets
|
|
|
|
const promotions = usePromotionStore().getPromotions(
|
|
subgraphNode.rootGraph.id,
|
|
subgraphNode.id
|
|
)
|
|
expect(promotions).toStrictEqual([
|
|
{ interiorNodeId: String(liveNode.id), widgetName: 'widgetA' },
|
|
{ interiorNodeId: '9999', widgetName: 'widgetB' }
|
|
])
|
|
})
|
|
|
|
test('caches view objects across getter calls (stable references)', () => {
|
|
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
|
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
|
setPromotions(subgraphNode, [['1', 'widgetA']])
|
|
|
|
const first = subgraphNode.widgets[0]
|
|
const second = subgraphNode.widgets[0]
|
|
expect(first).toBe(second)
|
|
})
|
|
|
|
test('memoizes promotion list by reference', () => {
|
|
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
|
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
|
|
|
setPromotions(subgraphNode, [['1', 'widgetA']])
|
|
|
|
const views1 = subgraphNode.widgets
|
|
expect(views1).toHaveLength(1)
|
|
|
|
// Same reference → same result (memoized)
|
|
const views2 = subgraphNode.widgets
|
|
expect(views2[0]).toBe(views1[0])
|
|
|
|
// New store value with same content → same cached view object
|
|
setPromotions(subgraphNode, [['1', 'widgetA']])
|
|
const views3 = subgraphNode.widgets
|
|
expect(views3[0]).toBe(views1[0])
|
|
})
|
|
|
|
test('cleans stale cache entries when promotions shrink', () => {
|
|
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
|
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
|
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
|
|
|
|
setPromotions(subgraphNode, [
|
|
['1', 'widgetA'],
|
|
['1', 'widgetB']
|
|
])
|
|
expect(subgraphNode.widgets).toHaveLength(2)
|
|
const viewA = subgraphNode.widgets[0]
|
|
|
|
// Remove widgetA from promotion list
|
|
setPromotions(subgraphNode, [['1', 'widgetB']])
|
|
expect(subgraphNode.widgets).toHaveLength(1)
|
|
expect(subgraphNode.widgets[0].name).toBe('widgetB')
|
|
|
|
// Re-adding widgetA creates a new view (old one was cleaned)
|
|
setPromotions(subgraphNode, [
|
|
['1', 'widgetB'],
|
|
['1', 'widgetA']
|
|
])
|
|
const newViewA = subgraphNode.widgets[1]
|
|
expect(newViewA).not.toBe(viewA)
|
|
})
|
|
|
|
test('deduplicates entries with same nodeId:widgetName', () => {
|
|
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
|
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
|
|
|
setPromotions(subgraphNode, [
|
|
['1', 'widgetA'],
|
|
['1', 'widgetA']
|
|
])
|
|
expect(subgraphNode.widgets).toHaveLength(1)
|
|
})
|
|
|
|
test('setter is a no-op', () => {
|
|
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
|
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
|
setPromotions(subgraphNode, [['1', 'widgetA']])
|
|
|
|
// Assigning to widgets does nothing
|
|
subgraphNode.widgets = []
|
|
expect(subgraphNode.widgets).toHaveLength(1)
|
|
})
|
|
|
|
test('migrates legacy -1 entries via _resolveLegacyEntry', () => {
|
|
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
|
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
|
|
|
// Simulate a slot-connected widget so legacy resolution works
|
|
const subgraph = subgraphNode.subgraph
|
|
subgraph.addInput('stringWidget', '*')
|
|
subgraphNode._internalConfigureAfterSlots()
|
|
|
|
// The _internalConfigureAfterSlots would have set up the slot-connected
|
|
// widget via _setWidget if there's a link. For unit testing legacy
|
|
// migration, we need to set up the input._widget manually.
|
|
const input = subgraphNode.inputs.find((i) => i.name === 'stringWidget')
|
|
if (input) {
|
|
input._widget = createPromotedWidgetView(
|
|
subgraphNode,
|
|
String(innerNodes[0].id),
|
|
'stringWidget'
|
|
)
|
|
}
|
|
|
|
// Set legacy -1 format via properties and re-run hydration
|
|
subgraphNode.properties.proxyWidgets = [['-1', 'stringWidget']]
|
|
subgraphNode._internalConfigureAfterSlots()
|
|
|
|
// Migration should have rewritten the store with resolved IDs
|
|
const entries = usePromotionStore().getPromotions(
|
|
subgraphNode.rootGraph.id,
|
|
subgraphNode.id
|
|
)
|
|
expect(entries).toStrictEqual([
|
|
{
|
|
interiorNodeId: String(innerNodes[0].id),
|
|
widgetName: 'stringWidget'
|
|
}
|
|
])
|
|
})
|
|
|
|
test('hydrate promotions from serialize/configure round-trip', () => {
|
|
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
|
const innerNode = firstInnerNode(innerNodes)
|
|
innerNode.addWidget('text', 'widgetA', 'a', () => {})
|
|
|
|
setPromotions(subgraphNode, [[String(innerNode.id), 'widgetA']])
|
|
const serialized = subgraphNode.serialize()
|
|
|
|
const restoredNode = createTestSubgraphNode(subgraphNode.subgraph, {
|
|
id: 99
|
|
})
|
|
restoredNode.configure({
|
|
...serialized,
|
|
id: restoredNode.id,
|
|
type: subgraphNode.subgraph.id
|
|
})
|
|
|
|
const restoredEntries = usePromotionStore().getPromotions(
|
|
restoredNode.rootGraph.id,
|
|
restoredNode.id
|
|
)
|
|
expect(restoredEntries).toStrictEqual([
|
|
{
|
|
interiorNodeId: String(innerNode.id),
|
|
widgetName: 'widgetA'
|
|
}
|
|
])
|
|
})
|
|
|
|
test('clone output preserves proxyWidgets for promotion hydration', () => {
|
|
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
|
const innerNode = firstInnerNode(innerNodes)
|
|
innerNode.addWidget('text', 'widgetA', 'a', () => {})
|
|
|
|
setPromotions(subgraphNode, [[String(innerNode.id), 'widgetA']])
|
|
|
|
const createNodeSpy = vi
|
|
.spyOn(LiteGraph, 'createNode')
|
|
.mockImplementation(() =>
|
|
createTestSubgraphNode(subgraphNode.subgraph, { id: 999 })
|
|
)
|
|
|
|
const clonedNode = subgraphNode.clone()
|
|
expect(clonedNode).toBeTruthy()
|
|
createNodeSpy.mockRestore()
|
|
if (!clonedNode) throw new Error('Expected clone to return a node')
|
|
|
|
const clonedSerialized = clonedNode.serialize()
|
|
expect(clonedSerialized.properties?.proxyWidgets).toStrictEqual([
|
|
[String(innerNode.id), 'widgetA']
|
|
])
|
|
|
|
const hydratedClone = createTestSubgraphNode(subgraphNode.subgraph, {
|
|
id: 100
|
|
})
|
|
hydratedClone.configure({
|
|
...clonedSerialized,
|
|
id: hydratedClone.id,
|
|
type: subgraphNode.subgraph.id
|
|
})
|
|
|
|
const hydratedEntries = usePromotionStore().getPromotions(
|
|
hydratedClone.rootGraph.id,
|
|
hydratedClone.id
|
|
)
|
|
expect(hydratedEntries).toStrictEqual([
|
|
{
|
|
interiorNodeId: String(innerNode.id),
|
|
widgetName: 'widgetA'
|
|
}
|
|
])
|
|
})
|
|
})
|
|
|
|
describe('widgets getter caching', () => {
|
|
beforeEach(() => {
|
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
|
})
|
|
|
|
test('preserves view identities when promotion order changes', () => {
|
|
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
|
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
|
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
|
|
setPromotions(subgraphNode, [
|
|
['1', 'widgetA'],
|
|
['1', 'widgetB']
|
|
])
|
|
|
|
const [viewA, viewB] = subgraphNode.widgets
|
|
|
|
setPromotions(subgraphNode, [
|
|
['1', 'widgetB'],
|
|
['1', 'widgetA']
|
|
])
|
|
|
|
expect(subgraphNode.widgets[0]).toBe(viewB)
|
|
expect(subgraphNode.widgets[1]).toBe(viewA)
|
|
})
|
|
|
|
test('deduplicates by key while preserving first-occurrence order', () => {
|
|
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
|
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
|
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
|
|
|
|
setPromotions(subgraphNode, [
|
|
['1', 'widgetB'],
|
|
['1', 'widgetA'],
|
|
['1', 'widgetB'],
|
|
['1', 'widgetA']
|
|
])
|
|
|
|
expect(subgraphNode.widgets).toHaveLength(2)
|
|
expect(subgraphNode.widgets[0].name).toBe('widgetB')
|
|
expect(subgraphNode.widgets[1].name).toBe('widgetA')
|
|
})
|
|
|
|
test('returns same array reference when promotions unchanged', () => {
|
|
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
|
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
|
setPromotions(subgraphNode, [['1', 'widgetA']])
|
|
|
|
const result1 = subgraphNode.widgets
|
|
const result2 = subgraphNode.widgets
|
|
expect(result1).toBe(result2)
|
|
})
|
|
|
|
test('returns new array after promotion change', () => {
|
|
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
|
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
|
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
|
|
setPromotions(subgraphNode, [['1', 'widgetA']])
|
|
|
|
const result1 = subgraphNode.widgets
|
|
|
|
setPromotions(subgraphNode, [
|
|
['1', 'widgetA'],
|
|
['1', 'widgetB']
|
|
])
|
|
const result2 = subgraphNode.widgets
|
|
|
|
expect(result1).not.toBe(result2)
|
|
expect(result2).toHaveLength(2)
|
|
})
|
|
|
|
test('invalidates cache on removeWidget', () => {
|
|
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
|
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
|
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
|
|
setPromotions(subgraphNode, [
|
|
['1', 'widgetA'],
|
|
['1', 'widgetB']
|
|
])
|
|
|
|
const result1 = subgraphNode.widgets
|
|
expect(result1).toHaveLength(2)
|
|
|
|
subgraphNode.removeWidget(result1[0])
|
|
const result2 = subgraphNode.widgets
|
|
expect(result2).toHaveLength(1)
|
|
expect(result1).not.toBe(result2)
|
|
})
|
|
})
|
|
|
|
describe('promote/demote cycle', () => {
|
|
beforeEach(() => {
|
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
|
})
|
|
|
|
test('promoting adds to store and widgets reflects it', () => {
|
|
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
|
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
|
|
|
expect(subgraphNode.widgets).toHaveLength(0)
|
|
|
|
setPromotions(subgraphNode, [['1', 'widgetA']])
|
|
expect(subgraphNode.widgets).toHaveLength(1)
|
|
const view = subgraphNode.widgets[0] as PromotedWidgetView
|
|
expect(view.sourceNodeId).toBe('1')
|
|
expect(view.sourceWidgetName).toBe('widgetA')
|
|
})
|
|
|
|
test('demoting via removeWidget removes from store', () => {
|
|
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
|
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
|
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
|
|
setPromotions(subgraphNode, [
|
|
['1', 'widgetA'],
|
|
['1', 'widgetB']
|
|
])
|
|
|
|
const viewA = subgraphNode.widgets[0]
|
|
subgraphNode.removeWidget(viewA)
|
|
|
|
expect(subgraphNode.widgets).toHaveLength(1)
|
|
expect(subgraphNode.widgets[0].name).toBe('widgetB')
|
|
const entries = usePromotionStore().getPromotions(
|
|
subgraphNode.rootGraph.id,
|
|
subgraphNode.id
|
|
)
|
|
expect(entries).toStrictEqual([
|
|
{ interiorNodeId: '1', widgetName: 'widgetB' }
|
|
])
|
|
})
|
|
|
|
test('full promote → demote → re-promote cycle', () => {
|
|
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
|
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
|
|
|
// Promote
|
|
setPromotions(subgraphNode, [['1', 'widgetA']])
|
|
expect(subgraphNode.widgets).toHaveLength(1)
|
|
const view1 = subgraphNode.widgets[0]
|
|
|
|
// Demote
|
|
subgraphNode.removeWidget(view1)
|
|
expect(subgraphNode.widgets).toHaveLength(0)
|
|
|
|
// Re-promote — creates a new view since the cache was cleared
|
|
setPromotions(subgraphNode, [['1', 'widgetA']])
|
|
expect(subgraphNode.widgets).toHaveLength(1)
|
|
expect(subgraphNode.widgets[0]).not.toBe(view1)
|
|
expect(
|
|
(subgraphNode.widgets[0] as PromotedWidgetView).sourceWidgetName
|
|
).toBe('widgetA')
|
|
})
|
|
})
|
|
|
|
describe('disconnected state', () => {
|
|
beforeEach(() => {
|
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
|
})
|
|
|
|
test('view resolves type when interior widget exists', () => {
|
|
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
|
innerNodes[0].addWidget('number', 'numWidget', 42, () => {})
|
|
setPromotions(subgraphNode, [['1', 'numWidget']])
|
|
|
|
expect(subgraphNode.widgets[0].type).toBe('number')
|
|
})
|
|
|
|
test('keeps promoted entry as disconnected when interior node is removed', () => {
|
|
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
|
innerNodes[0].addWidget('text', 'myWidget', 'val', () => {})
|
|
setPromotions(subgraphNode, [['1', 'myWidget']])
|
|
|
|
expect(subgraphNode.widgets[0].type).toBe('text')
|
|
|
|
// Remove the interior node from the subgraph
|
|
subgraphNode.subgraph.remove(innerNodes[0])
|
|
expect(subgraphNode.widgets).toHaveLength(1)
|
|
expect(subgraphNode.widgets[0].type).toBe('button')
|
|
})
|
|
|
|
test('view recovers when interior widget is re-added', () => {
|
|
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
|
innerNodes[0].addWidget('text', 'myWidget', 'val', () => {})
|
|
setPromotions(subgraphNode, [['1', 'myWidget']])
|
|
|
|
// Remove widget
|
|
innerNodes[0].widgets!.pop()
|
|
expect(subgraphNode.widgets[0].type).toBe('button')
|
|
|
|
// Re-add widget
|
|
innerNodes[0].addWidget('text', 'myWidget', 'val', () => {})
|
|
expect(subgraphNode.widgets[0].type).toBe('text')
|
|
})
|
|
|
|
test('keeps missing source-node promotions as disconnected views', () => {
|
|
const [subgraphNode] = setupSubgraph()
|
|
setPromotions(subgraphNode, [['999', 'ghost']])
|
|
expect(subgraphNode.widgets).toHaveLength(1)
|
|
expect(subgraphNode.widgets[0].type).toBe('button')
|
|
})
|
|
})
|
|
|
|
function createFakeCanvasContext() {
|
|
return new Proxy({} as CanvasRenderingContext2D, {
|
|
get: () => vi.fn(() => ({ width: 10 }))
|
|
})
|
|
}
|
|
|
|
function createInspectableCanvasContext(fillText = vi.fn()) {
|
|
const fallback = vi.fn()
|
|
return new Proxy(
|
|
{
|
|
fillText,
|
|
beginPath: vi.fn(),
|
|
roundRect: vi.fn(),
|
|
rect: vi.fn(),
|
|
fill: vi.fn(),
|
|
stroke: vi.fn(),
|
|
moveTo: vi.fn(),
|
|
lineTo: vi.fn(),
|
|
arc: vi.fn(),
|
|
measureText: (text: string) => ({ width: text.length * 8 }),
|
|
fillStyle: '#fff',
|
|
strokeStyle: '#fff',
|
|
textAlign: 'left',
|
|
globalAlpha: 1,
|
|
lineWidth: 1
|
|
} as Record<string, unknown>,
|
|
{
|
|
get(target, key) {
|
|
if (typeof key === 'string' && key in target)
|
|
return target[key as keyof typeof target]
|
|
return fallback
|
|
}
|
|
}
|
|
) as unknown as CanvasRenderingContext2D
|
|
}
|
|
|
|
function createTwoLevelNestedSubgraph() {
|
|
const subgraphA = createTestSubgraph({
|
|
inputs: [{ name: 'a_input', type: '*' }]
|
|
})
|
|
const innerNode = new LGraphNode('InnerComboNode')
|
|
const innerInput = innerNode.addInput('picker_input', '*')
|
|
const comboWidget = innerNode.addWidget('combo', 'picker', 'a', () => {}, {
|
|
values: ['a', 'b']
|
|
})
|
|
innerInput.widget = { name: 'picker' }
|
|
subgraphA.add(innerNode)
|
|
subgraphA.inputNode.slots[0].connect(innerInput, innerNode)
|
|
|
|
const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 11 })
|
|
|
|
const subgraphB = createTestSubgraph({
|
|
inputs: [{ name: 'b_input', type: '*' }]
|
|
})
|
|
subgraphB.add(subgraphNodeA)
|
|
subgraphNodeA._internalConfigureAfterSlots()
|
|
subgraphB.inputNode.slots[0].connect(subgraphNodeA.inputs[0], subgraphNodeA)
|
|
|
|
const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 22 })
|
|
return { innerNode, comboWidget, subgraphNodeB }
|
|
}
|
|
|
|
describe('promoted combo rendering', () => {
|
|
beforeEach(() => {
|
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
|
})
|
|
|
|
test('draw shows value even when interior combo is computedDisabled', () => {
|
|
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
|
const innerNode = firstInnerNode(innerNodes)
|
|
const comboWidget = innerNode.addWidget('combo', 'picker', 'a', () => {}, {
|
|
values: ['a', 'b']
|
|
})
|
|
|
|
// Simulates source widgets connected to subgraph inputs.
|
|
comboWidget.computedDisabled = true
|
|
setPromotions(subgraphNode, [[String(innerNode.id), 'picker']])
|
|
|
|
const fillText = vi.fn()
|
|
const ctx = createInspectableCanvasContext(fillText)
|
|
subgraphNode.widgets[0].draw?.(
|
|
ctx,
|
|
subgraphNode,
|
|
260,
|
|
0,
|
|
LiteGraph.NODE_WIDGET_HEIGHT,
|
|
false
|
|
)
|
|
|
|
const renderedText = fillText.mock.calls.map((call) => call[0])
|
|
expect(renderedText).toContain('a')
|
|
})
|
|
|
|
test('draw shows value through two input-based promotion layers', () => {
|
|
const { comboWidget, subgraphNodeB } = createTwoLevelNestedSubgraph()
|
|
comboWidget.computedDisabled = true
|
|
const fillText = vi.fn()
|
|
const ctx = createInspectableCanvasContext(fillText)
|
|
|
|
subgraphNodeB.widgets[0].draw?.(
|
|
ctx,
|
|
subgraphNodeB,
|
|
260,
|
|
0,
|
|
LiteGraph.NODE_WIDGET_HEIGHT,
|
|
false
|
|
)
|
|
|
|
const renderedText = fillText.mock.calls.map((call) => call[0])
|
|
expect(renderedText).toContain('a')
|
|
})
|
|
|
|
test('value updates propagate through two promoted input layers', () => {
|
|
const { comboWidget, subgraphNodeB } = createTwoLevelNestedSubgraph()
|
|
comboWidget.computedDisabled = true
|
|
const promotedWidget = subgraphNodeB.widgets[0]
|
|
|
|
expect(promotedWidget.value).toBe('a')
|
|
promotedWidget.value = 'b'
|
|
expect(comboWidget.value).toBe('b')
|
|
|
|
const fillText = vi.fn()
|
|
const ctx = createInspectableCanvasContext(fillText)
|
|
promotedWidget.draw?.(
|
|
ctx,
|
|
subgraphNodeB,
|
|
260,
|
|
0,
|
|
LiteGraph.NODE_WIDGET_HEIGHT,
|
|
false
|
|
)
|
|
|
|
const renderedText = fillText.mock.calls.map((call) => call[0])
|
|
expect(renderedText).toContain('b')
|
|
})
|
|
|
|
test('draw projection recovers after transient button fallback in nested promotion', () => {
|
|
const { innerNode, subgraphNodeB } = createTwoLevelNestedSubgraph()
|
|
const promotedWidget = subgraphNodeB.widgets[0]
|
|
|
|
// Force a transient disconnect to project a fallback widget once.
|
|
innerNode.widgets = []
|
|
promotedWidget.draw?.(
|
|
createInspectableCanvasContext(),
|
|
subgraphNodeB,
|
|
260,
|
|
0,
|
|
LiteGraph.NODE_WIDGET_HEIGHT,
|
|
false
|
|
)
|
|
|
|
// Restore the concrete widget and verify draw reflects recovery.
|
|
innerNode.addWidget('combo', 'picker', 'a', () => {}, {
|
|
values: ['a', 'b']
|
|
})
|
|
const fillText = vi.fn()
|
|
promotedWidget.draw?.(
|
|
createInspectableCanvasContext(fillText),
|
|
subgraphNodeB,
|
|
260,
|
|
0,
|
|
LiteGraph.NODE_WIDGET_HEIGHT,
|
|
false
|
|
)
|
|
|
|
const renderedText = fillText.mock.calls.map((call) => call[0])
|
|
expect(renderedText).toContain('a')
|
|
})
|
|
|
|
test('state lookup behavior resolves to deepest promoted widget source', () => {
|
|
const { comboWidget, subgraphNodeB } = createTwoLevelNestedSubgraph()
|
|
|
|
const promotedWidget = subgraphNodeB.widgets[0]
|
|
expect(promotedWidget.value).toBe('a')
|
|
|
|
comboWidget.value = 'b'
|
|
expect(promotedWidget.value).toBe('b')
|
|
})
|
|
|
|
test('state lookup does not use promotion store fallback when intermediate view is unavailable', () => {
|
|
const subgraphA = createTestSubgraph({
|
|
inputs: [{ name: 'strength_model', type: '*' }]
|
|
})
|
|
const innerNode = new LGraphNode('InnerNumberNode')
|
|
const innerInput = innerNode.addInput('strength_model', '*')
|
|
innerNode.addWidget('number', 'strength_model', 1, () => {})
|
|
innerInput.widget = { name: 'strength_model' }
|
|
subgraphA.add(innerNode)
|
|
subgraphA.inputNode.slots[0].connect(innerInput, innerNode)
|
|
|
|
const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 47 })
|
|
|
|
const subgraphB = createTestSubgraph({
|
|
inputs: [{ name: 'strength_model', type: '*' }]
|
|
})
|
|
subgraphB.add(subgraphNodeA)
|
|
subgraphNodeA._internalConfigureAfterSlots()
|
|
subgraphB.inputNode.slots[0].connect(subgraphNodeA.inputs[0], subgraphNodeA)
|
|
|
|
const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 46 })
|
|
|
|
// Simulate transient stale intermediate view state by forcing host 47
|
|
// to report no promoted widgets while promotionStore still has entries.
|
|
Object.defineProperty(subgraphNodeA, 'widgets', {
|
|
get: () => [],
|
|
configurable: true
|
|
})
|
|
|
|
expect(subgraphNodeB.widgets[0].type).toBe('button')
|
|
})
|
|
|
|
test('state lookup does not use input-widget fallback when intermediate promotions are absent', () => {
|
|
const subgraphA = createTestSubgraph({
|
|
inputs: [{ name: 'strength_model', type: '*' }]
|
|
})
|
|
const innerNode = new LGraphNode('InnerNumberNode')
|
|
const innerInput = innerNode.addInput('strength_model', '*')
|
|
innerNode.addWidget('number', 'strength_model', 1, () => {})
|
|
innerInput.widget = { name: 'strength_model' }
|
|
subgraphA.add(innerNode)
|
|
subgraphA.inputNode.slots[0].connect(innerInput, innerNode)
|
|
|
|
const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 47 })
|
|
|
|
const subgraphB = createTestSubgraph({
|
|
inputs: [{ name: 'strength_model', type: '*' }]
|
|
})
|
|
subgraphB.add(subgraphNodeA)
|
|
subgraphNodeA._internalConfigureAfterSlots()
|
|
subgraphB.inputNode.slots[0].connect(subgraphNodeA.inputs[0], subgraphNodeA)
|
|
|
|
const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 46 })
|
|
|
|
// Simulate a transient where intermediate promotions are unavailable but
|
|
// input _widget binding is already updated.
|
|
usePromotionStore().setPromotions(
|
|
subgraphNodeA.rootGraph.id,
|
|
subgraphNodeA.id,
|
|
[]
|
|
)
|
|
Object.defineProperty(subgraphNodeA, 'widgets', {
|
|
get: () => [],
|
|
configurable: true
|
|
})
|
|
|
|
expect(subgraphNodeB.widgets[0].type).toBe('button')
|
|
})
|
|
|
|
test('state lookup does not use subgraph-link fallback when intermediate bindings are unavailable', () => {
|
|
const subgraphA = createTestSubgraph({
|
|
inputs: [{ name: 'strength_model', type: '*' }]
|
|
})
|
|
const innerNode = new LGraphNode('InnerNumberNode')
|
|
const innerInput = innerNode.addInput('strength_model', '*')
|
|
innerNode.addWidget('number', 'strength_model', 1, () => {})
|
|
innerInput.widget = { name: 'strength_model' }
|
|
subgraphA.add(innerNode)
|
|
subgraphA.inputNode.slots[0].connect(innerInput, innerNode)
|
|
|
|
const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 47 })
|
|
|
|
const subgraphB = createTestSubgraph({
|
|
inputs: [{ name: 'strength_model', type: '*' }]
|
|
})
|
|
subgraphB.add(subgraphNodeA)
|
|
subgraphNodeA._internalConfigureAfterSlots()
|
|
subgraphB.inputNode.slots[0].connect(subgraphNodeA.inputs[0], subgraphNodeA)
|
|
|
|
const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 46 })
|
|
|
|
usePromotionStore().setPromotions(
|
|
subgraphNodeA.rootGraph.id,
|
|
subgraphNodeA.id,
|
|
[]
|
|
)
|
|
Object.defineProperty(subgraphNodeA, 'widgets', {
|
|
get: () => [],
|
|
configurable: true
|
|
})
|
|
subgraphNodeA.inputs[0]._widget = undefined
|
|
|
|
expect(subgraphNodeB.widgets[0].type).toBe('button')
|
|
})
|
|
|
|
test('nested promotion keeps concrete widget types at top level', () => {
|
|
const subgraphA = createTestSubgraph({
|
|
inputs: [
|
|
{ name: 'lora_name', type: '*' },
|
|
{ name: 'strength_model', type: '*' }
|
|
]
|
|
})
|
|
const innerNode = new LGraphNode('InnerLoraNode')
|
|
const comboInput = innerNode.addInput('lora_name', '*')
|
|
const numberInput = innerNode.addInput('strength_model', '*')
|
|
innerNode.addWidget('combo', 'lora_name', 'a', () => {}, {
|
|
values: ['a', 'b']
|
|
})
|
|
innerNode.addWidget('number', 'strength_model', 1, () => {})
|
|
comboInput.widget = { name: 'lora_name' }
|
|
numberInput.widget = { name: 'strength_model' }
|
|
subgraphA.add(innerNode)
|
|
subgraphA.inputNode.slots[0].connect(comboInput, innerNode)
|
|
subgraphA.inputNode.slots[1].connect(numberInput, innerNode)
|
|
|
|
const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 60 })
|
|
|
|
const subgraphB = createTestSubgraph({
|
|
inputs: [
|
|
{ name: 'lora_name', type: '*' },
|
|
{ name: 'strength_model', type: '*' }
|
|
]
|
|
})
|
|
subgraphB.add(subgraphNodeA)
|
|
subgraphNodeA._internalConfigureAfterSlots()
|
|
subgraphB.inputNode.slots[0].connect(subgraphNodeA.inputs[0], subgraphNodeA)
|
|
subgraphB.inputNode.slots[1].connect(subgraphNodeA.inputs[1], subgraphNodeA)
|
|
|
|
const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 61 })
|
|
|
|
expect(subgraphNodeB.widgets[0].type).toBe('combo')
|
|
expect(subgraphNodeB.widgets[1].type).toBe('number')
|
|
})
|
|
|
|
test('input promotion from promoted view stores immediate source node id', () => {
|
|
const subgraphA = createTestSubgraph({
|
|
inputs: [{ name: 'lora_name', type: '*' }]
|
|
})
|
|
const innerNode = new LGraphNode('InnerNode')
|
|
const innerInput = innerNode.addInput('lora_name', '*')
|
|
innerNode.addWidget('combo', 'lora_name', 'a', () => {}, {
|
|
values: ['a', 'b']
|
|
})
|
|
innerInput.widget = { name: 'lora_name' }
|
|
subgraphA.add(innerNode)
|
|
subgraphA.inputNode.slots[0].connect(innerInput, innerNode)
|
|
|
|
const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 70 })
|
|
|
|
const subgraphB = createTestSubgraph({
|
|
inputs: [{ name: 'lora_name', type: '*' }]
|
|
})
|
|
subgraphB.add(subgraphNodeA)
|
|
subgraphNodeA._internalConfigureAfterSlots()
|
|
subgraphB.inputNode.slots[0].connect(subgraphNodeA.inputs[0], subgraphNodeA)
|
|
|
|
const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 71 })
|
|
const promotions = usePromotionStore().getPromotions(
|
|
subgraphNodeB.rootGraph.id,
|
|
subgraphNodeB.id
|
|
)
|
|
|
|
expect(promotions).toContainEqual({
|
|
interiorNodeId: String(subgraphNodeA.id),
|
|
widgetName: 'lora_name'
|
|
})
|
|
expect(promotions).not.toContainEqual({
|
|
interiorNodeId: String(innerNode.id),
|
|
widgetName: 'lora_name'
|
|
})
|
|
})
|
|
|
|
test('resolvePromotedWidgetSource is safe for detached subgraph hosts', () => {
|
|
const subgraph = createTestSubgraph()
|
|
const subgraphNode = createTestSubgraphNode(subgraph, { id: 101 })
|
|
const promotedView = createPromotedWidgetView(
|
|
subgraphNode,
|
|
'999',
|
|
'missingWidget'
|
|
)
|
|
|
|
subgraphNode.graph = null
|
|
|
|
expect(() =>
|
|
resolvePromotedWidgetSource(subgraphNode, promotedView)
|
|
).not.toThrow()
|
|
expect(
|
|
resolvePromotedWidgetSource(subgraphNode, promotedView)
|
|
).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('DOM widget promotion', () => {
|
|
beforeEach(() => {
|
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
function createMockDOMWidget(node: LGraphNode, name: string) {
|
|
const widget = node.addWidget('text', name, 'val', () => {})
|
|
// Add 'element' and 'id' to make it a BaseDOMWidget
|
|
Object.defineProperties(widget, {
|
|
element: { value: document.createElement('div'), enumerable: true },
|
|
id: { value: `dom-widget-${name}`, enumerable: true }
|
|
})
|
|
return widget
|
|
}
|
|
|
|
function createMockComponentWidget(node: LGraphNode, name: string) {
|
|
const widget = node.addWidget('custom', name, 'val', () => {})
|
|
Object.defineProperties(widget, {
|
|
component: { value: {}, enumerable: true },
|
|
id: { value: `comp-widget-${name}`, enumerable: true }
|
|
})
|
|
return widget
|
|
}
|
|
|
|
test('draw registers position override for DOM widgets', () => {
|
|
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
|
createMockDOMWidget(innerNodes[0], 'textarea')
|
|
setPromotions(subgraphNode, [['1', 'textarea']])
|
|
|
|
const view = subgraphNode.widgets[0]
|
|
view.draw!(createFakeCanvasContext(), subgraphNode, 200, 0, 30)
|
|
|
|
expect(mockDomWidgetStore.setPositionOverride).toHaveBeenCalledWith(
|
|
'dom-widget-textarea',
|
|
{ node: subgraphNode, widget: view }
|
|
)
|
|
})
|
|
|
|
test('draw registers position override for component widgets', () => {
|
|
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
|
createMockComponentWidget(innerNodes[0], 'compWidget')
|
|
setPromotions(subgraphNode, [['1', 'compWidget']])
|
|
|
|
const view = subgraphNode.widgets[0]
|
|
view.draw!(createFakeCanvasContext(), subgraphNode, 200, 0, 30)
|
|
|
|
expect(mockDomWidgetStore.setPositionOverride).toHaveBeenCalledWith(
|
|
'comp-widget-compWidget',
|
|
{ node: subgraphNode, widget: view }
|
|
)
|
|
})
|
|
|
|
test('draw does not register override for non-DOM widgets', () => {
|
|
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
|
innerNodes[0].addWidget('text', 'textWidget', 'val', () => {})
|
|
setPromotions(subgraphNode, [['1', 'textWidget']])
|
|
|
|
const view = subgraphNode.widgets[0]
|
|
view.draw!(createFakeCanvasContext(), subgraphNode, 200, 0, 30, true)
|
|
|
|
expect(mockDomWidgetStore.setPositionOverride).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('draw does not mutate interior node pos or size for non-DOM widgets', () => {
|
|
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
|
const interiorNode = firstInnerNode(innerNodes)
|
|
interiorNode.pos = [10, 20]
|
|
interiorNode.size = [300, 120]
|
|
interiorNode.addWidget('text', 'textWidget', 'val', () => {})
|
|
setPromotions(subgraphNode, [[String(interiorNode.id), 'textWidget']])
|
|
|
|
const originalPos = [...interiorNode.pos]
|
|
const originalSize = [...interiorNode.size]
|
|
const view = subgraphNode.widgets[0]
|
|
|
|
view.draw!(createFakeCanvasContext(), subgraphNode, 200, 0, 30)
|
|
|
|
expect(Array.from(interiorNode.pos)).toEqual(originalPos)
|
|
expect(Array.from(interiorNode.size)).toEqual(originalSize)
|
|
})
|
|
|
|
test('computeLayoutSize delegates to interior DOM widget', () => {
|
|
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
|
const domWidget = createMockDOMWidget(innerNodes[0], 'textarea')
|
|
domWidget.computeLayoutSize = vi.fn(() => ({
|
|
minHeight: 100,
|
|
maxHeight: 300,
|
|
minWidth: 0
|
|
}))
|
|
setPromotions(subgraphNode, [['1', 'textarea']])
|
|
|
|
const view = subgraphNode.widgets[0]
|
|
const result = view.computeLayoutSize!(subgraphNode)
|
|
|
|
expect(result).toEqual({ minHeight: 100, maxHeight: 300, minWidth: 0 })
|
|
})
|
|
|
|
test('demoting clears position override for DOM widget', () => {
|
|
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
|
createMockDOMWidget(innerNodes[0], 'textarea')
|
|
setPromotions(subgraphNode, [['1', 'textarea']])
|
|
|
|
const view = subgraphNode.widgets[0]
|
|
subgraphNode.removeWidget(view)
|
|
|
|
expect(mockDomWidgetStore.clearPositionOverride).toHaveBeenCalledWith(
|
|
'dom-widget-textarea'
|
|
)
|
|
})
|
|
|
|
test('onRemoved clears position overrides for all promoted DOM widgets', () => {
|
|
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
|
createMockDOMWidget(innerNodes[0], 'widgetA')
|
|
createMockDOMWidget(innerNodes[0], 'widgetB')
|
|
setPromotions(subgraphNode, [
|
|
['1', 'widgetA'],
|
|
['1', 'widgetB']
|
|
])
|
|
|
|
// Access widgets to populate cache
|
|
expect(subgraphNode.widgets).toHaveLength(2)
|
|
|
|
subgraphNode.onRemoved()
|
|
|
|
expect(mockDomWidgetStore.clearPositionOverride).toHaveBeenCalledWith(
|
|
'dom-widget-widgetA'
|
|
)
|
|
expect(mockDomWidgetStore.clearPositionOverride).toHaveBeenCalledWith(
|
|
'dom-widget-widgetB'
|
|
)
|
|
})
|
|
})
|