/** * @vitest-environment jsdom */ import { afterEach, beforeEach, describe, expect, it, vi, } from 'vitest'; import { type BreadcrumbItem, createScrollBreadcrumbsStore, } from './scrollBreadcrumbsStore.svelte'; // Mock IntersectionObserver - class variable to track instances let mockObserverInstances: MockIntersectionObserver[] = []; class MockIntersectionObserver implements IntersectionObserver { root = null; rootMargin = ''; thresholds: number[] = []; readonly callbacks: Array<(entries: IntersectionObserverEntry[], observer: IntersectionObserver) => void> = []; readonly observedElements = new Set(); constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) { this.callbacks.push(callback); if (options?.rootMargin) { this.rootMargin = options.rootMargin; } if (options?.threshold) { this.thresholds = Array.isArray(options.threshold) ? options.threshold : [options.threshold]; } mockObserverInstances.push(this); } observe(target: Element): void { this.observedElements.add(target); } unobserve(target: Element): void { this.observedElements.delete(target); } disconnect(): void { this.observedElements.clear(); } takeRecords(): IntersectionObserverEntry[] { return []; } // Helper method for tests to trigger intersection changes triggerIntersection(target: Element, isIntersecting: boolean): void { const entry: Partial = { target, isIntersecting, intersectionRatio: isIntersecting ? 1 : 0, boundingClientRect: {} as DOMRectReadOnly, intersectionRect: {} as DOMRectReadOnly, rootBounds: null, time: Date.now(), }; this.callbacks.forEach(cb => cb([entry as IntersectionObserverEntry], this)); } } describe('ScrollBreadcrumbsStore', () => { let scrollListeners: Array<() => void> = []; let addEventListenerSpy: ReturnType; let removeEventListenerSpy: ReturnType; let scrollToSpy: ReturnType; // Helper to create mock elements const createMockElement = (): HTMLElement => { const el = document.createElement('div'); Object.defineProperty(el, 'getBoundingClientRect', { value: vi.fn(() => ({ top: 100, left: 0, bottom: 200, right: 100, width: 100, height: 100, x: 0, y: 100, toJSON: () => ({}), })), }); return el; }; // Helper to create breadcrumb item const createItem = (index: number, title: string, element?: HTMLElement): BreadcrumbItem => ({ index, title, element: element ?? createMockElement(), }); beforeEach(() => { mockObserverInstances = []; scrollListeners = []; // Set up IntersectionObserver mock before creating store vi.stubGlobal('IntersectionObserver', MockIntersectionObserver); // Mock window.scrollTo scrollToSpy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); // Track scroll event listeners addEventListenerSpy = vi.spyOn(window, 'addEventListener').mockImplementation( (event: string, listener: EventListenerOrEventListenerObject, options?: any) => { if (event === 'scroll') { scrollListeners.push(listener as () => void); } return undefined; }, ); removeEventListenerSpy = vi.spyOn(window, 'removeEventListener').mockImplementation( (event: string, listener: EventListenerOrEventListenerObject) => { if (event === 'scroll') { const index = scrollListeners.indexOf(listener as () => void); if (index > -1) { scrollListeners.splice(index, 1); } } return undefined; }, ); }); afterEach(() => { vi.restoreAllMocks(); }); describe('Adding items', () => { it('should add an item and track it', () => { const store = createScrollBreadcrumbsStore(); const element = createMockElement(); const item = createItem(0, 'Section 1', element); store.add(item); expect(store.items).toHaveLength(1); expect(store.items[0]).toEqual(item); }); it('should ignore duplicate indices', () => { const store = createScrollBreadcrumbsStore(); const element1 = createMockElement(); const element2 = createMockElement(); store.add(createItem(0, 'First', element1)); store.add(createItem(0, 'Second', element2)); expect(store.items).toHaveLength(1); expect(store.items[0].title).toBe('First'); }); it('should add multiple items with different indices', () => { const store = createScrollBreadcrumbsStore(); store.add(createItem(0, 'First')); store.add(createItem(1, 'Second')); store.add(createItem(2, 'Third')); expect(store.items).toHaveLength(3); expect(store.items.map(i => i.index)).toEqual([0, 1, 2]); }); it('should attach scroll listener when first item is added', () => { const store = createScrollBreadcrumbsStore(); expect(scrollListeners).toHaveLength(0); store.add(createItem(0, 'First')); expect(scrollListeners).toHaveLength(1); }); it('should initialize observer with element', () => { const store = createScrollBreadcrumbsStore(); const element = createMockElement(); store.add(createItem(0, 'Test', element)); expect(mockObserverInstances).toHaveLength(1); expect(mockObserverInstances[0].observedElements.has(element)).toBe(true); }); }); describe('Removing items', () => { it('should remove an item by index', () => { const store = createScrollBreadcrumbsStore(); store.add(createItem(0, 'First')); store.add(createItem(1, 'Second')); store.add(createItem(2, 'Third')); store.remove(1); expect(store.items).toHaveLength(2); expect(store.items.map(i => i.index)).toEqual([0, 2]); }); it('should do nothing when removing non-existent index', () => { const store = createScrollBreadcrumbsStore(); store.add(createItem(0, 'First')); store.add(createItem(1, 'Second')); store.remove(999); expect(store.items).toHaveLength(2); }); it('should unobserve element when removed', () => { const store = createScrollBreadcrumbsStore(); const element = createMockElement(); store.add(createItem(0, 'First', element)); expect(mockObserverInstances[0].observedElements.has(element)).toBe(true); store.remove(0); expect(mockObserverInstances[0].observedElements.has(element)).toBe(false); }); it('should disconnect observer when no items remain', () => { const store = createScrollBreadcrumbsStore(); store.add(createItem(0, 'First')); store.add(createItem(1, 'Second')); expect(addEventListenerSpy).toHaveBeenCalled(); const initialCallCount = addEventListenerSpy.mock.calls.length; store.remove(0); // addEventListener was called for the first item, still 1 call expect(addEventListenerSpy.mock.calls.length).toBe(initialCallCount); store.remove(1); // The listener count should be 0 now - disconnect was called // We verify the observer was disconnected expect(mockObserverInstances[0].observedElements.size).toBe(0); }); it('should reattach listener when adding after cleanup', () => { const store = createScrollBreadcrumbsStore(); store.add(createItem(0, 'First')); store.remove(0); expect(scrollListeners).toHaveLength(0); store.add(createItem(1, 'Second')); expect(scrollListeners).toHaveLength(1); }); }); describe('Intersection Observer behavior', () => { it('should add to scrolledPast when element exits viewport while scrolling down', () => { // Set initial scrollY before creating store/adding items Object.defineProperty(window, 'scrollY', { value: 0, writable: true, configurable: true }); const store = createScrollBreadcrumbsStore(); const element = createMockElement(); store.add(createItem(0, 'First', element)); // Simulate scrolling down (scrollY increases) Object.defineProperty(window, 'scrollY', { value: 100, writable: true, configurable: true }); scrollListeners.forEach(l => l()); // Trigger intersection: element exits viewport while scrolling down mockObserverInstances[0].triggerIntersection(element, false); expect(store.isScrolledPast(0)).toBe(true); expect(store.scrolledPastItems).toHaveLength(1); }); it('should not add to scrolledPast when not scrolling down', () => { const store = createScrollBreadcrumbsStore(); const element = createMockElement(); store.add(createItem(0, 'First', element)); // scrollY stays at 0 (not scrolling down) Object.defineProperty(window, 'scrollY', { value: 0, writable: true, configurable: true }); scrollListeners.forEach(l => l()); // Element exits viewport mockObserverInstances[0].triggerIntersection(element, false); expect(store.isScrolledPast(0)).toBe(false); }); it('should remove from scrolledPast when element enters viewport while scrolling up', () => { const store = createScrollBreadcrumbsStore(); const element = createMockElement(); store.add(createItem(0, 'First', element)); // First, scroll down and exit viewport Object.defineProperty(window, 'scrollY', { value: 100, writable: true, configurable: true }); scrollListeners.forEach(l => l()); mockObserverInstances[0].triggerIntersection(element, false); expect(store.isScrolledPast(0)).toBe(true); // Now scroll up (decrease scrollY) and element enters viewport Object.defineProperty(window, 'scrollY', { value: 50, writable: true, configurable: true }); scrollListeners.forEach(l => l()); mockObserverInstances[0].triggerIntersection(element, true); expect(store.isScrolledPast(0)).toBe(false); }); }); describe('scrollTo method', () => { it('should scroll to item by index with window as container', () => { const store = createScrollBreadcrumbsStore(); const element = createMockElement(); store.add(createItem(0, 'First', element)); store.scrollTo(0, window); expect(scrollToSpy).toHaveBeenCalledWith( expect.objectContaining({ behavior: 'smooth', }), ); }); it('should do nothing when index does not exist', () => { const store = createScrollBreadcrumbsStore(); store.add(createItem(0, 'First')); store.scrollTo(999); expect(scrollToSpy).not.toHaveBeenCalled(); }); it('should use scroll offset when calculating target position', () => { const store = createScrollBreadcrumbsStore(); // Reset the mock to clear previous calls scrollToSpy.mockClear(); // Create fresh mock element with specific getBoundingClientRect const element = document.createElement('div'); const getBoundingClientRectMock = vi.fn(() => ({ top: 200, left: 0, bottom: 300, right: 100, width: 100, height: 100, x: 0, y: 200, toJSON: () => ({}), })); Object.defineProperty(element, 'getBoundingClientRect', { value: getBoundingClientRectMock, writable: true, configurable: true, }); // Add item with 80px offset store.add(createItem(0, 'Third', element), 80); store.scrollTo(0); // The offset should be subtracted from the element position // 200 - 80 = 120 (but in jsdom, getBoundingClientRect might have different behavior) // Let's just verify smooth behavior is used expect(scrollToSpy).toHaveBeenCalledWith( expect.objectContaining({ behavior: 'smooth', }), ); // Verify that the scroll position is less than the element top (offset was applied) const scrollToCall = scrollToSpy.mock.calls[0][0] as ScrollToOptions; expect((scrollToCall as any).top).toBeLessThan(200); }); it('should handle HTMLElement container', () => { const store = createScrollBreadcrumbsStore(); const element = createMockElement(); store.add(createItem(0, 'Test', element)); const container: HTMLElement = { scrollTop: 50, scrollTo: vi.fn(), getBoundingClientRect: () => ({ top: 0, bottom: 500, left: 0, right: 400, width: 400, height: 500, x: 0, y: 0, toJSON: () => ({}), }), } as any; store.scrollTo(0, container); expect(container.scrollTo).toHaveBeenCalledWith( expect.objectContaining({ behavior: 'smooth', }), ); }); }); describe('Getters', () => { it('should return items sorted by index', () => { const store = createScrollBreadcrumbsStore(); store.add(createItem(1, 'Second')); store.add(createItem(0, 'First')); store.add(createItem(2, 'Third')); expect(store.items.map(i => i.index)).toEqual([0, 1, 2]); expect(store.items.map(i => i.title)).toEqual(['First', 'Second', 'Third']); }); it('should return empty scrolledPastItems initially', () => { const store = createScrollBreadcrumbsStore(); store.add(createItem(0, 'First')); store.add(createItem(1, 'Second')); expect(store.scrolledPastItems).toHaveLength(0); }); it('should return items that have been scrolled past', () => { const store = createScrollBreadcrumbsStore(); const element = createMockElement(); store.add(createItem(0, 'First', element)); store.add(createItem(1, 'Second')); // Simulate scrolling down and element exiting viewport Object.defineProperty(window, 'scrollY', { value: 100, writable: true, configurable: true }); scrollListeners.forEach(l => l()); mockObserverInstances[0].triggerIntersection(element, false); expect(store.scrolledPastItems).toHaveLength(1); expect(store.scrolledPastItems[0].index).toBe(0); }); }); describe('activeIndex getter', () => { it('should return null when no items are scrolled past', () => { const store = createScrollBreadcrumbsStore(); store.add(createItem(0, 'First')); store.add(createItem(1, 'Second')); expect(store.activeIndex).toBeNull(); }); it('should return the last scrolled item index', () => { // Set initial scroll position Object.defineProperty(window, 'scrollY', { value: 0, writable: true, configurable: true }); const store = createScrollBreadcrumbsStore(); const element0 = createMockElement(); const element1 = createMockElement(); store.add(createItem(0, 'First', element0)); store.add(createItem(1, 'Second', element1)); // Scroll down, first item exits Object.defineProperty(window, 'scrollY', { value: 100, writable: true, configurable: true }); scrollListeners.forEach(l => l()); mockObserverInstances[0].triggerIntersection(element0, false); expect(store.activeIndex).toBe(0); // Second item exits mockObserverInstances[0].triggerIntersection(element1, false); expect(store.activeIndex).toBe(1); }); it('should update active index when scrolling back up', () => { const store = createScrollBreadcrumbsStore(); const element0 = createMockElement(); const element1 = createMockElement(); store.add(createItem(0, 'First', element0)); store.add(createItem(1, 'Second', element1)); // Scroll past both items Object.defineProperty(window, 'scrollY', { value: 200, writable: true, configurable: true }); scrollListeners.forEach(l => l()); mockObserverInstances[0].triggerIntersection(element0, false); mockObserverInstances[0].triggerIntersection(element1, false); expect(store.activeIndex).toBe(1); // Scroll back up, item 1 enters viewport Object.defineProperty(window, 'scrollY', { value: 100, writable: true, configurable: true }); scrollListeners.forEach(l => l()); mockObserverInstances[0].triggerIntersection(element1, true); expect(store.activeIndex).toBe(0); }); }); describe('isScrolledPast', () => { it('should return false for items not scrolled past', () => { const store = createScrollBreadcrumbsStore(); store.add(createItem(0, 'First')); store.add(createItem(1, 'Second')); expect(store.isScrolledPast(0)).toBe(false); expect(store.isScrolledPast(1)).toBe(false); }); it('should return true for scrolled items', () => { // Set initial scroll position Object.defineProperty(window, 'scrollY', { value: 0, writable: true, configurable: true }); const store = createScrollBreadcrumbsStore(); const element = createMockElement(); store.add(createItem(0, 'First', element)); store.add(createItem(1, 'Second')); // Scroll down, first item exits viewport Object.defineProperty(window, 'scrollY', { value: 100, writable: true, configurable: true }); scrollListeners.forEach(l => l()); mockObserverInstances[0].triggerIntersection(element, false); expect(store.isScrolledPast(0)).toBe(true); expect(store.isScrolledPast(1)).toBe(false); }); it('should return false for non-existent indices', () => { const store = createScrollBreadcrumbsStore(); store.add(createItem(0, 'First')); expect(store.isScrolledPast(999)).toBe(false); }); }); describe('Scroll direction tracking', () => { it('should track scroll direction changes', () => { const store = createScrollBreadcrumbsStore(); const element = createMockElement(); store.add(createItem(0, 'First', element)); // Initial scroll position Object.defineProperty(window, 'scrollY', { value: 0, writable: true, configurable: true }); scrollListeners.forEach(l => l()); // Scroll down Object.defineProperty(window, 'scrollY', { value: 100, writable: true, configurable: true }); scrollListeners.forEach(l => l()); mockObserverInstances[0].triggerIntersection(element, false); // Should be in scrolledPast since we scrolled down expect(store.isScrolledPast(0)).toBe(true); // Scroll back up Object.defineProperty(window, 'scrollY', { value: 50, writable: true, configurable: true }); scrollListeners.forEach(l => l()); mockObserverInstances[0].triggerIntersection(element, true); // Should be removed since we scrolled up expect(store.isScrolledPast(0)).toBe(false); }); }); });