Initial support for grid for live and media viewer.

This commit is contained in:
Dermot Duffy
2023-08-08 22:26:25 -07:00
parent 69249b6c33
commit b663e0b731
59 changed files with 3308 additions and 429 deletions
+20 -24
View File
@@ -76,30 +76,26 @@ describe('capEndDate', () => {
});
describe('sortMedia', () => {
const media_1 = new TestViewMedia(
'id-1',
new Date('2023-04-29T14:25'),
'clip',
'camera-1',
);
const media_2 = new TestViewMedia(
'id-2',
new Date('2023-04-29T14:26'),
'clip',
'camera-1',
);
const media_3_dup_id = new TestViewMedia(
'id-2',
new Date('2023-04-29T14:26'),
'clip',
'camera-1',
);
const media_4_no_id = new TestViewMedia(
null,
new Date('2023-04-29T14:27'),
'clip',
'camera-1',
);
const media_1 = new TestViewMedia({
id: 'id-1',
startTime: new Date('2023-04-29T14:25'),
cameraID: 'camera-1',
});
const media_2 = new TestViewMedia({
id: 'id-2',
startTime: new Date('2023-04-29T14:26'),
cameraID: 'camera-1',
});
const media_3_dup_id = new TestViewMedia({
id: 'id-2',
startTime: new Date('2023-04-29T14:26'),
cameraID: 'camera-1',
});
const media_4_no_id = new TestViewMedia({
id: null,
startTime: new Date('2023-04-29T14:27'),
cameraID: 'camera-1',
});
it('should sort sorted media', () => {
const media = [media_1, media_2];
+12 -1
View File
@@ -1,4 +1,4 @@
import { afterEach, describe, it, expect, vi } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
ConditionController,
ConditionEvaluateRequestEvent,
@@ -6,6 +6,7 @@ import {
getOverriddenConfig,
getOverridesByKey,
} from '../src/conditions';
import { FrigateCardCondition } from '../src/types';
import { createCondition, createConfig, createStateEntity } from './test-utils';
// @vitest-environment jsdom
@@ -347,4 +348,14 @@ describe('ConditionController', () => {
controller.destroy();
expect(removeEventListener).toBeCalled();
});
it('should evaluate conditions with display mode', () => {
const controller = new ConditionController();
const condition: FrigateCardCondition = { display_mode: 'grid' };
expect(controller.evaluateCondition(condition)).toBeFalsy();
controller.setState({ displayMode: 'grid' });
expect(controller.evaluateCondition(condition)).toBeTruthy();
controller.setState({ displayMode: 'single' });
expect(controller.evaluateCondition(condition)).toBeFalsy();
});
});
+71 -10
View File
@@ -9,6 +9,7 @@ import {
CameraConfigs,
CameraManagerCameraCapabilities,
CameraManagerMediaCapabilities,
QueryType,
} from '../src/camera-manager/types';
import {
CameraConfig,
@@ -16,10 +17,12 @@ import {
FrigateCardCondition,
FrigateCardConfig,
MediaLoadedInfo,
PerformanceConfig,
RawFrigateCardConfig,
cameraConfigSchema,
frigateCardConditionSchema,
frigateCardConfigSchema,
performanceConfigSchema,
} from '../src/types';
import { Entity } from '../src/utils/ha/entity-registry/types';
import { ViewMedia, ViewMediaType } from '../src/view/media';
@@ -126,11 +129,25 @@ export const createCameraManager = (options?: {
const configs = options?.configs ?? new Map([['camera', createCameraConfig()]]);
vi.mocked(store.getCameras).mockReturnValue(configs);
vi.mocked(store.getVisibleCameras).mockReturnValue(configs);
vi.mocked(store.getVisibleCameraIDs).mockReturnValue(new Set(configs.keys()));
vi.mocked(store.getCameraConfig).mockImplementation((cameraID): CameraConfig => {
return configs.get(cameraID) ?? createCameraConfig();
});
}
vi.mocked(cameraManager.getStore).mockReturnValue(store);
vi.mocked(cameraManager.generateDefaultEventQueries).mockReturnValue([
{
cameraIDs: new Set(['camera']),
type: QueryType.Event,
},
]);
vi.mocked(cameraManager.generateDefaultRecordingQueries).mockReturnValue([
{
cameraIDs: new Set(['camera']),
type: QueryType.Recording,
},
]);
return cameraManager;
};
@@ -169,21 +186,44 @@ export const createMediaLoadedInfo = (
};
};
export const createPerformanceConfig = (config: unknown): PerformanceConfig => {
return performanceConfigSchema.parse(config);
};
export const generateViewMediaArray = (options?: {
cameraIDs?: string[];
count?: number;
}): ViewMedia[] => {
const media: ViewMedia[] = [];
for (let i = 0; i < (options?.count ?? 100); ++i) {
for (const cameraID of options?.cameraIDs ?? ['kitchen', 'office']) {
media.push(new TestViewMedia({ cameraID: cameraID, id: `id-${cameraID}-${i}` }));
}
}
return media;
};
// ViewMedia itself has no native way to set startTime and ID that aren't linked
// to an engine.
export class TestViewMedia extends ViewMedia {
protected _id: string | null;
protected _startTime: Date;
protected _startTime: Date | null;
protected _endTime: Date | null;
protected _inProgress: boolean | null;
constructor(
id: string | null,
startTime: Date,
mediaType: ViewMediaType,
cameraID: string,
) {
super(mediaType, cameraID);
this._id = id;
this._startTime = startTime;
constructor(options?: {
id?: string | null;
startTime?: Date;
mediaType?: ViewMediaType;
cameraID?: string;
endTime?: Date;
inProgress?: boolean;
}) {
super(options?.mediaType ?? 'clip', options?.cameraID ?? 'camera');
this._id = options?.id !== undefined ? options.id : 'id';
this._startTime = options?.startTime ?? null;
this._endTime = options?.endTime ?? null;
this._inProgress = options?.inProgress !== undefined ? options.inProgress : false;
}
public getID(): string | null {
return this._id;
@@ -191,4 +231,25 @@ export class TestViewMedia extends ViewMedia {
public getStartTime(): Date | null {
return this._startTime;
}
public getEndTime(): Date | null {
return this._endTime;
}
public inProgress(): boolean | null {
return this._inProgress;
}
}
export const createResizeObserverImplementation = (): (() => void) => {
return () => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
});
};
export const createMutationObserverImplementation = (): (() => void) => {
return () => ({
observe: vi.fn(),
disconnect: vi.fn(),
});
};
+18
View File
@@ -103,6 +103,24 @@ describe('createFrigateCardCustomAction', () => {
card_id: 'card_id',
});
});
it('should create display mode action', () => {
expect(
createFrigateCardCustomAction('display_mode_select', {
display_mode: 'grid',
cardID: 'card_id',
}),
).toEqual({
action: 'fire-dom-event',
frigate_card_action: 'display_mode_select',
display_mode: 'grid',
card_id: 'card_id',
});
});
it('should not create display mode action without display mode', () => {
expect(createFrigateCardCustomAction('display_mode_select')).toBeNull();
});
});
describe('getActionConfigGivenAction', () => {
+17 -1
View File
@@ -8,6 +8,7 @@ import {
dayToDate,
dispatchFrigateCardEvent,
errorToConsole,
filterTruthy,
formatDate,
formatDateAndTime,
getDurationString,
@@ -227,7 +228,13 @@ describe('isValidDate', () => {
});
describe('setOrRemoveAttribute', () => {
it('should set attribute', () => {
it('should set attribute without value', () => {
const element = document.createElement('div');
setOrRemoveAttribute(element, true, 'key');
expect(element.getAttribute('key')).toBe('');
});
it('should set attribute with value', () => {
const element = document.createElement('div');
setOrRemoveAttribute(element, true, 'key', 'value');
expect(element.getAttribute('key')).toBe('value');
@@ -240,3 +247,12 @@ describe('setOrRemoveAttribute', () => {
expect(element.getAttribute('key')).toBeFalsy();
});
});
describe('filterTruthy', () => {
it('should return true for true', () => {
expect(filterTruthy(true)).toBeTruthy();
});
it('should return false for false', () => {
expect(filterTruthy(false)).toBeFalsy();
});
});
+400
View File
@@ -0,0 +1,400 @@
import Masonry from 'masonry-layout';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { MediaLoadedInfo } from '../../src/types';
import {
MediaGridConstructorOptions,
MediaGridController,
} from '../../src/utils/media-grid-controller';
import { dispatchExistingMediaLoadedInfoAsEvent } from '../../src/utils/media-info';
import {
createMutationObserverImplementation,
createResizeObserverImplementation,
} from '../test-utils';
vi.mock('lodash-es/throttle', () => ({
default: vi.fn((fn) => fn),
}));
const masonry = mock<Masonry>();
vi.mock('masonry-layout', () => ({
default: vi.fn().mockImplementation(() => {
return masonry;
}),
}));
const createChildren = (childIDs?: string[], idAttribute?: string): HTMLElement[] => {
const children: HTMLElement[] = [];
for (let i = 0; i < (childIDs?.length ?? 3); ++i) {
const child = document.createElement('div');
if (childIDs) {
child.setAttribute(idAttribute ?? 'grid-id', childIDs[i]);
}
children.push(child);
}
return children;
};
const setElementWidth = (element: HTMLElement, width: number): void => {
element.getBoundingClientRect = vi.fn().mockReturnValue({
width: width,
});
};
const createHost = (options?: {
children?: HTMLElement[];
width?: number;
}): HTMLElement => {
const host = document.createElement('div');
if (options?.children) {
host.append(...options.children);
}
// Default Lovelace card width is 492.
setElementWidth(host, options?.width ?? 492);
return host;
};
const createSlotParent = (): HTMLElement => {
const parent = document.createElement('div');
parent.attachShadow({ mode: 'open' });
return parent;
};
const createSlotHost = (options?: {
children?: HTMLElement[];
parent?: HTMLElement;
}): HTMLElement => {
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);
};
const triggerMutationObserver = (): void => {
const mutationObserverTrigger = vi.mocked(global.MutationObserver).mock.calls[0][0];
mutationObserverTrigger([], mock<MutationObserver>());
};
const triggerResizeObserver = (cellOrHost: 'cell' | 'host'): void => {
const resizeObserverTrigger = vi.mocked(global.ResizeObserver).mock.calls[
cellOrHost === 'cell' ? 0 : 1
][0];
resizeObserverTrigger([], mock<ResizeObserver>());
};
// @vitest-environment jsdom
describe('MediaGridController', () => {
const mediaLoadedInfo: MediaLoadedInfo = {
width: 10,
height: 20,
};
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>();
});
it('should be constructable', () => {
const controller = createController(createHost());
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);
expect(controller.getGridContents()).toEqual(
new Map([
['0', children[0]],
['1', children[1]],
['2', children[2]],
]),
);
expect(controller.getGridSize()).toBe(3);
expect(masonry.layout).toBeCalled();
});
it('should set grid contents correctly from slotted elements', () => {
const children = createChildren();
const host = createSlotHost({ children: children });
const controller = createController(host);
expect(controller.getGridContents()).toEqual(
new Map([
['0', children[0]],
['1', children[1]],
['2', children[2]],
]),
);
expect(controller.getGridSize()).toBe(3);
});
it('should select element', () => {
const children = createChildren();
const controller = createController(createSlotHost({ children: children }));
// All children should be unselected.
expect(controller.getSelected()).toBeNull();
for (const child of children) {
expect(child.getAttribute('selected')).toBeNull();
expect(child.getAttribute('unselected')).toEqual('');
}
controller.selectCell('0');
expect(controller.getSelected()).toBe('0');
// 1st child should now be selected.
expect(children[0].getAttribute('selected')).toEqual('');
expect(children[0].getAttribute('unselected')).toBeNull();
// 2nd and 3rd should be unselected.
for (const child of children.slice(1)) {
expect(child.getAttribute('selected')).toBeNull();
expect(child.getAttribute('unselected')).toEqual('');
}
});
it('should re-select element', () => {
const controller = createController(createSlotHost({ children: createChildren() }));
// All children should be unselected.
expect(controller.getSelected()).toBeNull();
controller.selectCell('0');
expect(controller.getSelected()).toBe('0');
controller.selectCell('0');
expect(controller.getSelected()).toBe('0');
});
it('should dispatch media loaded info on selection', () => {
const children = createChildren();
const host = createSlotHost({ children: children });
const controller = createController(host);
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({
detail: mediaLoadedInfo,
}),
);
});
it('should unselect', () => {
const children = createChildren();
const host = createSlotHost({ children: children });
const controller = createController(host);
const unselectedHandler = vi.fn();
const unloadMediaHandler = vi.fn();
host.addEventListener('frigate-card:media-grid:unselected', unselectedHandler);
host.addEventListener('frigate-card:media:unloaded', unloadMediaHandler);
controller.selectCell('0');
expect(controller.getSelected()).toBe('0');
// Unselect all elements.
controller.unselectAll();
// Expect selected to now be null.
expect(controller.getSelected()).toBeNull();
// Expect styles to have been updated.
for (const child of children) {
expect(child.getAttribute('selected')).toBeNull();
expect(child.getAttribute('unselected')).toEqual('');
}
// Expect handlers to have been called.
expect(unselectedHandler).toBeCalled();
expect(unloadMediaHandler).toBeCalled();
});
it('should select in constructor', () => {
const children = createChildren();
const host = createSlotHost({ 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 controller = createController(host, { idAttribute: 'test-id' });
expect(controller.getGridContents()).toEqual(
new Map([
['one', children[0]],
['two', children[1]],
['three', children[2]],
]),
);
});
it('should destroy', () => {
const children = createChildren();
const host = createSlotHost({ children: children });
const controller = createController(host);
expect(controller.getGridSize()).toBe(3);
controller.destroy();
expect(controller.getGridSize()).toBe(0);
});
it('should replace children when they change', () => {
const children = createChildren();
const host = createHost({ children: children });
const controller = createController(host, { selected: '1' });
dispatchExistingMediaLoadedInfoAsEvent(children[0], mediaLoadedInfo);
expect(controller.getSelected()).toBe('1');
expect(controller.getGridSize()).toBe(3);
children.forEach((child) => host.removeChild(child));
const newChildren = createChildren(['one', 'two', 'three']);
newChildren.forEach((child) => host.appendChild(child));
triggerMutationObserver();
expect(controller.getGridContents()).toEqual(
new Map([
['one', newChildren[0]],
['two', newChildren[1]],
['three', newChildren[2]],
]),
);
expect(controller.getSelected()).toBeNull();
});
it('should construct masonry correctly', () => {
const children = createChildren();
const host = createHost({ children: children });
createController(host);
expect(Masonry).toBeCalledWith(
host,
expect.objectContaining({
initLayout: false,
percentPosition: true,
transitionDuration: '0.3s',
}),
);
});
it('should set default column size correctly', () => {
const host = createHost({ children: createChildren() });
createController(host);
expect(Masonry).toBeCalledWith(
host,
expect.objectContaining({
columnWidth: 246,
}),
);
expect(host.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);
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,
expect.objectContaining({
columnWidth: 1000,
}),
);
expect(host.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);
controller.setDisplayConfig({ mode: 'grid', grid_selected_width_factor: 3 });
expect(
host.style.getPropertyValue('--frigate-card-grid-selected-width-factor'),
).toBe('3');
});
it('should select cell with interacted with', () => {
const children = createChildren();
const host = createHost({ children: children, width: 2000 });
const controller = createController(host);
expect(controller.getSelected()).toBeNull();
const touchEvent = new TouchEvent('touchend');
children[1].dispatchEvent(touchEvent);
expect(controller.getSelected()).toBe('1');
});
it('should re-layout when child size changes', () => {
createController(createHost({ children: createChildren() }));
vi.mocked(masonry.layout)?.mockClear();
triggerResizeObserver('cell');
expect(masonry.layout).toBeCalled();
});
it('should re-create masonry when host size changes', () => {
const children = createChildren();
const host = createHost({ children: children });
const controller = createController(host);
expect(Masonry).toBeCalledWith(
host,
expect.objectContaining({
columnWidth: 246,
}),
);
expect(host.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);
triggerResizeObserver('host');
// Masonry should be reconstructed, styles set and layout called.
expect(Masonry).toBeCalledWith(
host,
expect.objectContaining({
columnWidth: 667,
}),
);
expect(host.style.getPropertyValue('--frigate-card-grid-column-size')).toBe('667px');
expect(masonry.layout).toBeCalled();
});
});
+452
View File
@@ -0,0 +1,452 @@
import add from 'date-fns/add';
import sub from 'date-fns/sub';
import { beforeEach, describe, expect, it, Mock, vi } from 'vitest';
import {
CameraConfigs,
PartialRecordingQuery,
QueryType,
} from '../../src/camera-manager/types';
import { setify } from '../../src/utils/basic';
import {
changeViewToRecentEventsForCameraAndDependents,
changeViewToRecentRecordingForCameraAndDependents,
createQueriesForRecordingsView,
executeMediaQueryForView,
findBestMediaIndex,
} from '../../src/utils/media-to-view';
import { ViewMedia } from '../../src/view/media';
import { EventMediaQueries } from '../../src/view/media-queries';
import {
createCameraManager,
createHASS,
createPerformanceConfig,
createView,
TestViewMedia,
} from '../test-utils';
vi.mock('../../src/camera-manager/manager.js');
const createElementListenForView = (): {
element: HTMLElement;
viewHandler: Mock<any, any>;
messageHandler: Mock<any, any>;
} => {
const element = document.createElement('div');
const viewHandler = vi.fn();
element.addEventListener('frigate-card:view:change', viewHandler);
const messageHandler = vi.fn();
element.addEventListener('frigate-card:message', messageHandler);
return {
element: element,
viewHandler: viewHandler,
messageHandler: messageHandler,
};
};
const getMediaFromHandlerCall = (handler: Mock<any, any>): ViewMedia[] | null => {
return handler.mock.calls[0][0].detail.queryResults.getResults();
};
const generateViewMedia = (
index: number,
base: Date,
durationSeconds: number,
): ViewMedia => {
return new TestViewMedia({
id: `id-${index}`,
startTime: base,
endTime: add(base, { seconds: durationSeconds }),
});
};
// @vitest-environment jsdom
describe('changeViewToRecentEventsForCameraAndDependents', () => {
beforeEach(() => {
vi.resetAllMocks();
});
it('should do nothing without camera config for selected camera', async () => {
const elementHandler = createElementListenForView();
const cameraManager = createCameraManager({ configs: new Map() });
await changeViewToRecentEventsForCameraAndDependents(
elementHandler.element,
createHASS(),
cameraManager,
{},
createView(),
);
expect(elementHandler.viewHandler).not.toBeCalled();
});
it('should do nothing without camera configs for all cameras', async () => {
const elementHandler = createElementListenForView();
const cameraManager = createCameraManager({ configs: new Map() });
await changeViewToRecentEventsForCameraAndDependents(
elementHandler.element,
createHASS(),
cameraManager,
{},
createView(),
{
allCameras: true,
},
);
expect(elementHandler.viewHandler).not.toBeCalled();
});
it('should do nothing unless queries can be created', async () => {
const elementHandler = createElementListenForView();
const cameraManager = createCameraManager();
vi.mocked(cameraManager.generateDefaultEventQueries).mockReturnValue(null);
await changeViewToRecentEventsForCameraAndDependents(
elementHandler.element,
createHASS(),
cameraManager,
{},
createView(),
{
mediaType: 'clips',
},
);
expect(elementHandler.viewHandler).not.toBeCalled();
});
it('should dispatch new view on success', async () => {
const elementHandler = createElementListenForView();
const cameraManager = createCameraManager();
const mediaArray = [new ViewMedia('clip', 'camera')];
vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue(mediaArray);
await changeViewToRecentEventsForCameraAndDependents(
elementHandler.element,
createHASS(),
cameraManager,
{},
createView(),
{
targetView: 'clips',
select: 'latest',
},
);
expect(elementHandler.viewHandler).toBeCalled();
expect(getMediaFromHandlerCall(elementHandler.viewHandler)).toBe(mediaArray);
});
it('should dispatch error message on fail', async () => {
vi.spyOn(global.console, 'warn').mockImplementation(() => true);
const elementHandler = createElementListenForView();
const cameraManager = createCameraManager();
vi.mocked(cameraManager.executeMediaQueries).mockRejectedValue(new Error());
await changeViewToRecentEventsForCameraAndDependents(
elementHandler.element,
createHASS(),
cameraManager,
{},
createView(),
);
expect(elementHandler.viewHandler).not.toBeCalled();
expect(elementHandler.messageHandler).toBeCalled();
});
it('should respect media chunk size', async () => {
const cameraManager = createCameraManager();
await changeViewToRecentEventsForCameraAndDependents(
createElementListenForView().element,
createHASS(),
cameraManager,
{
performance: createPerformanceConfig({
features: {
media_chunk_size: 1000,
},
}),
},
createView(),
);
expect(cameraManager.generateDefaultEventQueries).toBeCalledWith(
expect.anything(),
expect.objectContaining({
limit: 1000,
}),
);
});
describe('should respect request for media type', () => {
it.each([
['snapshots' as const, 'hasSnapshot'],
['clips' as const, 'hasClip'],
])('%s', async (mediaType, queryParameter) => {
const cameraManager = createCameraManager();
await changeViewToRecentEventsForCameraAndDependents(
createElementListenForView().element,
createHASS(),
cameraManager,
{},
createView(),
{
mediaType: mediaType,
},
);
expect(cameraManager.generateDefaultEventQueries).toBeCalledWith(
expect.anything(),
expect.objectContaining({
[queryParameter]: true,
}),
);
});
});
});
// @vitest-environment jsdom
describe('executeMediaQueryForView', () => {
beforeEach(() => {
vi.resetAllMocks();
});
it('should not execute empty queries', async () => {
const elementHandler = createElementListenForView();
const cameraConfigs: CameraConfigs = new Map();
const cameraManager = createCameraManager({ configs: cameraConfigs });
expect(
await executeMediaQueryForView(
elementHandler.element,
createHASS(),
cameraManager,
createView(),
new EventMediaQueries(),
),
).toBeNull();
});
it('should select time-based result', async () => {
const elementHandler = createElementListenForView();
const cameraConfigs: CameraConfigs = new Map();
const cameraManager = createCameraManager({ configs: cameraConfigs });
const now = new Date();
const mediaArray = [
generateViewMedia(0, now, 60),
generateViewMedia(1, now, 120),
generateViewMedia(2, now, 10),
];
vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue(mediaArray);
const view = await executeMediaQueryForView(
elementHandler.element,
createHASS(),
cameraManager,
createView(),
new EventMediaQueries(
cameraManager.generateDefaultEventQueries('camera') ?? undefined,
),
{
select: 'time',
targetTime: add(now, { seconds: 30 }),
},
);
// Should select the longest event.
expect(view?.queryResults?.getSelectedIndex()).toBe(1);
expect(view?.queryResults?.getResults()).toBe(mediaArray);
});
it('should select nothing when time-based selection does not match', async () => {
const elementHandler = createElementListenForView();
const cameraConfigs: CameraConfigs = new Map();
const cameraManager = createCameraManager({ configs: cameraConfigs });
const now = new Date();
const mediaArray = [
generateViewMedia(0, now, 60),
generateViewMedia(1, now, 120),
generateViewMedia(2, now, 10),
];
vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue(mediaArray);
const view = await executeMediaQueryForView(
elementHandler.element,
createHASS(),
cameraManager,
createView(),
new EventMediaQueries(
cameraManager.generateDefaultEventQueries('camera') ?? undefined,
),
{
select: 'time',
targetTime: sub(now, { seconds: 30 }),
},
);
// Should leave selection untouched (last item will remain selected).
expect(view?.queryResults?.getSelectedIndex()).toBe(2);
expect(view?.queryResults?.getResults()).toBe(mediaArray);
});
});
// @vitest-environment jsdom
describe('changeViewToRecentRecordingForCameraAndDependents', () => {
beforeEach(() => {
vi.resetAllMocks();
});
it('should do nothing without camera config for selected camera', async () => {
const elementHandler = createElementListenForView();
const cameraManager = createCameraManager({ configs: new Map() });
await changeViewToRecentRecordingForCameraAndDependents(
elementHandler.element,
createHASS(),
cameraManager,
{},
createView(),
);
expect(elementHandler.viewHandler).not.toBeCalled();
});
it('should do nothing without camera configs for all cameras', async () => {
const elementHandler = createElementListenForView();
const cameraManager = createCameraManager({ configs: new Map() });
await changeViewToRecentRecordingForCameraAndDependents(
elementHandler.element,
createHASS(),
cameraManager,
{},
createView(),
{
allCameras: true,
},
);
expect(elementHandler.viewHandler).not.toBeCalled();
});
it('should do nothing unless queries can be created', async () => {
const elementHandler = createElementListenForView();
const cameraManager = createCameraManager();
vi.mocked(cameraManager.generateDefaultRecordingQueries).mockReturnValue(null);
await changeViewToRecentRecordingForCameraAndDependents(
elementHandler.element,
createHASS(),
cameraManager,
{},
createView(),
);
expect(elementHandler.viewHandler).not.toBeCalled();
});
it('should dispatch new view on success', async () => {
const elementHandler = createElementListenForView();
const cameraManager = createCameraManager();
const mediaArray = [new ViewMedia('recording', 'camera')];
vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue(mediaArray);
await changeViewToRecentRecordingForCameraAndDependents(
elementHandler.element,
createHASS(),
cameraManager,
{},
createView(),
{
targetView: 'recordings',
select: 'latest',
},
);
expect(elementHandler.viewHandler).toBeCalled();
expect(getMediaFromHandlerCall(elementHandler.viewHandler)).toBe(mediaArray);
});
it('should respect media chunk size', async () => {
const cameraManager = createCameraManager();
await changeViewToRecentRecordingForCameraAndDependents(
createElementListenForView().element,
createHASS(),
cameraManager,
{
performance: createPerformanceConfig({
features: {
media_chunk_size: 1000,
},
}),
},
createView(),
);
expect(cameraManager.generateDefaultRecordingQueries).toBeCalledWith(
expect.anything(),
expect.objectContaining({
limit: 1000,
}),
);
});
});
// @vitest-environment jsdom
describe('createQueriesForRecordingsView', () => {
it('should respect start and end date in recording query', async () => {
const cameraManager = createCameraManager({ configs: new Map() });
vi.mocked(cameraManager.generateDefaultRecordingQueries).mockImplementation(
(cameraIDs: string | Set<string>, partialQuery?: PartialRecordingQuery) => [
{
cameraIDs: setify(cameraIDs),
type: QueryType.Recording,
...partialQuery,
},
],
);
const start = new Date('2023-04-29T14:00:00');
const end = new Date('2023-04-29T14:59:59');
const queries = createQueriesForRecordingsView(
cameraManager,
{},
new Set(['camera']),
{
start: start,
end: end,
},
);
expect(queries?.getQueries()).toEqual(
expect.arrayContaining([
expect.objectContaining({
start: start,
end: end,
}),
]),
);
});
});
// @vitest-environment jsdom
describe('findBestMediaIndex', () => {
it('should find best media index', async () => {
const now = new Date();
const mediaArray = [
generateViewMedia(0, now, 60),
generateViewMedia(1, now, 120),
generateViewMedia(2, now, 10),
];
expect(findBestMediaIndex(mediaArray, add(now, { seconds: 30 }))).toBe(1);
});
});
+7
View File
@@ -3,6 +3,7 @@ import { mock } from 'vitest-mock-extended';
import { FrigateCardMediaPlayer } from '../../src/types.js';
import {
FrigateCardHTMLVideoElement,
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
hideMediaControlsTemporarily,
playMediaMutingIfNecessary,
setControlsOnVideo,
@@ -108,3 +109,9 @@ describe('playMediaMutingIfNecessary', () => {
expect(player.mute).toBeCalled();
});
});
describe('constants', () => {
it('MEDIA_LOAD_CONTROLS_HIDE_SECONDS', () => {
expect(MEDIA_LOAD_CONTROLS_HIDE_SECONDS).toBe(2);
});
});
+31 -2
View File
@@ -9,6 +9,7 @@ import {
FrigateCardMediaPlayer,
MediaLoadedInfo,
MenuButton,
ViewDisplayMode,
} from '../../src/types';
import { createFrigateCardCustomAction } from '../../src/utils/action';
import { MenuButtonController } from '../../src/utils/menu-controller';
@@ -67,6 +68,7 @@ const calculateButtons = (
);
};
// @vitest-environment jsdom
describe('MenuButtonController', () => {
let controller: MenuButtonController;
const dynamicButton: MenuButton = {
@@ -612,7 +614,10 @@ describe('MenuButtonController', () => {
const cameraManager = createCameraManager();
const view = createView({
queryResults: new MediaQueriesResults([new ViewMedia('clip', 'camera-1')], 0),
queryResults: new MediaQueriesResults({
results: [new ViewMedia('clip', 'camera-1')],
selectedIndex: 0,
}),
});
mock<CameraManager>(cameraManager).getMediaCapabilities.mockReturnValue(
createMediaCapabilities({ canDownload: true }),
@@ -640,7 +645,10 @@ describe('MenuButtonController', () => {
const cameraManager = createCameraManager();
const view = createView({
queryResults: new MediaQueriesResults([new ViewMedia('clip', 'camera-1')], 0),
queryResults: new MediaQueriesResults({
results: [new ViewMedia('clip', 'camera-1')],
selectedIndex: 0,
}),
});
mock<CameraManager>(cameraManager).getMediaCapabilities.mockReturnValue(
createMediaCapabilities({ canDownload: true }),
@@ -1084,6 +1092,27 @@ describe('MenuButtonController', () => {
});
});
describe('should have grid button when display mode is', () => {
it.each([['single' as const], ['grid' as const]])(
'%s',
(displayMode: ViewDisplayMode) => {
const view = createView({ view: 'live', displayMode: displayMode });
expect(calculateButtons(controller, { view: view })).toContainEqual({
icon: displayMode === 'single' ? 'mdi:grid' : 'mdi:grid-off',
enabled: true,
priority: 50,
type: 'custom:frigate-card-menu-icon',
title: 'Display mode',
tap_action: {
action: 'fire-dom-event',
frigate_card_action: 'display_mode_select',
display_mode: displayMode === 'single' ? 'grid' : 'single',
},
});
},
);
});
it('should handle dynamic buttons', () => {
const button: MenuButton = {
...dynamicButton,
+12 -14
View File
@@ -62,32 +62,30 @@ describe('generateScreenshotTitle', () => {
});
it('should get title for media viewer view with id', () => {
const media = new TestViewMedia(
'id1',
new Date('2023-06-16T18:52'),
'clip',
'camera-1',
);
const media = new TestViewMedia({
id: 'id1',
startTime: new Date('2023-06-16T18:52'),
cameraID: 'camera-1',
});
const view = createView({
view: 'media',
camera: 'camera-1',
queryResults: new MediaQueriesResults([media], 0),
queryResults: new MediaQueriesResults({ results: [media], selectedIndex: 0 }),
});
expect(generateScreenshotTitle(view)).toBe('media-camera-1-id1.jpg');
});
it('should get title for media viewer view without id', () => {
const media = new TestViewMedia(
null,
new Date('2023-06-16T18:52'),
'clip',
'camera-1',
);
const media = new TestViewMedia({
id: null,
startTime: new Date('2023-06-16T18:52'),
cameraID: 'camera-1',
});
const view = createView({
view: 'media',
camera: 'camera-1',
queryResults: new MediaQueriesResults([media], 0),
queryResults: new MediaQueriesResults({ results: [media], selectedIndex: 0 }),
});
expect(generateScreenshotTitle(view)).toBe('media-camera-1.jpg');
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import { ViewMediaType } from '../../src/view/media';
import { ViewMediaClassifier } from '../../src/view/media-classifier';
import { TestViewMedia } from '../test-utils';
describe('ViewMediaClassifier', () => {
describe('isEvent', () => {
it.each([
['clip' as const, true],
['snapshot' as const, true],
['recording' as const, false],
])('%s', (mediaType: ViewMediaType, expectedResult: boolean) => {
expect(
ViewMediaClassifier.isEvent(new TestViewMedia({ mediaType: mediaType })),
).toBe(expectedResult);
});
});
describe('isRecording', () => {
it.each([
['clip' as const, false],
['snapshot' as const, false],
['recording' as const, true],
])('%s', (mediaType: ViewMediaType, expectedResult: boolean) => {
expect(
ViewMediaClassifier.isRecording(new TestViewMedia({ mediaType: mediaType })),
).toBe(expectedResult);
});
});
describe('isClip', () => {
it.each([
['clip' as const, true],
['snapshot' as const, false],
['recording' as const, false],
])('%s', (mediaType: ViewMediaType, expectedResult: boolean) => {
expect(
ViewMediaClassifier.isClip(new TestViewMedia({ mediaType: mediaType })),
).toBe(expectedResult);
});
});
describe('isSnapshot', () => {
it.each([
['clip' as const, false],
['snapshot' as const, true],
['recording' as const, false],
])('%s', (mediaType: ViewMediaType, expectedResult: boolean) => {
expect(
ViewMediaClassifier.isSnapshot(new TestViewMedia({ mediaType: mediaType })),
).toBe(expectedResult);
});
});
describe('isVideo', () => {
it.each([
['clip' as const, true],
['snapshot' as const, false],
['recording' as const, true],
])('%s', (mediaType: ViewMediaType, expectedResult: boolean) => {
expect(
ViewMediaClassifier.isVideo(new TestViewMedia({ mediaType: mediaType })),
).toBe(expectedResult);
});
});
});
+233
View File
@@ -0,0 +1,233 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ViewMedia } from '../../src/view/media';
import { MediaQueriesResults } from '../../src/view/media-queries-results';
import { generateViewMediaArray } from '../test-utils';
describe('dispatchViewContextChangeEvent', () => {
beforeEach(() => {
vi.useRealTimers();
});
it('should function with empty results', () => {
const fakeNow = new Date('2023-08-07T20:44');
vi.useFakeTimers();
vi.setSystemTime(fakeNow);
const results = new MediaQueriesResults();
expect(results.isSupersetOf(results)).toBeFalsy();
expect(results.getCameraIDs()).toEqual(new Set());
expect(results.getResults()).toEqual([]);
expect(results.getResultsCount()).toEqual(0);
expect(results.hasResults()).toBeFalsy();
expect(results.getResult(0)).toBeNull();
expect(results.getSelectedIndex()).toBeNull();
expect(results.getSelectedResult()).toBeNull();
expect(results.hasSelectedResult()).toBeFalsy();
expect(results.resetSelectedResult()).toBe(results);
expect(results.getResultsTimestamp()).toEqual(fakeNow);
expect(results.selectIndex(0)).toEqual(results);
expect(results.getSelectedResult()).toBeNull();
expect(results.selectResultIfFound((_media: ViewMedia) => true)).toEqual(results);
expect(results.getSelectedResult()).toBeNull();
expect(results.selectBestResult((_media: ViewMedia[]) => null)).toEqual(results);
expect(results.getSelectedResult()).toBeNull();
});
it('should function with basic results', () => {
const testResults = generateViewMediaArray();
const results = new MediaQueriesResults({ results: testResults });
expect(results.isSupersetOf(results)).toBeTruthy();
expect(results.getCameraIDs()).toEqual(new Set(['kitchen', 'office']));
expect(results.getResults()).toEqual(testResults);
expect(results.getResultsCount()).toEqual(200);
expect(results.hasResults()).toBeTruthy();
expect(results.getResult(0)).not.toBeNull();
expect(results.getSelectedIndex()).toBe(199);
expect(results.getSelectedResult()).not.toBeNull();
expect(results.hasSelectedResult()).toBeTruthy();
expect(results.resetSelectedResult()).toBe(results);
expect(results.getSelectedResult()).toBeNull();
expect(results.selectIndex(100)).toEqual(results);
expect(results.getSelectedIndex()).toBe(100);
expect(
results.selectResultIfFound(
(media: ViewMedia) => media.getID() === 'id-kitchen-42',
),
).toEqual(results);
expect(results.getSelectedResult()?.getID()).toBe('id-kitchen-42');
expect(
results.selectBestResult((mediaArray: ViewMedia[]) =>
mediaArray.findIndex((media) => media.getID() === 'id-kitchen-43'),
),
).toEqual(results);
expect(results.getSelectedResult()?.getID()).toBe('id-kitchen-43');
});
it('should function with camera slice', () => {
const testResults = generateViewMediaArray();
const results = new MediaQueriesResults({ results: testResults });
const slice = results.getSlice('office');
expect(slice).not.toBeNull();
expect(slice!.getResults()).toEqual(
testResults.filter((media) => media.getCameraID() === 'office'),
);
expect(slice!.getResultsCount()).toEqual(100);
expect(slice!.hasResults()).toBeTruthy();
expect(slice!.getResult(0)).not.toBeNull();
expect(slice!.getResult()).toBeNull();
expect(slice!.getSelectedIndex()).toBe(99);
expect(slice!.getSelectedResult()?.getID()).toEqual('id-office-99');
expect(slice!.hasSelectedResult()).toBeTruthy();
expect(slice!.resetSelectedResult());
expect(slice!.getSelectedResult()).toBeNull();
expect(slice!.selectIndex(10));
expect(slice!.getSelectedIndex()).toBe(10);
expect(slice!.selectIndex(10000));
expect(slice!.getSelectedIndex()).toBe(10);
expect(slice!.selectIndex(-10000));
expect(slice!.getSelectedIndex()).toBe(10);
slice!.selectResultIfFound((media: ViewMedia) => media.getID() === 'id-office-42');
expect(slice!.getSelectedResult()?.getID()).toBe('id-office-42');
slice!.selectBestResult((mediaArray: ViewMedia[]) =>
mediaArray.findIndex((media) => media.getID() === 'id-office-43'),
);
expect(slice!.getSelectedResult()?.getID()).toBe('id-office-43');
});
describe('should respect select approach during construction', () => {
it.each([
['first' as const, 0],
['last' as const, 199],
])('%s', async (selectApproach, expectedIndex) => {
const results = new MediaQueriesResults({
results: generateViewMediaArray(),
selectApproach: selectApproach,
});
expect(results.getSelectedIndex()).toBe(expectedIndex);
});
});
it('should respect selectIndex during construction', () => {
const results = new MediaQueriesResults({
results: generateViewMediaArray(),
selectedIndex: 42,
});
expect(results.getSelectedIndex()).toBe(42);
});
it('should correctly clone a slice', () => {
const results = new MediaQueriesResults({
results: generateViewMediaArray(),
});
const slice = results.getSlice('office');
const clone = slice?.clone();
expect(clone?.getResults()).toBe(slice?.getResults());
expect(clone?.getSelectedIndex()).toBe(slice?.getSelectedIndex());
});
it('should not get slice for non-existent camera', () => {
const results = new MediaQueriesResults({
results: generateViewMediaArray(),
});
expect(results.getSlice('not-a-camera')).toBeNull();
});
it('should get main slice', () => {
const results = new MediaQueriesResults({
results: generateViewMediaArray(),
});
expect(results.getSlice()?.getResults()).toBe(results.getResults());
});
it('should correctly clone', () => {
const results = new MediaQueriesResults({
results: generateViewMediaArray(),
});
const clone = results.clone();
expect(results.getResultsTimestamp()).toBe(clone.getResultsTimestamp());
expect(results.getResults()).toBe(clone.getResults());
for (const cameraID of results.getCameraIDs()) {
expect(results.getSlice(cameraID)?.getResults()).toBe(
clone.getSlice(cameraID)?.getResults(),
);
}
});
it('should not getResults on invalid slice', () => {
const results = new MediaQueriesResults({
results: generateViewMediaArray(),
});
expect(results.getResults('not-a-camera')).toBeNull();
expect(results.getResultsCount('not-a-camera')).toBe(0);
expect(results.hasSelectedResult('not-a-camera')).toBeFalsy();
});
it('should always demote main selection', () => {
const results = new MediaQueriesResults({
results: generateViewMediaArray(),
});
results
.getSlice('office')
?.selectResultIfFound((media) => media.getID() === 'id-office-42');
// Verify main and office selections are as expected.
expect(results.getSelectedIndex()).toBe(199);
expect(results.getSelectedResult('office')?.getID()).toBe('id-office-42');
// Select a different main result...
results?.selectResultIfFound((media) => media.getID() === 'id-office-80');
// ... and ensure that selection has been demoted into the camera slice.
expect(results.getSelectedResult('office')?.getID()).toBe('id-office-80');
});
it('should promote camera selection', () => {
const results = new MediaQueriesResults({
results: generateViewMediaArray(),
});
results
.getSlice('office')
?.selectResultIfFound((media) => media.getID() === 'id-office-42');
expect(results.getSelectedIndex()).toBe(199);
results.promoteCameraSelectionToMainSelection('office');
expect(results.getSelectedIndex()).not.toBe(199);
expect(results.getSelectedResult()?.getID()).toBe('id-office-42');
});
it('should selectBestResult via advanced selection criteria', () => {
const results = new MediaQueriesResults({
results: generateViewMediaArray(),
});
results.selectBestResult(
(mediaArray: ViewMedia[]) => {
const index = mediaArray.findIndex((media) => media.getID()?.endsWith('-42'));
return index < 0 ? null : index;
},
{ allCameras: true },
);
expect(results.getSelectedResult('office')?.getID()).toBe('id-office-42');
expect(results.getSelectedResult('kitchen')?.getID()).toBe('id-kitchen-42');
});
});
+102
View File
@@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest';
import {
EventQuery,
PartialEventQuery,
PartialRecordingQuery,
QueryType,
RecordingQuery,
} from '../../src/camera-manager/types';
import { setify } from '../../src/utils/basic';
import { EventMediaQueries, RecordingMediaQueries } from '../../src/view/media-queries';
describe('EventMediaQueries', () => {
const createRawEventQueries = (
cameraIDs: string | Set<string>,
query?: PartialEventQuery,
): EventQuery[] => {
return [
{
type: QueryType.Event,
cameraIDs: setify(cameraIDs),
...query,
},
];
};
it('should construct', () => {
const rawQueries = createRawEventQueries('office');
const queries = new EventMediaQueries(rawQueries);
expect(queries.getQueries()).toBe(rawQueries);
});
it('should set', () => {
const rawQueries = createRawEventQueries('office');
const queries = new EventMediaQueries(rawQueries);
const newRawQueries = createRawEventQueries('kitchen');
queries.setQueries(newRawQueries);
expect(queries.getQueries()).toBe(newRawQueries);
});
it('should determine if queries exist for CameraIDs', () => {
const rawQueries = createRawEventQueries(new Set(['office', 'kitchen']));
const queries = new EventMediaQueries(rawQueries);
expect(queries.hasQueriesForCameraIDs(new Set(['office']))).toBeTruthy();
expect(queries.hasQueriesForCameraIDs(new Set(['office', 'kitchen']))).toBeTruthy();
expect(
queries.hasQueriesForCameraIDs(new Set(['office', 'front_door'])),
).toBeFalsy();
});
it('should convert to clips querys', () => {
const rawQueries = createRawEventQueries('office', { hasSnapshot: true });
const queries = new EventMediaQueries(rawQueries);
expect(queries.convertToClipsQueries().getQueries()).toEqual([
{
type: QueryType.Event,
cameraIDs: new Set(['office']),
hasClip: true,
},
]);
});
it('should convert when queries are null', () => {
const queries = new EventMediaQueries();
expect(queries.convertToClipsQueries().getQueries()).toBeNull();
});
it('should clone', () => {
const rawQueries = createRawEventQueries('office', { hasSnapshot: true });
const queries = new EventMediaQueries(rawQueries);
expect(queries.clone().getQueries()).toEqual(queries.getQueries());
});
});
describe('RecordingMediaQueries', () => {
const createRawRecordingQueries = (
cameraIDs: string | Set<string>,
query?: PartialRecordingQuery,
): RecordingQuery[] => {
return [
{
type: QueryType.Recording,
cameraIDs: setify(cameraIDs),
...query,
},
];
};
it('should construct', () => {
const rawQueries = createRawRecordingQueries('office');
const queries = new RecordingMediaQueries(rawQueries);
expect(queries.getQueries()).toBe(rawQueries);
});
it('should clone', () => {
const rawQueries = createRawRecordingQueries('office');
const queries = new RecordingMediaQueries(rawQueries);
expect(queries.clone().getQueries()).toEqual(queries.getQueries());
});
});
+60
View File
@@ -0,0 +1,60 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { VideoContentType, ViewMedia } from '../../src/view/media';
import { TestViewMedia } from '../test-utils';
describe('ViewMedia', () => {
beforeEach(() => {
vi.useRealTimers();
});
it('should construct', () => {
const media = new ViewMedia('clip', 'camera');
expect(media.getCameraID()).toBe('camera');
expect(media.getMediaType()).toBe('clip');
expect(media.getVideoContentType()).toBeNull();
expect(media.getID()).toBeNull();
expect(media.getStartTime()).toBeNull();
expect(media.getEndTime()).toBeNull();
expect(media.getUsableEndTime()).toBeNull();
expect(media.inProgress()).toBeNull();
expect(media.getContentID()).toBeNull();
expect(media.getTitle()).toBeNull();
expect(media.getThumbnail()).toBeNull();
expect(media.getTitle()).toBeNull();
expect(media.includesTime(new Date())).toBeFalsy();
expect(media.getWhere()).toBeNull();
expect(media.setFavorite(true)).toBeUndefined();
expect(media.isFavorite()).toBeNull();
});
it('should correctly determine if a media item includes a time', () => {
const media = new TestViewMedia({
startTime: new Date('2023-08-08T17:00:00'),
endTime: new Date('2023-08-08T17:59:59'),
});
expect(media.includesTime(new Date('2023-08-08T17:30:30'))).toBeTruthy();
expect(media.includesTime(new Date('2023-08-08T18:00:00'))).toBeFalsy();
});
it('should correctly get usable end time for in-progress event', () => {
const media = new TestViewMedia({
startTime: new Date('2023-08-08T17:00:00'),
inProgress: true,
});
vi.useFakeTimers();
const fakeNow = new Date('2023-08-08T17:15:00');
vi.setSystemTime(fakeNow);
expect(media.getUsableEndTime()).toEqual(fakeNow)
});
});
describe('VideoContentType', () => {
it('MP4', () => {
expect(VideoContentType.MP4).toBe('mp4');
});
it('HLS', () => {
expect(VideoContentType.HLS).toBe('hls');
});
});
+87 -7
View File
@@ -1,10 +1,10 @@
import { describe, expect, it, test, vi } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { QueryType } from '../../src/camera-manager/types';
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 } from '../test-utils';
import { createView, generateViewMediaArray } from '../test-utils';
// @vitest-environment jsdom
describe('View Basics', () => {
@@ -52,6 +52,7 @@ describe('View Basics', () => {
query: new EventMediaQueries(),
queryResults: new MediaQueriesResults(),
context: {},
displayMode: 'single',
});
const evolved = view.evolve({
@@ -60,12 +61,14 @@ describe('View Basics', () => {
query: new EventMediaQueries(),
queryResults: new MediaQueriesResults(),
context: {},
displayMode: 'grid',
});
expect(evolved.view).not.toBe(view.view);
expect(evolved.camera).not.toBe(view.camera);
expect(evolved.query).not.toBe(view.query);
expect(evolved.queryResults).not.toBe(view.queryResults);
expect(evolved.context).not.toBe(view.context);
expect(evolved.displayMode).not.toBe(view.displayMode);
});
it('should evolve with nothing set', () => {
@@ -119,6 +122,13 @@ describe('View Basics', () => {
expect(view.context).toEqual({});
});
it('should remove context property', () => {
const view = createView({ context: { live: { overrides: new Map() } } });
view.removeContextProperty('live', 'overrides');
expect(view.context).toEqual({ live: {} });
});
it('should detect gallery views', () => {
expect(createView({ view: 'clips' }).isGalleryView()).toBeTruthy();
expect(createView({ view: 'snapshots' }).isGalleryView()).toBeTruthy();
@@ -240,8 +250,8 @@ describe('View.isMajorMediaChange', () => {
it('should consider result change as major in other view', () => {
const media = [new ViewMedia('clip', 'camera-1'), new ViewMedia('clip', 'camera-2')];
const queryResults_1 = new MediaQueriesResults(media, 0);
const queryResults_2 = new MediaQueriesResults(media, 1);
const queryResults_1 = new MediaQueriesResults({ results: media, selectedIndex: 0 });
const queryResults_2 = new MediaQueriesResults({ results: media, selectedIndex: 1 });
expect(
View.isMajorMediaChange(
createView({ view: 'media', queryResults: queryResults_1 }),
@@ -252,8 +262,8 @@ describe('View.isMajorMediaChange', () => {
it('should not consider selected result change as major in live view', () => {
const media = [new ViewMedia('clip', 'camera-1'), new ViewMedia('clip', 'camera-2')];
const queryResults_1 = new MediaQueriesResults(media, 0);
const queryResults_2 = new MediaQueriesResults(media, 1);
const queryResults_1 = new MediaQueriesResults({ results: media, selectedIndex: 0 });
const queryResults_2 = new MediaQueriesResults({ results: media, selectedIndex: 1 });
expect(
View.isMajorMediaChange(
createView({ queryResults: queryResults_1 }),
@@ -282,7 +292,7 @@ describe('View.adoptFromViewIfAppropriate', () => {
expect(next.queryResults).toBe(queryResults);
});
test.each([
it.each([
[
new EventMediaQueries([
{ type: QueryType.Event, cameraIDs: new Set(['camera']), hasClip: true },
@@ -409,6 +419,76 @@ describe('View.adoptFromViewIfAppropriate', () => {
View.adoptFromViewIfAppropriate(next, current);
expect(next.context?.live).toEqual(current.context?.live);
});
it('should determine if display mode is grid', () => {
expect(createView({ displayMode: 'grid' }).isGrid()).toBeTruthy();
expect(createView({ displayMode: 'single' }).isGrid()).toBeFalsy();
expect(createView().isGrid()).toBeFalsy();
});
it('should determine if view supports multiple display modes', () => {
const resultsOne = new MediaQueriesResults({
results: generateViewMediaArray({ cameraIDs: ['office'] }),
});
const resultsTwo = new MediaQueriesResults({
results: generateViewMediaArray({ cameraIDs: ['office', 'kitchen'] }),
});
expect(createView({ view: 'live' }).hasMultipleDisplayModes()).toBeFalsy();
expect(createView({ view: 'live' }).hasMultipleDisplayModes(0)).toBeFalsy();
expect(createView({ view: 'live' }).hasMultipleDisplayModes(1)).toBeFalsy();
expect(createView({ view: 'live' }).hasMultipleDisplayModes(2)).toBeTruthy();
expect(createView({ view: 'media' }).hasMultipleDisplayModes()).toBeFalsy();
expect(
createView({ view: 'media', queryResults: resultsOne }).hasMultipleDisplayModes(),
).toBeFalsy();
expect(
createView({ view: 'media', queryResults: resultsTwo }).hasMultipleDisplayModes(),
).toBeTruthy();
expect(createView({ view: 'clip' }).hasMultipleDisplayModes()).toBeFalsy();
expect(
createView({ view: 'clip', queryResults: resultsOne }).hasMultipleDisplayModes(),
).toBeFalsy();
expect(
createView({ view: 'clip', queryResults: resultsTwo }).hasMultipleDisplayModes(),
).toBeTruthy();
expect(createView({ view: 'snapshot' }).hasMultipleDisplayModes()).toBeFalsy();
expect(
createView({
view: 'snapshot',
queryResults: resultsOne,
}).hasMultipleDisplayModes(),
).toBeFalsy();
expect(
createView({
view: 'snapshot',
queryResults: resultsTwo,
}).hasMultipleDisplayModes(),
).toBeTruthy();
expect(createView({ view: 'recording' }).hasMultipleDisplayModes()).toBeFalsy();
expect(
createView({
view: 'recording',
queryResults: resultsOne,
}).hasMultipleDisplayModes(),
).toBeFalsy();
expect(
createView({
view: 'recording',
queryResults: resultsTwo,
}).hasMultipleDisplayModes(),
).toBeTruthy();
expect(createView({ view: 'clips' }).hasMultipleDisplayModes()).toBeFalsy();
expect(createView({ view: 'snapshots' }).hasMultipleDisplayModes()).toBeFalsy();
expect(createView({ view: 'recordings' }).hasMultipleDisplayModes()).toBeFalsy();
expect(createView({ view: 'image' }).hasMultipleDisplayModes()).toBeFalsy();
expect(createView({ view: 'timeline' }).hasMultipleDisplayModes()).toBeFalsy();
});
});
// @vitest-environment jsdom