From a2ef569b9cbb0164927424544f583ac4aef2dc76 Mon Sep 17 00:00:00 2001 From: Arjan Singh <1598641+arjansingh@users.noreply.github.com> Date: Wed, 5 Nov 2025 11:33:00 -0800 Subject: [PATCH] feat(ComboWidget): add ability to have mapped inputs (#6585) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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) --- src/lib/litegraph/src/types/widgets.ts | 2 + src/lib/litegraph/src/widgets/ComboWidget.ts | 38 +- src/platform/assets/services/assetService.ts | 17 +- .../widgets/composables/useComboWidget.ts | 232 +++- src/stores/assetsStore.ts | 37 +- .../litegraph/src/widgets/ComboWidget.test.ts | 1088 +++++++++++++++++ .../composables/useComboWidget.test.ts | 296 ++++- tests-ui/tests/services/assetService.test.ts | 43 + tests-ui/tests/store/assetsStore.test.ts | 157 +++ 9 files changed, 1804 insertions(+), 106 deletions(-) create mode 100644 tests-ui/tests/lib/litegraph/src/widgets/ComboWidget.test.ts create mode 100644 tests-ui/tests/store/assetsStore.test.ts diff --git a/src/lib/litegraph/src/types/widgets.ts b/src/lib/litegraph/src/types/widgets.ts index 5bdbc7dcb..16fb791c4 100644 --- a/src/lib/litegraph/src/types/widgets.ts +++ b/src/lib/litegraph/src/types/widgets.ts @@ -29,6 +29,8 @@ export interface IWidgetOptions { canvasOnly?: boolean values?: TValues + /** Optional function to format values for display (e.g., hash → human-readable name) */ + getOptionLabel?: (value?: string | null) => string callback?: IWidget['callback'] } diff --git a/src/lib/litegraph/src/widgets/ComboWidget.ts b/src/lib/litegraph/src/widgets/ComboWidget.ts index 01e10d0f1..2be407499 100644 --- a/src/lib/litegraph/src/widgets/ComboWidget.ts +++ b/src/lib/litegraph/src/widgets/ComboWidget.ts @@ -34,6 +34,18 @@ export class ComboWidget override get _displayValue() { if (this.computedDisabled) return '' + + if (this.options.getOptionLabel) { + try { + return this.options.getOptionLabel( + this.value ? String(this.value) : null + ) + } catch (e) { + console.error('Failed to map value:', e) + return this.value ? String(this.value) : '' + } + } + const { values: rawValues } = this.options if (rawValues) { const values = typeof rawValues === 'function' ? rawValues() : rawValues @@ -131,7 +143,31 @@ export class ComboWidget const values = this.getValues(node) const values_list = toArray(values) - // Handle center click - show dropdown menu + // Use addItem to solve duplicate filename issues + if (this.options.getOptionLabel) { + const menuOptions = { + scale: Math.max(1, canvas.ds.scale), + event: e, + className: 'dark', + callback: (value: string) => { + this.setValue(value, { e, node, canvas }) + } + } + const menu = new LiteGraph.ContextMenu([], menuOptions) + + for (const value of values_list) { + try { + const label = this.options.getOptionLabel(String(value)) + menu.addItem(label, value, menuOptions) + } catch (err) { + console.error('Failed to map value:', err) + menu.addItem(String(value), value, menuOptions) + } + } + return + } + + // Show dropdown menu when user clicks on widget label const text_values = values != values_list ? Object.values(values) : values new LiteGraph.ContextMenu(text_values, { scale: Math.max(1, canvas.ds.scale), diff --git a/src/platform/assets/services/assetService.ts b/src/platform/assets/services/assetService.ts index 378a95ec4..85023b29b 100644 --- a/src/platform/assets/services/assetService.ts +++ b/src/platform/assets/services/assetService.ts @@ -194,20 +194,31 @@ function createAssetService() { /** * Gets assets filtered by a specific tag * - * @param tag - The tag to filter by (e.g., 'models') + * @param tag - The tag to filter by (e.g., 'models', 'input') * @param includePublic - Whether to include public assets (default: true) + * @param options - Pagination options + * @param options.limit - Maximum number of assets to return (default: 500) + * @param options.offset - Number of assets to skip (default: 0) * @returns Promise - Full asset objects filtered by tag, excluding missing assets */ async function getAssetsByTag( tag: string, - includePublic: boolean = true + includePublic: boolean = true, + { + limit = DEFAULT_LIMIT, + offset = 0 + }: { limit?: number; offset?: number } = {} ): Promise { const queryParams = new URLSearchParams({ include_tags: tag, - limit: DEFAULT_LIMIT.toString(), + limit: limit.toString(), include_public: includePublic ? 'true' : 'false' }) + if (offset > 0) { + queryParams.set('offset', offset.toString()) + } + const data = await handleAssetRequest( `${ASSETS_ENDPOINT}?${queryParams.toString()}`, `assets for tag ${tag}` diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useComboWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useComboWidget.ts index b52ee0a13..9ecd8a579 100644 --- a/src/renderer/extensions/vueNodes/widgets/composables/useComboWidget.ts +++ b/src/renderer/extensions/vueNodes/widgets/composables/useComboWidget.ts @@ -11,6 +11,7 @@ import { assetItemSchema } from '@/platform/assets/schemas/assetSchema' import { assetService } from '@/platform/assets/services/assetService' +import { isCloud } from '@/platform/distribution/types' import { useSettingStore } from '@/platform/settings/settingStore' import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration' import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' @@ -22,6 +23,8 @@ import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget' import type { BaseDOMWidget } from '@/scripts/domWidget' import { addValueControlWidgets } from '@/scripts/widgets' import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets' +import { useAssetsStore } from '@/stores/assetsStore' +import { getMediaTypeFromFilename } from '@/utils/formatUtil' import { useRemoteWidget } from './useRemoteWidget' @@ -32,6 +35,20 @@ const getDefaultValue = (inputSpec: ComboInputSpec) => { return undefined } +// Map node types to expected media types +const NODE_MEDIA_TYPE_MAP: Record = { + LoadImage: 'image', + LoadVideo: 'video', + LoadAudio: 'audio' +} + +// Map node types to placeholder i18n keys +const NODE_PLACEHOLDER_MAP: Record = { + LoadImage: 'widgets.uploadSelect.placeholderImage', + LoadVideo: 'widgets.uploadSelect.placeholderVideo', + LoadAudio: 'widgets.uploadSelect.placeholderAudio' +} + const addMultiSelectWidget = ( node: LGraphNode, inputSpec: ComboInputSpec @@ -55,87 +72,168 @@ const addMultiSelectWidget = ( return widget } +const createAssetBrowserWidget = ( + node: LGraphNode, + inputSpec: ComboInputSpec, + defaultValue: string | undefined +): IBaseWidget => { + const currentValue = defaultValue + const displayLabel = currentValue ?? t('widgets.selectModel') + const assetBrowserDialog = useAssetBrowserDialog() + + const widget = node.addWidget( + 'asset', + inputSpec.name, + displayLabel, + async function (this: IBaseWidget) { + if (!isAssetWidget(widget)) { + throw new Error(`Expected asset widget but received ${widget.type}`) + } + await assetBrowserDialog.show({ + nodeType: node.comfyClass || '', + inputName: inputSpec.name, + currentValue: widget.value, + onAssetSelected: (asset) => { + const validatedAsset = assetItemSchema.safeParse(asset) + + if (!validatedAsset.success) { + console.error( + 'Invalid asset item:', + validatedAsset.error.errors, + 'Received:', + asset + ) + return + } + + const filename = validatedAsset.data.user_metadata?.filename + const validatedFilename = assetFilenameSchema.safeParse(filename) + + if (!validatedFilename.success) { + console.error( + 'Invalid asset filename:', + validatedFilename.error.errors, + 'for asset:', + validatedAsset.data.id + ) + return + } + + const oldValue = widget.value + this.value = validatedFilename.data + node.onWidgetChanged?.( + widget.name, + validatedFilename.data, + oldValue, + widget + ) + } + }) + } + ) + + return widget +} + +const createInputMappingWidget = ( + node: LGraphNode, + inputSpec: ComboInputSpec, + defaultValue: string | undefined +): IBaseWidget => { + const assetsStore = useAssetsStore() + + const widget = node.addWidget( + 'combo', + inputSpec.name, + defaultValue ?? '', + () => {}, + { + values: [], + getOptionLabel: (value?: string | null) => { + if (!value) { + const placeholderKey = + NODE_PLACEHOLDER_MAP[node.comfyClass ?? ''] ?? + 'widgets.uploadSelect.placeholder' + return t(placeholderKey) + } + return assetsStore.getInputName(value) + } + } + ) + + if (assetsStore.inputAssets.length === 0 && !assetsStore.inputLoading) { + void assetsStore.updateInputs().then(() => { + // edge for users using nodes with 0 prior inputs + // force canvas refresh the first time they add an asset + // so they see filenames instead of hashes. + node.setDirtyCanvas(true, false) + }) + } + + const origOptions = widget.options + widget.options = new Proxy(origOptions, { + get(target, prop) { + if (prop !== 'values') { + return target[prop as keyof typeof target] + } + return assetsStore.inputAssets + .filter( + (asset) => + getMediaTypeFromFilename(asset.name) === + NODE_MEDIA_TYPE_MAP[node.comfyClass ?? ''] + ) + .map((asset) => asset.asset_hash) + .filter((hash): hash is string => !!hash) + } + }) + + if (inputSpec.control_after_generate) { + if (!isComboWidget(widget)) { + throw new Error(`Expected combo widget but received ${widget.type}`) + } + widget.linkedWidgets = addValueControlWidgets( + node, + widget, + undefined, + undefined, + transformInputSpecV2ToV1(inputSpec) + ) + } + + return widget +} + const addComboWidget = ( node: LGraphNode, inputSpec: ComboInputSpec ): IBaseWidget => { - const settingStore = useSettingStore() - const isUsingAssetAPI = settingStore.get('Comfy.Assets.UseAssetAPI') - const isEligible = assetService.isAssetBrowserEligible( - node.comfyClass, - inputSpec.name - ) + const defaultValue = getDefaultValue(inputSpec) - if (isUsingAssetAPI && isEligible) { - const currentValue = getDefaultValue(inputSpec) - const displayLabel = currentValue ?? t('widgets.selectModel') - - const assetBrowserDialog = useAssetBrowserDialog() - - const widget = node.addWidget( - 'asset', - inputSpec.name, - displayLabel, - async function (this: IBaseWidget) { - if (!isAssetWidget(widget)) { - throw new Error(`Expected asset widget but received ${widget.type}`) - } - await assetBrowserDialog.show({ - nodeType: node.comfyClass || '', - inputName: inputSpec.name, - currentValue: widget.value, - onAssetSelected: (asset) => { - const validatedAsset = assetItemSchema.safeParse(asset) - - if (!validatedAsset.success) { - console.error( - 'Invalid asset item:', - validatedAsset.error.errors, - 'Received:', - asset - ) - return - } - - const filename = validatedAsset.data.user_metadata?.filename - const validatedFilename = assetFilenameSchema.safeParse(filename) - - if (!validatedFilename.success) { - console.error( - 'Invalid asset filename:', - validatedFilename.error.errors, - 'for asset:', - validatedAsset.data.id - ) - return - } - - const oldValue = widget.value - this.value = validatedFilename.data - node.onWidgetChanged?.( - widget.name, - validatedFilename.data, - oldValue, - widget - ) - } - }) - } + if (isCloud) { + const settingStore = useSettingStore() + const isUsingAssetAPI = settingStore.get('Comfy.Assets.UseAssetAPI') + const isEligible = assetService.isAssetBrowserEligible( + node.comfyClass, + inputSpec.name ) - return widget + if (isUsingAssetAPI && isEligible) { + return createAssetBrowserWidget(node, inputSpec, defaultValue) + } + + if (NODE_MEDIA_TYPE_MAP[node.comfyClass ?? '']) { + return createInputMappingWidget(node, inputSpec, defaultValue) + } } - // Create normal combo widget - const defaultValue = getDefaultValue(inputSpec) - const comboOptions = inputSpec.options ?? [] + // Standard combo widget const widget = node.addWidget( 'combo', inputSpec.name, defaultValue, () => {}, { - values: comboOptions + values: inputSpec.options ?? [] } ) @@ -143,6 +241,7 @@ const addComboWidget = ( if (!isComboWidget(widget)) { throw new Error(`Expected combo widget but received ${widget.type}`) } + const remoteWidget = useRemoteWidget({ remoteConfig: inputSpec.remote, defaultValue, @@ -166,6 +265,7 @@ const addComboWidget = ( if (!isComboWidget(widget)) { throw new Error(`Expected combo widget but received ${widget.type}`) } + widget.linkedWidgets = addValueControlWidgets( node, widget, diff --git a/src/stores/assetsStore.ts b/src/stores/assetsStore.ts index 9433b8260..1c23980cf 100644 --- a/src/stores/assetsStore.ts +++ b/src/stores/assetsStore.ts @@ -1,5 +1,6 @@ import { useAsyncState } from '@vueuse/core' import { defineStore } from 'pinia' +import { computed } from 'vue' import { mapInputFileToAssetItem, @@ -12,6 +13,8 @@ import { api } from '@/scripts/api' import { TaskItemImpl } from './queueStore' +const INPUT_LIMIT = 100 + /** * Fetch input files from the internal API (OSS version) */ @@ -36,7 +39,9 @@ async function fetchInputFilesFromAPI(): Promise { * Fetch input files from cloud service */ async function fetchInputFilesFromCloud(): Promise { - return await assetService.getAssetsByTag('input', false) + return await assetService.getAssetsByTag('input', false, { + limit: INPUT_LIMIT + }) } /** @@ -117,6 +122,30 @@ export const useAssetsStore = defineStore('assets', () => { } }) + /** + * Map of asset hash filename to asset item for O(1) lookup + * Cloud assets use asset_hash for the hash-based filename + */ + const inputAssetsByFilename = computed(() => { + const map = new Map() + for (const asset of inputAssets.value) { + // Use asset_hash as the key (hash-based filename) + if (asset.asset_hash) { + map.set(asset.asset_hash, asset) + } + } + return map + }) + + /** + * Get human-readable name for input asset filename + * @param filename Hash-based filename (e.g., "72e786ff...efb7.png") + * @returns Human-readable asset name or original filename if not found + */ + function getInputName(filename: string): string { + return inputAssetsByFilename.value.get(filename)?.name ?? filename + } + return { // States inputAssets, @@ -128,6 +157,10 @@ export const useAssetsStore = defineStore('assets', () => { // Actions updateInputs, - updateHistory + updateHistory, + + // Input mapping helpers + inputAssetsByFilename, + getInputName } }) diff --git a/tests-ui/tests/lib/litegraph/src/widgets/ComboWidget.test.ts b/tests-ui/tests/lib/litegraph/src/widgets/ComboWidget.test.ts new file mode 100644 index 000000000..e3b9ef6cd --- /dev/null +++ b/tests-ui/tests/lib/litegraph/src/widgets/ComboWidget.test.ts @@ -0,0 +1,1088 @@ +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 + +type ContextMenuInstance = { + addItem?: ( + name: string, + value: string, + options: { callback?: (value: string) => void; className?: string } + ) => void +} + +interface MockWidgetConfig extends Omit { + options: IComboWidget['options'] +} + +function createMockWidgetConfig( + overrides: Partial = {} +): 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 + }) + }) + }) +}) diff --git a/tests-ui/tests/renderer/extensions/vueNodes/widgets/composables/useComboWidget.test.ts b/tests-ui/tests/renderer/extensions/vueNodes/widgets/composables/useComboWidget.test.ts index 3bb985a47..54a101335 100644 --- a/tests-ui/tests/renderer/extensions/vueNodes/widgets/composables/useComboWidget.test.ts +++ b/tests-ui/tests/renderer/extensions/vueNodes/widgets/composables/useComboWidget.test.ts @@ -4,13 +4,61 @@ import { LGraphNode } from '@/lib/litegraph/src/litegraph' import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog' import { assetService } from '@/platform/assets/services/assetService' +import type { AssetItem } from '@/platform/assets/schemas/assetSchema' import { useComboWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useComboWidget' import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' +// Mock factory using actual type +function createMockAssetItem(overrides: Partial = {}): AssetItem { + return { + id: 'test-asset-id', + name: 'test-image.png', + asset_hash: 'hash123', + size: 1024, + mime_type: 'image/png', + tags: ['input'], + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + last_access_time: new Date().toISOString(), + ...overrides + } +} + +// Use vi.hoisted() to ensure mock state is initialized before mocks +const mockDistributionState = vi.hoisted(() => ({ isCloud: false })) +const mockUpdateInputs = vi.hoisted(() => vi.fn(() => Promise.resolve())) +const mockGetInputName = vi.hoisted(() => vi.fn((hash: string) => hash)) +const mockAssetsStoreState = vi.hoisted(() => { + const inputAssets: AssetItem[] = [] + return { + inputAssets, + inputLoading: false + } +}) + vi.mock('@/scripts/widgets', () => ({ addValueControlWidgets: vi.fn() })) +vi.mock('@/platform/distribution/types', () => ({ + get isCloud() { + return mockDistributionState.isCloud + } +})) + +vi.mock('@/stores/assetsStore', () => ({ + useAssetsStore: vi.fn(() => ({ + get inputAssets() { + return mockAssetsStoreState.inputAssets + }, + get inputLoading() { + return mockAssetsStoreState.inputLoading + }, + updateInputs: mockUpdateInputs, + getInputName: mockGetInputName + })) +})) + const mockSettingStoreGet = vi.fn(() => false) vi.mock('@/platform/settings/settingStore', () => ({ useSettingStore: vi.fn(() => ({ @@ -42,7 +90,7 @@ vi.mock('@/platform/assets/composables/useAssetBrowserDialog', () => { // Test factory functions function createMockWidget(overrides: Partial = {}): IBaseWidget { const mockCallback = vi.fn() - return { + const widget: IBaseWidget = { type: 'combo', options: {}, name: 'testWidget', @@ -50,7 +98,8 @@ function createMockWidget(overrides: Partial = {}): IBaseWidget { callback: mockCallback, y: 0, ...overrides - } as IBaseWidget + } + return widget } function createMockNode(comfyClass = 'TestNode'): LGraphNode { @@ -73,11 +122,12 @@ function createMockNode(comfyClass = 'TestNode'): LGraphNode { } function createMockInputSpec(overrides: Partial = {}): InputSpec { - return { + const inputSpec: InputSpec = { type: 'COMBO', name: 'testInput', ...overrides - } as InputSpec + } + return inputSpec } describe('useComboWidget', () => { @@ -86,6 +136,10 @@ describe('useComboWidget', () => { mockSettingStoreGet.mockReturnValue(false) vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(false) vi.mocked(useAssetBrowserDialog).mockClear() + mockDistributionState.isCloud = false + mockAssetsStoreState.inputAssets = [] + mockAssetsStoreState.inputLoading = false + mockUpdateInputs.mockClear() }) it('should handle undefined spec', () => { @@ -110,6 +164,7 @@ describe('useComboWidget', () => { }) it('should create normal combo widget when asset API is disabled', () => { + mockDistributionState.isCloud = true mockSettingStoreGet.mockReturnValue(false) // Asset API disabled vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true) // Widget is eligible @@ -137,6 +192,7 @@ describe('useComboWidget', () => { }) it('should create asset browser widget when API enabled', () => { + mockDistributionState.isCloud = true mockSettingStoreGet.mockReturnValue(true) vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true) @@ -169,36 +225,8 @@ describe('useComboWidget', () => { expect(widget).toBe(mockWidget) }) - it('should create asset browser widget with options when API enabled', () => { - mockSettingStoreGet.mockReturnValue(true) - vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true) - - const constructor = useComboWidget() - const mockWidget = createMockWidget({ - type: 'asset', - name: 'ckpt_name', - value: 'model1.safetensors' - }) - const mockNode = createMockNode('CheckpointLoaderSimple') - vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget) - const inputSpec = createMockInputSpec({ - name: 'ckpt_name', - options: ['model1.safetensors', 'model2.safetensors'] - }) - - const widget = constructor(mockNode, inputSpec) - - expect(mockNode.addWidget).toHaveBeenCalledWith( - 'asset', - 'ckpt_name', - 'model1.safetensors', - expect.any(Function) - ) - expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI') - expect(widget).toBe(mockWidget) - }) - - it('should use asset browser widget even when inputSpec has a default value but no options', () => { + it('should create asset browser widget when default value provided without options', () => { + mockDistributionState.isCloud = true mockSettingStoreGet.mockReturnValue(true) vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true) @@ -229,6 +257,7 @@ describe('useComboWidget', () => { }) it('should show Select model when asset widget has undefined current value', () => { + mockDistributionState.isCloud = true mockSettingStoreGet.mockReturnValue(true) vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true) @@ -256,4 +285,203 @@ describe('useComboWidget', () => { expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI') expect(widget).toBe(mockWidget) }) + + describe('cloud input asset mapping', () => { + const HASH_FILENAME = + '72e786ff2a44d682c4294db0b7098e569832bc394efc6dad644e6ec85a78efb7.png' + const HASH_FILENAME_2 = + 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456.jpg' + + it.each([ + { nodeClass: 'LoadImage', inputName: 'image' }, + { nodeClass: 'LoadVideo', inputName: 'video' }, + { nodeClass: 'LoadAudio', inputName: 'audio' } + ])( + 'should create combo widget with getOptionLabel for $nodeClass in cloud', + ({ nodeClass, inputName }) => { + mockDistributionState.isCloud = true + + const constructor = useComboWidget() + const mockWidget = createMockWidget({ + type: 'combo', + name: inputName, + value: HASH_FILENAME + }) + const mockNode = createMockNode(nodeClass) + vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget) + const inputSpec = createMockInputSpec({ + name: inputName, + options: [HASH_FILENAME, HASH_FILENAME_2] + }) + + const widget = constructor(mockNode, inputSpec) + + expect(mockNode.addWidget).toHaveBeenCalledWith( + 'combo', + inputName, + HASH_FILENAME, + expect.any(Function), + expect.objectContaining({ + values: [], // Empty initially, populated dynamically by Proxy + getOptionLabel: expect.any(Function) + }) + ) + expect(widget).toBe(mockWidget) + } + ) + + it("should format option labels using store's getInputName function", () => { + mockDistributionState.isCloud = true + mockGetInputName.mockReturnValue('Beautiful Sunset.png') + + const constructor = useComboWidget() + const mockWidget = createMockWidget({ + type: 'combo', + name: 'image', + value: HASH_FILENAME + }) + const mockNode = createMockNode('LoadImage') + vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget) + const inputSpec = createMockInputSpec({ + name: 'image', + options: [HASH_FILENAME] + }) + + constructor(mockNode, inputSpec) + + // Extract the injected getOptionLabel function with type narrowing + const addWidgetCall = vi.mocked(mockNode.addWidget).mock.calls[0] + const options = addWidgetCall[4] + + if (typeof options !== 'object' || !options) { + throw new Error('Expected options to be an object') + } + + if (!('getOptionLabel' in options)) { + throw new Error('Expected options to have getOptionLabel property') + } + + if (typeof options.getOptionLabel !== 'function') { + throw new Error('Expected getOptionLabel to be a function') + } + + // Test that the injected function calls getInputName + const result = options.getOptionLabel(HASH_FILENAME) + expect(mockGetInputName).toHaveBeenCalledWith(HASH_FILENAME) + expect(result).toBe('Beautiful Sunset.png') + }) + + it('should create normal combo widget for non-input nodes in cloud', () => { + mockDistributionState.isCloud = true + + const constructor = useComboWidget() + const mockWidget = createMockWidget() + const mockNode = createMockNode('SomeOtherNode') + vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget) + const inputSpec = createMockInputSpec({ + name: 'option', + options: [HASH_FILENAME, HASH_FILENAME_2] + }) + + const widget = constructor(mockNode, inputSpec) + + expect(mockNode.addWidget).toHaveBeenCalledWith( + 'combo', + 'option', + HASH_FILENAME, + expect.any(Function), + { values: [HASH_FILENAME, HASH_FILENAME_2] } + ) + expect(widget).toBe(mockWidget) + }) + + it('should create normal combo widget for LoadImage in OSS', () => { + mockDistributionState.isCloud = false + + const constructor = useComboWidget() + const mockWidget = createMockWidget() + const mockNode = createMockNode('LoadImage') + vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget) + const inputSpec = createMockInputSpec({ + name: 'image', + options: [HASH_FILENAME, HASH_FILENAME_2] + }) + + const widget = constructor(mockNode, inputSpec) + + expect(mockNode.addWidget).toHaveBeenCalledWith( + 'combo', + 'image', + HASH_FILENAME, + expect.any(Function), + { + values: [HASH_FILENAME, HASH_FILENAME_2] + } + ) + expect(widget).toBe(mockWidget) + }) + + it('should trigger lazy load for cloud input nodes', () => { + mockDistributionState.isCloud = true + mockAssetsStoreState.inputAssets = [] + mockAssetsStoreState.inputLoading = false + + const constructor = useComboWidget() + const mockWidget = createMockWidget({ type: 'combo' }) + const mockNode = createMockNode('LoadImage') + vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget) + const inputSpec = createMockInputSpec({ + name: 'image', + options: [HASH_FILENAME] + }) + + constructor(mockNode, inputSpec) + + expect(mockUpdateInputs).toHaveBeenCalledTimes(1) + }) + + it('should not trigger lazy load if assets already loading', () => { + mockDistributionState.isCloud = true + mockAssetsStoreState.inputAssets = [] + mockAssetsStoreState.inputLoading = true + + const constructor = useComboWidget() + const mockWidget = createMockWidget({ type: 'combo' }) + const mockNode = createMockNode('LoadImage') + vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget) + const inputSpec = createMockInputSpec({ + name: 'image', + options: [HASH_FILENAME] + }) + + constructor(mockNode, inputSpec) + + expect(mockUpdateInputs).not.toHaveBeenCalled() + }) + + it('should not trigger lazy load if assets already loaded', () => { + mockDistributionState.isCloud = true + mockAssetsStoreState.inputAssets = [ + createMockAssetItem({ + id: 'asset-123', + name: 'image1.png', + asset_hash: HASH_FILENAME + }) + ] + mockAssetsStoreState.inputLoading = false + + const constructor = useComboWidget() + const mockWidget = createMockWidget({ type: 'combo' }) + const mockNode = createMockNode('LoadImage') + vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget) + const inputSpec = createMockInputSpec({ + name: 'image', + options: [HASH_FILENAME] + }) + + constructor(mockNode, inputSpec) + + expect(mockUpdateInputs).not.toHaveBeenCalled() + }) + }) }) diff --git a/tests-ui/tests/services/assetService.test.ts b/tests-ui/tests/services/assetService.test.ts index f8c3f9414..ead7902ff 100644 --- a/tests-ui/tests/services/assetService.test.ts +++ b/tests-ui/tests/services/assetService.test.ts @@ -376,5 +376,48 @@ describe('assetService', () => { ) expect(result).toEqual(testAssets) }) + + it('should accept custom limit via options', async () => { + const testAssets = [MOCK_ASSETS.checkpoints] + mockApiResponse(testAssets) + + const result = await assetService.getAssetsByTag('input', false, { + limit: 100 + }) + + expect(api.fetchApi).toHaveBeenCalledWith( + '/assets?include_tags=input&limit=100&include_public=false' + ) + expect(result).toEqual(testAssets) + }) + + it('should accept custom offset via options', async () => { + const testAssets = [MOCK_ASSETS.loras] + mockApiResponse(testAssets) + + const result = await assetService.getAssetsByTag('models', true, { + offset: 50 + }) + + expect(api.fetchApi).toHaveBeenCalledWith( + '/assets?include_tags=models&limit=500&include_public=true&offset=50' + ) + expect(result).toEqual(testAssets) + }) + + it('should accept both limit and offset via options', async () => { + const testAssets = [MOCK_ASSETS.checkpoints] + mockApiResponse(testAssets) + + const result = await assetService.getAssetsByTag('input', false, { + limit: 100, + offset: 25 + }) + + expect(api.fetchApi).toHaveBeenCalledWith( + '/assets?include_tags=input&limit=100&include_public=false&offset=25' + ) + expect(result).toEqual(testAssets) + }) }) }) diff --git a/tests-ui/tests/store/assetsStore.test.ts b/tests-ui/tests/store/assetsStore.test.ts new file mode 100644 index 000000000..300efe02f --- /dev/null +++ b/tests-ui/tests/store/assetsStore.test.ts @@ -0,0 +1,157 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it } from 'vitest' + +import type { AssetItem } from '@/platform/assets/schemas/assetSchema' +import { useAssetsStore } from '@/stores/assetsStore' + +const HASH_FILENAME = + '72e786ff2a44d682c4294db0b7098e569832bc394efc6dad644e6ec85a78efb7.png' +const HASH_FILENAME_2 = + 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456.jpg' + +function createMockAssetItem(overrides: Partial = {}): AssetItem { + return { + id: 'test-id', + name: 'test.png', + asset_hash: 'test-hash', + size: 1024, + tags: [], + created_at: '2024-01-01T00:00:00.000Z', + ...overrides + } +} + +describe('assetsStore', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + describe('input asset mapping helpers', () => { + it('should return name for valid asset_hash', () => { + const store = useAssetsStore() + + store.inputAssets = [ + createMockAssetItem({ + name: 'Beautiful Sunset.png', + asset_hash: HASH_FILENAME + }), + createMockAssetItem({ + name: 'Mountain Vista.jpg', + asset_hash: HASH_FILENAME_2 + }) + ] + + expect(store.getInputName(HASH_FILENAME)).toBe('Beautiful Sunset.png') + expect(store.getInputName(HASH_FILENAME_2)).toBe('Mountain Vista.jpg') + }) + + it('should return original hash when no matching asset found', () => { + const store = useAssetsStore() + + store.inputAssets = [ + createMockAssetItem({ + name: 'Beautiful Sunset.png', + asset_hash: HASH_FILENAME + }) + ] + + const unknownHash = + 'fffffffffffffffffffffffffffuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu.png' + expect(store.getInputName(unknownHash)).toBe(unknownHash) + }) + + it('should return hash as-is when no assets loaded', () => { + const store = useAssetsStore() + + store.inputAssets = [] + + expect(store.getInputName(HASH_FILENAME)).toBe(HASH_FILENAME) + }) + + it('should ignore assets without asset_hash', () => { + const store = useAssetsStore() + + store.inputAssets = [ + createMockAssetItem({ + name: 'Beautiful Sunset.png', + asset_hash: HASH_FILENAME + }), + createMockAssetItem({ + name: 'No Hash Asset.jpg', + asset_hash: null + }) + ] + + // Should find first asset + expect(store.getInputName(HASH_FILENAME)).toBe('Beautiful Sunset.png') + // Map should only contain one entry + expect(store.inputAssetsByFilename.size).toBe(1) + }) + }) + + describe('inputAssetsByFilename computed', () => { + it('should create map keyed by asset_hash', () => { + const store = useAssetsStore() + + store.inputAssets = [ + createMockAssetItem({ + id: 'asset-123', + name: 'Beautiful Sunset.png', + asset_hash: HASH_FILENAME + }), + createMockAssetItem({ + id: 'asset-456', + name: 'Mountain Vista.jpg', + asset_hash: HASH_FILENAME_2 + }) + ] + + const map = store.inputAssetsByFilename + + expect(map.size).toBe(2) + expect(map.get(HASH_FILENAME)).toMatchObject({ + id: 'asset-123', + name: 'Beautiful Sunset.png', + asset_hash: HASH_FILENAME + }) + expect(map.get(HASH_FILENAME_2)).toMatchObject({ + id: 'asset-456', + name: 'Mountain Vista.jpg', + asset_hash: HASH_FILENAME_2 + }) + }) + + it('should exclude assets with null/undefined hash from map', () => { + const store = useAssetsStore() + + store.inputAssets = [ + createMockAssetItem({ + name: 'Has Hash.png', + asset_hash: HASH_FILENAME + }), + createMockAssetItem({ + name: 'Null Hash.jpg', + asset_hash: null + }), + createMockAssetItem({ + name: 'Undefined Hash.jpg', + asset_hash: undefined + }) + ] + + const map = store.inputAssetsByFilename + + // Only asset with valid asset_hash should be in map + expect(map.size).toBe(1) + expect(map.has(HASH_FILENAME)).toBe(true) + }) + + it('should return empty map when no assets loaded', () => { + const store = useAssetsStore() + + store.inputAssets = [] + + expect(store.inputAssetsByFilename.size).toBe(0) + }) + }) +})