mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-09 01:20:09 +00:00
## Summary 1. Add a `getOptionLabel` option to `ComboWidget` so users of it can map of custom labels to widget values. (e.g., `"My Photo" -> "my_photo_1235.png"`). 2. Utilize this ability in Cloud environment to map user uploaded filenames to their corresponding input asset. 3. Copious unit tests to make sure I didn't (AFAIK) break anything during the refactoring portion of development. 4. Bonus: Scope model browser to only show in cloud distributions until it's released elsewhere; should prevent some undesired UI behavior if a user accidentally enables the assetAPI. ## Review Focus Widget code: please double check the work there. ## Screenshots (if applicable) https://github.com/user-attachments/assets/a94b3203-c87f-4285-b692-479996859a5a ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-6585-Feat-input-mapping-2a26d73d365081faa667e49892c8d45a) by [Unito](https://www.unito.io)
1089 lines
31 KiB
TypeScript
1089 lines
31 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
|
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
|
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
|
|
import { ComboWidget } from '@/lib/litegraph/src/widgets/ComboWidget'
|
|
|
|
const { LGraphCanvas } = await vi.importActual<
|
|
typeof import('@/lib/litegraph/src/LGraphCanvas')
|
|
>('@/lib/litegraph/src/LGraphCanvas')
|
|
type LGraphCanvasType = InstanceType<typeof LGraphCanvas>
|
|
|
|
type ContextMenuInstance = {
|
|
addItem?: (
|
|
name: string,
|
|
value: string,
|
|
options: { callback?: (value: string) => void; className?: string }
|
|
) => void
|
|
}
|
|
|
|
interface MockWidgetConfig extends Omit<IComboWidget, 'options'> {
|
|
options: IComboWidget['options']
|
|
}
|
|
|
|
function createMockWidgetConfig(
|
|
overrides: Partial<MockWidgetConfig> = {}
|
|
): MockWidgetConfig {
|
|
return {
|
|
type: 'combo',
|
|
name: 'test',
|
|
value: '',
|
|
options: { values: [] },
|
|
y: 0,
|
|
...overrides
|
|
}
|
|
}
|
|
|
|
function setupIncrementDecrementTest() {
|
|
const mockCanvas = {
|
|
ds: { scale: 1 },
|
|
last_mouseclick: 1
|
|
} as LGraphCanvasType
|
|
const mockEvent = {} as CanvasPointerEvent
|
|
return { mockCanvas, mockEvent }
|
|
}
|
|
|
|
describe('ComboWidget', () => {
|
|
let node: LGraphNode
|
|
let widget: ComboWidget
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
node = new LGraphNode('TestNode')
|
|
})
|
|
|
|
describe('_displayValue', () => {
|
|
it('should return value as-is for array values', () => {
|
|
widget = new ComboWidget(
|
|
createMockWidgetConfig({
|
|
name: 'mode',
|
|
value: 'fast',
|
|
options: { values: ['fast', 'slow', 'medium'] }
|
|
}),
|
|
node
|
|
)
|
|
|
|
expect(widget._displayValue).toBe('fast')
|
|
})
|
|
|
|
it('should return mapped value for object values', () => {
|
|
widget = new ComboWidget(
|
|
createMockWidgetConfig({
|
|
name: 'quality',
|
|
value: 'hq',
|
|
options: {
|
|
values: {
|
|
hq: 'High Quality',
|
|
mq: 'Medium Quality',
|
|
lq: 'Low Quality'
|
|
}
|
|
}
|
|
}),
|
|
node
|
|
)
|
|
|
|
expect(widget._displayValue).toBe('High Quality')
|
|
})
|
|
|
|
it('should return empty string when disabled', () => {
|
|
widget = new ComboWidget(
|
|
createMockWidgetConfig({
|
|
name: 'mode',
|
|
value: 'fast',
|
|
options: { values: ['fast', 'slow'] },
|
|
computedDisabled: true
|
|
}),
|
|
node
|
|
)
|
|
|
|
expect(widget._displayValue).toBe('')
|
|
})
|
|
|
|
it('should convert number values to string before display', () => {
|
|
widget = new ComboWidget(
|
|
createMockWidgetConfig({
|
|
name: 'index',
|
|
value: 42,
|
|
options: { values: ['0', '1', '42'] }
|
|
}),
|
|
node
|
|
)
|
|
|
|
expect(widget._displayValue).toBe('42')
|
|
})
|
|
})
|
|
|
|
describe('canIncrement / canDecrement', () => {
|
|
it('should return true when not at end/start of list', () => {
|
|
widget = new ComboWidget(
|
|
createMockWidgetConfig({
|
|
name: 'mode',
|
|
value: 'medium',
|
|
options: { values: ['fast', 'medium', 'slow'] }
|
|
}),
|
|
node
|
|
)
|
|
|
|
expect(widget.canIncrement()).toBe(true)
|
|
expect(widget.canDecrement()).toBe(true)
|
|
})
|
|
|
|
it('should return false from canDecrement when at first value', () => {
|
|
widget = new ComboWidget(
|
|
createMockWidgetConfig({
|
|
name: 'mode',
|
|
value: 'fast',
|
|
options: { values: ['fast', 'medium', 'slow'] }
|
|
}),
|
|
node
|
|
)
|
|
|
|
expect(widget.canDecrement()).toBe(false)
|
|
expect(widget.canIncrement()).toBe(true)
|
|
})
|
|
|
|
it('should return false from canIncrement when at last value', () => {
|
|
widget = new ComboWidget(
|
|
createMockWidgetConfig({
|
|
name: 'mode',
|
|
value: 'slow',
|
|
options: { values: ['fast', 'medium', 'slow'] }
|
|
}),
|
|
node
|
|
)
|
|
|
|
expect(widget.canIncrement()).toBe(false)
|
|
expect(widget.canDecrement()).toBe(true)
|
|
})
|
|
|
|
it('should return false when list has only one item', () => {
|
|
widget = new ComboWidget(
|
|
createMockWidgetConfig({
|
|
name: 'mode',
|
|
value: 'only',
|
|
options: { values: ['only'] }
|
|
}),
|
|
node
|
|
)
|
|
|
|
expect(widget.canIncrement()).toBe(false)
|
|
expect(widget.canDecrement()).toBe(false)
|
|
})
|
|
|
|
it('should allow increment/decrement when duplicate values exist at different indices', () => {
|
|
widget = new ComboWidget(
|
|
createMockWidgetConfig({
|
|
name: 'mode',
|
|
value: 'duplicate',
|
|
options: { values: ['duplicate', 'other', 'duplicate'] }
|
|
}),
|
|
node
|
|
)
|
|
|
|
expect(widget.canIncrement()).toBe(true)
|
|
expect(widget.canDecrement()).toBe(true)
|
|
})
|
|
|
|
it('should return false for function values (DEPRECATED - legacy duck-typed behavior)', () => {
|
|
const valuesFn = vi.fn().mockReturnValue(['a', 'b', 'c'])
|
|
widget = new ComboWidget(
|
|
createMockWidgetConfig({
|
|
name: 'mode',
|
|
value: 'b',
|
|
options: { values: valuesFn }
|
|
}),
|
|
node
|
|
)
|
|
|
|
// Function values are legacy - should be permissive (return false)
|
|
expect(widget.canIncrement()).toBe(false)
|
|
expect(widget.canDecrement()).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('incrementValue / decrementValue', () => {
|
|
it('should increment value to next in list', () => {
|
|
widget = new ComboWidget(
|
|
createMockWidgetConfig({
|
|
name: 'mode',
|
|
value: 'fast',
|
|
options: { values: ['fast', 'medium', 'slow'] }
|
|
}),
|
|
node
|
|
)
|
|
|
|
const { mockCanvas, mockEvent } = setupIncrementDecrementTest()
|
|
|
|
const setValueSpy = vi.spyOn(widget, 'setValue')
|
|
widget.incrementValue({ e: mockEvent, node, canvas: mockCanvas })
|
|
|
|
expect(setValueSpy).toHaveBeenCalledWith('medium', {
|
|
e: mockEvent,
|
|
node,
|
|
canvas: mockCanvas
|
|
})
|
|
expect(mockCanvas.last_mouseclick).toBe(0) // Avoid double click event
|
|
})
|
|
|
|
it('should decrement value to previous in list', () => {
|
|
widget = new ComboWidget(
|
|
createMockWidgetConfig({
|
|
name: 'mode',
|
|
value: 'medium',
|
|
options: { values: ['fast', 'medium', 'slow'] }
|
|
}),
|
|
node
|
|
)
|
|
|
|
const { mockCanvas, mockEvent } = setupIncrementDecrementTest()
|
|
|
|
const setValueSpy = vi.spyOn(widget, 'setValue')
|
|
widget.decrementValue({ e: mockEvent, node, canvas: mockCanvas })
|
|
|
|
expect(setValueSpy).toHaveBeenCalledWith('fast', {
|
|
e: mockEvent,
|
|
node,
|
|
canvas: mockCanvas
|
|
})
|
|
expect(mockCanvas.last_mouseclick).toBe(0)
|
|
})
|
|
|
|
it('should clamp at last value when incrementing beyond', () => {
|
|
widget = new ComboWidget(
|
|
createMockWidgetConfig({
|
|
name: 'mode',
|
|
value: 'slow',
|
|
options: { values: ['fast', 'medium', 'slow'] }
|
|
}),
|
|
node
|
|
)
|
|
|
|
const { mockCanvas, mockEvent } = setupIncrementDecrementTest()
|
|
|
|
const setValueSpy = vi.spyOn(widget, 'setValue')
|
|
widget.incrementValue({ e: mockEvent, node, canvas: mockCanvas })
|
|
|
|
// Should stay at 'slow' (last value)
|
|
expect(setValueSpy).toHaveBeenCalledWith('slow', {
|
|
e: mockEvent,
|
|
node,
|
|
canvas: mockCanvas
|
|
})
|
|
})
|
|
|
|
it('should clamp at first value when decrementing beyond', () => {
|
|
widget = new ComboWidget(
|
|
createMockWidgetConfig({
|
|
name: 'mode',
|
|
value: 'fast',
|
|
options: { values: ['fast', 'medium', 'slow'] }
|
|
}),
|
|
node
|
|
)
|
|
|
|
const { mockCanvas, mockEvent } = setupIncrementDecrementTest()
|
|
|
|
const setValueSpy = vi.spyOn(widget, 'setValue')
|
|
widget.decrementValue({ e: mockEvent, node, canvas: mockCanvas })
|
|
|
|
// Should stay at 'fast' (first value)
|
|
expect(setValueSpy).toHaveBeenCalledWith('fast', {
|
|
e: mockEvent,
|
|
node,
|
|
canvas: mockCanvas
|
|
})
|
|
})
|
|
|
|
it('should set value to index position when incrementing object-type values', () => {
|
|
widget = new ComboWidget(
|
|
createMockWidgetConfig({
|
|
name: 'quality',
|
|
value: 'hq',
|
|
options: {
|
|
values: {
|
|
hq: 'High Quality',
|
|
mq: 'Medium Quality'
|
|
}
|
|
}
|
|
}),
|
|
node
|
|
)
|
|
|
|
const { mockCanvas, mockEvent } = setupIncrementDecrementTest()
|
|
|
|
const setValueSpy = vi.spyOn(widget, 'setValue')
|
|
widget.incrementValue({ e: mockEvent, node, canvas: mockCanvas })
|
|
|
|
// For object values, setValue receives the index
|
|
expect(setValueSpy).toHaveBeenCalledWith(1, {
|
|
e: mockEvent,
|
|
node,
|
|
canvas: mockCanvas
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('onClick', () => {
|
|
it('should decrement value when left arrow clicked', () => {
|
|
widget = new ComboWidget(
|
|
createMockWidgetConfig({
|
|
name: 'mode',
|
|
value: 'medium',
|
|
options: { values: ['fast', 'medium', 'slow'] }
|
|
}),
|
|
node
|
|
)
|
|
|
|
const mockCanvas = {
|
|
ds: { scale: 1 },
|
|
last_mouseclick: 0
|
|
} as LGraphCanvasType
|
|
const mockEvent = { canvasX: 60 } as CanvasPointerEvent // 60 - 50 = 10 < 40 (left arrow)
|
|
node.pos = [50, 50]
|
|
|
|
const decrementSpy = vi.spyOn(widget, 'decrementValue')
|
|
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
|
|
|
|
expect(decrementSpy).toHaveBeenCalledWith({
|
|
e: mockEvent,
|
|
node,
|
|
canvas: mockCanvas
|
|
})
|
|
})
|
|
|
|
it('should increment value when right arrow clicked', () => {
|
|
widget = new ComboWidget(
|
|
createMockWidgetConfig({
|
|
name: 'mode',
|
|
value: 'medium',
|
|
options: { values: ['fast', 'medium', 'slow'] }
|
|
}),
|
|
node
|
|
)
|
|
|
|
const mockCanvas = {
|
|
ds: { scale: 1 },
|
|
last_mouseclick: 0
|
|
} as LGraphCanvasType
|
|
const mockEvent = { canvasX: 240 } as CanvasPointerEvent
|
|
node.pos = [50, 50]
|
|
node.size = [200, 30]
|
|
|
|
const incrementSpy = vi.spyOn(widget, 'incrementValue')
|
|
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
|
|
|
|
expect(incrementSpy).toHaveBeenCalledWith({
|
|
e: mockEvent,
|
|
node,
|
|
canvas: mockCanvas
|
|
})
|
|
})
|
|
|
|
it('should show dropdown menu when clicking center area with array values', () => {
|
|
widget = new ComboWidget(
|
|
createMockWidgetConfig({
|
|
name: 'mode',
|
|
value: 'medium',
|
|
options: { values: ['fast', 'medium', 'slow'] }
|
|
}),
|
|
node
|
|
)
|
|
|
|
const mockCanvas = { ds: { scale: 1 } } as LGraphCanvasType
|
|
const mockEvent = { canvasX: 150 } as CanvasPointerEvent
|
|
node.pos = [50, 50]
|
|
node.size = [200, 30]
|
|
|
|
const mockContextMenu = vi.fn()
|
|
LiteGraph.ContextMenu =
|
|
mockContextMenu as unknown as typeof LiteGraph.ContextMenu
|
|
|
|
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
|
|
|
|
expect(mockContextMenu).toHaveBeenCalledWith(
|
|
['fast', 'medium', 'slow'],
|
|
expect.objectContaining({
|
|
scale: 1,
|
|
event: mockEvent,
|
|
className: 'dark'
|
|
})
|
|
)
|
|
})
|
|
|
|
it('should show dropdown menu with object display values', () => {
|
|
widget = new ComboWidget(
|
|
createMockWidgetConfig({
|
|
name: 'quality',
|
|
value: 'mq',
|
|
options: {
|
|
values: {
|
|
hq: 'High Quality',
|
|
mq: 'Medium Quality',
|
|
lq: 'Low Quality'
|
|
}
|
|
}
|
|
}),
|
|
node
|
|
)
|
|
|
|
const mockCanvas = { ds: { scale: 1 } } as LGraphCanvasType
|
|
const mockEvent = { canvasX: 150 } as CanvasPointerEvent
|
|
node.pos = [50, 50]
|
|
node.size = [200, 30]
|
|
|
|
const mockContextMenu = vi.fn()
|
|
LiteGraph.ContextMenu =
|
|
mockContextMenu as unknown as typeof LiteGraph.ContextMenu
|
|
|
|
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
|
|
|
|
// Should show the display values (values), not keys
|
|
expect(mockContextMenu).toHaveBeenCalledWith(
|
|
['High Quality', 'Medium Quality', 'Low Quality'],
|
|
expect.objectContaining({
|
|
scale: 1,
|
|
event: mockEvent,
|
|
className: 'dark'
|
|
})
|
|
)
|
|
})
|
|
|
|
it('should set value when selecting from dropdown with array values', () => {
|
|
widget = new ComboWidget(
|
|
createMockWidgetConfig({
|
|
name: 'mode',
|
|
value: 'fast',
|
|
options: { values: ['fast', 'medium', 'slow'] }
|
|
}),
|
|
node
|
|
)
|
|
|
|
const mockCanvas = { ds: { scale: 1 } } as LGraphCanvasType
|
|
const mockEvent = { canvasX: 150 } as CanvasPointerEvent
|
|
node.pos = [50, 50]
|
|
node.size = [200, 30]
|
|
|
|
let capturedCallback: ((value: string) => void) | undefined
|
|
const mockContextMenu = vi.fn((_values, options) => {
|
|
capturedCallback = options.callback
|
|
return {} as ContextMenuInstance
|
|
})
|
|
LiteGraph.ContextMenu =
|
|
mockContextMenu as unknown as typeof LiteGraph.ContextMenu
|
|
|
|
const setValueSpy = vi.spyOn(widget, 'setValue')
|
|
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
|
|
|
|
// Simulate selecting 'slow' from dropdown
|
|
capturedCallback?.('slow')
|
|
|
|
expect(setValueSpy).toHaveBeenCalledWith('slow', {
|
|
e: mockEvent,
|
|
node,
|
|
canvas: mockCanvas
|
|
})
|
|
})
|
|
|
|
it('should set value to selected index for object-type values', () => {
|
|
widget = new ComboWidget(
|
|
createMockWidgetConfig({
|
|
name: 'quality',
|
|
value: 'hq',
|
|
options: {
|
|
values: {
|
|
hq: 'High Quality',
|
|
mq: 'Medium Quality',
|
|
lq: 'Low Quality'
|
|
}
|
|
}
|
|
}),
|
|
node
|
|
)
|
|
|
|
const mockCanvas = { ds: { scale: 1 } } as LGraphCanvasType
|
|
const mockEvent = { canvasX: 150 } as CanvasPointerEvent
|
|
node.pos = [50, 50]
|
|
node.size = [200, 30]
|
|
|
|
let capturedCallback: ((value: string) => void) | undefined
|
|
const mockContextMenu = vi.fn((_values, options) => {
|
|
capturedCallback = options.callback
|
|
return {} as ContextMenuInstance
|
|
})
|
|
LiteGraph.ContextMenu =
|
|
mockContextMenu as unknown as typeof LiteGraph.ContextMenu
|
|
|
|
const setValueSpy = vi.spyOn(widget, 'setValue')
|
|
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
|
|
|
|
// Simulate selecting 'Medium Quality' (index 1) from dropdown
|
|
capturedCallback?.('Medium Quality')
|
|
|
|
expect(setValueSpy).toHaveBeenCalledWith(1, {
|
|
e: mockEvent,
|
|
node,
|
|
canvas: mockCanvas
|
|
})
|
|
})
|
|
|
|
it('should prevent menu scaling below 100%', () => {
|
|
widget = new ComboWidget(
|
|
createMockWidgetConfig({
|
|
name: 'mode',
|
|
value: 'fast',
|
|
options: { values: ['fast', 'slow'] }
|
|
}),
|
|
node
|
|
)
|
|
|
|
const mockCanvas = { ds: { scale: 0.5 } } as LGraphCanvasType
|
|
const mockEvent = { canvasX: 150 } as CanvasPointerEvent
|
|
node.pos = [50, 50]
|
|
node.size = [200, 30]
|
|
|
|
const mockContextMenu = vi.fn()
|
|
LiteGraph.ContextMenu =
|
|
mockContextMenu as unknown as typeof LiteGraph.ContextMenu
|
|
|
|
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
|
|
|
|
expect(mockContextMenu).toHaveBeenCalledWith(
|
|
expect.any(Array),
|
|
expect.objectContaining({
|
|
scale: 1 // Math.max(1, 0.5) = 1
|
|
})
|
|
)
|
|
})
|
|
|
|
it('should warn when using deprecated function values', () => {
|
|
const deprecationCallback = vi.fn()
|
|
const originalCallbacks = LiteGraph.onDeprecationWarning
|
|
LiteGraph.onDeprecationWarning = [deprecationCallback]
|
|
|
|
const valuesFn = vi.fn().mockReturnValue(['a', 'b', 'c'])
|
|
widget = new ComboWidget(
|
|
createMockWidgetConfig({
|
|
name: 'mode',
|
|
value: 'a',
|
|
options: { values: valuesFn }
|
|
}),
|
|
node
|
|
)
|
|
|
|
const mockCanvas = { ds: { scale: 1 } } as LGraphCanvasType
|
|
const mockEvent = { canvasX: 150 } as CanvasPointerEvent
|
|
node.pos = [50, 50]
|
|
node.size = [200, 30]
|
|
|
|
const mockContextMenu = vi.fn()
|
|
LiteGraph.ContextMenu =
|
|
mockContextMenu as unknown as typeof LiteGraph.ContextMenu
|
|
|
|
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
|
|
|
|
expect(deprecationCallback).toHaveBeenCalledWith(
|
|
'Using a function for values is deprecated. Use an array of unique values instead.',
|
|
undefined
|
|
)
|
|
|
|
LiteGraph.onDeprecationWarning = originalCallbacks
|
|
})
|
|
})
|
|
|
|
describe('with getOptionLabel', () => {
|
|
const HASH_FILENAME =
|
|
'72e786ff2a44d682c4294db0b7098e569832bc394efc6dad644e6ec85a78efb7.png'
|
|
const HASH_FILENAME_2 =
|
|
'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456.jpg'
|
|
|
|
describe('_displayValue', () => {
|
|
it('should return formatted value when getOptionLabel provided', () => {
|
|
const mockGetOptionLabel = vi
|
|
.fn()
|
|
.mockReturnValue('Beautiful Sunset.png')
|
|
|
|
widget = new ComboWidget(
|
|
createMockWidgetConfig({
|
|
name: 'image',
|
|
value: HASH_FILENAME,
|
|
options: {
|
|
values: [HASH_FILENAME],
|
|
getOptionLabel: mockGetOptionLabel
|
|
}
|
|
}),
|
|
node
|
|
)
|
|
|
|
expect(widget._displayValue).toBe('Beautiful Sunset.png')
|
|
expect(mockGetOptionLabel).toHaveBeenCalledWith(HASH_FILENAME)
|
|
})
|
|
|
|
it('should return original value when getOptionLabel not provided', () => {
|
|
widget = new ComboWidget(
|
|
createMockWidgetConfig({
|
|
name: 'image',
|
|
value: HASH_FILENAME,
|
|
options: { values: [HASH_FILENAME] }
|
|
}),
|
|
node
|
|
)
|
|
|
|
expect(widget._displayValue).toBe(HASH_FILENAME)
|
|
})
|
|
|
|
it('should not call getOptionLabel when disabled', () => {
|
|
const mockGetOptionLabel = vi.fn()
|
|
|
|
widget = new ComboWidget(
|
|
createMockWidgetConfig({
|
|
name: 'image',
|
|
value: HASH_FILENAME,
|
|
options: {
|
|
values: [HASH_FILENAME],
|
|
getOptionLabel: mockGetOptionLabel
|
|
},
|
|
computedDisabled: true
|
|
}),
|
|
node
|
|
)
|
|
|
|
expect(widget._displayValue).toBe('')
|
|
expect(mockGetOptionLabel).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('should handle getOptionLabel error gracefully', () => {
|
|
const mockGetOptionLabel = vi.fn().mockImplementation(() => {
|
|
throw new Error('Formatting failed')
|
|
})
|
|
const consoleErrorSpy = vi
|
|
.spyOn(console, 'error')
|
|
.mockImplementation(() => {})
|
|
|
|
widget = new ComboWidget(
|
|
createMockWidgetConfig({
|
|
name: 'image',
|
|
value: HASH_FILENAME,
|
|
options: {
|
|
values: [HASH_FILENAME],
|
|
getOptionLabel: mockGetOptionLabel
|
|
}
|
|
}),
|
|
node
|
|
)
|
|
|
|
expect(widget._displayValue).toBe(HASH_FILENAME)
|
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
'Failed to map value:',
|
|
expect.any(Error)
|
|
)
|
|
|
|
consoleErrorSpy.mockRestore()
|
|
})
|
|
|
|
it('should format non-hash filenames using getOptionLabel', () => {
|
|
const mockGetOptionLabel = vi.fn((value) => `Formatted ${value}`)
|
|
|
|
widget = new ComboWidget(
|
|
createMockWidgetConfig({
|
|
name: 'file',
|
|
value: 'regular-file.png',
|
|
options: {
|
|
values: ['regular-file.png'],
|
|
getOptionLabel: mockGetOptionLabel
|
|
}
|
|
}),
|
|
node
|
|
)
|
|
|
|
expect(widget._displayValue).toBe('Formatted regular-file.png')
|
|
expect(mockGetOptionLabel).toHaveBeenCalledWith('regular-file.png')
|
|
})
|
|
|
|
it('should use getOptionLabel over object value mapping when both present', () => {
|
|
const mockGetOptionLabel = vi.fn((value) => `Label: ${value}`)
|
|
|
|
widget = new ComboWidget(
|
|
createMockWidgetConfig({
|
|
name: 'quality',
|
|
value: 'hq',
|
|
options: {
|
|
values: {
|
|
hq: 'High Quality',
|
|
mq: 'Medium Quality'
|
|
},
|
|
getOptionLabel: mockGetOptionLabel
|
|
}
|
|
}),
|
|
node
|
|
)
|
|
|
|
// getOptionLabel should take precedence over object value mapping
|
|
expect(widget._displayValue).toBe('Label: hq')
|
|
expect(mockGetOptionLabel).toHaveBeenCalledWith('hq')
|
|
})
|
|
|
|
it('should format number values using getOptionLabel when provided', () => {
|
|
const mockGetOptionLabel = vi.fn((value) => `Number: ${value}`)
|
|
|
|
widget = new ComboWidget(
|
|
createMockWidgetConfig({
|
|
name: 'index',
|
|
value: 42,
|
|
options: {
|
|
values: ['0', '1', '42'],
|
|
getOptionLabel: mockGetOptionLabel
|
|
}
|
|
}),
|
|
node
|
|
)
|
|
|
|
expect(widget._displayValue).toBe('Number: 42')
|
|
expect(mockGetOptionLabel).toHaveBeenCalledWith('42')
|
|
})
|
|
})
|
|
|
|
describe('onClick', () => {
|
|
it('should show dropdown with formatted labels', () => {
|
|
const mockGetOptionLabel = vi
|
|
.fn()
|
|
.mockReturnValueOnce('Beautiful Sunset.png')
|
|
.mockReturnValueOnce('Mountain Vista.jpg')
|
|
|
|
widget = new ComboWidget(
|
|
createMockWidgetConfig({
|
|
name: 'image',
|
|
value: HASH_FILENAME,
|
|
options: {
|
|
values: [HASH_FILENAME, HASH_FILENAME_2],
|
|
getOptionLabel: mockGetOptionLabel
|
|
}
|
|
}),
|
|
node
|
|
)
|
|
|
|
const mockCanvas = { ds: { scale: 1 } } as LGraphCanvasType
|
|
const mockEvent = { canvasX: 150 } as CanvasPointerEvent
|
|
node.pos = [50, 50]
|
|
node.size = [200, 30]
|
|
|
|
const mockAddItem = vi.fn()
|
|
const mockContextMenu = vi.fn(() => ({ addItem: mockAddItem }))
|
|
LiteGraph.ContextMenu =
|
|
mockContextMenu as unknown as typeof LiteGraph.ContextMenu
|
|
|
|
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
|
|
|
|
// Should show formatted labels in dropdown
|
|
expect(mockContextMenu).toHaveBeenCalledWith(
|
|
[],
|
|
expect.objectContaining({
|
|
scale: 1,
|
|
event: mockEvent,
|
|
className: 'dark'
|
|
})
|
|
)
|
|
|
|
expect(mockAddItem).toHaveBeenCalledWith(
|
|
'Beautiful Sunset.png',
|
|
HASH_FILENAME,
|
|
expect.objectContaining({
|
|
callback: expect.any(Function),
|
|
className: 'dark'
|
|
})
|
|
)
|
|
expect(mockAddItem).toHaveBeenCalledWith(
|
|
'Mountain Vista.jpg',
|
|
HASH_FILENAME_2,
|
|
expect.objectContaining({
|
|
callback: expect.any(Function),
|
|
className: 'dark'
|
|
})
|
|
)
|
|
})
|
|
|
|
it('should set original value when selecting formatted label from dropdown', () => {
|
|
const mockGetOptionLabel = vi
|
|
.fn()
|
|
.mockReturnValueOnce('Beautiful Sunset.png')
|
|
.mockReturnValueOnce('Mountain Vista.jpg')
|
|
|
|
widget = new ComboWidget(
|
|
createMockWidgetConfig({
|
|
name: 'image',
|
|
value: HASH_FILENAME,
|
|
options: {
|
|
values: [HASH_FILENAME, HASH_FILENAME_2],
|
|
getOptionLabel: mockGetOptionLabel
|
|
}
|
|
}),
|
|
node
|
|
)
|
|
|
|
const mockCanvas = { ds: { scale: 1 } } as LGraphCanvasType
|
|
const mockEvent = { canvasX: 150 } as CanvasPointerEvent
|
|
node.pos = [50, 50]
|
|
node.size = [200, 30]
|
|
|
|
const mockAddItem = vi.fn()
|
|
let capturedCallback: ((value: string) => void) | undefined
|
|
const mockContextMenu = vi.fn((_values, options) => {
|
|
capturedCallback = options.callback
|
|
return { addItem: mockAddItem }
|
|
})
|
|
LiteGraph.ContextMenu =
|
|
mockContextMenu as unknown as typeof LiteGraph.ContextMenu
|
|
|
|
const setValueSpy = vi.spyOn(widget, 'setValue')
|
|
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
|
|
|
|
// Simulate selecting second item (Mountain Vista.jpg -> HASH_FILENAME_2)
|
|
capturedCallback?.(HASH_FILENAME_2)
|
|
|
|
// Should set the actual hash value, not the formatted label
|
|
expect(setValueSpy).toHaveBeenCalledWith(HASH_FILENAME_2, {
|
|
e: mockEvent,
|
|
node,
|
|
canvas: mockCanvas
|
|
})
|
|
})
|
|
|
|
it('should preserve value identity when multiple options have same display label', () => {
|
|
const mockGetOptionLabel = vi
|
|
.fn()
|
|
.mockReturnValueOnce('sunset.png')
|
|
.mockReturnValueOnce('sunset.png') // Same label, different values
|
|
.mockReturnValueOnce('mountain.png')
|
|
|
|
const hash1 = HASH_FILENAME
|
|
const hash2 = HASH_FILENAME_2
|
|
const hash3 = 'abc123def456.png'
|
|
|
|
widget = new ComboWidget(
|
|
createMockWidgetConfig({
|
|
name: 'image',
|
|
value: hash1,
|
|
options: {
|
|
values: [hash1, hash2, hash3],
|
|
getOptionLabel: mockGetOptionLabel
|
|
}
|
|
}),
|
|
node
|
|
)
|
|
|
|
const mockCanvas = { ds: { scale: 1 } } as LGraphCanvasType
|
|
const mockEvent = { canvasX: 150 } as CanvasPointerEvent
|
|
node.pos = [50, 50]
|
|
node.size = [200, 30]
|
|
|
|
const mockAddItem = vi.fn()
|
|
let capturedCallback: ((value: string) => void) | undefined
|
|
|
|
const mockContextMenu = vi.fn((_values, options) => {
|
|
capturedCallback = options.callback
|
|
return { addItem: mockAddItem } as ContextMenuInstance
|
|
})
|
|
LiteGraph.ContextMenu =
|
|
mockContextMenu as unknown as typeof LiteGraph.ContextMenu
|
|
|
|
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
|
|
|
|
// Should use addItem API with separate name/value
|
|
expect(mockAddItem).toHaveBeenCalledWith(
|
|
'sunset.png',
|
|
hash1,
|
|
expect.objectContaining({
|
|
callback: expect.any(Function),
|
|
className: 'dark'
|
|
})
|
|
)
|
|
expect(mockAddItem).toHaveBeenCalledWith(
|
|
'sunset.png',
|
|
hash2,
|
|
expect.objectContaining({
|
|
callback: expect.any(Function),
|
|
className: 'dark'
|
|
})
|
|
)
|
|
expect(mockAddItem).toHaveBeenCalledWith(
|
|
'mountain.png',
|
|
hash3,
|
|
expect.objectContaining({
|
|
callback: expect.any(Function),
|
|
className: 'dark'
|
|
})
|
|
)
|
|
|
|
const setValueSpy = vi.spyOn(widget, 'setValue')
|
|
|
|
// Simulate selecting the SECOND "sunset.png" (should pass hash2 directly)
|
|
capturedCallback?.(hash2)
|
|
|
|
// Should set hash2, not hash1 (fixes duplicate name bug)
|
|
expect(setValueSpy).toHaveBeenCalledWith(hash2, {
|
|
e: mockEvent,
|
|
node,
|
|
canvas: mockCanvas
|
|
})
|
|
})
|
|
|
|
it('should handle getOptionLabel error in dropdown gracefully', () => {
|
|
const mockGetOptionLabel = vi
|
|
.fn()
|
|
.mockReturnValueOnce('Beautiful Sunset.png')
|
|
.mockImplementationOnce(() => {
|
|
throw new Error('Formatting failed')
|
|
})
|
|
|
|
widget = new ComboWidget(
|
|
createMockWidgetConfig({
|
|
name: 'image',
|
|
value: HASH_FILENAME,
|
|
options: {
|
|
values: [HASH_FILENAME, HASH_FILENAME_2],
|
|
getOptionLabel: mockGetOptionLabel
|
|
}
|
|
}),
|
|
node
|
|
)
|
|
|
|
const mockCanvas = { ds: { scale: 1 } } as LGraphCanvasType
|
|
const mockEvent = { canvasX: 150 } as CanvasPointerEvent
|
|
node.pos = [50, 50]
|
|
node.size = [200, 30]
|
|
|
|
const consoleErrorSpy = vi
|
|
.spyOn(console, 'error')
|
|
.mockImplementation(() => {})
|
|
|
|
const mockAddItem = vi.fn()
|
|
const mockContextMenu = vi.fn(() => {
|
|
return { addItem: mockAddItem } as ContextMenuInstance
|
|
})
|
|
LiteGraph.ContextMenu =
|
|
mockContextMenu as unknown as typeof LiteGraph.ContextMenu
|
|
|
|
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
|
|
|
|
// Should show formatted label for first, fallback to hash for second
|
|
expect(mockAddItem).toHaveBeenCalledWith(
|
|
'Beautiful Sunset.png',
|
|
HASH_FILENAME,
|
|
expect.objectContaining({
|
|
callback: expect.any(Function),
|
|
className: 'dark'
|
|
})
|
|
)
|
|
expect(mockAddItem).toHaveBeenCalledWith(
|
|
HASH_FILENAME_2,
|
|
HASH_FILENAME_2,
|
|
expect.objectContaining({
|
|
callback: expect.any(Function),
|
|
className: 'dark'
|
|
})
|
|
)
|
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
'Failed to map value:',
|
|
expect.any(Error)
|
|
)
|
|
|
|
consoleErrorSpy.mockRestore()
|
|
})
|
|
|
|
it('should show hash values in dropdown when getOptionLabel not provided', () => {
|
|
widget = new ComboWidget(
|
|
createMockWidgetConfig({
|
|
name: 'image',
|
|
value: HASH_FILENAME,
|
|
options: {
|
|
values: [HASH_FILENAME, HASH_FILENAME_2]
|
|
}
|
|
}),
|
|
node
|
|
)
|
|
|
|
const mockCanvas = { ds: { scale: 1 } } as LGraphCanvasType
|
|
const mockEvent = { canvasX: 150 } as CanvasPointerEvent
|
|
node.pos = [50, 50]
|
|
node.size = [200, 30]
|
|
|
|
const mockContextMenu = vi.fn()
|
|
LiteGraph.ContextMenu =
|
|
mockContextMenu as unknown as typeof LiteGraph.ContextMenu
|
|
|
|
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
|
|
|
|
// Should show hash filenames directly (no formatting)
|
|
expect(mockContextMenu).toHaveBeenCalledWith(
|
|
[HASH_FILENAME, HASH_FILENAME_2],
|
|
expect.objectContaining({
|
|
scale: 1,
|
|
event: mockEvent,
|
|
className: 'dark'
|
|
})
|
|
)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('edge cases', () => {
|
|
it('should return empty display value and disallow increment/decrement for empty values', () => {
|
|
widget = new ComboWidget(
|
|
createMockWidgetConfig({
|
|
name: 'mode',
|
|
value: '',
|
|
options: { values: [] }
|
|
}),
|
|
node
|
|
)
|
|
|
|
expect(widget._displayValue).toBe('')
|
|
expect(widget.canIncrement()).toBe(false)
|
|
expect(widget.canDecrement()).toBe(false)
|
|
})
|
|
|
|
it('should throw error when values is null in getValues', () => {
|
|
widget = new ComboWidget(
|
|
createMockWidgetConfig({
|
|
name: 'mode',
|
|
value: 'test',
|
|
options: { values: null as any }
|
|
}),
|
|
node
|
|
)
|
|
|
|
const mockCanvas = { ds: { scale: 1 } } as LGraphCanvasType
|
|
const mockEvent = { canvasX: 150 } as CanvasPointerEvent
|
|
node.pos = [50, 50]
|
|
node.size = [200, 30]
|
|
|
|
expect(() => {
|
|
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
|
|
}).toThrow('[ComboWidget]: values is required')
|
|
})
|
|
|
|
it('should default to first value when incrementing from invalid value', () => {
|
|
widget = new ComboWidget(
|
|
createMockWidgetConfig({
|
|
name: 'mode',
|
|
value: 'nonexistent',
|
|
options: { values: ['fast', 'medium', 'slow'] }
|
|
}),
|
|
node
|
|
)
|
|
|
|
const { mockCanvas, mockEvent } = setupIncrementDecrementTest()
|
|
|
|
const setValueSpy = vi.spyOn(widget, 'setValue')
|
|
widget.incrementValue({ e: mockEvent, node, canvas: mockCanvas })
|
|
|
|
// When value not found (indexOf returns -1), -1 + 1 = 0, clamped to 0
|
|
expect(setValueSpy).toHaveBeenCalledWith('fast', {
|
|
e: mockEvent,
|
|
node,
|
|
canvas: mockCanvas
|
|
})
|
|
})
|
|
})
|
|
})
|