Complete carousel refactor.
Reduces one layer of DOM nesting for simplication, uses the latest Embla version, unittests for everything.
This commit is contained in:
@@ -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());
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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'),
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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']]));
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user