Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions src/renderer/extensions/vueNodes/VideoPreview.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import type { ComponentProps } from 'vue-component-type-helpers'

import VideoPreview from '@/renderer/extensions/vueNodes/VideoPreview.vue'

vi.mock('@/base/common/downloadUtil', () => ({
downloadFile: vi.fn()
}))

const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
downloadVideo: 'Download video',
removeVideo: 'Remove video',
viewVideoOfTotal: 'View video {index} of {total}',
videoPreview:
'Video preview - Use arrow keys to navigate between videos',
errorLoadingVideo: 'Error loading video',
failedToDownloadVideo: 'Failed to download video',
calculatingDimensions: 'Calculating dimensions',
videoFailedToLoad: 'Video failed to load',
loading: 'Loading'
}
}
}
})

describe('VideoPreview', () => {
const defaultProps: ComponentProps<typeof VideoPreview> = {
imageUrls: [
'/api/view?filename=test1.mp4&type=output',
'/api/view?filename=test2.mp4&type=output'
]
}

afterEach(() => {
vi.clearAllMocks()
})

function mountVideoPreview(
props: Partial<ComponentProps<typeof VideoPreview>> = {}
) {
return mount(VideoPreview, {
props: { ...defaultProps, ...props } as ComponentProps<
typeof VideoPreview
>,
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n],
stubs: {
Skeleton: true
}
}
})
}

describe('batch cycling with identical URLs', () => {
it('should not enter persistent loading state when cycling through identical videos', async () => {
const sameUrl = '/api/view?filename=test.mp4&type=output'
const wrapper = mountVideoPreview({
imageUrls: [sameUrl, sameUrl, sameUrl]
})

// Simulate initial video load
await wrapper.find('video').trigger('loadeddata')
await nextTick()
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)

// Click second navigation dot to cycle to identical URL
const dots = wrapper.findAll('[aria-label^="View video"]')
await dots[1].trigger('click')
await nextTick()

// Should NOT be in loading state since URL didn't change
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)
})

it('should show loader when cycling to a different URL', async () => {
const wrapper = mountVideoPreview({
imageUrls: [
'/api/view?filename=a.mp4&type=output',
'/api/view?filename=b.mp4&type=output'
]
})

// Simulate initial video load
await wrapper.find('video').trigger('loadeddata')
await nextTick()
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)

// Click second dot — different URL
const dots = wrapper.findAll('[aria-label^="View video"]')
await dots[1].trigger('click')
await nextTick()

// Should be in loading state since URL changed
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(true)
})
})
})
19 changes: 12 additions & 7 deletions src/renderer/extensions/vueNodes/VideoPreview.vue
Original file line number Diff line number Diff line change
Expand Up @@ -217,11 +217,15 @@ const handleRemove = () => {
}

const setCurrentIndex = (index: number) => {
if (currentIndex.value === index) return
if (index >= 0 && index < props.imageUrls.length) {
const urlChanged = props.imageUrls[index] !== currentVideoUrl.value
currentIndex.value = index
actualDimensions.value = null
showLoader.value = true
videoError.value = false
if (urlChanged) {
actualDimensions.value = null
showLoader.value = true
}
}
}

Expand All @@ -243,12 +247,13 @@ const handleFocusOut = (event: FocusEvent) => {
}
}

const getNavigationDotClass = (index: number) => {
return [
const getNavigationDotClass = (index: number) =>
cn(
'w-2 h-2 rounded-full transition-all duration-200 border-0 cursor-pointer',
index === currentIndex.value ? 'bg-white' : 'bg-white/50 hover:bg-white/80'
]
}
index === currentIndex.value
? 'bg-base-foreground'
: 'bg-base-foreground/50 hover:bg-base-foreground/80'
)

const handleKeyDown = (event: KeyboardEvent) => {
if (props.imageUrls.length <= 1) return
Expand Down
72 changes: 53 additions & 19 deletions src/renderer/extensions/vueNodes/components/ImagePreview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,37 @@ describe('ImagePreview', () => {
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('.w-2.h-2.rounded-full')
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()
Expand Down Expand Up @@ -343,30 +374,33 @@ describe('ImagePreview', () => {
})

it('should reset loading state when imageUrls prop changes to different URLs', async () => {
const urls = ['/api/view?filename=test.png&type=output']
const wrapper = mountImagePreview({ imageUrls: urls })
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()
// 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)
// 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()
// Change to different URL
await wrapper.setProps({
imageUrls: ['/api/view?filename=different.png&type=output']
})
await nextTick()

// After 250ms timeout, loading state should be reset (aria-busy="true")
// We can check the internal state via the Skeleton appearing
// or wait for the timeout
await new Promise((resolve) => setTimeout(resolve, 300))
await nextTick()
// Advance past the 250ms delayed loader timeout
await vi.advanceTimersByTimeAsync(300)
await nextTick()

expect(wrapper.find('[aria-busy="true"]').exists()).toBe(true)
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(true)
} finally {
vi.useRealTimers()
}
})

it('should handle empty to non-empty URL transitions correctly', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -257,9 +257,10 @@ const handleRemove = () => {
const setCurrentIndex = (index: number) => {
if (currentIndex.value === index) return
if (index >= 0 && index < props.imageUrls.length) {
const urlChanged = props.imageUrls[index] !== currentImageUrl.value
currentIndex.value = index
startDelayedLoader()
imageError.value = false
if (urlChanged) startDelayedLoader()
}
}

Expand Down
Loading