Refactor views to support dynamic updates.

This commit is contained in:
Dermot Duffy
2024-07-26 19:15:38 -07:00
parent 33dee47205
commit d5c4e56c45
80 changed files with 3714 additions and 4021 deletions
+751
View File
@@ -0,0 +1,751 @@
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { ViewFactory } from '../../../src/card-controller/view/factory';
import { QueryExecutor } from '../../../src/card-controller/view/query-executor';
import { ViewModifier } from '../../../src/card-controller/view/types';
import { FrigateCardView, ViewDisplayMode } from '../../../src/config/types';
import {
EventMediaQueries,
RecordingMediaQueries,
} from '../../../src/view/media-queries';
import { MediaQueriesResults } from '../../../src/view/media-queries-results';
import { View } from '../../../src/view/view';
import {
createCameraManager,
createCapabilities,
createCardAPI,
createConfig,
createStore,
} from '../../test-utils';
import { createPopulatedAPI } from './test-utils';
describe('getViewDefault', () => {
it('should return null without config', () => {
const factory = new ViewFactory(createCardAPI());
expect(factory.getViewDefault()).toBeNull();
});
it('should create view', () => {
const factory = new ViewFactory(createPopulatedAPI());
const view = factory.getViewDefault();
expect(view?.is('live')).toBeTruthy();
expect(view?.camera).toBe('camera.office');
});
it('should cycle camera when configured', () => {
const api = createPopulatedAPI();
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
view: {
default_cycle_camera: true,
},
}),
);
const factory = new ViewFactory(api);
let view = factory.getViewDefault();
expect(view?.camera).toBe('camera.office');
view = factory.getViewDefault({ baseView: view });
expect(view?.camera).toBe('camera.kitchen');
view = factory.getViewDefault({ baseView: view });
expect(view?.camera).toBe('camera.office');
// When a parameter is specified, it will not cycle.
view = factory.getViewDefault({
params: { camera: 'camera.office' },
baseView: view,
});
expect(view?.camera).toBe('camera.office');
});
it('should respect parameters', () => {
const factory = new ViewFactory(createPopulatedAPI());
const view = factory.getViewDefault({
params: {
camera: 'camera.office',
},
});
expect(view?.is('live')).toBeTruthy();
expect(view?.camera).toBe('camera.office');
});
});
describe('getViewByParameters', () => {
it('should get view by parameters specifying camera and view', () => {
const api = createPopulatedAPI();
vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue(
createCapabilities({
clips: true,
}),
);
const factory = new ViewFactory(api);
const view = factory.getViewByParameters({
params: {
camera: 'camera.kitchen',
view: 'clips',
},
});
expect(view?.is('clips')).toBeTruthy();
expect(view?.camera).toBe('camera.kitchen');
});
it('should get view by parameters using base view if unspecified', () => {
const api = createPopulatedAPI();
vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue(
createCapabilities({
clips: true,
}),
);
const factory = new ViewFactory(api);
const baseView = new View({
camera: 'camera.kitchen',
view: 'clips',
});
const view = factory.getViewByParameters({
baseView: baseView,
params: {
camera: 'camera.office',
},
});
expect(view?.view).toBe('clips');
expect(view?.camera).toBe('camera.office');
});
it('should set view by parameters using config as fallback', () => {
const api = createCardAPI();
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
createStore([
{
cameraID: 'camera.kitchen',
capabilities: createCapabilities({ live: true }),
},
]),
);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
const factory = new ViewFactory(api);
const view = factory.getViewByParameters({
params: {
camera: 'camera.kitchen',
// No prior view, and no specified view. This could happen during query
// string based initialization.
},
});
expect(view?.is('live')).toBeTruthy();
expect(view?.camera).toBe('camera.kitchen');
});
it('should not set view by parameters without config', () => {
const factory = new ViewFactory(createCardAPI());
const view = factory.getViewByParameters({
params: {
view: 'live',
},
});
expect(view).toBeNull();
});
it('should throw without camera and without failsafe', () => {
const api = createCardAPI();
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
createStore([
{
cameraID: 'camera.kitchen',
capabilities: createCapabilities({ snapshots: false }),
},
{
cameraID: 'camera.office',
// No capabilities.
capabilities: null,
},
]),
);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
const factory = new ViewFactory(api);
expect(() =>
factory.getViewByParameters({
// Since no camera is specified, and no camera supports the capabilities
// necessary for this view, the view will be null.
params: {
view: 'snapshots',
},
}),
).toThrowError(/No cameras support this view/);
});
describe('should handle unsupported view', () => {
it('should throw without failsafe', () => {
const api = createCardAPI();
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
createStore([
{
cameraID: 'camera.kitchen',
capabilities: createCapabilities({ snapshots: false }),
},
]),
);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue(
createCapabilities({
snapshots: false,
}),
);
const factory = new ViewFactory(api);
expect(() =>
factory.getViewByParameters({
params: {
camera: 'camera.kitchen',
view: 'snapshots',
},
}),
).toThrowError(/The selected camera does not support this view/);
});
it('should choose live view with failsafe', () => {
const api = createCardAPI();
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
createStore([
{
cameraID: 'camera.kitchen',
capabilities: createCapabilities({ live: true, snapshots: false }),
},
]),
);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue(
createCapabilities({
live: true,
snapshots: false,
}),
);
const factory = new ViewFactory(api);
const view = factory.getViewByParameters({
params: {
camera: 'camera.kitchen',
view: 'snapshots',
},
failSafe: true,
});
expect(view?.is('live')).toBeTruthy();
});
});
it('should call modifiers', () => {
const api = createCardAPI();
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
createStore([
{
cameraID: 'camera.office',
capabilities: createCapabilities({ live: true }),
},
]),
);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
const modifyCallback = vi.fn();
class TestViewModifier implements ViewModifier {
modify = modifyCallback;
}
const view = new View({
view: 'live',
camera: 'camera.office',
});
const factory = new ViewFactory(api);
const modifiedView = factory.getViewByParameters({
baseView: view,
modifiers: [new TestViewModifier()],
});
expect(modifiedView?.is('live')).toBeTruthy();
expect(modifiedView?.camera).toBe('camera.office');
expect(view).not.toBe(modifiedView);
expect(modifyCallback).toHaveBeenCalledWith(modifiedView);
});
describe('should get correct default display mode', () => {
describe.each([['single' as const], ['grid' as const]])(
'%s',
(displayMode: ViewDisplayMode) => {
it.each([
['media' as const],
['clip' as const],
['recording' as const],
['snapshot' as const],
['live' as const],
])('%s', (viewName: FrigateCardView) => {
const api = createPopulatedAPI({
media_viewer: {
display: {
mode: displayMode,
},
},
live: {
display: {
mode: displayMode,
},
},
});
const factory = new ViewFactory(api);
expect(
factory.getViewByParameters({
params: {
view: viewName,
},
})?.displayMode,
).toBe(displayMode);
});
},
);
});
});
describe('getViewByParametersWithNewQuery', () => {
it('should not execute query without config', async () => {
const factory = new ViewFactory(createCardAPI());
expect(await factory.getViewByParametersWithNewQuery()).toBeNull();
});
describe('with a live view', () => {
beforeAll(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-07-21T13:22:06Z'));
});
afterAll(() => {
vi.useRealTimers();
});
it('should set timeline window', async () => {
const executor = mock<QueryExecutor>();
const factory = new ViewFactory(createPopulatedAPI(), executor);
const view = await factory.getViewByParametersWithNewQuery({
params: {
view: 'live',
},
});
expect(view?.context).toEqual({
timeline: {
window: {
start: new Date('2024-07-21T12:22:06.000Z'),
end: new Date('2024-07-21T13:22:06.000Z'),
},
},
});
});
it('should not fetch anything if configured for no thumbnails', async () => {
const executor = mock<QueryExecutor>();
const factory = new ViewFactory(
createPopulatedAPI({
live: {
controls: {
thumbnails: {
mode: 'none' as const,
},
},
},
}),
executor,
);
const view = await factory.getViewByParametersWithNewQuery({
params: {
view: 'live',
},
});
expect(view?.query).toBeNull();
expect(view?.queryResults).toBeNull();
expect(executor.executeDefaultEventQuery).not.toHaveBeenCalled();
expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
});
it('should fetch events', async () => {
const executor = mock<QueryExecutor>();
const query = new EventMediaQueries();
const queryResults = new MediaQueriesResults();
executor.executeDefaultEventQuery.mockResolvedValue({
query: query,
queryResults: queryResults,
});
const factory = new ViewFactory(createPopulatedAPI(), executor);
const view = await factory.getViewByParametersWithNewQuery({
params: {
view: 'live',
},
});
expect(view?.query).toBe(query);
expect(view?.queryResults).toBe(queryResults);
expect(executor.executeDefaultEventQuery).toBeCalledWith({
cameraID: 'camera.office',
eventsMediaType: 'all',
executorOptions: {
useCache: false,
},
});
expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
});
it('should fetch recordings', async () => {
const executor = mock<QueryExecutor>();
const query = new RecordingMediaQueries();
const queryResults = new MediaQueriesResults();
executor.executeDefaultRecordingQuery.mockResolvedValue({
query: query,
queryResults: queryResults,
});
const factory = new ViewFactory(
createPopulatedAPI({
live: {
controls: {
thumbnails: {
media_type: 'recordings',
},
},
},
}),
executor,
);
const view = await factory.getViewByParametersWithNewQuery({
params: {
view: 'live',
},
});
expect(view?.query).toBe(query);
expect(view?.queryResults).toBe(queryResults);
expect(executor.executeDefaultEventQuery).not.toBeCalled();
expect(executor.executeDefaultRecordingQuery).toBeCalledWith({
cameraID: 'camera.office',
executorOptions: {
useCache: false,
},
});
});
});
describe('with a media view', () => {
it('should do nothing with same camera', async () => {
const executor = mock<QueryExecutor>();
const factory = new ViewFactory(createPopulatedAPI(), executor);
const baseView = new View({
view: 'media',
camera: 'camera.office',
});
const view = await factory.getViewByParametersWithNewQuery({
baseView: baseView,
params: {
view: 'media',
},
});
expect(view?.query).toBeNull();
expect(view?.queryResults).toBeNull();
expect(executor.executeDefaultEventQuery).not.toHaveBeenCalled();
expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
});
it('should fetch clips with different camera', async () => {
const executor = mock<QueryExecutor>();
const query = new EventMediaQueries();
const queryResults = new MediaQueriesResults();
executor.executeDefaultEventQuery.mockResolvedValue({
query: query,
queryResults: queryResults,
});
const factory = new ViewFactory(createPopulatedAPI(), executor);
const baseView = new View({
view: 'media',
camera: 'camera.office',
});
const view = await factory.getViewByParametersWithNewQuery({
baseView: baseView,
params: {
view: 'media',
camera: 'camera.kitchen',
},
});
expect(view?.query).toBe(query);
expect(view?.queryResults).toBe(queryResults);
expect(executor.executeDefaultEventQuery).toBeCalledWith({
cameraID: 'camera.kitchen',
eventsMediaType: 'clips',
executorOptions: {
useCache: false,
},
});
expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
});
});
describe('with an events-based view', () => {
it.each([
['clip' as const, 'clips' as const],
['clips' as const, 'clips' as const],
['snapshot' as const, 'snapshots' as const],
['snapshots' as const, 'snapshots' as const],
])(
'%s',
async (viewName: FrigateCardView, eventsMediaType: 'clips' | 'snapshots') => {
const executor = mock<QueryExecutor>();
const query = new EventMediaQueries();
const queryResults = new MediaQueriesResults();
executor.executeDefaultEventQuery.mockResolvedValue({
query: query,
queryResults: queryResults,
});
const factory = new ViewFactory(createPopulatedAPI(), executor);
const view = await factory.getViewByParametersWithNewQuery({
params: {
view: viewName,
},
});
expect(view?.query).toBe(query);
expect(view?.queryResults).toBe(queryResults);
expect(executor.executeDefaultEventQuery).toBeCalledWith({
cameraID: 'camera.office',
eventsMediaType: eventsMediaType,
executorOptions: {
useCache: false,
},
});
expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
},
);
});
describe('with an recordings-based view', () => {
it.each([['recording' as const], ['recordings' as const]])(
'%s',
async (viewName: FrigateCardView) => {
const executor = mock<QueryExecutor>();
const query = new RecordingMediaQueries();
const queryResults = new MediaQueriesResults();
executor.executeDefaultRecordingQuery.mockResolvedValue({
query: query,
queryResults: queryResults,
});
const factory = new ViewFactory(createPopulatedAPI(), executor);
const view = await factory.getViewByParametersWithNewQuery({
params: {
view: viewName,
},
});
expect(view?.query).toBe(query);
expect(view?.queryResults).toBe(queryResults);
expect(executor.executeDefaultEventQuery).not.toHaveBeenCalled();
expect(executor.executeDefaultRecordingQuery).toBeCalledWith({
cameraID: 'camera.office',
executorOptions: {
useCache: false,
},
});
},
);
});
describe('with an media viewer view', () => {
it('hould not fetch anything if configured for no thumbnails', async () => {
const executor = mock<QueryExecutor>();
const factory = new ViewFactory(
createPopulatedAPI({
media_viewer: {
controls: {
thumbnails: {
mode: 'none' as const,
},
},
},
}),
executor,
);
const view = await factory.getViewByParametersWithNewQuery({
params: {
view: 'clip',
},
});
expect(view?.query).toBeNull();
expect(view?.queryResults).toBeNull();
expect(executor.executeDefaultEventQuery).not.toHaveBeenCalled();
expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
});
});
describe('when changing to gallery from the media viewer', () => {
it('should adopt query and results', async () => {
const executor = mock<QueryExecutor>();
const factory = new ViewFactory(createPopulatedAPI(), executor);
const baseView = new View({
view: 'media',
camera: 'camera.office',
query: new EventMediaQueries(),
queryResults: new MediaQueriesResults(),
});
const view = await factory.getViewByParametersWithNewQuery({
baseView: baseView,
params: {
view: 'clips',
},
});
expect(view?.query).toBe(baseView.query);
expect(view?.queryResults).toBe(baseView.queryResults);
expect(executor.executeDefaultEventQuery).not.toHaveBeenCalled();
expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
});
});
describe('when set or remove seek time', () => {
it('should set seek time when results are selected based on time', async () => {
const now = new Date();
const executor = mock<QueryExecutor>();
const factory = new ViewFactory(createPopulatedAPI(), executor);
const view = await factory.getViewByParametersWithNewQuery({
params: {
view: 'clips',
},
queryExecutorOptions: {
selectResult: {
time: {
time: now,
},
},
},
});
expect(view?.context).toEqual({
mediaViewer: {
seek: now,
},
});
});
it('should remove seek time when results are not selected based on time', async () => {
const executor = mock<QueryExecutor>();
const factory = new ViewFactory(createPopulatedAPI(), executor);
const view = await factory.getViewByParametersWithNewQuery({
baseView: new View({
view: 'clips',
camera: 'camera.office',
context: {
mediaViewer: {
seek: new Date(),
},
},
}),
params: {
view: 'clips',
},
});
expect(view?.context?.mediaViewer?.seek).toBeUndefined();
});
});
});
describe('getViewDefaultWithNewQuery', () => {
it('should fetch events', async () => {
const executor = mock<QueryExecutor>();
const query = new EventMediaQueries();
const queryResults = new MediaQueriesResults();
executor.executeDefaultEventQuery.mockResolvedValue({
query: query,
queryResults: queryResults,
});
const factory = new ViewFactory(createPopulatedAPI(), executor);
const view = await factory.getViewDefaultWithNewQuery();
expect(view?.query).toBe(query);
expect(view?.queryResults).toBe(queryResults);
expect(executor.executeDefaultEventQuery).toBeCalledWith({
cameraID: 'camera.office',
eventsMediaType: 'all',
executorOptions: {
useCache: false,
},
});
expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
});
});
describe('getViewByParametersWithExistingQuery', () => {
it('should not execute anything when query is absent', async () => {
const executor = mock<QueryExecutor>();
const factory = new ViewFactory(createPopulatedAPI(), executor);
const view = await factory.getViewByParametersWithExistingQuery({
params: {
view: 'live',
camera: 'camera.office',
},
});
expect(view?.query).toBeNull();
expect(view?.queryResults).toBeNull();
expect(executor.executeDefaultEventQuery).not.toBeCalled();
expect(executor.executeDefaultRecordingQuery).not.toBeCalled();
});
it('should set query results', async () => {
const executor = mock<QueryExecutor>();
const queryResults = new MediaQueriesResults();
executor.execute.mockResolvedValue(queryResults);
const factory = new ViewFactory(createPopulatedAPI(), executor);
const query = new RecordingMediaQueries();
const view = await factory.getViewByParametersWithExistingQuery({
params: {
view: 'live',
camera: 'camera.office',
query: query,
},
});
expect(view?.query).toBe(query);
expect(view?.queryResults).toBe(queryResults);
});
});
@@ -0,0 +1,28 @@
import { ViewContext } from 'view';
import { expect, it } from 'vitest';
import { MergeContextViewModifier } from '../../../../src/card-controller/view/modifiers/merge-context';
import { createView } from '../../../test-utils';
it('should merge context', () => {
const context: ViewContext = {
timeline: {
window: {
start: new Date(),
end: new Date(),
},
},
};
const modifier = new MergeContextViewModifier(context);
const view = createView({
view: 'live',
camera: 'camera',
displayMode: 'grid',
});
expect(view.context).toBeNull();
modifier.modify(view);
expect(view.context).toEqual(context);
});
@@ -0,0 +1,25 @@
import { expect, it } from 'vitest';
import { createView } from '../../../test-utils';
import { RemoveContextPropertyViewModifier } from '../../../../src/card-controller/view/modifiers/remove-context-property';
it('should remove context property', () => {
const modifier = new RemoveContextPropertyViewModifier('timeline', 'window');
const view = createView({
view: 'live',
camera: 'camera',
displayMode: 'grid',
context: {
timeline: {
window: {
start: new Date(),
end: new Date(),
},
},
},
});
modifier.modify(view);
expect(view.context).toEqual({ timeline: {} });
});
@@ -0,0 +1,26 @@
import { expect, it } from 'vitest';
import { createView } from '../../../test-utils';
import { RemoveContextViewModifier } from '../../../../src/card-controller/view/modifiers/remove-context';
it('should remove context property', () => {
const modifier = new RemoveContextViewModifier(['timeline']);
const view = createView({
view: 'live',
camera: 'camera',
displayMode: 'grid',
context: {
timeline: {
window: {
start: new Date(),
end: new Date(),
},
},
},
});
modifier.modify(view);
expect(view.context).toEqual({});
});
@@ -0,0 +1,20 @@
import { expect, it } from 'vitest';
import { createView } from '../../../test-utils';
import { SubstreamOffViewModifier } from '../../../../src/card-controller/view/modifiers/substream-off';
import { hasSubstream, setSubstream } from '../../../../src/utils/substream';
it('should turn off substream', () => {
const view = createView({
view: 'live',
camera: 'camera',
displayMode: 'grid',
});
setSubstream(view, 'substream');
expect(hasSubstream(view)).toBe(true);
const modifier = new SubstreamOffViewModifier();
modifier.modify(view);
expect(hasSubstream(view)).toBe(false);
});
@@ -0,0 +1,101 @@
import { describe, expect, it, vi } from 'vitest';
import { CardController } from '../../../../src/card-controller/controller';
import { SubstreamOnViewModifier } from '../../../../src/card-controller/view/modifiers/substream-on';
import { RawFrigateCardConfig } from '../../../../src/config/types';
import { getStreamCameraID, hasSubstream, setSubstream } from '../../../../src/utils/substream';
import {
createCameraConfig,
createCameraManager,
createCapabilities,
createCardAPI,
createConfig,
createStore,
createView,
} from '../../../test-utils';
const createAPIWithSubstreams = (config?: RawFrigateCardConfig): CardController => {
const api = createCardAPI();
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
createStore([
{
cameraID: 'camera.office',
capabilities: createCapabilities({
live: true,
substream: true,
}),
config: createCameraConfig({
dependencies: {
all_cameras: true,
},
}),
},
{
cameraID: 'camera.kitchen',
capabilities: createCapabilities({
substream: true,
}),
},
]),
);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig(config));
return api;
};
describe('should turn on substream', () => {
it('substream available', () => {
const view = createView({
view: 'live',
camera: 'camera.office',
});
expect(hasSubstream(view)).toBe(false);
const api = createAPIWithSubstreams();
const modifier = new SubstreamOnViewModifier(api);
modifier.modify(view);
expect(hasSubstream(view)).toBe(true);
expect(getStreamCameraID(view)).toBe('camera.kitchen');
modifier.modify(view);
expect(hasSubstream(view)).toBe(false);
expect(getStreamCameraID(view)).toBe('camera.office');
});
it('malformed substream', () => {
const view = createView({
view: 'live',
camera: 'camera.office',
});
const api = createAPIWithSubstreams();
setSubstream(view, 'NOT_A_REAL_CAMERA');
const modifier = new SubstreamOnViewModifier(api);
modifier.modify(view);
expect(hasSubstream(view)).toBe(false);
expect(getStreamCameraID(view)).toBe('camera.office');
});
it('substream unavailable', () => {
const view = createView({
view: 'live',
camera: 'camera.office',
});
expect(hasSubstream(view)).toBe(false);
const api = createCardAPI();
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
const modifier = new SubstreamOnViewModifier(api);
modifier.modify(view);
expect(hasSubstream(view)).toBe(false);
});
});
@@ -0,0 +1,19 @@
import { expect, it } from 'vitest';
import { SubstreamSelectViewModifier } from '../../../../src/card-controller/view/modifiers/substream-select';
import { getStreamCameraID, hasSubstream } from '../../../../src/utils/substream';
import { createView } from '../../../test-utils';
it('should select substream', () => {
const view = createView({
view: 'live',
camera: 'camera.office',
});
expect(hasSubstream(view)).toBe(false);
const modifier = new SubstreamSelectViewModifier('substream');
modifier.modify(view);
expect(hasSubstream(view)).toBe(true);
expect(getStreamCameraID(view)).toBe('substream');
});
@@ -0,0 +1,360 @@
import { add } from 'date-fns';
import { describe, expect, it, vi } from 'vitest';
import { QueryType } from '../../../src/camera-manager/types';
import { QueryExecutor } from '../../../src/card-controller/view/query-executor';
import { ClipsOrSnapshotsOrAll } from '../../../src/types';
import { EventMediaQueries } from '../../../src/view/media-queries';
import {
TestViewMedia,
createCameraManager,
createCardAPI,
createStore,
generateViewMediaArray,
} from '../../test-utils';
import { createPopulatedAPI } from './test-utils';
describe('executeDefaultEventQuery', () => {
it('should return null without cameras', async () => {
const api = createCardAPI();
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
vi.mocked(api.getCameraManager().getStore).mockReturnValue(createStore());
const executor = new QueryExecutor(api);
expect(
await executor.executeDefaultEventQuery({
cameraID: 'camera.office',
}),
).toBeNull();
});
it('should return null without queries', async () => {
const api = createPopulatedAPI();
vi.mocked(api.getCameraManager().generateDefaultEventQueries).mockReturnValue(null);
const executor = new QueryExecutor(api);
expect(
await executor.executeDefaultEventQuery({
cameraID: 'camera.office',
}),
).toBeNull();
});
describe('should return query results for specified camera', async () => {
it.each([['all' as const], ['clips' as const], ['snapshots' as const], [undefined]])(
'%s',
async (mediaType?: ClipsOrSnapshotsOrAll) => {
const api = createPopulatedAPI();
const media = generateViewMediaArray();
const rawQueries = [
{ type: QueryType.Event as const, cameraIDs: new Set(['camera.office']) },
];
vi.mocked(api.getCameraManager().generateDefaultEventQueries).mockReturnValue(
rawQueries,
);
vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media);
const executor = new QueryExecutor(api);
const results = await executor.executeDefaultEventQuery({
cameraID: 'camera.office',
eventsMediaType: mediaType,
});
expect(results?.query.getQueries()).toEqual(rawQueries);
expect(results?.queryResults.getResults()).toEqual(media);
expect(api.getCameraManager().generateDefaultEventQueries).toBeCalledWith(
new Set(['camera.office']),
{
limit: 50,
...(mediaType === 'clips' && { hasClip: true }),
...(mediaType === 'snapshots' && { hasSnapshot: true }),
},
);
},
);
});
it('should return query results for all cameras', async () => {
const api = createPopulatedAPI();
const media = generateViewMediaArray();
const rawQueries = [
{
type: QueryType.Event as const,
cameraIDs: new Set(['camera.office', 'camera.kitchen']),
},
];
vi.mocked(api.getCameraManager().generateDefaultEventQueries).mockReturnValue(
rawQueries,
);
vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media);
const executor = new QueryExecutor(api);
const results = await executor.executeDefaultEventQuery();
expect(results?.query.getQueries()).toEqual(rawQueries);
expect(results?.queryResults.getResults()).toEqual(media);
expect(api.getCameraManager().generateDefaultEventQueries).toBeCalledWith(
new Set(['camera.office', 'camera.kitchen']),
{
limit: 50,
},
);
});
it('should return null when query returns null', async () => {
const api = createPopulatedAPI();
const rawQueries = [
{
type: QueryType.Event as const,
cameraIDs: new Set(['camera.office']),
},
];
vi.mocked(api.getCameraManager().generateDefaultEventQueries).mockReturnValue(
rawQueries,
);
vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(null);
const executor = new QueryExecutor(api);
expect(
await executor.executeDefaultEventQuery({ cameraID: 'camera.office' }),
).toBeNull();
expect(api.getCameraManager().generateDefaultEventQueries).toBeCalledWith(
new Set(['camera.office']),
{
limit: 50,
},
);
});
});
describe('executeDefaultRecordingQuery', () => {
it('should return null without cameras', async () => {
const api = createCardAPI();
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
vi.mocked(api.getCameraManager().getStore).mockReturnValue(createStore());
const executor = new QueryExecutor(api);
expect(
await executor.executeDefaultRecordingQuery({
cameraID: 'camera.office',
}),
).toBeNull();
});
it('should return null without queries', async () => {
const api = createPopulatedAPI();
vi.mocked(api.getCameraManager().generateDefaultRecordingQueries).mockReturnValue(
null,
);
const executor = new QueryExecutor(api);
expect(
await executor.executeDefaultRecordingQuery({
cameraID: 'camera.office',
}),
).toBeNull();
});
it('should return query results for specified camera', async () => {
const api = createPopulatedAPI();
const media = generateViewMediaArray();
const rawQueries = [
{ type: QueryType.Recording as const, cameraIDs: new Set(['camera.office']) },
];
vi.mocked(api.getCameraManager().generateDefaultRecordingQueries).mockReturnValue(
rawQueries,
);
vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media);
const executor = new QueryExecutor(api);
const results = await executor.executeDefaultRecordingQuery({
cameraID: 'camera.office',
});
expect(results?.query.getQueries()).toEqual(rawQueries);
expect(results?.queryResults.getResults()).toEqual(media);
expect(api.getCameraManager().generateDefaultRecordingQueries).toBeCalledWith(
new Set(['camera.office']),
{
limit: 50,
},
);
});
it('should return query results for all cameras', async () => {
const api = createPopulatedAPI();
const media = generateViewMediaArray();
const rawQueries = [
{
type: QueryType.Recording as const,
cameraIDs: new Set(['camera.office', 'camera.kitchen']),
},
];
vi.mocked(api.getCameraManager().generateDefaultRecordingQueries).mockReturnValue(
rawQueries,
);
vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media);
const executor = new QueryExecutor(api);
const results = await executor.executeDefaultRecordingQuery();
expect(results?.query.getQueries()).toEqual(rawQueries);
expect(results?.queryResults.getResults()).toEqual(media);
expect(api.getCameraManager().generateDefaultRecordingQueries).toBeCalledWith(
new Set(['camera.office', 'camera.kitchen']),
{
limit: 50,
},
);
});
it('should return null when query returns null', async () => {
const api = createPopulatedAPI();
const rawQueries = [
{
type: QueryType.Recording as const,
cameraIDs: new Set(['camera.office']),
},
];
vi.mocked(api.getCameraManager().generateDefaultRecordingQueries).mockReturnValue(
rawQueries,
);
vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(null);
const executor = new QueryExecutor(api);
expect(
await executor.executeDefaultRecordingQuery({ cameraID: 'camera.office' }),
).toBeNull();
expect(api.getCameraManager().generateDefaultRecordingQueries).toBeCalledWith(
new Set(['camera.office']),
{
limit: 50,
},
);
});
});
describe('execute', () => {
it('should return null when query is empty', async () => {
const executor = new QueryExecutor(createCardAPI());
expect(await executor.execute(new EventMediaQueries())).toBeNull();
});
describe('should handle result rejections', () => {
it('rejected', async () => {
const api = createPopulatedAPI();
const media = generateViewMediaArray();
vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media);
const query = new EventMediaQueries([
{
type: QueryType.Event as const,
cameraIDs: new Set(['camera.office']),
},
]);
const executor = new QueryExecutor(api);
expect(await executor.execute(query, { rejectResults: (_) => true })).toBeNull();
});
it('not rejected', async () => {
const api = createPopulatedAPI();
const media = generateViewMediaArray();
vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media);
const query = new EventMediaQueries([
{
type: QueryType.Event as const,
cameraIDs: new Set(['camera.office']),
},
]);
const executor = new QueryExecutor(api);
expect(
await executor.execute(query, { rejectResults: (_) => false }),
).not.toBeNull();
});
});
describe('should select', () => {
it('by id', async () => {
const api = createPopulatedAPI();
const media = generateViewMediaArray({
cameraIDs: ['camera.office'],
count: 100,
});
vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media);
const query = new EventMediaQueries([
{
type: QueryType.Event as const,
cameraIDs: new Set(['camera.office']),
},
]);
const executor = new QueryExecutor(api);
const results = await executor.execute(query, {
selectResult: { id: 'id-camera.office-42' },
});
expect(results?.getSelectedResult()?.getID()).toBe('id-camera.office-42');
});
});
it('by func', async () => {
const api = createPopulatedAPI();
const media = generateViewMediaArray({
cameraIDs: ['camera.office'],
count: 100,
});
vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media);
const query = new EventMediaQueries([
{
type: QueryType.Event as const,
cameraIDs: new Set(['camera.office']),
},
]);
const executor = new QueryExecutor(api);
const results = await executor.execute(query, {
selectResult: { func: (media) => media.getID() === 'id-camera.office-43' },
});
expect(results?.getSelectedResult()?.getID()).toBe('id-camera.office-43');
});
it('by time', async () => {
const now = new Date('2024-07-21T19:09:37Z');
const api = createPopulatedAPI();
const media = [
new TestViewMedia({
cameraID: 'camera.office',
id: 'id-camera.office-0',
startTime: now,
}),
new TestViewMedia({
cameraID: 'camera.office',
id: 'id-camera.office-1',
startTime: add(now, { seconds: 1 }),
}),
new TestViewMedia({
cameraID: 'camera.office',
id: 'id-camera.office-2',
startTime: add(now, { seconds: 2 }),
}),
];
vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media);
const query = new EventMediaQueries([
{
type: QueryType.Event as const,
cameraIDs: new Set(['camera.office']),
},
]);
const executor = new QueryExecutor(api);
const results = await executor.execute(query, {
selectResult: { time: { time: add(now, { seconds: 1 }) } },
});
expect(results?.getSelectedResult()?.getID()).toBe('id-camera.office-1');
});
});
+41
View File
@@ -0,0 +1,41 @@
import { vi } from 'vitest';
import { CardController } from '../../../src/card-controller/controller';
import { RawFrigateCardConfig } from '../../../src/config/types';
import {
createCameraManager,
createCapabilities,
createCardAPI,
createConfig,
createStore,
} from '../../test-utils';
export const createPopulatedAPI = (config?: RawFrigateCardConfig): CardController => {
const api = createCardAPI();
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
createStore([
{
cameraID: 'camera.office',
capabilities: createCapabilities({
live: true,
snapshots: true,
clips: true,
recordings: true,
substream: true,
}),
},
{
cameraID: 'camera.kitchen',
capabilities: createCapabilities({
live: true,
snapshots: true,
clips: true,
recordings: true,
substream: true,
}),
},
]),
);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig(config));
return api;
};
@@ -0,0 +1,363 @@
import { ViewContext } from 'view';
import { describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { ViewFactory } from '../../../src/card-controller/view/factory';
import { ViewManager } from '../../../src/card-controller/view/view-manager';
import { FrigateCardView } from '../../../src/config/types';
import {
createCameraManager,
createCapabilities,
createCardAPI,
createStore,
createView,
} from '../../test-utils';
import { ViewMedia } from '../../../src/view/media';
import { MediaQueriesResults } from '../../../src/view/media-queries-results';
describe('should act correctly when view is set', () => {
it('basic view', () => {
const view = createView({
view: 'live',
camera: 'camera',
displayMode: 'grid',
});
const factory = mock<ViewFactory>();
factory.getViewDefault.mockReturnValue(view);
const api = createCardAPI();
const manager = new ViewManager(api, factory);
manager.setViewDefault();
expect(manager.getView()).toBe(view);
expect(manager.hasView()).toBeTruthy();
expect(api.getMediaLoadedInfoManager().clear).toBeCalled();
expect(api.getCardElementManager().scrollReset).toBeCalled();
expect(api.getMessageManager().reset).toBeCalled();
expect(api.getStyleManager().setExpandedMode).toBeCalled();
expect(api.getConditionsManager()?.setState).toBeCalledWith({
view: 'live',
camera: 'camera',
displayMode: 'grid',
});
expect(api.getCardElementManager().update).toBeCalled();
});
it('view with minor changes without media clearing or scroll', () => {
const view_1 = createView({
view: 'live',
camera: 'camera',
});
const factory = mock<ViewFactory>();
factory.getViewDefault.mockReturnValue(view_1);
const api = createCardAPI();
const manager = new ViewManager(api, factory);
manager.setViewDefault();
vi.mocked(api.getMediaLoadedInfoManager().clear).mockClear();
vi.mocked(api.getCardElementManager().scrollReset).mockClear();
const view_2 = createView({
view: 'live',
camera: 'camera',
displayMode: 'single',
});
factory.getViewDefault.mockReturnValue(view_2);
manager.setViewDefault();
expect(manager.getView()).toBe(view_2);
// The new view is neither a major media change, nor a different view name,
// so media clearing and scrolling should not happen.
expect(api.getMediaLoadedInfoManager().clear).not.toBeCalled();
expect(api.getCardElementManager().scrollReset).not.toBeCalled();
});
});
it('setViewWithMergedContext', () => {
const api = createCardAPI();
const factory = mock<ViewFactory>();
const manager = new ViewManager(api, factory);
const context: ViewContext = { timeline: {} };
// Setting context with no existing view does nothing.
manager.setViewWithMergedContext(context);
expect(manager.getView()).toBeNull();
const view = createView({
view: 'live',
camera: 'camera',
});
factory.getViewDefault.mockReturnValue(view);
manager.setViewDefault();
manager.setViewWithMergedContext(context);
expect(manager.getView()?.camera).toBe('camera');
expect(manager.getView()?.view).toBe('live');
expect(manager.getView()?.context).toEqual(context);
});
it('getEpoch', () => {
const factory = mock<ViewFactory>();
const manager = new ViewManager(createCardAPI(), factory);
expect(manager.getEpoch()).toBeTruthy();
expect(manager.getEpoch().manager).toBe(manager);
});
it('reset', () => {
const factory = mock<ViewFactory>();
const manager = new ViewManager(createCardAPI(), factory);
manager.reset();
expect(manager.getView()).toBeNull();
expect(manager.hasView()).toBeFalsy();
factory.getViewDefault.mockReturnValue(createView());
manager.setViewDefault();
expect(manager.getView()).not.toBeNull();
expect(manager.hasView()).toBeTruthy();
manager.reset();
expect(manager.getView()).toBeNull();
expect(manager.hasView()).toBeFalsy();
});
it('setViewDefault', () => {
const factory = mock<ViewFactory>();
factory.getViewDefault.mockReturnValue(createView());
const manager = new ViewManager(createCardAPI(), factory);
manager.setViewDefault();
expect(manager.getView()?.view).toBe('live');
expect(manager.getView()?.camera).toBe('camera');
});
it('setViewByParameters', () => {
const factory = mock<ViewFactory>();
factory.getViewByParameters.mockReturnValue(createView());
const manager = new ViewManager(createCardAPI(), factory);
manager.setViewByParameters();
expect(manager.getView()?.view).toBe('live');
expect(manager.getView()?.camera).toBe('camera');
});
it('setViewDefaultWithNewQuery', async () => {
const factory = mock<ViewFactory>();
factory.getViewDefaultWithNewQuery.mockResolvedValue(createView());
const manager = new ViewManager(createCardAPI(), factory);
await manager.setViewDefaultWithNewQuery();
expect(manager.getView()?.view).toBe('live');
expect(manager.getView()?.camera).toBe('camera');
});
it('setViewByParametersWithNewQuery', async () => {
const factory = mock<ViewFactory>();
factory.getViewByParametersWithNewQuery.mockResolvedValue(createView());
const manager = new ViewManager(createCardAPI(), factory);
await manager.setViewByParametersWithNewQuery();
expect(manager.getView()?.view).toBe('live');
expect(manager.getView()?.camera).toBe('camera');
});
it('setViewByParametersWithExistingQuery', async () => {
const factory = mock<ViewFactory>();
factory.getViewByParametersWithExistingQuery.mockResolvedValue(createView());
const manager = new ViewManager(createCardAPI(), factory);
await manager.setViewByParametersWithExistingQuery();
expect(manager.getView()?.view).toBe('live');
expect(manager.getView()?.camera).toBe('camera');
});
describe('should handle exceptions', () => {
it('non-async', () => {
const factory = mock<ViewFactory>();
const error = new Error();
factory.getViewDefault.mockImplementation(() => {
throw error;
});
const api = createCardAPI();
const manager = new ViewManager(api, factory);
manager.setViewDefault();
expect(manager.hasView()).toBeFalsy();
expect(api.getMessageManager().setErrorIfHigherPriority).toBeCalledWith(error);
});
it('async', async () => {
const factory = mock<ViewFactory>();
const error = new Error();
factory.getViewByParametersWithNewQuery.mockRejectedValue(error);
const api = createCardAPI();
const manager = new ViewManager(api, factory);
await manager.setViewByParametersWithNewQuery();
expect(manager.hasView()).toBeFalsy();
expect(api.getMessageManager().setErrorIfHigherPriority).toBeCalledWith(error);
});
});
describe('isViewSupportedByCamera', () => {
it.each([
['live' as const, false],
['image' as const, true],
['diagnostics' as const, true],
['clip' as const, false],
['clips' as const, false],
['snapshot' as const, false],
['snapshots' as const, false],
['recording' as const, false],
['recordings' as const, false],
['timeline' as const, false],
['media' as const, false],
])('%s', (viewName: FrigateCardView, expected: boolean) => {
const api = createCardAPI();
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
createStore([
{
cameraID: 'camera.kitchen',
capabilities: createCapabilities({
live: false,
'favorite-events': false,
'favorite-recordings': false,
seek: false,
clips: false,
recordings: false,
snapshots: false,
}),
},
]),
);
const manager = new ViewManager(api);
expect(manager.isViewSupportedByCamera('camera', viewName)).toBe(expected);
});
});
describe('hasMajorMediaChange', () => {
it('should consider undefined views as major', () => {
const manager = new ViewManager(createCardAPI());
expect(manager.hasMajorMediaChange(undefined)).toBeFalsy();
expect(manager.hasMajorMediaChange(createView())).toBeTruthy();
expect(manager.hasMajorMediaChange()).toBeFalsy();
});
it('should consider view change as major', () => {
const factory = mock<ViewFactory>();
factory.getViewDefault.mockReturnValue(createView({ view: 'live' }));
const manager = new ViewManager(createCardAPI(), factory);
manager.setViewDefault();
expect(manager.hasMajorMediaChange(createView({ view: 'clips' }))).toBeTruthy();
});
it('should consider camera change as major', () => {
const factory = mock<ViewFactory>();
factory.getViewDefault.mockReturnValue(createView({ camera: 'camera-1' }));
const manager = new ViewManager(createCardAPI(), factory);
manager.setViewDefault();
expect(manager.hasMajorMediaChange(createView({ camera: 'camera-2' }))).toBeTruthy();
});
it('should consider live substream change as major in live view', () => {
const overrides_1: Map<string, string> = new Map();
overrides_1.set('camera', 'camera-2');
const overrides_2: Map<string, string> = new Map();
overrides_2.set('camera', 'camera-3');
const factory = mock<ViewFactory>();
factory.getViewDefault.mockReturnValue(
createView({ context: { live: { overrides: overrides_1 } } }),
);
const manager = new ViewManager(createCardAPI(), factory);
manager.setViewDefault();
expect(
manager.hasMajorMediaChange(
createView({ context: { live: { overrides: overrides_2 } } }),
),
).toBeTruthy();
});
it('should not consider live substream change as major in other view', () => {
const overrides_1: Map<string, string> = new Map();
overrides_1.set('camera', 'camera-2');
const overrides_2: Map<string, string> = new Map();
overrides_2.set('camera', 'camera-3');
const factory = mock<ViewFactory>();
factory.getViewDefault.mockReturnValue(
createView({ view: 'clips', context: { live: { overrides: overrides_1 } } }),
);
const manager = new ViewManager(createCardAPI(), factory);
manager.setViewDefault();
expect(
manager.hasMajorMediaChange(
createView({ view: 'clips', context: { live: { overrides: overrides_2 } } }),
),
).toBeFalsy();
});
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({ results: media, selectedIndex: 0 });
const queryResults_2 = new MediaQueriesResults({ results: media, selectedIndex: 1 });
const factory = mock<ViewFactory>();
factory.getViewDefault.mockReturnValue(
createView({ view: 'media', queryResults: queryResults_1 }),
);
const manager = new ViewManager(createCardAPI(), factory);
manager.setViewDefault();
expect(
manager.hasMajorMediaChange(
createView({ view: 'media', queryResults: queryResults_2 }),
),
).toBeTruthy();
});
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({ results: media, selectedIndex: 0 });
const queryResults_2 = new MediaQueriesResults({ results: media, selectedIndex: 1 });
const factory = mock<ViewFactory>();
factory.getViewDefault.mockReturnValue(createView({ queryResults: queryResults_1 }));
const manager = new ViewManager(createCardAPI(), factory);
manager.setViewDefault();
expect(
manager.hasMajorMediaChange(createView({ queryResults: queryResults_2 })),
).toBeFalsy();
});
});