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
+23 -5
View File
@@ -59,7 +59,7 @@ describe('RecordingSegmentsCache', () => {
start: now,
end: add(now, { hours: 1 }),
};
const badRange = { start: sub(now, { hours: 1 }), end: now };
const pastRange = { start: sub(now, { hours: 1 }), end: now };
const createSegment = (date: Date, id: string): RecordingSegment => {
return {
start_time: date.getTime() / 1000,
@@ -89,7 +89,7 @@ describe('RecordingSegmentsCache', () => {
});
it('should not get for other range', () => {
cache.add('camera-1', range, segments);
expect(cache.get('camera-1', badRange)).toBeNull();
expect(cache.get('camera-1', pastRange)).toBeNull();
});
it('should have coverage when added', () => {
@@ -102,7 +102,7 @@ describe('RecordingSegmentsCache', () => {
});
it('should not have coverage for other range', () => {
cache.add('camera-1', range, segments);
expect(cache.hasCoverage('camera-1', badRange)).toBeFalsy();
expect(cache.hasCoverage('camera-1', pastRange)).toBeFalsy();
});
it('should be empty when cleared', () => {
@@ -114,11 +114,11 @@ describe('RecordingSegmentsCache', () => {
it('should get size', () => {
cache.add('camera-1', range, segments);
expect(cache.getSize("camera-1")).toBe(3);
expect(cache.getSize('camera-1')).toBe(3);
});
it('should not size for other camera', () => {
cache.add('camera-1', range, segments);
expect(cache.getSize("camera-2")).toBeNull();
expect(cache.getSize('camera-2')).toBeNull();
});
it('should return cameraIDs', () => {
@@ -127,6 +127,24 @@ describe('RecordingSegmentsCache', () => {
expect(sortBy(cache.getCameraIDs())).toEqual(sortBy(['camera-1', 'camera-2']));
});
it('should add segments to existing range', () => {
cache.add('camera-1', range, segments);
cache.add('camera-1', range, [
createSegment(add(now, { seconds: 15 }), 'segment-2.5'),
]);
expect(cache.get('camera-1', range)?.length).toBe(4);
});
it('should not get segments that are outside range', () => {
cache.add('camera-1', range, segments);
expect(cache.get('camera-1', range)?.length).toBe(3);
// Add a segment before and after the desired range.
cache.add('camera-1', range, [
createSegment(sub(now, { seconds: 15 }), 'segment-0'),
createSegment(add(range.end, { seconds: 10 }), 'segment-4'),
]);
expect(cache.get('camera-1', range)?.length).toBe(3);
});
it('should remove expired matches', () => {
cache.add('camera-1', range, segments);
cache.expireMatches('camera-1', (segment) => segment === segments[0]);
@@ -100,6 +100,11 @@ describe('CameraManagerEngineFactory.getEngineForCamera()', () => {
Engine.Frigate,
);
});
it('should get no engine from config with insufficient details', async () => {
const config = createCameraConfig({});
expect(await createFactory().getEngineForCamera(createHASS(), config)).toBeNull();
});
it('should throw error on invalid entity', async () => {
const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' });
const entityRegistryManager = new EntityRegistryManager(new EntityCache());
+5
View File
@@ -135,6 +135,7 @@ describe('compressRanges', () => {
compressRanges([
{ start: now, end: nowPlusOne },
{ start: nowPlusOne, end: nowPlusTwo },
{ start: now, end: nowPlusOne },
]),
).toEqual([{ start: now, end: nowPlusTwo }]);
});
@@ -162,4 +163,8 @@ describe('compressRanges', () => {
];
expect(compressRanges(input)).toEqual([{ start: 1, end: 3 }]);
});
it('should return nothing with no input', () => {
expect(compressRanges([])).toEqual([]);
});
});
+7
View File
@@ -99,6 +99,13 @@ describe('getOverriddenConfig', () => {
},
});
});
it('should do nothing without overrides', () => {
const controller = new ConditionController();
controller.setState({ fullscreen: true });
expect(getOverriddenConfig(controller, config)).toBe(config);
});
});
describe('getOverridesByKey', () => {
+47 -11
View File
@@ -239,17 +239,53 @@ export class TestViewMedia extends ViewMedia {
}
}
export const createResizeObserverImplementation = (): (() => void) => {
return () => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
});
export const ResizeObserverMock = vi.fn(() => ({
disconnect: vi.fn(),
observe: vi.fn(),
unobserve: vi.fn(),
}));
export const IntersectionObserverMock = vi.fn(() => ({
disconnect: vi.fn(),
observe: vi.fn(),
unobserve: vi.fn(),
}));
export const MutationObserverMock = vi.fn(() => ({
disconnect: vi.fn(),
observe: vi.fn(),
unobserve: vi.fn(),
}));
export const requestAnimationFrameMock = (callback: FrameRequestCallback) => {
callback(new Date().getTime());
return 1;
};
export const createMutationObserverImplementation = (): (() => void) => {
return () => ({
observe: vi.fn(),
disconnect: vi.fn(),
});
export const createSlotHost = (options?: {
slot?: HTMLSlotElement;
children?: HTMLElement[];
}): HTMLElement => {
const parent = document.createElement('div');
parent.attachShadow({ mode: 'open' });
if (options?.slot) {
parent.shadowRoot?.append(options.slot);
}
if (options?.children) {
// Children will automatically be slotted into the default slot when it is
// created.
parent.append(...options.children);
}
return parent;
};
export const createSlot = (): HTMLSlotElement => {
return document.createElement('slot');
};
export const createParent = (options?: { children?: HTMLElement[] }): HTMLElement => {
const parent = document.createElement('div');
parent.append(...(options?.children ?? []));
return parent;
};
+28 -5
View File
@@ -1,27 +1,29 @@
import { describe, it, expect, vi, afterAll } from 'vitest';
import { afterAll, describe, expect, it, vi } from 'vitest';
import { FrigateCardError } from '../../src/types';
import {
allPromises,
arrayify,
arrayMove,
arrayify,
contentsChanged,
dayToDate,
dispatchFrigateCardEvent,
errorToConsole,
isTruthy,
formatDate,
formatDateAndTime,
getChildrenFromElement,
getDurationString,
isHTMLElement,
isHoverableDevice,
isSuperset,
isTruthy,
isValidDate,
prettifyTitle,
runWhenIdleIfSupported,
setify,
setOrRemoveAttribute,
setify,
sleep,
isHTMLElement,
} from '../../src/utils/basic';
import { createSlot, createSlotHost } from '../test-utils';
// @vitest-environment jsdom
describe('dispatchFrigateCardEvent', () => {
@@ -183,6 +185,11 @@ describe('getDurationString', () => {
const end = new Date(2023, 3, 14, 15, 37, 20);
expect(getDurationString(start, end)).toBe('2h 2m 20s');
});
it('should return very short duration', () => {
const start = new Date(2023, 3, 14, 13, 35, 10);
const end = new Date(2023, 3, 14, 13, 35, 12);
expect(getDurationString(start, end)).toBe('2s');
});
});
describe('allPromises', () => {
@@ -268,3 +275,19 @@ describe('isHTMLElement', () => {
expect(isHTMLElement(svgElement)).toBeFalsy();
});
});
describe('getChildrenFromElement', () => {
it('should return children for simple parent', () => {
const children = [document.createElement('div'), document.createElement('div')];
const parent = document.createElement('div');
children.forEach((child) => parent.appendChild(child));
expect(getChildrenFromElement(parent)).toEqual(children);
});
it('should return children for slot', () => {
const children = [document.createElement('div'), document.createElement('div')];
const slot = createSlot();
createSlotHost({ slot: slot, children: children });
expect(getChildrenFromElement(slot)).toEqual(children);
});
});
+61 -36
View File
@@ -12,13 +12,66 @@ vi.mock('../../src/utils/ha');
const media = new ViewMedia('clip', 'camera-1');
// @vitest-environment jsdom
describe('downloadURL', () => {
afterEach(() => {
vi.restoreAllMocks();
global.window.location = mock<Location>();
});
it('should download same origin via link', () => {
const location: Location & { origin: string } = mock<Location>();
location.origin = 'http://foo';
global.window.location = location;
const link = document.createElement('a');
link.click = vi.fn();
link.setAttribute = vi.fn();
vi.spyOn(document, 'createElement').mockReturnValue(link);
downloadURL('http://foo/url.mp4');
expect(link.href).toBe('http://foo/url.mp4');
expect(link.setAttribute).toBeCalledWith('download', 'download');
expect(link.click).toBeCalled();
});
it('should download data URL via link', () => {
const link = document.createElement('a');
link.click = vi.fn();
link.setAttribute = vi.fn();
vi.spyOn(document, 'createElement').mockReturnValue(link);
downloadURL('data:text/plain;charset=utf-8;base64,VEhJUyBJUyBEQVRB');
expect(link.href).toBe('data:text/plain;charset=utf-8;base64,VEhJUyBJUyBEQVRB');
expect(link.setAttribute).toBeCalledWith('download', 'download');
expect(link.click).toBeCalled();
});
it('should download in apps via window.open', () => {
// Set the origin to the same.
const location: Location & { origin: string } = mock<Location>();
location.origin = 'http://foo';
global.window.location = location;
vi.stubGlobal('navigator', {
userAgent: 'Home Assistant/2023.3.0-3260 (Android 13; Pixel 7 Pro)',
});
const windowSpy = vi.spyOn(window, 'open').mockReturnValue(null);
downloadURL('http://foo/url.mp4');
expect(windowSpy).toBeCalledWith('http://foo/url.mp4', '_blank');
});
});
describe('downloadMedia', () => {
afterEach(() => {
vi.resetAllMocks();
global.window.location = mock<Location>();
});
it('should throw error when no media', async () => {
it('should throw error when no media', () => {
const cameraManager = createCameraManager();
mock<CameraManager>(cameraManager).getMediaDownloadPath.mockResolvedValue(null);
@@ -55,44 +108,16 @@ describe('downloadMedia', () => {
await downloadMedia(createHASS(), cameraManager, media);
expect(windowSpy).toBeCalledWith('http://foo/signed-url', '_blank');
});
});
describe('downloadURL', () => {
afterEach(() => {
vi.resetAllMocks();
global.window.location = mock<Location>();
});
it('should download same origin via link', async () => {
const location: Location & { origin: string } = mock<Location>();
location.origin = 'http://foo';
global.window.location = location;
const link = document.createElement('a');
link.click = vi.fn();
link.setAttribute = vi.fn();
vi.spyOn(document, 'createElement').mockReturnValue(link);
downloadURL('http://foo/url.mp4');
expect(link.href).toBe('http://foo/url.mp4');
expect(link.setAttribute).toBeCalledWith('download', 'download');
expect(link.click).toBeCalled();
});
it('should download in apps via window.open', async () => {
// Set the origin to the same.
const location: Location & { origin: string } = mock<Location>();
location.origin = 'http://foo';
global.window.location = location;
vi.stubGlobal('navigator', {
userAgent: 'Home Assistant/2023.3.0-3260 (Android 13; Pixel 7 Pro)',
it('should download media without signing', async () => {
const cameraManager = createCameraManager();
mock<CameraManager>(cameraManager).getMediaDownloadPath.mockResolvedValue({
sign: false,
endpoint: 'https://foo/',
});
const windowSpy = vi.spyOn(window, 'open').mockReturnValue(null);
downloadURL('http://foo/url.mp4');
expect(windowSpy).toBeCalledWith('http://foo/url.mp4', '_blank');
await downloadMedia(createHASS(), cameraManager, media);
expect(windowSpy).toBeCalledWith('https://foo/', '_blank');
});
});
@@ -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'),
);
};
+159 -91
View File
@@ -8,8 +8,10 @@ import {
} from '../../src/utils/media-grid-controller';
import { dispatchExistingMediaLoadedInfoAsEvent } from '../../src/utils/media-info';
import {
createMutationObserverImplementation,
createResizeObserverImplementation,
MutationObserverMock,
ResizeObserverMock,
createSlot,
createSlotHost,
} from '../test-utils';
vi.mock('lodash-es/throttle', () => ({
@@ -41,7 +43,7 @@ const setElementWidth = (element: HTMLElement, width: number): void => {
});
};
const createHost = (options?: {
const createParent = (options?: {
children?: HTMLElement[];
width?: number;
}): HTMLElement => {
@@ -54,28 +56,6 @@ const createHost = (options?: {
return host;
};
const createSlotParent = (): HTMLElement => {
const parent = document.createElement('div');
parent.attachShadow({ mode: 'open' });
return parent;
};
const createSlotHost = (options?: {
children?: HTMLElement[];
parent?: HTMLElement;
}): HTMLSlotElement => {
const parent = options?.parent ?? createSlotParent();
const slot = document.createElement('slot');
parent.shadowRoot?.append(slot);
if (options?.children) {
// Children will automatically be slotted into the default slot.
parent.append(...options.children);
}
return slot;
};
const createController = (host: HTMLElement, options?: MediaGridConstructorOptions) => {
return new MediaGridController(host, options);
};
@@ -101,30 +81,20 @@ describe('MediaGridController', () => {
beforeEach(() => {
vi.clearAllMocks();
global.ResizeObserver = vi
.fn()
// Caution: Order must match the order of initialization in
// media-grid-controller.ts .
.mockImplementationOnce(createResizeObserverImplementation())
.mockImplementationOnce(createResizeObserverImplementation());
global.MutationObserver = vi
.fn()
.mockImplementation(createMutationObserverImplementation());
//global.MutationObserver = mock<MutationObserver>();
vi.stubGlobal('MutationObserver', MutationObserverMock);
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
});
it('should be constructable', () => {
const controller = createController(createHost());
const controller = createController(createParent());
expect(controller).toBeTruthy();
expect(masonry.layout).toBeCalled();
});
it('should set grid contents correctly from regular elements', () => {
const children = createChildren();
const host = createHost({ children: children });
const controller = createController(host);
const parent = createParent({ children: children });
const controller = createController(parent);
expect(controller.getGridContents()).toEqual(
new Map([
['0', children[0]],
@@ -138,7 +108,8 @@ describe('MediaGridController', () => {
it('should set grid contents correctly from slotted elements', () => {
const children = createChildren();
const host = createSlotHost({ children: children });
const slot = createSlot();
const host = createSlotHost({ slot: slot, children: children });
const controller = createController(host);
expect(controller.getGridContents()).toEqual(
new Map([
@@ -152,7 +123,10 @@ describe('MediaGridController', () => {
it('should select element', () => {
const children = createChildren();
const controller = createController(createSlotHost({ children: children }));
const slot = createSlot();
createSlotHost({ slot: slot, children: children });
const controller = createController(slot);
// All children should be unselected.
expect(controller.getSelected()).toBeNull();
@@ -176,7 +150,10 @@ describe('MediaGridController', () => {
});
it('should re-select element', () => {
const controller = createController(createSlotHost({ children: createChildren() }));
const children = createChildren();
const slot = createSlot();
createSlotHost({ slot: slot, children: children });
const controller = createController(slot);
// All children should be unselected.
expect(controller.getSelected()).toBeNull();
@@ -190,16 +167,14 @@ describe('MediaGridController', () => {
it('should dispatch media loaded info on selection', () => {
const children = createChildren();
const host = createSlotHost({ children: children });
const controller = createController(host);
const slot = createSlot();
const host = createSlotHost({ slot: slot, children: children });
const controller = createController(slot);
const mediaLoadedInfoHandler = vi.fn();
host.addEventListener('frigate-card:media:loaded', mediaLoadedInfoHandler);
dispatchExistingMediaLoadedInfoAsEvent(children[0], mediaLoadedInfo);
// Nothing is selected, so the event should not have propagated.
expect(mediaLoadedInfoHandler).not.toBeCalled();
controller.selectCell('0');
expect(mediaLoadedInfoHandler).toBeCalledWith(
expect.objectContaining({
@@ -208,9 +183,45 @@ describe('MediaGridController', () => {
);
});
it('should dispatch media loaded info when cell is selected', () => {
const children = createChildren();
const slot = createSlot();
const host = createSlotHost({ slot: slot, children: children });
const controller = createController(slot);
controller.selectCell('0');
const mediaLoadedInfoHandler = vi.fn();
host.addEventListener('frigate-card:media:loaded', mediaLoadedInfoHandler);
dispatchExistingMediaLoadedInfoAsEvent(children[0], mediaLoadedInfo);
expect(mediaLoadedInfoHandler).toBeCalledWith(
expect.objectContaining({
detail: mediaLoadedInfo,
}),
);
});
it('should not dispatch media loaded info when cell is not selected', () => {
const children = createChildren();
const slot = createSlot();
const host = createSlotHost({ slot: slot, children: children });
const controller = createController(host);
controller.selectCell('1');
const mediaLoadedInfoHandler = vi.fn();
host.addEventListener('frigate-card:media:loaded', mediaLoadedInfoHandler);
dispatchExistingMediaLoadedInfoAsEvent(children[0], mediaLoadedInfo);
// Another element is selected, so the event should not have propagated.
expect(mediaLoadedInfoHandler).not.toBeCalled();
});
it('should unselect', () => {
const children = createChildren();
const host = createSlotHost({ children: children });
const slot = createSlot();
const host = createSlotHost({ slot: slot, children: children });
const controller = createController(host);
const unselectedHandler = vi.fn();
@@ -234,20 +245,29 @@ describe('MediaGridController', () => {
}
// Expect handlers to have been called.
expect(unselectedHandler).toBeCalled();
expect(unloadMediaHandler).toBeCalled();
expect(unselectedHandler).toBeCalledTimes(1);
expect(unloadMediaHandler).toBeCalledTimes(1);
// Unselecting a second time should do nothing.
controller.unselectAll();
expect(unselectedHandler).toBeCalledTimes(1);
expect(unloadMediaHandler).toBeCalledTimes(1);
});
it('should select in constructor', () => {
const children = createChildren();
const host = createSlotHost({ children: children });
const slot = createSlot();
const host = createSlotHost({ slot: slot, children: children });
const controller = createController(host, { selected: '2' });
expect(controller.getSelected()).toBe('2');
});
it('should respect grid attribute option', () => {
const children = createChildren(['one', 'two', 'three'], 'test-id');
const host = createSlotHost({ children: children });
const slot = createSlot();
const host = createSlotHost({ slot: slot, children: children });
const controller = createController(host, { idAttribute: 'test-id' });
expect(controller.getGridContents()).toEqual(
new Map([
@@ -258,10 +278,21 @@ describe('MediaGridController', () => {
);
});
it('should destroy', () => {
it('should destroy with regular elements', () => {
const children = createChildren();
const host = createSlotHost({ children: children });
const controller = createController(host);
const parent = createParent({ children: children });
const controller = createController(parent);
expect(controller.getGridSize()).toBe(3);
controller.destroy();
expect(controller.getGridSize()).toBe(0);
});
it('should destroy with slotted elements', () => {
const children = createChildren();
const slot = createSlot();
createSlotHost({ slot: slot, children: children });
const controller = createController(slot);
expect(controller.getGridSize()).toBe(3);
controller.destroy();
expect(controller.getGridSize()).toBe(0);
@@ -269,16 +300,16 @@ describe('MediaGridController', () => {
it('should replace children when they change', () => {
const children = createChildren();
const host = createHost({ children: children });
const controller = createController(host, { selected: '1' });
const parent = createParent({ children: children });
const controller = createController(parent, { selected: '1' });
dispatchExistingMediaLoadedInfoAsEvent(children[0], mediaLoadedInfo);
expect(controller.getSelected()).toBe('1');
expect(controller.getGridSize()).toBe(3);
children.forEach((child) => host.removeChild(child));
children.forEach((child) => parent.removeChild(child));
const newChildren = createChildren(['one', 'two', 'three']);
newChildren.forEach((child) => host.appendChild(child));
newChildren.forEach((child) => parent.appendChild(child));
triggerMutationObserver();
@@ -294,19 +325,19 @@ describe('MediaGridController', () => {
it('should replace children of a slot when they change', () => {
const children = createChildren();
const slotParent = createSlotParent();
const host = createSlotHost({ children: children, parent: slotParent });
const slot = createSlot();
const host = createSlotHost({ slot: slot, children: children });
const controller = createController(host, { selected: '1' });
const controller = createController(slot, { selected: '1' });
expect(controller.getSelected()).toBe('1');
expect(controller.getGridSize()).toBe(3);
children.forEach((child) => slotParent.removeChild(child));
children.forEach((child) => host.removeChild(child));
const newChildren = createChildren(['one', 'two', 'three']);
newChildren.forEach((child) => slotParent.append(child));
newChildren.forEach((child) => host.append(child));
host.dispatchEvent(new Event('slotchange'));
slot.dispatchEvent(new Event('slotchange'));
expect(controller.getGridContents()).toEqual(
new Map([
@@ -320,10 +351,10 @@ describe('MediaGridController', () => {
it('should construct masonry correctly', () => {
const children = createChildren();
const host = createHost({ children: children });
createController(host);
const parent = createParent({ children: children });
createController(parent);
expect(Masonry).toBeCalledWith(
host,
parent,
expect.objectContaining({
initLayout: false,
percentPosition: true,
@@ -333,57 +364,85 @@ describe('MediaGridController', () => {
});
it('should set default column size correctly', () => {
const host = createHost({ children: createChildren() });
createController(host);
const parent = createParent({ children: createChildren() });
createController(parent);
expect(Masonry).toBeCalledWith(
host,
parent,
expect.objectContaining({
columnWidth: 246,
}),
);
expect(host.style.getPropertyValue('--frigate-card-grid-column-size')).toBe('246px');
expect(parent.style.getPropertyValue('--frigate-card-grid-column-size')).toBe('246px');
});
it('should respect exact columns', () => {
const host = createHost({ children: createChildren(), width: 2000 });
const controller = createController(host);
const parent = createParent({ children: createChildren(), width: 2000 });
const controller = createController(parent);
controller.setDisplayConfig({ mode: 'grid', grid_columns: 2 });
// Will have been called once on construction, and then again when the
// number of columns changes.
expect(Masonry).toBeCalledTimes(2);
expect(Masonry).toBeCalledWith(
host,
parent,
expect.objectContaining({
columnWidth: 1000,
}),
);
expect(host.style.getPropertyValue('--frigate-card-grid-column-size')).toBe(
expect(parent.style.getPropertyValue('--frigate-card-grid-column-size')).toBe(
'1000px',
);
});
it('should respect selected width factor', () => {
const host = createHost({ children: createChildren(), width: 2000 });
const controller = createController(host);
const parent = createParent({ children: createChildren(), width: 2000 });
const controller = createController(parent);
controller.setDisplayConfig({ mode: 'grid', grid_selected_width_factor: 3 });
expect(
host.style.getPropertyValue('--frigate-card-grid-selected-width-factor'),
parent.style.getPropertyValue('--frigate-card-grid-selected-width-factor'),
).toBe('3');
// Setting the same config again should do nothing.
controller.setDisplayConfig({ mode: 'grid', grid_selected_width_factor: 3 });
expect(
parent.style.getPropertyValue('--frigate-card-grid-selected-width-factor'),
).toBe('3');
});
it('should select cell with interacted with', () => {
it('should select cell when interacted with', () => {
const children = createChildren();
const host = createHost({ children: children, width: 2000 });
const controller = createController(host);
const parent = createParent({ children: children, width: 2000 });
const controller = createController(parent);
expect(controller.getSelected()).toBeNull();
const clickHandler = vi.fn();
parent.addEventListener('click', clickHandler);
children[1].click();
// Click will not be allowed through.
expect(clickHandler).not.toBeCalled();
expect(controller.getSelected()).toBe('1');
});
it('should ignore interaction events on already selected cell', () => {
const children = createChildren();
const parent = createParent({ children: children, width: 2000 });
const controller = createController(parent);
controller.selectCell('1');
const clickHandler = vi.fn();
parent.addEventListener('click', clickHandler);
children[1].click();
// Click will be allowed through.
expect(clickHandler).toBeCalled();
expect(controller.getSelected()).toBe('1');
});
it('should re-layout when child size changes', () => {
createController(createHost({ children: createChildren() }));
createController(createParent({ children: createChildren() }));
vi.mocked(masonry.layout)?.mockClear();
triggerResizeObserver('cell');
@@ -392,32 +451,41 @@ describe('MediaGridController', () => {
it('should re-create masonry when host size changes', () => {
const children = createChildren();
const host = createHost({ children: children });
const controller = createController(host);
const parent = createParent({ children: children });
createController(parent);
expect(Masonry).toBeCalledWith(
host,
parent,
expect.objectContaining({
columnWidth: 246,
}),
);
expect(host.style.getPropertyValue('--frigate-card-grid-column-size')).toBe('246px');
expect(parent.style.getPropertyValue('--frigate-card-grid-column-size')).toBe('246px');
// Clear mock state.
vi.mocked(Masonry).mockClear();
vi.mocked(masonry.layout)?.mockClear();
// Resize the host.
setElementWidth(host, 2000);
setElementWidth(parent, 2000);
triggerResizeObserver('host');
// Masonry should be reconstructed, styles set and layout called.
expect(Masonry).toBeCalledWith(
host,
parent,
expect.objectContaining({
columnWidth: 667,
}),
);
expect(host.style.getPropertyValue('--frigate-card-grid-column-size')).toBe('667px');
expect(parent.style.getPropertyValue('--frigate-card-grid-column-size')).toBe('667px');
expect(masonry.layout).toBeCalled();
// Clear mock state.
vi.mocked(Masonry).mockClear();
vi.mocked(masonry.layout)?.mockClear();
// Triger with the same sizes.
triggerResizeObserver('host');
expect(Masonry).not.toBeCalled();
expect(masonry.layout).not.toBeCalled();
});
});
+10
View File
@@ -108,6 +108,16 @@ describe('playMediaMutingIfNecessary', () => {
expect(player.isMuted).toBeCalled();
expect(player.mute).toBeCalled();
});
it('should ignore calls without a video', async () => {
const player = mock<FrigateCardMediaPlayer>();
player.isMuted.mockReturnValue(false);
await playMediaMutingIfNecessary(player);
expect(player.isMuted).not.toBeCalled();
expect(player.mute).not.toBeCalled();
});
});
describe('constants', () => {
+32 -1
View File
@@ -1,4 +1,5 @@
import { HomeAssistant } from 'custom-card-helpers';
import isEqual from 'lodash-es/isEqual';
import screenfull from 'screenfull';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
@@ -160,6 +161,26 @@ describe('MenuButtonController', () => {
});
});
it('should not have a cameras menu without a visible camera', () => {
const cameraManager = createCameraManager({
configs: new Map([
['camera-1', createCameraConfig()],
['camera-2', createCameraConfig()],
]),
});
vi.mocked(cameraManager.getStore()).getVisibleCameras.mockReturnValue(new Map());
const buttons = calculateButtons(controller, { cameraManager: cameraManager });
expect(buttons).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
title: 'Cameras',
}),
]),
);
});
it('should have substream button with single dependency', () => {
const cameraManager = createCameraManager({
configs: new Map([
@@ -1122,7 +1143,17 @@ describe('MenuButtonController', () => {
style: {},
};
controller.addDynamicMenuButton(button);
expect(calculateButtons(controller)).toContainEqual(button);
expect(
calculateButtons(controller).filter((menuButton) => isEqual(button, menuButton))
.length,
).toBe(1);
// Adding it again will have no effect.
controller.addDynamicMenuButton(button);
expect(
calculateButtons(controller).filter((menuButton) => isEqual(button, menuButton))
.length,
).toBe(1);
controller.removeDynamicMenuButton(button);
expect(calculateButtons(controller)).not.toContainEqual(button);
+18 -4
View File
@@ -52,6 +52,20 @@ describe('createViewWithoutSubstream', () => {
const newView = createViewWithoutSubstream(view);
expect(newView?.context?.live?.overrides).toEqual(new Map());
});
it('should create view with overrides untouched', () => {
const view = new View({
view: 'live',
camera: 'camera-1',
context: {
live: {
overrides: new Map([['camera-2', 'camera-3']]),
},
},
});
const newView = createViewWithoutSubstream(view);
expect(newView?.context?.live?.overrides).toEqual(view.context?.live?.overrides);
});
});
describe('hasSubstream', () => {
@@ -95,7 +109,7 @@ describe('createViewWithNextStream', () => {
camera: 'camera',
});
vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera']));
const cameraManager = createCameraManager()
const cameraManager = createCameraManager();
const newView = createViewWithNextStream(cameraManager, view);
expect(newView.camera).toBe(view.camera);
expect(newView.view).toBe(view.view);
@@ -107,7 +121,7 @@ describe('createViewWithNextStream', () => {
camera: 'camera',
});
vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2']));
const cameraManager = createCameraManager()
const cameraManager = createCameraManager();
const newView = createViewWithNextStream(cameraManager, view);
expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera2']]));
});
@@ -122,7 +136,7 @@ describe('createViewWithNextStream', () => {
},
});
vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2']));
const cameraManager = createCameraManager()
const cameraManager = createCameraManager();
const newView = createViewWithNextStream(cameraManager, view);
expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera']]));
});
@@ -137,7 +151,7 @@ describe('createViewWithNextStream', () => {
},
});
vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2']));
const cameraManager = createCameraManager()
const cameraManager = createCameraManager();
const newView = createViewWithNextStream(cameraManager, view);
expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera']]));
});
+7 -4
View File
@@ -1,9 +1,9 @@
import { describe, expect, it } from 'vitest';
import { z } from 'zod';
import { z, ZodError } from 'zod';
import {
deepRemoveDefaults,
getParseErrorKeys,
getParseErrorPaths,
deepRemoveDefaults,
getParseErrorKeys,
getParseErrorPaths,
} from '../../src/utils/zod';
describe('deepRemoveDefaults', () => {
@@ -88,4 +88,7 @@ describe('getParseErrorPaths', () => {
new Set(['array[0] -> type', 'array[0] -> data']),
);
});
it('should get no paths for empty error', () => {
expect(getParseErrorPaths(new ZodError([]))).toEqual(new Set());
});
});
+67
View File
@@ -92,6 +92,27 @@ describe('Zoom', () => {
expect(panzoom.handleUp).toBeCalledWith(ev_5);
});
it('should not respond to pointer when not zoomed', () => {
const element = document.createElement('div');
const panzoom = createMockPanZoom();
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
createAndRegisterZoom(element);
const ev_1 = new PointerEvent('pointerdown');
element.dispatchEvent(ev_1);
expect(panzoom.handleDown).not.toBeCalledWith(ev_1);
const ev_2 = new PointerEvent('pointermove');
element.dispatchEvent(ev_2);
expect(panzoom.handleDown).not.toBeCalledWith(ev_2);
const ev_3 = new PointerEvent('pointerup');
element.dispatchEvent(ev_3);
expect(panzoom.handleDown).not.toBeCalledWith(ev_3);
});
it('should respond with touch', () => {
mediaMediSpy.mockReturnValue(<MediaQueryList>{ matches: false });
@@ -252,4 +273,50 @@ describe('Zoom', () => {
element.dispatchEvent(ev_2);
expect(element.style.touchAction).toBeFalsy();
});
it('should not fire frigate cards when state has not changed or spurious events received', () => {
const element = document.createElement('div');
const zoomedFunc = vi.fn();
const unzoomedFunc = vi.fn();
element.addEventListener('frigate-card:zoom:zoomed', zoomedFunc);
element.addEventListener('frigate-card:zoom:unzoomed', unzoomedFunc);
vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom());
createAndRegisterZoom(element);
const ev_1 = new CustomEvent<PanzoomEventDetail>('panzoomzoom', {
detail: {
x: 0,
y: 0,
scale: 1,
isSVG: false,
originalEvent: new PointerEvent('pointermove'),
},
});
element.dispatchEvent(ev_1);
// Unzoomed event with scale === 1, this._zoomed will already be false.
expect(unzoomedFunc).not.toBeCalled();
expect(zoomedFunc).not.toBeCalled();
const ev_2 = new CustomEvent<PanzoomEventDetail>('panzoomzoom', {
detail: {
x: 0,
y: 0,
scale: 1.2,
isSVG: false,
originalEvent: new PointerEvent('pointermove'),
},
});
element.dispatchEvent(ev_2);
expect(zoomedFunc).toBeCalledTimes(1);
expect(unzoomedFunc).not.toBeCalled();
// Another call when already zoomed will be ignored.
element.dispatchEvent(ev_2);
expect(zoomedFunc).toBeCalledTimes(1);
});
});
+36
View File
@@ -243,4 +243,40 @@ describe('dispatchViewContextChangeEvent', () => {
.map((media) => media.getID()),
).toEqual(['id-office-99', 'id-kitchen-99', 'id-office-99']);
});
it('should get multiple selected results without main', () => {
const results = new MediaQueriesResults({
results: generateViewMediaArray(),
});
expect(
results
.getMultipleSelectedResults({ main: false, allCameras: true })
.map((media) => media.getID()),
).toEqual(['id-kitchen-99', 'id-office-99']);
});
it('should get no results with invalid camera ID without main', () => {
const results = new MediaQueriesResults({
results: generateViewMediaArray(),
});
expect(
results
.getMultipleSelectedResults({ main: false, cameraID: 'not-a-real-camera' })
.map((media) => media.getID()),
).toEqual([]);
});
it('should not demote main selection when selecting from a specific camera', () => {
const results = new MediaQueriesResults({
results: generateViewMediaArray(),
});
results.selectIndex(42);
results.selectIndex(24, 'office');
expect(results.getSelectedIndex()).toBe(42);
expect(results.getSelectedIndex('office')).toBe(24);
});
});
+93 -4
View File
@@ -4,7 +4,7 @@ import { ViewMedia } from '../../src/view/media';
import { EventMediaQueries, RecordingMediaQueries } from '../../src/view/media-queries';
import { MediaQueriesResults } from '../../src/view/media-queries-results';
import { View, dispatchViewContextChangeEvent } from '../../src/view/view';
import { createView, generateViewMediaArray } from '../test-utils';
import { createView } from '../test-utils';
// @vitest-environment jsdom
describe('View Basics', () => {
@@ -122,6 +122,14 @@ describe('View Basics', () => {
expect(view.context).toEqual({});
});
it('should not remove context when no context', () => {
const view = createView();
expect(view.context).toBeNull();
view.removeContext('live');
expect(view.context).toBeNull();
});
it('should remove context property', () => {
const view = createView({ context: { live: { overrides: new Map() } } });
@@ -129,6 +137,13 @@ describe('View Basics', () => {
expect(view.context).toEqual({ live: {} });
});
it('should not remove context property that does not exist', () => {
const view = createView({ context: {} });
view.removeContextProperty('live', 'overrides');
expect(view.context).toEqual({});
});
it('should detect gallery views', () => {
expect(createView({ view: 'clips' }).isGalleryView()).toBeTruthy();
expect(createView({ view: 'snapshots' }).isGalleryView()).toBeTruthy();
@@ -292,6 +307,28 @@ describe('View.adoptFromViewIfAppropriate', () => {
expect(next.queryResults).toBe(queryResults);
});
it('should not adopt for gallery case if neither query nor results in current view', () => {
const current = createView({
view: 'clip',
query: null,
queryResults: null,
});
const nextQuery = new EventMediaQueries([
{ type: QueryType.Event, cameraIDs: new Set(['camera']), hasClip: true },
]);
const nextResults = new MediaQueriesResults();
const next = createView({
view: 'clips',
query: nextQuery,
queryResults: nextResults,
});
View.adoptFromViewIfAppropriate(next, current);
expect(next.view).toBe('clips');
expect(next.query).toBe(nextQuery);
expect(next.queryResults).toBe(nextResults);
});
it.each([
[
new EventMediaQueries([
@@ -311,7 +348,7 @@ describe('View.adoptFromViewIfAppropriate', () => {
]),
'recording',
],
])('should adopt for media case', (mediaQueries, expectedView) => {
])('should adopt in media case', (mediaQueries, expectedView) => {
const current = createView({
view: 'media',
query: mediaQueries,
@@ -324,6 +361,54 @@ describe('View.adoptFromViewIfAppropriate', () => {
expect(next.queryResults).toBeFalsy();
});
it('should not adopt for mixed queries in media case', () => {
const query = new EventMediaQueries([
{ type: QueryType.Event, cameraIDs: new Set(['camera']), hasClip: true },
{ type: QueryType.Event, cameraIDs: new Set(['camera']), hasSnapshot: true },
]);
const results = new MediaQueriesResults();
const current = createView({
view: 'media',
query: query,
queryResults: results,
});
const next = createView({ view: 'media' });
View.adoptFromViewIfAppropriate(next, current);
expect(next.view).toBe('media');
expect(next.query).toBeNull();
expect(next.queryResults).toBeNull();
});
it('should not adopt when queries and results present in next view in media case', () => {
const currentQuery = new EventMediaQueries([
{ type: QueryType.Event, cameraIDs: new Set(['camera-1']), hasClip: true },
{ type: QueryType.Event, cameraIDs: new Set(['camera-1']), hasSnapshot: true },
]);
const currentResults = new MediaQueriesResults();
const current = createView({
view: 'media',
query: currentQuery,
queryResults: currentResults,
});
const nextQuery = new EventMediaQueries([
{ type: QueryType.Event, cameraIDs: new Set(['camera-2']), hasClip: true },
{ type: QueryType.Event, cameraIDs: new Set(['camera-2']), hasSnapshot: true },
]);
const nextResults = new MediaQueriesResults();
const next = createView({
view: 'media',
query: nextQuery,
queryResults: nextResults,
});
View.adoptFromViewIfAppropriate(next, current);
expect(next.view).toBe('media');
expect(next.query).toBe(nextQuery);
expect(next.queryResults).toBe(nextResults);
});
it('should not adopt for other case', () => {
const query = new EventMediaQueries([
{ type: QueryType.Event, cameraIDs: new Set(['camera']), hasClip: true },
@@ -431,11 +516,15 @@ describe('View.adoptFromViewIfAppropriate', () => {
expect(createView({ view: 'media' }).supportsMultipleDisplayModes()).toBeTruthy();
expect(createView({ view: 'clip' }).supportsMultipleDisplayModes()).toBeTruthy();
expect(createView({ view: 'snapshot' }).supportsMultipleDisplayModes()).toBeTruthy();
expect(createView({ view: 'recording' }).supportsMultipleDisplayModes()).toBeTruthy();
expect(
createView({ view: 'recording' }).supportsMultipleDisplayModes(),
).toBeTruthy();
expect(createView({ view: 'clips' }).supportsMultipleDisplayModes()).toBeFalsy();
expect(createView({ view: 'snapshots' }).supportsMultipleDisplayModes()).toBeFalsy();
expect(createView({ view: 'recordings' }).supportsMultipleDisplayModes()).toBeFalsy();
expect(
createView({ view: 'recordings' }).supportsMultipleDisplayModes(),
).toBeFalsy();
expect(createView({ view: 'image' }).supportsMultipleDisplayModes()).toBeFalsy();
expect(createView({ view: 'timeline' }).supportsMultipleDisplayModes()).toBeFalsy();
});