mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-24 16:54:51 +00:00
Compare commits
1 Commits
codex/fix-
...
coderabbit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc11de54fa |
175
src/core/graph/widgets/control/useWidgetControlHooks.test.ts
Normal file
175
src/core/graph/widgets/control/useWidgetControlHooks.test.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { IS_CONTROL_WIDGET } from './controlWidgetMarker'
|
||||
import { runWidgetControl } from '@/core/graph/widgets/widgetControlSystem'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
import { installWidgetControlHooks } from './useWidgetControlHooks'
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: (key: string) =>
|
||||
key === 'Comfy.WidgetControlMode' ? 'after' : undefined
|
||||
})
|
||||
}))
|
||||
|
||||
function controlFor(
|
||||
store: ReturnType<typeof useWidgetValueStore>,
|
||||
graph: LGraph,
|
||||
targetId: string
|
||||
) {
|
||||
return store
|
||||
.getWidgetControls(graph.rootGraph.id)
|
||||
.find(([id]) => id === targetId)?.[1]
|
||||
}
|
||||
|
||||
function addSeedNode(graph: LGraph): LGraphNode {
|
||||
const node = new LGraphNode('SeedNode')
|
||||
node.id = 1
|
||||
const seed = node.addWidget('number', 'seed', 1, () => {}, {
|
||||
min: 0,
|
||||
max: 1_000_000,
|
||||
step2: 1
|
||||
})
|
||||
const control = node.addWidget(
|
||||
'combo',
|
||||
'control_after_generate',
|
||||
'increment',
|
||||
() => {},
|
||||
{ values: ['fixed', 'increment', 'decrement', 'randomize'] }
|
||||
)
|
||||
;(control as IBaseWidget & Record<symbol, unknown>)[IS_CONTROL_WIDGET] = true
|
||||
seed.linkedWidgets = [control]
|
||||
graph.add(node)
|
||||
return node
|
||||
}
|
||||
|
||||
describe('installWidgetControlHooks', () => {
|
||||
let graph: LGraph
|
||||
let store: ReturnType<typeof useWidgetValueStore>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = useWidgetValueStore()
|
||||
graph = new LGraph()
|
||||
})
|
||||
|
||||
it('registers a control component for a control-target widget', () => {
|
||||
const node = addSeedNode(graph)
|
||||
const seedId = node.widgets![0].widgetId!
|
||||
|
||||
installWidgetControlHooks(graph)
|
||||
|
||||
const control = controlFor(store, graph, seedId)
|
||||
expect(control?.controlWidgetId).toBe(node.widgets![1].widgetId)
|
||||
})
|
||||
|
||||
it('advances the registered target value through the control system', () => {
|
||||
const node = addSeedNode(graph)
|
||||
const seedId = node.widgets![0].widgetId!
|
||||
installWidgetControlHooks(graph)
|
||||
|
||||
runWidgetControl(graph.rootGraph.id, 'after')
|
||||
|
||||
expect(store.getWidget(seedId)?.value).toBe(2)
|
||||
})
|
||||
|
||||
it('removes the control component when the node is removed', () => {
|
||||
const node = addSeedNode(graph)
|
||||
const seedId = node.widgets![0].widgetId!
|
||||
installWidgetControlHooks(graph)
|
||||
expect(controlFor(store, graph, seedId)).toBeDefined()
|
||||
|
||||
graph.remove(node)
|
||||
|
||||
expect(controlFor(store, graph, seedId)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('registers controls for nodes added after install', () => {
|
||||
installWidgetControlHooks(graph)
|
||||
const node = addSeedNode(graph)
|
||||
const seedId = node.widgets![0].widgetId!
|
||||
|
||||
expect(controlFor(store, graph, seedId)).toBeDefined()
|
||||
})
|
||||
|
||||
it('restores the original node callback on uninstall', () => {
|
||||
const node = addSeedNode(graph)
|
||||
const original = node.onConnectionsChange
|
||||
const uninstall = installWidgetControlHooks(graph)
|
||||
expect(node.onConnectionsChange).not.toBe(original)
|
||||
|
||||
uninstall()
|
||||
|
||||
expect(node.onConnectionsChange).toBe(original)
|
||||
})
|
||||
|
||||
it('does not register a control for a widget with no linked control widget', () => {
|
||||
const node = new LGraphNode('PlainNode')
|
||||
node.id = 2
|
||||
const plain = node.addWidget('number', 'steps', 20, () => {}, {
|
||||
min: 1,
|
||||
max: 100,
|
||||
step2: 1
|
||||
})
|
||||
// No linkedWidgets — not a control-target widget
|
||||
plain.linkedWidgets = undefined
|
||||
graph.add(node)
|
||||
|
||||
installWidgetControlHooks(graph)
|
||||
|
||||
const plainId = plain.widgetId!
|
||||
expect(controlFor(store, graph, plainId)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('installs on an empty graph without errors', () => {
|
||||
expect(() => installWidgetControlHooks(graph)).not.toThrow()
|
||||
})
|
||||
|
||||
it('uninstall removes the onNodeAdded and onNodeRemoved overrides', () => {
|
||||
const originalAdded = graph.onNodeAdded
|
||||
const originalRemoved = graph.onNodeRemoved
|
||||
|
||||
const uninstall = installWidgetControlHooks(graph)
|
||||
expect(graph.onNodeAdded).not.toBe(originalAdded)
|
||||
expect(graph.onNodeRemoved).not.toBe(originalRemoved)
|
||||
|
||||
uninstall()
|
||||
|
||||
// After uninstall the callbacks should revert to original (undefined or the
|
||||
// prior value set before install)
|
||||
expect(graph.onNodeAdded).toBe(originalAdded || undefined)
|
||||
expect(graph.onNodeRemoved).toBe(originalRemoved || undefined)
|
||||
})
|
||||
|
||||
it('re-syncs the control when an INPUT connection changes', () => {
|
||||
const node = addSeedNode(graph)
|
||||
const seedId = node.widgets![0].widgetId!
|
||||
installWidgetControlHooks(graph)
|
||||
|
||||
// Simulate an INPUT connection change on the node
|
||||
node.onConnectionsChange?.(NodeSlotType.INPUT, 0, true, null as never, null as never)
|
||||
|
||||
// Control should still be registered after the re-sync
|
||||
expect(controlFor(store, graph, seedId)).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not re-sync when an OUTPUT connection changes', () => {
|
||||
const node = addSeedNode(graph)
|
||||
const seedId = node.widgets![0].widgetId!
|
||||
installWidgetControlHooks(graph)
|
||||
|
||||
const controlBefore = controlFor(store, graph, seedId)
|
||||
|
||||
// Simulate an OUTPUT connection change — should NOT trigger syncNodeControls
|
||||
node.onConnectionsChange?.(NodeSlotType.OUTPUT, 0, true, null as never, null as never)
|
||||
|
||||
// Control state should be unchanged (same reference)
|
||||
expect(controlFor(store, graph, seedId)).toEqual(controlBefore)
|
||||
})
|
||||
})
|
||||
165
src/core/graph/widgets/control/valueControl.test.ts
Normal file
165
src/core/graph/widgets/control/valueControl.test.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { IS_CONTROL_WIDGET } from '@/core/graph/widgets/control/controlWidgetMarker'
|
||||
|
||||
import {
|
||||
computeNextControlledValue,
|
||||
isValueControlMode,
|
||||
isValueControlWidget
|
||||
} from './valueControl'
|
||||
|
||||
const makeNumberWidget = (
|
||||
value: number,
|
||||
options: Partial<IBaseWidget['options']> = {}
|
||||
): IBaseWidget =>
|
||||
({
|
||||
type: 'number',
|
||||
name: 'seed',
|
||||
value,
|
||||
options
|
||||
}) as unknown as IBaseWidget
|
||||
|
||||
const makeComboWidget = (value: string, values: string[]): IBaseWidget =>
|
||||
({
|
||||
type: 'combo',
|
||||
name: 'choice',
|
||||
value,
|
||||
options: { values }
|
||||
}) as unknown as IBaseWidget
|
||||
|
||||
describe('isValueControlMode', () => {
|
||||
it('returns true for each valid mode string', () => {
|
||||
expect(isValueControlMode('fixed')).toBe(true)
|
||||
expect(isValueControlMode('increment')).toBe(true)
|
||||
expect(isValueControlMode('increment-wrap')).toBe(true)
|
||||
expect(isValueControlMode('decrement')).toBe(true)
|
||||
expect(isValueControlMode('randomize')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for an unrecognized string', () => {
|
||||
expect(isValueControlMode('after')).toBe(false)
|
||||
expect(isValueControlMode('before')).toBe(false)
|
||||
expect(isValueControlMode('')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for non-string values', () => {
|
||||
expect(isValueControlMode(undefined)).toBe(false)
|
||||
expect(isValueControlMode(null)).toBe(false)
|
||||
expect(isValueControlMode(42)).toBe(false)
|
||||
expect(isValueControlMode(true)).toBe(false)
|
||||
expect(isValueControlMode({})).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValueControlWidget', () => {
|
||||
it('returns true for a marked widget', () => {
|
||||
const widget = {
|
||||
[IS_CONTROL_WIDGET]: true
|
||||
} as unknown as IBaseWidget
|
||||
expect(isValueControlWidget(widget)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when the marker symbol is missing', () => {
|
||||
const widget = {} as unknown as IBaseWidget
|
||||
expect(isValueControlWidget(widget)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeNextControlledValue (number)', () => {
|
||||
it('returns undefined for fixed mode', () => {
|
||||
expect(
|
||||
computeNextControlledValue(makeNumberWidget(5), 'fixed')
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('increments by step2', () => {
|
||||
const widget = makeNumberWidget(5, { min: 0, max: 100, step2: 2 })
|
||||
expect(computeNextControlledValue(widget, 'increment')).toBe(7)
|
||||
})
|
||||
|
||||
it('decrements by step2', () => {
|
||||
const widget = makeNumberWidget(5, { min: 0, max: 100, step2: 3 })
|
||||
expect(computeNextControlledValue(widget, 'decrement')).toBe(2)
|
||||
})
|
||||
|
||||
it('clamps to max on increment', () => {
|
||||
const widget = makeNumberWidget(99, { min: 0, max: 100, step2: 5 })
|
||||
expect(computeNextControlledValue(widget, 'increment')).toBe(100)
|
||||
})
|
||||
|
||||
it('clamps to min on decrement', () => {
|
||||
const widget = makeNumberWidget(1, { min: 0, max: 100, step2: 5 })
|
||||
expect(computeNextControlledValue(widget, 'decrement')).toBe(0)
|
||||
})
|
||||
|
||||
it('randomizes within range using a seeded random', () => {
|
||||
const widget = makeNumberWidget(0, { min: 10, max: 20, step2: 1 })
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.5)
|
||||
expect(computeNextControlledValue(widget, 'randomize')).toBe(15)
|
||||
})
|
||||
|
||||
it('returns undefined when target value is not numeric', () => {
|
||||
const widget = {
|
||||
type: 'number',
|
||||
name: 'seed',
|
||||
value: 'not a number',
|
||||
options: {}
|
||||
} as unknown as IBaseWidget
|
||||
expect(computeNextControlledValue(widget, 'increment')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeNextControlledValue (combo)', () => {
|
||||
it('cycles to the next value on increment', () => {
|
||||
const widget = makeComboWidget('a', ['a', 'b', 'c'])
|
||||
expect(computeNextControlledValue(widget, 'increment')).toBe('b')
|
||||
})
|
||||
|
||||
it('clamps at the end on increment without wrap', () => {
|
||||
const widget = makeComboWidget('c', ['a', 'b', 'c'])
|
||||
expect(computeNextControlledValue(widget, 'increment')).toBe('c')
|
||||
})
|
||||
|
||||
it('wraps to first value on increment-wrap past the end', () => {
|
||||
const widget = makeComboWidget('c', ['a', 'b', 'c'])
|
||||
expect(computeNextControlledValue(widget, 'increment-wrap')).toBe('a')
|
||||
})
|
||||
|
||||
it('cycles to the previous value on decrement', () => {
|
||||
const widget = makeComboWidget('b', ['a', 'b', 'c'])
|
||||
expect(computeNextControlledValue(widget, 'decrement')).toBe('a')
|
||||
})
|
||||
|
||||
it('randomizes by index', () => {
|
||||
const widget = makeComboWidget('a', ['a', 'b', 'c', 'd'])
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.6)
|
||||
expect(computeNextControlledValue(widget, 'randomize')).toBe('c')
|
||||
})
|
||||
|
||||
it('applies a substring filter', () => {
|
||||
const widget = makeComboWidget('apple', ['apple', 'banana', 'apricot'])
|
||||
expect(
|
||||
computeNextControlledValue(widget, 'increment', { comboFilter: 'ap' })
|
||||
).toBe('apricot')
|
||||
})
|
||||
|
||||
it('applies a regex filter when wrapped in slashes', () => {
|
||||
const widget = makeComboWidget('foo1', ['foo1', 'bar', 'foo2'])
|
||||
expect(
|
||||
computeNextControlledValue(widget, 'increment', { comboFilter: '/foo/' })
|
||||
).toBe('foo2')
|
||||
})
|
||||
|
||||
it('returns undefined when the filter eliminates all values', () => {
|
||||
const widget = makeComboWidget('a', ['a', 'b'])
|
||||
expect(
|
||||
computeNextControlledValue(widget, 'increment', { comboFilter: 'zzz' })
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
})
|
||||
264
src/core/graph/widgets/widgetControlSystem.test.ts
Normal file
264
src/core/graph/widgets/widgetControlSystem.test.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
|
||||
import { runWidgetControl } from './widgetControlSystem'
|
||||
|
||||
const controlMode = vi.hoisted(() => ({ value: 'after' as 'before' | 'after' }))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: (key: string) =>
|
||||
key === 'Comfy.WidgetControlMode' ? controlMode.value : undefined
|
||||
})
|
||||
}))
|
||||
|
||||
const GRAPH = 'graph'
|
||||
|
||||
function seedSetup(
|
||||
mode: string,
|
||||
{ value = 1 }: { value?: number } = {}
|
||||
): { targetId: ReturnType<typeof widgetId> } {
|
||||
const store = useWidgetValueStore()
|
||||
const targetId = widgetId(GRAPH, '1', 'seed')
|
||||
const controlId = widgetId(GRAPH, '1', 'control_after_generate')
|
||||
store.registerWidget(targetId, {
|
||||
type: 'number',
|
||||
value,
|
||||
options: { min: 0, max: 1_000_000, step2: 1 }
|
||||
})
|
||||
store.registerWidget(controlId, {
|
||||
type: 'combo',
|
||||
value: mode,
|
||||
options: {}
|
||||
})
|
||||
store.registerWidgetControl(targetId, { controlWidgetId: controlId })
|
||||
return { targetId }
|
||||
}
|
||||
|
||||
describe('runWidgetControl', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
controlMode.value = 'after'
|
||||
})
|
||||
|
||||
it('increments a controlled value after queueing', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const { targetId } = seedSetup('increment')
|
||||
|
||||
runWidgetControl(GRAPH, 'after')
|
||||
|
||||
expect(store.getWidget(targetId)?.value).toBe(2)
|
||||
})
|
||||
|
||||
it('leaves the value unchanged when the mode is fixed', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const { targetId } = seedSetup('fixed')
|
||||
|
||||
runWidgetControl(GRAPH, 'after')
|
||||
|
||||
expect(store.getWidget(targetId)?.value).toBe(1)
|
||||
})
|
||||
|
||||
it('does not run on a target whose input is link-fed', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const { targetId } = seedSetup('increment')
|
||||
store.setInputLinked(targetId, true)
|
||||
|
||||
runWidgetControl(GRAPH, 'after')
|
||||
|
||||
expect(store.getWidget(targetId)?.value).toBe(1)
|
||||
})
|
||||
|
||||
it('does not run during partial execution', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const { targetId } = seedSetup('increment')
|
||||
|
||||
runWidgetControl(GRAPH, 'after', { isPartialExecution: true })
|
||||
|
||||
expect(store.getWidget(targetId)?.value).toBe(1)
|
||||
})
|
||||
|
||||
it('skips the first queue in before mode, then advances', () => {
|
||||
controlMode.value = 'before'
|
||||
const store = useWidgetValueStore()
|
||||
const { targetId } = seedSetup('increment')
|
||||
|
||||
runWidgetControl(GRAPH, 'before')
|
||||
expect(store.getWidget(targetId)?.value).toBe(1)
|
||||
|
||||
runWidgetControl(GRAPH, 'before')
|
||||
expect(store.getWidget(targetId)?.value).toBe(2)
|
||||
})
|
||||
|
||||
it('ignores after-phase work when in before mode', () => {
|
||||
controlMode.value = 'before'
|
||||
const store = useWidgetValueStore()
|
||||
const { targetId } = seedSetup('increment')
|
||||
|
||||
runWidgetControl(GRAPH, 'after')
|
||||
|
||||
expect(store.getWidget(targetId)?.value).toBe(1)
|
||||
})
|
||||
|
||||
it('applies a combo filter when advancing a combo value', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const targetId = widgetId(GRAPH, '1', 'ckpt')
|
||||
const controlId = widgetId(GRAPH, '1', 'control_after_generate')
|
||||
const filterId = widgetId(GRAPH, '1', 'control_filter_list')
|
||||
store.registerWidget(targetId, {
|
||||
type: 'combo',
|
||||
value: 'a.safetensors',
|
||||
options: { values: ['a.safetensors', 'b.ckpt', 'c.safetensors'] }
|
||||
})
|
||||
store.registerWidget(controlId, {
|
||||
type: 'combo',
|
||||
value: 'increment',
|
||||
options: {}
|
||||
})
|
||||
store.registerWidget(filterId, {
|
||||
type: 'string',
|
||||
value: 'safetensors',
|
||||
options: {}
|
||||
})
|
||||
store.registerWidgetControl(targetId, {
|
||||
controlWidgetId: controlId,
|
||||
filterWidgetId: filterId
|
||||
})
|
||||
|
||||
runWidgetControl(GRAPH, 'after')
|
||||
|
||||
expect(store.getWidget(targetId)?.value).toBe('c.safetensors')
|
||||
})
|
||||
|
||||
it('only advances controls belonging to the queued graph', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const { targetId } = seedSetup('increment')
|
||||
const otherTarget = widgetId('other-graph', '1', 'seed')
|
||||
const otherControl = widgetId('other-graph', '1', 'control_after_generate')
|
||||
store.registerWidget(otherTarget, {
|
||||
type: 'number',
|
||||
value: 1,
|
||||
options: { min: 0, max: 1_000_000, step2: 1 }
|
||||
})
|
||||
store.registerWidget(otherControl, {
|
||||
type: 'combo',
|
||||
value: 'increment',
|
||||
options: {}
|
||||
})
|
||||
store.registerWidgetControl(otherTarget, { controlWidgetId: otherControl })
|
||||
|
||||
runWidgetControl(GRAPH, 'after')
|
||||
|
||||
expect(store.getWidget(targetId)?.value).toBe(2)
|
||||
expect(store.getWidget(otherTarget)?.value).toBe(1)
|
||||
})
|
||||
|
||||
it('preserves the before-mode skip across re-registration', () => {
|
||||
controlMode.value = 'before'
|
||||
const store = useWidgetValueStore()
|
||||
const { targetId } = seedSetup('increment')
|
||||
const controlId = widgetId(GRAPH, '1', 'control_after_generate')
|
||||
|
||||
runWidgetControl(GRAPH, 'before')
|
||||
expect(store.getWidget(targetId)?.value).toBe(1)
|
||||
|
||||
store.registerWidgetControl(targetId, { controlWidgetId: controlId })
|
||||
|
||||
runWidgetControl(GRAPH, 'before')
|
||||
expect(store.getWidget(targetId)?.value).toBe(2)
|
||||
})
|
||||
|
||||
it('skips a target whose control widget has an invalid mode string', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const { targetId } = seedSetup('not-a-valid-mode')
|
||||
|
||||
runWidgetControl(GRAPH, 'after')
|
||||
|
||||
// Value should remain unchanged since 'not-a-valid-mode' is not a ValueControlMode
|
||||
expect(store.getWidget(targetId)?.value).toBe(1)
|
||||
})
|
||||
|
||||
it('skips a target whose control widget is missing from the store', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const targetId = widgetId(GRAPH, '1', 'seed')
|
||||
const missingControlId = widgetId(GRAPH, '1', 'nonexistent_control')
|
||||
store.registerWidget(targetId, {
|
||||
type: 'number',
|
||||
value: 5,
|
||||
options: { min: 0, max: 100, step2: 1 }
|
||||
})
|
||||
store.registerWidgetControl(targetId, { controlWidgetId: missingControlId })
|
||||
|
||||
runWidgetControl(GRAPH, 'after')
|
||||
|
||||
expect(store.getWidget(targetId)?.value).toBe(5)
|
||||
})
|
||||
|
||||
it('decrements a controlled value after queueing', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const { targetId } = seedSetup('decrement', { value: 5 })
|
||||
|
||||
runWidgetControl(GRAPH, 'after')
|
||||
|
||||
expect(store.getWidget(targetId)?.value).toBe(4)
|
||||
})
|
||||
|
||||
it('ignores a non-string filter widget value', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const targetId = widgetId(GRAPH, '1', 'ckpt')
|
||||
const controlId = widgetId(GRAPH, '1', 'control_after_generate')
|
||||
const filterId = widgetId(GRAPH, '1', 'control_filter_list')
|
||||
store.registerWidget(targetId, {
|
||||
type: 'combo',
|
||||
value: 'a',
|
||||
options: { values: ['a', 'b', 'c'] }
|
||||
})
|
||||
store.registerWidget(controlId, {
|
||||
type: 'combo',
|
||||
value: 'increment',
|
||||
options: {}
|
||||
})
|
||||
// Register filter with a numeric (non-string) value
|
||||
store.registerWidget(filterId, {
|
||||
type: 'number',
|
||||
value: 42,
|
||||
options: {}
|
||||
})
|
||||
store.registerWidgetControl(targetId, {
|
||||
controlWidgetId: controlId,
|
||||
filterWidgetId: filterId
|
||||
})
|
||||
|
||||
runWidgetControl(GRAPH, 'after')
|
||||
|
||||
// Non-string filter is ignored, so increment advances normally from 'a' to 'b'
|
||||
expect(store.getWidget(targetId)?.value).toBe('b')
|
||||
})
|
||||
|
||||
it('advances multiple independent controls in the same graph', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const { targetId: target1 } = seedSetup('increment', { value: 10 })
|
||||
const target2 = widgetId(GRAPH, '2', 'cfg')
|
||||
const control2 = widgetId(GRAPH, '2', 'control_after_generate')
|
||||
store.registerWidget(target2, {
|
||||
type: 'number',
|
||||
value: 20,
|
||||
options: { min: 0, max: 100, step2: 5 }
|
||||
})
|
||||
store.registerWidget(control2, {
|
||||
type: 'combo',
|
||||
value: 'decrement',
|
||||
options: {}
|
||||
})
|
||||
store.registerWidgetControl(target2, { controlWidgetId: control2 })
|
||||
|
||||
runWidgetControl(GRAPH, 'after')
|
||||
|
||||
expect(store.getWidget(target1)?.value).toBe(11)
|
||||
expect(store.getWidget(target2)?.value).toBe(15)
|
||||
})
|
||||
})
|
||||
@@ -3,24 +3,25 @@ import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { UUID } from '@/utils/uuid'
|
||||
import type { WidgetState } from './widgetValueStore'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
import type { WidgetState } from '@/types/widgetState'
|
||||
|
||||
import { useWidgetValueStore } from './widgetValueStore'
|
||||
|
||||
function widget<T>(
|
||||
nodeId: string,
|
||||
name: string,
|
||||
function state<T>(
|
||||
type: string,
|
||||
value: T,
|
||||
extra: Partial<
|
||||
Omit<WidgetState<T>, 'nodeId' | 'name' | 'type' | 'value'>
|
||||
> = {}
|
||||
): WidgetState<T> {
|
||||
return { nodeId, name, type, value, options: {}, ...extra }
|
||||
extra: Partial<Omit<WidgetState<T>, 'type' | 'value'>> = {}
|
||||
): Omit<WidgetState<T>, 'nodeId' | 'name' | 'y'> & { y?: number } {
|
||||
return { type, value, options: {}, ...extra }
|
||||
}
|
||||
|
||||
describe('useWidgetValueStore', () => {
|
||||
const graphA = 'graph-a' as UUID
|
||||
const graphB = 'graph-b' as UUID
|
||||
const seedA = widgetId(graphA, 'node-1', 'seed')
|
||||
const seedB = widgetId(graphB, 'node-1', 'seed')
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
@@ -28,62 +29,92 @@ describe('useWidgetValueStore', () => {
|
||||
describe('widgetState.value access', () => {
|
||||
it('getWidget returns undefined for unregistered widget', () => {
|
||||
const store = useWidgetValueStore()
|
||||
expect(store.getWidget(graphA, 'missing', 'widget')).toBeUndefined()
|
||||
expect(store.getWidget(seedA)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('widgetState.value can be read and written directly', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const state = store.registerWidget(
|
||||
graphA,
|
||||
widget('node-1', 'seed', 'number', 100)
|
||||
)
|
||||
expect(state.value).toBe(100)
|
||||
const registered = store.registerWidget(seedA, state('number', 100))
|
||||
expect(registered.value).toBe(100)
|
||||
|
||||
state.value = 200
|
||||
expect(store.getWidget(graphA, 'node-1', 'seed')?.value).toBe(200)
|
||||
registered.value = 200
|
||||
expect(store.getWidget(seedA)?.value).toBe(200)
|
||||
})
|
||||
|
||||
it('stores different value types', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget(graphA, widget('node-1', 'text', 'string', 'hello'))
|
||||
store.registerWidget(graphA, widget('node-1', 'number', 'number', 42))
|
||||
store.registerWidget(graphA, widget('node-1', 'boolean', 'toggle', true))
|
||||
store.registerWidget(
|
||||
graphA,
|
||||
widget('node-1', 'array', 'combo', [1, 2, 3])
|
||||
widgetId(graphA, 'node-1', 'text'),
|
||||
state('string', 'hello')
|
||||
)
|
||||
store.registerWidget(
|
||||
widgetId(graphA, 'node-1', 'number'),
|
||||
state('number', 42)
|
||||
)
|
||||
store.registerWidget(
|
||||
widgetId(graphA, 'node-1', 'boolean'),
|
||||
state('toggle', true)
|
||||
)
|
||||
store.registerWidget(
|
||||
widgetId(graphA, 'node-1', 'array'),
|
||||
state('combo', [1, 2, 3])
|
||||
)
|
||||
|
||||
expect(store.getWidget(graphA, 'node-1', 'text')?.value).toBe('hello')
|
||||
expect(store.getWidget(graphA, 'node-1', 'number')?.value).toBe(42)
|
||||
expect(store.getWidget(graphA, 'node-1', 'boolean')?.value).toBe(true)
|
||||
expect(store.getWidget(graphA, 'node-1', 'array')?.value).toEqual([
|
||||
1, 2, 3
|
||||
])
|
||||
expect(store.getWidget(widgetId(graphA, 'node-1', 'text'))?.value).toBe(
|
||||
'hello'
|
||||
)
|
||||
expect(store.getWidget(widgetId(graphA, 'node-1', 'number'))?.value).toBe(
|
||||
42
|
||||
)
|
||||
expect(
|
||||
store.getWidget(widgetId(graphA, 'node-1', 'boolean'))?.value
|
||||
).toBe(true)
|
||||
expect(
|
||||
store.getWidget(widgetId(graphA, 'node-1', 'array'))?.value
|
||||
).toEqual([1, 2, 3])
|
||||
})
|
||||
})
|
||||
|
||||
describe('widget registration', () => {
|
||||
it('registers a widget with minimal properties', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const state = store.registerWidget(
|
||||
graphA,
|
||||
widget('node-1', 'seed', 'number', 12345)
|
||||
const registered = store.registerWidget(seedA, state('number', 12345))
|
||||
|
||||
expect(registered.nodeId).toBe('node-1')
|
||||
expect(registered.name).toBe('seed')
|
||||
expect(registered.type).toBe('number')
|
||||
expect(registered.value).toBe(12345)
|
||||
expect(registered.disabled).toBeUndefined()
|
||||
expect(registered.serialize).toBeUndefined()
|
||||
expect(registered.options).toEqual({})
|
||||
expect(registered.y).toBe(0)
|
||||
})
|
||||
|
||||
it('registers explicit widget layout y', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const registered = store.registerWidget(
|
||||
seedA,
|
||||
state('number', 12345, { y: 42 })
|
||||
)
|
||||
|
||||
expect(state.nodeId).toBe('node-1')
|
||||
expect(state.name).toBe('seed')
|
||||
expect(state.type).toBe('number')
|
||||
expect(state.value).toBe(12345)
|
||||
expect(state.disabled).toBeUndefined()
|
||||
expect(state.serialize).toBeUndefined()
|
||||
expect(state.options).toEqual({})
|
||||
expect(registered.y).toBe(42)
|
||||
})
|
||||
|
||||
it('registerWidget is idempotent and does not overwrite existing state', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const first = store.registerWidget(seedA, state('number', 11))
|
||||
first.value = 99
|
||||
|
||||
const second = store.registerWidget(seedA, state('number', 11))
|
||||
expect(second).toBe(first)
|
||||
expect(second.value).toBe(99)
|
||||
})
|
||||
|
||||
it('registers a widget with all properties', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const state = store.registerWidget(
|
||||
graphA,
|
||||
widget('node-1', 'prompt', 'string', 'test', {
|
||||
const registered = store.registerWidget(
|
||||
seedA,
|
||||
state('string', 'test', {
|
||||
label: 'Prompt Text',
|
||||
disabled: true,
|
||||
serialize: false,
|
||||
@@ -91,34 +122,38 @@ describe('useWidgetValueStore', () => {
|
||||
})
|
||||
)
|
||||
|
||||
expect(state.label).toBe('Prompt Text')
|
||||
expect(state.disabled).toBe(true)
|
||||
expect(state.serialize).toBe(false)
|
||||
expect(state.options).toEqual({ multiline: true })
|
||||
expect(registered.label).toBe('Prompt Text')
|
||||
expect(registered.disabled).toBe(true)
|
||||
expect(registered.serialize).toBe(false)
|
||||
expect(registered.options).toEqual({ multiline: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('widget getters', () => {
|
||||
it('getWidget returns widget state', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 100))
|
||||
store.registerWidget(seedA, state('number', 100))
|
||||
|
||||
const state = store.getWidget(graphA, 'node-1', 'seed')
|
||||
expect(state).toBeDefined()
|
||||
expect(state?.name).toBe('seed')
|
||||
expect(state?.value).toBe(100)
|
||||
})
|
||||
|
||||
it('getWidget returns undefined for missing widget', () => {
|
||||
const store = useWidgetValueStore()
|
||||
expect(store.getWidget(graphA, 'missing', 'widget')).toBeUndefined()
|
||||
const registered = store.getWidget(seedA)
|
||||
expect(registered).toBeDefined()
|
||||
expect(registered?.name).toBe('seed')
|
||||
expect(registered?.value).toBe(100)
|
||||
})
|
||||
|
||||
it('getNodeWidgets returns all widgets for a node', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 1))
|
||||
store.registerWidget(graphA, widget('node-1', 'steps', 'number', 20))
|
||||
store.registerWidget(graphA, widget('node-2', 'cfg', 'number', 7))
|
||||
store.registerWidget(
|
||||
widgetId(graphA, 'node-1', 'seed'),
|
||||
state('number', 1)
|
||||
)
|
||||
store.registerWidget(
|
||||
widgetId(graphA, 'node-1', 'steps'),
|
||||
state('number', 20)
|
||||
)
|
||||
store.registerWidget(
|
||||
widgetId(graphA, 'node-2', 'cfg'),
|
||||
state('number', 7)
|
||||
)
|
||||
|
||||
const widgets = store.getNodeWidgets(graphA, 'node-1')
|
||||
expect(widgets).toHaveLength(2)
|
||||
@@ -126,54 +161,231 @@ describe('useWidgetValueStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('value mutation', () => {
|
||||
it('setValue updates registered widgets and reports missing widgets', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget(seedA, state('number', 100))
|
||||
|
||||
expect(store.setValue(seedA, 200)).toBe(true)
|
||||
expect(store.getWidget(seedA)?.value).toBe(200)
|
||||
expect(store.setValue(widgetId(graphA, 'missing', 'seed'), 1)).toBe(false)
|
||||
})
|
||||
|
||||
it('deleteWidget removes registered widgets', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget(seedA, state('number', 100))
|
||||
|
||||
expect(store.deleteWidget(seedA)).toBe(true)
|
||||
expect(store.getWidget(seedA)).toBeUndefined()
|
||||
expect(store.deleteWidget(seedA)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('direct property mutation', () => {
|
||||
it('disabled can be set directly via getWidget', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const state = store.registerWidget(
|
||||
graphA,
|
||||
widget('node-1', 'seed', 'number', 100)
|
||||
)
|
||||
const registered = store.registerWidget(seedA, state('number', 100))
|
||||
|
||||
state.disabled = true
|
||||
expect(store.getWidget(graphA, 'node-1', 'seed')?.disabled).toBe(true)
|
||||
registered.disabled = true
|
||||
expect(store.getWidget(seedA)?.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('label can be set directly via getWidget', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const state = store.registerWidget(
|
||||
graphA,
|
||||
widget('node-1', 'seed', 'number', 100)
|
||||
)
|
||||
const registered = store.registerWidget(seedA, state('number', 100))
|
||||
|
||||
state.label = 'Random Seed'
|
||||
expect(store.getWidget(graphA, 'node-1', 'seed')?.label).toBe(
|
||||
'Random Seed'
|
||||
)
|
||||
registered.label = 'Random Seed'
|
||||
expect(store.getWidget(seedA)?.label).toBe('Random Seed')
|
||||
|
||||
state.label = undefined
|
||||
expect(store.getWidget(graphA, 'node-1', 'seed')?.label).toBeUndefined()
|
||||
registered.label = undefined
|
||||
expect(store.getWidget(seedA)?.label).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('graph isolation', () => {
|
||||
it('isolates widget states by graph', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 1))
|
||||
store.registerWidget(graphB, widget('node-1', 'seed', 'number', 2))
|
||||
store.registerWidget(seedA, state('number', 1))
|
||||
store.registerWidget(seedB, state('number', 2))
|
||||
|
||||
expect(store.getWidget(graphA, 'node-1', 'seed')?.value).toBe(1)
|
||||
expect(store.getWidget(graphB, 'node-1', 'seed')?.value).toBe(2)
|
||||
expect(store.getWidget(seedA)?.value).toBe(1)
|
||||
expect(store.getWidget(seedB)?.value).toBe(2)
|
||||
})
|
||||
|
||||
it('clearGraph only removes one graph namespace', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 1))
|
||||
store.registerWidget(graphB, widget('node-1', 'seed', 'number', 2))
|
||||
store.registerWidget(seedA, state('number', 1))
|
||||
store.registerWidget(seedB, state('number', 2))
|
||||
|
||||
store.clearGraph(graphA)
|
||||
|
||||
expect(store.getWidget(graphA, 'node-1', 'seed')).toBeUndefined()
|
||||
expect(store.getWidget(graphB, 'node-1', 'seed')?.value).toBe(2)
|
||||
expect(store.getWidget(seedA)).toBeUndefined()
|
||||
expect(store.getWidget(seedB)?.value).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setInputLinked', () => {
|
||||
it('sets inputLinked to true on a registered widget', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget(seedA, state('number', 1))
|
||||
|
||||
store.setInputLinked(seedA, true)
|
||||
|
||||
expect(store.getWidget(seedA)?.inputLinked).toBe(true)
|
||||
})
|
||||
|
||||
it('sets inputLinked to false on a previously linked widget', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget(seedA, state('number', 1))
|
||||
store.setInputLinked(seedA, true)
|
||||
|
||||
store.setInputLinked(seedA, false)
|
||||
|
||||
expect(store.getWidget(seedA)?.inputLinked).toBe(false)
|
||||
})
|
||||
|
||||
it('is a no-op when the widget is not registered', () => {
|
||||
const store = useWidgetValueStore()
|
||||
// Should not throw even for an unknown id
|
||||
expect(() => store.setInputLinked(seedA, true)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('registerWidgetControl / getWidgetControls / deleteWidgetControl', () => {
|
||||
const controlId = widgetId(graphA, 'node-1', 'control_after_generate')
|
||||
|
||||
it('registers a control component and returns the live state', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget(seedA, state('number', 1))
|
||||
|
||||
const ctrl = store.registerWidgetControl(seedA, {
|
||||
controlWidgetId: controlId
|
||||
})
|
||||
|
||||
expect(ctrl.controlWidgetId).toBe(controlId)
|
||||
expect(ctrl.filterWidgetId).toBeUndefined()
|
||||
expect(ctrl.hasExecuted).toBe(false)
|
||||
})
|
||||
|
||||
it('includes the control in getWidgetControls for the same graph', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget(seedA, state('number', 1))
|
||||
store.registerWidgetControl(seedA, { controlWidgetId: controlId })
|
||||
|
||||
const controls = store.getWidgetControls(graphA)
|
||||
|
||||
expect(controls).toHaveLength(1)
|
||||
expect(controls[0][0]).toBe(seedA)
|
||||
expect(controls[0][1].controlWidgetId).toBe(controlId)
|
||||
})
|
||||
|
||||
it('returns an empty array when no controls are registered', () => {
|
||||
const store = useWidgetValueStore()
|
||||
expect(store.getWidgetControls(graphA)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('preserves hasExecuted when re-registering the same control and filter ids', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget(seedA, state('number', 1))
|
||||
const ctrl = store.registerWidgetControl(seedA, {
|
||||
controlWidgetId: controlId
|
||||
})
|
||||
ctrl.hasExecuted = true
|
||||
|
||||
const ctrl2 = store.registerWidgetControl(seedA, {
|
||||
controlWidgetId: controlId
|
||||
})
|
||||
|
||||
expect(ctrl2.hasExecuted).toBe(true)
|
||||
})
|
||||
|
||||
it('resets hasExecuted when re-registering with a different control widget id', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const otherControlId = widgetId(graphA, 'node-1', 'other_control')
|
||||
store.registerWidget(seedA, state('number', 1))
|
||||
const ctrl = store.registerWidgetControl(seedA, {
|
||||
controlWidgetId: controlId
|
||||
})
|
||||
ctrl.hasExecuted = true
|
||||
|
||||
const ctrl2 = store.registerWidgetControl(seedA, {
|
||||
controlWidgetId: otherControlId
|
||||
})
|
||||
|
||||
expect(ctrl2.hasExecuted).toBe(false)
|
||||
})
|
||||
|
||||
it('stores and returns optional filterWidgetId', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const filterId = widgetId(graphA, 'node-1', 'filter')
|
||||
store.registerWidget(seedA, state('number', 1))
|
||||
|
||||
const ctrl = store.registerWidgetControl(seedA, {
|
||||
controlWidgetId: controlId,
|
||||
filterWidgetId: filterId
|
||||
})
|
||||
|
||||
expect(ctrl.filterWidgetId).toBe(filterId)
|
||||
})
|
||||
|
||||
it('deleteWidgetControl removes the control and returns true', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget(seedA, state('number', 1))
|
||||
store.registerWidgetControl(seedA, { controlWidgetId: controlId })
|
||||
|
||||
expect(store.deleteWidgetControl(seedA)).toBe(true)
|
||||
expect(store.getWidgetControls(graphA)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('deleteWidgetControl returns false for an unregistered target', () => {
|
||||
const store = useWidgetValueStore()
|
||||
expect(store.deleteWidgetControl(seedA)).toBe(false)
|
||||
})
|
||||
|
||||
it('isolates controls across graphs', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const controlB = widgetId(graphB, 'node-1', 'control_after_generate')
|
||||
store.registerWidget(seedA, state('number', 1))
|
||||
store.registerWidget(seedB, state('number', 2))
|
||||
store.registerWidgetControl(seedA, { controlWidgetId: controlId })
|
||||
store.registerWidgetControl(seedB, { controlWidgetId: controlB })
|
||||
|
||||
expect(store.getWidgetControls(graphA)).toHaveLength(1)
|
||||
expect(store.getWidgetControls(graphB)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteWidget also removes the associated control', () => {
|
||||
it('removes the widget control entry when the widget is deleted', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const controlId = widgetId(graphA, 'node-1', 'control_after_generate')
|
||||
store.registerWidget(seedA, state('number', 1))
|
||||
store.registerWidgetControl(seedA, { controlWidgetId: controlId })
|
||||
expect(store.getWidgetControls(graphA)).toHaveLength(1)
|
||||
|
||||
store.deleteWidget(seedA)
|
||||
|
||||
expect(store.getWidgetControls(graphA)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearGraph also removes controls', () => {
|
||||
it('removes both widget states and control entries for the cleared graph', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const controlId = widgetId(graphA, 'node-1', 'control_after_generate')
|
||||
const controlB = widgetId(graphB, 'node-1', 'control_after_generate')
|
||||
store.registerWidget(seedA, state('number', 1))
|
||||
store.registerWidget(seedB, state('number', 2))
|
||||
store.registerWidgetControl(seedA, { controlWidgetId: controlId })
|
||||
store.registerWidgetControl(seedB, { controlWidgetId: controlB })
|
||||
|
||||
store.clearGraph(graphA)
|
||||
|
||||
expect(store.getWidget(seedA)).toBeUndefined()
|
||||
expect(store.getWidgetControls(graphA)).toHaveLength(0)
|
||||
// Other graph should be unaffected
|
||||
expect(store.getWidget(seedB)?.value).toBe(2)
|
||||
expect(store.getWidgetControls(graphB)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user