Files
advanced-camera-card/tests/camera-manager/frigate/engine-frigate.test.ts
T

413 lines
12 KiB
TypeScript

import { afterEach, assert, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { RecordingSegmentsCache } from '../../../src/camera-manager/cache';
import {
FrigateCameraManagerEngine,
FrigateQueryResultsClassifier,
} from '../../../src/camera-manager/frigate/engine-frigate';
import {
FrigateEventViewMedia,
FrigateRecordingViewMedia,
} from '../../../src/camera-manager/frigate/media';
import {
getRecordingSegments,
getReviews,
} from '../../../src/camera-manager/frigate/requests';
import {
eventSchema,
FrigateEvent,
FrigateReview,
} from '../../../src/camera-manager/frigate/types.js';
import { CameraManagerStore } from '../../../src/camera-manager/store';
import { CameraManagerRequestCache, QueryType } from '../../../src/camera-manager/types';
import { StateWatcher } from '../../../src/card-controller/hass/state-watcher';
import { CameraConfig } from '../../../src/config/schema/cameras';
import { RawAdvancedCameraCardConfig } from '../../../src/config/types';
import { QuerySource } from '../../../src/query-source';
import { Severity } from '../../../src/severity';
import { ViewMedia, ViewMediaType } from '../../../src/view/item';
import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock';
import { createCameraConfig, createHASS, TestViewMedia } from '../../test-utils';
vi.mock('../../../src/camera-manager/frigate/requests');
const createEngine = (): FrigateCameraManagerEngine => {
return new FrigateCameraManagerEngine(
new EntityRegistryManagerMock(),
new StateWatcher(),
new RecordingSegmentsCache(),
new CameraManagerRequestCache(),
);
};
const createRecordingMedia = (): FrigateRecordingViewMedia => {
return new FrigateRecordingViewMedia(
ViewMediaType.Recording,
'camera-1',
{
cameraID: 'camera-1',
startTime: new Date('2023-06-16T20:00:00Z'),
endTime: new Date('2023-06-16T20:59:59Z'),
events: 1,
},
'recording-id',
'recording-content-id',
'recording-title',
);
};
const createEvent = (): FrigateEvent => {
return eventSchema.parse({
camera: 'camera-1',
end_time: 1686974399,
false_positive: false,
has_clip: true,
has_snapshot: true,
id: 'event-id',
label: 'person',
sub_label: null,
start_time: 1686970800,
top_score: 0.8,
zones: [],
retain_indefinitely: true,
});
};
const createFrigateReview = (id: string): FrigateReview => ({
id,
camera: 'camera-1',
start_time: 100,
end_time: 200,
severity: 'alert',
thumb_path: 'thumb.jpg',
data: { objects: [], zones: [] },
has_been_reviewed: false,
});
const createClipMedia = (): FrigateEventViewMedia => {
return new FrigateEventViewMedia(
ViewMediaType.Clip,
'camera-1',
createEvent(),
'event-clip-content-id',
'event-clip-thumbnail',
);
};
const createSnapshotMedia = (): FrigateEventViewMedia => {
return new FrigateEventViewMedia(
ViewMediaType.Snapshot,
'camera-1',
createEvent(),
'event-snapshot-content-id',
'event-snapshot-thumbnail',
);
};
const createFrigateCameraConfig = (
config?: RawAdvancedCameraCardConfig,
): CameraConfig => {
return createCameraConfig({
frigate: {
camera_name: 'camera-1',
},
camera_entity: 'camera.office',
...config,
});
};
// @vitest-environment jsdom
describe('getMediaDownloadPath', () => {
it('should get event with clip download path', async () => {
const endpoint = await createEngine().getMediaDownloadPath(
createHASS(),
createFrigateCameraConfig(),
createClipMedia(),
);
expect(endpoint).toEqual({
endpoint: '/api/frigate/frigate/notifications/event-id/clip.mp4?download=true',
sign: true,
});
});
it('should get event with snapshot download path', async () => {
const endpoint = await createEngine().getMediaDownloadPath(
createHASS(),
createFrigateCameraConfig(),
createSnapshotMedia(),
);
expect(endpoint).toEqual({
endpoint: '/api/frigate/frigate/notifications/event-id/snapshot.jpg?download=true',
sign: true,
});
});
it('should get recording download path', async () => {
const endpoint = await createEngine().getMediaDownloadPath(
createHASS(),
createFrigateCameraConfig(),
createRecordingMedia(),
);
expect(endpoint).toEqual({
endpoint:
'/api/frigate/frigate/recording/camera-1/start/1686945600/end/1686949199?download=true',
sign: true,
});
});
it('should get no path for unknown type', async () => {
const endpoint = await createEngine().getMediaDownloadPath(
createHASS(),
createFrigateCameraConfig(),
new ViewMedia(ViewMediaType.Clip, {
cameraID: 'camera-1',
}),
);
expect(endpoint).toBeNull();
});
});
describe('getReviews', () => {
afterEach(() => {
vi.resetAllMocks();
});
it('should fan out requests for multiple severities', async () => {
const engine = createEngine();
const hass = createHASS();
const cameraConfig = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = mock<CameraManagerStore>();
store.getCameraIDs.mockReturnValue(new Set(['camera-1']));
store.getCameraIDsWithCapability.mockReturnValue(new Set(['camera-1']));
store.getCameraConfig.mockReturnValue(cameraConfig);
store.getCameraConfigs.mockImplementation(function* () {
yield cameraConfig;
});
store.getCameraConfigEntries.mockImplementation(function* () {
yield ['camera-1', cameraConfig];
});
store.hasCameraID.mockReturnValue(true);
const reviewHigh = createFrigateReview('review-high');
const reviewMedium = createFrigateReview('review-medium');
vi.mocked(getReviews)
.mockResolvedValueOnce([reviewHigh])
.mockResolvedValueOnce([reviewMedium]);
const resultsMap = await engine.getReviews(hass, store, {
type: QueryType.Review,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
severity: new Set(['high', 'medium']),
});
expect(getReviews).toHaveBeenCalledTimes(2);
expect(getReviews).toHaveBeenCalledWith(
hass,
expect.objectContaining({
severity: 'alert',
}),
);
expect(getReviews).toHaveBeenCalledWith(
hass,
expect.objectContaining({
severity: 'detection',
}),
);
const result = resultsMap?.values().next().value;
assert(result);
assert(FrigateQueryResultsClassifier.isFrigateReviewQueryResults(result));
expect(result.reviews).toHaveLength(2);
expect(result.reviews).toContainEqual(reviewHigh);
expect(result.reviews).toContainEqual(reviewMedium);
});
it('should handle single severity', async () => {
const engine = createEngine();
const hass = createHASS();
const cameraConfig = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = mock<CameraManagerStore>();
store.getCameraIDs.mockReturnValue(new Set(['camera-1']));
store.getCameraIDsWithCapability.mockReturnValue(new Set(['camera-1']));
store.getCameraConfig.mockReturnValue(cameraConfig);
store.getCameraConfigs.mockImplementation(function* () {
yield cameraConfig;
});
store.getCameraConfigEntries.mockImplementation(function* () {
yield ['camera-1', cameraConfig];
});
store.hasCameraID.mockReturnValue(true);
const review = createFrigateReview('review-low');
vi.mocked(getReviews).mockResolvedValue([review]);
await engine.getReviews(hass, store, {
type: QueryType.Review,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
severity: new Set<Severity>(['high']),
});
expect(getReviews).toHaveBeenCalledTimes(1);
expect(getReviews).toHaveBeenCalledWith(
hass,
expect.objectContaining({
severity: 'alert',
}),
);
});
it('should ignore low severity', async () => {
const engine = createEngine();
const hass = createHASS();
const cameraConfig = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = mock<CameraManagerStore>();
store.getCameraIDs.mockReturnValue(new Set(['camera-1']));
store.getCameraIDsWithCapability.mockReturnValue(new Set(['camera-1']));
store.getCameraConfig.mockReturnValue(cameraConfig);
store.getCameraConfigs.mockImplementation(function* () {
yield cameraConfig;
});
store.getCameraConfigEntries.mockImplementation(function* () {
yield ['camera-1', cameraConfig];
});
store.hasCameraID.mockReturnValue(true);
vi.mocked(getReviews).mockResolvedValue([]);
await engine.getReviews(hass, store, {
type: QueryType.Review,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
severity: new Set<Severity>(['low']),
});
expect(getReviews).not.toHaveBeenCalled();
});
it('should query all valid severities when severity is undefined', async () => {
const engine = createEngine();
const hass = createHASS();
const cameraConfig = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = mock<CameraManagerStore>();
store.getCameraIDs.mockReturnValue(new Set(['camera-1']));
store.getCameraIDsWithCapability.mockReturnValue(new Set(['camera-1']));
store.getCameraConfig.mockReturnValue(cameraConfig);
store.getCameraConfigs.mockImplementation(function* () {
yield cameraConfig;
});
store.getCameraConfigEntries.mockImplementation(function* () {
yield ['camera-1', cameraConfig];
});
store.hasCameraID.mockReturnValue(true);
vi.mocked(getReviews).mockResolvedValue([]);
await engine.getReviews(hass, store, {
type: QueryType.Review,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
severity: undefined,
});
expect(getReviews).toHaveBeenCalledTimes(1);
expect(getReviews).toHaveBeenCalledWith(
hass,
expect.objectContaining({
severity: undefined,
}),
);
});
});
describe('getMediaSeekTime', () => {
it('should get seek time for review media', async () => {
const engine = createEngine();
const hass = createHASS();
const cameraConfig = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = mock<CameraManagerStore>();
store.getCameraConfig.mockReturnValue(cameraConfig);
const startTime = new Date('2023-06-16T20:15:00Z');
const endTime = new Date('2023-06-16T20:45:00Z');
const media = new TestViewMedia({
mediaType: ViewMediaType.Review,
cameraID: 'camera-1',
startTime,
endTime,
});
const segments = [
{
start_time: new Date('2023-06-16T20:00:00Z').getTime() / 1000,
end_time: new Date('2023-06-16T21:00:00Z').getTime() / 1000,
id: 'segment-1',
motion: 0,
objects: 0,
segment_size: 0,
},
];
vi.mocked(getRecordingSegments).mockResolvedValue(segments);
const seekTime = await engine.getMediaSeekTime(hass, store, media, startTime);
// 15 minutes into the hour-long recording.
expect(seekTime).toBe(15 * 60);
});
it('should get zero seek time for clip media when seeking to start', async () => {
const engine = createEngine();
const hass = createHASS();
const cameraConfig = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = mock<CameraManagerStore>();
store.getCameraConfig.mockReturnValue(cameraConfig);
const startTime = new Date('2023-06-16T20:15:00Z');
const endTime = new Date('2023-06-16T20:15:10Z');
const media = new TestViewMedia({
mediaType: ViewMediaType.Clip,
cameraID: 'camera-1',
startTime,
endTime,
});
const segments = [
{
start_time: startTime.getTime() / 1000,
end_time: endTime.getTime() / 1000,
id: 'segment-1',
motion: 0,
objects: 0,
segment_size: 0,
},
];
vi.mocked(getRecordingSegments).mockResolvedValue(segments);
const seekTime = await engine.getMediaSeekTime(hass, store, media, startTime);
expect(seekTime).toBe(0);
});
});