diff --git a/src/camera-manager/motioneye/engine-motioneye.ts b/src/camera-manager/motioneye/engine-motioneye.ts index f6ea8b08..2ca454b5 100644 --- a/src/camera-manager/motioneye/engine-motioneye.ts +++ b/src/camera-manager/motioneye/engine-motioneye.ts @@ -96,6 +96,8 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine parent?: RichBrowseMedia, ): BrowseMediaMetadata | null { let startDate = parent?._metadata?.startDate ?? new Date(); + /* istanbul ignore next: dateFormat is always non-null for files because + Zod requires file_pattern to contain % -- @preserve */ if (dateFormat) { const extensionlessTitle = media.title.replace(/\.[^/.]+$/, ''); startDate = parse(extensionlessTitle, dateFormat, startDate); diff --git a/tests/camera-manager/motioneye/engine-motioneye.test.ts b/tests/camera-manager/motioneye/engine-motioneye.test.ts index 6e94a1c8..f4d8239e 100644 --- a/tests/camera-manager/motioneye/engine-motioneye.test.ts +++ b/tests/camera-manager/motioneye/engine-motioneye.test.ts @@ -1,25 +1,985 @@ -import { describe, expect, it } from 'vitest'; -import { mock } from 'vitest-mock-extended'; +// @vitest-environment jsdom +import { endOfDay, startOfDay } from 'date-fns'; +import { afterEach, assert, describe, expect, it, vi } from 'vitest'; +import { mock, type MockProxy } from 'vitest-mock-extended'; +import { Camera } from '../../../src/camera-manager/camera'; +import { MotionEyeCamera } from '../../../src/camera-manager/motioneye/camera'; import { MotionEyeCameraManagerEngine } from '../../../src/camera-manager/motioneye/engine-motioneye'; -import { CameraManagerRequestCache, Engine } from '../../../src/camera-manager/types'; +import { MotionEyeEventQueryResults } from '../../../src/camera-manager/motioneye/types'; +import { CameraManagerStore } from '../../../src/camera-manager/store'; +import { + CameraManagerRequestCache, + Engine, + EventQueryResults, + QueryResultsType, + QueryType, +} from '../../../src/camera-manager/types'; import { StateWatcher } from '../../../src/card-controller/hass/state-watcher'; -import { BrowseMediaWalker } from '../../../src/ha/browse-media/walker'; +import { BrowseMediaMetadata } from '../../../src/ha/browse-media/types'; +import { BrowseMediaStep, BrowseMediaWalker } from '../../../src/ha/browse-media/walker'; +import { Entity } from '../../../src/ha/registry/entity/types'; import { ResolvedMediaCache } from '../../../src/ha/resolved-media'; +import { QuerySource } from '../../../src/query-source'; import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock'; +import { + createCameraConfig, + createHASS, + createRegistryEntity, + createRichBrowseMedia, +} from '../../test-utils'; -const createEngine = (): MotionEyeCameraManagerEngine => { +vi.mock('../../../src/ha/browse-media/browse-media-to-view-media'); + +const CAMERA_ENTITY_ID = 'camera.motioneye_test'; +const CONFIG_ENTRY_ID = 'config-entry-1'; +const DEVICE_ID = 'device-1'; + +const createEntity = (overrides?: Partial): Entity => + createRegistryEntity({ + entity_id: CAMERA_ENTITY_ID, + config_entry_id: CONFIG_ENTRY_ID, + device_id: DEVICE_ID, + platform: 'motioneye', + ...overrides, + }); + +const createEngine = (options?: { + entities?: Entity[]; + walker?: BrowseMediaWalker; + requestCache?: CameraManagerRequestCache; +}): MotionEyeCameraManagerEngine => { return new MotionEyeCameraManagerEngine( - new EntityRegistryManagerMock(), + new EntityRegistryManagerMock(options?.entities ?? [createEntity()]), mock(), - new BrowseMediaWalker(), + options?.walker ?? new BrowseMediaWalker(), new ResolvedMediaCache(), - new CameraManagerRequestCache(), + options?.requestCache ?? new CameraManagerRequestCache(), ); }; +const createMotionEyeStore = async ( + engine: MotionEyeCameraManagerEngine, + options?: { + cameraID?: string; + cameraEntity?: string; + entityOverrides?: Partial; + }, +): Promise => { + const entity = createEntity(options?.entityOverrides); + const config = createCameraConfig({ + camera_entity: options?.cameraEntity ?? CAMERA_ENTITY_ID, + }); + const camera = new MotionEyeCamera(config, engine); + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: new EntityRegistryManagerMock([entity]), + stateWatcher: mock(), + }); + camera.setID(options?.cameraID ?? 'camera-1'); + const store = new CameraManagerStore(); + store.addCamera(camera); + return store; +}; + +const getDirectorySteps = ( + walker: MockProxy, +): BrowseMediaStep[] => { + const steps = walker.walk.mock.calls[0][1]; + assert(steps); + return steps as BrowseMediaStep[]; +}; + +const getFileSteps = ( + walker: MockProxy, +): BrowseMediaStep[] => { + assert(walker.walk.mock.calls.length >= 2); + const steps = walker.walk.mock.calls[1][1]; + assert(steps); + return steps as BrowseMediaStep[]; +}; + +const createDirectoryResult = () => + createRichBrowseMedia({ + can_expand: true, + _metadata: { + cameraID: 'camera-1', + startDate: startOfDay(new Date('2026-03-14')), + endDate: endOfDay(new Date('2026-03-14')), + }, + }); + +const createDefaultEventQuery = (overrides?: Record) => ({ + type: QueryType.Event as const, + source: QuerySource.Camera as const, + cameraIDs: new Set(['camera-1']), + ...overrides, +}); + describe('MotionEyeCameraManagerEngine', () => { - it('should get correct engine type', () => { - const engine = createEngine(); - expect(engine.getEngineType()).toBe(Engine.MotionEye); + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('getEngineType', () => { + it('should return MotionEye engine type', () => { + expect(createEngine().getEngineType()).toBe(Engine.MotionEye); + }); + }); + + describe('createCamera', () => { + it('should create a MotionEyeCamera', async () => { + const engine = createEngine({ entities: [createEntity()] }); + const config = createCameraConfig({ + camera_entity: CAMERA_ENTITY_ID, + }); + + const camera = await engine.createCamera(createHASS(), config); + + expect(camera).toBeInstanceOf(Camera); + expect(camera).toBeInstanceOf(MotionEyeCamera); + }); + }); + + describe('getEvents', () => { + it('should return null for unsupported filters', async () => { + const engine = createEngine(); + const store = await createMotionEyeStore(engine); + + const result = await engine.getEvents( + createHASS(), + store, + createDefaultEventQuery({ favorite: true }), + ); + + expect(result).toBeNull(); + }); + + it('should return null when camera is not EntityCamera', async () => { + const engine = createEngine(); + const store = new CameraManagerStore(); + const camera = new Camera(createCameraConfig(), engine); + camera.setID('camera-1'); + store.addCamera(camera); + + const result = await engine.getEvents( + createHASS(), + store, + createDefaultEventQuery(), + ); + + expect(result).toBeNull(); + }); + + it('should return null when entity has no config_entry_id', async () => { + const entity = createEntity({ config_entry_id: null }); + const engine = createEngine({ entities: [entity] }); + const store = await createMotionEyeStore(engine, { + entityOverrides: { config_entry_id: null }, + }); + + const result = await engine.getEvents( + createHASS(), + store, + createDefaultEventQuery(), + ); + + expect(result).toBeNull(); + }); + + it('should return null when entity has no device_id', async () => { + const entity = createEntity({ device_id: null }); + const engine = createEngine({ entities: [entity] }); + const store = await createMotionEyeStore(engine, { + entityOverrides: { device_id: null }, + }); + + const result = await engine.getEvents( + createHASS(), + store, + createDefaultEventQuery(), + ); + + expect(result).toBeNull(); + }); + + it('should return null when walker returns empty directories', async () => { + const walker = mock(); + walker.walk.mockResolvedValue([]); + const engine = createEngine({ walker }); + const store = await createMotionEyeStore(engine); + + const result = await engine.getEvents( + createHASS(), + store, + createDefaultEventQuery(), + ); + + expect(result).toBeNull(); + }); + + it('should return null when camera config not found', async () => { + const walker = mock(); + const engine = createEngine({ walker }); + const store = await createMotionEyeStore(engine); + + const result = await engine.getEvents( + createHASS(), + store, + createDefaultEventQuery({ cameraIDs: new Set(['nonexistent']) }), + ); + + expect(result).toBeNull(); + }); + + it('should return event results from walker', async () => { + const walker = mock(); + walker.walk.mockResolvedValueOnce([createDirectoryResult()]); + walker.walk.mockResolvedValueOnce([ + createRichBrowseMedia({ + title: '20-15-00.mp4', + media_class: 'video', + can_expand: false, + _metadata: { + cameraID: 'camera-1', + startDate: new Date('2026-03-14T20:15:00'), + endDate: new Date('2026-03-14T20:15:00'), + }, + }), + ]); + + const engine = createEngine({ walker }); + const store = await createMotionEyeStore(engine); + + const result = await engine.getEvents( + createHASS(), + store, + createDefaultEventQuery(), + ); + + assert(result); + expect(result.size).toBe(1); + const queryResult = [...result.values()][0]; + expect(queryResult.engine).toBe(Engine.MotionEye); + expect(queryResult.type).toBe(QueryResultsType.Event); + }); + + it('should use cache when available', async () => { + const walker = mock(); + const requestCache = new CameraManagerRequestCache(); + walker.walk.mockResolvedValueOnce([createDirectoryResult()]); + walker.walk.mockResolvedValueOnce([]); + + const engine = createEngine({ walker, requestCache }); + const store = await createMotionEyeStore(engine); + const query = createDefaultEventQuery(); + + await engine.getEvents(createHASS(), store, query); + await engine.getEvents(createHASS(), store, query); + + // Only 2 calls (dir + file) for the first request; second uses cache. + expect(walker.walk).toHaveBeenCalledTimes(2); + }); + + it('should skip cache when useCache is false', async () => { + const walker = mock(); + const requestCache = new CameraManagerRequestCache(); + walker.walk.mockResolvedValue([createDirectoryResult()]); + + const engine = createEngine({ walker, requestCache }); + const store = await createMotionEyeStore(engine); + const query = createDefaultEventQuery(); + + await engine.getEvents(createHASS(), store, query, { useCache: false }); + await engine.getEvents(createHASS(), store, query, { useCache: false }); + + // Both requests hit the walker: 2 calls (dir + file) per request. + expect(walker.walk).toHaveBeenCalledTimes(4); + }); + + it('should not pass directory cache when useCache is false', async () => { + const walker = mock(); + walker.walk.mockResolvedValue([]); + const engine = createEngine({ walker }); + const store = await createMotionEyeStore(engine); + + await engine.getEvents(createHASS(), store, createDefaultEventQuery(), { + useCache: false, + }); + + expect(walker.walk).toHaveBeenCalledWith(expect.anything(), expect.anything(), {}); + }); + + it('should sort results descending and apply limit', async () => { + const walker = mock(); + walker.walk.mockResolvedValueOnce([createDirectoryResult()]); + const earlyMedia = createRichBrowseMedia({ + title: 'early.mp4', + _metadata: { + cameraID: 'camera-1', + startDate: new Date('2026-03-14T10:00:00'), + endDate: new Date('2026-03-14T10:00:00'), + }, + }); + const lateMedia = createRichBrowseMedia({ + title: 'late.mp4', + _metadata: { + cameraID: 'camera-1', + startDate: new Date('2026-03-14T20:00:00'), + endDate: new Date('2026-03-14T20:00:00'), + }, + }); + walker.walk.mockResolvedValueOnce([earlyMedia, lateMedia]); + + const engine = createEngine({ walker }); + const store = await createMotionEyeStore(engine); + + const result = await engine.getEvents( + createHASS(), + store, + createDefaultEventQuery({ limit: 1 }), + ); + + assert(result); + const queryResult = [...result.values()][0] as MotionEyeEventQueryResults; + expect(queryResult.browseMedia).toHaveLength(1); + expect(queryResult.browseMedia[0].title).toBe('late.mp4'); + }); + + describe('directory steps', () => { + it('should produce steps for both movies and images by default', async () => { + const walker = mock(); + walker.walk.mockResolvedValue([]); + const engine = createEngine({ walker }); + const store = await createMotionEyeStore(engine); + + await engine.getEvents(createHASS(), store, createDefaultEventQuery()); + + expect(getDirectorySteps(walker)).toHaveLength(2); + }); + + it('should produce only movies step when hasClip is true', async () => { + const walker = mock(); + walker.walk.mockResolvedValue([]); + const engine = createEngine({ walker }); + const store = await createMotionEyeStore(engine); + + await engine.getEvents( + createHASS(), + store, + createDefaultEventQuery({ hasClip: true }), + ); + + const steps = getDirectorySteps(walker); + expect(steps).toHaveLength(1); + expect(steps[0].targets[0]).toContain('#movies'); + }); + + it('should produce only images step when hasSnapshot is true', async () => { + const walker = mock(); + walker.walk.mockResolvedValue([]); + const engine = createEngine({ walker }); + const store = await createMotionEyeStore(engine); + + await engine.getEvents( + createHASS(), + store, + createDefaultEventQuery({ hasSnapshot: true }), + ); + + const steps = getDirectorySteps(walker); + expect(steps).toHaveLength(1); + expect(steps[0].targets[0]).toContain('#images'); + }); + + it('should parse valid date directories into metadata', async () => { + const walker = mock(); + walker.walk.mockResolvedValue([]); + const engine = createEngine({ walker }); + const store = await createMotionEyeStore(engine); + + await engine.getEvents(createHASS(), store, createDefaultEventQuery()); + + const step = getDirectorySteps(walker)[0]; + assert(step.metadataGenerator); + + const metadata = step.metadataGenerator( + createRichBrowseMedia({ title: '2026-03-14' }), + ); + + assert(metadata); + expect(metadata.cameraID).toBe('camera-1'); + expect(metadata.startDate).toEqual(startOfDay(new Date(2026, 2, 14))); + }); + + it('should return null metadata for invalid date directories', async () => { + const walker = mock(); + walker.walk.mockResolvedValue([]); + const engine = createEngine({ walker }); + const store = await createMotionEyeStore(engine); + + await engine.getEvents(createHASS(), store, createDefaultEventQuery()); + + const step = getDirectorySteps(walker)[0]; + assert(step.metadataGenerator); + + const metadata = step.metadataGenerator( + createRichBrowseMedia({ title: 'not-a-date' }), + ); + + expect(metadata).toBeNull(); + }); + + it('should match only expandable directories', async () => { + const walker = mock(); + walker.walk.mockResolvedValue([]); + const engine = createEngine({ walker }); + const store = await createMotionEyeStore(engine); + + await engine.getEvents(createHASS(), store, createDefaultEventQuery()); + + const step = getDirectorySteps(walker)[0]; + assert(step.matcher); + + const expandable = createRichBrowseMedia({ + can_expand: true, + _metadata: { + startDate: new Date('2026-03-14'), + endDate: new Date('2026-03-14'), + }, + }); + expect(step.matcher(expandable)).toBe(true); + + const nonExpandable = createRichBrowseMedia({ + can_expand: false, + _metadata: { + startDate: new Date('2026-03-14'), + endDate: new Date('2026-03-14'), + }, + }); + expect(step.matcher(nonExpandable)).toBe(false); + }); + + it('should return empty from advance when pattern has a single part', async () => { + const walker = mock(); + walker.walk.mockResolvedValue([]); + const engine = createEngine({ walker }); + const store = await createMotionEyeStore(engine); + + await engine.getEvents(createHASS(), store, createDefaultEventQuery()); + + const step = getDirectorySteps(walker)[0]; + assert(step.advance); + + const nextSteps = step.advance([createRichBrowseMedia({ can_expand: true })]); + // Default pattern '%Y-%m-%d' is a single part, so no further steps. + expect(nextSteps).toEqual([]); + }); + + it('should use parent metadata for literal directory parts', async () => { + const walker = mock(); + walker.walk.mockResolvedValue([]); + const config = createCameraConfig({ + camera_entity: CAMERA_ENTITY_ID, + motioneye: { + movies: { + directory_pattern: 'fixed/%Y-%m-%d', + file_pattern: '%H-%M-%S', + }, + images: { + directory_pattern: 'fixed/%Y-%m-%d', + file_pattern: '%H-%M-%S', + }, + }, + }); + + const engine = createEngine({ walker }); + const camera = new MotionEyeCamera(config, engine); + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: new EntityRegistryManagerMock([createEntity()]), + stateWatcher: mock(), + }); + camera.setID('camera-1'); + const store = new CameraManagerStore(); + store.addCamera(camera); + + await engine.getEvents(createHASS(), store, createDefaultEventQuery()); + + const fixedStep = getDirectorySteps(walker)[0]; + assert(fixedStep.metadataGenerator); + + const parentMedia = createRichBrowseMedia({ + _metadata: { + startDate: new Date('2026-03-14'), + endDate: new Date('2026-03-15'), + }, + }); + const metadata = fixedStep.metadataGenerator( + createRichBrowseMedia({ title: 'fixed' }), + parentMedia, + ); + + assert(metadata); + expect(metadata.endDate).toEqual(new Date('2026-03-15')); + }); + + it('should match literal directory parts by title', async () => { + const walker = mock(); + walker.walk.mockResolvedValue([]); + const config = createCameraConfig({ + camera_entity: CAMERA_ENTITY_ID, + motioneye: { + movies: { + directory_pattern: 'fixed/%Y-%m-%d', + file_pattern: '%H-%M-%S', + }, + images: { + directory_pattern: 'fixed/%Y-%m-%d', + file_pattern: '%H-%M-%S', + }, + }, + }); + + const engine = createEngine({ walker }); + const camera = new MotionEyeCamera(config, engine); + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: new EntityRegistryManagerMock([createEntity()]), + stateWatcher: mock(), + }); + camera.setID('camera-1'); + const store = new CameraManagerStore(); + store.addCamera(camera); + + await engine.getEvents(createHASS(), store, createDefaultEventQuery()); + + const fixedStep = getDirectorySteps(walker)[0]; + assert(fixedStep.matcher); + + const matching = createRichBrowseMedia({ + title: 'fixed', + can_expand: true, + _metadata: { + startDate: new Date('2026-03-14'), + endDate: new Date('2026-03-14'), + }, + }); + expect(fixedStep.matcher(matching)).toBe(true); + + const nonMatching = createRichBrowseMedia({ + title: 'other', + can_expand: true, + _metadata: { + startDate: new Date('2026-03-14'), + endDate: new Date('2026-03-14'), + }, + }); + expect(fixedStep.matcher(nonMatching)).toBe(false); + }); + + it('should advance to next step for multi-part directory patterns', async () => { + const walker = mock(); + walker.walk.mockResolvedValue([]); + const config = createCameraConfig({ + camera_entity: CAMERA_ENTITY_ID, + motioneye: { + movies: { + directory_pattern: 'fixed/%Y-%m-%d', + file_pattern: '%H-%M-%S', + }, + images: { + directory_pattern: 'fixed/%Y-%m-%d', + file_pattern: '%H-%M-%S', + }, + }, + }); + + const engine = createEngine({ walker }); + const camera = new MotionEyeCamera(config, engine); + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: new EntityRegistryManagerMock([createEntity()]), + stateWatcher: mock(), + }); + camera.setID('camera-1'); + const store = new CameraManagerStore(); + store.addCamera(camera); + + await engine.getEvents(createHASS(), store, createDefaultEventQuery()); + + const fixedStep = getDirectorySteps(walker)[0]; + assert(fixedStep.advance); + + const nextSteps = fixedStep.advance([ + createRichBrowseMedia({ can_expand: true }), + ]); + expect(nextSteps).toHaveLength(1); + }); + }); + + describe('file steps', () => { + it('should generate metadata for video files', async () => { + const walker = mock(); + walker.walk.mockResolvedValueOnce([createDirectoryResult()]); + walker.walk.mockResolvedValueOnce([]); + const engine = createEngine({ walker }); + const store = await createMotionEyeStore(engine); + + await engine.getEvents(createHASS(), store, createDefaultEventQuery()); + + const fileStep = getFileSteps(walker)[0]; + assert(fileStep.metadataGenerator); + + const metadata = fileStep.metadataGenerator( + createRichBrowseMedia({ + title: '20-15-00.mp4', + media_class: 'video', + }), + ); + + assert(metadata); + expect(metadata.cameraID).toBe('camera-1'); + }); + + it('should generate metadata for image files', async () => { + const walker = mock(); + walker.walk.mockResolvedValueOnce([createDirectoryResult()]); + walker.walk.mockResolvedValueOnce([]); + const engine = createEngine({ walker }); + const store = await createMotionEyeStore(engine); + + await engine.getEvents(createHASS(), store, createDefaultEventQuery()); + + const fileStep = getFileSteps(walker)[0]; + assert(fileStep.metadataGenerator); + + const metadata = fileStep.metadataGenerator( + createRichBrowseMedia({ + title: '20-15-00.jpg', + media_class: 'image', + }), + ); + + assert(metadata); + expect(metadata.cameraID).toBe('camera-1'); + }); + + it('should return null metadata for non-media files', async () => { + const walker = mock(); + walker.walk.mockResolvedValueOnce([createDirectoryResult()]); + walker.walk.mockResolvedValueOnce([]); + const engine = createEngine({ walker }); + const store = await createMotionEyeStore(engine); + + await engine.getEvents(createHASS(), store, createDefaultEventQuery()); + + const fileStep = getFileSteps(walker)[0]; + assert(fileStep.metadataGenerator); + + const metadata = fileStep.metadataGenerator( + createRichBrowseMedia({ + title: 'readme.txt', + media_class: 'other', + }), + ); + + expect(metadata).toBeNull(); + }); + + it('should return null metadata for invalid date in file title', async () => { + const walker = mock(); + walker.walk.mockResolvedValueOnce([createDirectoryResult()]); + walker.walk.mockResolvedValueOnce([]); + const engine = createEngine({ walker }); + const store = await createMotionEyeStore(engine); + + await engine.getEvents(createHASS(), store, createDefaultEventQuery()); + + const fileStep = getFileSteps(walker)[0]; + assert(fileStep.metadataGenerator); + + const metadata = fileStep.metadataGenerator( + createRichBrowseMedia({ + title: 'invalid.mp4', + media_class: 'video', + }), + ); + + expect(metadata).toBeNull(); + }); + + it('should parse time relative to parent startDate', async () => { + const walker = mock(); + walker.walk.mockResolvedValueOnce([createDirectoryResult()]); + walker.walk.mockResolvedValueOnce([]); + const engine = createEngine({ walker }); + const store = await createMotionEyeStore(engine); + + await engine.getEvents(createHASS(), store, createDefaultEventQuery()); + + const fileStep = getFileSteps(walker)[0]; + assert(fileStep.metadataGenerator); + + const parent = createRichBrowseMedia({ + _metadata: { + cameraID: 'camera-1', + startDate: new Date('2026-03-14T00:00:00'), + endDate: endOfDay(new Date('2026-03-14')), + }, + }); + const metadata = fileStep.metadataGenerator( + createRichBrowseMedia({ + title: '20-15-00.mp4', + media_class: 'video', + }), + parent, + ); + + assert(metadata); + assert(metadata.startDate); + expect(metadata.startDate.getHours()).toBe(20); + expect(metadata.startDate.getMinutes()).toBe(15); + }); + + it('should match only non-expandable files', async () => { + const walker = mock(); + walker.walk.mockResolvedValueOnce([createDirectoryResult()]); + walker.walk.mockResolvedValueOnce([]); + const engine = createEngine({ walker }); + const store = await createMotionEyeStore(engine); + + await engine.getEvents(createHASS(), store, createDefaultEventQuery()); + + const fileStep = getFileSteps(walker)[0]; + assert(fileStep.matcher); + + const file = createRichBrowseMedia({ + can_expand: false, + _metadata: { + startDate: new Date('2026-03-14T20:15:00'), + endDate: new Date('2026-03-14T20:15:00'), + }, + }); + expect(fileStep.matcher(file)).toBe(true); + + const directory = createRichBrowseMedia({ + can_expand: true, + _metadata: { + startDate: new Date('2026-03-14T20:15:00'), + endDate: new Date('2026-03-14T20:15:00'), + }, + }); + expect(fileStep.matcher(directory)).toBe(false); + }); + + it('should early exit when media count reaches limit', async () => { + const walker = mock(); + walker.walk.mockResolvedValueOnce([createDirectoryResult()]); + walker.walk.mockResolvedValueOnce([]); + const engine = createEngine({ walker }); + const store = await createMotionEyeStore(engine); + + await engine.getEvents(createHASS(), store, createDefaultEventQuery()); + + const fileStep = getFileSteps(walker)[0]; + assert(fileStep.earlyExit); + + expect(fileStep.earlyExit([])).toBe(false); + + const manyItems = Array.from({ length: 10000 }, () => createRichBrowseMedia()); + expect(fileStep.earlyExit(manyItems)).toBe(true); + }); + }); + }); + + describe('generateMediaFromEvents', () => { + it('should return null for non-motioneye results', () => { + const result = createEngine().generateMediaFromEvents( + createHASS(), + new CameraManagerStore(), + createDefaultEventQuery(), + { + type: QueryResultsType.Event, + engine: Engine.Generic, + } as EventQueryResults, + ); + + expect(result).toBeNull(); + }); + + it('should generate media from motioneye event results', async () => { + const { getViewMediaFromBrowseMediaArray } = await import( + '../../../src/ha/browse-media/browse-media-to-view-media' + ); + vi.mocked(getViewMediaFromBrowseMediaArray).mockReturnValue([]); + + const browseMedia = [createRichBrowseMedia()]; + const result = createEngine().generateMediaFromEvents( + createHASS(), + new CameraManagerStore(), + createDefaultEventQuery(), + { + type: QueryResultsType.Event, + engine: Engine.MotionEye, + browseMedia, + } as MotionEyeEventQueryResults, + ); + + expect(result).toEqual([]); + expect(getViewMediaFromBrowseMediaArray).toHaveBeenCalledWith(browseMedia); + }); + }); + + describe('getMediaMetadata', () => { + it('should return metadata with days from directories', async () => { + const walker = mock(); + walker.walk.mockResolvedValue([ + createRichBrowseMedia({ + can_expand: true, + _metadata: { + cameraID: 'camera-1', + startDate: new Date('2026-03-14'), + endDate: endOfDay(new Date('2026-03-14')), + }, + }), + createRichBrowseMedia({ + can_expand: true, + _metadata: { + cameraID: 'camera-1', + startDate: new Date('2026-03-15'), + endDate: endOfDay(new Date('2026-03-15')), + }, + }), + ]); + + const engine = createEngine({ walker }); + const store = await createMotionEyeStore(engine); + + const result = await engine.getMediaMetadata(createHASS(), store, { + type: QueryType.MediaMetadata, + cameraIDs: new Set(['camera-1']), + }); + + assert(result); + const queryResult = [...result.values()][0]; + assert(queryResult.metadata.days); + expect(queryResult.metadata.days.size).toBe(2); + }); + + it('should use cache when available', async () => { + const walker = mock(); + const requestCache = new CameraManagerRequestCache(); + walker.walk.mockResolvedValue([]); + + const engine = createEngine({ walker, requestCache }); + const store = await createMotionEyeStore(engine); + + const query = { + type: QueryType.MediaMetadata as const, + cameraIDs: new Set(['camera-1']), + }; + + await engine.getMediaMetadata(createHASS(), store, query); + await engine.getMediaMetadata(createHASS(), store, query); + + expect(walker.walk).toHaveBeenCalledTimes(1); + }); + + it('should skip cache when useCache is false', async () => { + const walker = mock(); + const requestCache = new CameraManagerRequestCache(); + walker.walk.mockResolvedValue([]); + + const engine = createEngine({ walker, requestCache }); + const store = await createMotionEyeStore(engine); + + const query = { + type: QueryType.MediaMetadata as const, + cameraIDs: new Set(['camera-1']), + }; + + await engine.getMediaMetadata(createHASS(), store, query); + await engine.getMediaMetadata(createHASS(), store, query, { + useCache: false, + }); + + expect(walker.walk).toHaveBeenCalledTimes(2); + }); + + it('should handle directories without startDate metadata', async () => { + const walker = mock(); + walker.walk.mockResolvedValue([ + createRichBrowseMedia({ + can_expand: true, + _metadata: { cameraID: 'camera-1' }, + }), + ]); + + const engine = createEngine({ walker }); + const store = await createMotionEyeStore(engine); + + const result = await engine.getMediaMetadata(createHASS(), store, { + type: QueryType.MediaMetadata, + cameraIDs: new Set(['camera-1']), + }); + + assert(result); + const queryResult = [...result.values()][0]; + expect(queryResult.metadata.days).toBeUndefined(); + }); + + it('should handle null directories from _getMatchingDirectories', async () => { + const engine = createEngine(); + const store = new CameraManagerStore(); + const camera = new Camera(createCameraConfig(), engine); + camera.setID('camera-1'); + store.addCamera(camera); + + const result = await engine.getMediaMetadata(createHASS(), store, { + type: QueryType.MediaMetadata, + cameraIDs: new Set(['camera-1']), + }); + + assert(result); + const queryResult = [...result.values()][0]; + expect(queryResult.metadata.days).toBeUndefined(); + }); + + it('should re-fetch when cached result is expired', async () => { + const walker = mock(); + const requestCache = new CameraManagerRequestCache(); + walker.walk.mockResolvedValue([]); + + const engine = createEngine({ walker, requestCache }); + const store = await createMotionEyeStore(engine); + + const query = { + type: QueryType.MediaMetadata as const, + cameraIDs: new Set(['camera-1']), + }; + + await engine.getMediaMetadata(createHASS(), store, query); + + vi.spyOn(requestCache, 'has').mockReturnValue(true); + vi.spyOn(requestCache, 'get').mockReturnValue(null); + + await engine.getMediaMetadata(createHASS(), store, query); + + expect(walker.walk).toHaveBeenCalledTimes(2); + }); + }); + + describe('getCameraMetadata', () => { + it('should return motioneye engine icon', () => { + const config = createCameraConfig({ + title: 'My Camera', + camera_entity: CAMERA_ENTITY_ID, + }); + + const metadata = createEngine().getCameraMetadata(createHASS(), config); + + expect(metadata.title).toBe('My Camera'); + expect(metadata.engineIcon).toBe('motioneye'); + }); }); }); diff --git a/vite.config.ts b/vite.config.ts index d913e316..66c7f436 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,14 +2,7 @@ import { defineConfig } from 'vitest/config'; // These globs are expected to have 100% coverage. const FULL_COVERAGE = [ - 'src/camera-manager/*.ts', - 'src/camera-manager/browse-media/*.ts', - 'src/camera-manager/frigate/*.ts', - 'src/camera-manager/generic/*.ts', - 'src/camera-manager/motioneye/camera.ts', - 'src/camera-manager/reolink/*.ts', - 'src/camera-manager/tplink/*.ts', - 'src/camera-manager/utils/**/*.ts', + 'src/camera-manager/**/*.ts', 'src/card-controller/**/*.ts', 'src/components-lib/**/*.ts', 'src/conditions/**/*.ts',