feat(smoothScroll): cover smoothScroll util with unit tests

This commit is contained in:
Ilia Mashkov
2026-02-18 20:20:24 +03:00
parent 1c3908f89e
commit e81cadb32a

View File

@@ -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<typeof vi.fn>;
let mockPushState: ReturnType<typeof vi.fn>;
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();
});
});
});