refactor(Breadcrumb): simplify entity structure and add tests

This commit is contained in:
Ilia Mashkov
2026-03-02 22:18:41 +03:00
parent af4137f47f
commit 594af924c7
10 changed files with 920 additions and 103 deletions

View File

@@ -0,0 +1,559 @@
/** @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<Element>();
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<IntersectionObserverEntry> = {
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<typeof vi.spyOn>;
let removeEventListenerSpy: ReturnType<typeof vi.spyOn>;
let scrollToSpy: ReturnType<typeof vi.spyOn>;
// 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);
});
});
});