From d83af149b0746a0ce61ff8ef35b0d804711ef60a Mon Sep 17 00:00:00 2001 From: Richard Yu Date: Tue, 2 Sep 2025 16:56:40 -0700 Subject: [PATCH] wip --- src/composables/widgets/useComboWidget.ts | 278 ++++++++++ .../widgets/useImageUploadWidget.ts | 47 +- src/extensions/core/uploadAudio.ts | 19 +- src/services/fileNameMappingService.ts | 321 +++++++++++ src/utils/litegraphUtil.ts | 27 +- .../widgets/useComboWidget.test.ts | 515 ++++++++++++++++++ .../services/fileNameMappingService.test.ts | 396 ++++++++++++++ 7 files changed, 1595 insertions(+), 8 deletions(-) create mode 100644 src/services/fileNameMappingService.ts create mode 100644 tests-ui/tests/services/fileNameMappingService.test.ts diff --git a/src/composables/widgets/useComboWidget.ts b/src/composables/widgets/useComboWidget.ts index ad973325d..00f7eeaf5 100644 --- a/src/composables/widgets/useComboWidget.ts +++ b/src/composables/widgets/useComboWidget.ts @@ -18,9 +18,262 @@ import { type ComfyWidgetConstructorV2, addValueControlWidgets } from '@/scripts/widgets' +import { fileNameMappingService } from '@/services/fileNameMappingService' import { useRemoteWidget } from './useRemoteWidget' +// Common file extensions that indicate file inputs +const FILE_EXTENSIONS = [ + '.jpg', + '.jpeg', + '.png', + '.gif', + '.webp', + '.bmp', + '.tiff', + '.svg', + '.safetensors', + '.ckpt', + '.pt', + '.pth', + '.bin' +] + +/** + * Check if options contain filename-like values + */ +function hasFilenameOptions(options: any[]): boolean { + return options.some((opt: any) => { + if (typeof opt !== 'string') return false + // Check for common file extensions + const hasExtension = FILE_EXTENSIONS.some((ext) => + opt.toLowerCase().endsWith(ext) + ) + // Check for hash-like filenames (ComfyUI hashed files) + const isHashLike = /^[a-f0-9]{8,}\./i.test(opt) + return hasExtension || isHashLike + }) +} + +/** + * Apply filename mapping to a widget using a simplified approach + */ +function applyFilenameMappingToWidget( + widget: IComboWidget, + node: LGraphNode, + inputSpec: ComboInputSpec +) { + // Simple approach: just override _displayValue for text display + // Leave all widget functionality intact + console.debug( + `[FilenameMapping] STARTING applyFilenameMappingToWidget for:`, + { + inputName: inputSpec.name, + widgetName: widget.name, + currentOptions: widget.options, + currentValues: Array.isArray(widget.options?.values) + ? widget.options.values.slice(0, 3) + : widget.options?.values || 'none' + } + ) + + // Override serializeValue to ensure hash is used for API + ;(widget as any).serializeValue = function () { + // Always return the actual widget value (hash) for serialization + return widget.value + } + + // Override _displayValue to show human-readable names + Object.defineProperty(widget, '_displayValue', { + get() { + if ((widget as any).computedDisabled) return '' + + // Get current hash value + const hashValue = widget.value + if (typeof hashValue !== 'string') return String(hashValue) + + // Try to get human-readable name from cache + const mapping = fileNameMappingService.getCachedMapping('input') + const humanName = mapping[hashValue] + + // Return human name for display, fallback to hash + return humanName || hashValue + }, + configurable: true + }) + + // Also override the options.values to show human names in dropdown + const originalOptions = widget.options as any + + // Store original values array - maintain the same array reference + const rawValues = Array.isArray(originalOptions.values) + ? originalOptions.values + : [] + + console.debug('[FilenameMapping] Initial raw values:', rawValues) + + // Create a computed property that returns mapped values + Object.defineProperty(widget.options, 'values', { + get() { + if (!Array.isArray(rawValues)) return rawValues + + // Map values to human-readable names + const mapping = fileNameMappingService.getCachedMapping('input') + const mapped = rawValues.map((value: any) => { + if (typeof value === 'string') { + const humanName = mapping[value] + if (humanName) { + console.debug(`[FilenameMapping] Mapped ${value} -> ${humanName}`) + return humanName + } + } + return value + }) + console.debug('[FilenameMapping] Returning mapped values:', mapped) + return mapped + }, + set(newValues) { + // Update raw values array in place to maintain reference + rawValues.length = 0 + if (Array.isArray(newValues)) { + rawValues.push(...newValues) + } + console.debug('[FilenameMapping] Values set to:', rawValues) + // Trigger UI update + node.setDirtyCanvas?.(true, true) + node.graph?.setDirtyCanvas?.(true, true) + }, + configurable: true, + enumerable: true + }) + + // Add helper methods for managing the raw values + ;(widget as any).getRawValues = function () { + return rawValues + } + + // Add a method to force refresh the dropdown + ;(widget as any).refreshMappings = function () { + console.debug('[FilenameMapping] Force refreshing dropdown') + // Force litegraph to re-read the values + const currentValues = widget.options.values + console.debug('[FilenameMapping] Current mapped values:', currentValues) + // Trigger UI update + node.setDirtyCanvas?.(true, true) + node.graph?.setDirtyCanvas?.(true, true) + } + + // Override incrementValue and decrementValue for arrow key navigation + ;(widget as any).incrementValue = function (options: any) { + // Get the current human-readable value + const mapping = fileNameMappingService.getCachedMapping('input') + const currentHumanName = mapping[widget.value] || widget.value + + // Get the values array (which contains human names through our proxy) + const rawValues = widget.options?.values + if (!rawValues || typeof rawValues === 'function') return + + const values = Array.isArray(rawValues) + ? rawValues + : Object.values(rawValues) + const currentIndex = values.indexOf(currentHumanName as any) + + if (currentIndex >= 0 && currentIndex < values.length - 1) { + // Get next value and set it (setValue will handle conversion) + const nextValue = values[currentIndex + 1] + ;(widget as any).setValue(nextValue, options) + } + } + ;(widget as any).decrementValue = function (options: any) { + // Get the current human-readable value + const mapping = fileNameMappingService.getCachedMapping('input') + const currentHumanName = mapping[widget.value] || widget.value + + // Get the values array (which contains human names through our proxy) + const rawValues = widget.options?.values + if (!rawValues || typeof rawValues === 'function') return + + const values = Array.isArray(rawValues) + ? rawValues + : Object.values(rawValues) + const currentIndex = values.indexOf(currentHumanName as any) + + if (currentIndex > 0) { + // Get previous value and set it (setValue will handle conversion) + const prevValue = values[currentIndex - 1] + ;(widget as any).setValue(prevValue, options) + } + } + + // Override setValue to handle human name selection from dropdown + const originalSetValue = (widget as any).setValue + ;(widget as any).setValue = function (selectedValue: any, options?: any) { + if (typeof selectedValue === 'string') { + // Check if this is a human-readable name that needs reverse mapping + const reverseMapping = + fileNameMappingService.getCachedReverseMapping('input') + const hashValue = reverseMapping[selectedValue] || selectedValue + + // Set the hash value + widget.value = hashValue + + // Call original setValue with hash value if it exists + if (originalSetValue) { + originalSetValue.call(widget, hashValue, options) + } + + // Trigger callback with hash value + if (widget.callback) { + widget.callback.call(widget, hashValue) + } + } else { + widget.value = selectedValue + if (originalSetValue) { + originalSetValue.call(widget, selectedValue, options) + } + if (widget.callback) { + widget.callback.call(widget, selectedValue) + } + } + } + + // Override callback to handle human name selection + const originalCallback = widget.callback + widget.callback = function (selectedValue: any) { + if (typeof selectedValue === 'string') { + // Check if this is a human-readable name that needs reverse mapping + const reverseMapping = + fileNameMappingService.getCachedReverseMapping('input') + const hashValue = reverseMapping[selectedValue] || selectedValue + + // Set the hash value + widget.value = hashValue + + // Call original callback with hash value + if (originalCallback) { + originalCallback.call(widget, hashValue) + } + } else { + widget.value = selectedValue + if (originalCallback) { + originalCallback.call(widget, selectedValue) + } + } + } + + // Trigger async load of mappings and update display when ready + fileNameMappingService + .getMapping('input') + .then(() => { + // Mappings loaded, trigger redraw to update display + node.setDirtyCanvas?.(true, true) + node.graph?.setDirtyCanvas?.(true, true) + }) + .catch(() => { + // Silently fail - will show hash values as fallback + }) +} + const getDefaultValue = (inputSpec: ComboInputSpec) => { if (inputSpec.default) return inputSpec.default if (inputSpec.options?.length) return inputSpec.options[0] @@ -91,6 +344,31 @@ const addComboWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => { ) } + // For non-remote combo widgets, check if they contain filenames and apply mapping + if (!inputSpec.remote && inputSpec.options) { + // Check if options contain filename-like values + const hasFilenames = hasFilenameOptions(inputSpec.options) + + console.debug( + '[FilenameMapping] Checking combo widget for filename mapping:', + { + inputName: inputSpec.name, + hasFilenames, + optionsCount: inputSpec.options.length, + sampleOptions: inputSpec.options.slice(0, 3) + } + ) + + if (hasFilenames) { + // Apply filename mapping for display + console.debug( + '[FilenameMapping] Applying filename mapping to widget:', + inputSpec.name + ) + applyFilenameMappingToWidget(widget, node, inputSpec) + } + } + return widget } diff --git a/src/composables/widgets/useImageUploadWidget.ts b/src/composables/widgets/useImageUploadWidget.ts index bf4e18fcb..348aa8a38 100644 --- a/src/composables/widgets/useImageUploadWidget.ts +++ b/src/composables/widgets/useImageUploadWidget.ts @@ -7,6 +7,7 @@ import { IComboWidget } from '@/lib/litegraph/src/types/widgets' import type { ResultItem, ResultItemType } from '@/schemas/apiSchema' import type { InputSpec } from '@/schemas/nodeDefSchema' import type { ComfyWidgetConstructor } from '@/scripts/widgets' +import { fileNameMappingService } from '@/services/fileNameMappingService' import { useNodeOutputStore } from '@/stores/imagePreviewStore' import { isImageUploadInput } from '@/types/nodeDefAugmentation' import { createAnnotatedPath } from '@/utils/formatUtil' @@ -76,11 +77,49 @@ export const useImageUploadWidget = () => { fileFilter, accept, folder, - onUploadComplete: (output) => { - output.forEach((path) => addToComboValues(fileComboWidget, path)) + onUploadComplete: async (output) => { + console.debug('[ImageUpload] Upload complete, output:', output) + + // CRITICAL: Refresh mappings FIRST before updating dropdown + // This ensures new hash→human mappings are available when dropdown renders + try { + await fileNameMappingService.refreshMapping('input') + console.debug( + '[ImageUpload] Filename mappings refreshed, updating dropdown' + ) + } catch (error) { + console.debug( + '[ImageUpload] Failed to refresh filename mappings:', + error + ) + // Continue anyway - will show hash values as fallback + } + + // Now add the files to dropdown - addToComboValues will trigger refreshMappings + output.forEach((path) => { + console.debug('[ImageUpload] Adding to combo values:', path) + addToComboValues(fileComboWidget, path) + }) + + // Set the widget value to the newly uploaded files + // Use the last uploaded file for single selection widgets + const selectedValue = allow_batch ? output : output[output.length - 1] + // @ts-expect-error litegraph combo value type does not support arrays yet - fileComboWidget.value = output - fileComboWidget.callback?.(output) + fileComboWidget.value = selectedValue + fileComboWidget.callback?.(selectedValue) + + // Force one more refresh to ensure UI is in sync + if (typeof (fileComboWidget as any).refreshMappings === 'function') { + console.debug('[ImageUpload] Final refreshMappings call for UI sync') + ;(fileComboWidget as any).refreshMappings() + } + + // Trigger UI update to show human-readable names + node.setDirtyCanvas?.(true, true) + node.graph?.setDirtyCanvas?.(true, true) + + console.debug('[ImageUpload] Upload handling complete') } }) diff --git a/src/extensions/core/uploadAudio.ts b/src/extensions/core/uploadAudio.ts index dd6351244..de0543f28 100644 --- a/src/extensions/core/uploadAudio.ts +++ b/src/extensions/core/uploadAudio.ts @@ -14,6 +14,7 @@ import type { ResultItemType } from '@/schemas/apiSchema' import type { ComfyNodeDef } from '@/schemas/nodeDefSchema' import type { DOMWidget } from '@/scripts/domWidget' import { useAudioService } from '@/services/audioService' +import { fileNameMappingService } from '@/services/fileNameMappingService' import { useToastStore } from '@/stores/toastStore' import { NodeLocatorId } from '@/types' import { getNodeByLocatorId } from '@/utils/graphTraversalUtil' @@ -66,10 +67,26 @@ async function uploadFile( if (resp.status === 200) { const data = await resp.json() - // Add the file to the dropdown list and update the widget value + // Build the file path let path = data.name if (data.subfolder) path = data.subfolder + '/' + path + // CRITICAL: Refresh mappings FIRST before updating dropdown + // This ensures new hash→human mappings are available when dropdown renders + try { + await fileNameMappingService.refreshMapping('input') + console.debug( + '[AudioUpload] Filename mappings refreshed, updating dropdown' + ) + } catch (error) { + console.debug( + '[AudioUpload] Failed to refresh filename mappings:', + error + ) + // Continue anyway - will show hash values as fallback + } + + // Now add the file to the dropdown list - any filename proxy will use fresh mappings // @ts-expect-error fixme ts strict error if (!audioWidget.options.values.includes(path)) { // @ts-expect-error fixme ts strict error diff --git a/src/services/fileNameMappingService.ts b/src/services/fileNameMappingService.ts new file mode 100644 index 000000000..882eb2616 --- /dev/null +++ b/src/services/fileNameMappingService.ts @@ -0,0 +1,321 @@ +import { api } from '@/scripts/api' + +export type FileType = 'input' | 'output' | 'temp' + +export interface FileNameMapping { + [hashFilename: string]: string // hash -> human readable name +} + +export interface CacheEntry { + data: FileNameMapping + timestamp: number + error?: Error | null + fetchPromise?: Promise + failed?: boolean +} + +/** + * Service for fetching and caching filename mappings from the backend. + * Maps SHA256 hash filenames to their original human-readable names. + */ +export class FileNameMappingService { + private cache = new Map() + private readonly TTL = 5 * 60 * 1000 // 5 minutes + + /** + * Get filename mapping for the specified file type. + * @param fileType - The type of files to get mappings for + * @returns Promise resolving to the filename mapping + */ + async getMapping(fileType: FileType = 'input'): Promise { + const cached = this.cache.get(fileType) + + // Return cached data if valid and not expired + if (cached && !this.isExpired(cached) && !cached.failed) { + return cached.data + } + + // Return cached data if we're already fetching or if previous fetch failed recently + if (cached?.fetchPromise || (cached?.failed && !this.shouldRetry(cached))) { + return cached?.data ?? {} + } + + // Fetch new data + return this.fetchMapping(fileType) + } + + /** + * Get human-readable filename from hash filename. + * @param hashFilename - The SHA256 hash filename + * @param fileType - The type of file + * @returns Promise resolving to human-readable name or original if not found + */ + async getHumanReadableName( + hashFilename: string, + fileType: FileType = 'input' + ): Promise { + try { + const mapping = await this.getMapping(fileType) + return mapping[hashFilename] ?? hashFilename + } catch (error) { + console.warn( + `Failed to get human readable name for ${hashFilename}:`, + error + ) + return hashFilename + } + } + + /** + * Apply filename mapping to an array of hash filenames. + * @param hashFilenames - Array of SHA256 hash filenames + * @param fileType - The type of files + * @returns Promise resolving to array of human-readable names + */ + async applyMappingToArray( + hashFilenames: string[], + fileType: FileType = 'input' + ): Promise { + try { + const mapping = await this.getMapping(fileType) + return hashFilenames.map((filename) => mapping[filename] ?? filename) + } catch (error) { + console.warn('Failed to apply filename mapping:', error) + return hashFilenames + } + } + + /** + * Get cached mapping synchronously (returns empty object if not cached). + * @param fileType - The file type to get cached mapping for + * @returns The cached mapping or empty object + */ + getCachedMapping(fileType: FileType = 'input'): FileNameMapping { + const cached = this.cache.get(fileType) + if (cached && !this.isExpired(cached) && !cached.failed) { + const result = cached.data + console.debug( + `[FileNameMapping] getCachedMapping returning cached data:`, + { + fileType, + mappingCount: Object.keys(result).length, + sampleMappings: Object.entries(result).slice(0, 3) + } + ) + return result + } + console.debug( + `[FileNameMapping] getCachedMapping returning empty object for ${fileType} (cache miss)` + ) + return {} + } + + /** + * Get reverse mapping (human-readable name to hash) synchronously. + * @param fileType - The file type to get reverse mapping for + * @returns The reverse mapping object + */ + getCachedReverseMapping( + fileType: FileType = 'input' + ): Record { + const mapping = this.getCachedMapping(fileType) + const reverseMapping: Record = {} + + // Build reverse mapping: humanName -> hashName + for (const [hash, humanName] of Object.entries(mapping)) { + reverseMapping[humanName] = hash + } + + return reverseMapping + } + + /** + * Convert a human-readable name back to its hash filename. + * @param humanName - The human-readable filename + * @param fileType - The file type + * @returns The hash filename or the original if no mapping exists + */ + getHashFromHumanName( + humanName: string, + fileType: FileType = 'input' + ): string { + const reverseMapping = this.getCachedReverseMapping(fileType) + return reverseMapping[humanName] ?? humanName + } + + /** + * Invalidate cached mapping for a specific file type. + * @param fileType - The file type to invalidate, or undefined to clear all + */ + invalidateCache(fileType?: FileType): void { + if (fileType) { + this.cache.delete(fileType) + } else { + this.cache.clear() + } + } + + /** + * Refresh the mapping for a specific file type by clearing cache and fetching new data. + * @param fileType - The file type to refresh + * @returns Promise resolving to the new mapping + */ + async refreshMapping(fileType: FileType = 'input'): Promise { + console.debug(`[FileNameMapping] Refreshing mapping for ${fileType}`) + this.invalidateCache(fileType) + const freshMapping = await this.getMapping(fileType) + console.debug(`[FileNameMapping] Fresh mapping fetched:`, { + fileType, + mappingCount: Object.keys(freshMapping).length, + sampleMappings: Object.entries(freshMapping).slice(0, 3) + }) + return freshMapping + } + + /** + * Ensures mappings are loaded and cached for immediate synchronous access. + * Use this to preload mappings before widget creation. + * @param fileType - The file type to preload + * @returns Promise that resolves when mappings are loaded + */ + async ensureMappingsLoaded(fileType: FileType = 'input'): Promise { + try { + await this.getMapping(fileType) + } catch (error) { + // Errors are already handled in getMapping/performFetch + // This ensures we don't break the app initialization + console.debug( + '[FileNameMappingService] Preload completed with fallback to empty mapping' + ) + } + } + + private async fetchMapping(fileType: FileType): Promise { + const cacheKey = fileType + let entry = this.cache.get(cacheKey) + + if (!entry) { + entry = { data: {}, timestamp: 0 } + this.cache.set(cacheKey, entry) + } + + // Prevent concurrent requests for the same fileType + if (entry.fetchPromise) { + return entry.fetchPromise + } + + // Set up fetch promise to prevent concurrent requests + entry.fetchPromise = this.performFetch(fileType) + + try { + const data = await entry.fetchPromise + + // Update cache with successful result + entry.data = data + entry.timestamp = Date.now() + entry.error = null + entry.failed = false + + return data + } catch (error) { + // Should not happen as performFetch now returns empty mapping on error + // But keep for safety + entry.error = error instanceof Error ? error : new Error(String(error)) + entry.failed = true + + console.debug(`[FileNameMappingService] Using fallback for ${fileType}`) + return entry.data // Return existing data or empty object + } finally { + // Clear the promise after completion + entry.fetchPromise = undefined + } + } + + private async performFetch(fileType: FileType): Promise { + // Check if api is available + if (!api || typeof api.fetchApi !== 'function') { + console.warn( + '[FileNameMappingService] API not available, returning empty mapping' + ) + return {} + } + + let response: Response + try { + response = await api.fetchApi(`/files/mappings`) + } catch (error) { + console.warn( + '[FileNameMappingService] Network error fetching mappings:', + error + ) + return {} // Return empty mapping instead of throwing + } + + if (!response.ok) { + console.warn( + `[FileNameMappingService] Server returned ${response.status} ${response.statusText}, using empty mapping` + ) + return {} // Graceful degradation + } + + let data: any + try { + // Check if response has json method + if (typeof response.json !== 'function') { + console.warn('[FileNameMappingService] Response has no json() method') + return {} + } + data = await response.json() + } catch (jsonError) { + console.warn( + '[FileNameMappingService] Failed to parse JSON response:', + jsonError + ) + return {} // Return empty mapping on parse error + } + + // Validate response structure + if (typeof data !== 'object' || data === null || Array.isArray(data)) { + console.warn( + '[FileNameMappingService] Invalid response format, expected object' + ) + return {} + } + + // Validate and filter entries + const validEntries: FileNameMapping = {} + let invalidEntryCount = 0 + + for (const [key, value] of Object.entries(data)) { + if (typeof key === 'string' && typeof value === 'string') { + validEntries[key] = value + } else { + invalidEntryCount++ + } + } + + if (invalidEntryCount > 0) { + console.debug( + `[FileNameMappingService] Filtered out ${invalidEntryCount} invalid entries` + ) + } + + console.debug( + `[FileNameMappingService] Loaded ${Object.keys(validEntries).length} mappings for '${fileType}'` + ) + + return validEntries + } + + private isExpired(entry: CacheEntry): boolean { + return Date.now() - entry.timestamp > this.TTL + } + + private shouldRetry(entry: CacheEntry): boolean { + // Allow retry after 30 seconds for failed requests + return entry.timestamp > 0 && Date.now() - entry.timestamp > 30000 + } +} + +// Singleton instance +export const fileNameMappingService = new FileNameMappingService() diff --git a/src/utils/litegraphUtil.ts b/src/utils/litegraphUtil.ts index 24503c286..04df67518 100644 --- a/src/utils/litegraphUtil.ts +++ b/src/utils/litegraphUtil.ts @@ -43,10 +43,31 @@ export function isAudioNode(node: LGraphNode | undefined): boolean { export function addToComboValues(widget: IComboWidget, value: string) { if (!widget.options) widget.options = { values: [] } if (!widget.options.values) widget.options.values = [] - // @ts-expect-error Combo widget values may be a dictionary or legacy function type - if (!widget.options.values.includes(value)) { + + // Check if this widget has our filename mapping (has getRawValues method) + const mappingWidget = widget as any + if ( + mappingWidget.getRawValues && + typeof mappingWidget.getRawValues === 'function' + ) { + // This is a filename mapping widget - work with raw values directly + const rawValues = mappingWidget.getRawValues() + if (!rawValues.includes(value)) { + console.debug('[FilenameMapping] Adding to raw values:', value) + rawValues.push(value) + + // Trigger refresh + if (mappingWidget.refreshMappings) { + mappingWidget.refreshMappings() + } + } + } else { + // Regular widget without mapping // @ts-expect-error Combo widget values may be a dictionary or legacy function type - widget.options.values.push(value) + if (!widget.options.values.includes(value)) { + // @ts-expect-error Combo widget values may be a dictionary or legacy function type + widget.options.values.push(value) + } } } diff --git a/tests-ui/tests/composables/widgets/useComboWidget.test.ts b/tests-ui/tests/composables/widgets/useComboWidget.test.ts index cbdd74bab..526d33928 100644 --- a/tests-ui/tests/composables/widgets/useComboWidget.test.ts +++ b/tests-ui/tests/composables/widgets/useComboWidget.test.ts @@ -2,11 +2,32 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { useComboWidget } from '@/composables/widgets/useComboWidget' import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' +import { fileNameMappingService } from '@/services/fileNameMappingService' + +// Mock api to prevent app initialization +vi.mock('@/scripts/api', () => ({ + api: { + fetchApi: vi.fn(), + addEventListener: vi.fn(), + apiURL: vi.fn((path) => `/api${path}`), + fileURL: vi.fn((path) => path) + } +})) vi.mock('@/scripts/widgets', () => ({ addValueControlWidgets: vi.fn() })) +vi.mock('@/services/fileNameMappingService', () => ({ + fileNameMappingService: { + getMapping: vi.fn().mockResolvedValue({}), + getCachedMapping: vi.fn().mockReturnValue({}), + getCachedReverseMapping: vi.fn().mockReturnValue({}), + refreshMapping: vi.fn().mockResolvedValue({}), + invalidateCache: vi.fn() + } +})) + describe('useComboWidget', () => { beforeEach(() => { vi.clearAllMocks() @@ -36,4 +57,498 @@ describe('useComboWidget', () => { ) expect(widget).toEqual({ options: {} }) }) + + describe('filename mapping', () => { + it('should apply filename mapping to widgets with file extensions', () => { + const constructor = useComboWidget() + const mockWidget = { + name: 'image', + value: 'abc123.png', + options: { + values: ['abc123.png', 'def456.jpg'] + }, + callback: vi.fn() + } + + const mockNode = { + addWidget: vi.fn().mockReturnValue(mockWidget), + setDirtyCanvas: vi.fn(), + graph: { + setDirtyCanvas: vi.fn() + } + } + + const inputSpec: InputSpec = { + type: 'COMBO', + name: 'image', + options: ['abc123.png', 'def456.jpg', 'xyz789.webp'] + } + + // Setup mapping service mocks + vi.mocked(fileNameMappingService.getCachedMapping).mockReturnValue({ + 'abc123.png': 'vacation_photo.png', + 'def456.jpg': 'profile_picture.jpg', + 'xyz789.webp': 'animated_logo.webp' + }) + + vi.mocked(fileNameMappingService.getCachedReverseMapping).mockReturnValue( + { + 'vacation_photo.png': 'abc123.png', + 'profile_picture.jpg': 'def456.jpg', + 'animated_logo.webp': 'xyz789.webp' + } + ) + + vi.mocked(fileNameMappingService.getMapping).mockResolvedValue({ + 'abc123.png': 'vacation_photo.png', + 'def456.jpg': 'profile_picture.jpg', + 'xyz789.webp': 'animated_logo.webp' + }) + + const widget = constructor(mockNode as any, inputSpec) + + // Widget should have mapping methods + expect(widget).toBeDefined() + expect(typeof (widget as any).refreshMappings).toBe('function') + expect(typeof (widget as any).serializeValue).toBe('function') + }) + + it('should display human-readable names in dropdown', () => { + const constructor = useComboWidget() + const mockWidget = { + name: 'image', + value: 'abc123.png', + options: { + values: ['abc123.png', 'def456.jpg'] + }, + callback: vi.fn() + } + + const mockNode = { + addWidget: vi.fn().mockReturnValue(mockWidget), + setDirtyCanvas: vi.fn(), + graph: { + setDirtyCanvas: vi.fn() + } + } + + const inputSpec: InputSpec = { + type: 'COMBO', + name: 'image', + options: ['abc123.png', 'def456.jpg'] + } + + vi.mocked(fileNameMappingService.getCachedMapping).mockReturnValue({ + 'abc123.png': 'vacation_photo.png', + 'def456.jpg': 'profile_picture.jpg' + }) + + const widget = constructor(mockNode as any, inputSpec) as any + + // Access options.values through the proxy + const dropdownValues = widget.options.values + + // Should return human-readable names + expect(dropdownValues).toEqual([ + 'vacation_photo.png', + 'profile_picture.jpg' + ]) + }) + + it('should handle selection of human-readable name and convert to hash', () => { + const constructor = useComboWidget() + const mockWidget = { + name: 'image', + value: 'abc123.png', + options: { + values: ['abc123.png'] + }, + callback: vi.fn() + } + + const mockNode = { + addWidget: vi.fn().mockReturnValue(mockWidget), + setDirtyCanvas: vi.fn(), + graph: { + setDirtyCanvas: vi.fn() + } + } + + const inputSpec: InputSpec = { + type: 'COMBO', + name: 'image', + options: ['abc123.png'] + } + + vi.mocked(fileNameMappingService.getCachedReverseMapping).mockReturnValue( + { + 'vacation_photo.png': 'abc123.png' + } + ) + + const widget = constructor(mockNode as any, inputSpec) as any + + // Simulate selecting human-readable name + widget.callback('vacation_photo.png') + + // Should store hash value + expect(widget.value).toBe('abc123.png') + }) + + it('should not apply mapping to non-file widgets', () => { + const constructor = useComboWidget() + const mockWidget = { + name: 'mode', + value: 'linear', + options: { + values: ['linear', 'cubic', 'nearest'] + }, + callback: vi.fn() + } + + const mockNode = { + addWidget: vi.fn().mockReturnValue(mockWidget) + } + + const inputSpec: InputSpec = { + type: 'COMBO', + name: 'mode', + options: ['linear', 'cubic', 'nearest'] + } + + const widget = constructor(mockNode as any, inputSpec) + + // Should not have mapping methods + expect((widget as any).refreshMappings).toBeUndefined() + expect((widget as any).serializeValue).toBeUndefined() + }) + + it('should show newly uploaded file in dropdown even without mapping', () => { + const constructor = useComboWidget() + const mockWidget = { + name: 'image', + value: 'abc123.png', + options: { + values: ['abc123.png'] + }, + callback: vi.fn() + } + + const mockNode = { + addWidget: vi.fn().mockReturnValue(mockWidget), + setDirtyCanvas: vi.fn(), + graph: { + setDirtyCanvas: vi.fn() + } + } + + const inputSpec: InputSpec = { + type: 'COMBO', + name: 'image', + options: ['abc123.png'] + } + + // Start with mapping for existing file only + vi.mocked(fileNameMappingService.getCachedMapping).mockReturnValue({ + 'abc123.png': 'vacation_photo.png' + }) + + const widget = constructor(mockNode as any, inputSpec) as any + + // Simulate adding new file without mapping yet + const newValues = [...mockWidget.options.values, 'new789.png'] + mockWidget.options.values = newValues + + // Mapping still doesn't have the new file + vi.mocked(fileNameMappingService.getCachedMapping).mockReturnValue({ + 'abc123.png': 'vacation_photo.png' + }) + + // Force refresh + widget.refreshMappings() + + // Access updated dropdown values + const dropdownValues = widget.options.values + + // Should show human name for mapped file and hash for unmapped file + expect(dropdownValues).toEqual(['vacation_photo.png', 'new789.png']) + }) + + it('should handle dropdown update after new file upload', () => { + const constructor = useComboWidget() + const mockWidget = { + name: 'image', + value: 'abc123.png', + options: { + values: ['abc123.png'] + }, + callback: vi.fn() + } + + const mockNode = { + addWidget: vi.fn().mockReturnValue(mockWidget), + setDirtyCanvas: vi.fn(), + graph: { + setDirtyCanvas: vi.fn() + } + } + + const inputSpec: InputSpec = { + type: 'COMBO', + name: 'image', + options: ['abc123.png'] + } + + // Initial mapping + vi.mocked(fileNameMappingService.getCachedMapping).mockReturnValue({ + 'abc123.png': 'vacation_photo.png' + }) + + const widget = constructor(mockNode as any, inputSpec) as any + + // The proxy should initially return mapped values + expect(widget.options.values).toEqual(['vacation_photo.png']) + + // Simulate adding new file by replacing the values array (as happens in practice) + // This is how addToComboValues would modify it + const newValues = [...mockWidget.options.values, 'new789.png'] + mockWidget.options.values = newValues + + // Update mapping to include the new file + vi.mocked(fileNameMappingService.getCachedMapping).mockReturnValue({ + 'abc123.png': 'vacation_photo.png', + 'new789.png': 'new_upload.png' + }) + + // Force refresh of cached values + widget.refreshMappings() + + // Access updated dropdown values - proxy should recompute with new mapping + const dropdownValues = widget.options.values + + // Should include both mapped names + expect(dropdownValues).toEqual(['vacation_photo.png', 'new_upload.png']) + }) + + it('should display hash as fallback when no mapping exists', () => { + const constructor = useComboWidget() + const mockWidget = { + name: 'image', + value: 'unmapped123.png', + options: { + values: ['unmapped123.png'] + }, + callback: vi.fn() + } + + const mockNode = { + addWidget: vi.fn().mockReturnValue(mockWidget), + setDirtyCanvas: vi.fn(), + graph: { + setDirtyCanvas: vi.fn() + } + } + + const inputSpec: InputSpec = { + type: 'COMBO', + name: 'image', + options: ['unmapped123.png'] + } + + // Return empty mapping + vi.mocked(fileNameMappingService.getCachedMapping).mockReturnValue({}) + + const widget = constructor(mockNode as any, inputSpec) as any + + // Access _displayValue + const displayValue = widget._displayValue + + // Should show hash when no mapping exists + expect(displayValue).toBe('unmapped123.png') + + // Dropdown should also show hash + const dropdownValues = widget.options.values + expect(dropdownValues).toEqual(['unmapped123.png']) + }) + + it('should serialize widget value as hash for API calls', () => { + const constructor = useComboWidget() + const mockWidget = { + name: 'image', + value: 'abc123.png', + options: { + values: ['abc123.png'] + }, + callback: vi.fn() + } + + const mockNode = { + addWidget: vi.fn().mockReturnValue(mockWidget), + setDirtyCanvas: vi.fn(), + graph: { + setDirtyCanvas: vi.fn() + } + } + + const inputSpec: InputSpec = { + type: 'COMBO', + name: 'image', + options: ['abc123.png'] + } + + vi.mocked(fileNameMappingService.getCachedMapping).mockReturnValue({ + 'abc123.png': 'vacation_photo.png' + }) + + const widget = constructor(mockNode as any, inputSpec) as any + + // serializeValue should always return hash + const serialized = widget.serializeValue() + expect(serialized).toBe('abc123.png') + }) + + it('should ensure widget.value always contains hash for API calls', () => { + const constructor = useComboWidget() + const mockWidget = { + name: 'image', + value: 'abc123.png', + options: { + values: ['abc123.png'] + }, + callback: vi.fn() + } + + const mockNode = { + addWidget: vi.fn().mockReturnValue(mockWidget), + setDirtyCanvas: vi.fn(), + graph: { + setDirtyCanvas: vi.fn() + } + } + + const inputSpec: InputSpec = { + type: 'COMBO', + name: 'image', + options: ['abc123.png'] + } + + vi.mocked(fileNameMappingService.getCachedMapping).mockReturnValue({ + 'abc123.png': 'vacation.png' + }) + + vi.mocked(fileNameMappingService.getCachedReverseMapping).mockReturnValue( + { + 'vacation.png': 'abc123.png' + } + ) + + const widget = constructor(mockNode as any, inputSpec) as any + + // Simulate user selecting from dropdown (human name) + widget.setValue('vacation.png') + + // Widget.value should contain the hash for API calls + expect(widget.value).toBe('abc123.png') + + // Callback should also convert human name to hash + widget.callback('vacation.png') + expect(widget.value).toBe('abc123.png') + + // The value used for API calls should always be the hash + // This is what would be used in /view?filename=... + const apiValue = widget.value + expect(apiValue).toBe('abc123.png') + }) + + it('should handle arrow key navigation with filename mapping', () => { + const constructor = useComboWidget() + const mockWidget = { + name: 'image', + value: 'abc123.png', + options: { + values: ['abc123.png', 'def456.jpg', 'xyz789.webp'] + }, + callback: vi.fn() + } + + const mockNode = { + addWidget: vi.fn().mockReturnValue(mockWidget), + setDirtyCanvas: vi.fn(), + graph: { + setDirtyCanvas: vi.fn() + } + } + + const inputSpec: InputSpec = { + type: 'COMBO', + name: 'image', + options: ['abc123.png', 'def456.jpg', 'xyz789.webp'] + } + + vi.mocked(fileNameMappingService.getCachedMapping).mockReturnValue({ + 'abc123.png': 'vacation.png', + 'def456.jpg': 'profile.jpg', + 'xyz789.webp': 'banner.webp' + }) + + vi.mocked(fileNameMappingService.getCachedReverseMapping).mockReturnValue( + { + 'vacation.png': 'abc123.png', + 'profile.jpg': 'def456.jpg', + 'banner.webp': 'xyz789.webp' + } + ) + + const widget = constructor(mockNode as any, inputSpec) as any + + // Test increment (arrow right/up) + widget.incrementValue({ canvas: { last_mouseclick: 0 } }) + + // Should move from abc123.png to def456.jpg + expect(widget.value).toBe('def456.jpg') + + // Test decrement (arrow left/down) + widget.decrementValue({ canvas: { last_mouseclick: 0 } }) + + // Should move back to abc123.png + expect(widget.value).toBe('abc123.png') + }) + + it('should handle mixed file and non-file options', () => { + const constructor = useComboWidget() + const mockWidget = { + name: 'source', + value: 'abc123.png', + options: { + values: ['abc123.png', 'none', 'default'] + }, + callback: vi.fn() + } + + const mockNode = { + addWidget: vi.fn().mockReturnValue(mockWidget), + setDirtyCanvas: vi.fn(), + graph: { + setDirtyCanvas: vi.fn() + } + } + + const inputSpec: InputSpec = { + type: 'COMBO', + name: 'source', + options: ['abc123.png', 'none', 'default'] + } + + vi.mocked(fileNameMappingService.getCachedMapping).mockReturnValue({ + 'abc123.png': 'background.png' + }) + + const widget = constructor(mockNode as any, inputSpec) as any + + const dropdownValues = widget.options.values + + // Should map file, but leave non-files unchanged + expect(dropdownValues).toEqual(['background.png', 'none', 'default']) + }) + }) }) diff --git a/tests-ui/tests/services/fileNameMappingService.test.ts b/tests-ui/tests/services/fileNameMappingService.test.ts new file mode 100644 index 000000000..0e646ca5a --- /dev/null +++ b/tests-ui/tests/services/fileNameMappingService.test.ts @@ -0,0 +1,396 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { api } from '@/scripts/api' +import { + type FileNameMapping, + FileNameMappingService +} from '@/services/fileNameMappingService' + +// Mock api module +vi.mock('@/scripts/api', () => ({ + api: { + fetchApi: vi.fn() + } +})) + +describe('FileNameMappingService', () => { + let service: FileNameMappingService + + beforeEach(() => { + vi.clearAllMocks() + // Create a new instance for each test to avoid cache pollution + service = new FileNameMappingService() + }) + + describe('getMapping', () => { + it('should fetch mappings from API', async () => { + const mockData: FileNameMapping = { + 'abc123.png': 'vacation_photo.png', + 'def456.jpg': 'profile_picture.jpg' + } + + vi.mocked(api.fetchApi).mockResolvedValue({ + ok: true, + status: 200, + json: async () => mockData + } as any) + + const result = await service.getMapping('input') + + expect(api.fetchApi).toHaveBeenCalledWith('/files/mappings') + expect(result).toEqual(mockData) + }) + + it('should cache mappings and not refetch within TTL', async () => { + const mockData: FileNameMapping = { + 'abc123.png': 'vacation_photo.png' + } + + vi.mocked(api.fetchApi).mockResolvedValue({ + ok: true, + status: 200, + json: async () => mockData + } as any) + + // First call + await service.getMapping('input') + expect(api.fetchApi).toHaveBeenCalledTimes(1) + + // Second call should use cache + const result = await service.getMapping('input') + expect(api.fetchApi).toHaveBeenCalledTimes(1) + expect(result).toEqual(mockData) + }) + + it('should return empty object on API failure', async () => { + vi.mocked(api.fetchApi).mockRejectedValue(new Error('Network error')) + + const result = await service.getMapping('input') + + expect(result).toEqual({}) + }) + + it('should return empty object on non-200 response', async () => { + vi.mocked(api.fetchApi).mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found' + } as any) + + const result = await service.getMapping('input') + + expect(result).toEqual({}) + }) + }) + + describe('getCachedMapping', () => { + it('should return empty object if no cached data', () => { + const result = service.getCachedMapping('input') + expect(result).toEqual({}) + }) + + it('should return cached data after successful fetch', async () => { + const mockData: FileNameMapping = { + 'abc123.png': 'vacation_photo.png' + } + + vi.mocked(api.fetchApi).mockResolvedValue({ + ok: true, + status: 200, + json: async () => mockData + } as any) + + await service.getMapping('input') + + const result = service.getCachedMapping('input') + expect(result).toEqual(mockData) + }) + }) + + describe('getCachedReverseMapping', () => { + it('should return reverse mapping (human -> hash)', async () => { + const mockData: FileNameMapping = { + 'abc123.png': 'vacation_photo.png', + 'def456.jpg': 'profile_picture.jpg' + } + + vi.mocked(api.fetchApi).mockResolvedValue({ + ok: true, + status: 200, + json: async () => mockData + } as any) + + await service.getMapping('input') + + const reverseMapping = service.getCachedReverseMapping('input') + expect(reverseMapping).toEqual({ + 'vacation_photo.png': 'abc123.png', + 'profile_picture.jpg': 'def456.jpg' + }) + }) + + it('should return empty object if no cached data', () => { + const result = service.getCachedReverseMapping('input') + expect(result).toEqual({}) + }) + }) + + describe('getHashFromHumanName', () => { + it('should convert human name to hash', async () => { + const mockData: FileNameMapping = { + 'abc123.png': 'vacation_photo.png' + } + + vi.mocked(api.fetchApi).mockResolvedValue({ + ok: true, + status: 200, + json: async () => mockData + } as any) + + await service.getMapping('input') + + const hash = service.getHashFromHumanName('vacation_photo.png', 'input') + expect(hash).toBe('abc123.png') + }) + + it('should return original name if no mapping exists', async () => { + vi.mocked(api.fetchApi).mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({}) + } as any) + + await service.getMapping('input') + + const result = service.getHashFromHumanName('unknown.png', 'input') + expect(result).toBe('unknown.png') + }) + }) + + describe('getHumanReadableName', () => { + it('should convert hash to human-readable name', async () => { + const mockData: FileNameMapping = { + 'abc123.png': 'vacation_photo.png' + } + + vi.mocked(api.fetchApi).mockResolvedValue({ + ok: true, + status: 200, + json: async () => mockData + } as any) + + const humanName = await service.getHumanReadableName( + 'abc123.png', + 'input' + ) + expect(humanName).toBe('vacation_photo.png') + }) + + it('should return hash if no mapping exists', async () => { + vi.mocked(api.fetchApi).mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({}) + } as any) + + const result = await service.getHumanReadableName('xyz789.png', 'input') + expect(result).toBe('xyz789.png') + }) + }) + + describe('refreshMapping', () => { + it('should invalidate cache and fetch fresh data', async () => { + const mockData1: FileNameMapping = { + 'abc123.png': 'old_photo.png' + } + const mockData2: FileNameMapping = { + 'def456.png': 'new_photo.png' + } + + vi.mocked(api.fetchApi) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => mockData1 + } as any) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => mockData2 + } as any) + + // First fetch + await service.getMapping('input') + expect(service.getCachedMapping('input')).toEqual(mockData1) + + // Refresh should fetch new data + const refreshedData = await service.refreshMapping('input') + expect(api.fetchApi).toHaveBeenCalledTimes(2) + expect(refreshedData).toEqual(mockData2) + expect(service.getCachedMapping('input')).toEqual(mockData2) + }) + }) + + describe('invalidateCache', () => { + it('should clear cache for specific file type', async () => { + const mockData: FileNameMapping = { + 'abc123.png': 'photo.png' + } + + vi.mocked(api.fetchApi).mockResolvedValue({ + ok: true, + status: 200, + json: async () => mockData + } as any) + + await service.getMapping('input') + expect(service.getCachedMapping('input')).toEqual(mockData) + + service.invalidateCache('input') + expect(service.getCachedMapping('input')).toEqual({}) + }) + + it('should clear all caches when no type specified', async () => { + const mockData: FileNameMapping = { + 'abc123.png': 'photo.png' + } + + vi.mocked(api.fetchApi).mockResolvedValue({ + ok: true, + status: 200, + json: async () => mockData + } as any) + + await service.getMapping('input') + await service.getMapping('output') + + service.invalidateCache() + + expect(service.getCachedMapping('input')).toEqual({}) + expect(service.getCachedMapping('output')).toEqual({}) + }) + }) + + describe('ensureMappingsLoaded', () => { + it('should preload mappings for immediate synchronous access', async () => { + const mockData: FileNameMapping = { + 'abc123.png': 'photo.png' + } + + vi.mocked(api.fetchApi).mockResolvedValue({ + ok: true, + status: 200, + json: async () => mockData + } as any) + + // Ensure mappings are loaded + await service.ensureMappingsLoaded('input') + + // Should be available synchronously + const cached = service.getCachedMapping('input') + expect(cached).toEqual(mockData) + }) + + it('should not throw on API failure', async () => { + vi.mocked(api.fetchApi).mockRejectedValue(new Error('Network error')) + + // Should not throw + await expect(service.ensureMappingsLoaded('input')).resolves.not.toThrow() + + // Should have empty mapping + expect(service.getCachedMapping('input')).toEqual({}) + }) + }) + + describe('applyMappingToArray', () => { + it('should apply mapping to array of filenames', async () => { + const mockData: FileNameMapping = { + 'abc123.png': 'vacation.png', + 'def456.jpg': 'profile.jpg' + } + + vi.mocked(api.fetchApi).mockResolvedValue({ + ok: true, + status: 200, + json: async () => mockData + } as any) + + const result = await service.applyMappingToArray( + ['abc123.png', 'def456.jpg', 'unknown.gif'], + 'input' + ) + + expect(result).toEqual(['vacation.png', 'profile.jpg', 'unknown.gif']) + }) + + it('should return original array on API failure', async () => { + vi.mocked(api.fetchApi).mockRejectedValue(new Error('Network error')) + + const input = ['abc123.png', 'def456.jpg'] + const result = await service.applyMappingToArray(input, 'input') + + expect(result).toEqual(input) + }) + }) + + describe('edge cases', () => { + it('should handle invalid JSON response gracefully', async () => { + vi.mocked(api.fetchApi).mockResolvedValue({ + ok: true, + status: 200, + json: async () => { + throw new Error('Invalid JSON') + } + } as any) + + const result = await service.getMapping('input') + expect(result).toEqual({}) + }) + + it('should filter out invalid entries from response', async () => { + const mockData = { + 'valid.png': 'photo.png', + invalid: 123, // Invalid value type - will be filtered + 123: 'number_key', // Numeric key becomes string "123" in JS + 'another_valid.jpg': 'image.jpg' + } + + vi.mocked(api.fetchApi).mockResolvedValue({ + ok: true, + status: 200, + json: async () => mockData + } as any) + + const result = await service.getMapping('input') + + // Should filter out non-string values but keep string keys (including coerced numeric keys) + expect(result).toEqual({ + 'valid.png': 'photo.png', + '123': 'number_key', // Numeric key becomes string + 'another_valid.jpg': 'image.jpg' + }) + }) + + it('should handle null or array responses', async () => { + // Test null response + vi.mocked(api.fetchApi).mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => null + } as any) + + let result = await service.getMapping('input') + expect(result).toEqual({}) + + // Test array response + vi.mocked(api.fetchApi).mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => [] + } as any) + + result = await service.getMapping('output') + expect(result).toEqual({}) + }) + }) +})