feat: widgetValueStore promotion resolution

Add resolvePromotedWidget() to WidgetValueStore for looking up interior
widget state by subgraph + nodeId + widgetName. Add getPromotionList()
helper as the single entry point for reading the promotion list from a
SubgraphNode's properties.proxyWidgets.

Phase 1 of ProxyWidget elimination — purely additive, no behavior changes.

Amp-Thread-ID: https://ampcode.com/threads/T-019c4fd4-6e4c-721f-8106-7b3f3cb93990
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Alexander Brown
2026-02-11 19:27:40 -08:00
parent c52f48af45
commit d54054bb1e
4 changed files with 190 additions and 3 deletions

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { getPromotionList } from './promotionList'
function mockSubgraphNode(proxyWidgets?: unknown) {
return { properties: { proxyWidgets } } as any
}
describe('getPromotionList', () => {
it('returns empty array for node with no proxyWidgets property', () => {
const node = mockSubgraphNode(undefined)
expect(getPromotionList(node)).toEqual([])
})
it('returns empty array for empty proxyWidgets', () => {
const node = mockSubgraphNode([])
expect(getPromotionList(node)).toEqual([])
})
it('parses valid promotion entries', () => {
const entries = [
['42', 'seed'],
['7', 'steps']
]
const node = mockSubgraphNode(entries)
expect(getPromotionList(node)).toEqual(entries)
})
it('handles string-serialized proxyWidgets (JSON)', () => {
const entries = [['42', 'seed']]
const node = mockSubgraphNode(JSON.stringify(entries))
expect(getPromotionList(node)).toEqual(entries)
})
it('throws on invalid format', () => {
const node = mockSubgraphNode('not-valid-json{{{')
expect(() => getPromotionList(node)).toThrow()
})
it('throws on structurally invalid data', () => {
const node = mockSubgraphNode([['only-one-element']])
expect(() => getPromotionList(node)).toThrow()
})
})

View File

@@ -0,0 +1,15 @@
import type { ProxyWidgetsProperty } from '@/core/schemas/proxyWidget'
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
/**
* Returns the list of promoted widget entries from a SubgraphNode.
* Each entry is a [nodeId, widgetName] tuple referencing an interior widget.
*
* This is the single entry point for reading the promotion list,
* replacing direct access to `node.properties.proxyWidgets`.
*/
export function getPromotionList(node: SubgraphNode): ProxyWidgetsProperty {
if (node.properties.proxyWidgets == null) return []
return parseProxyWidgets(node.properties.proxyWidgets)
}

View File

@@ -2,8 +2,12 @@ import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { WidgetState } from './widgetValueStore'
import { useWidgetValueStore } from './widgetValueStore'
import { stripGraphPrefix, useWidgetValueStore } from './widgetValueStore'
function widget<T>(
nodeId: string,
@@ -17,6 +21,22 @@ function widget<T>(
return { nodeId, name, type, value, options: {}, ...extra }
}
function mockWidget(name: string, type = 'number'): IBaseWidget {
return { name, type } as IBaseWidget
}
function mockNode(id: string, widgets: IBaseWidget[] = []): LGraphNode {
return { id, widgets } as unknown as LGraphNode
}
function mockSubgraph(nodes: LGraphNode[]): LGraph {
const nodeMap = new Map(nodes.map((n) => [String(n.id), n]))
return {
getNodeById: (id: string | number | null | undefined) =>
id != null ? (nodeMap.get(String(id)) ?? null) : null
} as unknown as LGraph
}
describe('useWidgetValueStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
@@ -152,4 +172,93 @@ describe('useWidgetValueStore', () => {
expect(store.getWidget('node-1', 'seed')?.label).toBeUndefined()
})
})
describe('resolvePromotedWidget', () => {
it('returns null for missing node', () => {
const store = useWidgetValueStore()
const subgraph = mockSubgraph([])
expect(store.resolvePromotedWidget(subgraph, '99', 'seed')).toBeNull()
})
it('returns null for missing widget on existing node', () => {
const store = useWidgetValueStore()
const node = mockNode('42', [mockWidget('steps')])
const subgraph = mockSubgraph([node])
store.registerWidget(widget('42', 'steps', 'number', 20))
expect(store.resolvePromotedWidget(subgraph, '42', 'seed')).toBeNull()
})
it('returns null when widget exists on node but not in store', () => {
const store = useWidgetValueStore()
const w = mockWidget('seed')
const node = mockNode('42', [w])
const subgraph = mockSubgraph([node])
expect(store.resolvePromotedWidget(subgraph, '42', 'seed')).toBeNull()
})
it('returns correct state, widget, and node for registered widget', () => {
const store = useWidgetValueStore()
const w = mockWidget('seed')
const node = mockNode('42', [w])
const subgraph = mockSubgraph([node])
const registeredState = store.registerWidget(
widget('42', 'seed', 'number', 12345)
)
const result = store.resolvePromotedWidget(subgraph, '42', 'seed')
expect(result).not.toBeNull()
expect(result!.state).toBe(registeredState)
expect(result!.widget).toBe(w)
expect(result!.node).toBe(node)
})
it('state.value matches the store value (same reference)', () => {
const store = useWidgetValueStore()
const w = mockWidget('seed')
const node = mockNode('42', [w])
const subgraph = mockSubgraph([node])
store.registerWidget(widget('42', 'seed', 'number', 100))
const result = store.resolvePromotedWidget(subgraph, '42', 'seed')
expect(result!.state.value).toBe(100)
result!.state.value = 200
expect(store.getWidget('42', 'seed')?.value).toBe(200)
})
it('handles stripGraphPrefix for scoped node IDs', () => {
const store = useWidgetValueStore()
const w = mockWidget('cfg')
const node = mockNode('7', [w])
const subgraph = mockSubgraph([node])
store.registerWidget(widget('7', 'cfg', 'number', 7.5))
// nodeId passed as bare '7' resolves to store key '7:cfg'
const result = store.resolvePromotedWidget(subgraph, '7', 'cfg')
expect(result).not.toBeNull()
expect(result!.state.value).toBe(7.5)
})
})
describe('stripGraphPrefix', () => {
it('strips single prefix', () => {
expect(stripGraphPrefix('graph1:42')).toBe('42')
})
it('strips multiple prefixes', () => {
expect(stripGraphPrefix('graph1:subgraph2:42')).toBe('42')
})
it('returns bare id unchanged', () => {
expect(stripGraphPrefix('42')).toBe('42')
})
it('handles numeric input', () => {
expect(stripGraphPrefix(42 as unknown as string)).toBe('42')
})
})
})

View File

@@ -1,7 +1,8 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type {
IBaseWidget,
IWidgetOptions
@@ -69,9 +70,27 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
return widgetStates.value.get(makeKey(nodeId, widgetName))
}
function resolvePromotedWidget(
subgraph: LGraph,
nodeId: NodeId,
widgetName: string
): { state: WidgetState; widget: IBaseWidget; node: LGraphNode } | null {
const node = subgraph.getNodeById(nodeId)
if (!node) return null
const widget = node.widgets?.find((w) => w.name === widgetName)
if (!widget) return null
const state = getWidget(stripGraphPrefix(nodeId), widgetName)
if (!state) return null
return { state, widget, node }
}
return {
registerWidget,
getWidget,
getNodeWidgets
getNodeWidgets,
resolvePromotedWidget
}
})