import { beforeEach, expect, test, vi } from 'vitest'; import { createCarousel } from '../src/index.ts'; // Minimal IO shim — records instances so tests can fire entries manually. class IOShim { static last: IOShim | null = null; cb: IntersectionObserverCallback; elements: Element[] = []; constructor(cb: IntersectionObserverCallback) { this.cb = cb; IOShim.last = this; } observe(el: Element) { this.elements.push(el); } unobserve() {} disconnect() {} // helper: emit "slide i is the one intersecting" (ratio 1), others 0 emit(i: number) { this.cb( this.elements.map((target, idx) => ({ target, isIntersecting: idx === i, intersectionRatio: idx === i ? 1 : 0, })) as unknown as IntersectionObserverEntry[], this as unknown as IntersectionObserver, ); } } vi.stubGlobal('IntersectionObserver', IOShim); // jsdom has no ResizeObserver; record instance so tests can trigger it. class ROShim { static last: ROShim | null = null; cb: ResizeObserverCallback; constructor(cb: ResizeObserverCallback) { this.cb = cb; ROShim.last = this; } observe() {} unobserve() {} disconnect() {} fire() { this.cb([] as unknown as ResizeObserverEntry[], this as unknown as ResizeObserver); } } vi.stubGlobal('ResizeObserver', ROShim); function makeTrack(n: number): HTMLElement { const track = document.createElement('div'); for (let i = 0; i < n; i++) { const slide = document.createElement('div'); slide.className = 'slide'; track.append(slide); } track.scrollTo = vi.fn(); return track; } let track: HTMLElement; beforeEach(() => { track = makeTrack(3); }); test('count reflects slide children', () => { const c = createCarousel(track); expect(c.count).toBe(3); }); test('next/prev clamp to range and scroll to target offset', () => { const c = createCarousel(track); c.next(); expect(track.scrollTo).toHaveBeenCalled(); }); test('change fires with new index when a slide intersects', () => { const c = createCarousel(track); const seen: number[] = []; c.on('change', (i) => seen.push(i)); (IOShim.last as IOShim).emit(2); // user swiped to slide 2 expect(seen).toEqual([2]); expect(c.index).toBe(2); }); test('change does not re-fire for the same index', () => { const c = createCarousel(track); const seen: number[] = []; c.on('change', (i) => seen.push(i)); (IOShim.last as IOShim).emit(1); (IOShim.last as IOShim).emit(1); expect(seen).toEqual([1]); }); test('unsubscribe stops delivery; destroy disconnects observer', () => { const c = createCarousel(track); const seen: number[] = []; const off = c.on('change', (i) => seen.push(i)); off(); (IOShim.last as IOShim).emit(2); expect(seen).toEqual([]); const spy = vi.spyOn(IOShim.last as IOShim, 'disconnect'); c.destroy(); expect(spy).toHaveBeenCalled(); }); test('atStart/atEnd track the current index', () => { const c = createCarousel(track); expect(c.atStart).toBe(true); expect(c.atEnd).toBe(false); (IOShim.last as IOShim).emit(2); expect(c.atStart).toBe(false); expect(c.atEnd).toBe(true); }); test('overflow flips on resize and fires change once per change', () => { const c = createCarousel(track); expect(c.overflow).toBe(false); const seen: number[] = []; c.on('change', (i) => seen.push(i)); Object.defineProperty(track, 'scrollWidth', { value: 100, configurable: true }); Object.defineProperty(track, 'clientWidth', { value: 50, configurable: true }); (ROShim.last as ROShim).fire(); expect(c.overflow).toBe(true); expect(seen).toEqual([0]); (ROShim.last as ROShim).fire(); // no change → no re-fire expect(seen).toEqual([0]); }); test('adding/removing slides updates count and fires change', async () => { const c = createCarousel(track); const io = IOShim.last as IOShim; const seen: number[] = []; c.on('change', (i) => seen.push(i)); const observe = vi.spyOn(io, 'observe'); const unobserve = vi.spyOn(io, 'unobserve'); const extra = document.createElement('div'); track.append(extra); await Promise.resolve(); // MutationObserver is async (microtask) expect(observe).toHaveBeenCalledWith(extra); track.removeChild(extra); await Promise.resolve(); expect(unobserve).toHaveBeenCalledWith(extra); expect(c.count).toBe(3); expect(seen.length).toBeGreaterThan(0); }); test('overflow recomputes on add/remove without a resize', async () => { Object.defineProperty(track, 'clientWidth', { value: 50, configurable: true }); Object.defineProperty(track, 'scrollWidth', { value: 40, configurable: true }); const c = createCarousel(track); expect(c.overflow).toBe(false); const seen: number[] = []; c.on('change', (i) => seen.push(i)); // content now exceeds the track — MutationObserver must pick it up, no RO fire Object.defineProperty(track, 'scrollWidth', { value: 120, configurable: true }); track.append(document.createElement('div')); await Promise.resolve(); // MutationObserver is async (microtask) expect(c.overflow).toBe(true); expect(seen.length).toBeGreaterThan(0); }); test('removing the current slide clamps index into range', async () => { const c = createCarousel(track); (IOShim.last as IOShim).emit(2); expect(c.index).toBe(2); track.removeChild(track.children[2]); track.removeChild(track.children[1]); await Promise.resolve(); // MutationObserver is async (microtask) expect(c.index).toBe(0); expect(c.atEnd).toBe(true); });