mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-04 15:10:06 +00:00
wip
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
321
src/services/fileNameMappingService.ts
Normal file
321
src/services/fileNameMappingService.ts
Normal file
@@ -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<FileNameMapping>
|
||||
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<FileType, CacheEntry>()
|
||||
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<FileNameMapping> {
|
||||
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<string> {
|
||||
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<string[]> {
|
||||
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<string, string> {
|
||||
const mapping = this.getCachedMapping(fileType)
|
||||
const reverseMapping: Record<string, string> = {}
|
||||
|
||||
// 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<FileNameMapping> {
|
||||
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<void> {
|
||||
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<FileNameMapping> {
|
||||
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<FileNameMapping> {
|
||||
// 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()
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
396
tests-ui/tests/services/fileNameMappingService.test.ts
Normal file
396
tests-ui/tests/services/fileNameMappingService.test.ts
Normal file
@@ -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({})
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user