Add support for pre-defined zoom/pan settings.

This commit is contained in:
Dermot Duffy
2024-04-27 14:55:38 -07:00
parent 246dd7ff54
commit 7917e5c537
59 changed files with 1981 additions and 638 deletions
+3 -10
View File
@@ -259,16 +259,9 @@ describe('CameraManager', async () => {
return new CameraManager(api, { factory: mockFactory });
};
it('should reset', async () => {
const store = new CameraManagerStore();
store.addCamera(
new Camera(createCameraConfig({ id: 'id' }), mock<CameraManagerEngine>()),
);
const manager = new CameraManager(createCardAPI(), { store: store });
expect(manager.getStore().getCameraCount()).toBe(1);
await manager.reset();
expect(manager.getStore().getCameraCount()).toBe(0);
it('should construct', async () => {
const manager = new CameraManager(createCardAPI());
expect(manager.getStore()).toBeTruthy();
});
describe('should initialize cameras from config', () => {
+58 -2
View File
@@ -196,7 +196,6 @@ describe('ActionsManager.handleInteraction', () => {
manager.handleInteractionEvent(
new CustomEvent<Interaction>('event', {
// Malformed interaction type.
detail: { action: 'double_finger_snap' } as unknown as Interaction,
}),
@@ -800,11 +799,68 @@ describe('ActionsManager.executeAction', () => {
})!,
);
expect(api.getViewManager().setViewWithNewContext).toBeCalledWith(
expect(api.getViewManager().setViewWithMergedContext).toBeCalledWith(
expect.objectContaining({ live: { ptzVisible: true } }),
);
});
describe('should handle change_zoom action', () => {
it('default', async () => {
const api = createCardAPI();
const manager = new ActionsManager(api);
await manager.executeFrigateAction(
createAction({
frigate_card_action: 'change_zoom',
target_id: 'camera.office',
})!,
);
expect(api.getViewManager().setViewWithMergedContext).toBeCalledWith(
expect.objectContaining({
zoom: {
'camera.office': {
zoom: {},
},
},
}),
);
});
it('non-default', async () => {
const api = createCardAPI();
const manager = new ActionsManager(api);
await manager.executeFrigateAction(
createAction({
frigate_card_action: 'change_zoom',
target_id: 'camera.office',
zoom: 2,
pan: {
x: 3,
y: 4,
},
})!,
);
expect(api.getViewManager().setViewWithMergedContext).toBeCalledWith(
expect.objectContaining({
zoom: {
'camera.office': {
zoom: {
zoom: 2,
pan: {
x: 3,
y: 4,
},
},
},
},
}),
);
});
});
it('should handle unknown action', async () => {
const manager = new ActionsManager(createCardAPI());
@@ -149,7 +149,6 @@ describe('CardElementManager', () => {
element.removeEventListener = removeEventListener;
const api = createCardAPI();
vi.mocked(api.getCameraManager().reset).mockResolvedValue();
const manager = new CardElementManager(
api,
@@ -186,6 +185,6 @@ describe('CardElementManager', () => {
);
expect(windowRemoveEventListener).toBeCalledWith('popstate', expect.anything());
expect(api.getCameraManager().reset).toBeCalled();
expect(api.getInitializationManager().uninitialize).toBeCalledWith('cameras');
});
});
@@ -1,10 +1,10 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { loadLanguages } from '../../src/localize/localize';
import {
InitializationAspect,
InitializationManager,
} from '../../src/card-controller/initialization-manager';
import { loadLanguages } from '../../src/localize/localize';
import { sideLoadHomeAssistantElements } from '../../src/utils/ha';
import { Initializer } from '../../src/utils/initializer/initializer';
import { createCardAPI, createConfig, createHASS } from '../test-utils';
@@ -184,6 +184,22 @@ describe('InitializationManager', () => {
expect(await manager.initializeMandatory()).toBeFalsy();
});
it('with existing view', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
vi.mocked(api.getViewManager().hasView).mockReturnValue(true);
const initializer = mock<Initializer>();
const manager = new InitializationManager(api, initializer);
initializer.initializeMultipleIfNecessary
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(true);
expect(await manager.initializeMandatory()).toBeTruthy();
expect(api.getCardElementManager().update).toBeCalled();
});
});
describe('should initialize background', () => {
+2 -2
View File
@@ -81,7 +81,7 @@ describe('ViewManager.setView', () => {
const context = { live: { fetchThumbnails: false } };
// Setting context with no existing view does nothing.
manager.setViewWithNewContext(context);
manager.setViewWithMergedContext(context);
expect(manager.getView()).toBeNull();
const view = createView({
@@ -89,7 +89,7 @@ describe('ViewManager.setView', () => {
camera: 'camera',
});
manager.setView(view);
manager.setViewWithNewContext(context);
manager.setViewWithMergedContext(context);
expect(manager.getView()?.camera).toBe('camera');
expect(manager.getView()?.view).toBe('live');
@@ -11,7 +11,12 @@ import {
MenuButtonController,
MenuButtonControllerOptions,
} from '../../src/components-lib/menu-button-controller';
import { FrigateCardConfig, MenuItem, ViewDisplayMode } from '../../src/config/types';
import {
FrigateCardConfig,
FrigateCardView,
MenuItem,
ViewDisplayMode,
} from '../../src/config/types';
import { FrigateCardMediaPlayer } from '../../src/types';
import { createFrigateCardSimpleAction } from '../../src/utils/action';
import { ViewMedia } from '../../src/view/media';
@@ -29,6 +34,7 @@ import {
createStateEntity,
createStore,
createView,
TestViewMedia,
} from '../test-utils';
vi.mock('../../src/utils/media-player-controller.js');
@@ -1487,6 +1493,69 @@ describe('MenuButtonController', () => {
});
});
describe('should have change zoom button', () => {
it.each([
['live' as const, true, false],
['live' as const, undefined, false],
['live' as const, false, true],
['media' as const, true, false],
['media' as const, undefined, false],
['media' as const, false, true],
])(
'%s view with isDefault %s',
(
viewName: FrigateCardView,
isDefault: boolean | undefined,
expectedResult: boolean,
) => {
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getStore).mockReturnValue(
createStore([{ cameraID: 'camera-1' }]),
);
const targetID = viewName === 'live' ? 'camera-1' : 'media-1';
const view = createView({
view: viewName,
camera: 'camera-1',
queryResults: new MediaQueriesResults({
results: [new TestViewMedia({ id: 'media-1' })],
selectedIndex: 0,
}),
context: {
zoom: {
[targetID]: {
isDefault: isDefault,
},
},
},
});
const buttons = calculateButtons(controller, {
cameraManager: cameraManager,
view: view,
});
if (expectedResult) {
expect(buttons).toContainEqual({
enabled: true,
icon: 'mdi:magnify-close',
priority: 50,
tap_action: {
action: 'fire-dom-event',
frigate_card_action: 'change_zoom',
target_id: targetID,
},
title: 'Zoom to default',
type: 'custom:frigate-card-menu-icon',
});
} else {
expect(buttons).not.toContainEqual(
expect.objectContaining({ title: 'Set zoom to default' }),
);
}
},
);
});
describe('should handle dynamic buttons', () => {
it('successfully', () => {
const button: MenuItem = {
@@ -1,321 +0,0 @@
import Panzoom, { PanzoomEventDetail, PanzoomObject } from '@dermotduffy/panzoom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { ZoomController } from '../../src/components-lib/zoom-controller';
vi.mock('@dermotduffy/panzoom');
// https://github.com/jsdom/jsdom/issues/2527
(window as any).PointerEvent = MouseEvent;
// @vitest-environment jsdom
describe('ZoomController', () => {
const mediaMediSpy = vi.spyOn(window, 'matchMedia');
const createMockPanZoom = (): PanzoomObject => {
const panzoom = mock<PanzoomObject>();
panzoom.getScale.mockReturnValue(1.0);
return panzoom;
};
const createAndRegisterZoom = (element: HTMLElement): ZoomController => {
const zoom = new ZoomController(element);
zoom.activate();
return zoom;
};
const createTouch = (target: HTMLElement): Touch => {
return {
clientX: 0,
clientY: 0,
force: 0,
identifier: 0,
pageX: 0,
pageY: 0,
radiusX: 0,
radiusY: 0,
rotationAngle: 0,
screenX: 0,
screenY: 0,
target: target,
};
};
beforeEach(() => {
mediaMediSpy.mockReturnValue(<MediaQueryList>{ matches: true });
});
afterEach(() => {
vi.clearAllMocks();
});
it('should be creatable', () => {
const element = document.createElement('div');
const zoom = new ZoomController(element);
expect(zoom).toBeTruthy();
});
it('should respond with pointer', () => {
const element = document.createElement('div');
const panzoom = createMockPanZoom();
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
createAndRegisterZoom(element);
// Won't zoom without control key.
const ev_1 = new WheelEvent('wheel', { bubbles: false, deltaY: -120 });
element.dispatchEvent(ev_1);
expect(panzoom.zoomWithWheel).not.toBeCalled();
const ev_2 = new WheelEvent('wheel', {
bubbles: false,
deltaY: -120,
ctrlKey: true,
});
element.dispatchEvent(ev_2);
expect(panzoom.zoomWithWheel).toBeCalledWith(ev_2);
panzoom.getScale = vi.fn().mockReturnValue(1.2);
const ev_3 = new PointerEvent('pointerdown');
element.dispatchEvent(ev_3);
expect(panzoom.handleDown).toBeCalledWith(ev_3);
const ev_4 = new PointerEvent('pointermove');
element.dispatchEvent(ev_4);
expect(panzoom.handleMove).toBeCalledWith(ev_4);
const ev_5 = new PointerEvent('pointerup');
element.dispatchEvent(ev_5);
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 });
const element = document.createElement('div');
const panzoom = createMockPanZoom();
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
createAndRegisterZoom(element);
const ev_1 = new TouchEvent('touchstart', {
bubbles: false,
touches: [createTouch(element), createTouch(element)],
});
element.dispatchEvent(ev_1);
expect(panzoom.handleDown).toBeCalledWith(ev_1);
panzoom.getScale = vi.fn().mockReturnValue(1.2);
const ev_3 = new TouchEvent('touchstart');
element.dispatchEvent(ev_3);
expect(panzoom.handleDown).toBeCalledWith(ev_3);
const ev_4 = new TouchEvent('touchmove');
element.dispatchEvent(ev_4);
expect(panzoom.handleMove).toBeCalledWith(ev_4);
const ev_5 = new TouchEvent('touchend');
element.dispatchEvent(ev_5);
expect(panzoom.handleUp).toBeCalledWith(ev_5);
});
it('should ignore click after pointerdown', () => {
const outer = document.createElement('div');
const inner = document.createElement('div');
outer.appendChild(inner);
const clickHandler = vi.fn();
outer.addEventListener('click', clickHandler);
const panzoom = createMockPanZoom();
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
createAndRegisterZoom(inner);
// Simulate being zoomed in.
panzoom.getScale = vi.fn().mockReturnValue(1.2);
// A click on its own will be fine.
const click_1 = new MouseEvent('click', { bubbles: true });
inner.dispatchEvent(click_1);
expect(clickHandler).toBeCalledTimes(1);
// A click after a pointerdown will be ignored.
const pointerdown_1 = new PointerEvent('pointerdown');
inner.dispatchEvent(pointerdown_1);
const click_2 = new MouseEvent('click', { bubbles: true });
inner.dispatchEvent(click_2);
// Click will have been ignored.
expect(clickHandler).toBeCalledTimes(1);
// Simulate being zoomed out.
panzoom.getScale = vi.fn().mockReturnValue(1.0);
const pointerdown_2 = new PointerEvent('pointerdown');
inner.dispatchEvent(pointerdown_2);
const click_3 = new MouseEvent('click', { bubbles: true });
inner.dispatchEvent(click_3);
// Click will have been processed.
expect(clickHandler).toBeCalledTimes(2);
});
it('deactivate should remove event handlers', () => {
const element = document.createElement('div');
const panzoom = createMockPanZoom();
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
createAndRegisterZoom(element).deactivate();
const ev_1 = new WheelEvent('wheel', {
bubbles: false,
deltaY: -120,
ctrlKey: true,
});
element.dispatchEvent(ev_1);
expect(panzoom.zoomWithWheel).not.toBeCalled();
});
it('should fire frigate cards on zoom/unzoom', () => {
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.2,
isSVG: false,
originalEvent: new PointerEvent('pointermove'),
},
});
element.dispatchEvent(ev_1);
expect(zoomedFunc).toBeCalled();
expect(unzoomedFunc).not.toBeCalled();
const ev_2 = new CustomEvent<PanzoomEventDetail>('panzoomzoom', {
detail: {
x: 0,
y: 0,
scale: 1,
isSVG: false,
originalEvent: new PointerEvent('pointermove'),
},
});
element.dispatchEvent(ev_2);
expect(unzoomedFunc).toBeCalled();
});
it('should set touch action on zoom/unzoom', () => {
const element = document.createElement('div');
vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom());
createAndRegisterZoom(element);
const ev_1 = new CustomEvent<PanzoomEventDetail>('panzoomzoom', {
detail: {
x: 0,
y: 0,
scale: 1.2,
isSVG: false,
originalEvent: new PointerEvent('pointermove'),
},
});
element.dispatchEvent(ev_1);
expect(element.style.touchAction).toBe('none');
const ev_2 = new CustomEvent<PanzoomEventDetail>('panzoomzoom', {
detail: {
x: 0,
y: 0,
scale: 1,
isSVG: false,
originalEvent: new PointerEvent('pointermove'),
},
});
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);
});
});
@@ -0,0 +1,658 @@
import Panzoom, { PanzoomEventDetail, PanzoomObject } from '@dermotduffy/panzoom';
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mock, mockClear } from 'vitest-mock-extended';
import { ZoomController } from '../../../src/components-lib/zoom/zoom-controller';
import { ResizeObserverMock, requestAnimationFrameMock } from '../../test-utils';
vi.mock('@dermotduffy/panzoom');
vi.mock('lodash-es/debounce', () => ({
default: vi.fn((fn) => fn),
}));
// https://github.com/jsdom/jsdom/issues/2527
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).PointerEvent = MouseEvent;
const triggerResizeObserver = (): void => {
const resizeObserverTrigger = vi.mocked(global.ResizeObserver).mock.calls[0][0];
resizeObserverTrigger([], mock<ResizeObserver>());
};
const setElementToDefaultCardSize = (element: HTMLElement, multiple?: number): void => {
element.getBoundingClientRect = vi.fn().mockReturnValue({
width: 492 * (multiple ?? 1),
height: 276.75 * (multiple ?? 1),
});
};
// @vitest-environment jsdom
describe('ZoomController', () => {
const mediaSpy = vi.spyOn(window, 'matchMedia');
const createMockPanZoom = (): PanzoomObject => {
const panzoom = mock<PanzoomObject>();
panzoom.getScale.mockReturnValue(1.0);
panzoom.getPan.mockReturnValue({ x: 0, y: 0 });
return panzoom;
};
const createAndRegisterZoom = (element: HTMLElement): ZoomController => {
const zoom = new ZoomController(element);
zoom.activate();
return zoom;
};
const createTouch = (target: HTMLElement): Touch => {
return {
clientX: 0,
clientY: 0,
force: 0,
identifier: 0,
pageX: 0,
pageY: 0,
radiusX: 0,
radiusY: 0,
rotationAngle: 0,
screenX: 0,
screenY: 0,
target: target,
};
};
beforeAll(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
window.requestAnimationFrame = requestAnimationFrameMock;
});
beforeEach(() => {
vi.mocked(Panzoom).mockReset();
vi.mocked(global.ResizeObserver).mockClear();
mediaSpy.mockReturnValue(<MediaQueryList>{ matches: true });
});
it('should be creatable', () => {
const element = document.createElement('div');
const zoom = new ZoomController(element);
expect(zoom).toBeTruthy();
});
describe('should pan and zoom', () => {
it('should respond with pointer', () => {
const element = document.createElement('div');
const panzoom = createMockPanZoom();
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
createAndRegisterZoom(element);
// Won't zoom without control key.
const ev_1 = new WheelEvent('wheel', { bubbles: false, deltaY: -120 });
element.dispatchEvent(ev_1);
expect(panzoom.zoomWithWheel).not.toBeCalled();
const ev_2 = new WheelEvent('wheel', {
bubbles: false,
deltaY: -120,
ctrlKey: true,
});
element.dispatchEvent(ev_2);
expect(panzoom.zoomWithWheel).toBeCalledWith(ev_2);
panzoom.getScale = vi.fn().mockReturnValue(1.2);
const ev_3 = new PointerEvent('pointerdown');
element.dispatchEvent(ev_3);
expect(panzoom.handleDown).toBeCalledWith(ev_3);
const ev_4 = new PointerEvent('pointermove');
element.dispatchEvent(ev_4);
expect(panzoom.handleMove).toBeCalledWith(ev_4);
const ev_5 = new PointerEvent('pointerup');
element.dispatchEvent(ev_5);
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', () => {
mediaSpy.mockReturnValue(<MediaQueryList>{ matches: false });
const element = document.createElement('div');
const panzoom = createMockPanZoom();
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
createAndRegisterZoom(element);
const ev_1 = new TouchEvent('touchstart', {
bubbles: false,
touches: [createTouch(element), createTouch(element)],
});
element.dispatchEvent(ev_1);
expect(panzoom.handleDown).toBeCalledWith(ev_1);
panzoom.getScale = vi.fn().mockReturnValue(1.2);
const ev_3 = new TouchEvent('touchstart');
element.dispatchEvent(ev_3);
expect(panzoom.handleDown).toBeCalledWith(ev_3);
const ev_4 = new TouchEvent('touchmove');
element.dispatchEvent(ev_4);
expect(panzoom.handleMove).toBeCalledWith(ev_4);
const ev_5 = new TouchEvent('touchend');
element.dispatchEvent(ev_5);
expect(panzoom.handleUp).toBeCalledWith(ev_5);
});
});
it('should ignore click after pointerdown', () => {
const outer = document.createElement('div');
const inner = document.createElement('div');
outer.appendChild(inner);
const clickHandler = vi.fn();
outer.addEventListener('click', clickHandler);
const panzoom = createMockPanZoom();
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
createAndRegisterZoom(inner);
// Simulate being zoomed in.
panzoom.getScale = vi.fn().mockReturnValue(1.2);
// A click on its own will be fine.
const click_1 = new MouseEvent('click', { bubbles: true });
inner.dispatchEvent(click_1);
expect(clickHandler).toBeCalledTimes(1);
// A click after a pointerdown will be ignored.
const pointerdown_1 = new PointerEvent('pointerdown');
inner.dispatchEvent(pointerdown_1);
const click_2 = new MouseEvent('click', { bubbles: true });
inner.dispatchEvent(click_2);
// Click will have been ignored.
//expect(clickHandler).toBeCalledTimes(1);
// Simulate being zoomed out.
panzoom.getScale = vi.fn().mockReturnValue(1.0);
const pointerdown_2 = new PointerEvent('pointerdown');
inner.dispatchEvent(pointerdown_2);
const click_3 = new MouseEvent('click', { bubbles: true });
inner.dispatchEvent(click_3);
// Click will have been processed.
expect(clickHandler).toBeCalledTimes(2);
});
it('deactivate should remove event handlers', () => {
const element = document.createElement('div');
const panzoom = createMockPanZoom();
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
createAndRegisterZoom(element).deactivate();
const ev_1 = new WheelEvent('wheel', {
bubbles: false,
deltaY: -120,
ctrlKey: true,
});
element.dispatchEvent(ev_1);
expect(panzoom.zoomWithWheel).not.toBeCalled();
});
describe('should fire events', () => {
it('on zoom/unzoom', () => {
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>('panzoomchange', {
detail: {
x: 0,
y: 0,
scale: 1.2,
isSVG: false,
originalEvent: new PointerEvent('pointermove'),
},
});
element.dispatchEvent(ev_1);
expect(zoomedFunc).toBeCalled();
expect(unzoomedFunc).not.toBeCalled();
const ev_2 = new CustomEvent<PanzoomEventDetail>('panzoomchange', {
detail: {
x: 0,
y: 0,
scale: 1,
isSVG: false,
originalEvent: new PointerEvent('pointermove'),
},
});
element.dispatchEvent(ev_2);
expect(unzoomedFunc).toBeCalled();
});
it('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>('panzoomchange', {
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>('panzoomchange', {
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);
});
describe('on default/non-default', () => {
it('without explicit default', () => {
const element = document.createElement('div');
setElementToDefaultCardSize(element);
const defaultFunc = vi.fn();
element.addEventListener('frigate-card:zoom:default', defaultFunc);
vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom());
createAndRegisterZoom(element);
const ev_1 = new CustomEvent<PanzoomEventDetail>('panzoomchange', {
detail: {
x: 0,
y: 0,
scale: 1.2,
isSVG: false,
originalEvent: new PointerEvent('pointermove'),
},
});
element.dispatchEvent(ev_1);
expect(defaultFunc).toBeCalledWith(
expect.objectContaining({ detail: { isDefault: false } }),
);
const ev_2 = new CustomEvent<PanzoomEventDetail>('panzoomchange', {
detail: {
x: 50,
y: 50,
scale: 1,
isSVG: false,
originalEvent: new PointerEvent('pointermove'),
},
});
element.dispatchEvent(ev_2);
expect(defaultFunc).toBeCalledWith(
expect.objectContaining({ detail: { isDefault: true } }),
);
});
it('with complete explicit default', () => {
const element = document.createElement('div');
setElementToDefaultCardSize(element);
const defaultFunc = vi.fn();
element.addEventListener('frigate-card:zoom:default', defaultFunc);
vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom());
const controller = createAndRegisterZoom(element);
controller.setDefaultConfig({ zoom: 2, pan: { x: 3, y: 4 } });
const ev_1 = new CustomEvent<PanzoomEventDetail>('panzoomchange', {
detail: {
x: 50,
y: 50,
scale: 1,
isSVG: false,
originalEvent: new PointerEvent('pointermove'),
},
});
element.dispatchEvent(ev_1);
expect(defaultFunc).toHaveBeenLastCalledWith(
expect.objectContaining({ detail: { isDefault: false } }),
);
const ev_2 = new CustomEvent<PanzoomEventDetail>('panzoomchange', {
detail: {
x: 115.62,
y: 63.6525,
scale: 2,
isSVG: false,
originalEvent: new PointerEvent('pointermove'),
},
});
element.dispatchEvent(ev_2);
expect(defaultFunc).toHaveBeenLastCalledWith(
expect.objectContaining({ detail: { isDefault: true } }),
);
});
it('with partial explicit default', () => {
const element = document.createElement('div');
setElementToDefaultCardSize(element);
const defaultFunc = vi.fn();
element.addEventListener('frigate-card:zoom:default', defaultFunc);
vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom());
const controller = createAndRegisterZoom(element);
const ev_1 = new CustomEvent<PanzoomEventDetail>('panzoomchange', {
detail: {
x: 51,
y: 51,
scale: 2,
isSVG: false,
originalEvent: new PointerEvent('pointermove'),
},
});
element.dispatchEvent(ev_1);
expect(defaultFunc).toHaveBeenLastCalledWith(
expect.objectContaining({ detail: { isDefault: false } }),
);
controller.setDefaultConfig({});
const ev_2 = new CustomEvent<PanzoomEventDetail>('panzoomchange', {
detail: {
x: 50,
y: 50,
scale: 1,
isSVG: false,
originalEvent: new PointerEvent('pointermove'),
},
});
element.dispatchEvent(ev_2);
expect(defaultFunc).toHaveBeenLastCalledWith(
expect.objectContaining({ detail: { isDefault: true } }),
);
});
});
});
describe('should automatically set correct zoom', () => {
it('with start', () => {
const panzoom = createMockPanZoom();
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
const element = document.createElement('div');
setElementToDefaultCardSize(element);
const controller = new ZoomController(element);
controller.setDefaultConfig({ zoom: 2, pan: { x: 3, y: 4 } });
// Controller was not activated, config setting will not update pan/zoom.
expect(panzoom.zoom).not.toBeCalled();
expect(panzoom.pan).not.toBeCalled();
controller.activate();
expect(Panzoom).toBeCalledWith(
expect.anything(),
expect.objectContaining({
contain: "outside",
cursor: undefined,
maxScale: 10,
minScale: 1,
noBind: true,
touchAction: "",
startScale: 2,
startX: 115.62,
startY: 63.6525,
})
);
});
it('with set of default config', () => {
const panzoom = createMockPanZoom();
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
const element = document.createElement('div');
setElementToDefaultCardSize(element);
const controller = createAndRegisterZoom(element);
controller.setDefaultConfig({ zoom: 2, pan: { x: 3, y: 4 } });
triggerResizeObserver();
expect(panzoom.zoom).toBeCalledWith(2, { animate: false });
expect(panzoom.pan).toBeCalledWith(115.62, 63.6525, { animate: false });
});
it('with set of config when a default is already set', () => {
const panzoom = createMockPanZoom();
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
const element = document.createElement('div');
setElementToDefaultCardSize(element);
const controller = createAndRegisterZoom(element);
// This call will do nothing since this is what zoom/pan already are.
controller.setDefaultConfig({ zoom: 1, pan: { x: 0, y: 0 } });
expect(panzoom.zoom).not.toBeCalled();
expect(panzoom.pan).not.toBeCalled();
controller.setDefaultConfig({ zoom: 2, pan: { x: 3, y: 4 } });
expect(panzoom.zoom).toHaveBeenNthCalledWith(1, 2, { animate: false });
expect(panzoom.pan).toHaveBeenNthCalledWith(1, 115.62, 63.6525, {
animate: false,
});
controller.setConfig({ zoom: 3, pan: { x: 5, y: 6 } });
expect(panzoom.zoom).toHaveBeenNthCalledWith(2, 3, { animate: false });
expect(panzoom.pan).toHaveBeenNthCalledWith(2, 147.6, 81.18, {
animate: false,
});
});
it('with repeated calls with same values', () => {
const panzoom = createMockPanZoom();
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
const element = document.createElement('div');
setElementToDefaultCardSize(element);
const controller = createAndRegisterZoom(element);
controller.setConfig({ zoom: 1 });
expect(panzoom.zoom).not.toHaveBeenCalled();
controller.setConfig({ pan: { x: 50, y: 50 } });
expect(panzoom.zoom).not.toHaveBeenCalled();
controller.setConfig({ zoom: 1, pan: { x: 50, y: 50 } });
expect(panzoom.zoom).not.toHaveBeenCalled();
controller.setConfig({});
expect(panzoom.zoom).not.toHaveBeenCalled();
controller.setConfig({ zoom: 2 });
expect(panzoom.zoom).toBeCalledTimes(1);
expect(panzoom.pan).toBeCalledTimes(1);
expect(panzoom.zoom).toHaveBeenNthCalledWith(1, 2, { animate: false });
expect(panzoom.pan).toHaveBeenNthCalledWith(1, 0, 0, {
animate: false,
});
vi.mocked(panzoom.getScale).mockReturnValue(2);
vi.mocked(panzoom.getPan).mockReturnValue({ x: 0, y: 0 });
controller.setConfig({ zoom: 2 });
expect(panzoom.zoom).toBeCalledTimes(1);
expect(panzoom.pan).toBeCalledTimes(1);
});
it('when config is set to empty', () => {
const panzoom = createMockPanZoom();
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
const element = document.createElement('div');
setElementToDefaultCardSize(element);
const controller = createAndRegisterZoom(element);
controller.setDefaultConfig({ zoom: 2, pan: { x: 3, y: 4 } });
mockClear(panzoom);
controller.setConfig({});
// Should fall back to default.
expect(panzoom.zoom).toBeCalledWith(2, { animate: false });
expect(panzoom.pan).toBeCalledWith(115.62, 63.6525, {
animate: false,
});
});
it('when resized', () => {
const panzoom = createMockPanZoom();
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
const element = document.createElement('div');
setElementToDefaultCardSize(element);
const controller = createAndRegisterZoom(element);
controller.setConfig({ zoom: 2, pan: { x: 3, y: 4 } });
expect(panzoom.zoom).toHaveBeenNthCalledWith(1, 2, { animate: false });
expect(panzoom.pan).toHaveBeenNthCalledWith(1, 115.62, 63.6525, {
animate: false,
});
vi.mocked(panzoom.getScale).mockReturnValue(2);
vi.mocked(panzoom.getPan).mockReturnValue({ x: 3, y: 4 });
setElementToDefaultCardSize(element);
triggerResizeObserver();
expect(panzoom.zoom).toHaveBeenNthCalledWith(2, 2, { animate: false });
expect(panzoom.pan).toHaveBeenNthCalledWith(2, 57.81, 31.82625, {
animate: false,
});
});
it('when not yet activated', () => {
const panzoom = createMockPanZoom();
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
const element = document.createElement('div');
setElementToDefaultCardSize(element);
new ZoomController(element);
triggerResizeObserver();
expect(panzoom.zoom).not.toBeCalled();
expect(panzoom.pan).not.toBeCalled();
});
it('when element has no size', () => {
const panzoom = createMockPanZoom();
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
const element = document.createElement('div');
element.getBoundingClientRect = vi.fn().mockReturnValue({
width: 0,
height: 0,
});
createAndRegisterZoom(element);
triggerResizeObserver();
expect(panzoom.zoom).not.toBeCalled();
expect(panzoom.pan).not.toBeCalled();
});
});
it('should set touch action on zoom/unzoom', () => {
const element = document.createElement('div');
vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom());
createAndRegisterZoom(element);
const ev_1 = new CustomEvent<PanzoomEventDetail>('panzoomchange', {
detail: {
x: 0,
y: 0,
scale: 1.2,
isSVG: false,
originalEvent: new PointerEvent('pointermove'),
},
});
element.dispatchEvent(ev_1);
expect(element.style.touchAction).toBe('none');
const ev_2 = new CustomEvent<PanzoomEventDetail>('panzoomchange', {
detail: {
x: 0,
y: 0,
scale: 1,
isSVG: false,
originalEvent: new PointerEvent('pointermove'),
},
});
element.dispatchEvent(ev_2);
expect(element.style.touchAction).toBeFalsy();
});
});
@@ -0,0 +1,85 @@
import { describe, expect, it, vi } from 'vitest';
import {
generateViewContextForZoomChange,
handleZoomDefaultEvent,
} from '../../../src/components-lib/zoom/zoom-view-context';
describe('generateViewContextForZoomChangeRequest', () => {
it('with config', () => {
expect(
generateViewContextForZoomChange('target', {
zoom: {
pan: { x: 1, y: 2 },
zoom: 3,
},
}),
).toEqual({
zoom: {
target: {
zoom: {
pan: { x: 1, y: 2 },
zoom: 3,
},
},
},
});
});
it('without config', () => {
expect(generateViewContextForZoomChange('target')).toEqual({
zoom: {
target: {
zoom: null,
},
},
});
});
});
describe('generateViewContextForZoomDefault', () => {
it('default', () => {
expect(generateViewContextForZoomChange('target', { isDefault: true })).toEqual({
zoom: {
target: {
isDefault: true,
zoom: null,
},
},
});
});
it('not default', () => {
expect(generateViewContextForZoomChange('target', { isDefault: false })).toEqual({
zoom: {
target: {
isDefault: false,
zoom: null,
},
},
});
});
});
// @vitest-environment jsdom
it('handleZoomDefaultEvent', () => {
const element = document.createElement('div');
const callback = vi.fn();
element.addEventListener('frigate-card:view:change-context', callback);
handleZoomDefaultEvent(
element,
new CustomEvent('frigate-card:zoom:default', { detail: { isDefault: true } }),
'target',
);
expect(callback).toBeCalledWith(
expect.objectContaining({
detail: {
zoom: {
target: {
zoom: null,
isDefault: true,
},
},
},
}),
);
});
+4
View File
@@ -175,6 +175,10 @@ describe('config defaults', () => {
enabled: true,
priority: 50,
},
default_zoom: {
enabled: true,
priority: 50,
},
display_mode: {
enabled: true,
priority: 50,
+33
View File
@@ -5,6 +5,7 @@ import { actionSchema } from '../../src/config/types';
import {
convertActionToFrigateCardCustomAction,
createFrigateCardCameraAction,
createFrigateCardChangeZoomAction,
createFrigateCardDisplayModeAction,
createFrigateCardMediaPlayerAction,
createFrigateCardShowPTZAction,
@@ -114,6 +115,38 @@ describe('createFrigateCardShowPTZAction', () => {
});
});
describe('createFrigateCardChangeZoomAction', () => {
it('should create change zoom default action', () => {
expect(
createFrigateCardChangeZoomAction('target_id', {
cardID: 'card_id',
}),
).toEqual({
action: 'fire-dom-event',
frigate_card_action: 'change_zoom',
card_id: 'card_id',
target_id: 'target_id',
});
});
it('should create change zoom specific action', () => {
expect(
createFrigateCardChangeZoomAction('target_id', {
cardID: 'card_id',
pan: { x: 1, y: 2 },
zoom: 3,
}),
).toEqual({
action: 'fire-dom-event',
frigate_card_action: 'change_zoom',
card_id: 'card_id',
target_id: 'target_id',
pan: { x: 1, y: 2 },
zoom: 3,
});
});
});
describe('getActionConfigGivenAction', () => {
const action = actionSchema.parse({
action: 'fire-dom-event',
+53
View File
@@ -2,6 +2,7 @@ import { afterAll, describe, expect, it, vi } from 'vitest';
import { FrigateCardError } from '../../src/types';
import {
allPromises,
arefloatsApproximatelyEqual,
arrayify,
arrayMove,
aspectRatioToStyle,
@@ -12,6 +13,7 @@ import {
errorToConsole,
formatDate,
formatDateAndTime,
generateFloatApproximatelyEqualsCustomizer,
getChildrenFromElement,
getDurationString,
isHoverableDevice,
@@ -406,3 +408,54 @@ describe('desparsifyArrays', () => {
});
});
});
describe('arefloatsApproximatelyEqual', () => {
describe('without precision', () => {
it('equals', () => {
expect(arefloatsApproximatelyEqual(1.1, 1.2)).toBeTruthy();
});
it('not equals', () => {
expect(arefloatsApproximatelyEqual(0.5, 1.5)).toBeFalsy();
});
});
describe('with precision', () => {
it('equals', () => {
expect(arefloatsApproximatelyEqual(1.00001, 1.00002, 4)).toBeTruthy();
});
it('not equals', () => {
expect(arefloatsApproximatelyEqual(0.5, 1.5, 4)).toBeFalsy();
});
});
});
describe('generateFloatApproximatelyEqualsCustomizer', () => {
describe('with incorrect types', () => {
it('undefined a', () => {
expect(
generateFloatApproximatelyEqualsCustomizer(4)(undefined, 1.2),
).toBeUndefined();
});
it('undefined b', () => {
expect(
generateFloatApproximatelyEqualsCustomizer(4)(1.2, undefined),
).toBeUndefined();
});
it('strings', () => {
expect(
generateFloatApproximatelyEqualsCustomizer(4)('foo', 'bar'),
).toBeUndefined();
});
});
describe('with correct types', () => {
it('equals', () => {
expect(
generateFloatApproximatelyEqualsCustomizer(4)(1.00001, 1.00002),
).toBeTruthy();
});
it('not equals', () => {
expect(
generateFloatApproximatelyEqualsCustomizer(5)(1.00001, 1.00002),
).toBeFalsy();
});
});
});