import EmblaCarousel, { type EmblaCarouselType } from 'embla-carousel'; import { beforeAll, beforeEach, describe, expect, it, vi, type MockedObject, } from 'vitest'; import { CarouselController } from '../../../src/utils/embla/carousel-controller'; import { callMutationHandler, createParent, createSlot, createSlotHost, MutationObserverMock, } from '../../test-utils'; import { callEmblaHandler, createEmblaApiInstance, createTestSlideNodes, } from './test-utils'; vi.mock('embla-carousel', () => ({ default: vi.fn().mockImplementation(() => { return createEmblaApiInstance(); }), })); // Get the nth most recently constructed EmblaAPI instance. const getEmblaApi = (n = 0): MockedObject | null => { const constructions = vi.mocked(EmblaCarousel).mock.results; const mostRecentResult = constructions[constructions.length - 1 - n] ?? null; if (mostRecentResult && mostRecentResult.type === 'return') { return vi.mocked(mostRecentResult.value); } return null; }; const createRoot = (): HTMLElement => { return document.createElement('div'); }; // @vitest-environment jsdom describe('CarouselController', () => { beforeAll(() => { vi.stubGlobal('MutationObserver', MutationObserverMock); }); beforeEach(() => { vi.clearAllMocks(); }); it('should construct', () => { const children = createTestSlideNodes(); const parent = createParent({ children: children }); const carousel = new CarouselController(createRoot(), parent); expect(carousel).toBeTruthy(); }); it('should construct with slot parent', () => { const slot = createSlot(); const host = createSlotHost({ slot: slot, children: createTestSlideNodes() }); const carousel = new CarouselController(host, slot); expect(carousel).toBeTruthy(); }); it('should destroy', () => { const children = createTestSlideNodes(); const parent = createParent({ children: children }); const carousel = new CarouselController(createRoot(), parent); carousel.destroy(); expect(getEmblaApi()?.destroy).toHaveBeenCalled(); }); it('should destroy with slot', () => { const slot = createSlot(); const host = createSlotHost({ slot: slot, children: createTestSlideNodes() }); const carousel = new CarouselController(host, slot); carousel.destroy(); expect(getEmblaApi()?.destroy).toHaveBeenCalled(); }); it('should get slide by index', () => { const children = createTestSlideNodes(); const parent = createParent({ children: children }); const carousel = new CarouselController(createRoot(), parent); getEmblaApi()?.slideNodes.mockReturnValue(children); expect(carousel.getSlide(2)).toBe(children[2]); }); it('should get slide by index when index is invalid', () => { const children = createTestSlideNodes(); const parent = createParent({ children: children }); const carousel = new CarouselController(createRoot(), parent); expect(carousel.getSlide(1000)).toBeNull(); }); it('should get selected slide', () => { const children = createTestSlideNodes(); const parent = createParent({ children: children }); const carousel = new CarouselController(createRoot(), parent); getEmblaApi()?.slideNodes.mockReturnValue(children); getEmblaApi()?.selectedScrollSnap.mockReturnValue(3); expect(carousel.getSelectedIndex()).toBe(3); expect(carousel.getSelectedSlide()).toBe(children[3]); }); it('should select given slide', () => { const children = createTestSlideNodes(); const parent = createParent({ children: children }); const carousel = new CarouselController(createRoot(), parent); carousel.selectSlide(4); expect(getEmblaApi()?.scrollTo).toHaveBeenCalledWith(4, false); }); it('should not select non-existent slide', () => { const children = createTestSlideNodes({ n: 10 }); const parent = createParent({ children: children }); const carousel = new CarouselController(createRoot(), parent); carousel.selectSlide(11); // Should not call scrollTo because index is out of bounds. expect(getEmblaApi()?.scrollTo).not.toHaveBeenCalled(); }); it('should dispatch select event', () => { const children = createTestSlideNodes(); const parent = createParent({ children: children }); new CarouselController(createRoot(), parent); const selectHandler = vi.fn(); parent.addEventListener('advanced-camera-card:carousel:select', selectHandler); getEmblaApi()?.selectedScrollSnap.mockReturnValue(6); getEmblaApi()?.slideNodes.mockReturnValue(children); callEmblaHandler(getEmblaApi(), 'select'); expect(selectHandler).toHaveBeenCalledWith( expect.objectContaining({ detail: { index: 6, element: children[6], }, }), ); }); it('should not dispatch anything with an invalid scroll snap', () => { const children = createTestSlideNodes(); const parent = createParent({ children: children }); new CarouselController(createRoot(), parent); const selectHandler = vi.fn(); parent.addEventListener('advanced-camera-card:carousel:select', selectHandler); getEmblaApi()?.selectedScrollSnap.mockReturnValue(1000); getEmblaApi()?.slideNodes.mockReturnValue(children); callEmblaHandler(getEmblaApi(), 'init'); callEmblaHandler(getEmblaApi(), 'select'); callEmblaHandler(getEmblaApi(), 'settle'); expect(selectHandler).not.toHaveBeenCalled(); }); it('should honor creation options', () => { const children = createTestSlideNodes({ n: 1 }); const root = createRoot(); const parent = createParent({ children: children }); new CarouselController(root, parent, { direction: 'vertical', transitionEffect: 'none', startIndex: 7, dragFree: true, loop: true, dragEnabled: false, textDirection: 'rtl', }); expect(EmblaCarousel).toHaveBeenCalledWith( root, { slides: children, axis: 'y', duration: 20, startIndex: 7, dragFree: true, loop: true, containScroll: 'trimSnaps', watchSlides: false, watchResize: true, watchDrag: expect.any(Function), direction: 'rtl', }, [], ); }); it('should pass a watchDrag predicate reflecting the current drag state', () => { const children = createTestSlideNodes(); const root = createRoot(); const parent = createParent({ children: children }); const carousel = new CarouselController(root, parent, { dragEnabled: true }); const emblaOptions = vi.mocked(EmblaCarousel).mock.calls[0][1] as { watchDrag: () => boolean; }; expect(emblaOptions.watchDrag()).toBe(true); carousel.setDragEnabled(false); expect(emblaOptions.watchDrag()).toBe(false); carousel.setDragEnabled(true); expect(emblaOptions.watchDrag()).toBe(true); }); it('should toggle drag without rebuilding the carousel', () => { const children = createTestSlideNodes(); const parent = createParent({ children: children }); const carousel = new CarouselController(createRoot(), parent, { dragEnabled: true, }); const emblaApi = getEmblaApi(); expect(emblaApi).toBeTruthy(); carousel.setDragEnabled(false); expect(emblaApi?.reInit).not.toHaveBeenCalled(); expect(emblaApi?.destroy).not.toHaveBeenCalled(); }); it('should include wheel plugin when slides > 1', () => { const children = createTestSlideNodes(); const root = createRoot(); const parent = createParent({ children: children }); new CarouselController(root, parent); expect(EmblaCarousel).toHaveBeenCalledWith( root, expect.anything(), expect.arrayContaining([ expect.objectContaining({ name: 'wheelGestures', }), ]), ); }); it('should not include wheel plugin when wheelScrolling is false', () => { const children = createTestSlideNodes(); const root = createRoot(); const parent = createParent({ children: children }); new CarouselController(root, parent, { wheelScrolling: false }); // Verify WheelGesturesPlugin is NOT present expect(EmblaCarousel).toHaveBeenCalledWith( root, expect.anything(), expect.not.arrayContaining([ expect.objectContaining({ name: 'wheelGestures', }), ]), ); }); it('should reinit carousel when children are added', () => { const children = createTestSlideNodes(); const root = createRoot(); const parent = createParent({ children: children }); new CarouselController(root, parent); expect(EmblaCarousel).toHaveBeenCalledTimes(1); const originalEmblaApi = getEmblaApi(); expect(originalEmblaApi).toBeTruthy(); originalEmblaApi?.slideNodes.mockReturnValue(children); const newChild = document.createElement('div'); parent.appendChild(newChild); callMutationHandler(); // Should call reInit instead of destroy/recreate expect(originalEmblaApi?.reInit).toHaveBeenCalledWith({ slides: [...children, newChild], }); // Should still be same carousel instance (no new creation) expect(EmblaCarousel).toHaveBeenCalledTimes(1); }); it('should not recreate carousel when children have not changed', () => { const children = createTestSlideNodes(); const root = createRoot(); const parent = createParent({ children: children }); new CarouselController(root, parent); expect(EmblaCarousel).toHaveBeenCalledTimes(1); const originalEmblaApi = getEmblaApi(); expect(originalEmblaApi).toBeTruthy(); originalEmblaApi?.slideNodes.mockReturnValue(children); callMutationHandler(); expect(originalEmblaApi?.destroy).not.toHaveBeenCalled(); expect(getEmblaApi()).toBe(originalEmblaApi); expect(EmblaCarousel).toHaveBeenCalledTimes(1); }); it('should reinit carousel when children are added to slot', () => { const children = createTestSlideNodes(); const slot = createSlot(); const host = createSlotHost({ slot: slot, children: children }); new CarouselController(host, slot); expect(EmblaCarousel).toHaveBeenCalledTimes(1); const originalEmblaApi = getEmblaApi(); expect(originalEmblaApi).toBeTruthy(); originalEmblaApi?.slideNodes.mockReturnValue(children); const newChild = document.createElement('div'); host.appendChild(newChild); slot.dispatchEvent(new Event('slotchange')); // Should call reInit instead of destroy/recreate expect(originalEmblaApi?.reInit).toHaveBeenCalledWith({ slides: [...children, newChild], }); // Should still be same carousel instance (no new creation) expect(EmblaCarousel).toHaveBeenCalledTimes(1); }); });