feature/test-coverage #27
368
src/shared/lib/utils/smoothScroll/smoothScroll.test.ts
Normal file
368
src/shared/lib/utils/smoothScroll/smoothScroll.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user