Files
ComfyUI_frontend/tests-ui/tests/lib/litegraph/src/widgets/ComboWidget.test.ts
Arjan Singh a2ef569b9c feat(ComboWidget): add ability to have mapped inputs (#6585)
## 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)
2025-11-05 11:33:00 -08:00

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
})
})
})
})