mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-29 18:52:19 +00:00
feat: add filename mapping to frontend to display human readable names on input nodes
This commit is contained in:
706
tests-ui/tests/composables/widgets/useComboWidget.test.ts
Normal file
706
tests-ui/tests/composables/widgets/useComboWidget.test.ts
Normal file
@@ -0,0 +1,706 @@
|
||||
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 = {
|
||||
addWidget: vi.fn().mockReturnValue({ options: {} } as any)
|
||||
}
|
||||
|
||||
const inputSpec: InputSpec = {
|
||||
type: 'COMBO',
|
||||
name: 'inputName'
|
||||
}
|
||||
|
||||
const widget = constructor(mockNode as any, inputSpec)
|
||||
|
||||
expect(mockNode.addWidget).toHaveBeenCalledWith(
|
||||
'combo',
|
||||
'inputName',
|
||||
undefined, // default value
|
||||
expect.any(Function), // callback
|
||||
expect.objectContaining({
|
||||
values: []
|
||||
})
|
||||
)
|
||||
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'])
|
||||
})
|
||||
})
|
||||
})
|
||||
571
tests-ui/tests/services/fileNameMappingService.test.ts
Normal file
571
tests-ui/tests/services/fileNameMappingService.test.ts
Normal 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({})
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user