mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-04 07:00:23 +00:00
* add image outputs on Vue nodes * add unit tests and update cursor pointer * use testing pinia * properly mock i18n in component test * get node via current graph * use subgraph ID from node creation * add better error handling for downloadFile util * refactor: simplify image preview component architecture - Replace awkward composable pattern with standard Vue component state - Fix reactivity issues where images didn't update on new outputs - Add proper subgraph-aware node resolution using NodeLocatorId - Enhance accessibility with keyboard navigation and ARIA labels - Add comprehensive error handling and loading states - Include PrimeVue Skeleton for better loading UX - Remove unused composable and test files The image preview now properly updates when new outputs are generated and follows standard Vue reactivity patterns. * resolve merge conflict with main - Keep both subgraphId field and hasErrors field from main - No conflicts in other files (LGraphNode.vue and main.json merged cleanly) * Fix LGraphNode test by adding proper Pinia testing setup Added createTestingPinia and i18n configuration following the pattern from working ImagePreview tests. Resolves test failures due to missing Pinia store dependencies. All 6 tests now pass successfully.
278 lines
8.4 KiB
TypeScript
278 lines
8.4 KiB
TypeScript
import { createTestingPinia } from '@pinia/testing'
|
|
import { mount } from '@vue/test-utils'
|
|
import { 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',
|
|
loading: 'Loading'
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
describe('ImagePreview', () => {
|
|
const defaultProps = {
|
|
imageUrls: [
|
|
'/api/view?filename=test1.png&type=output',
|
|
'/api/view?filename=test2.png&type=output'
|
|
]
|
|
}
|
|
|
|
const mountImagePreview = (props = {}) => {
|
|
return 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
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
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('.w-2.h-2.rounded-full')
|
|
expect(navigationDots).toHaveLength(2)
|
|
})
|
|
|
|
it('does not show navigation dots for single image', () => {
|
|
const wrapper = mountImagePreview({
|
|
imageUrls: [defaultProps.imageUrls[0]]
|
|
})
|
|
|
|
const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full')
|
|
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
|
|
await wrapper.trigger('mouseenter')
|
|
await nextTick()
|
|
|
|
// Action buttons should now be visible
|
|
expect(wrapper.find('.actions').exists()).toBe(true)
|
|
expect(wrapper.findAll('.action-btn')).toHaveLength(2) // download, remove (no mask for multiple images)
|
|
})
|
|
|
|
it('hides action buttons when not hovering', async () => {
|
|
const wrapper = mountImagePreview()
|
|
|
|
// Trigger hover
|
|
await wrapper.trigger('mouseenter')
|
|
await nextTick()
|
|
expect(wrapper.find('.actions').exists()).toBe(true)
|
|
|
|
// Trigger mouse leave
|
|
await wrapper.trigger('mouseleave')
|
|
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.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.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.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.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('.w-2.h-2.rounded-full')
|
|
await navigationDots[1].trigger('click')
|
|
await nextTick()
|
|
|
|
// After clicking, component shows loading state (Skeleton), not img
|
|
expect(wrapper.find('skeleton-stub').exists()).toBe(true)
|
|
expect(wrapper.find('img').exists()).toBe(false)
|
|
|
|
// Simulate image load event to clear loading state
|
|
const component = wrapper.vm as any
|
|
component.isLoading = false
|
|
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('applies correct classes to navigation dots based on current image', async () => {
|
|
const wrapper = mountImagePreview()
|
|
|
|
const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full')
|
|
|
|
// First dot should be active (has bg-white class)
|
|
expect(navigationDots[0].classes()).toContain('bg-white')
|
|
expect(navigationDots[1].classes()).toContain('bg-white/50')
|
|
|
|
// Switch to second image
|
|
await navigationDots[1].trigger('click')
|
|
await nextTick()
|
|
|
|
// Second dot should now be active
|
|
expect(navigationDots[0].classes()).toContain('bg-white/50')
|
|
expect(navigationDots[1].classes()).toContain('bg-white')
|
|
})
|
|
|
|
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('.w-2.h-2.rounded-full')
|
|
await navigationDots[1].trigger('click')
|
|
await nextTick()
|
|
|
|
// Simulate image load event to clear loading state
|
|
const component = wrapper.vm as any
|
|
component.isLoading = false
|
|
await nextTick()
|
|
|
|
// Alt text should update
|
|
const imgElement = wrapper.find('img')
|
|
expect(imgElement.exists()).toBe(true)
|
|
expect(imgElement.attributes('alt')).toBe('Node output 2')
|
|
})
|
|
})
|