/** * @vitest-environment jsdom */ import { afterEach, beforeEach, describe, expect, it, vi, } from 'vitest'; import { smoothScroll } from './smoothScroll'; describe('smoothScroll', () => { let mockAnchor: HTMLAnchorElement; let mockTarget: HTMLElement; let mockScrollIntoView: ReturnType; let mockPushState: ReturnType; beforeEach(() => { // Mock scrollIntoView mockScrollIntoView = vi.fn(); HTMLElement.prototype.scrollIntoView = mockScrollIntoView as (arg?: boolean | ScrollIntoViewOptions) => void; // Mock history.pushState mockPushState = vi.fn(); vi.stubGlobal('history', { pushState: mockPushState, }); // Create mock elements mockAnchor = document.createElement('a'); mockAnchor.setAttribute('href', '#section-1'); mockTarget = document.createElement('div'); mockTarget.id = 'section-1'; document.body.appendChild(mockTarget); }); afterEach(() => { vi.clearAllMocks(); vi.unstubAllGlobals(); document.body.innerHTML = ''; }); describe('Basic Functionality', () => { it('should be a function that returns an object with destroy method', () => { const action = smoothScroll(mockAnchor); expect(typeof action).toBe('object'); expect(typeof action.destroy).toBe('function'); }); it('should add click event listener to the anchor element', () => { const addEventListenerSpy = vi.spyOn(mockAnchor, 'addEventListener'); smoothScroll(mockAnchor); expect(addEventListenerSpy).toHaveBeenCalledWith('click', expect.any(Function)); addEventListenerSpy.mockRestore(); }); it('should remove click event listener when destroy is called', () => { const action = smoothScroll(mockAnchor); const removeEventListenerSpy = vi.spyOn(mockAnchor, 'removeEventListener'); action.destroy(); expect(removeEventListenerSpy).toHaveBeenCalledWith('click', expect.any(Function)); removeEventListenerSpy.mockRestore(); }); }); describe('Click Handling', () => { it('should prevent default behavior on click', () => { const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true, }); const preventDefaultSpy = vi.spyOn(mockEvent, 'preventDefault'); smoothScroll(mockAnchor); mockAnchor.dispatchEvent(mockEvent); expect(preventDefaultSpy).toHaveBeenCalled(); preventDefaultSpy.mockRestore(); }); it('should scroll to target element when clicked', () => { const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true }); smoothScroll(mockAnchor); mockAnchor.dispatchEvent(mockEvent); expect(mockScrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'start', }); }); it('should update URL hash without jumping when clicked', () => { const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true }); smoothScroll(mockAnchor); mockAnchor.dispatchEvent(mockEvent); expect(mockPushState).toHaveBeenCalledWith(null, '', '#section-1'); }); }); describe('Edge Cases', () => { it('should do nothing when href attribute is missing', () => { mockAnchor.removeAttribute('href'); const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true }); smoothScroll(mockAnchor); mockAnchor.dispatchEvent(mockEvent); expect(mockScrollIntoView).not.toHaveBeenCalled(); expect(mockPushState).not.toHaveBeenCalled(); }); it('should do nothing when href is just "#"', () => { mockAnchor.setAttribute('href', '#'); const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true }); smoothScroll(mockAnchor); mockAnchor.dispatchEvent(mockEvent); expect(mockScrollIntoView).not.toHaveBeenCalled(); expect(mockPushState).not.toHaveBeenCalled(); }); it('should do nothing when target element does not exist', () => { mockAnchor.setAttribute('href', '#non-existent'); const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true }); smoothScroll(mockAnchor); mockAnchor.dispatchEvent(mockEvent); expect(mockScrollIntoView).not.toHaveBeenCalled(); expect(mockPushState).not.toHaveBeenCalled(); }); it('should handle empty href attribute', () => { mockAnchor.setAttribute('href', ''); const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true }); smoothScroll(mockAnchor); mockAnchor.dispatchEvent(mockEvent); expect(mockScrollIntoView).not.toHaveBeenCalled(); }); }); describe('Multiple Anchors', () => { it('should work correctly with multiple anchor elements', () => { const anchor1 = document.createElement('a'); anchor1.setAttribute('href', '#section-1'); const target1 = document.createElement('div'); target1.id = 'section-1'; document.body.appendChild(target1); const anchor2 = document.createElement('a'); anchor2.setAttribute('href', '#section-2'); const target2 = document.createElement('div'); target2.id = 'section-2'; document.body.appendChild(target2); const action1 = smoothScroll(anchor1); const action2 = smoothScroll(anchor2); const event1 = new MouseEvent('click', { bubbles: true, cancelable: true }); anchor1.dispatchEvent(event1); expect(mockScrollIntoView).toHaveBeenCalledTimes(1); const event2 = new MouseEvent('click', { bubbles: true, cancelable: true }); anchor2.dispatchEvent(event2); expect(mockScrollIntoView).toHaveBeenCalledTimes(2); expect(mockPushState).toHaveBeenCalledWith(null, '', '#section-2'); // Cleanup action1.destroy(); action2.destroy(); }); }); describe('Cleanup', () => { it('should not trigger clicks after destroy is called', () => { const action = smoothScroll(mockAnchor); action.destroy(); const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true }); mockAnchor.dispatchEvent(mockEvent); expect(mockScrollIntoView).not.toHaveBeenCalled(); expect(mockPushState).not.toHaveBeenCalled(); }); it('should allow multiple destroy calls without errors', () => { const action = smoothScroll(mockAnchor); expect(() => { action.destroy(); action.destroy(); action.destroy(); }).not.toThrow(); }); }); describe('Scroll Options', () => { it('should always use smooth behavior', () => { const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true }); smoothScroll(mockAnchor); mockAnchor.dispatchEvent(mockEvent); expect(mockScrollIntoView).toHaveBeenCalledWith( expect.objectContaining({ behavior: 'smooth', }), ); }); it('should always use block: start', () => { const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true }); smoothScroll(mockAnchor); mockAnchor.dispatchEvent(mockEvent); expect(mockScrollIntoView).toHaveBeenCalledWith( expect.objectContaining({ block: 'start', }), ); }); }); describe('Different Hash Formats', () => { it('should handle simple hash like "#section"', () => { const target = document.createElement('div'); target.id = 'section'; document.body.appendChild(target); mockAnchor.setAttribute('href', '#section'); const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true }); smoothScroll(mockAnchor); mockAnchor.dispatchEvent(mockEvent); expect(mockScrollIntoView).toHaveBeenCalled(); expect(mockPushState).toHaveBeenCalledWith(null, '', '#section'); }); it('should handle hash with multiple words like "#my-section"', () => { const target = document.createElement('div'); target.id = 'my-section'; document.body.appendChild(target); mockAnchor.setAttribute('href', '#my-section'); const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true }); smoothScroll(mockAnchor); mockAnchor.dispatchEvent(mockEvent); expect(mockScrollIntoView).toHaveBeenCalled(); expect(mockPushState).toHaveBeenCalledWith(null, '', '#my-section'); }); it('should handle hash with numbers like "#section-1-2"', () => { const target = document.createElement('div'); target.id = 'section-1-2'; document.body.appendChild(target); mockAnchor.setAttribute('href', '#section-1-2'); const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true }); smoothScroll(mockAnchor); mockAnchor.dispatchEvent(mockEvent); expect(mockScrollIntoView).toHaveBeenCalled(); expect(mockPushState).toHaveBeenCalledWith(null, '', '#section-1-2'); }); }); describe('Special Cases', () => { it('should gracefully handle missing history.pushState', () => { // Create a fresh test environment const testAnchor = document.createElement('a'); testAnchor.href = '#test'; const testTarget = document.createElement('div'); testTarget.id = 'test'; document.body.appendChild(testTarget); // Don't stub history - the action should still work without it const action = smoothScroll(testAnchor); const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true }); // Should not throw even if history.pushState might not exist expect(() => testAnchor.dispatchEvent(mockEvent)).not.toThrow(); action.destroy(); testTarget.remove(); }); }); describe('Return Value', () => { it('should return an action object compatible with Svelte use directive', () => { const action = smoothScroll(mockAnchor); expect(action).toHaveProperty('destroy'); expect(typeof action.destroy).toBe('function'); }); it('should allow chaining destroy calls', () => { const action = smoothScroll(mockAnchor); const result = action.destroy(); expect(result).toBeUndefined(); }); }); describe('Real-World Scenarios', () => { it('should handle table of contents navigation', () => { const sections = ['intro', 'features', 'pricing', 'contact']; sections.forEach(id => { const section = document.createElement('section'); section.id = id; document.body.appendChild(section); const link = document.createElement('a'); link.href = `#${id}`; document.body.appendChild(link); const action = smoothScroll(link); const event = new MouseEvent('click', { bubbles: true, cancelable: true }); link.dispatchEvent(event); expect(mockScrollIntoView).toHaveBeenCalled(); action.destroy(); }); expect(mockScrollIntoView).toHaveBeenCalledTimes(sections.length); }); it('should work with back-to-top button', () => { const topAnchor = document.createElement('a'); topAnchor.href = '#top'; document.body.appendChild(topAnchor); const topElement = document.createElement('div'); topElement.id = 'top'; document.body.prepend(topElement); const action = smoothScroll(topAnchor); const event = new MouseEvent('click', { bubbles: true, cancelable: true }); topAnchor.dispatchEvent(event); expect(mockScrollIntoView).toHaveBeenCalled(); action.destroy(); }); }); });