/** @vitest-environment jsdom */ import { afterEach, describe, expect, it, vi, } from 'vitest'; import { createVirtualizer } from './createVirtualizer.svelte'; /** * NOTE: Svelte 5 Runes Testing Limitations * * The createVirtualizer helper uses Svelte 5 runes ($state, $derived, $derived.by) * which require a full Svelte runtime environment to work correctly. In unit tests * with jsdom, these runes are stubbed and don't provide actual reactivity. * * These tests focus on: * 1. API surface verification (methods, getters exist) * 2. Initial state calculation * 3. DOM integration (event listeners are attached) * 4. Edge case handling * * For full reactivity testing, use browser-based tests with @vitest/browser-playwright */ // Mock ResizeObserver globally since it's not available in jsdom class MockResizeObserver { observe = vi.fn(); unobserve = vi.fn(); disconnect = vi.fn(); } globalThis.ResizeObserver = MockResizeObserver as any; // Mock requestAnimationFrame globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => setTimeout(() => cb(performance.now()), 16) as unknown) as typeof requestAnimationFrame; globalThis.cancelAnimationFrame = vi.fn(); /** * Helper to create test data array */ function createTestData(count: number): string[] { return Array.from({ length: count }, (_, i) => `Item ${i}`); } /** * Helper to create a mock scrollable container element */ function createMockContainer(height = 500, scrollTop = 0): any { const container = document.createElement('div'); Object.defineProperty(container, 'offsetHeight', { value: height, configurable: true, writable: true, }); Object.defineProperty(container, 'scrollTop', { value: scrollTop, writable: true, configurable: true, }); // Add scrollTo method for testing container.scrollTo = vi.fn(); return container; } describe('createVirtualizer - Basic API and State', () => { describe('Basic Initialization and API Surface', () => { it('should initialize and return expected API surface', () => { const virtualizer = createVirtualizer(() => ({ count: 0, data: [], estimateSize: () => 50, })); // Verify API surface exists expect(virtualizer).toHaveProperty('items'); expect(virtualizer).toHaveProperty('totalSize'); expect(virtualizer).toHaveProperty('scrollOffset'); expect(virtualizer).toHaveProperty('containerHeight'); expect(virtualizer).toHaveProperty('container'); expect(virtualizer).toHaveProperty('measureElement'); expect(virtualizer).toHaveProperty('scrollToIndex'); expect(virtualizer).toHaveProperty('scrollToOffset'); // Verify initial values expect(virtualizer.items).toEqual([]); expect(virtualizer.totalSize).toBe(0); expect(virtualizer.scrollOffset).toBe(0); expect(virtualizer.containerHeight).toBe(0); }); it('should calculate correct totalSize for uniform item sizes', () => { const virtualizer = createVirtualizer(() => ({ count: 10, data: createTestData(10), estimateSize: () => 50, })); // 10 items * 50px each = 500px total expect(virtualizer.totalSize).toBe(500); }); it('should calculate correct totalSize for varying item sizes', () => { const sizes = [50, 100, 150, 75, 125]; // Sum = 500 const virtualizer = createVirtualizer(() => ({ count: 5, data: createTestData(5), estimateSize: (i: number) => sizes[i], })); expect(virtualizer.totalSize).toBe(500); }); it('should handle empty list (count = 0)', () => { const virtualizer = createVirtualizer(() => ({ count: 0, data: [], estimateSize: () => 50, })); expect(virtualizer.totalSize).toBe(0); expect(virtualizer.items).toEqual([]); }); it('should handle very large lists', () => { const virtualizer = createVirtualizer(() => ({ count: 100000, data: createTestData(100000), estimateSize: () => 50, })); expect(virtualizer.totalSize).toBe(5000000); // 100000 * 50 }); it('should handle zero estimated size', () => { const virtualizer = createVirtualizer(() => ({ count: 10, data: createTestData(10), estimateSize: () => 0, })); expect(virtualizer.totalSize).toBe(0); }); }); describe('Container Action', () => { let cleanupHandlers: (() => void)[] = []; afterEach(() => { cleanupHandlers.forEach(cleanup => cleanup()); cleanupHandlers = []; }); it('should attach container action and set up listeners', () => { const virtualizer = createVirtualizer(() => ({ count: 100, data: createTestData(100), estimateSize: () => 50, })); const container = createMockContainer(500, 0); const addEventListenerSpy = vi.spyOn(container, 'addEventListener'); const cleanup = virtualizer.container(container); cleanupHandlers.push(() => cleanup?.destroy?.()); // Verify scroll listener was attached expect(addEventListenerSpy).toHaveBeenCalledWith( 'scroll', expect.any(Function), { passive: true }, ); }); it('should update containerHeight when container is attached', () => { const virtualizer = createVirtualizer(() => ({ count: 100, data: createTestData(100), estimateSize: () => 50, })); const container = createMockContainer(500, 0); const cleanup = virtualizer.container(container); cleanupHandlers.push(() => cleanup?.destroy?.()); expect(virtualizer.containerHeight).toBe(500); }); it('should clean up listeners on destroy', () => { const virtualizer = createVirtualizer(() => ({ count: 100, data: createTestData(100), estimateSize: () => 50, })); const container = createMockContainer(500, 0); const removeEventListenerSpy = vi.spyOn(container, 'removeEventListener'); const cleanup = virtualizer.container(container); cleanup?.destroy?.(); expect(removeEventListenerSpy).toHaveBeenCalled(); }); it('should support window scrolling mode', () => { const virtualizer = createVirtualizer(() => ({ count: 100, data: createTestData(100), estimateSize: () => 50, useWindowScroll: true, })); const container = createMockContainer(500, 0); const windowAddSpy = vi.spyOn(window, 'addEventListener'); const cleanup = virtualizer.container(container); cleanupHandlers.push(() => cleanup?.destroy?.()); // Should attach to window scroll expect(windowAddSpy).toHaveBeenCalledWith('scroll', expect.any(Function), expect.any(Object)); expect(windowAddSpy).toHaveBeenCalledWith('resize', expect.any(Function)); windowAddSpy.mockRestore(); }); }); describe('scrollToIndex Method', () => { let cleanupHandlers: (() => void)[] = []; afterEach(() => { cleanupHandlers.forEach(cleanup => cleanup()); cleanupHandlers = []; }); it('should have scrollToIndex method that does not throw without container', () => { const virtualizer = createVirtualizer(() => ({ count: 100, data: createTestData(100), estimateSize: () => 50, })); // Should not throw when container is not attached expect(() => virtualizer.scrollToIndex(50)).not.toThrow(); }); it('should scroll to specific index with container attached', () => { const virtualizer = createVirtualizer(() => ({ count: 100, data: createTestData(100), estimateSize: () => 50, })); const container = createMockContainer(500, 0); const scrollToSpy = vi.spyOn(container, 'scrollTo'); const cleanup = virtualizer.container(container); cleanupHandlers.push(() => cleanup?.destroy?.()); virtualizer.scrollToIndex(10); expect(scrollToSpy).toHaveBeenCalledWith({ top: expect.any(Number), behavior: 'smooth', }); }); it('should handle center alignment', () => { const virtualizer = createVirtualizer(() => ({ count: 100, data: createTestData(100), estimateSize: () => 50, })); const container = createMockContainer(500, 0); const scrollToSpy = vi.spyOn(container, 'scrollTo'); const cleanup = virtualizer.container(container); cleanupHandlers.push(() => cleanup?.destroy?.()); virtualizer.scrollToIndex(10, 'center'); expect(scrollToSpy).toHaveBeenCalled(); }); it('should handle end alignment', () => { const virtualizer = createVirtualizer(() => ({ count: 100, data: createTestData(100), estimateSize: () => 50, })); const container = createMockContainer(500, 0); const scrollToSpy = vi.spyOn(container, 'scrollTo'); const cleanup = virtualizer.container(container); cleanupHandlers.push(() => cleanup?.destroy?.()); virtualizer.scrollToIndex(10, 'end'); expect(scrollToSpy).toHaveBeenCalled(); }); it('should not scroll for out of bounds indices', () => { const virtualizer = createVirtualizer(() => ({ count: 100, data: createTestData(100), estimateSize: () => 50, })); const container = createMockContainer(500, 0); const scrollToSpy = vi.spyOn(container, 'scrollTo'); const cleanup = virtualizer.container(container); cleanupHandlers.push(() => cleanup?.destroy?.()); // Negative index virtualizer.scrollToIndex(-1); // Index >= count virtualizer.scrollToIndex(100); // Should not have been called expect(scrollToSpy).not.toHaveBeenCalled(); }); }); describe('scrollToOffset Method', () => { let cleanupHandlers: (() => void)[] = []; afterEach(() => { cleanupHandlers.forEach(cleanup => cleanup()); cleanupHandlers = []; }); it('should scroll to specific pixel offset', () => { const virtualizer = createVirtualizer(() => ({ count: 100, data: createTestData(100), estimateSize: () => 50, })); const container = createMockContainer(500, 0); const scrollToSpy = vi.spyOn(container, 'scrollTo'); const cleanup = virtualizer.container(container); cleanupHandlers.push(() => cleanup?.destroy?.()); virtualizer.scrollToOffset(1000); expect(scrollToSpy).toHaveBeenCalledWith({ top: 1000, behavior: 'auto' }); }); it('should support smooth behavior', () => { const virtualizer = createVirtualizer(() => ({ count: 100, data: createTestData(100), estimateSize: () => 50, })); const container = createMockContainer(500, 0); const scrollToSpy = vi.spyOn(container, 'scrollTo'); const cleanup = virtualizer.container(container); cleanupHandlers.push(() => cleanup?.destroy?.()); virtualizer.scrollToOffset(1000, 'smooth'); expect(scrollToSpy).toHaveBeenCalledWith({ top: 1000, behavior: 'smooth' }); }); }); describe('measureElement Action', () => { it('should attach measureElement action to DOM element', () => { const virtualizer = createVirtualizer(() => ({ count: 10, data: createTestData(10), estimateSize: () => 50, })); const element = document.createElement('div'); element.dataset.index = '0'; // Should not throw when attaching measureElement expect(() => { const cleanup = virtualizer.measureElement(element); cleanup?.destroy?.(); }).not.toThrow(); }); it('should clean up observer on destroy', () => { const virtualizer = createVirtualizer(() => ({ count: 10, data: createTestData(10), estimateSize: () => 50, })); const element = document.createElement('div'); element.dataset.index = '0'; const cleanup = virtualizer.measureElement(element); // Should not throw when destroying expect(() => cleanup?.destroy?.()).not.toThrow(); }); it('should handle multiple elements being measured', () => { const virtualizer = createVirtualizer(() => ({ count: 10, data: createTestData(10), estimateSize: () => 50, })); const elements = Array.from({ length: 5 }, (_, i) => { const el = document.createElement('div'); el.dataset.index = String(i); return el; }); const cleanups = elements.map(el => virtualizer.measureElement(el)); // Should not throw when measuring multiple elements expect(() => { cleanups.forEach(cleanup => cleanup?.destroy?.()); }).not.toThrow(); }); }); describe('Options Handling', () => { it('should use default overscan of 5', () => { const virtualizer = createVirtualizer(() => ({ count: 100, data: createTestData(100), estimateSize: () => 50, })); // Options with default overscan should work expect(virtualizer).toHaveProperty('items'); }); it('should use custom overscan value', () => { const virtualizer = createVirtualizer(() => ({ count: 100, data: createTestData(100), estimateSize: () => 50, overscan: 10, })); expect(virtualizer).toHaveProperty('items'); }); it('should use index as default key when getItemKey is not provided', () => { const virtualizer = createVirtualizer(() => ({ count: 10, data: createTestData(10), estimateSize: () => 50, })); expect(virtualizer).toHaveProperty('items'); }); it('should use custom getItemKey when provided', () => { const virtualizer = createVirtualizer(() => ({ count: 10, data: createTestData(10), estimateSize: () => 50, getItemKey: (i: number) => `custom-key-${i}`, })); expect(virtualizer).toHaveProperty('items'); }); it('should use custom scrollMargin when provided', () => { const virtualizer = createVirtualizer(() => ({ count: 100, data: createTestData(100), estimateSize: () => 50, scrollMargin: 100, })); expect(virtualizer).toHaveProperty('items'); }); }); describe('Edge Cases', () => { it('should handle single item list', () => { const virtualizer = createVirtualizer(() => ({ count: 1, data: ['Item 0'], estimateSize: () => 100, })); expect(virtualizer.totalSize).toBe(100); }); it('should handle items larger than viewport', () => { const virtualizer = createVirtualizer(() => ({ count: 5, data: createTestData(5), estimateSize: () => 200, // Each item is 200px })); // Total size should still be calculated correctly expect(virtualizer.totalSize).toBe(1000); // 5 * 200 }); it('should handle overscan larger than viewport', () => { const virtualizer = createVirtualizer(() => ({ count: 10, data: createTestData(10), estimateSize: () => 50, overscan: 100, // Very large overscan })); expect(virtualizer).toHaveProperty('items'); }); it('should handle negative estimated size (graceful degradation)', () => { const virtualizer = createVirtualizer(() => ({ count: 10, data: createTestData(10), estimateSize: () => -10, })); // Should calculate total size (may be negative, but shouldn't crash) expect(virtualizer.totalSize).toBeLessThanOrEqual(0); }); }); describe('Virtual Item Structure', () => { it('should return items with correct structure when container is attached', () => { const virtualizer = createVirtualizer(() => ({ count: 100, data: createTestData(100), estimateSize: () => 50, })); const container = createMockContainer(500, 0); const cleanup = virtualizer.container(container); // Items may be empty in test environment due to reactivity limitations // but we verify the structure exists expect(Array.isArray(virtualizer.items)).toBe(true); cleanup?.destroy?.(); }); }); });