Files
ComfyUI_frontend/src/renderer/extensions/vueNodes/components/ImagePreview.test.ts
Dante 8db6fb7733 feat: replace PrimeVue Galleria/Skeleton with custom DisplayCarousel and ImagePreview (#9712)
## Summary
- Replace `primevue/galleria` with custom `DisplayCarousel` component
featuring Single (carousel) and Grid display modes
- Hover action buttons (mask, download, remove) appear on image
hover/focus
- Thumbnail strip with prev/next navigation; arrows at edges, thumbnails
centered
- Grid mode uses fixed 56px image tiles matching Figma spec
- Replace `primevue/skeleton` and `useToast()` in `ImagePreview` with
`Skeleton.vue` and `useToastStore()`
- Rename `WidgetGalleria` → `DisplayCarousel` across registry, stories,
and tests
- Add Storybook stories for both `DisplayCarousel` and `ImagePreview`
- Retain `WidgetGalleriaOriginal` with its own story for side-by-side
comparison

## Test plan
- [x] Unit tests pass (30 DisplayCarousel + 21 ImagePreview)
- [x] `pnpm typecheck` clean
- [x] `pnpm lint` clean
- [x] `pnpm knip` clean
- [x] Visual verification via Storybook: hover controls, nav, grid mode,
single/grid toggle
- [x] Manual Storybook check: Components/Display/DisplayCarousel,
Components/Display/ImagePreview


## screenshot
<img width="604" height="642" alt="스크린샷 2026-03-12 오후 2 01 51"
src="https://github.com/user-attachments/assets/94df3070-9910-470b-a8f5-5507433ef6e6"
/>
<img width="609" height="651" alt="스크린샷 2026-03-12 오후 2 04 47"
src="https://github.com/user-attachments/assets/3d9884b4-f1bd-4ef5-957a-c7cf7fdc04d8"
/>
<img width="729" height="681" alt="스크린샷 2026-03-12 오후 2 04 49"
src="https://github.com/user-attachments/assets/715f9367-17a3-4d7b-b81f-a7cd6bd446bf"
/>
<img width="534" height="460" alt="스크린샷 2026-03-12 오후 2 05 39"
src="https://github.com/user-attachments/assets/b810eee2-55cb-4dbd-aaca-6331527d13ca"
/>


🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 18:10:42 +09:00

424 lines
13 KiB
TypeScript

import { createTestingPinia } from '@pinia/testing'
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
import ImagePreview from '@/renderer/extensions/vueNodes/components/ImagePreview.vue'
// Mock downloadFile to avoid DOM errors
vi.mock('@/base/common/downloadUtil', () => ({
downloadFile: vi.fn()
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
editOrMaskImage: 'Edit or mask image',
downloadImage: 'Download image',
removeImage: 'Remove image',
viewImageOfTotal: 'View image {index} of {total}',
imagePreview:
'Image preview - Use arrow keys to navigate between images',
errorLoadingImage: 'Error loading image',
failedToDownloadImage: 'Failed to download image',
calculatingDimensions: 'Calculating dimensions',
imageFailedToLoad: 'Image failed to load',
imageDoesNotExist: 'Image does not exist',
unknownFile: 'Unknown file',
loading: 'Loading'
}
}
}
})
describe('ImagePreview', () => {
const defaultProps = {
imageUrls: [
'/api/view?filename=test1.png&type=output',
'/api/view?filename=test2.png&type=output'
]
}
const wrapperRegistry = new Set<VueWrapper>()
const mountImagePreview = (props = {}) => {
const wrapper = mount(ImagePreview, {
props: { ...defaultProps, ...props },
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn
}),
i18n
],
stubs: {
'i-lucide:venetian-mask': true,
'i-lucide:download': true,
'i-lucide:x': true,
'i-lucide:image-off': true,
Skeleton: true
}
}
})
wrapperRegistry.add(wrapper)
return wrapper
}
afterEach(() => {
wrapperRegistry.forEach((wrapper) => {
wrapper.unmount()
})
wrapperRegistry.clear()
})
it('renders image preview when imageUrls provided', () => {
const wrapper = mountImagePreview()
expect(wrapper.find('.image-preview').exists()).toBe(true)
expect(wrapper.find('img').exists()).toBe(true)
expect(wrapper.find('img').attributes('src')).toBe(
defaultProps.imageUrls[0]
)
})
it('does not render when no imageUrls provided', () => {
const wrapper = mountImagePreview({ imageUrls: [] })
expect(wrapper.find('.image-preview').exists()).toBe(false)
})
it('displays calculating dimensions text initially', () => {
const wrapper = mountImagePreview()
expect(wrapper.text()).toContain('Calculating dimensions')
})
it('shows navigation dots for multiple images', () => {
const wrapper = mountImagePreview()
const navigationDots = wrapper.findAll('[aria-label*="View image"]')
expect(navigationDots).toHaveLength(2)
})
it('does not show navigation dots for single image', () => {
const wrapper = mountImagePreview({
imageUrls: [defaultProps.imageUrls[0]]
})
const navigationDots = wrapper.findAll('[aria-label*="View image"]')
expect(navigationDots).toHaveLength(0)
})
it('shows action buttons on hover', async () => {
const wrapper = mountImagePreview()
// Initially buttons should not be visible
expect(wrapper.find('.actions').exists()).toBe(false)
// Trigger hover on the image wrapper (the element with role="img" has the hover handlers)
const imageWrapper = wrapper.find('[role="img"]')
await imageWrapper.trigger('mouseenter')
await nextTick()
// Action buttons should now be visible
expect(wrapper.find('.actions').exists()).toBe(true)
// For multiple images: download and remove buttons (no mask button)
expect(wrapper.find('[aria-label="Download image"]').exists()).toBe(true)
expect(wrapper.find('[aria-label="Remove image"]').exists()).toBe(true)
expect(wrapper.find('[aria-label="Edit or mask image"]').exists()).toBe(
false
)
})
it('hides action buttons when not hovering', async () => {
const wrapper = mountImagePreview()
const imageWrapper = wrapper.find('[role="img"]')
// Trigger hover
await imageWrapper.trigger('mouseenter')
await nextTick()
expect(wrapper.find('.actions').exists()).toBe(true)
// Trigger mouse leave
await imageWrapper.trigger('mouseleave')
await nextTick()
expect(wrapper.find('.actions').exists()).toBe(false)
})
it('shows action buttons on focus', async () => {
const wrapper = mountImagePreview()
// Initially buttons should not be visible
expect(wrapper.find('.actions').exists()).toBe(false)
// Trigger focusin on the image wrapper (useFocusWithin listens to focusin/focusout)
const imageWrapper = wrapper.find('[role="img"]')
await imageWrapper.trigger('focusin')
await nextTick()
// Action buttons should now be visible
expect(wrapper.find('.actions').exists()).toBe(true)
})
it('hides action buttons on blur', async () => {
const wrapper = mountImagePreview()
const imageWrapper = wrapper.find('[role="img"]')
// Trigger focus
await imageWrapper.trigger('focusin')
await nextTick()
expect(wrapper.find('.actions').exists()).toBe(true)
// Trigger focusout
await imageWrapper.trigger('focusout')
await nextTick()
expect(wrapper.find('.actions').exists()).toBe(false)
})
it('shows mask/edit button only for single images', async () => {
// Multiple images - should not show mask button
const multipleImagesWrapper = mountImagePreview()
await multipleImagesWrapper.find('[role="img"]').trigger('mouseenter')
await nextTick()
const maskButtonMultiple = multipleImagesWrapper.find(
'[aria-label="Edit or mask image"]'
)
expect(maskButtonMultiple.exists()).toBe(false)
// Single image - should show mask button
const singleImageWrapper = mountImagePreview({
imageUrls: [defaultProps.imageUrls[0]]
})
await singleImageWrapper.find('[role="img"]').trigger('mouseenter')
await nextTick()
const maskButtonSingle = singleImageWrapper.find(
'[aria-label="Edit or mask image"]'
)
expect(maskButtonSingle.exists()).toBe(true)
})
it('handles action button clicks', async () => {
const wrapper = mountImagePreview({
imageUrls: [defaultProps.imageUrls[0]]
})
await wrapper.find('[role="img"]').trigger('mouseenter')
await nextTick()
// Test Edit/Mask button - just verify it can be clicked without errors
const editButton = wrapper.find('[aria-label="Edit or mask image"]')
expect(editButton.exists()).toBe(true)
await editButton.trigger('click')
// Test Remove button - just verify it can be clicked without errors
const removeButton = wrapper.find('[aria-label="Remove image"]')
expect(removeButton.exists()).toBe(true)
await removeButton.trigger('click')
})
it('handles download button click', async () => {
const wrapper = mountImagePreview({
imageUrls: [defaultProps.imageUrls[0]]
})
await wrapper.find('[role="img"]').trigger('mouseenter')
await nextTick()
// Test Download button
const downloadButton = wrapper.find('[aria-label="Download image"]')
expect(downloadButton.exists()).toBe(true)
await downloadButton.trigger('click')
// Verify the mocked downloadFile was called
expect(downloadFile).toHaveBeenCalledWith(defaultProps.imageUrls[0])
})
it('switches images when navigation dots are clicked', async () => {
const wrapper = mountImagePreview()
// Initially shows first image
expect(wrapper.find('img').attributes('src')).toBe(
defaultProps.imageUrls[0]
)
// Click second navigation dot
const navigationDots = wrapper.findAll('[aria-label*="View image"]')
await navigationDots[1].trigger('click')
await nextTick()
// Now should show second image
const imgElement = wrapper.find('img')
expect(imgElement.exists()).toBe(true)
expect(imgElement.attributes('src')).toBe(defaultProps.imageUrls[1])
})
it('marks active navigation dot with aria-current', async () => {
const wrapper = mountImagePreview()
const navigationDots = wrapper.findAll('[aria-label*="View image"]')
// First dot should be active
expect(navigationDots[0].attributes('aria-current')).toBe('true')
expect(navigationDots[1].attributes('aria-current')).toBeUndefined()
// Switch to second image
await navigationDots[1].trigger('click')
await nextTick()
// Second dot should now be active
expect(navigationDots[0].attributes('aria-current')).toBeUndefined()
expect(navigationDots[1].attributes('aria-current')).toBe('true')
})
it('loads image without errors', async () => {
const wrapper = mountImagePreview()
const img = wrapper.find('img')
expect(img.exists()).toBe(true)
// Just verify the image element is properly set up
expect(img.attributes('src')).toBe(defaultProps.imageUrls[0])
})
it('has proper accessibility attributes', () => {
const wrapper = mountImagePreview()
const img = wrapper.find('img')
expect(img.attributes('alt')).toBe('Node output 1')
})
it('updates alt text when switching images', async () => {
const wrapper = mountImagePreview()
// Initially first image
expect(wrapper.find('img').attributes('alt')).toBe('Node output 1')
// Switch to second image
const navigationDots = wrapper.findAll('[aria-label*="View image"]')
await navigationDots[1].trigger('click')
await nextTick()
// Alt text should update
const imgElement = wrapper.find('img')
expect(imgElement.exists()).toBe(true)
expect(imgElement.attributes('alt')).toBe('Node output 2')
})
describe('batch cycling with identical URLs', () => {
it('should not enter persistent loading state when cycling through identical images', async () => {
vi.useFakeTimers()
try {
const sameUrl = '/api/view?filename=test.png&type=output'
const wrapper = mountImagePreview({
imageUrls: [sameUrl, sameUrl, sameUrl]
})
// Simulate initial image load
await wrapper.find('img').trigger('load')
await nextTick()
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)
// Click second navigation dot to cycle
const dots = wrapper.findAll('[aria-label*="View image"]')
await dots[1].trigger('click')
await nextTick()
// Advance past the delayed loader timeout
await vi.advanceTimersByTimeAsync(300)
await nextTick()
// Should NOT be in loading state since URL didn't change
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)
} finally {
vi.useRealTimers()
}
})
})
describe('URL change detection', () => {
it('should NOT reset loading state when imageUrls prop is reassigned with identical URLs', async () => {
vi.useFakeTimers()
try {
const urls = ['/api/view?filename=test.png&type=output']
const wrapper = mountImagePreview({ imageUrls: urls })
// Simulate image load completing
const img = wrapper.find('img')
await img.trigger('load')
await nextTick()
// Verify loader is hidden after load
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)
// Reassign with new array reference but same content
await wrapper.setProps({ imageUrls: [...urls] })
await nextTick()
// Advance past the 250ms delayed loader timeout
await vi.advanceTimersByTimeAsync(300)
await nextTick()
// Loading state should NOT have been reset - aria-busy should still be false
// because the URLs are identical (just a new array reference)
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)
} finally {
vi.useRealTimers()
}
})
it('should reset loading state when imageUrls prop changes to different URLs', async () => {
vi.useFakeTimers()
try {
const urls = ['/api/view?filename=test.png&type=output']
const wrapper = mountImagePreview({ imageUrls: urls })
// Simulate image load completing
const img = wrapper.find('img')
await img.trigger('load')
await nextTick()
// Verify loader is hidden
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)
// Change to different URL
await wrapper.setProps({
imageUrls: ['/api/view?filename=different.png&type=output']
})
await nextTick()
// Advance past the 250ms delayed loader timeout
await vi.advanceTimersByTimeAsync(300)
await nextTick()
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(true)
} finally {
vi.useRealTimers()
}
})
it('should handle empty to non-empty URL transitions correctly', async () => {
const wrapper = mountImagePreview({ imageUrls: [] })
// No preview initially
expect(wrapper.find('.image-preview').exists()).toBe(false)
// Add URLs
await wrapper.setProps({
imageUrls: ['/api/view?filename=test.png&type=output']
})
await nextTick()
// Preview should appear
expect(wrapper.find('.image-preview').exists()).toBe(true)
expect(wrapper.find('img').exists()).toBe(true)
})
})
})