2026-07-01 07:53:46 +03:00
|
|
|
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);
|
|
|
|
|
|
2026-07-02 11:15:15 +03:00
|
|
|
// 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);
|
|
|
|
|
|
2026-07-01 07:53:46 +03:00
|
|
|
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();
|
|
|
|
|
});
|
2026-07-01 07:54:05 +03:00
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
});
|
2026-07-02 11:15:15 +03:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
});
|