feat: add filename mapping to frontend to display human readable names on input nodes

This commit is contained in:
Richard Yu
2025-09-03 11:04:00 -07:00
parent 799795cf56
commit c6c9487c0d
7 changed files with 2039 additions and 8 deletions

View File

@@ -18,9 +18,280 @@ import {
type ComfyWidgetConstructorV2,
addValueControlWidgets
} from '@/scripts/widgets'
import { fileNameMappingService } from '@/services/fileNameMappingService'
import { useRemoteWidget } from './useRemoteWidget'
// Extended interface for widgets with filename mapping
interface IFilenameMappingWidget extends IComboWidget {
serializeValue?: () => any
getRawValues?: () => string[]
refreshMappings?: () => void
incrementValue?: (options: any) => void
decrementValue?: (options: any) => void
setValue?: (value: any, options?: any) => void
_displayValue?: string
computedDisabled?: boolean
}
// 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
) {
// Validate widget exists
if (!widget) {
return
}
// Simple approach: just override _displayValue for text display
// Leave all widget functionality intact
// Cast to extended interface for type safety
const mappingWidget = widget as IFilenameMappingWidget
// Override serializeValue to ensure hash is used for API
mappingWidget.serializeValue = function () {
// Always return the actual widget value (hash) for serialization
return mappingWidget.value
}
// Override _displayValue to show human-readable names
try {
Object.defineProperty(mappingWidget, '_displayValue', {
get() {
if (mappingWidget.computedDisabled) return ''
// Get current hash value
const hashValue = mappingWidget.value
if (typeof hashValue !== 'string') return String(hashValue)
// Try to get human-readable name from cache (deduplicated for display)
const mapping = fileNameMappingService.getCachedMapping('input', true)
const humanName = mapping[hashValue]
// Return human name for display, fallback to hash
return humanName || hashValue
},
configurable: true
})
} catch (error) {
// Property might be non-configurable, continue without override
}
// Also override the options.values to show human names in dropdown
const originalOptions = mappingWidget.options as any
// Store original values array - maintain the same array reference
const rawValues = Array.isArray(originalOptions.values)
? originalOptions.values
: []
// Create a computed property that returns mapped values
if (mappingWidget.options) {
try {
Object.defineProperty(mappingWidget.options, 'values', {
get() {
if (!Array.isArray(rawValues)) return rawValues
// Map values to human-readable names (deduplicated for dropdown display)
const mapping = fileNameMappingService.getCachedMapping('input', true)
const mapped = rawValues.map((value: any) => {
if (typeof value === 'string') {
const humanName = mapping[value]
if (humanName) {
return humanName
}
}
return value
})
return mapped
},
set(newValues) {
// Update raw values array in place to maintain reference
rawValues.length = 0
if (Array.isArray(newValues)) {
rawValues.push(...newValues)
}
// Trigger UI update
node.setDirtyCanvas?.(true, true)
node.graph?.setDirtyCanvas?.(true, true)
},
configurable: true,
enumerable: true
})
} catch (error) {
// Property might be non-configurable, continue without override
}
}
// Add helper methods for managing the raw values
mappingWidget.getRawValues = function () {
return rawValues
}
// Add a method to force refresh the dropdown
mappingWidget.refreshMappings = function () {
// Force litegraph to re-read the values and trigger UI update
node.setDirtyCanvas?.(true, true)
node.graph?.setDirtyCanvas?.(true, true)
}
// Override incrementValue and decrementValue for arrow key navigation
mappingWidget.incrementValue = function (options: any) {
// Get the current human-readable value (deduplicated)
const mapping = fileNameMappingService.getCachedMapping('input', true)
const currentHumanName = mapping[mappingWidget.value] || mappingWidget.value
// Get the values array (which contains human names through our proxy)
const rawValues = mappingWidget.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]
mappingWidget.setValue?.(nextValue, options)
}
}
mappingWidget.decrementValue = function (options: any) {
// Get the current human-readable value (deduplicated)
const mapping = fileNameMappingService.getCachedMapping('input', true)
const currentHumanName = mapping[mappingWidget.value] || mappingWidget.value
// Get the values array (which contains human names through our proxy)
const rawValues = mappingWidget.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]
mappingWidget.setValue?.(prevValue, options)
}
}
// Override setValue to handle human name selection from dropdown
const originalSetValue = mappingWidget.setValue
mappingWidget.setValue = function (selectedValue: any, options?: any) {
if (typeof selectedValue === 'string') {
// Check if this is a human-readable name that needs reverse mapping
// Use deduplicated reverse mapping to handle suffixed names
const reverseMapping = fileNameMappingService.getCachedReverseMapping(
'input',
true
)
const hashValue = reverseMapping[selectedValue] || selectedValue
// Set the hash value
mappingWidget.value = hashValue
// Call original setValue with hash value if it exists
if (originalSetValue) {
originalSetValue.call(mappingWidget, hashValue, options)
}
// Trigger callback with hash value
if (mappingWidget.callback) {
mappingWidget.callback.call(mappingWidget, hashValue)
}
} else {
mappingWidget.value = selectedValue
if (originalSetValue) {
originalSetValue.call(mappingWidget, selectedValue, options)
}
if (mappingWidget.callback) {
mappingWidget.callback.call(mappingWidget, selectedValue)
}
}
}
// Override callback to handle human name selection
const originalCallback = mappingWidget.callback
if (mappingWidget.callback) {
mappingWidget.callback = function (selectedValue: any) {
if (typeof selectedValue === 'string') {
// Check if this is a human-readable name that needs reverse mapping
// Use deduplicated reverse mapping to handle suffixed names
const reverseMapping = fileNameMappingService.getCachedReverseMapping(
'input',
true
)
const hashValue = reverseMapping[selectedValue] || selectedValue
// Set the hash value
mappingWidget.value = hashValue
// Call original callback with hash value
if (originalCallback) {
originalCallback.call(mappingWidget, hashValue)
}
} else {
mappingWidget.value = selectedValue
if (originalCallback) {
originalCallback.call(mappingWidget, 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 +362,17 @@ 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)
if (hasFilenames) {
// Apply filename mapping for display
applyFilenameMappingToWidget(widget, node, inputSpec)
}
}
return widget
}

View File

@@ -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,36 @@ export const useImageUploadWidget = () => {
fileFilter,
accept,
folder,
onUploadComplete: (output) => {
output.forEach((path) => addToComboValues(fileComboWidget, path))
onUploadComplete: async (output) => {
// CRITICAL: Refresh mappings FIRST before updating dropdown
// This ensures new hash→human mappings are available when dropdown renders
try {
await fileNameMappingService.refreshMapping('input')
} catch (error) {
// Continue anyway - will show hash values as fallback
}
// Now add the files to dropdown - addToComboValues will trigger refreshMappings
output.forEach((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') {
;(fileComboWidget as any).refreshMappings()
}
// Trigger UI update to show human-readable names
node.setDirtyCanvas?.(true, true)
node.graph?.setDirtyCanvas?.(true, true)
}
})

View File

@@ -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,19 @@ 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')
} catch (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

View File

@@ -0,0 +1,451 @@
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
dedupData?: FileNameMapping // Deduplicated mapping with unique display names
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
private readonly MAX_MAPPING_SIZE = 10000 // Maximum entries per mapping
private readonly MAX_FILENAME_LENGTH = 256 // Maximum filename length
private cleanupTimer: NodeJS.Timeout | null = null
/**
* 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) {
// Log error without exposing file paths
if (process.env.NODE_ENV === 'development') {
console.warn('Failed to get human readable name:', 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) {
// Log error without exposing sensitive data
if (process.env.NODE_ENV === 'development') {
console.warn('Failed to apply filename mapping')
}
return hashFilenames
}
}
/**
* Get cached mapping synchronously (returns empty object if not cached).
* @param fileType - The file type to get cached mapping for
* @param deduplicated - Whether to return deduplicated names for display
* @returns The cached mapping or empty object
*/
getCachedMapping(
fileType: FileType = 'input',
deduplicated: boolean = false
): FileNameMapping {
const cached = this.cache.get(fileType)
if (cached && !this.isExpired(cached) && !cached.failed) {
// Return deduplicated mapping if requested and available
if (deduplicated && cached.dedupData) {
return cached.dedupData
}
const result = cached.data
return result
}
// Cache miss - return empty object
return {}
}
/**
* Get reverse mapping (human-readable name to hash) synchronously.
* @param fileType - The file type to get reverse mapping for
* @param deduplicated - Whether to use deduplicated names
* @returns The reverse mapping object
*/
getCachedReverseMapping(
fileType: FileType = 'input',
deduplicated: boolean = false
): Record<string, string> {
const mapping = this.getCachedMapping(fileType, deduplicated)
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> {
this.invalidateCache(fileType)
const freshMapping = await this.getMapping(fileType)
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
}
}
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)
}
// Schedule cleanup if not already scheduled
this.scheduleCleanup()
// 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.dedupData = this.deduplicateMapping(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
// Using fallback for failed fetch
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') {
// API not available - return empty mapping silently
return {}
}
let response: Response
try {
response = await api.fetchApi(`/files/mappings`)
} catch (error) {
// Network error - return empty mapping silently
return {} // Return empty mapping instead of throwing
}
if (!response.ok) {
// Non-OK response - use empty mapping silently
return {} // Graceful degradation
}
let data: any
try {
// Check if response has json method
if (typeof response.json !== 'function') {
// Response has no json() method - return empty
return {}
}
data = await response.json()
} catch (jsonError) {
// JSON parse error - return empty mapping silently
return {} // Return empty mapping on parse error
}
// Validate response structure
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
// Invalid response format - return empty mapping silently
return {}
}
// Validate and filter entries
const validEntries: FileNameMapping = {}
let invalidEntryCount = 0
let entryCount = 0
for (const [key, value] of Object.entries(data)) {
// Enforce maximum mapping size
if (entryCount >= this.MAX_MAPPING_SIZE) {
// Mapping size exceeds limit - truncate silently
break
}
// Validate entry types and content
if (
typeof key === 'string' &&
typeof value === 'string' &&
this.isValidFilename(key) &&
this.isValidFilename(value)
) {
validEntries[key] = value
entryCount++
} else {
invalidEntryCount++
}
}
// Log if entries were filtered
if (invalidEntryCount > 0) {
// Some entries were filtered due to validation
}
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
}
/**
* Deduplicate human-readable names when multiple hashes map to the same name.
* Adds a suffix to duplicate names to make them unique.
* @param mapping - The original hash -> human name mapping
* @returns A new mapping with deduplicated human names
*/
private deduplicateMapping(mapping: FileNameMapping): FileNameMapping {
const dedupMapping: FileNameMapping = {}
const nameCount = new Map<string, number>()
const nameToHashes = new Map<string, string[]>()
// First pass: count occurrences of each human name
for (const [hash, humanName] of Object.entries(mapping)) {
const count = nameCount.get(humanName) || 0
nameCount.set(humanName, count + 1)
// Track which hashes map to this human name
const hashes = nameToHashes.get(humanName) || []
hashes.push(hash)
nameToHashes.set(humanName, hashes)
}
// Second pass: create deduplicated names
const nameIndex = new Map<string, number>()
for (const [hash, humanName] of Object.entries(mapping)) {
const count = nameCount.get(humanName) || 1
if (count === 1) {
// No duplicates, use original name
dedupMapping[hash] = humanName
} else {
// Has duplicates, add suffix
const currentIndex = (nameIndex.get(humanName) || 0) + 1
nameIndex.set(humanName, currentIndex)
// Extract file extension if present
const lastDotIndex = humanName.lastIndexOf('.')
let baseName = humanName
let extension = ''
if (lastDotIndex > 0 && lastDotIndex < humanName.length - 1) {
baseName = humanName.substring(0, lastDotIndex)
extension = humanName.substring(lastDotIndex)
}
// Add suffix: use first 8 chars of hash (without extension)
// Remove extension from hash if present
const hashWithoutExt = hash.includes('.')
? hash.substring(0, hash.lastIndexOf('.'))
: hash
const hashSuffix = hashWithoutExt.substring(0, 8)
dedupMapping[hash] = `${baseName}_${hashSuffix}${extension}`
}
}
// Deduplication complete
return dedupMapping
}
/**
* Validate filename for security and length constraints
*/
private isValidFilename(filename: string): boolean {
// Check length
if (filename.length === 0 || filename.length > this.MAX_FILENAME_LENGTH) {
return false
}
// Check for dangerous characters or patterns
// Disallow: < > : " | ? * and control characters (0x00-0x1f)
// eslint-disable-next-line no-control-regex
const dangerousPattern = /[<>:"|?*\x00-\x1f]/
if (dangerousPattern.test(filename)) {
return false
}
// Disallow path traversal attempts
if (filename.includes('../') || filename.includes('..\\')) {
return false
}
return true
}
/**
* Schedule automatic cleanup of expired cache entries
*/
private scheduleCleanup(): void {
if (this.cleanupTimer) {
return // Already scheduled
}
// Schedule cleanup to run after TTL expires
this.cleanupTimer = setTimeout(() => {
this.cleanupExpiredEntries()
this.cleanupTimer = null
}, this.TTL)
}
/**
* Remove expired entries from cache to prevent memory leaks
*/
private cleanupExpiredEntries(): void {
const entriesToDelete: FileType[] = []
// Find expired entries
this.cache.forEach((entry, fileType) => {
if (this.isExpired(entry) && !entry.fetchPromise) {
entriesToDelete.push(fileType)
}
})
// Remove expired entries
entriesToDelete.forEach((fileType) => {
this.cache.delete(fileType)
})
}
/**
* Cleanup method for proper disposal
*/
dispose(): void {
if (this.cleanupTimer) {
clearTimeout(this.cleanupTimer)
this.cleanupTimer = null
}
this.cache.clear()
}
}
// Singleton instance
export const fileNameMappingService = new FileNameMappingService()

View File

@@ -43,10 +43,34 @@ 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)
interface MappingWidget extends IComboWidget {
getRawValues?: () => string[]
refreshMappings?: () => void
}
const mappingWidget = widget as MappingWidget
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)) {
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)
}
}
}

View File

@@ -2,16 +2,189 @@ 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()
})
describe('deduplication', () => {
it('should display deduplicated names in dropdown', () => {
const constructor = useComboWidget()
const mockWidget = {
name: 'image',
value: 'hash1.png',
options: {
values: ['hash1.png', 'hash2.png', 'hash3.png']
},
callback: vi.fn()
}
const mockNode = {
addWidget: vi.fn().mockReturnValue(mockWidget)
}
// Mock deduplicated mapping
vi.mocked(fileNameMappingService.getCachedMapping).mockImplementation(
(_fileType, deduplicated) => {
if (deduplicated) {
return {
'hash1.png': 'vacation_hash1.png',
'hash2.png': 'vacation_hash2.png',
'hash3.png': 'landscape.png'
}
}
return {
'hash1.png': 'vacation.png',
'hash2.png': 'vacation.png',
'hash3.png': 'landscape.png'
}
}
)
const inputSpec: InputSpec = {
type: 'COMBO',
name: 'image',
options: ['hash1.png', 'hash2.png', 'hash3.png']
}
const widget = constructor(mockNode as any, inputSpec)
// Check that dropdown values are deduplicated
const dropdownValues = widget.options.values
expect(dropdownValues).toEqual([
'vacation_hash1.png',
'vacation_hash2.png',
'landscape.png'
])
})
it('should correctly handle selection of deduplicated names', () => {
const constructor = useComboWidget()
const mockWidget = {
name: 'image',
value: 'hash1.png',
options: {
values: ['hash1.png', 'hash2.png']
},
callback: vi.fn()
}
const mockNode = {
addWidget: vi.fn().mockReturnValue(mockWidget)
}
// Mock deduplicated mappings
vi.mocked(fileNameMappingService.getCachedMapping).mockImplementation(
(_fileType, deduplicated) => {
if (deduplicated) {
return {
'hash1.png': 'image_hash1.png',
'hash2.png': 'image_hash2.png'
}
}
return {
'hash1.png': 'image.png',
'hash2.png': 'image.png'
}
}
)
vi.mocked(
fileNameMappingService.getCachedReverseMapping
).mockImplementation((_fileType, deduplicated) => {
if (deduplicated) {
return {
'image_hash1.png': 'hash1.png',
'image_hash2.png': 'hash2.png'
} as Record<string, string>
}
return {
'image.png': 'hash2.png' // Last one wins in non-dedup
} as Record<string, string>
})
const inputSpec: InputSpec = {
type: 'COMBO',
name: 'image',
options: ['hash1.png', 'hash2.png']
}
const widget = constructor(mockNode as any, inputSpec)
// Select deduplicated name
;(widget as any).setValue('image_hash1.png')
// Should set the correct hash value
expect(widget.value).toBe('hash1.png')
})
it('should display correct deduplicated name in _displayValue', () => {
const constructor = useComboWidget()
const mockWidget = {
name: 'image',
value: 'abc123.png',
options: {
values: ['abc123.png', 'def456.png']
},
callback: vi.fn()
}
const mockNode = {
addWidget: vi.fn().mockReturnValue(mockWidget)
}
// Mock deduplicated mapping
vi.mocked(fileNameMappingService.getCachedMapping).mockImplementation(
(_fileType, deduplicated) => {
if (deduplicated) {
return {
'abc123.png': 'photo_abc123.png',
'def456.png': 'photo_def456.png'
}
}
return {
'abc123.png': 'photo.png',
'def456.png': 'photo.png'
}
}
)
const inputSpec: InputSpec = {
type: 'COMBO',
name: 'image',
options: ['abc123.png', 'def456.png']
}
const widget = constructor(mockNode as any, inputSpec)
// Check display value shows deduplicated name
expect((widget as any)._displayValue).toBe('photo_abc123.png')
})
})
it('should handle undefined spec', () => {
const constructor = useComboWidget()
const mockNode = {
@@ -36,4 +209,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'])
})
})
})

View File

@@ -0,0 +1,571 @@
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('deduplication', () => {
it('should not modify unique names', async () => {
const mockData: FileNameMapping = {
'abc123.png': 'vacation.png',
'def456.jpg': 'profile.jpg',
'ghi789.gif': 'animation.gif'
}
vi.mocked(api.fetchApi).mockResolvedValue({
ok: true,
status: 200,
json: async () => mockData
} as any)
await service.getMapping('input')
const dedupMapping = service.getCachedMapping('input', true)
// All unique names should remain unchanged
expect(dedupMapping['abc123.png']).toBe('vacation.png')
expect(dedupMapping['def456.jpg']).toBe('profile.jpg')
expect(dedupMapping['ghi789.gif']).toBe('animation.gif')
})
it('should add hash suffix to duplicate names', async () => {
const mockData: FileNameMapping = {
'abc123def456.png': 'vacation.png',
'xyz789uvw012.png': 'vacation.png',
'mno345pqr678.png': 'vacation.png'
}
vi.mocked(api.fetchApi).mockResolvedValue({
ok: true,
status: 200,
json: async () => mockData
} as any)
await service.getMapping('input')
const dedupMapping = service.getCachedMapping('input', true)
// Check that all values are unique
const values = Object.values(dedupMapping)
const uniqueValues = new Set(values)
expect(uniqueValues.size).toBe(values.length)
// Check that suffixes are added correctly
expect(dedupMapping['abc123def456.png']).toBe('vacation_abc123de.png')
expect(dedupMapping['xyz789uvw012.png']).toBe('vacation_xyz789uv.png')
expect(dedupMapping['mno345pqr678.png']).toBe('vacation_mno345pq.png')
})
it('should preserve file extensions when deduplicating', async () => {
const mockData: FileNameMapping = {
'hash1234.safetensors': 'model.safetensors',
'hash5678.safetensors': 'model.safetensors'
}
vi.mocked(api.fetchApi).mockResolvedValue({
ok: true,
status: 200,
json: async () => mockData
} as any)
await service.getMapping('input')
const dedupMapping = service.getCachedMapping('input', true)
// Extensions should be preserved
expect(dedupMapping['hash1234.safetensors']).toBe(
'model_hash1234.safetensors'
)
expect(dedupMapping['hash5678.safetensors']).toBe(
'model_hash5678.safetensors'
)
})
it('should handle files without extensions', async () => {
const mockData: FileNameMapping = {
abc123: 'README',
def456: 'README',
ghi789: 'LICENSE'
}
vi.mocked(api.fetchApi).mockResolvedValue({
ok: true,
status: 200,
json: async () => mockData
} as any)
await service.getMapping('input')
const dedupMapping = service.getCachedMapping('input', true)
// Files without extensions should still get deduplicated
expect(dedupMapping['abc123']).toBe('README_abc123')
expect(dedupMapping['def456']).toBe('README_def456')
expect(dedupMapping['ghi789']).toBe('LICENSE') // Unique, no suffix
})
it('should build correct reverse mapping for deduplicated names', async () => {
const mockData: FileNameMapping = {
'hash1.png': 'image.png',
'hash2.png': 'image.png',
'hash3.jpg': 'photo.jpg'
}
vi.mocked(api.fetchApi).mockResolvedValue({
ok: true,
status: 200,
json: async () => mockData
} as any)
await service.getMapping('input')
const reverseMapping = service.getCachedReverseMapping('input', true)
// Reverse mapping should map deduplicated names back to hashes
expect(reverseMapping['image_hash1.png']).toBe('hash1.png')
expect(reverseMapping['image_hash2.png']).toBe('hash2.png')
expect(reverseMapping['photo.jpg']).toBe('hash3.jpg')
// Should not have original duplicate names in reverse mapping
expect(reverseMapping['image.png']).toBeUndefined()
})
it('should handle mixed duplicate and unique names', async () => {
const mockData: FileNameMapping = {
'a1.png': 'sunset.png',
'b2.png': 'sunset.png',
'c3.jpg': 'portrait.jpg',
'd4.gif': 'animation.gif',
'e5.png': 'sunset.png'
}
vi.mocked(api.fetchApi).mockResolvedValue({
ok: true,
status: 200,
json: async () => mockData
} as any)
await service.getMapping('input')
const dedupMapping = service.getCachedMapping('input', true)
// Duplicates get suffixes
expect(dedupMapping['a1.png']).toBe('sunset_a1.png')
expect(dedupMapping['b2.png']).toBe('sunset_b2.png')
expect(dedupMapping['e5.png']).toBe('sunset_e5.png')
// Unique names remain unchanged
expect(dedupMapping['c3.jpg']).toBe('portrait.jpg')
expect(dedupMapping['d4.gif']).toBe('animation.gif')
})
it('should return non-deduplicated mapping when deduplicated=false', async () => {
const mockData: FileNameMapping = {
'hash1.png': 'image.png',
'hash2.png': 'image.png'
}
vi.mocked(api.fetchApi).mockResolvedValue({
ok: true,
status: 200,
json: async () => mockData
} as any)
await service.getMapping('input')
// Without deduplication flag
const normalMapping = service.getCachedMapping('input', false)
expect(normalMapping['hash1.png']).toBe('image.png')
expect(normalMapping['hash2.png']).toBe('image.png')
// With deduplication flag
const dedupMapping = service.getCachedMapping('input', true)
expect(dedupMapping['hash1.png']).toBe('image_hash1.png')
expect(dedupMapping['hash2.png']).toBe('image_hash2.png')
})
})
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({})
})
})
})