Files
advanced-camera-card/tests/card-controller/view/view-manager.test.ts
T
Dermot Duffy cc376a8be1 feat: Rename the card to advanced-camera-card (#1873)
Whilst the Frigate support in this card is the best among camera
engines, the name incorrectly suggests that Frigate is a requirement.
Instead, to broaden the appeal, change to more camera agnostic name.
This does not suggest any change in priority, role or support for
Frigate.

This change is likely to be bug prone, due to the size of the rename --
the code contains 1500+ references to "Frigate" most of which make sense
to rename, some which do not, all of which needed human assessment.

- Closes #1298


BREAKING CHANGE: References to `frigate-card` in all kinds of
configuration need to be updated to `advanced-camera-card`. An automated
config upgrade should take care of the majority of usecases (click `Edit
-> Upgrade -> Save`), though may not be perfect.
2025-02-06 19:32:32 -08:00

722 lines
24 KiB
TypeScript

import { ViewContext } from 'view';
import { describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { QueryType } from '../../../src/camera-manager/types';
import { ViewFactory } from '../../../src/card-controller/view/factory';
import { SetQueryViewModifier } from '../../../src/card-controller/view/modifiers/set-query';
import {
QueryExecutorOptions,
ViewModifier,
} from '../../../src/card-controller/view/types';
import { ViewManager } from '../../../src/card-controller/view/view-manager';
import { ViewQueryExecutor } from '../../../src/card-controller/view/view-query-executor';
import { AdvancedCameraCardView } from '../../../src/config/types';
import { ViewMedia } from '../../../src/view/media';
import { EventMediaQueries } from '../../../src/view/media-queries';
import { MediaQueriesResults } from '../../../src/view/media-queries-results';
import { View } from '../../../src/view/view';
import {
createCameraManager,
createCapabilities,
createCardAPI,
createStore,
createView,
} from '../../test-utils';
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, { viewFactory: 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, { viewFactory: 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, { viewFactory: 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(), { viewFactory: factory });
expect(manager.getEpoch()).toBeTruthy();
expect(manager.getEpoch().manager).toBe(manager);
});
it('reset', () => {
const factory = mock<ViewFactory>();
const manager = new ViewManager(createCardAPI(), { viewFactory: 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(), { viewFactory: 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(), { viewFactory: factory });
manager.setViewByParameters();
expect(manager.getView()?.view).toBe('live');
expect(manager.getView()?.camera).toBe('camera');
});
it('setViewDefaultWithNewQuery', async () => {
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault.mockReturnValue(createView());
const viewQueryExecutor = mock<ViewQueryExecutor>();
viewQueryExecutor.getNewQueryModifiers.mockResolvedValue([]);
const manager = new ViewManager(createCardAPI(), {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
await manager.setViewDefaultWithNewQuery();
expect(manager.getView()?.view).toBe('live');
expect(manager.getView()?.camera).toBe('camera');
});
it('setViewByParametersWithNewQuery', async () => {
const viewFactory = mock<ViewFactory>();
viewFactory.getViewByParameters.mockReturnValue(createView());
const viewQueryExecutor = mock<ViewQueryExecutor>();
viewQueryExecutor.getNewQueryModifiers.mockResolvedValue([]);
const manager = new ViewManager(createCardAPI(), {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
await manager.setViewByParametersWithNewQuery();
expect(manager.getView()?.view).toBe('live');
expect(manager.getView()?.camera).toBe('camera');
});
it('setViewByParametersWithExistingQuery', async () => {
const viewFactory = mock<ViewFactory>();
viewFactory.getViewByParameters.mockReturnValue(createView());
const viewQueryExecutor = mock<ViewQueryExecutor>();
viewQueryExecutor.getNewQueryModifiers.mockResolvedValue([]);
const manager = new ViewManager(createCardAPI(), {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
await manager.setViewByParametersWithExistingQuery();
expect(manager.getView()?.view).toBe('live');
expect(manager.getView()?.camera).toBe('camera');
});
describe('should handle exceptions', () => {
it('should handle exceptions in sync calls', () => {
const error = new Error();
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault.mockImplementation(() => {
throw error;
});
const api = createCardAPI();
const manager = new ViewManager(api, { viewFactory: viewFactory });
manager.setViewDefault();
expect(manager.hasView()).toBeFalsy();
expect(api.getMessageManager().setErrorIfHigherPriority).toBeCalledWith(error);
});
it('should handle viewFactory exceptions in async calls', async () => {
const error = new Error();
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault.mockImplementation(() => {
throw error;
});
const api = createCardAPI();
const manager = new ViewManager(api, { viewFactory: viewFactory });
await manager.setViewDefaultWithNewQuery();
expect(manager.hasView()).toBeFalsy();
expect(api.getMessageManager().setErrorIfHigherPriority).toBeCalledWith(error);
});
it('should handle viewQueryExecutor exceptions in async calls', async () => {
const error = new Error();
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault.mockReturnValue(createView());
const viewQueryExecutor = mock<ViewQueryExecutor>();
viewQueryExecutor.getNewQueryModifiers.mockRejectedValue(error);
const api = createCardAPI();
const manager = new ViewManager(api, {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
await manager.setViewDefaultWithNewQuery();
// The initial view will have been set.
expect(manager.hasView()).toBeTruthy();
// But an error will also be generated.
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: AdvancedCameraCardView, 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(), { viewFactory: 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(), { viewFactory: 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(), { viewFactory: 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(), { viewFactory: 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(), { viewFactory: 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(), { viewFactory: factory });
manager.setViewDefault();
expect(
manager.hasMajorMediaChange(createView({ queryResults: queryResults_2 })),
).toBeFalsy();
});
});
describe('should initialize', () => {
it('without querystring', async () => {
const view = createView({
view: 'live',
camera: 'camera',
});
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault.mockReturnValue(view);
const api = createCardAPI();
const manager = new ViewManager(api, {
viewFactory: viewFactory,
});
expect(await manager.initialize()).toBeTruthy();
expect(manager.getView()).toBe(view);
});
it('with querystring', async () => {
const api = createCardAPI();
const factory = mock<ViewFactory>();
const manager = new ViewManager(api, { viewFactory: factory });
vi.mocked(api.getQueryStringManager().hasViewRelatedActionsToRun).mockReturnValue(
true,
);
expect(await manager.initialize()).toBeTruthy();
expect(manager.hasView()).toBeFalsy();
});
});
it('should adopt query and results when changing to gallery from viewer', async () => {
const baseView = createView({
view: 'media',
camera: 'camera.office',
query: new EventMediaQueries([
{
type: QueryType.Event,
cameraIDs: new Set(['camera.office']),
hasClip: true,
},
]),
queryResults: new MediaQueriesResults(),
});
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault
.mockReturnValueOnce(baseView)
.mockReturnValueOnce(createView({ view: 'clips' }));
const viewQueryExecutor = mock<ViewQueryExecutor>();
viewQueryExecutor.getNewQueryModifiers.mockResolvedValue([]);
const manager = new ViewManager(createCardAPI(), {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
manager.setViewDefault();
expect(manager.getView()?.is('media')).toBeTruthy();
await manager.setViewDefaultWithNewQuery({
params: {
view: 'clips',
},
});
expect(manager.getView()?.is('clips')).toBeTruthy();
expect(manager.getView()?.query).toBe(baseView.query);
expect(manager.getView()?.queryResults).toBe(baseView.queryResults);
expect(viewQueryExecutor.getNewQueryModifiers).not.toHaveBeenCalled();
expect(viewQueryExecutor.getExistingQueryModifiers).not.toHaveBeenCalled();
});
describe('should apply async view modifications', () => {
it('should apply modifications successfully', async () => {
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault.mockReturnValue(createView({ view: 'live' }));
const query = new EventMediaQueries();
const queryResults = new MediaQueriesResults();
const viewQueryExecutor = mock<ViewQueryExecutor>();
viewQueryExecutor.getNewQueryModifiers.mockResolvedValue([
new SetQueryViewModifier({
query: query,
queryResults: queryResults,
}),
]);
const manager = new ViewManager(createCardAPI(), {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
await manager.setViewDefaultWithNewQuery();
expect(manager.getView()?.query).toBe(query);
expect(manager.getView()?.queryResults).toBe(queryResults);
expect(manager.getView()?.context?.loading?.query).toBeUndefined();
});
it('should not apply modifications if there is a major media change', async () => {
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault.mockReturnValueOnce(createView({ view: 'live' }));
const query = new EventMediaQueries();
const queryResults = new MediaQueriesResults();
const viewQueryExecutor = mock<ViewQueryExecutor>();
const manager = new ViewManager(createCardAPI(), {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
viewQueryExecutor.getNewQueryModifiers.mockImplementation(
async (
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_view: View,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_executorqueryExecutorOptions?: QueryExecutorOptions,
): Promise<ViewModifier[] | null> => {
// Simulate a major media change while the async operation is running.
viewFactory.getViewDefault.mockReturnValueOnce(
createView({ view: 'clips', context: { loading: { query: 1 } } }),
);
manager.setViewDefault();
// Now return the modifiers (which should be ignored since there has
// been a major change in the meantime).
return [
new SetQueryViewModifier({
query: query,
queryResults: queryResults,
}),
];
},
);
await manager.setViewDefaultWithNewQuery();
// View set during the async operation should not be touched.
expect(manager.getView()?.is('clips')).toBeTruthy();
expect(manager.getView()?.query).toBeNull();
expect(manager.getView()?.queryResults).toBeNull();
expect(manager.getView()?.context?.loading?.query).toBeUndefined();
});
describe('should manage loading state correctly', () => {
it('should mark as not loading when with major media change', async () => {
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault.mockReturnValueOnce(createView({ view: 'live' }));
const viewQueryExecutor = mock<ViewQueryExecutor>();
const manager = new ViewManager(createCardAPI(), {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
viewQueryExecutor.getNewQueryModifiers.mockImplementation(
async (
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_view: View,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_executorqueryExecutorOptions?: QueryExecutorOptions,
): Promise<ViewModifier[] | null> => {
expect(manager.getView()?.context?.loading?.query).not.toBeUndefined();
// Simulate a major media change while the async operation is running.
viewFactory.getViewDefault.mockReturnValueOnce(
createView({ view: 'clips', context: { loading: { query: 1 } } }),
);
manager.setViewDefault();
// Now return the modifiers (which should be ignored since there has
// been a major change in the meantime).
return [];
},
);
await manager.setViewDefaultWithNewQuery();
expect(manager.getView()?.context?.loading?.query).toBeUndefined();
});
it('should not change loading status if something else is being loaded', async () => {
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault.mockReturnValueOnce(createView({ view: 'live' }));
const viewQueryExecutor = mock<ViewQueryExecutor>();
const manager = new ViewManager(createCardAPI(), {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
viewQueryExecutor.getNewQueryModifiers.mockImplementation(
async (
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_view: View,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_executorqueryExecutorOptions?: QueryExecutorOptions,
): Promise<ViewModifier[] | null> => {
expect(manager.getView()?.context?.loading?.query).not.toBeUndefined();
// Simulate a major media change while the async operation is running.
viewFactory.getViewDefault.mockReturnValueOnce(
createView({ view: 'clips', context: { loading: { query: 2 } } }),
);
manager.setViewDefault();
// Now return the modifiers (which should be ignored since there has
// been a major change in the meantime).
return [];
},
);
await manager.setViewDefaultWithNewQuery();
expect(manager.getView()?.context?.loading?.query).toBe(2);
});
it('should not change loading status if it is unexpected', async () => {
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault.mockReturnValueOnce(createView({ view: 'live' }));
const viewQueryExecutor = mock<ViewQueryExecutor>();
const manager = new ViewManager(createCardAPI(), {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
viewQueryExecutor.getNewQueryModifiers.mockImplementation(
async (
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_view: View,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_executorqueryExecutorOptions?: QueryExecutorOptions,
): Promise<ViewModifier[] | null> => {
expect(manager.getView()?.context?.loading?.query).not.toBeUndefined();
// Simulate another view update (without a major media change)
viewFactory.getViewDefault.mockReturnValueOnce(
createView({ context: { loading: { query: 100 } } }),
);
manager.setViewDefault();
// Now return the modifiers (which should be ignored since there has
// been a major change in the meantime).
return [];
},
);
await manager.setViewDefaultWithNewQuery();
expect(manager.getView()?.context?.loading?.query).toBe(100);
});
});
});
it('should adopt query and results when changing to gallery from viewer', async () => {
const baseView = createView({
view: 'media',
camera: 'camera.office',
query: new EventMediaQueries([
{
type: QueryType.Event,
cameraIDs: new Set(['camera.office']),
hasClip: true,
},
]),
queryResults: new MediaQueriesResults(),
});
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault
.mockReturnValueOnce(baseView)
.mockReturnValueOnce(createView({ view: 'clips' }));
const viewQueryExecutor = mock<ViewQueryExecutor>();
viewQueryExecutor.getNewQueryModifiers.mockResolvedValue([]);
const manager = new ViewManager(createCardAPI(), {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
manager.setViewDefault();
expect(manager.getView()?.is('media')).toBeTruthy();
await manager.setViewDefaultWithNewQuery({
params: {
view: 'clips',
},
});
expect(manager.getView()?.is('clips')).toBeTruthy();
expect(manager.getView()?.query).toBe(baseView.query);
expect(manager.getView()?.queryResults).toBe(baseView.queryResults);
expect(viewQueryExecutor.getNewQueryModifiers).not.toHaveBeenCalled();
expect(viewQueryExecutor.getExistingQueryModifiers).not.toHaveBeenCalled();
});