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 { ViewMedia } from '../../../src/view/media'; import { MediaQueriesResults } from '../../../src/view/media-queries-results'; import { createCameraManager, createCapabilities, createCardAPI, createStore, createView, } from '../../test-utils'; import { ViewQueryExecutor } from '../../../src/card-controller/view/view-query-executor'; import { EventMediaQueries } from '../../../src/view/media-queries'; import { QueryType } from '../../../src/camera-manager/types'; import { SetQueryViewModifier } from '../../../src/card-controller/view/modifiers/set-query'; import { View } from '../../../src/view/view'; import { QueryExecutorOptions, ViewModifier, } from '../../../src/card-controller/view/types'; describe('should act correctly when view is set', () => { it('basic view', () => { const view = createView({ view: 'live', camera: 'camera', displayMode: 'grid', }); const factory = mock(); 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(); 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(); 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(); const manager = new ViewManager(createCardAPI(), { viewFactory: factory }); expect(manager.getEpoch()).toBeTruthy(); expect(manager.getEpoch().manager).toBe(manager); }); it('reset', () => { const factory = mock(); 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(); 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(); 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.getViewDefault.mockReturnValue(createView()); const viewQueryExecutor = mock(); 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.getViewByParameters.mockReturnValue(createView()); const viewQueryExecutor = mock(); 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.getViewByParameters.mockReturnValue(createView()); const viewQueryExecutor = mock(); 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.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.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.getViewDefault.mockReturnValue(createView()); const viewQueryExecutor = mock(); 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: 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(); 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(); 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 = new Map(); overrides_1.set('camera', 'camera-2'); const overrides_2: Map = new Map(); overrides_2.set('camera', 'camera-3'); const factory = mock(); 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 = new Map(); overrides_1.set('camera', 'camera-2'); const overrides_2: Map = new Map(); overrides_2.set('camera', 'camera-3'); const factory = mock(); 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(); 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(); 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.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(); 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.getViewDefault .mockReturnValueOnce(baseView) .mockReturnValueOnce(createView({ view: 'clips' })); const viewQueryExecutor = mock(); 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.getViewDefault.mockReturnValue(createView({ view: 'live' })); const query = new EventMediaQueries(); const queryResults = new MediaQueriesResults(); const viewQueryExecutor = mock(); 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.getViewDefault.mockReturnValueOnce(createView({ view: 'live' })); const query = new EventMediaQueries(); const queryResults = new MediaQueriesResults(); const viewQueryExecutor = mock(); 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 => { // 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.getViewDefault.mockReturnValueOnce(createView({ view: 'live' })); const viewQueryExecutor = mock(); 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 => { 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.getViewDefault.mockReturnValueOnce(createView({ view: 'live' })); const viewQueryExecutor = mock(); 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 => { 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.getViewDefault.mockReturnValueOnce(createView({ view: 'live' })); const viewQueryExecutor = mock(); 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 => { 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.getViewDefault .mockReturnValueOnce(baseView) .mockReturnValueOnce(createView({ view: 'clips' })); const viewQueryExecutor = mock(); 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(); });