Complete carousel refactor.

Reduces one layer of DOM nesting for simplication, uses the latest Embla
version, unittests for everything.
This commit is contained in:
Dermot Duffy
2023-09-04 16:25:32 -07:00
parent 48ece1e154
commit e830db1a77
56 changed files with 3561 additions and 1950 deletions
@@ -0,0 +1,291 @@
import EmblaCarousel, { EmblaCarouselType } from 'embla-carousel';
import { MockedObject, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { CarouselController } from '../../../src/utils/embla/carousel-controller';
import AutoMediaLoadedInfo from '../../../src/utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info';
import {
MutationObserverMock,
createParent,
createSlot,
createSlotHost,
} from '../../test-utils';
import {
callEmblaHandler,
callMutationHandler,
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<EmblaCarouselType> | 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).toBeCalled();
});
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).toBeCalled();
});
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).toBeCalledWith(4, false);
});
it('should dispatch settle event', () => {
const children = createTestSlideNodes();
const parent = createParent({ children: children });
new CarouselController(createRoot(), parent);
const settleHandler = vi.fn();
parent.addEventListener('frigate-card:carousel:settle', settleHandler);
callEmblaHandler(getEmblaApi(), 'settle');
expect(settleHandler).toBeCalled();
});
describe('should dispatch select event on', () => {
it.each([['init' as const], ['select' as const]])(
'%s',
(emblaApiEvt: 'init' | 'select') => {
const children = createTestSlideNodes();
const parent = createParent({ children: children });
new CarouselController(createRoot(), parent);
const selectHandler = vi.fn();
parent.addEventListener('frigate-card:carousel:select', selectHandler);
getEmblaApi()?.selectedScrollSnap.mockReturnValue(6);
getEmblaApi()?.slideNodes.mockReturnValue(children);
callEmblaHandler(getEmblaApi(), emblaApiEvt);
expect(selectHandler).toBeCalledWith(
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('frigate-card:carousel:select', selectHandler);
getEmblaApi()?.selectedScrollSnap.mockReturnValue(1000);
getEmblaApi()?.slideNodes.mockReturnValue(children);
callEmblaHandler(getEmblaApi(), 'init');
callEmblaHandler(getEmblaApi(), 'select');
callEmblaHandler(getEmblaApi(), 'settle');
expect(selectHandler).not.toBeCalled();
});
it('should honor creation options', () => {
const children = createTestSlideNodes({ n: 1 });
const root = createRoot();
const parent = createParent({ children: children });
const plugins = [AutoMediaLoadedInfo()];
new CarouselController(root, parent, {
direction: 'vertical',
transitionEffect: 'none',
startIndex: 7,
dragFree: true,
loop: true,
dragEnabled: false,
plugins: plugins,
});
expect(EmblaCarousel).toBeCalledWith(
root,
{
slides: children,
axis: 'y',
duration: 20,
startIndex: 7,
dragFree: true,
loop: true,
containScroll: 'trimSnaps',
watchSlides: false,
watchResize: false,
watchDrag: false,
},
plugins,
);
});
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).toBeCalledWith(
root,
expect.anything(),
expect.arrayContaining([
expect.objectContaining({
name: 'wheelGestures',
}),
]),
);
});
it('should recreate carousel when children are added', () => {
const children = createTestSlideNodes();
const root = createRoot();
const parent = createParent({ children: children });
new CarouselController(root, parent);
expect(EmblaCarousel).toBeCalledTimes(1);
const originalEmblaApi = getEmblaApi();
expect(originalEmblaApi).toBeTruthy();
originalEmblaApi?.slideNodes.mockReturnValue(children);
parent.appendChild(document.createElement('div'));
callMutationHandler();
expect(originalEmblaApi?.destroy).toBeCalled();
expect(getEmblaApi()).not.toBe(originalEmblaApi);
expect(EmblaCarousel).toBeCalledTimes(2);
});
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).toBeCalledTimes(1);
const originalEmblaApi = getEmblaApi();
expect(originalEmblaApi).toBeTruthy();
originalEmblaApi?.slideNodes.mockReturnValue(children);
callMutationHandler();
expect(originalEmblaApi?.destroy).not.toBeCalled();
expect(getEmblaApi()).toBe(originalEmblaApi);
expect(EmblaCarousel).toBeCalledTimes(1);
});
it('should recreate 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).toBeCalledTimes(1);
const originalEmblaApi = getEmblaApi();
expect(originalEmblaApi).toBeTruthy();
originalEmblaApi?.slideNodes.mockReturnValue(children);
host.appendChild(document.createElement('div'));
slot.dispatchEvent(new Event('slotchange'));
expect(originalEmblaApi?.destroy).toBeCalled();
expect(getEmblaApi()).not.toBe(originalEmblaApi);
expect(EmblaCarousel).toBeCalledTimes(2);
});
});
@@ -0,0 +1,219 @@
import { describe, expect, it, vi } from 'vitest';
import { AutoLazyLoad } from '../../../../../src/utils/embla/plugins/auto-lazy-load/auto-lazy-load';
import {
callEmblaHandler,
callVisibilityHandler,
createEmblaApiInstance,
createTestEmblaOptionHandler,
createTestSlideNodes,
} from '../../test-utils';
// @vitest-environment jsdom
describe('AutoLazyLoad', () => {
it('should construct', () => {
const plugin = AutoLazyLoad();
expect(plugin.name).toBe('autoLazyLoad');
});
it('should destroy', () => {
const plugin = AutoLazyLoad({
lazyLoadCallback: vi.fn(),
lazyUnloadCallback: vi.fn(),
});
const emblaApi = createEmblaApiInstance();
plugin.init(emblaApi, createTestEmblaOptionHandler());
plugin.destroy();
expect(emblaApi.off).toBeCalledWith('init', expect.anything());
expect(emblaApi.off).toBeCalledWith('select', expect.anything());
});
it('should do nothing without callbacks', () => {
const plugin = AutoLazyLoad({
// No callbacks provided.
});
const children = createTestSlideNodes();
const emblaApi = createEmblaApiInstance({ slideNodes: children });
plugin.init(emblaApi, createTestEmblaOptionHandler());
expect(emblaApi.on).not.toBeCalled();
plugin.destroy();
expect(emblaApi.off).not.toBeCalled();
});
it('should lazy load single slide on select', () => {
const lazyLoadCallback = vi.fn();
const plugin = AutoLazyLoad({
lazyLoadCallback: lazyLoadCallback,
});
const children = createTestSlideNodes();
const emblaApi = createEmblaApiInstance({ slideNodes: children });
plugin.init(emblaApi, createTestEmblaOptionHandler());
expect(emblaApi.on).toBeCalledWith('init', expect.anything());
expect(emblaApi.on).toBeCalledWith('select', expect.anything());
callEmblaHandler(emblaApi, 'init');
expect(lazyLoadCallback).toBeCalledWith(0, children[0]);
callEmblaHandler(emblaApi, 'select');
// The select call will not re-lazyload the same slide.
expect(lazyLoadCallback).toBeCalledTimes(1);
});
it('should lazy load multiple slides on select', () => {
const lazyLoadCallback = vi.fn();
const plugin = AutoLazyLoad({
lazyLoadCallback: lazyLoadCallback,
lazyLoadCount: 3,
});
const children = createTestSlideNodes();
const emblaApi = createEmblaApiInstance({
slideNodes: children,
selectedScrollSnap: 5,
});
plugin.init(emblaApi, createTestEmblaOptionHandler());
callEmblaHandler(emblaApi, 'select');
for (let i = 3; i <= 8; ++i) {
expect(lazyLoadCallback).toBeCalledWith(i, children[i]);
}
});
it('should lazy unload on select', () => {
const lazyUnloadCallback = vi.fn();
const plugin = AutoLazyLoad({
lazyLoadCallback: vi.fn(),
lazyLoadCount: 3,
lazyUnloadCallback: lazyUnloadCallback,
lazyUnloadCondition: 'all',
});
const children = createTestSlideNodes();
const emblaApi = createEmblaApiInstance({
selectedScrollSnap: 5,
slideNodes: children,
});
plugin.init(emblaApi, createTestEmblaOptionHandler());
callEmblaHandler(emblaApi, 'select');
// First call will not unload anything, since it was not lazy loaded.
expect(lazyUnloadCallback).not.toBeCalled();
vi.mocked(emblaApi.previousScrollSnap).mockReturnValue(5);
callEmblaHandler(emblaApi, 'select');
// Second call should lazy unload the previous slide.
expect(lazyUnloadCallback).toBeCalledWith(5, children[5]);
});
it('should lazy load on visibility', () => {
vi.spyOn(global.document, 'addEventListener');
const lazyLoadCallback = vi.fn();
const plugin = AutoLazyLoad({
lazyLoadCallback: lazyLoadCallback,
});
const children = createTestSlideNodes();
const emblaApi = createEmblaApiInstance({
slideNodes: children,
});
plugin.init(emblaApi, createTestEmblaOptionHandler());
Object.defineProperty(document, 'visibilityState', {
value: 'visible',
writable: true,
});
callVisibilityHandler();
expect(lazyLoadCallback).toBeCalledWith(0, children[0]);
});
it('should lazy unload on visibility', () => {
vi.spyOn(global.document, 'addEventListener');
const lazyUnloadCallback = vi.fn();
const plugin = AutoLazyLoad({
lazyLoadCallback: vi.fn(),
lazyUnloadCallback: lazyUnloadCallback,
lazyUnloadCondition: 'all',
});
const children = createTestSlideNodes();
const emblaApi = createEmblaApiInstance({
slideNodes: children,
});
plugin.init(emblaApi, createTestEmblaOptionHandler());
Object.defineProperty(document, 'visibilityState', {
value: 'visible',
writable: true,
});
callVisibilityHandler();
Object.defineProperty(document, 'visibilityState', {
value: 'hidden',
writable: true,
});
callVisibilityHandler();
expect(lazyUnloadCallback).toBeCalledWith(0, children[0]);
});
it('should not lazy unload on visibility without a callback', () => {
vi.spyOn(global.document, 'addEventListener');
const lazyLoadCallback = vi.fn();
const plugin = AutoLazyLoad({
lazyLoadCallback: lazyLoadCallback,
lazyUnloadCondition: 'all',
// No lazy unload callback.
});
const children = createTestSlideNodes();
const emblaApi = createEmblaApiInstance({
slideNodes: children,
});
plugin.init(emblaApi, createTestEmblaOptionHandler());
Object.defineProperty(document, 'visibilityState', {
value: 'visible',
writable: true,
});
callVisibilityHandler();
expect(lazyLoadCallback).toBeCalledTimes(1);
Object.defineProperty(document, 'visibilityState', {
value: 'hidden',
writable: true,
});
callVisibilityHandler();
expect(lazyLoadCallback).toBeCalledTimes(1);
});
it('should not lazy load or unload on visibility when no callback provided', () => {
vi.spyOn(global.document, 'addEventListener');
const plugin = AutoLazyLoad({
lazyUnloadCondition: 'all',
// No callbacks provided.
});
const children = createTestSlideNodes();
const emblaApi = createEmblaApiInstance({
slideNodes: children,
});
plugin.init(emblaApi, createTestEmblaOptionHandler());
Object.defineProperty(document, 'visibilityState', {
value: 'visible',
writable: true,
});
callVisibilityHandler();
});
});
@@ -0,0 +1,304 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { FrigateCardMediaPlayer } from '../../../../../src/types';
import {
AutoMediaActions,
AutoMediaActionsOptionsType,
AutoMediaActionsType,
} from '../../../../../src/utils/embla/plugins/auto-media-actions/auto-media-actions';
import { dispatchExistingMediaLoadedInfoAsEvent } from '../../../../../src/utils/media-info';
import {
IntersectionObserverMock,
createMediaLoadedInfo,
createParent,
} from '../../../../test-utils';
import {
callEmblaHandler,
callIntersectionHandler,
callVisibilityHandler,
createEmblaApiInstance,
createTestEmblaOptionHandler,
createTestSlideNodes,
} from '../../test-utils';
const getPlayer = (
element: HTMLElement,
selector: string,
): (HTMLElement & FrigateCardMediaPlayer) | null => {
return element.querySelector(selector);
};
const createPlayerSlideNodes = (n = 10): HTMLElement[] => {
const slides = createTestSlideNodes({ n: n });
for (const slide of slides) {
const player = document.createElement('video');
player['play'] = vi.fn();
player['pause'] = vi.fn();
player['mute'] = vi.fn();
player['unmute'] = vi.fn();
player['isMuted'] = vi.fn().mockReturnValue(true);
player['seek'] = vi.fn();
player['getScreenshotURL'] = vi.fn();
player['setControls'] = vi.fn();
player['isPaused'] = vi.fn();
slide.appendChild(player);
}
return slides;
};
const createPlugin = (options?: AutoMediaActionsOptionsType): AutoMediaActionsType => {
return AutoMediaActions({
playerSelector: 'video',
autoPlayCondition: 'all',
autoUnmuteCondition: 'all',
autoPauseCondition: 'all',
autoMuteCondition: 'all',
...options,
});
};
// @vitest-environment jsdom
describe('AutoMediaActions', () => {
beforeAll(() => {
vi.stubGlobal('IntersectionObserver', IntersectionObserverMock);
});
beforeEach(() => {
vi.clearAllMocks();
});
it('should construct', () => {
const plugin = AutoMediaActions();
expect(plugin.name).toBe('autoMediaActions');
});
it('should init without any conditions', () => {
const plugin = AutoMediaActions();
const parent = createParent();
const addEventListener = vi.fn();
parent.addEventListener = addEventListener;
const emblaApi = createEmblaApiInstance({ containerNode: parent });
plugin.init(emblaApi, createTestEmblaOptionHandler());
expect(emblaApi.on).toBeCalledWith('destroy', expect.anything());
expect(emblaApi.on).not.toBeCalledWith('select', expect.anything());
expect(addEventListener).not.toBeCalled();
});
it('should destroy', () => {
const plugin = createPlugin();
const parent = createParent();
const removeEventListener = vi.fn();
parent.removeEventListener = removeEventListener;
const emblaApi = createEmblaApiInstance({ containerNode: parent });
plugin.init(emblaApi, createTestEmblaOptionHandler());
plugin.destroy();
expect(emblaApi.off).toBeCalledWith('destroy', expect.anything());
expect(emblaApi.off).toBeCalledWith('select', expect.anything());
expect(removeEventListener).toBeCalled();
});
it('should destroy without any conditions', () => {
const plugin = AutoMediaActions();
const parent = createParent();
const removeEventListener = vi.fn();
parent.removeEventListener = removeEventListener;
const emblaApi = createEmblaApiInstance({ containerNode: parent });
plugin.init(emblaApi, createTestEmblaOptionHandler());
plugin.destroy();
expect(emblaApi.off).toBeCalledWith('destroy', expect.anything());
expect(emblaApi.off).not.toBeCalledWith('select', expect.anything());
expect(removeEventListener).not.toBeCalled();
});
it('should mute and pause on destroy', () => {
const plugin = createPlugin();
const children = createPlayerSlideNodes();
const parent = createParent({ children: children });
const emblaApi = createEmblaApiInstance({
slideNodes: children,
containerNode: parent,
selectedScrollSnap: 5,
});
plugin.init(emblaApi, createTestEmblaOptionHandler());
callEmblaHandler(emblaApi, 'destroy');
expect(getPlayer(children[5], 'video')?.pause).toBeCalled();
expect(getPlayer(children[5], 'video')?.mute).toBeCalled();
});
it('should play and unmute on media load', () => {
const plugin = createPlugin();
const children = createPlayerSlideNodes();
const parent = createParent({ children: children });
const emblaApi = createEmblaApiInstance({
slideNodes: children,
containerNode: parent,
selectedScrollSnap: 5,
});
plugin.init(emblaApi, createTestEmblaOptionHandler());
dispatchExistingMediaLoadedInfoAsEvent(parent, createMediaLoadedInfo());
expect(getPlayer(children[5], 'video')?.play).toBeCalled();
expect(getPlayer(children[5], 'video')?.unmute).toBeCalled();
});
it('should not play or unmute on media load when player selecter not provided', () => {
const plugin = createPlugin({ playerSelector: undefined });
const children = createPlayerSlideNodes();
const parent = createParent({ children: children });
const emblaApi = createEmblaApiInstance({
slideNodes: children,
containerNode: parent,
selectedScrollSnap: 5,
});
plugin.init(emblaApi, createTestEmblaOptionHandler());
dispatchExistingMediaLoadedInfoAsEvent(parent, createMediaLoadedInfo());
expect(getPlayer(children[5], 'video')?.play).not.toBeCalled();
expect(getPlayer(children[5], 'video')?.unmute).not.toBeCalled();
});
it('should pause and mute previous on select', () => {
const plugin = createPlugin();
const children = createPlayerSlideNodes();
const emblaApi = createEmblaApiInstance({
slideNodes: children,
previousScrollSnap: 4,
});
plugin.init(emblaApi, createTestEmblaOptionHandler());
callEmblaHandler(emblaApi, 'select');
expect(getPlayer(children[4], 'video')?.pause).toBeCalled();
expect(getPlayer(children[4], 'video')?.mute).toBeCalled();
});
it('should play and unmute on visibility change to visible', () => {
vi.spyOn(global.document, 'addEventListener');
const plugin = createPlugin();
const children = createPlayerSlideNodes();
const emblaApi = createEmblaApiInstance({
slideNodes: children,
selectedScrollSnap: 5,
});
plugin.init(emblaApi, createTestEmblaOptionHandler());
Object.defineProperty(document, 'visibilityState', {
value: 'visible',
writable: true,
});
callVisibilityHandler();
expect(getPlayer(children[5], 'video')?.play).toBeCalled();
expect(getPlayer(children[5], 'video')?.unmute).toBeCalled();
});
it('should pause and unmute on visibility change to hidden', () => {
vi.spyOn(global.document, 'addEventListener');
const plugin = createPlugin();
const children = createPlayerSlideNodes();
const emblaApi = createEmblaApiInstance({
slideNodes: children,
});
plugin.init(emblaApi, createTestEmblaOptionHandler());
Object.defineProperty(document, 'visibilityState', {
value: 'hidden',
writable: true,
});
callVisibilityHandler();
for (const child of children) {
expect(getPlayer(child, 'video')?.pause).toBeCalled();
expect(getPlayer(child, 'video')?.mute).toBeCalled();
}
});
describe('should take no action on visibility change without callbacks', () => {
it.each([['visible' as const], ['hidden' as const]])(
'%s',
(visibilityState: 'visible' | 'hidden') => {
vi.spyOn(global.document, 'addEventListener');
const plugin = AutoMediaActions();
const children = createPlayerSlideNodes();
const emblaApi = createEmblaApiInstance({
slideNodes: children,
});
plugin.init(emblaApi, createTestEmblaOptionHandler());
Object.defineProperty(document, 'visibilityState', {
value: visibilityState,
writable: true,
});
callVisibilityHandler();
for (const child of children) {
expect(getPlayer(child, 'video')?.play).not.toBeCalled();
expect(getPlayer(child, 'video')?.pause).not.toBeCalled();
expect(getPlayer(child, 'video')?.mute).not.toBeCalled();
expect(getPlayer(child, 'video')?.unmute).not.toBeCalled();
}
},
);
});
it('should play and unmute on intersection', () => {
const plugin = createPlugin();
const children = createPlayerSlideNodes();
const emblaApi = createEmblaApiInstance({
slideNodes: children,
selectedScrollSnap: 5,
});
plugin.init(emblaApi, createTestEmblaOptionHandler());
// Intersection observer always calls handler on creation (and we ignore
// these first calls).
callIntersectionHandler(true);
callIntersectionHandler(true);
expect(getPlayer(children[5], 'video')?.play).toBeCalled();
expect(getPlayer(children[5], 'video')?.unmute).toBeCalled();
});
it('should pause and mute on intersection', () => {
vi.spyOn(global.document, 'addEventListener');
const plugin = createPlugin();
const children = createPlayerSlideNodes();
const emblaApi = createEmblaApiInstance({
slideNodes: children,
});
plugin.init(emblaApi, createTestEmblaOptionHandler());
// Intersection observer always calls handler on creation (and we ignore
// these first calls).
callIntersectionHandler(true);
callIntersectionHandler(false);
for (const child of children) {
expect(getPlayer(child, 'video')?.pause).toBeCalled();
expect(getPlayer(child, 'video')?.mute).toBeCalled();
}
});
});
@@ -0,0 +1,92 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import AutoMediaLoadedInfo from '../../../../../src/utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info';
import {
dispatchExistingMediaLoadedInfoAsEvent,
dispatchMediaUnloadedEvent,
} from '../../../../../src/utils/media-info';
import { createMediaLoadedInfo, createParent } from '../../../../test-utils';
import {
callEmblaHandler,
createEmblaApiInstance,
createTestEmblaOptionHandler,
createTestSlideNodes,
} from '../../test-utils';
// @vitest-environment jsdom
describe('AutoMediaLoadedInfo', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should construct', () => {
const plugin = AutoMediaLoadedInfo();
expect(plugin.name).toBe('autoMediaLoadedInfo');
});
it('should destroy', () => {
const plugin = AutoMediaLoadedInfo();
const emblaApi = createEmblaApiInstance();
plugin.init(emblaApi, createTestEmblaOptionHandler());
plugin.destroy();
expect(emblaApi.off).toBeCalledWith('init', expect.anything());
expect(emblaApi.off).toBeCalledWith('select', expect.anything());
});
describe('should correctly propogate media load/unload depending on whether media is currently selected', () => {
it.each([
['loaded' as const, true],
['unloaded' as const, true],
['loaded' as const, false],
['unloaded' as const, false],
])('%s', (type: string, selected: boolean) => {
const plugin = AutoMediaLoadedInfo();
const children = createTestSlideNodes();
const parent = createParent({ children: children });
const emblaApi = createEmblaApiInstance({
containerNode: parent,
slideNodes: children,
selectedScrollSnap: selected ? 5 : 4,
});
plugin.init(emblaApi, createTestEmblaOptionHandler());
const mediaLoadedHandler = vi.fn();
parent.addEventListener('frigate-card:media:' + type, mediaLoadedHandler);
if (type === 'loaded') {
dispatchExistingMediaLoadedInfoAsEvent(children[5], createMediaLoadedInfo());
} else if (type === 'unloaded') {
dispatchMediaUnloadedEvent(children[5]);
}
if (selected) {
expect(mediaLoadedHandler).toBeCalled();
} else {
expect(mediaLoadedHandler).not.toBeCalled();
}
});
});
it('selecting a slide should dispatch a previously saved media loaded info if present', () => {
const plugin = AutoMediaLoadedInfo();
const children = createTestSlideNodes();
const parent = createParent({ children: children });
const emblaApi = createEmblaApiInstance({
containerNode: parent,
slideNodes: children,
});
plugin.init(emblaApi, createTestEmblaOptionHandler());
const mediaLoadedHandler = vi.fn();
parent.addEventListener('frigate-card:media:loaded', mediaLoadedHandler);
dispatchExistingMediaLoadedInfoAsEvent(children[5], createMediaLoadedInfo());
vi.mocked(emblaApi.selectedScrollSnap).mockReturnValue(4);
callEmblaHandler(emblaApi, 'select');
expect(mediaLoadedHandler).not.toBeCalled();
vi.mocked(emblaApi.selectedScrollSnap).mockReturnValue(5);
callEmblaHandler(emblaApi, 'select');
expect(mediaLoadedHandler).toBeCalled();
});
});
@@ -0,0 +1,161 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import AutoSize from '../../../../../src/utils/embla/plugins/auto-size/auto-size';
import {
IntersectionObserverMock,
ResizeObserverMock,
createParent,
requestAnimationFrameMock,
} from '../../../../test-utils';
import {
callEmblaHandler,
callIntersectionHandler,
callResizeHandler,
createEmblaApiInstance,
createTestEmblaOptionHandler,
createTestSlideNodes,
} from '../../test-utils';
// Mock out debouncing (used in the reinit controller).
vi.mock('lodash-es/debounce', () => ({
default: vi.fn((fn) => fn),
}));
// @vitest-environment jsdom
describe('AutoSize', () => {
beforeAll(() => {
// Mock out requestAnimationFrame (used in the reinit controller).
window.requestAnimationFrame = requestAnimationFrameMock;
vi.stubGlobal('IntersectionObserver', IntersectionObserverMock);
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
});
beforeEach(() => {
vi.clearAllMocks();
});
it('should construct', () => {
const plugin = AutoSize();
expect(plugin.name).toBe('autoSize');
});
it('should destroy', () => {
const plugin = AutoSize();
const emblaApi = createEmblaApiInstance();
plugin.init(emblaApi, createTestEmblaOptionHandler());
plugin.destroy();
expect(emblaApi.off).toBeCalledWith('settle', expect.anything());
expect(
vi.mocked(IntersectionObserver).mock.results[0].value.disconnect,
).toBeCalled();
expect(vi.mocked(ResizeObserver).mock.results[0].value.disconnect).toBeCalled();
});
it('should correctly handle intersection', () => {
const plugin = AutoSize();
const emblaApi = createEmblaApiInstance();
plugin.init(emblaApi, createTestEmblaOptionHandler());
// First intersection handler call sets the state only.
callIntersectionHandler(true);
callIntersectionHandler(false);
callIntersectionHandler(false);
callIntersectionHandler(false);
expect(emblaApi.reInit).toBeCalledTimes(1);
});
it('should correctly handle resize', () => {
const plugin = AutoSize();
const parent = createParent();
const emblaApi = createEmblaApiInstance({ containerNode: parent });
plugin.init(emblaApi, createTestEmblaOptionHandler());
callResizeHandler([{ target: parent, width: 10, height: 20 }]);
callResizeHandler([{ target: parent, width: 10, height: 20 }]);
callResizeHandler([{ target: parent, width: 10, height: 20 }]);
expect(emblaApi.reInit).toBeCalledTimes(1);
callResizeHandler([{ target: parent, width: 20, height: 40 }]);
expect(emblaApi.reInit).toBeCalledTimes(2);
});
it('should set container height on slide settle', () => {
const plugin = AutoSize();
const parent = createParent();
const children = createTestSlideNodes();
const emblaApi = createEmblaApiInstance({
containerNode: parent,
selectedScrollSnap: 0,
slideNodes: children,
// 0th scroll snap shows the 0th slide only.
slideRegistry: [[0]],
});
plugin.init(emblaApi, createTestEmblaOptionHandler());
children[0].getBoundingClientRect = vi.fn().mockReturnValue({
width: 200,
height: 800,
});
// select should not do anything, we wait for it to have settled for
// smoothness.
callEmblaHandler(emblaApi, 'select');
expect(parent.style.maxHeight).toBeFalsy();
callEmblaHandler(emblaApi, 'settle');
expect(parent.style.maxHeight).toBe('800px');
});
it('should not set container height on horizontal carousel', () => {
const plugin = AutoSize();
const parent = createParent();
const children = createTestSlideNodes();
const emblaApi = createEmblaApiInstance({
containerNode: parent,
selectedScrollSnap: 0,
slideNodes: children,
axis: 'y',
// 0th scroll snap shows the 0th slide only.
slideRegistry: [[0]],
});
plugin.init(emblaApi, createTestEmblaOptionHandler());
children[0].getBoundingClientRect = vi.fn().mockReturnValue({
width: 200,
height: 800,
});
callEmblaHandler(emblaApi, 'settle');
expect(parent.style.maxHeight).toBeFalsy();
});
it('should not set container height when slide dimensions are invalid', () => {
const plugin = AutoSize();
const parent = createParent();
const children = createTestSlideNodes();
const emblaApi = createEmblaApiInstance({
containerNode: parent,
selectedScrollSnap: 0,
slideNodes: children,
axis: 'x',
// 0th scroll snap shows the 0th slide only.
slideRegistry: [[0]],
});
plugin.init(emblaApi, createTestEmblaOptionHandler());
children[0].getBoundingClientRect = vi.fn().mockReturnValue(NaN);
callEmblaHandler(emblaApi, 'settle');
children[0].getBoundingClientRect = vi.fn().mockReturnValue(0);
callEmblaHandler(emblaApi, 'settle');
expect(parent.style.maxHeight).toBeFalsy();
});
});
@@ -0,0 +1,59 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { EmblaReInitController } from '../../../src/utils/embla/reinit-controller';
import { requestAnimationFrameMock } from '../../test-utils';
import { callEmblaHandler, createEmblaApiInstance } from './test-utils';
vi.mock('lodash-es/debounce', () => ({
default: vi.fn((fn) => fn),
}));
// @vitest-environment jsdom
describe('EmblaReInitController', () => {
beforeAll(() => {
window.requestAnimationFrame = requestAnimationFrameMock;
});
beforeEach(() => {
vi.clearAllMocks();
});
it('should construct', () => {
const emblaApi = createEmblaApiInstance();
new EmblaReInitController(emblaApi);
expect(emblaApi.on).toBeCalledWith('scroll', expect.anything());
expect(emblaApi.on).toBeCalledWith('settle', expect.anything());
expect(emblaApi.on).toBeCalledWith('destroy', expect.anything());
});
it('should destroy', () => {
const emblaApi = createEmblaApiInstance();
const controller = new EmblaReInitController(emblaApi);
controller.destroy();
expect(emblaApi.off).toBeCalledWith('scroll', expect.anything());
expect(emblaApi.off).toBeCalledWith('settle', expect.anything());
expect(emblaApi.off).toBeCalledWith('destroy', expect.anything());
});
it('should reinit when not scrolling', () => {
const emblaApi = createEmblaApiInstance();
const controller = new EmblaReInitController(emblaApi);
controller.reinit();
expect(emblaApi.reInit).toBeCalled();
});
it('should carefully reinit when scrolling', () => {
const emblaApi = createEmblaApiInstance();
const controller = new EmblaReInitController(emblaApi);
callEmblaHandler(emblaApi, 'scroll');
controller.reinit();
expect(emblaApi.reInit).not.toBeCalled();
callEmblaHandler(emblaApi, 'settle');
expect(emblaApi.reInit).toBeCalled();
});
});
+132
View File
@@ -0,0 +1,132 @@
import { EmblaCarouselType, EmblaEventType } from 'embla-carousel';
import { EngineType } from 'embla-carousel/components/Engine';
import { LooseOptionsType } from 'embla-carousel/components/Options';
import { OptionsHandlerType } from 'embla-carousel/components/OptionsHandler';
import merge from 'lodash-es/merge';
import { vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
export const createTestEmblaOptionHandler = (): OptionsHandlerType => ({
mergeOptions: <TypeA extends LooseOptionsType, TypeB extends LooseOptionsType>(
optionsA: TypeA,
optionsB?: TypeB,
): TypeA => {
return merge({}, optionsA, optionsB);
},
optionsAtMedia: <Type extends LooseOptionsType>(options: Type): Type => {
return options;
},
optionsMediaQueries: (_optionsList: LooseOptionsType[]): MediaQueryList[] => [],
});
export const callEmblaHandler = (
emblaApi: EmblaCarouselType | null,
eventName: EmblaEventType,
): void => {
if (!emblaApi) {
return;
}
const mock = vi.mocked(emblaApi.on).mock;
for (const [evt, cb] of mock.calls) {
if (evt === eventName) {
cb(emblaApi, evt);
}
}
};
export const callVisibilityHandler = (): void => {
const mock = vi.mocked(global.document.addEventListener).mock;
for (const [evt, cb] of mock.calls) {
if (evt === 'visibilitychange' && typeof cb === 'function') {
cb(new Event('foo'));
}
}
};
export const callIntersectionHandler = (intersecting = true, n = 0): void => {
const mockResult = vi.mocked(IntersectionObserver).mock.results[n];
if (mockResult.type !== 'return') {
return;
}
const observer = mockResult.value;
vi.mocked(IntersectionObserver).mock.calls[n][0](
// Note this is a very incomplete / invalid IntersectionObserverEntry that
// just provides the bare basics current implementation uses.
intersecting ? [{ isIntersecting: true } as IntersectionObserverEntry] : [],
observer,
);
};
export const callMutationHandler = (n = 0): void => {
const mockResult = vi.mocked(MutationObserver).mock.results[n];
if (mockResult.type !== 'return') {
return;
}
const observer = mockResult.value;
vi.mocked(MutationObserver).mock.calls[n][0](
// Note this is a very incomplete / invalid IntersectionObserverEntry that
// just provides the bare basics current implementation uses.
[],
observer,
);
};
export const callResizeHandler = (
entries: {
target: HTMLElement;
width: number;
height: number;
}[],
n = 0,
): void => {
const mockResult = vi.mocked(ResizeObserver).mock.results[n];
if (mockResult.type !== 'return') {
return;
}
const observer = mockResult.value;
vi.mocked(ResizeObserver).mock.calls[n][0](
// Note this is a very incomplete / invalid ResizeObserverEntry that
// just provides the bare basics current implementation uses.
entries.map(
(entry) =>
({
target: entry.target,
contentRect: {
height: entry.height,
width: entry.width,
},
} as unknown as ResizeObserverEntry),
),
observer,
);
};
export const createEmblaApiInstance = (options?: {
slideNodes?: HTMLElement[];
selectedScrollSnap?: number;
previousScrollSnap?: number;
containerNode?: HTMLElement;
axis?: 'x' | 'y';
slideRegistry?: number[][];
}): EmblaCarouselType => {
const emblaApi = mock<EmblaCarouselType>();
emblaApi.slideNodes.mockReturnValue(options?.slideNodes ?? createTestSlideNodes());
emblaApi.selectedScrollSnap.mockReturnValue(options?.selectedScrollSnap ?? 0);
emblaApi.previousScrollSnap.mockReturnValue(options?.previousScrollSnap ?? 0);
emblaApi.containerNode.mockReturnValue(
options?.containerNode ?? document.createElement('div'),
);
emblaApi.internalEngine.mockReturnValue({
options: { axis: options?.axis ?? 'x' },
...(options?.slideRegistry && { slideRegistry: options.slideRegistry }),
} as EngineType);
return emblaApi;
};
export const createTestSlideNodes = (options?: {
n?: number;
}): HTMLElement[] => {
return [...Array(options?.n ?? 10).keys()].map((_) =>
document.createElement('div'),
);
};