diff --git a/browser_tests/tests/vueNodes/widgets/imageDropdownVirtualization.spec.ts b/browser_tests/tests/vueNodes/widgets/imageDropdownVirtualization.spec.ts new file mode 100644 index 0000000000..6db9594b88 --- /dev/null +++ b/browser_tests/tests/vueNodes/widgets/imageDropdownVirtualization.spec.ts @@ -0,0 +1,49 @@ +import { + comfyExpect as expect, + comfyPageFixture as test +} from '../../../fixtures/ComfyPage' + +test.describe('Image Dropdown Virtualization', { tag: '@widget' }, () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.VueNodes.Enabled', true) + await comfyPage.vueNodes.waitForNodes() + }) + + test('should virtualize items when dropdown has many entries', async ({ + comfyPage + }) => { + await comfyPage.loadWorkflow('widgets/load_image_widget') + await comfyPage.vueNodes.waitForNodes() + + const totalItems = await comfyPage.page.evaluate(() => { + const node = window['graph']._nodes_by_id['10'] + const widget = node.widgets.find( + (w: { name: string }) => w.name === 'image' + ) + const count = 60 + const values = Array.from( + { length: count }, + (_, i) => `test_image_${i}.png` + ) + widget.options.values = values + widget.value = values[0] + return count + }) + + const loadImageNode = comfyPage.vueNodes.getNodeByTitle('Load Image') + const dropdownButton = loadImageNode.locator( + 'button:has(span:has-text("test_image_0.png"))' + ) + await dropdownButton.waitFor({ state: 'visible' }) + await dropdownButton.click() + + const virtualGridItems = comfyPage.page.locator( + '[data-virtual-grid-item]' + ) + await expect(virtualGridItems.first()).toBeVisible() + + const renderedCount = await virtualGridItems.count() + expect(renderedCount).toBeLessThan(totalItems) + expect(renderedCount).toBeGreaterThan(0) + }) +}) diff --git a/src/components/common/VirtualGrid.test.ts b/src/components/common/VirtualGrid.test.ts new file mode 100644 index 0000000000..8b11e092e9 --- /dev/null +++ b/src/components/common/VirtualGrid.test.ts @@ -0,0 +1,180 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import { nextTick, ref } from 'vue' + +import VirtualGrid from './VirtualGrid.vue' + +type TestItem = { key: string; name: string } + +const mockedWidth = ref(400) +const mockedHeight = ref(200) +const mockedScrollY = ref(0) + +vi.mock('@vueuse/core', async () => { + const actual = await vi.importActual>('@vueuse/core') + return { + ...actual, + useElementSize: () => ({ width: mockedWidth, height: mockedHeight }), + useScroll: () => ({ y: mockedScrollY }) + } +}) + +function createItems(count: number): TestItem[] { + return Array.from({ length: count }, (_, i) => ({ + key: `item-${i}`, + name: `Item ${i}` + })) +} + +describe('VirtualGrid', () => { + const defaultGridStyle = { + display: 'grid', + gridTemplateColumns: 'repeat(4, 1fr)', + gap: '1rem' + } + + it('renders items within the visible range', async () => { + const items = createItems(100) + mockedWidth.value = 400 + mockedHeight.value = 200 + mockedScrollY.value = 0 + + const wrapper = mount(VirtualGrid, { + props: { + items, + gridStyle: defaultGridStyle, + defaultItemHeight: 100, + defaultItemWidth: 100, + maxColumns: 4, + bufferRows: 1 + }, + slots: { + item: `` + }, + attachTo: document.body + }) + + await nextTick() + + const renderedItems = wrapper.findAll('.test-item') + expect(renderedItems.length).toBeGreaterThan(0) + expect(renderedItems.length).toBeLessThan(items.length) + + wrapper.unmount() + }) + + it('provides correct index in slot props', async () => { + const items = createItems(20) + const receivedIndices: number[] = [] + mockedWidth.value = 400 + mockedHeight.value = 200 + mockedScrollY.value = 0 + + const wrapper = mount(VirtualGrid, { + props: { + items, + gridStyle: defaultGridStyle, + defaultItemHeight: 50, + defaultItemWidth: 100, + maxColumns: 1, + bufferRows: 0 + }, + slots: { + item: ({ index }: { index: number }) => { + receivedIndices.push(index) + return null + } + }, + attachTo: document.body + }) + + await nextTick() + + expect(receivedIndices.length).toBeGreaterThan(0) + expect(receivedIndices[0]).toBe(0) + for (let i = 1; i < receivedIndices.length; i++) { + expect(receivedIndices[i]).toBe(receivedIndices[i - 1] + 1) + } + + wrapper.unmount() + }) + + it('respects maxColumns prop', async () => { + const items = createItems(10) + mockedWidth.value = 400 + mockedHeight.value = 200 + mockedScrollY.value = 0 + + const wrapper = mount(VirtualGrid, { + props: { + items, + gridStyle: defaultGridStyle, + maxColumns: 2 + }, + attachTo: document.body + }) + + await nextTick() + + const gridElement = wrapper.find('[style*="display: grid"]') + expect(gridElement.exists()).toBe(true) + + const gridEl = gridElement.element as HTMLElement + expect(gridEl.style.gridTemplateColumns).toBe('repeat(2, minmax(0, 1fr))') + + wrapper.unmount() + }) + + it('renders empty when no items provided', async () => { + const wrapper = mount(VirtualGrid, { + props: { + items: [], + gridStyle: defaultGridStyle + }, + slots: { + item: `` + } + }) + + await nextTick() + + const renderedItems = wrapper.findAll('.test-item') + expect(renderedItems.length).toBe(0) + }) + + it('forces cols to maxColumns when maxColumns is finite', async () => { + mockedWidth.value = 100 + mockedHeight.value = 200 + mockedScrollY.value = 0 + + const items = createItems(20) + const wrapper = mount(VirtualGrid, { + props: { + items, + gridStyle: defaultGridStyle, + defaultItemHeight: 50, + defaultItemWidth: 200, + maxColumns: 4, + bufferRows: 0 + }, + slots: { + item: `` + }, + attachTo: document.body + }) + + await nextTick() + + const renderedItems = wrapper.findAll('.test-item') + expect(renderedItems.length).toBeGreaterThan(0) + expect(renderedItems.length % 4).toBe(0) + + wrapper.unmount() + }) +}) diff --git a/src/components/common/VirtualGrid.vue b/src/components/common/VirtualGrid.vue index fb6b4374c0..42d51e4bd0 100644 --- a/src/components/common/VirtualGrid.vue +++ b/src/components/common/VirtualGrid.vue @@ -1,17 +1,16 @@