From e81cadb32ac117a9ac9d6e39f39a7035699db134 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Wed, 18 Feb 2026 20:20:24 +0300 Subject: [PATCH] feat(smoothScroll): cover smoothScroll util with unit tests --- .../utils/smoothScroll/smoothScroll.test.ts | 368 ++++++++++++++++++ 1 file changed, 368 insertions(+) create mode 100644 src/shared/lib/utils/smoothScroll/smoothScroll.test.ts diff --git a/src/shared/lib/utils/smoothScroll/smoothScroll.test.ts b/src/shared/lib/utils/smoothScroll/smoothScroll.test.ts new file mode 100644 index 0000000..0f825b7 --- /dev/null +++ b/src/shared/lib/utils/smoothScroll/smoothScroll.test.ts @@ -0,0 +1,368 @@ +/** @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; + + // 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(); + }); + }); +});