import { add } from 'date-fns'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import type { Camera } from '../../src/camera-manager/camera.js'; import { Capabilities } from '../../src/camera-manager/capabilities.js'; import type { CameraManagerEngineFactory } from '../../src/camera-manager/engine-factory.js'; import type { CameraManagerEngine } from '../../src/camera-manager/engine.js'; import { CameraDuplicateIDError, CameraNoEngineError, CameraNoIDError, } from '../../src/camera-manager/error.js'; import { CameraManager, CameraQueryClassifier, QueryResultClassifier, } from '../../src/camera-manager/manager.js'; import type { CameraManagerStore } from '../../src/camera-manager/store.js'; import { Engine, QueryResultsType, QueryType, type CameraEvent, type CameraManagerCameraMetadata, type EventQuery, type EventQueryResults, type MediaMetadata, type QueryResults, type RecordingQuery, type RecordingSegmentsQuery, type ReviewQuery, } from '../../src/camera-manager/types.js'; import type { CardController } from '../../src/card-controller/controller.js'; import type { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js'; import { sortItems } from '../../src/card-controller/view/sort.js'; import type { CameraConfig } from '../../src/config/schema/cameras.js'; import { advancedCameraCardConfigSchema } from '../../src/config/schema/types.js'; import { QuerySource } from '../../src/query-source.js'; import { PTZMovementType, type Endpoint } from '../../src/types.js'; import { ViewFolder, type ViewItem, type ViewMedia } from '../../src/view/item.js'; import type { ViewItemCapabilities } from '../../src/view/types.js'; import { createCameraConfig, createConfig } from '../config/test-utils'; import { createCardAPI, createFolder, createHASS } from '../test-utils.js'; import { generateViewMediaArray, TestViewMedia } from '../view/test-utils'; import { createCapabilities, createInitializedCamera } from './test-utils'; describe('QueryClassifier', () => { it('should classify event query', () => { expect(CameraQueryClassifier.isEventQuery({ type: QueryType.Event })).toBeTruthy(); expect( CameraQueryClassifier.isEventQuery({ type: QueryType.Recording }), ).toBeFalsy(); expect( CameraQueryClassifier.isEventQuery({ type: QueryType.RecordingSegments }), ).toBeFalsy(); expect( CameraQueryClassifier.isEventQuery({ type: QueryType.MediaMetadata }), ).toBeFalsy(); }); it('should classify recording query', () => { expect( CameraQueryClassifier.isRecordingQuery({ type: QueryType.Event }), ).toBeFalsy(); expect( CameraQueryClassifier.isRecordingQuery({ type: QueryType.Recording }), ).toBeTruthy(); expect( CameraQueryClassifier.isRecordingQuery({ type: QueryType.RecordingSegments }), ).toBeFalsy(); expect( CameraQueryClassifier.isRecordingQuery({ type: QueryType.MediaMetadata }), ).toBeFalsy(); }); it('should classify recording segments query', () => { expect( CameraQueryClassifier.isRecordingSegmentsQuery({ type: QueryType.Event }), ).toBeFalsy(); expect( CameraQueryClassifier.isRecordingSegmentsQuery({ type: QueryType.Recording }), ).toBeFalsy(); expect( CameraQueryClassifier.isRecordingSegmentsQuery({ type: QueryType.RecordingSegments, }), ).toBeTruthy(); expect( CameraQueryClassifier.isRecordingSegmentsQuery({ type: QueryType.MediaMetadata }), ).toBeFalsy(); }); it('should classify media metadata query', () => { expect( CameraQueryClassifier.isMediaMetadataQuery({ type: QueryType.Event }), ).toBeFalsy(); expect( CameraQueryClassifier.isMediaMetadataQuery({ type: QueryType.Recording }), ).toBeFalsy(); expect( CameraQueryClassifier.isMediaMetadataQuery({ type: QueryType.RecordingSegments }), ).toBeFalsy(); expect( CameraQueryClassifier.isMediaMetadataQuery({ type: QueryType.MediaMetadata }), ).toBeTruthy(); }); it('should classify review query', () => { expect(CameraQueryClassifier.isReviewQuery({ type: QueryType.Event })).toBeFalsy(); expect( CameraQueryClassifier.isReviewQuery({ type: QueryType.Recording }), ).toBeFalsy(); expect( CameraQueryClassifier.isReviewQuery({ type: QueryType.RecordingSegments }), ).toBeFalsy(); expect( CameraQueryClassifier.isReviewQuery({ type: QueryType.MediaMetadata }), ).toBeFalsy(); expect(CameraQueryClassifier.isReviewQuery({ type: QueryType.Review })).toBeTruthy(); }); }); describe('QueryResultClassifier', async () => { const createResults = (type: Partial): QueryResults => { return { type: type, engine: Engine.Generic, }; }; it('should classify event query result', async () => { expect( QueryResultClassifier.isEventQueryResult(createResults(QueryResultsType.Event)), ).toBeTruthy(); expect( QueryResultClassifier.isEventQueryResult( createResults(QueryResultsType.Recording), ), ).toBeFalsy(); expect( QueryResultClassifier.isEventQueryResult( createResults(QueryResultsType.RecordingSegments), ), ).toBeFalsy(); expect( QueryResultClassifier.isEventQueryResult( createResults(QueryResultsType.MediaMetadata), ), ).toBeFalsy(); }); it('should classify recording query result', async () => { expect( QueryResultClassifier.isRecordingQueryResult( createResults(QueryResultsType.Event), ), ).toBeFalsy(); expect( QueryResultClassifier.isRecordingQueryResult( createResults(QueryResultsType.Recording), ), ).toBeTruthy(); expect( QueryResultClassifier.isRecordingQueryResult( createResults(QueryResultsType.RecordingSegments), ), ).toBeFalsy(); expect( QueryResultClassifier.isRecordingQueryResult( createResults(QueryResultsType.MediaMetadata), ), ).toBeFalsy(); }); it('should classify recording segments query result', async () => { expect( QueryResultClassifier.isRecordingSegmentsQueryResult( createResults(QueryResultsType.Event), ), ).toBeFalsy(); expect( QueryResultClassifier.isRecordingSegmentsQueryResult( createResults(QueryResultsType.Recording), ), ).toBeFalsy(); expect( QueryResultClassifier.isRecordingSegmentsQueryResult( createResults(QueryResultsType.RecordingSegments), ), ).toBeTruthy(); expect( QueryResultClassifier.isRecordingSegmentsQueryResult( createResults(QueryResultsType.MediaMetadata), ), ).toBeFalsy(); }); it('should classify media metadata query result', async () => { expect( QueryResultClassifier.isMediaMetadataQueryResult( createResults(QueryResultsType.Event), ), ).toBeFalsy(); expect( QueryResultClassifier.isMediaMetadataQueryResult( createResults(QueryResultsType.Recording), ), ).toBeFalsy(); expect( QueryResultClassifier.isMediaMetadataQueryResult( createResults(QueryResultsType.RecordingSegments), ), ).toBeFalsy(); expect( QueryResultClassifier.isMediaMetadataQueryResult( createResults(QueryResultsType.MediaMetadata), ), ).toBeTruthy(); }); it('should classify review query result', async () => { expect( QueryResultClassifier.isReviewQueryResult(createResults(QueryResultsType.Event)), ).toBeFalsy(); expect( QueryResultClassifier.isReviewQueryResult( createResults(QueryResultsType.Recording), ), ).toBeFalsy(); expect( QueryResultClassifier.isReviewQueryResult( createResults(QueryResultsType.RecordingSegments), ), ).toBeFalsy(); expect( QueryResultClassifier.isReviewQueryResult( createResults(QueryResultsType.MediaMetadata), ), ).toBeFalsy(); expect( QueryResultClassifier.isReviewQueryResult(createResults(QueryResultsType.Review)), ).toBeTruthy(); }); }); describe('CameraManager', () => { const baseCameraConfig = { id: 'id', camera_entity: 'camera.foo', engine: 'generic', }; const baseEventQuery: EventQuery = { source: QuerySource.Camera, type: QueryType.Event as const, cameraIDs: new Set(['id']), }; const baseEventQueryResults: EventQueryResults = { type: QueryResultsType.Event as const, engine: Engine.Generic, }; const baseRecordingQuery = { source: QuerySource.Camera as const, type: QueryType.Recording as const, cameraIDs: new Set(['id']), }; const baseRecordingQueryResults = { type: QueryResultsType.Recording as const, engine: Engine.Generic, }; const createCameraManager = ( api: CardController, engine?: CameraManagerEngine, cameras: { config?: CameraConfig; // Replaces what the engine does for this camera, for cases the default // path cannot express (e.g. failing, or completing out of order). createCamera?: (cameraConfig: CameraConfig) => Promise; engineType?: Engine | null; capabilties?: Capabilities; stateWatcher?: StateWatcherSubscriptionInterface; }[] = [{}], factory?: CameraManagerEngineFactory, ): CameraManager => { const camerasConfig = cameras?.map( (camera) => camera.config ?? createCameraConfig(baseCameraConfig), ); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ cameras: camerasConfig, }), ); const mockFactory = factory ?? mock(); const mockEngine = engine ?? mock(); vi.mocked(mockFactory.createEngine).mockResolvedValueOnce(mockEngine); for (const camera of cameras ?? []) { const engineType = camera.engineType === undefined ? Engine.Generic : camera.engineType; if (engineType) { vi.mocked(mockEngine.createCamera).mockImplementationOnce( camera.createCamera ?? (async (cameraConfig: CameraConfig): Promise => await createInitializedCamera( cameraConfig, mockEngine, camera.capabilties ?? createCapabilities(), camera.stateWatcher, )), ); } vi.mocked(mockFactory.getEngineForCamera).mockResolvedValueOnce(engineType); } return new CameraManager(api, { factory: mockFactory }); }; it('should construct', () => { const manager = new CameraManager(createCardAPI()); expect(manager.getStore()).toBeTruthy(); }); describe('should initialize cameras from config', () => { beforeEach(() => { vi.clearAllMocks(); }); it('should succeed', async () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const manager = createCameraManager(api); await manager.initializeCamerasFromConfig(); expect(manager.getStore().getCameraCount()).toBe(1); expect(manager.isInitialized()).toBeTruthy(); }); it('should handle missing hass', async () => { const manager = createCameraManager(createCardAPI()); await manager.initializeCamerasFromConfig(); expect(manager.getStore().getCameraCount()).toBe(0); }); it('should handle missing config', async () => { const api = createCardAPI(); vi.mocked(api.getConfigManager().getConfig).mockReturnValue(null); const manager = createCameraManager(api); await manager.initializeCamerasFromConfig(); expect(manager.getStore().getCameraCount()).toBe(0); }); it('should handle missing cameras in config', async () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( advancedCameraCardConfigSchema.parse({ type: 'advanced-camera-card' as const, }), ); const manager = new CameraManager(api); await manager.initializeCamerasFromConfig(); expect(manager.getStore().getCameraCount()).toBe(0); }); it('should reject missing id', async () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const manager = createCameraManager(api, mock(), [ { config: createCameraConfig({ // No id. engine: 'generic', }), }, ]); await expect(manager.initializeCamerasFromConfig()).rejects.toThrow( CameraNoIDError, ); }); it('should reject duplicate id', async () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const cameraConfig = createCameraConfig({ id: 'DUPLICATE', engine: 'generic', }); const manager = createCameraManager(api, mock(), [ { config: cameraConfig, }, { config: cameraConfig, }, ]); await expect(manager.initializeCamerasFromConfig()).rejects.toThrow( CameraDuplicateIDError, ); }); it('should await camera destruction before throwing on duplicate id', async () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const cameraConfig = createCameraConfig({ id: 'DUPLICATE', engine: 'generic', }); // Camera.destroy() awaits its _destroyCallbacks, one of which is the // trigger-path unsubscribe. By returning a deferred Promise from each // camera's stateWatcher.unsubscribe, we make destroy completion // externally observable without spying on any Camera method. const order: string[] = []; const buildStateWatcher = (): StateWatcherSubscriptionInterface => { const watcher = mock(); vi.mocked(watcher.unsubscribe).mockImplementation( () => new Promise((resolve) => setTimeout(() => { order.push('destroy-done'); resolve(); }, 0), ), ); return watcher; }; const cameraEntry = { config: cameraConfig, capabilties: createCapabilities({ trigger: true }), }; const manager = createCameraManager(api, mock(), [ { ...cameraEntry, stateWatcher: buildStateWatcher() }, { ...cameraEntry, stateWatcher: buildStateWatcher() }, ]); await manager.initializeCamerasFromConfig().catch(() => order.push('throw')); expect(order).toEqual(['destroy-done', 'destroy-done', 'throw']); }); describe('should handle a camera that fails to initialize', () => { const createAPI = (): CardController => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); return api; }; it('should destroy a camera that initialized before the failure', async () => { const stateWatcher = mock(); const error = new Error('initialization failed'); const manager = createCameraManager(createAPI(), mock(), [ { capabilties: createCapabilities({ trigger: true }), stateWatcher }, { createCamera: () => Promise.reject(error) }, ]); await expect(manager.initializeCamerasFromConfig()).rejects.toThrow(error); expect(stateWatcher.unsubscribe).toHaveBeenCalled(); expect(manager.getStore().getCameraCount()).toBe(0); }); it('should destroy a camera that initialized after the failure', async () => { const engine = mock(); const stateWatcher = mock(); const error = new Error('initialization failed'); let releaseSlowCamera: () => void = () => {}; const slowCameraReady = new Promise((resolve) => { releaseSlowCamera = resolve; }); const manager = createCameraManager(createAPI(), engine, [ { createCamera: () => Promise.reject(error) }, { createCamera: async (cameraConfig) => { await slowCameraReady; return await createInitializedCamera( cameraConfig, engine, createCapabilities({ trigger: true }), stateWatcher, ); }, }, ]); const initialization = manager.initializeCamerasFromConfig(); releaseSlowCamera(); await expect(initialization).rejects.toThrow(error); expect(stateWatcher.unsubscribe).toHaveBeenCalled(); }); it('should report the initialization failure when a camera cannot be destroyed', async () => { const error = new Error('initialization failed'); const unluckyWatcher = mock(); vi.mocked(unluckyWatcher.unsubscribe).mockRejectedValue( new Error('destroy failed'), ); // Destroy completion is observable through the trigger-path // unsubscribe, without spying on any Camera method. const slowWatcher = mock(); let releaseSlowDestroy: () => void = () => {}; vi.mocked(slowWatcher.unsubscribe).mockImplementation( () => new Promise((resolve) => { releaseSlowDestroy = resolve; }), ); const cameraEntry = { capabilties: createCapabilities({ trigger: true }) }; const manager = createCameraManager(createAPI(), mock(), [ { ...cameraEntry, stateWatcher: unluckyWatcher }, { ...cameraEntry, stateWatcher: slowWatcher }, { createCamera: () => Promise.reject(error) }, ]); let settled = false; const initialization = manager .initializeCamerasFromConfig() .catch((e: unknown) => { settled = true; throw e; }); await vi.waitFor(() => expect(slowWatcher.unsubscribe).toHaveBeenCalled()); expect(settled).toBe(false); releaseSlowDestroy(); // The failing destroy neither masks the initialization error nor // prevents the other camera from being destroyed. await expect(initialization).rejects.toThrow(error); expect(unluckyWatcher.unsubscribe).toHaveBeenCalled(); }); }); describe('should discard cameras nothing will own', () => { const createSlowManager = (): { manager: CameraManager; stateWatcher: StateWatcherSubscriptionInterface; releaseCamera: () => void; } => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const engine = mock(); const stateWatcher = mock(); let releaseCamera: () => void = () => {}; const cameraReady = new Promise((resolve) => { releaseCamera = resolve; }); const manager = createCameraManager(api, engine, [ { createCamera: async (cameraConfig) => { await cameraReady; return await createInitializedCamera( cameraConfig, engine, createCapabilities({ trigger: true }), stateWatcher, ); }, }, ]); return { manager, stateWatcher, releaseCamera: () => releaseCamera() }; }; it('should destroy cameras built after the manager was destroyed', async () => { const { manager, stateWatcher, releaseCamera } = createSlowManager(); const initialization = manager.initializeCamerasFromConfig(); await manager.destroy(); releaseCamera(); await initialization; expect(stateWatcher.unsubscribe).toHaveBeenCalled(); expect(manager.getStore().getCameraCount()).toBe(0); }); it('should destroy cameras built by a superseded initialization', async () => { const { manager, stateWatcher, releaseCamera } = createSlowManager(); const superseded = manager.initializeCamerasFromConfig(); // A second initialization takes over. It cannot build cameras of its // own from the exhausted mocks; what is under test is that the first // initialization's cameras are discarded rather than stored. const current = manager.initializeCamerasFromConfig(); releaseCamera(); await expect(current).rejects.toThrow(CameraNoEngineError); await superseded; expect(stateWatcher.unsubscribe).toHaveBeenCalled(); expect(manager.getStore().getCameraCount()).toBe(0); }); it('should discard cameras superseded by a call that cannot proceed', async () => { const { manager, stateWatcher, releaseCamera } = createSlowManager(); const superseded = manager.initializeCamerasFromConfig(); // A newer call that returns early still supersedes: the cameras being // built belong to a configuration that no longer applies. vi.mocked(manager['_api'].getConfigManager().getConfig).mockReturnValue(null); await manager.initializeCamerasFromConfig(); releaseCamera(); await superseded; expect(stateWatcher.unsubscribe).toHaveBeenCalled(); expect(manager.getStore().getCameraCount()).toBe(0); }); it('should not reset the store while a commit is in flight', async () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ cameras: [{ id: 'id', engine: 'generic' }] }), ); const engine = mock(); const factory = mock(); vi.mocked(factory.createEngine).mockResolvedValue(engine); vi.mocked(factory.getEngineForCamera).mockResolvedValue(Engine.Generic); vi.mocked(engine.createCamera).mockImplementation( async (cameraConfig: CameraConfig) => await createInitializedCamera(cameraConfig, engine, createCapabilities()), ); // The real store mutates incrementally; a mock makes the commit window // externally controllable. const order: string[] = []; const store = mock(); let releaseCommit: () => void = () => {}; vi.mocked(store.setCameras).mockImplementation( () => new Promise((resolve) => { order.push('commit-start'); releaseCommit = () => { order.push('commit-end'); resolve(); }; }), ); vi.mocked(store.reset).mockImplementation(async () => { order.push('reset'); }); const manager = new CameraManager(api, { factory, store }); const initialization = manager.initializeCamerasFromConfig(); await vi.waitFor(() => expect(store.setCameras).toHaveBeenCalled()); const destruction = manager.destroy(); releaseCommit(); await Promise.all([initialization, destruction]); // The reset waits for the commit rather than interleaving with it. expect(order).toEqual(['commit-start', 'commit-end', 'reset']); }); }); it('should reject missing engine', async () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const manager = createCameraManager(api, mock(), [ { config: createCameraConfig({ id: 'id', }), engineType: null, }, ]); await expect(manager.initializeCamerasFromConfig()).rejects.toThrow( CameraNoEngineError, ); }); it('should pass events to triggers manager', async () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const factory = mock(); const manager = createCameraManager( api, mock(), [{}], factory, ); await manager.initializeCamerasFromConfig(); const eventCallback = factory.createEngine.mock.calls[0][1].eventCallback; const cameraEvent: CameraEvent = { cameraID: 'camera', id: 'event-1', type: 'new', }; eventCallback?.(cameraEvent); expect(api.getCameraTriggersManager().handleCameraEvent).toHaveBeenCalledWith( cameraEvent, ); }); describe('should fetch entity list when required', () => { it.each([['occupancy' as const], ['motion' as const], ['doorbell' as const]])( 'should fetch with %s trigger', async (triggerKey) => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const manager = createCameraManager(api, mock(), [ { config: createCameraConfig({ ...baseCameraConfig, triggers: { [triggerKey]: true, }, }), }, ]); await manager.initializeCamerasFromConfig(); expect(api.getEntityRegistryManager().fetchEntityList).toHaveBeenCalled(); }, ); it('should skip without entity based trigger', async () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const manager = createCameraManager(api); await manager.initializeCamerasFromConfig(); expect(api.getEntityRegistryManager().fetchEntityList).not.toHaveBeenCalled(); }); }); describe('generate default queries', () => { const setupManagerWithEngine = async () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const engine = mock(); const manager = createCameraManager(api, engine); await manager.initializeCamerasFromConfig(); return { engine, manager }; }; it('should generate default event queries', async () => { const { engine, manager } = await setupManagerWithEngine(); const queries: EventQuery[] = [baseEventQuery]; engine.generateDefaultEventQuery.mockReturnValue(queries); expect(manager.generateDefaultEventQueries('id')).toEqual(queries); }); it('should generate default recording queries', async () => { const { engine, manager } = await setupManagerWithEngine(); const queries: RecordingQuery[] = [baseRecordingQuery]; engine.generateDefaultRecordingQuery.mockReturnValue(queries); expect(manager.generateDefaultRecordingQueries('id')).toEqual(queries); }); it('should generate default recording segments queries', async () => { const { engine, manager } = await setupManagerWithEngine(); const queries: RecordingSegmentsQuery[] = [ { type: QueryType.RecordingSegments, cameraIDs: new Set(['id']), start: new Date(), end: new Date(), }, ]; engine.generateDefaultRecordingSegmentsQuery.mockReturnValue(queries); expect(manager.generateDefaultRecordingSegmentsQueries('id')).toEqual(queries); }); it('should generate default review queries', async () => { const { engine, manager } = await setupManagerWithEngine(); const queries: ReviewQuery[] = [ { source: QuerySource.Camera, type: QueryType.Review, cameraIDs: new Set(['id']), }, ]; engine.generateDefaultReviewQuery.mockReturnValue(queries); expect(manager.generateDefaultReviewQueries('id')).toEqual(queries); }); it('should handle missing camera', async () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const manager = createCameraManager(api, mock()); expect(manager.generateDefaultEventQueries('not_a_camera')).toBeNull(); }); it('should handle missing queries', async () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const engine = mock(); const manager = createCameraManager(api, engine); await manager.initializeCamerasFromConfig(); engine.generateDefaultEventQuery.mockReturnValue(null); expect(manager.generateDefaultEventQueries('id')).toBeNull(); }); }); describe('getDefaultQueryParameters', () => { it('should return empty object for non-existent camera', async () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const manager = createCameraManager(api, mock()); expect( manager.getDefaultQueryParameters('not_a_camera', QueryType.Event), ).toEqual({}); }); it('should return parameters from engine for existing camera', async () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const engine = mock(); const manager = createCameraManager(api, engine); await manager.initializeCamerasFromConfig(); engine.getDefaultQueryParameters.mockReturnValue({ what: new Set(['person']) }); expect(manager.getDefaultQueryParameters('id', QueryType.Event)).toEqual({ what: new Set(['person']), }); }); }); it('should merge defaults correctly', async () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const engine = mock(); const manager = createCameraManager(api, engine, [ { config: createCameraConfig({ ...baseCameraConfig, triggers: { media_events: ['snapshots'], }, }), }, ]); await manager.initializeCamerasFromConfig(); expect( manager.getStore().getCamera('id')?.getConfig().triggers.media_events, ).toEqual(['snapshots']); }); }); describe('should get media metadata', () => { const query = { type: QueryType.MediaMetadata as const, cameraIDs: new Set('id'), }; it('should handle empty metadata', async () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const engine = mock(); const manager = createCameraManager(api, engine); await manager.initializeCamerasFromConfig(); const queryResults = { type: QueryResultsType.MediaMetadata as const, engine: Engine.Generic, metadata: {}, }; engine.getMediaMetadata.mockResolvedValue(new Map([[query, queryResults]])); expect(await manager.getMediaMetadata()).toBeNull(); }); it.each([['days'], ['tags'], ['where'], ['what']])( 'with %s', async (metadataType: string) => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const engine = mock(); const manager = createCameraManager(api, engine); await manager.initializeCamerasFromConfig(); const metadata: MediaMetadata = { [metadataType]: new Set(['data']), }; const queryResults = { type: QueryResultsType.MediaMetadata as const, engine: Engine.Generic, metadata: metadata, }; engine.getMediaMetadata.mockResolvedValue(new Map([[query, queryResults]])); expect(await manager.getMediaMetadata()).toEqual(metadata); }, ); }); describe('should get events', () => { it('should handle missing hass', async () => { const manager = createCameraManager(createCardAPI()); expect(await manager.getEvents(baseEventQuery)).toEqual(new Map()); }); it('should handle missing cameras', async () => { const api = createCardAPI(); const hass = createHASS(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); const manager = createCameraManager(api); expect(await manager.getEvents(baseEventQuery)).toEqual(new Map()); }); it('should succeed', async () => { const api = createCardAPI(); const hass = createHASS(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); const engine = mock(); const manager = createCameraManager(api, engine); await manager.initializeCamerasFromConfig(); const engineOptions = {}; const results = new Map([[baseEventQuery, baseEventQueryResults]]); engine.getEvents.mockResolvedValue(results); expect(await manager.getEvents(baseEventQuery, engineOptions)).toEqual(results); expect(engine.getEvents).toHaveBeenCalledWith( hass, expect.anything(), baseEventQuery, engineOptions, ); }); }); describe('should review media', () => { it('should handle missing camera', async () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const manager = createCameraManager(api); const media = new TestViewMedia(); await manager.reviewMedia(media, true); }); it('should succeed', async () => { const api = createCardAPI(); const hass = createHASS(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); const engine = mock(); vi.mocked(engine.getEngineType).mockReturnValue(Engine.Generic); const manager = createCameraManager(api, engine); await manager.initializeCamerasFromConfig(); const media = new TestViewMedia({ cameraID: 'id' }); await manager.reviewMedia(media, true); expect(engine.reviewMedia).toHaveBeenCalledWith( hass, expect.anything(), media, true, ); }); }); describe('should get recordings', () => { it('should succeed', async () => { const api = createCardAPI(); const hass = createHASS(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); const engine = mock(); const manager = createCameraManager(api, engine); await manager.initializeCamerasFromConfig(); const engineOptions = {}; const results = new Map([[baseRecordingQuery, baseRecordingQueryResults]]); engine.getRecordings.mockResolvedValue(results); expect(await manager.getRecordings(baseRecordingQuery, engineOptions)).toEqual( results, ); }); }); describe('should get recording segments', () => { const query = { type: QueryType.RecordingSegments as const, cameraIDs: new Set(['id']), start: new Date(), end: new Date(), }; const queryResults = { type: QueryResultsType.RecordingSegments as const, engine: Engine.Generic, segments: [], }; it('should succeed', async () => { const api = createCardAPI(); const hass = createHASS(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); const engine = mock(); const manager = createCameraManager(api, engine); await manager.initializeCamerasFromConfig(); const engineOptions = {}; const results = new Map([[query, queryResults]]); engine.getRecordingSegments.mockResolvedValue(results); expect(await manager.getRecordingSegments(query, engineOptions)).toEqual(results); }); }); describe('should execute media queries', () => { it('should handle events', async () => { const api = createCardAPI(); const hass = createHASS(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); const engine = mock(); vi.mocked(engine.getEngineType).mockReturnValue(Engine.Generic); const manager = createCameraManager(api, engine); await manager.initializeCamerasFromConfig(); const results = new Map([[baseEventQuery, baseEventQueryResults]]); engine.getEvents.mockResolvedValue(results); const media = sortItems(generateViewMediaArray({ count: 5 })); engine.generateMediaFromEvents.mockReturnValue(media); expect(await manager.executeMediaQueries([baseEventQuery])).toEqual(media); }); it('should handle no converted media', async () => { const api = createCardAPI(); const hass = createHASS(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); const engine = mock(); vi.mocked(engine.getEngineType).mockReturnValue(Engine.Generic); const manager = createCameraManager(api, engine); await manager.initializeCamerasFromConfig(); const results = new Map([[baseEventQuery, baseEventQueryResults]]); engine.getEvents.mockResolvedValue(results); engine.generateMediaFromEvents.mockReturnValue(null); expect(await manager.executeMediaQueries([baseEventQuery])).toEqual([]); }); it('should handle missing camera engine during conversion', async () => { const api = createCardAPI(); const hass = createHASS(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); const engine = mock(); vi.mocked(engine.getEngineType).mockReturnValue(Engine.Generic); const manager = createCameraManager(api, engine); await manager.initializeCamerasFromConfig(); const results = new Map([ [baseEventQuery, { ...baseEventQueryResults, engine: Engine.MotionEye }], ]); engine.getEvents.mockResolvedValue(results); expect(await manager.executeMediaQueries([baseEventQuery])).toEqual([]); }); it('should handle recordings', async () => { const api = createCardAPI(); const hass = createHASS(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); const engine = mock(); vi.mocked(engine.getEngineType).mockReturnValue(Engine.Generic); const manager = createCameraManager(api, engine); await manager.initializeCamerasFromConfig(); const results = new Map([[baseRecordingQuery, baseRecordingQueryResults]]); engine.getRecordings.mockResolvedValue(results); const media = sortItems(generateViewMediaArray({ count: 5 })); engine.generateMediaFromRecordings.mockReturnValue(media); expect(await manager.executeMediaQueries([baseRecordingQuery])).toEqual(media); }); it('should handle missing hass', async () => { const engine = mock(); vi.mocked(engine.getEngineType).mockReturnValue(Engine.Generic); const manager = createCameraManager(createCardAPI(), engine); const results = new Map([[baseEventQuery, baseEventQueryResults]]); engine.getEvents.mockResolvedValue(results); expect(await manager.executeMediaQueries([baseEventQuery])).toEqual([]); }); describe('should merge compatible queries', () => { it('should merge queries with identical properties', async () => { const api = createCardAPI(); const engine = mock(); vi.mocked(engine.getEngineType).mockReturnValue(Engine.Generic); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const manager = createCameraManager(api, engine, [ { config: createCameraConfig({ ...baseCameraConfig, id: 'cam1' }) }, { config: createCameraConfig({ ...baseCameraConfig, id: 'cam2' }) }, ]); await manager.initializeCamerasFromConfig(); const query1: EventQuery = { source: QuerySource.Camera, type: QueryType.Event, cameraIDs: new Set(['cam1']), reviewed: false, }; const query2: EventQuery = { source: QuerySource.Camera, type: QueryType.Event, cameraIDs: new Set(['cam2']), reviewed: false, }; engine.getEvents.mockResolvedValue(new Map()); engine.generateMediaFromEvents.mockReturnValue([]); await manager.executeMediaQueries([query1, query2]); // Should be called once with merged cameraIDs expect(engine.getEvents).toHaveBeenCalledTimes(1); expect(engine.getEvents).toHaveBeenCalledWith( expect.anything(), expect.anything(), expect.objectContaining({ cameraIDs: new Set(['cam1', 'cam2']), reviewed: false, }), undefined, ); }); it('should not merge queries with different properties', async () => { const api = createCardAPI(); const engine = mock(); vi.mocked(engine.getEngineType).mockReturnValue(Engine.Generic); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const manager = createCameraManager(api, engine, [ { config: createCameraConfig({ ...baseCameraConfig, id: 'cam1' }) }, { config: createCameraConfig({ ...baseCameraConfig, id: 'cam2' }) }, ]); await manager.initializeCamerasFromConfig(); const query1: EventQuery = { source: QuerySource.Camera, type: QueryType.Event, cameraIDs: new Set(['cam1']), reviewed: false, }; const query2: EventQuery = { source: QuerySource.Camera, type: QueryType.Event, cameraIDs: new Set(['cam2']), reviewed: true, }; engine.getEvents.mockResolvedValue(new Map()); engine.generateMediaFromEvents.mockReturnValue([]); await manager.executeMediaQueries([query1, query2]); // Should be called twice for different queries expect(engine.getEvents).toHaveBeenCalledTimes(2); }); it('should handle single query without merging', async () => { const api = createCardAPI(); const engine = mock(); vi.mocked(engine.getEngineType).mockReturnValue(Engine.Generic); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const manager = createCameraManager(api, engine); await manager.initializeCamerasFromConfig(); engine.getEvents.mockResolvedValue( new Map([[baseEventQuery, baseEventQueryResults]]), ); engine.generateMediaFromEvents.mockReturnValue([]); await manager.executeMediaQueries([baseEventQuery]); expect(engine.getEvents).toHaveBeenCalledTimes(1); expect(engine.getEvents).toHaveBeenCalledWith( expect.anything(), expect.anything(), baseEventQuery, undefined, ); }); }); }); describe('should extend media queries', () => { const dateBase = new Date('2024-03-01T20:01:00'); const mediaTwoCameras = generateViewMediaArray({ count: 5 }); const mediaMixedStart: ViewItem[] = [ new TestViewMedia({ startTime: dateBase, }), new TestViewMedia({ startTime: add(dateBase, { days: 1 }), }), new TestViewMedia({ startTime: add(dateBase, { days: 2 }), }), new ViewFolder(createFolder(), []), ]; it('should handle missing hass', async () => { const engine = mock(); vi.mocked(engine.getEngineType).mockReturnValue(Engine.Generic); const manager = createCameraManager(createCardAPI(), engine); expect(await manager.extendMediaQueries([baseEventQuery], [], 'later')).toBeNull(); }); it.each([ ['empty query and results', new Map(), [], [], [], null], [ 'query without existing media', new Map([[baseEventQuery, baseEventQueryResults]]), [], [{ ...baseEventQuery, limit: 50 }], generateViewMediaArray({ count: 5 }), { queries: [{ ...baseEventQuery, limit: 50 }], results: sortItems(generateViewMediaArray({ count: 5 })), }, ], [ 'query that extends existing results', new Map([[baseEventQuery, baseEventQueryResults]]), generateViewMediaArray({ count: 5, cameraIDs: ['kitchen'] }), [{ ...baseEventQuery, limit: 50 }], generateViewMediaArray({ count: 5, cameraIDs: ['office'] }), { queries: [{ ...baseEventQuery, limit: 50 }], results: sortItems(mediaTwoCameras), }, ], [ 'query with existing media but no new media', new Map([[baseEventQuery, baseEventQueryResults]]), mediaTwoCameras, [ { ...baseEventQuery, limit: 50, }, ], // Fetch identical media again. mediaTwoCameras, // Returns null to signify nothing new. null, ], [ 'query fetching later', new Map([[{ ...baseEventQuery, start: dateBase }, baseEventQueryResults]]), mediaMixedStart, [ { ...baseEventQuery, limit: 50, start: add(dateBase, { days: 2 }), }, ], mediaTwoCameras, { queries: [{ ...baseEventQuery, limit: 50, start: dateBase }], results: sortItems(mediaMixedStart.concat(mediaTwoCameras)), }, 'later' as const, ], [ 'query fetching earlier', new Map([[{ ...baseEventQuery, start: dateBase }, baseEventQueryResults]]), mediaMixedStart, [ { ...baseEventQuery, limit: 50, end: dateBase, }, ], mediaTwoCameras, { queries: [{ ...baseEventQuery, limit: 50, start: dateBase }], results: sortItems(mediaMixedStart.concat(mediaTwoCameras)), }, 'earlier' as const, ], ])( 'handles %s', async ( _name: string, // The previously submitted query & results. inputQueries: Map, // The previously received media. inputResults: ViewItem[], // The queries expected to be dispatched. newChunkQueries: EventQuery[], // The media received from the new queries. outputMediaResults: ViewMedia[], // The expect extended queries and results. expected?: { queries: EventQuery[]; results: ViewItem[]; } | null, direction?: 'earlier' | 'later', ) => { const engine = mock(); vi.mocked(engine.getEngineType).mockReturnValue(Engine.Generic); const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const manager = createCameraManager(api, engine); await manager.initializeCamerasFromConfig(); engine.getEvents.mockResolvedValue(inputQueries); engine.generateMediaFromEvents.mockReturnValue(outputMediaResults); expect( await manager.extendMediaQueries( [...inputQueries.keys()], inputResults, direction ?? 'later', ), ).toEqual(expected); // Make sure the issued queries are correct. for (const query of newChunkQueries) { expect(engine.getEvents).toHaveBeenCalledWith( expect.anything(), expect.anything(), query, undefined, ); } }, ); }); describe('should get media download path', () => { it('should handle missing camera', async () => { const manager = createCameraManager(createCardAPI()); expect(await manager.getMediaDownloadPath(new TestViewMedia())).toBeNull(); }); it('should handle missing hass', async () => { const api = createCardAPI(); const manager = createCameraManager(api); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); await manager.initializeCamerasFromConfig(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(null); expect(await manager.getMediaDownloadPath(new TestViewMedia())).toBeNull(); }); it('should succeed', async () => { const api = createCardAPI(); const engine = mock(); const manager = createCameraManager(api, engine); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); await manager.initializeCamerasFromConfig(); const result: Endpoint = { endpoint: 'http://localhost/path/to/media', }; vi.mocked(engine.getMediaDownloadPath).mockResolvedValue(result); expect( await manager.getMediaDownloadPath(new TestViewMedia({ cameraID: 'id' })), ).toBe(result); }); }); describe('should get media capabilities', () => { it('should handle missing camera', async () => { const manager = createCameraManager(createCardAPI()); expect(manager.getMediaCapabilities(new TestViewMedia())).toBeNull(); }); it('should succeed', async () => { const api = createCardAPI(); const engine = mock(); const manager = createCameraManager(api, engine); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); await manager.initializeCamerasFromConfig(); const result: ViewItemCapabilities = { canFavorite: false, canDownload: false, }; vi.mocked(engine.getMediaCapabilities).mockReturnValue(result); expect(manager.getMediaCapabilities(new TestViewMedia({ cameraID: 'id' }))).toBe( result, ); }); }); describe('should favorite media', () => { it('should handle missing camera', async () => { const engine = mock(); const manager = createCameraManager(createCardAPI(), engine); manager.favoriteMedia(new TestViewMedia(), true); expect(engine.favoriteMedia).not.toHaveBeenCalled(); }); it('should succeed', async () => { const api = createCardAPI(); const engine = mock(); const manager = createCameraManager(api, engine); const hass = createHASS(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); await manager.initializeCamerasFromConfig(); const media = new TestViewMedia({ cameraID: 'id' }); manager.favoriteMedia(media, true); expect(engine.favoriteMedia).toHaveBeenCalledWith( hass, expect.anything(), media, true, ); }); }); describe('should get camera endpoints', () => { it('should handle missing camera', () => { const manager = createCameraManager(createCardAPI()); expect(manager.getCameraEndpoints('BAD')).toBeNull(); }); it('should succeed', async () => { const api = createCardAPI(); const engine = mock(); const manager = createCameraManager(api, engine); const hass = createHASS(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); await manager.initializeCamerasFromConfig(); expect(manager.getCameraEndpoints('id', { view: 'live' })).toBeDefined(); }); }); describe('should get camera metadata', () => { it('should handle missing camera', () => { const manager = createCameraManager(createCardAPI()); expect(manager.getCameraMetadata('BAD')).toBeNull(); }); it('should succeed', async () => { const api = createCardAPI(); const engine = mock(); const manager = createCameraManager(api, engine); const hass = createHASS(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); await manager.initializeCamerasFromConfig(); const result: CameraManagerCameraMetadata = { title: 'My Camera', icon: { icon: 'mdi:camera', }, }; vi.mocked(engine.getCameraMetadata).mockReturnValue(result); expect(manager.getCameraMetadata('id')).toBe(result); }); }); describe('should get camera capabilities', () => { it('should handle missing camera', () => { const manager = createCameraManager(createCardAPI()); expect(manager.getCameraCapabilities('BAD')).toBeNull(); }); it('should succeed', async () => { const api = createCardAPI(); const engine = mock(); const manager = createCameraManager(api, engine); const hass = createHASS(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); await manager.initializeCamerasFromConfig(); expect(manager.getCameraCapabilities('id')).toEqual(createCapabilities()); }); }); describe('should get aggregate camera capabilities', () => { it('should handle missing camera', () => { const manager = createCameraManager(createCardAPI()); const capabilities = manager.getAggregateCameraCapabilities(); expect(capabilities.has('favorite-events')).toBeFalsy(); expect(capabilities.has('favorite-recordings')).toBeFalsy(); expect(capabilities.has('seek')).toBeFalsy(); expect(capabilities.has('live')).toBeFalsy(); expect(capabilities.has('clips')).toBeFalsy(); expect(capabilities.has('recordings')).toBeFalsy(); expect(capabilities.has('snapshots')).toBeFalsy(); }); it('should succeed', async () => { const api = createCardAPI(); const manager = createCameraManager(api, mock(), [ { capabilties: new Capabilities({ 'favorite-events': false, 'favorite-recordings': false, seek: false, live: false, clips: false, recordings: false, snapshots: false, }), }, { config: createCameraConfig({ baseCameraConfig, id: 'another' }), capabilties: new Capabilities({ 'favorite-events': true, 'favorite-recordings': true, seek: true, live: true, clips: true, recordings: true, snapshots: true, ptz: { left: [PTZMovementType.Continuous], }, }), }, ]); const hass = createHASS(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); await manager.initializeCamerasFromConfig(); const capabilities = manager.getAggregateCameraCapabilities(); expect(capabilities.has('favorite-events')).toBeTruthy(); expect(capabilities.has('favorite-recordings')).toBeTruthy(); expect(capabilities.has('seek')).toBeTruthy(); expect(capabilities.has('live')).toBeTruthy(); expect(capabilities.has('clips')).toBeTruthy(); expect(capabilities.has('recordings')).toBeTruthy(); expect(capabilities.has('snapshots')).toBeTruthy(); }); }); describe('should execute PTZ action', () => { it('should handle missing camera', () => { const engine = mock(); const manager = createCameraManager(createCardAPI(), engine); manager.executePTZAction('id', 'left', {}); // No visible action. }); it('should succeed with null hass', async () => { const api = createCardAPI(); const engine = mock(); const hass = createHASS(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); const action = { action: 'perform-action' as const, perform_action: 'action', }; const manager = createCameraManager(api, engine, [ { config: createCameraConfig({ baseCameraConfig, id: 'another', ptz: { actions_left: action, }, }), }, ]); await manager.initializeCamerasFromConfig(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(null); manager.executePTZAction('another', 'left'); expect(api.getActionsManager().executeActions).toHaveBeenCalledWith({ actions: action, }); }); it('should succeed', async () => { const api = createCardAPI(); const engine = mock(); const hass = createHASS(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); const action = { action: 'perform-action' as const, perform_action: 'action', }; const manager = createCameraManager(api, engine, [ { config: createCameraConfig({ baseCameraConfig, id: 'another', ptz: { actions_left: action, }, }), }, ]); await manager.initializeCamerasFromConfig(); manager.executePTZAction('another', 'left'); expect(api.getActionsManager().executeActions).toHaveBeenCalledWith({ actions: action, }); }); describe('with rotation', () => { it.each([ // No rotation [undefined, 'left', 'left', undefined], [undefined, 'right', 'right', undefined], [undefined, 'up', 'up', undefined], [undefined, 'down', 'down', undefined], [0, 'left', 'left', undefined], [0, 'right', 'right', undefined], [0, 'up', 'up', undefined], [0, 'down', 'down', undefined], // 90° rotation (clockwise view rotation means controls rotate counter-clockwise) [90, 'left', 'down', undefined], [90, 'right', 'up', undefined], [90, 'up', 'left', undefined], [90, 'down', 'right', undefined], // 180° rotation [180, 'left', 'right', undefined], [180, 'right', 'left', undefined], [180, 'up', 'down', undefined], [180, 'down', 'up', undefined], // 270° rotation [270, 'left', 'up', undefined], [270, 'right', 'down', undefined], [270, 'up', 'right', undefined], [270, 'down', 'left', undefined], // Non-directional actions should pass through unchanged [90, 'zoom_in', 'zoom_in', undefined], [90, 'zoom_out', 'zoom_out', undefined], [90, 'preset', 'preset', 'test-preset'], [180, 'zoom_in', 'zoom_in', undefined], [180, 'zoom_out', 'zoom_out', undefined], [180, 'preset', 'preset', 'test-preset'], [270, 'zoom_in', 'zoom_in', undefined], [270, 'zoom_out', 'zoom_out', undefined], [270, 'preset', 'preset', 'test-preset'], ] as const)( 'rotates %s° %s to %s', async (rotation, inputAction, expectedAction, preset) => { const api = createCardAPI(); const engine = mock(); const hass = createHASS(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); const leftAction = { action: 'perform-action' as const, perform_action: 'left-action', }; const rightAction = { action: 'perform-action' as const, perform_action: 'right-action', }; const upAction = { action: 'perform-action' as const, perform_action: 'up-action', }; const downAction = { action: 'perform-action' as const, perform_action: 'down-action', }; const zoomInAction = { action: 'perform-action' as const, perform_action: 'zoom-in-action', }; const zoomOutAction = { action: 'perform-action' as const, perform_action: 'zoom-out-action', }; const presetAction = { action: 'perform-action' as const, perform_action: 'preset-action', }; const manager = createCameraManager(api, engine, [ { config: createCameraConfig({ baseCameraConfig, id: 'rotated-camera', dimensions: rotation !== undefined ? { rotation } : undefined, ptz: { actions_left: leftAction, actions_right: rightAction, actions_up: upAction, actions_down: downAction, actions_zoom_in: zoomInAction, actions_zoom_out: zoomOutAction, presets: { 'test-preset': presetAction, }, }, }), }, ]); await manager.initializeCamerasFromConfig(); manager.executePTZAction( 'rotated-camera', inputAction, preset ? { preset } : undefined, ); // Map expected action to the corresponding action object const expectedActionMap = { left: leftAction, right: rightAction, up: upAction, down: downAction, zoom_in: zoomInAction, zoom_out: zoomOutAction, preset: presetAction, }; expect(api.getActionsManager().executeActions).toHaveBeenCalledWith({ actions: expectedActionMap[expectedAction], }); }, ); }); }); describe('should determine if queries are fresh', () => { beforeAll(() => { const start = new Date('2024-03-02T20:35:00'); vi.useFakeTimers(); vi.setSystemTime(start); }); afterAll(() => { vi.useRealTimers(); }); it.each([ ['not fresh', new Date('2024-03-02T20:32:00'), false], ['fresh on lower bound', new Date('2024-03-02T20:34:00'), true], ['fresh at current time', new Date('2024-03-02T20:35:00'), true], ['fresh in the future', new Date('2024-03-02T20:40:00'), true], [ 'unknown camera', new Date('2024-03-02T20:35:00'), // Default assumed to be fresh. true, [ { ...baseEventQuery, cameraIDs: new Set(['BAD']), }, ], ], ])( '%s', async ( _name: string, resultsTimestamp: Date, expectedFresh: boolean, queries: EventQuery[] = [baseEventQuery], ) => { const api = createCardAPI(); const engine = mock(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const manager = createCameraManager(api, engine); await manager.initializeCamerasFromConfig(); engine.getQueryResultMaxAge.mockReturnValue(60); expect(manager.areMediaQueriesResultsFresh(resultsTimestamp, queries)).toBe( expectedFresh, ); }, ); it('should always return false for null queries', () => { const manager = createCameraManager(createCardAPI(), mock()); expect(manager.areMediaQueriesResultsFresh(new Date(), null)).toBe(false); }); }); describe('should get media seek time', () => { const startTime = new Date('2024-03-02T20:52:00'); const endTime = new Date('2024-03-02T20:53:00'); const middleTime = new Date('2024-03-02T20:52:30'); describe('invalid requests', () => { it.each([ ['null start and end', null, null, middleTime], ['no start', null, endTime, middleTime], ['no end', startTime, null, middleTime], ['target < start', endTime, endTime, startTime], ['target > end', startTime, startTime, endTime], ])( '%s', async (_name: string, start: Date | null, end: Date | null, target: Date) => { const api = createCardAPI(); const engine = mock(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const manager = createCameraManager(api, engine); await manager.initializeCamerasFromConfig(); expect( await manager.getMediaSeekTime( new TestViewMedia({ startTime: start, endTime: end }), target, ), ).toBeNull(); }, ); }); it('should succeed', async () => { const api = createCardAPI(); const engine = mock(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const manager = createCameraManager(api, engine); await manager.initializeCamerasFromConfig(); engine.getMediaSeekTime.mockResolvedValue(42); const media = new TestViewMedia({ cameraID: 'id', startTime: startTime, endTime: endTime, }); expect(await manager.getMediaSeekTime(media, middleTime)).toBe(42); expect(engine.getMediaSeekTime).toHaveBeenCalledWith( expect.anything(), expect.anything(), media, middleTime, ); }); it('should handle null return value', async () => { const api = createCardAPI(); const engine = mock(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const manager = createCameraManager(api, engine); await manager.initializeCamerasFromConfig(); engine.getMediaSeekTime.mockResolvedValue(null); const media = new TestViewMedia({ cameraID: 'id', startTime: startTime, endTime: endTime, }); expect(await manager.getMediaSeekTime(media, middleTime)).toBeNull(); }); }); it('should destroy', async () => { const api = createCardAPI(); const engine = mock(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const manager = createCameraManager(api, engine); await manager.initializeCamerasFromConfig(); expect(manager.getStore().getCameraCount()).toBe(1); await manager.destroy(); expect(manager.getStore().getCameraCount()).toBe(0); }); });