mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-13 00:50:01 +00:00
## 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>
424 lines
13 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
})
|