diff --git a/src/shared/lib/helpers/createVirtualizer/createVirtualizer.test.ts b/src/shared/lib/helpers/createVirtualizer/createVirtualizer.test.ts new file mode 100644 index 0000000..c2fdaa5 --- /dev/null +++ b/src/shared/lib/helpers/createVirtualizer/createVirtualizer.test.ts @@ -0,0 +1,550 @@ +/** @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?.(); + }); + }); +});