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)
This commit is contained in:
Arjan Singh
2025-11-05 11:33:00 -08:00
committed by GitHub
parent 265f1257e7
commit a2ef569b9c
9 changed files with 1804 additions and 106 deletions

View File

@@ -29,6 +29,8 @@ export interface IWidgetOptions<TValues = unknown[]> {
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']
}

View File

@@ -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),

View File

@@ -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<AssetItem[]> - 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<AssetItem[]> {
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}`

View File

@@ -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<string, 'image' | 'video' | 'audio'> = {
LoadImage: 'image',
LoadVideo: 'video',
LoadAudio: 'audio'
}
// Map node types to placeholder i18n keys
const NODE_PLACEHOLDER_MAP: Record<string, string> = {
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,

View File

@@ -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<AssetItem[]> {
* Fetch input files from cloud service
*/
async function fetchInputFilesFromCloud(): Promise<AssetItem[]> {
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<string, AssetItem>()
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
}
})

File diff suppressed because it is too large Load Diff

View File

@@ -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> = {}): 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> = {}): IBaseWidget {
const mockCallback = vi.fn()
return {
const widget: IBaseWidget = {
type: 'combo',
options: {},
name: 'testWidget',
@@ -50,7 +98,8 @@ function createMockWidget(overrides: Partial<IBaseWidget> = {}): 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> = {}): 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()
})
})
})

View File

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

View File

@@ -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> = {}): 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)
})
})
})