From 415a3e08864f0920a152a38280b843637fabada2 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 14 Mar 2026 15:49:56 -0700 Subject: [PATCH] test: Improve test coverage of various camera engine files (#2415) --- .../browse-media/engine-browse-media.test.ts | 113 +++ .../frigate/media-classifier.test.ts | 110 +++ tests/camera-manager/frigate/media.test.ts | 826 +++++++++++++++++- tests/camera-manager/frigate/types.test.ts | 39 + tests/camera-manager/motioneye/camera.test.ts | 117 +++ vite.config.ts | 8 +- 6 files changed, 1163 insertions(+), 50 deletions(-) create mode 100644 tests/camera-manager/browse-media/engine-browse-media.test.ts create mode 100644 tests/camera-manager/frigate/media-classifier.test.ts create mode 100644 tests/camera-manager/frigate/types.test.ts create mode 100644 tests/camera-manager/motioneye/camera.test.ts diff --git a/tests/camera-manager/browse-media/engine-browse-media.test.ts b/tests/camera-manager/browse-media/engine-browse-media.test.ts new file mode 100644 index 00000000..018e037c --- /dev/null +++ b/tests/camera-manager/browse-media/engine-browse-media.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { BrowseMediaCameraManagerEngine } from '../../../src/camera-manager/browse-media/engine-browse-media'; +import { + CameraManagerRequestCache, + CameraQuery, + QueryType, +} from '../../../src/camera-manager/types'; +import { StateWatcher } from '../../../src/card-controller/hass/state-watcher'; +import { BROWSE_MEDIA_CACHE_SECONDS } from '../../../src/ha/browse-media/types'; +import { BrowseMediaWalker } from '../../../src/ha/browse-media/walker'; +import { ResolvedMediaCache } from '../../../src/ha/resolved-media'; +import { QuerySource } from '../../../src/query-source'; +import { ViewMedia } from '../../../src/view/item'; +import { CameraManagerReadOnlyConfigStore } from '../../../src/camera-manager/store'; +import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock'; +import { createCameraConfig, createHASS } from '../../test-utils'; + +const createEngine = (): BrowseMediaCameraManagerEngine => { + return new BrowseMediaCameraManagerEngine( + new EntityRegistryManagerMock(), + mock(), + new BrowseMediaWalker(), + new ResolvedMediaCache(), + new CameraManagerRequestCache(), + ); +}; + +describe('BrowseMediaCameraManagerEngine', () => { + describe('generateDefaultEventQuery', () => { + it('should generate event query', () => { + const engine = createEngine(); + const cameraIDs = new Set(['camera1']); + const result = engine.generateDefaultEventQuery( + mock(), + cameraIDs, + {}, + ); + + expect(result).toEqual([ + { + source: QuerySource.Camera, + type: QueryType.Event, + cameraIDs: cameraIDs, + }, + ]); + }); + + it('should merge partial query fields', () => { + const engine = createEngine(); + const cameraIDs = new Set(['camera1']); + const result = engine.generateDefaultEventQuery( + mock(), + cameraIDs, + { limit: 10 }, + ); + + expect(result).toEqual([ + { + source: QuerySource.Camera, + type: QueryType.Event, + cameraIDs: cameraIDs, + limit: 10, + }, + ]); + }); + }); + + describe('getMediaDownloadPath', () => { + it('should call getMediaDownloadPath with content ID', async () => { + const engine = createEngine(); + const hass = createHASS(); + const media = mock(); + media.getContentID.mockReturnValue('content-id'); + + await engine.getMediaDownloadPath(hass, createCameraConfig(), media); + + expect(media.getContentID).toHaveBeenCalled(); + }); + }); + + describe('getQueryResultMaxAge', () => { + it('should return cache seconds for event queries', () => { + const engine = createEngine(); + const query = { + type: QueryType.Event, + } as CameraQuery; + + expect(engine.getQueryResultMaxAge(query)).toBe(BROWSE_MEDIA_CACHE_SECONDS); + }); + + it('should return null for non-event queries', () => { + const engine = createEngine(); + const query = { + type: QueryType.Recording, + } as CameraQuery; + + expect(engine.getQueryResultMaxAge(query)).toBeNull(); + }); + }); + + describe('getMediaCapabilities', () => { + it('should return correct capabilities', () => { + const engine = createEngine(); + const media = mock(); + + expect(engine.getMediaCapabilities(media)).toEqual({ + canFavorite: false, + canDownload: true, + }); + }); + }); +}); diff --git a/tests/camera-manager/frigate/media-classifier.test.ts b/tests/camera-manager/frigate/media-classifier.test.ts new file mode 100644 index 00000000..24b917a9 --- /dev/null +++ b/tests/camera-manager/frigate/media-classifier.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { + FrigateEventViewMedia, + FrigateRecordingViewMedia, + FrigateReviewViewMedia, +} from '../../../src/camera-manager/frigate/media'; +import { FrigateViewMediaClassifier } from '../../../src/camera-manager/frigate/media-classifier'; +import { ViewMedia, ViewMediaType } from '../../../src/view/item'; +import { + createFrigateEvent, + createFrigateRecording, + createFrigateReview, +} from '../../test-utils'; + +describe('FrigateViewMediaClassifier', () => { + describe('isFrigateEvent', () => { + it('should return true for event media', () => { + const media = new FrigateEventViewMedia( + ViewMediaType.Clip, + 'camera', + createFrigateEvent(), + 'content_id', + 'thumbnail', + ); + expect(FrigateViewMediaClassifier.isFrigateEvent(media)).toBe(true); + }); + + it('should return false for non-event media', () => { + const media = mock(); + expect(FrigateViewMediaClassifier.isFrigateEvent(media)).toBe(false); + }); + }); + + describe('isFrigateRecording', () => { + it('should return true for recording media', () => { + const media = new FrigateRecordingViewMedia( + ViewMediaType.Recording, + 'camera', + createFrigateRecording(), + 'id', + 'content_id', + 'title', + ); + expect(FrigateViewMediaClassifier.isFrigateRecording(media)).toBe(true); + }); + + it('should return false for non-recording media', () => { + const media = mock(); + expect(FrigateViewMediaClassifier.isFrigateRecording(media)).toBe(false); + }); + }); + + describe('isFrigateReview', () => { + it('should return true for review media', () => { + const media = new FrigateReviewViewMedia( + 'camera', + createFrigateReview(), + 'content_id', + 'thumbnail', + ); + expect(FrigateViewMediaClassifier.isFrigateReview(media)).toBe(true); + }); + + it('should return false for non-review media', () => { + const media = mock(); + expect(FrigateViewMediaClassifier.isFrigateReview(media)).toBe(false); + }); + }); + + describe('isFrigateMedia', () => { + it('should return true for event media', () => { + const media = new FrigateEventViewMedia( + ViewMediaType.Clip, + 'camera', + createFrigateEvent(), + 'content_id', + 'thumbnail', + ); + expect(FrigateViewMediaClassifier.isFrigateMedia(media)).toBe(true); + }); + + it('should return true for review media', () => { + const media = new FrigateReviewViewMedia( + 'camera', + createFrigateReview(), + 'content_id', + 'thumbnail', + ); + expect(FrigateViewMediaClassifier.isFrigateMedia(media)).toBe(true); + }); + + it('should return true for recording media', () => { + const media = new FrigateRecordingViewMedia( + ViewMediaType.Recording, + 'camera', + createFrigateRecording(), + 'id', + 'content_id', + 'title', + ); + expect(FrigateViewMediaClassifier.isFrigateMedia(media)).toBe(true); + }); + + it('should return false for non-frigate media', () => { + const media = mock(); + expect(FrigateViewMediaClassifier.isFrigateMedia(media)).toBe(false); + }); + }); +}); diff --git a/tests/camera-manager/frigate/media.test.ts b/tests/camera-manager/frigate/media.test.ts index ddabd909..a217af9c 100644 --- a/tests/camera-manager/frigate/media.test.ts +++ b/tests/camera-manager/frigate/media.test.ts @@ -1,59 +1,129 @@ import { describe, expect, it } from 'vitest'; import { FrigateEventViewMedia, + FrigateRecordingViewMedia, FrigateReviewViewMedia, + FrigateViewMediaFactory, } from '../../../src/camera-manager/frigate/media'; import { ViewMediaType } from '../../../src/view/item'; -import { createFrigateEvent, createFrigateReview } from '../../test-utils'; - -describe('FrigateReviewViewMedia', () => { - it('should get description when scene is present', () => { - const review = createFrigateReview({ - data: { - objects: [], - zones: [], - metadata: { - scene: 'A person walking', - title: 'Title', - }, - }, - }); - const media = new FrigateReviewViewMedia( - 'camera', - review, - 'content_id', - 'thumbnail', - ); - expect(media.getDescription()).toBe('A person walking'); - }); - - it('should get null description when scene is absent', () => { - const review = createFrigateReview({ - data: { - objects: [], - zones: [], - metadata: { - title: 'Title', - // scene is absent. - }, - }, - }); - const media = new FrigateReviewViewMedia( - 'camera', - review, - 'content_id', - 'thumbnail', - ); - expect(media.getDescription()).toBeNull(); - }); -}); +import { + createCameraConfig, + createFrigateEvent, + createFrigateRecording, + createFrigateReview, +} from '../../test-utils'; describe('FrigateEventViewMedia', () => { - it('should get description when description is present', () => { + it('should get start time', () => { + const event = createFrigateEvent({ start_time: 1683395000 }); + const media = new FrigateEventViewMedia( + ViewMediaType.Clip, + 'camera', + event, + 'content_id', + 'thumbnail', + ); + expect(media.getStartTime()).toEqual(new Date(1683395000 * 1000)); + }); + + it('should get end time', () => { + const event = createFrigateEvent({ end_time: 1683397124 }); + const media = new FrigateEventViewMedia( + ViewMediaType.Clip, + 'camera', + event, + 'content_id', + 'thumbnail', + ); + expect(media.getEndTime()).toEqual(new Date(1683397124 * 1000)); + }); + + it('should get null end time when null', () => { + const event = createFrigateEvent({ end_time: null }); + const media = new FrigateEventViewMedia( + ViewMediaType.Clip, + 'camera', + event, + 'content_id', + 'thumbnail', + ); + expect(media.getEndTime()).toBeNull(); + }); + + it('should report in progress when no end time', () => { + const event = createFrigateEvent({ end_time: null }); + const media = new FrigateEventViewMedia( + ViewMediaType.Clip, + 'camera', + event, + 'content_id', + 'thumbnail', + ); + expect(media.inProgress()).toBe(true); + }); + + it('should report not in progress when end time exists', () => { + const event = createFrigateEvent({ end_time: 1683397124 }); + const media = new FrigateEventViewMedia( + ViewMediaType.Clip, + 'camera', + event, + 'content_id', + 'thumbnail', + ); + expect(media.inProgress()).toBe(false); + }); + + it('should get video content type as HLS', () => { + const media = new FrigateEventViewMedia( + ViewMediaType.Clip, + 'camera', + createFrigateEvent(), + 'content_id', + 'thumbnail', + ); + expect(media.getVideoContentType()).toBe('hls'); + }); + + it('should get ID', () => { const event = createFrigateEvent({ - data: { - description: 'A person walking', - }, + id: 'test-event-id', + }); + const media = new FrigateEventViewMedia( + ViewMediaType.Clip, + 'camera', + event, + 'content_id', + 'thumbnail', + ); + expect(media.getID()).toBe('test-event-id'); + }); + + it('should get content ID', () => { + const media = new FrigateEventViewMedia( + ViewMediaType.Clip, + 'camera', + createFrigateEvent(), + 'my-content-id', + 'thumbnail', + ); + expect(media.getContentID()).toBe('my-content-id'); + }); + + it('should get title', () => { + const media = new FrigateEventViewMedia( + ViewMediaType.Clip, + 'camera', + createFrigateEvent(), + 'content_id', + 'thumbnail', + ); + expect(media.getTitle()).toBeTruthy(); + }); + + it('should get description when present', () => { + const event = createFrigateEvent({ + data: { description: 'A person walking' }, }); const media = new FrigateEventViewMedia( ViewMediaType.Clip, @@ -65,7 +135,7 @@ describe('FrigateEventViewMedia', () => { expect(media.getDescription()).toBe('A person walking'); }); - it('should get null description when data is absent', () => { + it('should get null description when absent', () => { const event = createFrigateEvent(); const media = new FrigateEventViewMedia( ViewMediaType.Clip, @@ -76,4 +146,664 @@ describe('FrigateEventViewMedia', () => { ); expect(media.getDescription()).toBeNull(); }); + + it('should get thumbnail', () => { + const media = new FrigateEventViewMedia( + ViewMediaType.Clip, + 'camera', + createFrigateEvent(), + 'content_id', + 'my-thumb', + ); + expect(media.getThumbnail()).toBe('my-thumb'); + }); + + it('should get favorite status', () => { + const event = createFrigateEvent({ + retain_indefinitely: true, + }); + const media = new FrigateEventViewMedia( + ViewMediaType.Clip, + 'camera', + event, + 'content_id', + 'thumbnail', + ); + expect(media.isFavorite()).toBe(true); + }); + + it('should get null favorite when undefined', () => { + const event = createFrigateEvent(); + delete (event as Record).retain_indefinitely; + const media = new FrigateEventViewMedia( + ViewMediaType.Clip, + 'camera', + event, + 'content_id', + 'thumbnail', + ); + expect(media.isFavorite()).toBeNull(); + }); + + it('should set favorite', () => { + const media = new FrigateEventViewMedia( + ViewMediaType.Clip, + 'camera', + createFrigateEvent({ retain_indefinitely: false }), + 'content_id', + 'thumbnail', + ); + media.setFavorite(true); + expect(media.isFavorite()).toBe(true); + }); + + it('should get what', () => { + const event = createFrigateEvent({ label: 'cat' }); + const media = new FrigateEventViewMedia( + ViewMediaType.Clip, + 'camera', + event, + 'content_id', + 'thumbnail', + ); + expect(media.getWhat()).toEqual(['cat']); + }); + + it('should get where when zones present', () => { + const event = createFrigateEvent({ + zones: ['front_yard'], + }); + const media = new FrigateEventViewMedia( + ViewMediaType.Clip, + 'camera', + event, + 'content_id', + 'thumbnail', + ); + expect(media.getWhere()).toEqual(['front_yard']); + }); + + it('should get null where when no zones', () => { + const event = createFrigateEvent({ zones: [] }); + const media = new FrigateEventViewMedia( + ViewMediaType.Clip, + 'camera', + event, + 'content_id', + 'thumbnail', + ); + expect(media.getWhere()).toBeNull(); + }); + + it('should get score', () => { + const event = createFrigateEvent({ + top_score: 0.95, + }); + const media = new FrigateEventViewMedia( + ViewMediaType.Clip, + 'camera', + event, + 'content_id', + 'thumbnail', + ); + expect(media.getScore()).toBe(0.95); + }); + + it('should get tags from sub-labels', () => { + const media = new FrigateEventViewMedia( + ViewMediaType.Clip, + 'camera', + createFrigateEvent(), + 'content_id', + 'thumbnail', + ['Amazon', 'UPS'], + ); + expect(media.getTags()).toEqual(['Amazon', 'UPS']); + }); + + it('should get null tags when no sub-labels', () => { + const media = new FrigateEventViewMedia( + ViewMediaType.Clip, + 'camera', + createFrigateEvent(), + 'content_id', + 'thumbnail', + ); + expect(media.getTags()).toBeNull(); + }); + + it('should be groupable with same type and criteria', () => { + const media1 = new FrigateEventViewMedia( + ViewMediaType.Clip, + 'camera', + createFrigateEvent({ + label: 'person', + zones: ['yard'], + }), + 'c1', + 't1', + ); + const media2 = new FrigateEventViewMedia( + ViewMediaType.Clip, + 'camera', + createFrigateEvent({ + label: 'person', + zones: ['yard'], + }), + 'c2', + 't2', + ); + expect(media1.isGroupableWith(media2)).toBe(true); + }); + + it('should not be groupable with different type', () => { + const media1 = new FrigateEventViewMedia( + ViewMediaType.Clip, + 'camera', + createFrigateEvent({ label: 'person' }), + 'c1', + 't1', + ); + const media2 = new FrigateEventViewMedia( + ViewMediaType.Snapshot, + 'camera', + createFrigateEvent({ label: 'person' }), + 'c2', + 't2', + ); + expect(media1.isGroupableWith(media2)).toBe(false); + }); + + it('should not be groupable with different label', () => { + const media1 = new FrigateEventViewMedia( + ViewMediaType.Clip, + 'camera', + createFrigateEvent({ label: 'person' }), + 'c1', + 't1', + ); + const media2 = new FrigateEventViewMedia( + ViewMediaType.Clip, + 'camera', + createFrigateEvent({ label: 'car' }), + 'c2', + 't2', + ); + expect(media1.isGroupableWith(media2)).toBe(false); + }); +}); + +describe('FrigateRecordingViewMedia', () => { + it('should get ID', () => { + const media = new FrigateRecordingViewMedia( + ViewMediaType.Recording, + 'camera', + createFrigateRecording(), + 'rec-id', + 'content_id', + 'title', + ); + expect(media.getID()).toBe('rec-id'); + }); + + it('should get start time', () => { + const startTime = new Date('2023-04-29T14:00:00'); + const media = new FrigateRecordingViewMedia( + ViewMediaType.Recording, + 'camera', + createFrigateRecording({ startTime }), + 'id', + 'content_id', + 'title', + ); + expect(media.getStartTime()).toEqual(startTime); + }); + + it('should get end time', () => { + const endTime = new Date('2023-04-29T14:59:59'); + const media = new FrigateRecordingViewMedia( + ViewMediaType.Recording, + 'camera', + createFrigateRecording({ endTime }), + 'id', + 'content_id', + 'title', + ); + expect(media.getEndTime()).toEqual(endTime); + }); + + it('should report not in progress', () => { + const media = new FrigateRecordingViewMedia( + ViewMediaType.Recording, + 'camera', + createFrigateRecording(), + 'id', + 'content_id', + 'title', + ); + expect(media.inProgress()).toBe(false); + }); + + it('should get video content type as HLS', () => { + const media = new FrigateRecordingViewMedia( + ViewMediaType.Recording, + 'camera', + createFrigateRecording(), + 'id', + 'content_id', + 'title', + ); + expect(media.getVideoContentType()).toBe('hls'); + }); + + it('should get content ID', () => { + const media = new FrigateRecordingViewMedia( + ViewMediaType.Recording, + 'camera', + createFrigateRecording(), + 'id', + 'my-content-id', + 'title', + ); + expect(media.getContentID()).toBe('my-content-id'); + }); + + it('should get title', () => { + const media = new FrigateRecordingViewMedia( + ViewMediaType.Recording, + 'camera', + createFrigateRecording(), + 'id', + 'content_id', + 'My Recording', + ); + expect(media.getTitle()).toBe('My Recording'); + }); + + it('should get event count', () => { + const media = new FrigateRecordingViewMedia( + ViewMediaType.Recording, + 'camera', + createFrigateRecording({ events: 5 }), + 'id', + 'content_id', + 'title', + ); + expect(media.getEventCount()).toBe(5); + }); +}); + +describe('FrigateReviewViewMedia', () => { + it('should get ID', () => { + const review = createFrigateReview({ id: 'rev-1' }); + const media = new FrigateReviewViewMedia('camera', review, 'content_id', 'thumb'); + expect(media.getID()).toBe('rev-1'); + }); + + it('should get start time', () => { + const review = createFrigateReview({ + start_time: 1683395000, + }); + const media = new FrigateReviewViewMedia('camera', review, 'content_id', 'thumb'); + expect(media.getStartTime()).toEqual(new Date(1683395000 * 1000)); + }); + + it('should get end time', () => { + const review = createFrigateReview({ + end_time: 1683397124, + }); + const media = new FrigateReviewViewMedia('camera', review, 'content_id', 'thumb'); + expect(media.getEndTime()).toEqual(new Date(1683397124 * 1000)); + }); + + it('should get null end time when null', () => { + const review = createFrigateReview({ end_time: null }); + const media = new FrigateReviewViewMedia('camera', review, 'content_id', 'thumb'); + expect(media.getEndTime()).toBeNull(); + }); + + it('should report in progress when no end time', () => { + const review = createFrigateReview({ end_time: null }); + const media = new FrigateReviewViewMedia('camera', review, 'content_id', 'thumb'); + expect(media.inProgress()).toBe(true); + }); + + it('should report not in progress when end time set', () => { + const review = createFrigateReview({ + end_time: 1683397124, + }); + const media = new FrigateReviewViewMedia('camera', review, 'content_id', 'thumb'); + expect(media.inProgress()).toBe(false); + }); + + it('should get video content type as HLS', () => { + const media = new FrigateReviewViewMedia( + 'camera', + createFrigateReview(), + 'content_id', + 'thumb', + ); + expect(media.getVideoContentType()).toBe('hls'); + }); + + it('should get content ID', () => { + const media = new FrigateReviewViewMedia( + 'camera', + createFrigateReview(), + 'my-content', + 'thumb', + ); + expect(media.getContentID()).toBe('my-content'); + }); + + it('should get title from metadata', () => { + const review = createFrigateReview({ + data: { + objects: ['person'], + zones: [], + metadata: { title: 'Metadata Title' }, + }, + }); + const media = new FrigateReviewViewMedia('camera', review, 'content_id', 'thumb'); + expect(media.getTitle()).toBe('Metadata Title'); + }); + + it('should get title from review util when no metadata', () => { + const review = createFrigateReview({ + data: { objects: ['person'], zones: [] }, + }); + const media = new FrigateReviewViewMedia('camera', review, 'content_id', 'thumb'); + expect(media.getTitle()).toBe('Person'); + }); + + it('should get description from scene', () => { + const review = createFrigateReview({ + data: { + objects: [], + zones: [], + metadata: { + scene: 'A person walking', + title: 'Title', + }, + }, + }); + const media = new FrigateReviewViewMedia('camera', review, 'content_id', 'thumb'); + expect(media.getDescription()).toBe('A person walking'); + }); + + it('should get description from shortSummary', () => { + const review = createFrigateReview({ + data: { + objects: [], + zones: [], + metadata: { + shortSummary: 'Short summary text', + title: 'Title', + }, + }, + }); + const media = new FrigateReviewViewMedia('camera', review, 'content_id', 'thumb'); + expect(media.getDescription()).toBe('Short summary text'); + }); + + it('should get null description when no metadata', () => { + const review = createFrigateReview({ + data: { objects: [], zones: [] }, + }); + const media = new FrigateReviewViewMedia('camera', review, 'content_id', 'thumb'); + expect(media.getDescription()).toBeNull(); + }); + + it('should get thumbnail', () => { + const media = new FrigateReviewViewMedia( + 'camera', + createFrigateReview(), + 'content_id', + 'my-thumb', + ); + expect(media.getThumbnail()).toBe('my-thumb'); + }); + + it('should get null thumbnail', () => { + const media = new FrigateReviewViewMedia( + 'camera', + createFrigateReview(), + 'content_id', + null, + ); + expect(media.getThumbnail()).toBeNull(); + }); + + it('should get severity for alert', () => { + const review = createFrigateReview({ + severity: 'alert', + }); + const media = new FrigateReviewViewMedia('camera', review, 'content_id', 'thumb'); + expect(media.getSeverity()).toBe('high'); + }); + + it('should get severity for detection', () => { + const review = createFrigateReview({ + severity: 'detection', + }); + const media = new FrigateReviewViewMedia('camera', review, 'content_id', 'thumb'); + expect(media.getSeverity()).toBe('medium'); + }); + + it('should get reviewed status', () => { + const review = createFrigateReview({ + has_been_reviewed: true, + }); + const media = new FrigateReviewViewMedia('camera', review, 'content_id', 'thumb'); + expect(media.isReviewed()).toBe(true); + }); + + it('should set reviewed status', () => { + const media = new FrigateReviewViewMedia( + 'camera', + createFrigateReview({ has_been_reviewed: false }), + 'content_id', + 'thumb', + ); + media.setReviewed(true); + expect(media.isReviewed()).toBe(true); + }); + + it('should get what from objects', () => { + const review = createFrigateReview({ + data: { objects: ['person', 'car'], zones: [] }, + }); + const media = new FrigateReviewViewMedia('camera', review, 'content_id', 'thumb'); + expect(media.getWhat()).toEqual(['person', 'car']); + }); + + it('should get null what when no objects', () => { + const review = createFrigateReview({ + data: { zones: [] }, + }); + const media = new FrigateReviewViewMedia('camera', review, 'content_id', 'thumb'); + expect(media.getWhat()).toBeNull(); + }); + + it('should get where from zones', () => { + const review = createFrigateReview({ + data: { objects: [], zones: ['front_yard'] }, + }); + const media = new FrigateReviewViewMedia('camera', review, 'content_id', 'thumb'); + expect(media.getWhere()).toEqual(['front_yard']); + }); + + it('should get null where when no zones', () => { + const review = createFrigateReview({ + data: { objects: [], zones: [] }, + }); + const media = new FrigateReviewViewMedia('camera', review, 'content_id', 'thumb'); + expect(media.getWhere()).toBeNull(); + }); +}); + +describe('FrigateViewMediaFactory', () => { + describe('createEventViewMedia', () => { + it('should create clip view media', () => { + const config = createCameraConfig({ + frigate: { + client_id: 'client', + camera_name: 'front', + }, + }); + const event = createFrigateEvent({ + has_clip: true, + }); + const media = FrigateViewMediaFactory.createEventViewMedia( + ViewMediaType.Clip, + 'camera', + config, + event, + ); + expect(media).toBeInstanceOf(FrigateEventViewMedia); + expect(media?.getContentID()).toContain('clips'); + }); + + it('should create snapshot view media', () => { + const config = createCameraConfig({ + frigate: { + client_id: 'client', + camera_name: 'front', + }, + }); + const event = createFrigateEvent({ + has_snapshot: true, + }); + const media = FrigateViewMediaFactory.createEventViewMedia( + ViewMediaType.Snapshot, + 'camera', + config, + event, + ); + expect(media).toBeInstanceOf(FrigateEventViewMedia); + expect(media?.getContentID()).toContain('snapshots'); + }); + + it('should return null when clip missing', () => { + const config = createCameraConfig({ + frigate: { + client_id: 'client', + camera_name: 'front', + }, + }); + const event = createFrigateEvent({ + has_clip: false, + }); + const media = FrigateViewMediaFactory.createEventViewMedia( + ViewMediaType.Clip, + 'camera', + config, + event, + ); + expect(media).toBeNull(); + }); + + it('should return null when snapshot missing', () => { + const config = createCameraConfig({ + frigate: { + client_id: 'client', + camera_name: 'front', + }, + }); + const event = createFrigateEvent({ + has_snapshot: false, + }); + const media = FrigateViewMediaFactory.createEventViewMedia( + ViewMediaType.Snapshot, + 'camera', + config, + event, + ); + expect(media).toBeNull(); + }); + + it('should return null when no camera_name', () => { + const config = createCameraConfig(); + const media = FrigateViewMediaFactory.createEventViewMedia( + ViewMediaType.Clip, + 'camera', + config, + createFrigateEvent(), + ); + expect(media).toBeNull(); + }); + + it('should pass sub-labels through', () => { + const config = createCameraConfig({ + frigate: { + client_id: 'client', + camera_name: 'front', + }, + }); + const media = FrigateViewMediaFactory.createEventViewMedia( + ViewMediaType.Clip, + 'camera', + config, + createFrigateEvent(), + ['Amazon'], + ); + expect(media?.getTags()).toEqual(['Amazon']); + }); + }); + + describe('createRecordingViewMedia', () => { + it('should create recording view media', () => { + const config = createCameraConfig({ + frigate: { + client_id: 'client', + camera_name: 'front', + }, + }); + const media = FrigateViewMediaFactory.createRecordingViewMedia( + 'camera', + createFrigateRecording(), + config, + 'Front Camera', + ); + expect(media).toBeInstanceOf(FrigateRecordingViewMedia); + }); + + it('should return null when no camera_name', () => { + const config = createCameraConfig(); + const media = FrigateViewMediaFactory.createRecordingViewMedia( + 'camera', + createFrigateRecording(), + config, + 'Front Camera', + ); + expect(media).toBeNull(); + }); + }); + + describe('createReviewViewMedia', () => { + it('should create review view media', () => { + const config = createCameraConfig({ + frigate: { + client_id: 'client', + camera_name: 'front', + }, + }); + const media = FrigateViewMediaFactory.createReviewViewMedia( + 'camera', + createFrigateReview(), + config, + ); + expect(media).toBeInstanceOf(FrigateReviewViewMedia); + }); + + it('should return null when no camera_name', () => { + const config = createCameraConfig(); + const media = FrigateViewMediaFactory.createReviewViewMedia( + 'camera', + createFrigateReview(), + config, + ); + expect(media).toBeNull(); + }); + }); }); diff --git a/tests/camera-manager/frigate/types.test.ts b/tests/camera-manager/frigate/types.test.ts new file mode 100644 index 00000000..86b62ef8 --- /dev/null +++ b/tests/camera-manager/frigate/types.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { recordingSummarySchema } from '../../../src/camera-manager/frigate/types'; + +describe('recordingSummarySchema', () => { + it('should parse day string to date', () => { + const result = recordingSummarySchema.parse([ + { + day: '2023-05-06', + events: 10, + hours: [], + }, + ]); + expect(result[0].day).toBeInstanceOf(Date); + expect(result[0].day.getFullYear()).toBe(2023); + }); + + it('should pass through non-string day values', () => { + const date = new Date('2023-05-06'); + const result = recordingSummarySchema.parse([ + { + day: date, + events: 5, + hours: [], + }, + ]); + expect(result[0].day).toEqual(date); + }); + + it('should preprocess hour from string to number', () => { + const result = recordingSummarySchema.parse([ + { + day: '2023-05-06', + events: 3, + hours: [{ hour: '15', duration: 3600, events: 3 }], + }, + ]); + expect(result[0].hours[0].hour).toBe(15); + }); +}); diff --git a/tests/camera-manager/motioneye/camera.test.ts b/tests/camera-manager/motioneye/camera.test.ts new file mode 100644 index 00000000..b31272d6 --- /dev/null +++ b/tests/camera-manager/motioneye/camera.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { CameraManagerEngine } from '../../../src/camera-manager/engine'; +import { MotionEyeCamera } from '../../../src/camera-manager/motioneye/camera'; +import { StateWatcher } from '../../../src/card-controller/hass/state-watcher'; +import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock'; +import { createCameraConfig, createHASS, createRegistryEntity } from '../../test-utils'; + +const cameraEntity = createRegistryEntity({ + entity_id: 'camera.motioneye', + platform: 'motioneye', +}); + +describe('MotionEyeCamera', () => { + describe('getProxyConfig', () => { + it('should proxy media when proxy.media is auto', () => { + const config = createCameraConfig({ + proxy: { media: 'auto' }, + }); + const camera = new MotionEyeCamera(config, mock()); + expect(camera.getProxyConfig().media).toBe(true); + }); + + it('should respect explicit false for proxy.media', () => { + const config = createCameraConfig({ + proxy: { media: false }, + }); + const camera = new MotionEyeCamera(config, mock()); + expect(camera.getProxyConfig().media).toBe(false); + }); + + it('should respect explicit true for proxy.media', () => { + const config = createCameraConfig({ + proxy: { media: true }, + }); + const camera = new MotionEyeCamera(config, mock()); + expect(camera.getProxyConfig().media).toBe(true); + }); + }); + + describe('getEndpoints', () => { + it('should return UI endpoint when motioneye url set', async () => { + const config = createCameraConfig({ + camera_entity: 'camera.motioneye', + motioneye: { url: 'http://motioneye.local' }, + }); + const camera = new MotionEyeCamera(config, mock()); + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: new EntityRegistryManagerMock([cameraEntity]), + stateWatcher: mock(), + }); + + const endpoints = camera.getEndpoints(); + expect(endpoints?.ui).toEqual({ + endpoint: 'http://motioneye.local', + }); + }); + + it('should return null UI endpoint when no url', async () => { + const config = createCameraConfig({ + camera_entity: 'camera.motioneye', + }); + const camera = new MotionEyeCamera(config, mock()); + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: new EntityRegistryManagerMock([cameraEntity]), + stateWatcher: mock(), + }); + + const endpoints = camera.getEndpoints(); + expect(endpoints?.ui).toBeUndefined(); + }); + }); + + describe('capabilities', () => { + it('should include clips and snapshots', async () => { + const config = createCameraConfig({ + camera_entity: 'camera.motioneye', + }); + const camera = new MotionEyeCamera(config, mock()); + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: new EntityRegistryManagerMock([cameraEntity]), + stateWatcher: mock(), + }); + + const capabilities = camera.getCapabilities(); + expect(capabilities?.has('clips')).toBe(true); + expect(capabilities?.has('snapshots')).toBe(true); + }); + + it('should include PTZ when configured', async () => { + const config = createCameraConfig({ + camera_entity: 'camera.motioneye', + ptz: { + actions_left: { + action: 'perform-action', + perform_action: 'homeassistant.toggle', + target: { + entity_id: 'switch.camera_move_left', + }, + }, + }, + }); + const camera = new MotionEyeCamera(config, mock()); + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: new EntityRegistryManagerMock([cameraEntity]), + stateWatcher: mock(), + }); + + const capabilities = camera.getCapabilities(); + expect(capabilities?.getPTZCapabilities()).toBeTruthy(); + }); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 11cabcad..7e7e06ac 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,14 +3,18 @@ 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/camera.ts', - 'src/camera-manager/browse-media/utils/*.ts', + 'src/camera-manager/browse-media/*.ts', 'src/camera-manager/frigate/camera.ts', + 'src/camera-manager/frigate/media-classifier.ts', + 'src/camera-manager/frigate/media.ts', 'src/camera-manager/frigate/requests.ts', + 'src/camera-manager/frigate/types.ts', 'src/camera-manager/frigate/util.ts', 'src/camera-manager/frigate/watcher.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/card-controller/**/*.ts', 'src/components-lib/**/*.ts',