Merge branch 'main' into sno-fix-playwright-babel-config

This commit is contained in:
snomiao
2025-09-15 06:58:58 +00:00
151 changed files with 9341 additions and 659 deletions

View File

@@ -0,0 +1,277 @@
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')
})
})

View File

@@ -1,11 +1,14 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState'
vi.mock(
'@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking',
@@ -40,6 +43,29 @@ vi.mock('@/renderer/extensions/vueNodes/lod/useLOD', () => ({
LODLevel: { MINIMAL: 0 }
}))
vi.mock(
'@/renderer/extensions/vueNodes/execution/useNodeExecutionState',
() => ({
useNodeExecutionState: vi.fn(() => ({
executing: computed(() => false),
progress: computed(() => undefined),
progressPercentage: computed(() => undefined),
progressState: computed(() => undefined as any),
executionState: computed(() => 'idle' as const)
}))
})
)
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
'Node Render Error': 'Node Render Error'
}
}
})
describe('LGraphNode', () => {
const mockNodeData: VueNodeData = {
id: 'test-node-123',
@@ -58,8 +84,21 @@ describe('LGraphNode', () => {
return mount(LGraphNode, {
props,
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn
}),
i18n
],
provide: {
[SelectedNodeIdsKey as symbol]: ref(selectedNodeIds)
},
stubs: {
NodeHeader: true,
NodeSlots: true,
NodeWidgets: true,
NodeContent: true,
SlotConnectionDot: true
}
}
})
@@ -67,6 +106,14 @@ describe('LGraphNode', () => {
beforeEach(() => {
vi.clearAllMocks()
// Reset to default mock
vi.mocked(useNodeExecutionState).mockReturnValue({
executing: computed(() => false),
progress: computed(() => undefined),
progressPercentage: computed(() => undefined),
progressState: computed(() => undefined as any),
executionState: computed(() => 'idle' as const)
})
})
it('should call resize tracking composable with node ID', () => {
@@ -82,7 +129,27 @@ describe('LGraphNode', () => {
})
it('should render node title', () => {
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
// Don't stub NodeHeader for this test so we can see the title
const wrapper = mount(LGraphNode, {
props: { nodeData: mockNodeData },
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn
}),
i18n
],
provide: {
[SelectedNodeIdsKey as symbol]: ref(new Set())
},
stubs: {
NodeSlots: true,
NodeWidgets: true,
NodeContent: true,
SlotConnectionDot: true
}
}
})
expect(wrapper.text()).toContain('Test Node')
})
@@ -98,7 +165,16 @@ describe('LGraphNode', () => {
})
it('should apply executing animation when executing prop is true', () => {
const wrapper = mountLGraphNode({ nodeData: mockNodeData, executing: true })
// Mock the execution state to return executing: true
vi.mocked(useNodeExecutionState).mockReturnValue({
executing: computed(() => true),
progress: computed(() => undefined),
progressPercentage: computed(() => undefined),
progressState: computed(() => undefined as any),
executionState: computed(() => 'running' as const)
})
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
expect(wrapper.classes()).toContain('animate-pulse')
})

View File

@@ -4,11 +4,11 @@ import { ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { useCanvasStore } from '@/stores/graphStore'
vi.mock('@/stores/graphStore', () => ({
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: vi.fn()
}))