feature/test-coverage #27

Merged
ilia merged 12 commits from feature/test-coverage into main 2026-02-22 07:46:55 +00:00
Showing only changes of commit d15b2ffe3f - Show all commits

View File

@@ -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?.();
});
});
});