fix: PromotedWidgetSlot label setter, callback property, sourceNodeId passthrough

- Add label setter to PromotedWidgetSlot so renameWidget() doesn't throw
- Move callback from class method to constructor property for safe reassignment
- Pass sourceNodeId from PromotedWidgetSlot to getSharedWidgetEnhancements
- Add tests for all three fixes

Amp-Thread-ID: https://ampcode.com/threads/T-019c5105-89d8-717d-a12e-fcecfe27f947
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Alexander Brown
2026-02-12 00:58:35 -08:00
parent 19c5b1c3b4
commit 35f3d84b57
4 changed files with 139 additions and 17 deletions

View File

@@ -3,8 +3,12 @@ import { createTestingPinia } from '@pinia/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, nextTick, watch } from 'vue'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import {
getSharedWidgetEnhancements,
useGraphNodeManager
} from '@/composables/graph/useGraphNodeManager'
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
@@ -74,3 +78,38 @@ describe('Node Reactivity', () => {
expect(widgetValue.value).toBe(99)
})
})
describe('getSharedWidgetEnhancements', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('returns nodeType when sourceNodeId is provided for a subgraph node', () => {
const subgraph = new LGraph()
const interiorNode = new LGraphNode('KSampler', 'KSampler')
subgraph.add(interiorNode)
const subgraphNode = new LGraphNode('graph/subgraph')
;(subgraphNode as unknown as { subgraph: LGraph }).subgraph = subgraph
vi.spyOn(subgraphNode, 'isSubgraphNode').mockReturnValue(true)
const widget = { name: 'seed', type: 'number', value: 0 } as IBaseWidget
const result = getSharedWidgetEnhancements(
subgraphNode,
widget,
String(interiorNode.id)
)
expect(result.nodeType).toBe('KSampler')
})
it('returns undefined nodeType when sourceNodeId is omitted', () => {
const subgraphNode = new LGraphNode('graph/subgraph')
vi.spyOn(subgraphNode, 'isSubgraphNode').mockReturnValue(true)
const widget = { name: 'seed', type: 'number', value: 0 } as IBaseWidget
const result = getSharedWidgetEnhancements(subgraphNode, widget)
expect(result.nodeType).toBeUndefined()
})
})

View File

@@ -197,7 +197,13 @@ function safeWidgetMapper(
return function (widget) {
try {
// Get shared enhancements (controlWidget, spec, nodeType)
const sharedEnhancements = getSharedWidgetEnhancements(node, widget)
const sourceNodeId =
'sourceNodeId' in widget ? (widget.sourceNodeId as string) : undefined
const sharedEnhancements = getSharedWidgetEnhancements(
node,
widget,
sourceNodeId
)
const slotInfo = slotMetadata.get(widget.name)
// Wrapper callback specific to Nodes 2.0 rendering

View File

@@ -185,6 +185,44 @@ describe('PromotedWidgetSlot', () => {
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
expect(slot.label).toBe('5: seed')
})
it('writes label to interior widget', () => {
const interiorWidget = createMockWidget()
const interiorNode = {
id: '5',
widgets: [interiorWidget]
} as unknown as LGraphNode
const subNode = createMockSubgraphNode({ '5': interiorNode })
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
slot.label = 'Renamed'
expect(interiorWidget.label).toBe('Renamed')
})
it('clears label on interior widget when set to undefined', () => {
const interiorWidget = createMockWidget()
interiorWidget.label = 'Old Label'
const interiorNode = {
id: '5',
widgets: [interiorWidget]
} as unknown as LGraphNode
const subNode = createMockSubgraphNode({ '5': interiorNode })
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
slot.label = undefined
expect(interiorWidget.label).toBeUndefined()
})
it('does not throw when setting label while disconnected', () => {
const subNode = createMockSubgraphNode()
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
expect(() => {
slot.label = 'Renamed'
}).not.toThrow()
})
})
describe('options', () => {
@@ -260,6 +298,47 @@ describe('PromotedWidgetSlot', () => {
})
})
describe('callback', () => {
it('delegates to interior widget callback', () => {
const interiorCallback = vi.fn()
const interiorWidget = createMockWidget({ callback: interiorCallback })
const interiorNode = {
id: '5',
widgets: [interiorWidget]
} as unknown as LGraphNode
const subNode = createMockSubgraphNode({ '5': interiorNode })
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
slot.callback?.(42)
expect(interiorCallback).toHaveBeenCalledWith(
42,
undefined,
interiorNode,
undefined,
undefined
)
})
it('does not throw when disconnected', () => {
const subNode = createMockSubgraphNode()
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
expect(() => slot.callback?.(42)).not.toThrow()
})
it('can be reassigned as a property', () => {
const subNode = createMockSubgraphNode()
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
const customCallback = vi.fn()
slot.callback = customCallback
slot.callback?.(99)
expect(customCallback).toHaveBeenCalledWith(99)
})
})
describe('_displayValue', () => {
it('returns string representation of value', () => {
const store = useWidgetValueStore()

View File

@@ -1,9 +1,6 @@
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { Point } from '@/lib/litegraph/src/interfaces'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type {
DrawWidgetOptions,
@@ -63,6 +60,12 @@ export class PromotedWidgetSlot
// our getters are used.
delete (this as Record<string, unknown>).type
delete (this as Record<string, unknown>).options
this.callback = (value, canvas, _node, pos, e) => {
const resolved = this.resolve()
if (!resolved) return
resolved.widget.callback?.(value, canvas, resolved.node, pos, e)
}
}
private resolve(): {
@@ -113,6 +116,13 @@ export class PromotedWidgetSlot
return state?.label ?? this.name
}
override set label(v: string | undefined) {
const resolved = this.resolve()
if (resolved) {
resolved.widget.label = v
}
}
override get promoted(): boolean {
return true
}
@@ -179,18 +189,6 @@ export class PromotedWidgetSlot
const concrete = toConcreteWidget(resolved.widget, resolved.node, false)
concrete?.onClick(options)
}
override callback(
value: WidgetValue,
canvas?: LGraphCanvas,
_node?: LGraphNode,
pos?: Point,
e?: CanvasPointerEvent
): void {
const resolved = this.resolve()
if (!resolved) return
resolved.widget.callback?.(value, canvas, resolved.node, pos, e)
}
}
// Install dynamic getters via defineProperty on the prototype.