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

2964 lines
90 KiB
TypeScript

import { afterEach, assert, describe, expect, it, vi } from 'vitest';
import { RecordingSegmentsCache } from '../../../src/camera-manager/cache';
import { Camera } from '../../../src/camera-manager/camera';
import {
FrigateCameraManagerEngine,
FrigateQueryResultsClassifier,
} from '../../../src/camera-manager/frigate/engine-frigate';
import {
FrigateEventViewMedia,
FrigateRecordingViewMedia,
FrigateReviewViewMedia,
FrigateViewMediaFactory,
} from '../../../src/camera-manager/frigate/media';
import {
getEvents,
getEventSummary,
getPTZInfo,
getRecordingSegments,
getRecordingsSummary,
getReviews,
retainEvent,
setReviewsReviewed,
} from '../../../src/camera-manager/frigate/requests';
import type {
FrigateEventQueryResults,
FrigateRecordingQueryResults,
FrigateReviewQueryResults,
} from '../../../src/camera-manager/frigate/types';
import {
CameraManagerRequestCache,
Engine,
QueryResultsType,
QueryType,
} from '../../../src/camera-manager/types';
import type { CameraConfig } from '../../../src/config/schema/cameras';
import type { RawAdvancedCameraCardConfig } from '../../../src/config/types';
import { QuerySource } from '../../../src/query-source';
import type { Severity } from '../../../src/severity';
import { ViewMedia, ViewMediaType } from '../../../src/view/item';
import { createCameraConfig } from '../../config/test-utils';
import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock';
import {
createFrigateEvent,
createFrigateRecording,
createFrigateReview,
createHASS,
createHASSManager,
} from '../../test-utils';
import { TestViewMedia } from '../../view/test-utils';
import { createStore } from '../test-utils';
vi.mock('../../../src/camera-manager/frigate/requests');
const createEngine = (options?: {
cache?: RecordingSegmentsCache;
requestCache?: CameraManagerRequestCache;
}): FrigateCameraManagerEngine => {
return new FrigateCameraManagerEngine(
new EntityRegistryManagerMock(),
createHASSManager(),
options?.cache ?? new RecordingSegmentsCache(),
options?.requestCache ?? new CameraManagerRequestCache(),
);
};
const createRecordingMedia = (): FrigateRecordingViewMedia => {
return new FrigateRecordingViewMedia(
ViewMediaType.Recording,
'camera-1',
{
cameraID: 'camera-1',
startTime: new Date('2026-03-14T20:00:00Z'),
endTime: new Date('2026-03-14T20:59:59Z'),
events: 1,
},
'recording-id',
'recording-content-id',
'recording-title',
);
};
const createClipMedia = (): FrigateEventViewMedia => {
return new FrigateEventViewMedia(
ViewMediaType.Clip,
'camera-1',
createFrigateEvent({
camera: 'camera-1',
id: 'event-id',
retain_indefinitely: true,
}),
'event-clip-content-id',
'event-clip-thumbnail',
);
};
const createSnapshotMedia = (): FrigateEventViewMedia => {
return new FrigateEventViewMedia(
ViewMediaType.Snapshot,
'camera-1',
createFrigateEvent({
camera: 'camera-1',
id: 'event-id',
retain_indefinitely: true,
}),
'event-snapshot-content-id',
'event-snapshot-thumbnail',
);
};
const createFrigateCameraConfig = (
config?: RawAdvancedCameraCardConfig,
): CameraConfig => {
return createCameraConfig({
frigate: {
camera_name: 'camera-1',
client_id: 'frigate',
},
camera_entity: 'camera.office',
...config,
});
};
// @vitest-environment jsdom
describe('FrigateQueryResultsClassifier', () => {
it('should identify frigate event query results', () => {
expect(
FrigateQueryResultsClassifier.isFrigateEventQueryResults({
type: QueryResultsType.Event,
engine: Engine.Frigate,
}),
).toBeTruthy();
});
it('should reject non-frigate event query results', () => {
expect(
FrigateQueryResultsClassifier.isFrigateEventQueryResults({
type: QueryResultsType.Event,
engine: Engine.Generic,
}),
).toBeFalsy();
});
it('should reject non-event query results', () => {
expect(
FrigateQueryResultsClassifier.isFrigateEventQueryResults({
type: QueryResultsType.Recording,
engine: Engine.Frigate,
}),
).toBeFalsy();
});
it('should identify frigate recording query results', () => {
expect(
FrigateQueryResultsClassifier.isFrigateRecordingQueryResults({
type: QueryResultsType.Recording,
engine: Engine.Frigate,
}),
).toBeTruthy();
});
it('should reject non-frigate recording query results', () => {
expect(
FrigateQueryResultsClassifier.isFrigateRecordingQueryResults({
type: QueryResultsType.Recording,
engine: Engine.Generic,
}),
).toBeFalsy();
});
it('should identify frigate recording segments results', () => {
expect(
FrigateQueryResultsClassifier.isFrigateRecordingSegmentsResults({
type: QueryResultsType.RecordingSegments,
engine: Engine.Frigate,
}),
).toBeTruthy();
});
it('should reject non-frigate recording segments results', () => {
expect(
FrigateQueryResultsClassifier.isFrigateRecordingSegmentsResults({
type: QueryResultsType.RecordingSegments,
engine: Engine.Generic,
}),
).toBeFalsy();
});
it('should identify frigate review query results', () => {
expect(
FrigateQueryResultsClassifier.isFrigateReviewQueryResults({
type: QueryResultsType.Review,
engine: Engine.Frigate,
}),
).toBeTruthy();
});
it('should reject non-frigate review query results', () => {
expect(
FrigateQueryResultsClassifier.isFrigateReviewQueryResults({
type: QueryResultsType.Review,
engine: Engine.Generic,
}),
).toBeFalsy();
});
});
describe('FrigateCameraManagerEngine', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
});
describe('getEngineType', () => {
it('should return Frigate engine type', () => {
expect(createEngine().getEngineType()).toBe(Engine.Frigate);
});
});
describe('createCamera', () => {
it('should create a FrigateCamera', async () => {
const engine = createEngine();
vi.mocked(getPTZInfo).mockResolvedValue({ features: [], presets: [] });
const camera = await engine.createCamera(createFrigateCameraConfig());
expect(camera).toBeInstanceOf(Camera);
});
});
describe('getDefaultQueryParameters', () => {
it('should return labels and zones for event queries', () => {
const engine = createEngine();
const config = createCameraConfig({
frigate: {
camera_name: 'camera-1',
labels: ['person', 'car'],
zones: ['front_yard'],
},
});
const store = createStore([{ cameraID: 'camera-1', config, engine }]);
const camera = store.getCamera('camera-1');
assert(camera);
const params = engine.getDefaultQueryParameters(camera, QueryType.Event);
expect(params).toEqual({
what: new Set(['person', 'car']),
where: new Set(['front_yard']),
});
});
it('should return labels and zones for review queries', () => {
const engine = createEngine();
const config = createCameraConfig({
frigate: {
camera_name: 'camera-1',
labels: ['person'],
zones: ['backyard'],
},
});
const store = createStore([{ cameraID: 'camera-1', config, engine }]);
const camera = store.getCamera('camera-1');
assert(camera);
const params = engine.getDefaultQueryParameters(camera, QueryType.Review);
expect(params).toEqual({
what: new Set(['person']),
where: new Set(['backyard']),
});
});
it('should return empty for recording queries', () => {
const engine = createEngine();
const config = createCameraConfig({
frigate: {
camera_name: 'camera-1',
labels: ['person'],
},
});
const store = createStore([{ cameraID: 'camera-1', config, engine }]);
const camera = store.getCamera('camera-1');
assert(camera);
const params = engine.getDefaultQueryParameters(camera, QueryType.Recording);
expect(params).toEqual({});
});
it('should return empty when no labels or zones', () => {
const engine = createEngine();
const config = createCameraConfig({
frigate: { camera_name: 'camera-1' },
});
const store = createStore([{ cameraID: 'camera-1', config, engine }]);
const camera = store.getCamera('camera-1');
assert(camera);
const params = engine.getDefaultQueryParameters(camera, QueryType.Event);
expect(params).toEqual({});
});
});
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/1773518400/end/1773521999?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();
});
it('should get no path when client_id is unresolved', async () => {
const endpoint = await createEngine().getMediaDownloadPath(
createHASS(),
createFrigateCameraConfig({
frigate: { camera_name: 'camera-1' },
}),
createClipMedia(),
);
expect(endpoint).toBeNull();
});
});
describe('generateDefaultEventQuery', () => {
it('should batch cameras with same config', () => {
const engine = createEngine();
const config1 = createCameraConfig({
frigate: { camera_name: 'cam1', labels: ['person'] },
});
const config2 = createCameraConfig({
frigate: { camera_name: 'cam2', labels: ['person'] },
});
const store = createStore([
{ cameraID: 'camera-1', config: config1 },
{ cameraID: 'camera-2', config: config2 },
]);
const queries = engine.generateDefaultEventQuery(
store,
new Set(['camera-1', 'camera-2']),
);
expect(queries).toHaveLength(1);
assert(queries);
expect(queries[0].cameraIDs).toEqual(new Set(['camera-1', 'camera-2']));
expect(queries[0].what).toEqual(new Set(['person']));
});
it('should fan out cameras with different configs', () => {
const engine = createEngine();
const config1 = createCameraConfig({
frigate: { camera_name: 'cam1', labels: ['person'] },
});
const config2 = createCameraConfig({
frigate: { camera_name: 'cam2', labels: ['car'] },
});
const store = createStore([
{ cameraID: 'camera-1', config: config1 },
{ cameraID: 'camera-2', config: config2 },
]);
const queries = engine.generateDefaultEventQuery(
store,
new Set(['camera-1', 'camera-2']),
);
expect(queries).toHaveLength(2);
});
it('should fan out cameras with different zones', () => {
const engine = createEngine();
const config1 = createCameraConfig({
frigate: { camera_name: 'cam1', zones: ['front'] },
});
const config2 = createCameraConfig({
frigate: { camera_name: 'cam2', zones: ['back'] },
});
const store = createStore([
{ cameraID: 'camera-1', config: config1 },
{ cameraID: 'camera-2', config: config2 },
]);
const queries = engine.generateDefaultEventQuery(
store,
new Set(['camera-1', 'camera-2']),
);
expect(queries).toHaveLength(2);
assert(queries);
expect(queries[0].where).toEqual(new Set(['front']));
expect(queries[1].where).toEqual(new Set(['back']));
});
it('should return null when no cameras produce queries', () => {
const engine = createEngine();
const store = createStore();
const queries = engine.generateDefaultEventQuery(
store,
new Set(['camera-nonexistent']),
);
expect(queries).toBeNull();
});
it('should include partial query overrides', () => {
const engine = createEngine();
const config = createCameraConfig({
frigate: { camera_name: 'cam1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
const start = new Date('2026-03-14');
const end = new Date('2026-03-15');
const queries = engine.generateDefaultEventQuery(store, new Set(['camera-1']), {
start,
end,
limit: 10,
});
assert(queries);
expect(queries[0].start).toEqual(start);
expect(queries[0].end).toEqual(end);
expect(queries[0].limit).toBe(10);
});
it('should batch cameras with shared zones in event query', () => {
const config1 = createCameraConfig({
frigate: { camera_name: 'cam1', zones: ['front'] },
});
const config2 = createCameraConfig({
frigate: { camera_name: 'cam2', zones: ['front'] },
});
const store = createStore([
{ cameraID: 'camera-1', config: config1 },
{ cameraID: 'camera-2', config: config2 },
]);
const queries = createEngine().generateDefaultEventQuery(
store,
new Set(['camera-1', 'camera-2']),
);
assert(queries);
expect(queries).toHaveLength(1);
expect(queries[0].where).toEqual(new Set(['front']));
});
});
describe('generateDefaultRecordingQuery', () => {
it('should generate recording query', () => {
const engine = createEngine();
const store = createStore();
const queries = engine.generateDefaultRecordingQuery(store, new Set(['camera-1']));
expect(queries).toHaveLength(1);
expect(queries[0].type).toBe(QueryType.Recording);
expect(queries[0].cameraIDs).toEqual(new Set(['camera-1']));
});
it('should include partial query overrides', () => {
const engine = createEngine();
const store = createStore();
const start = new Date('2026-03-14');
const queries = engine.generateDefaultRecordingQuery(
store,
new Set(['camera-1']),
{ start },
);
expect(queries[0].start).toEqual(start);
});
});
describe('generateDefaultRecordingSegmentsQuery', () => {
it('should generate segments query with start and end', () => {
const engine = createEngine();
const store = createStore();
const start = new Date('2023-01-01T00:00:00Z');
const end = new Date('2023-01-01T01:00:00Z');
const queries = engine.generateDefaultRecordingSegmentsQuery(
store,
new Set(['camera-1']),
{ start, end },
);
assert(queries);
expect(queries).toHaveLength(1);
expect(queries[0].start).toEqual(start);
expect(queries[0].end).toEqual(end);
});
it('should return null without start', () => {
const engine = createEngine();
const store = createStore();
const queries = engine.generateDefaultRecordingSegmentsQuery(
store,
new Set(['camera-1']),
{ end: new Date() },
);
expect(queries).toBeNull();
});
it('should return null without end', () => {
const engine = createEngine();
const store = createStore();
const queries = engine.generateDefaultRecordingSegmentsQuery(
store,
new Set(['camera-1']),
{ start: new Date() },
);
expect(queries).toBeNull();
});
});
describe('generateDefaultReviewQuery', () => {
it('should batch cameras with same config', () => {
const engine = createEngine();
const config1 = createCameraConfig({
frigate: { camera_name: 'cam1' },
});
const config2 = createCameraConfig({
frigate: { camera_name: 'cam2' },
});
const store = createStore([
{ cameraID: 'camera-1', config: config1 },
{ cameraID: 'camera-2', config: config2 },
]);
const queries = engine.generateDefaultReviewQuery(
store,
new Set(['camera-1', 'camera-2']),
);
expect(queries).toHaveLength(1);
});
});
describe('favoriteMedia', () => {
it('should retain a frigate event', async () => {
const hass = createHASS();
const media = createClipMedia();
vi.mocked(retainEvent).mockResolvedValue();
await createEngine().favoriteMedia(hass, createFrigateCameraConfig(), media, true);
expect(retainEvent).toHaveBeenCalledWith(hass, 'frigate', 'event-id', true);
expect(media.isFavorite()).toBe(true);
});
it('should unretain a frigate event', async () => {
const hass = createHASS();
vi.mocked(retainEvent).mockResolvedValue();
await createEngine().favoriteMedia(
hass,
createFrigateCameraConfig(),
createClipMedia(),
false,
);
expect(retainEvent).toHaveBeenCalledWith(hass, 'frigate', 'event-id', false);
});
it('should do nothing for non-frigate event media', async () => {
const media = new ViewMedia(ViewMediaType.Clip, { cameraID: 'camera-1' });
await createEngine().favoriteMedia(
createHASS(),
createFrigateCameraConfig(),
media,
true,
);
expect(retainEvent).not.toHaveBeenCalled();
});
});
describe('reviewMedia', () => {
it('should mark a frigate review as reviewed', async () => {
const hass = createHASS();
const media = new FrigateReviewViewMedia(
'camera-1',
createFrigateReview(),
'content-id',
'thumb',
);
vi.mocked(setReviewsReviewed).mockResolvedValue();
await createEngine().reviewMedia(hass, createFrigateCameraConfig(), media, true);
expect(setReviewsReviewed).toHaveBeenCalledWith(
hass,
'frigate',
['review_id'],
true,
);
});
it('should do nothing for non-frigate review media', async () => {
const media = new ViewMedia(ViewMediaType.Review, { cameraID: 'camera-1' });
await createEngine().reviewMedia(
createHASS(),
createFrigateCameraConfig(),
media,
true,
);
expect(setReviewsReviewed).not.toHaveBeenCalled();
});
});
describe('getEvents', () => {
it('should return null for unsupported filters', async () => {
const result = await createEngine().getEvents(createHASS(), createStore(), {
type: QueryType.Event,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
reviewed: true,
});
expect(result).toBeNull();
});
it('should fetch events successfully', async () => {
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
const event = createFrigateEvent({ camera: 'camera-1' });
vi.mocked(getEvents).mockResolvedValue([event]);
const result = await createEngine().getEvents(createHASS(), store, {
type: QueryType.Event,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
});
assert(result);
expect(result.size).toBe(1);
const queryResult = [...result.values()][0];
assert(FrigateQueryResultsClassifier.isFrigateEventQueryResults(queryResult));
expect(queryResult.events).toEqual([event]);
expect(queryResult.cached).toBe(false);
});
it('should pass query parameters to native query', async () => {
const hass = createHASS();
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
const start = new Date('2023-01-01T00:00:00Z');
const end = new Date('2023-01-02T00:00:00Z');
vi.mocked(getEvents).mockResolvedValue([]);
await createEngine().getEvents(hass, store, {
type: QueryType.Event,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
what: new Set(['person']),
where: new Set(['front_yard']),
tags: new Set(['john']),
start,
end,
limit: 5,
hasClip: true,
hasSnapshot: true,
favorite: true,
});
expect(getEvents).toHaveBeenCalledWith(hass, {
instance_id: 'client-1',
cameras: ['camera-1'],
labels: ['person'],
zones: ['front_yard'],
sub_labels: ['john'],
after: Math.floor(start.getTime() / 1000),
before: Math.floor(end.getTime() / 1000),
limit: 5,
has_clip: true,
has_snapshot: true,
favorites: true,
});
});
it('should use cache when available', async () => {
const requestCache = new CameraManagerRequestCache();
const engine = createEngine({ requestCache });
const hass = createHASS();
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
const event = createFrigateEvent({ camera: 'camera-1' });
vi.mocked(getEvents).mockResolvedValue([event]);
// First call populates cache.
await engine.getEvents(hass, store, {
type: QueryType.Event,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
});
// Second call should use cache.
const result = await engine.getEvents(hass, store, {
type: QueryType.Event,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
});
expect(getEvents).toHaveBeenCalledTimes(1);
assert(result);
const queryResult = [...result.values()][0];
assert(FrigateQueryResultsClassifier.isFrigateEventQueryResults(queryResult));
expect(queryResult.cached).toBe(true);
});
it('should skip cache when useCache is false', async () => {
const requestCache = new CameraManagerRequestCache();
const engine = createEngine({ requestCache });
const hass = createHASS();
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
vi.mocked(getEvents).mockResolvedValue([]);
await engine.getEvents(hass, store, {
type: QueryType.Event,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
});
await engine.getEvents(
hass,
store,
{
type: QueryType.Event,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
},
{ useCache: false },
);
expect(getEvents).toHaveBeenCalledTimes(2);
});
it('should return null when no cameras map to instances', async () => {
// Birdseye cameras are excluded from queries.
const config = createCameraConfig({
frigate: { camera_name: 'birdseye', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
const result = await createEngine().getEvents(createHASS(), store, {
type: QueryType.Event,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
});
expect(result).toBeNull();
});
it('should group cameras by instance', async () => {
const config1 = createCameraConfig({
frigate: { camera_name: 'cam1', client_id: 'instance-1' },
});
const config2 = createCameraConfig({
frigate: { camera_name: 'cam2', client_id: 'instance-1' },
});
const config3 = createCameraConfig({
frigate: { camera_name: 'cam3', client_id: 'instance-2' },
});
const store = createStore([
{ cameraID: 'camera-1', config: config1 },
{ cameraID: 'camera-2', config: config2 },
{ cameraID: 'camera-3', config: config3 },
]);
vi.mocked(getEvents).mockResolvedValue([]);
await createEngine().getEvents(createHASS(), store, {
type: QueryType.Event,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1', 'camera-2', 'camera-3']),
});
// Two calls: one per Frigate instance.
expect(getEvents).toHaveBeenCalledTimes(2);
});
it('should send empty cameras list when camera_name is empty', async () => {
const hass = createHASS();
const config = createCameraConfig({
frigate: { camera_name: '', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
vi.mocked(getEvents).mockResolvedValue([]);
const result = await createEngine().getEvents(hass, store, {
type: QueryType.Event,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
});
assert(result);
// Event query still runs but with no cameras filter.
expect(getEvents).toHaveBeenCalledWith(
hass,
expect.objectContaining({ cameras: [] }),
);
});
it('should skip event cache write when useCache is false', async () => {
const requestCache = new CameraManagerRequestCache();
const engine = createEngine({ requestCache });
const hass = createHASS();
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
vi.mocked(getEvents).mockResolvedValue([]);
await engine.getEvents(
hass,
store,
{
type: QueryType.Event,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
},
{ useCache: false },
);
await engine.getEvents(hass, store, {
type: QueryType.Event,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
});
expect(getEvents).toHaveBeenCalledTimes(2);
});
});
describe('getReviews', () => {
it('should return null for unsupported filters', async () => {
const result = await createEngine().getReviews(createHASS(), createStore(), {
type: QueryType.Review,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
favorite: true,
});
expect(result).toBeNull();
});
it('should fan out requests for multiple severities', async () => {
const hass = createHASS();
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
const reviewHigh = createFrigateReview({ id: 'review-high' });
const reviewMedium = createFrigateReview({ id: 'review-medium' });
vi.mocked(getReviews)
.mockResolvedValueOnce([reviewHigh])
.mockResolvedValueOnce([reviewMedium]);
const resultsMap = await createEngine().getReviews(hass, store, {
type: QueryType.Review,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
severity: new Set<Severity>(['high', 'medium']),
});
expect(getReviews).toHaveBeenCalledTimes(2);
expect(getReviews).toHaveBeenCalledWith(
hass,
expect.objectContaining({ severity: 'alert' }),
);
expect(getReviews).toHaveBeenCalledWith(
hass,
expect.objectContaining({ severity: 'detection' }),
);
assert(resultsMap);
const result = [...resultsMap.values()][0];
assert(FrigateQueryResultsClassifier.isFrigateReviewQueryResults(result));
expect(result.reviews).toHaveLength(2);
});
it('should handle single severity', async () => {
const hass = createHASS();
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
vi.mocked(getReviews).mockResolvedValue([]);
await createEngine().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 config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
vi.mocked(getReviews).mockResolvedValue([]);
await createEngine().getReviews(createHASS(), 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 hass = createHASS();
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
vi.mocked(getReviews).mockResolvedValue([]);
await createEngine().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 }),
);
});
it('should use cache when available', async () => {
const requestCache = new CameraManagerRequestCache();
const engine = createEngine({ requestCache });
const hass = createHASS();
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
vi.mocked(getReviews).mockResolvedValue([]);
await engine.getReviews(hass, store, {
type: QueryType.Review,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
});
await engine.getReviews(hass, store, {
type: QueryType.Review,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
});
expect(getReviews).toHaveBeenCalledTimes(1);
});
it('should pass query parameters to review request', async () => {
const hass = createHASS();
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
const start = new Date('2023-01-01T00:00:00Z');
const end = new Date('2023-01-02T00:00:00Z');
vi.mocked(getReviews).mockResolvedValue([]);
await createEngine().getReviews(hass, store, {
type: QueryType.Review,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
what: new Set(['person']),
where: new Set(['zone1']),
start,
end,
limit: 5,
reviewed: false,
severity: new Set<Severity>(['high']),
});
expect(getReviews).toHaveBeenCalledWith(hass, {
instance_id: 'client-1',
cameras: ['camera-1'],
labels: ['person'],
zones: ['zone1'],
after: Math.floor(start.getTime() / 1000),
before: Math.floor(end.getTime() / 1000),
limit: 5,
severity: 'alert',
reviewed: false,
});
});
it('should return null when no cameras map to instances', async () => {
const config = createCameraConfig({
frigate: { camera_name: 'birdseye', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
const result = await createEngine().getReviews(createHASS(), store, {
type: QueryType.Review,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
});
expect(result).toBeNull();
});
it('should skip review cache write when useCache is false', async () => {
const requestCache = new CameraManagerRequestCache();
const engine = createEngine({ requestCache });
const hass = createHASS();
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
vi.mocked(getReviews).mockResolvedValue([]);
await engine.getReviews(
hass,
store,
{
type: QueryType.Review,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
},
{ useCache: false },
);
// Second call should still hit the API since cache was not populated.
await engine.getReviews(hass, store, {
type: QueryType.Review,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
});
expect(getReviews).toHaveBeenCalledTimes(2);
});
});
describe('getRecordings', () => {
it('should return null for unsupported filters', async () => {
const result = await createEngine().getRecordings(createHASS(), createStore(), {
type: QueryType.Recording,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
what: new Set(['person']),
});
expect(result).toBeNull();
});
it('should fetch recordings successfully', async () => {
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
vi.mocked(getRecordingsSummary).mockResolvedValue([
{
day: new Date('2026-03-14'),
events: 5,
hours: [{ hour: 20, duration: 3600, events: 5 }],
},
]);
const result = await createEngine().getRecordings(createHASS(), store, {
type: QueryType.Recording,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
});
assert(result);
expect(result.size).toBe(1);
const queryResult = [...result.values()][0];
assert(FrigateQueryResultsClassifier.isFrigateRecordingQueryResults(queryResult));
expect(queryResult.recordings).toHaveLength(1);
expect(queryResult.recordings[0].events).toBe(5);
});
it('should filter recordings by date range', async () => {
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
// Use a fixed local-timezone day to avoid UTC/local mismatch.
const day = new Date(2026, 2, 14, 0, 0, 0);
vi.mocked(getRecordingsSummary).mockResolvedValue([
{
day: day,
events: 10,
hours: [
{ hour: 10, duration: 3600, events: 3 },
{ hour: 14, duration: 3600, events: 4 },
{ hour: 20, duration: 3600, events: 3 },
],
},
]);
const result = await createEngine().getRecordings(createHASS(), store, {
type: QueryType.Recording,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
start: new Date(2026, 2, 14, 12, 0, 0),
end: new Date(2026, 2, 14, 18, 0, 0),
});
assert(result);
const queryResult = [...result.values()][0];
assert(FrigateQueryResultsClassifier.isFrigateRecordingQueryResults(queryResult));
// Only the hour 14 recording falls within 12:00-18:00.
expect(queryResult.recordings).toHaveLength(1);
expect(queryResult.recordings[0].events).toBe(4);
});
it('should limit recordings', async () => {
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
vi.mocked(getRecordingsSummary).mockResolvedValue([
{
day: new Date('2026-03-14'),
events: 6,
hours: [
{ hour: 10, duration: 3600, events: 2 },
{ hour: 14, duration: 3600, events: 2 },
{ hour: 20, duration: 3600, events: 2 },
],
},
]);
const result = await createEngine().getRecordings(createHASS(), store, {
type: QueryType.Recording,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
limit: 2,
});
assert(result);
const queryResult = [...result.values()][0];
assert(FrigateQueryResultsClassifier.isFrigateRecordingQueryResults(queryResult));
expect(queryResult.recordings).toHaveLength(2);
});
it('should use cache when available', async () => {
const requestCache = new CameraManagerRequestCache();
const engine = createEngine({ requestCache });
const hass = createHASS();
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
vi.mocked(getRecordingsSummary).mockResolvedValue([]);
await engine.getRecordings(hass, store, {
type: QueryType.Recording,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
});
await engine.getRecordings(hass, store, {
type: QueryType.Recording,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
});
expect(getRecordingsSummary).toHaveBeenCalledTimes(1);
});
it('should skip birdseye cameras', async () => {
const config = createCameraConfig({
frigate: { camera_name: 'birdseye', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
const result = await createEngine().getRecordings(createHASS(), store, {
type: QueryType.Recording,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
});
expect(result).toBeNull();
expect(getRecordingsSummary).not.toHaveBeenCalled();
});
it('should skip cameras without camera_name', async () => {
const config = createCameraConfig({
frigate: { camera_name: '' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
const result = await createEngine().getRecordings(createHASS(), store, {
type: QueryType.Recording,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
});
expect(result).toBeNull();
});
it('should handle null recording summary', async () => {
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
vi.mocked(getRecordingsSummary).mockResolvedValue(
null as unknown as Awaited<ReturnType<typeof getRecordingsSummary>>,
);
const result = await createEngine().getRecordings(createHASS(), store, {
type: QueryType.Recording,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
});
assert(result);
const queryResult = [...result.values()][0];
assert(FrigateQueryResultsClassifier.isFrigateRecordingQueryResults(queryResult));
expect(queryResult.recordings).toHaveLength(0);
});
it('should skip recording cache write when useCache is false', async () => {
const requestCache = new CameraManagerRequestCache();
const engine = createEngine({ requestCache });
const hass = createHASS();
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
vi.mocked(getRecordingsSummary).mockResolvedValue([]);
await engine.getRecordings(
hass,
store,
{
type: QueryType.Recording,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
},
{ useCache: false },
);
// Second call should still hit the API since cache was not populated.
await engine.getRecordings(hass, store, {
type: QueryType.Recording,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
});
expect(getRecordingsSummary).toHaveBeenCalledTimes(2);
});
});
describe('getRecordingSegments', () => {
it('should fetch segments successfully', async () => {
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
const segments = [{ start_time: 100, end_time: 200, id: 'seg-1' }];
vi.mocked(getRecordingSegments).mockResolvedValue(segments);
const start = new Date('2026-03-14T20:00:00Z');
const end = new Date('2026-03-14T21:00:00Z');
const result = await createEngine().getRecordingSegments(createHASS(), store, {
type: QueryType.RecordingSegments,
cameraIDs: new Set(['camera-1']),
start,
end,
});
assert(result);
expect(result.size).toBe(1);
const queryResult = [...result.values()][0];
assert(
FrigateQueryResultsClassifier.isFrigateRecordingSegmentsResults(queryResult),
);
expect(queryResult.segments).toEqual(segments);
expect(queryResult.cached).toBe(false);
});
it('should use segment cache when available', async () => {
const cache = new RecordingSegmentsCache();
const engine = createEngine({ cache });
const hass = createHASS();
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
const start = new Date('2026-03-14T20:00:00Z');
const end = new Date('2026-03-14T21:00:00Z');
const segments = [{ start_time: 100, end_time: 200, id: 'seg-1' }];
cache.add('camera-1', { start, end }, segments);
const result = await engine.getRecordingSegments(hass, store, {
type: QueryType.RecordingSegments,
cameraIDs: new Set(['camera-1']),
start,
end,
});
expect(getRecordingSegments).not.toHaveBeenCalled();
assert(result);
const queryResult = [...result.values()][0];
assert(
FrigateQueryResultsClassifier.isFrigateRecordingSegmentsResults(queryResult),
);
expect(queryResult.cached).toBe(true);
});
it('should skip birdseye cameras', async () => {
const config = createCameraConfig({
frigate: { camera_name: 'birdseye', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
const result = await createEngine().getRecordingSegments(createHASS(), store, {
type: QueryType.RecordingSegments,
cameraIDs: new Set(['camera-1']),
start: new Date(),
end: new Date(),
});
expect(result).toBeNull();
});
it('should skip cameras without camera_name', async () => {
const config = createCameraConfig({
frigate: { camera_name: '' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
const result = await createEngine().getRecordingSegments(createHASS(), store, {
type: QueryType.RecordingSegments,
cameraIDs: new Set(['camera-1']),
start: new Date(),
end: new Date(),
});
expect(result).toBeNull();
});
it('should skip segment cache when useCache is false', async () => {
const cache = new RecordingSegmentsCache();
const engine = createEngine({ cache });
const hass = createHASS();
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
const start = new Date('2026-03-14T20:00:00Z');
const end = new Date('2026-03-14T21:00:00Z');
const segments = [{ start_time: 100, end_time: 200, id: 'seg-1' }];
cache.add('camera-1', { start, end }, segments);
vi.mocked(getRecordingSegments).mockResolvedValue(segments);
await engine.getRecordingSegments(
hass,
store,
{
type: QueryType.RecordingSegments,
cameraIDs: new Set(['camera-1']),
start,
end,
},
{ useCache: false },
);
expect(getRecordingSegments).toHaveBeenCalled();
});
});
describe('generateMediaFromEvents', () => {
it('should return null for non-frigate results', () => {
const result = createEngine().generateMediaFromEvents(
createHASS(),
createStore(),
{
type: QueryType.Event,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
},
{
type: QueryResultsType.Event,
engine: Engine.Generic,
},
);
expect(result).toBeNull();
});
it('should generate clip media from events', () => {
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
const event = createFrigateEvent({
camera: 'camera-1',
has_clip: true,
has_snapshot: false,
});
const result = createEngine().generateMediaFromEvents(
createHASS(),
store,
{
type: QueryType.Event,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
},
{
type: QueryResultsType.Event,
engine: Engine.Frigate,
instanceID: 'client-1',
events: [event],
} as FrigateEventQueryResults,
);
assert(result);
expect(result).toHaveLength(1);
expect(result[0]).toBeInstanceOf(FrigateEventViewMedia);
expect(result[0].getMediaType()).toBe(ViewMediaType.Clip);
});
it('should generate snapshot media when only snapshot available', () => {
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
const event = createFrigateEvent({
camera: 'camera-1',
has_clip: false,
has_snapshot: true,
});
const result = createEngine().generateMediaFromEvents(
createHASS(),
store,
{
type: QueryType.Event,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
},
{
type: QueryResultsType.Event,
engine: Engine.Frigate,
instanceID: 'client-1',
events: [event],
} as FrigateEventQueryResults,
);
assert(result);
expect(result).toHaveLength(1);
expect(result[0].getMediaType()).toBe(ViewMediaType.Snapshot);
});
it('should respect hasSnapshot query filter', () => {
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
const event = createFrigateEvent({
camera: 'camera-1',
has_clip: true,
has_snapshot: true,
});
const result = createEngine().generateMediaFromEvents(
createHASS(),
store,
{
type: QueryType.Event,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
hasSnapshot: true,
},
{
type: QueryResultsType.Event,
engine: Engine.Frigate,
instanceID: 'client-1',
events: [event],
} as FrigateEventQueryResults,
);
assert(result);
expect(result).toHaveLength(1);
expect(result[0].getMediaType()).toBe(ViewMediaType.Snapshot);
});
it('should respect hasClip query filter', () => {
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
const event = createFrigateEvent({
camera: 'camera-1',
has_clip: true,
has_snapshot: true,
});
const result = createEngine().generateMediaFromEvents(
createHASS(),
store,
{
type: QueryType.Event,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
hasClip: true,
},
{
type: QueryResultsType.Event,
engine: Engine.Frigate,
instanceID: 'client-1',
events: [event],
} as FrigateEventQueryResults,
);
assert(result);
expect(result).toHaveLength(1);
expect(result[0].getMediaType()).toBe(ViewMediaType.Clip);
});
it('should skip events without matching media type', () => {
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
const event = createFrigateEvent({
camera: 'camera-1',
has_clip: false,
has_snapshot: false,
});
const result = createEngine().generateMediaFromEvents(
createHASS(),
store,
{
type: QueryType.Event,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
},
{
type: QueryResultsType.Event,
engine: Engine.Frigate,
instanceID: 'client-1',
events: [event],
} as FrigateEventQueryResults,
);
expect(result).toEqual([]);
});
it('should skip events with hasSnapshot but no snapshot', () => {
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
const event = createFrigateEvent({
camera: 'camera-1',
has_clip: true,
has_snapshot: false,
});
const result = createEngine().generateMediaFromEvents(
createHASS(),
store,
{
type: QueryType.Event,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
hasSnapshot: true,
},
{
type: QueryResultsType.Event,
engine: Engine.Frigate,
instanceID: 'client-1',
events: [event],
} as FrigateEventQueryResults,
);
expect(result).toEqual([]);
});
it('should handle sub_labels on events', () => {
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
const event = createFrigateEvent({
camera: 'camera-1',
has_clip: true,
sub_label: 'john, jane',
});
const result = createEngine().generateMediaFromEvents(
createHASS(),
store,
{
type: QueryType.Event,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
},
{
type: QueryResultsType.Event,
engine: Engine.Frigate,
instanceID: 'client-1',
events: [event],
} as FrigateEventQueryResults,
);
assert(result);
expect(result).toHaveLength(1);
assert(result[0] instanceof FrigateEventViewMedia);
expect(result[0].getTags()).toEqual(['john', 'jane']);
});
it('should skip events for birdseye cameras', () => {
const config = createCameraConfig({
frigate: { camera_name: 'birdseye', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
const event = createFrigateEvent({
camera: 'camera-1',
has_clip: true,
});
const result = createEngine().generateMediaFromEvents(
createHASS(),
store,
{
type: QueryType.Event,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
},
{
type: QueryResultsType.Event,
engine: Engine.Frigate,
instanceID: 'client-1',
events: [event],
} as FrigateEventQueryResults,
);
expect(result).toEqual([]);
});
it('should match camera by instance and name for multi-camera queries', () => {
const config1 = createCameraConfig({
frigate: { camera_name: 'cam1', client_id: 'client-1' },
});
const config2 = createCameraConfig({
frigate: { camera_name: 'cam2', client_id: 'client-1' },
});
const store = createStore([
{ cameraID: 'camera-1', config: config1 },
{ cameraID: 'camera-2', config: config2 },
]);
const event = createFrigateEvent({
camera: 'cam2',
has_clip: true,
});
const result = createEngine().generateMediaFromEvents(
createHASS(),
store,
{
type: QueryType.Event,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1', 'camera-2']),
},
{
type: QueryResultsType.Event,
engine: Engine.Frigate,
instanceID: 'client-1',
events: [event],
} as FrigateEventQueryResults,
);
assert(result);
expect(result).toHaveLength(1);
expect(result[0].getCameraID()).toBe('camera-2');
});
it('should skip events with no matching camera', () => {
const config1 = createCameraConfig({
frigate: { camera_name: 'cam1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config: config1 }]);
const event = createFrigateEvent({
camera: 'unknown-cam',
has_clip: true,
});
const result = createEngine().generateMediaFromEvents(
createHASS(),
store,
{
type: QueryType.Event,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1', 'camera-2']),
},
{
type: QueryResultsType.Event,
engine: Engine.Frigate,
instanceID: 'client-1',
events: [event],
} as FrigateEventQueryResults,
);
expect(result).toEqual([]);
});
it('should skip events when factory returns null', () => {
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
const event = createFrigateEvent({
camera: 'camera-1',
has_clip: true,
});
vi.spyOn(FrigateViewMediaFactory, 'createEventViewMedia').mockReturnValue(null);
const result = createEngine().generateMediaFromEvents(
createHASS(),
store,
{
type: QueryType.Event,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
},
{
type: QueryResultsType.Event,
engine: Engine.Frigate,
instanceID: 'client-1',
events: [event],
} as FrigateEventQueryResults,
);
expect(result).toEqual([]);
});
});
describe('generateMediaFromRecordings', () => {
it('should return null for non-frigate results', () => {
const result = createEngine().generateMediaFromRecordings(
createHASS(),
createStore(),
{
type: QueryType.Recording,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
},
{
type: QueryResultsType.Recording,
engine: Engine.Generic,
},
);
expect(result).toBeNull();
});
it('should generate media from recordings', () => {
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
camera_entity: 'camera.office',
});
const store = createStore([{ cameraID: 'camera-1', config }]);
const recording = createFrigateRecording({
cameraID: 'camera-1',
});
const result = createEngine().generateMediaFromRecordings(
createHASS(),
store,
{
type: QueryType.Recording,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
},
{
type: QueryResultsType.Recording,
engine: Engine.Frigate,
instanceID: 'client-1',
recordings: [recording],
} as FrigateRecordingQueryResults,
);
assert(result);
expect(result).toHaveLength(1);
expect(result[0]).toBeInstanceOf(FrigateRecordingViewMedia);
});
it('should skip birdseye camera recordings', () => {
const config = createCameraConfig({
frigate: { camera_name: 'birdseye', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
const recording = createFrigateRecording({
cameraID: 'camera-1',
});
const result = createEngine().generateMediaFromRecordings(
createHASS(),
store,
{
type: QueryType.Recording,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
},
{
type: QueryResultsType.Recording,
engine: Engine.Frigate,
instanceID: 'client-1',
recordings: [recording],
} as FrigateRecordingQueryResults,
);
expect(result).toEqual([]);
});
it('should skip recordings when factory returns null', () => {
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
camera_entity: 'camera.office',
});
const store = createStore([{ cameraID: 'camera-1', config }]);
const recording = createFrigateRecording({ cameraID: 'camera-1' });
vi.spyOn(FrigateViewMediaFactory, 'createRecordingViewMedia').mockReturnValue(
null,
);
const result = createEngine().generateMediaFromRecordings(
createHASS(),
store,
{
type: QueryType.Recording,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
},
{
type: QueryResultsType.Recording,
engine: Engine.Frigate,
instanceID: 'client-1',
recordings: [recording],
} as FrigateRecordingQueryResults,
);
expect(result).toEqual([]);
});
});
describe('generateMediaFromReviews', () => {
it('should return null for non-frigate results', () => {
const result = createEngine().generateMediaFromReviews(
createHASS(),
createStore(),
{
type: QueryType.Review,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
},
{
type: QueryResultsType.Review,
engine: Engine.Generic,
},
);
expect(result).toBeNull();
});
it('should generate media from reviews', () => {
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
const review = createFrigateReview({ camera: 'camera-1' });
const result = createEngine().generateMediaFromReviews(
createHASS(),
store,
{
type: QueryType.Review,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
},
{
type: QueryResultsType.Review,
engine: Engine.Frigate,
instanceID: 'client-1',
reviews: [review],
} as FrigateReviewQueryResults,
);
assert(result);
expect(result).toHaveLength(1);
expect(result[0]).toBeInstanceOf(FrigateReviewViewMedia);
});
it('should skip reviews for birdseye cameras', () => {
const config = createCameraConfig({
frigate: { camera_name: 'birdseye', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
const review = createFrigateReview({ camera: 'camera-1' });
const result = createEngine().generateMediaFromReviews(
createHASS(),
store,
{
type: QueryType.Review,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
},
{
type: QueryResultsType.Review,
engine: Engine.Frigate,
instanceID: 'client-1',
reviews: [review],
} as FrigateReviewQueryResults,
);
expect(result).toEqual([]);
});
it('should skip reviews with no matching camera', () => {
const config = createCameraConfig({
frigate: { camera_name: 'cam1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
const review = createFrigateReview({ camera: 'unknown-cam' });
const result = createEngine().generateMediaFromReviews(
createHASS(),
store,
{
type: QueryType.Review,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1', 'camera-2']),
},
{
type: QueryResultsType.Review,
engine: Engine.Frigate,
instanceID: 'client-1',
reviews: [review],
} as FrigateReviewQueryResults,
);
expect(result).toEqual([]);
});
it('should skip reviews when factory returns null', () => {
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
const review = createFrigateReview({ camera: 'camera-1' });
vi.spyOn(FrigateViewMediaFactory, 'createReviewViewMedia').mockReturnValue(null);
const result = createEngine().generateMediaFromReviews(
createHASS(),
store,
{
type: QueryType.Review,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
},
{
type: QueryResultsType.Review,
engine: Engine.Frigate,
instanceID: 'client-1',
reviews: [review],
} as FrigateReviewQueryResults,
);
expect(result).toEqual([]);
});
});
describe('getQueryResultMaxAge', () => {
it('should return 60 for event queries', () => {
expect(
createEngine().getQueryResultMaxAge({
type: QueryType.Event,
cameraIDs: new Set(),
}),
).toBe(60);
});
it('should return 60 for recording queries', () => {
expect(
createEngine().getQueryResultMaxAge({
type: QueryType.Recording,
cameraIDs: new Set(),
}),
).toBe(60);
});
it('should return 60 for review queries', () => {
expect(
createEngine().getQueryResultMaxAge({
type: QueryType.Review,
cameraIDs: new Set(),
}),
).toBe(60);
});
it('should return null for other query types', () => {
expect(
createEngine().getQueryResultMaxAge({
type: QueryType.RecordingSegments,
cameraIDs: new Set(),
}),
).toBeNull();
});
});
describe('getMediaSeekTime', () => {
it('should get seek time for review media', async () => {
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
const startTime = new Date('2026-03-14T20:15:00Z');
const endTime = new Date('2026-03-14T20:45:00Z');
const media = new TestViewMedia({
mediaType: ViewMediaType.Review,
cameraID: 'camera-1',
startTime,
endTime,
});
const segments = [
{
start_time: new Date('2026-03-14T20:00:00Z').getTime() / 1000,
end_time: new Date('2026-03-14T21:00:00Z').getTime() / 1000,
id: 'segment-1',
},
];
vi.mocked(getRecordingSegments).mockResolvedValue(segments);
const seekTime = await createEngine().getMediaSeekTime(
createHASS(),
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 config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
const startTime = new Date('2026-03-14T20:15:00Z');
const endTime = new Date('2026-03-14T20: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',
},
];
vi.mocked(getRecordingSegments).mockResolvedValue(segments);
const seekTime = await createEngine().getMediaSeekTime(
createHASS(),
store,
media,
startTime,
);
expect(seekTime).toBe(0);
});
it('should return null when media has no start time', async () => {
const media = new TestViewMedia({
mediaType: ViewMediaType.Clip,
cameraID: 'camera-1',
});
const result = await createEngine().getMediaSeekTime(
createHASS(),
createStore(),
media,
new Date(),
);
expect(result).toBeNull();
});
it('should return null when media has no cameraID', async () => {
const media = new TestViewMedia({
mediaType: ViewMediaType.Clip,
startTime: new Date(),
});
const result = await createEngine().getMediaSeekTime(
createHASS(),
createStore(),
media,
new Date(),
);
expect(result).toBeNull();
});
it('should return null when media has no end time', async () => {
const media = new TestViewMedia({
mediaType: ViewMediaType.Clip,
cameraID: 'camera-1',
startTime: new Date('2026-03-14T20:15:00Z'),
});
const result = await createEngine().getMediaSeekTime(
createHASS(),
createStore(),
media,
new Date('2026-03-14T20:20:00Z'),
);
expect(result).toBeNull();
});
it('should return null when target is before start', async () => {
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
const startTime = new Date('2026-03-14T20:15:00Z');
const endTime = new Date('2026-03-14T20:45:00Z');
const media = new TestViewMedia({
mediaType: ViewMediaType.Clip,
cameraID: 'camera-1',
startTime,
endTime,
});
const result = await createEngine().getMediaSeekTime(
createHASS(),
store,
media,
new Date('2026-03-14T20:00:00Z'),
);
expect(result).toBeNull();
});
it('should return null when target is after end', async () => {
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
const startTime = new Date('2026-03-14T20:15:00Z');
const endTime = new Date('2026-03-14T20:45:00Z');
const media = new TestViewMedia({
mediaType: ViewMediaType.Clip,
cameraID: 'camera-1',
startTime,
endTime,
});
const result = await createEngine().getMediaSeekTime(
createHASS(),
store,
media,
new Date('2026-03-14T21:00:00Z'),
);
expect(result).toBeNull();
});
it('should return null when no segments found', async () => {
// Use birdseye so getRecordingSegments returns null.
const config = createCameraConfig({
frigate: { camera_name: 'birdseye', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
const startTime = new Date('2026-03-14T20:15:00Z');
const endTime = new Date('2026-03-14T20:45:00Z');
const media = new TestViewMedia({
mediaType: ViewMediaType.Clip,
cameraID: 'camera-1',
startTime,
endTime,
});
const result = await createEngine().getMediaSeekTime(
createHASS(),
store,
media,
new Date('2026-03-14T20:20:00Z'),
);
expect(result).toBeNull();
});
it('should handle empty segments', async () => {
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
const startTime = new Date('2026-03-14T20:15:00Z');
const endTime = new Date('2026-03-14T20:45:00Z');
const media = new TestViewMedia({
mediaType: ViewMediaType.Clip,
cameraID: 'camera-1',
startTime,
endTime,
});
vi.mocked(getRecordingSegments).mockResolvedValue([]);
const result = await createEngine().getMediaSeekTime(
createHASS(),
store,
media,
new Date('2026-03-14T20:20:00Z'),
);
expect(result).toBeNull();
});
it('should handle multiple segments with gaps', async () => {
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
const startTime = new Date('2026-03-14T20:00:00Z');
const endTime = new Date('2026-03-14T20:30:00Z');
const media = new TestViewMedia({
mediaType: ViewMediaType.Recording,
cameraID: 'camera-1',
startTime,
endTime,
});
// Two 10-minute segments with a gap.
const segments = [
{
start_time: new Date('2026-03-14T20:00:00Z').getTime() / 1000,
end_time: new Date('2026-03-14T20:10:00Z').getTime() / 1000,
id: 'seg-1',
},
{
start_time: new Date('2026-03-14T20:20:00Z').getTime() / 1000,
end_time: new Date('2026-03-14T20:30:00Z').getTime() / 1000,
id: 'seg-2',
},
];
vi.mocked(getRecordingSegments).mockResolvedValue(segments);
const seekTime = await createEngine().getMediaSeekTime(
createHASS(),
store,
media,
new Date('2026-03-14T20:25:00Z'),
);
// 10 min from first segment + 5 min from second segment = 15 min.
expect(seekTime).toBe(15 * 60);
});
it('should use recording type for seek (hour boundaries)', async () => {
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
const startTime = new Date('2026-03-14T20:15:00Z');
const endTime = new Date('2026-03-14T20:45:00Z');
const media = new TestViewMedia({
mediaType: ViewMediaType.Recording,
cameraID: 'camera-1',
startTime,
endTime,
});
const segments = [
{
start_time: new Date('2026-03-14T20:00:00Z').getTime() / 1000,
end_time: new Date('2026-03-14T21:00:00Z').getTime() / 1000,
id: 'segment-1',
},
];
vi.mocked(getRecordingSegments).mockResolvedValue(segments);
// For recording, start is startOfHour(20:15) = 20:00, end = endOfHour = ~21:00.
const seekTime = await createEngine().getMediaSeekTime(
createHASS(),
store,
media,
new Date('2026-03-14T20:30:00Z'),
);
// 30 minutes from start of hour.
expect(seekTime).toBe(30 * 60);
});
it('should handle segment starting after target in seek', async () => {
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
const startTime = new Date('2026-03-14T20:00:00Z');
const endTime = new Date('2026-03-14T20:30:00Z');
const media = new TestViewMedia({
mediaType: ViewMediaType.Clip,
cameraID: 'camera-1',
startTime,
endTime,
});
// First segment is before target, second starts after target.
const segments = [
{
start_time: new Date('2026-03-14T20:00:00Z').getTime() / 1000,
end_time: new Date('2026-03-14T20:05:00Z').getTime() / 1000,
id: 'seg-1',
},
{
start_time: new Date('2026-03-14T20:20:00Z').getTime() / 1000,
end_time: new Date('2026-03-14T20:30:00Z').getTime() / 1000,
id: 'seg-2',
},
];
vi.mocked(getRecordingSegments).mockResolvedValue(segments);
const seekTime = await createEngine().getMediaSeekTime(
createHASS(),
store,
media,
new Date('2026-03-14T20:10:00Z'),
);
// Only first 5-minute segment counts; second segment starts after target.
expect(seekTime).toBe(5 * 60);
});
it('should clamp segment start to startTime in seek', async () => {
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
const startTime = new Date('2026-03-14T20:05:00Z');
const endTime = new Date('2026-03-14T20:15:00Z');
const media = new TestViewMedia({
mediaType: ViewMediaType.Clip,
cameraID: 'camera-1',
startTime,
endTime,
});
// Segment starts before the media start time.
const segments = [
{
start_time: new Date('2026-03-14T20:00:00Z').getTime() / 1000,
end_time: new Date('2026-03-14T20:20:00Z').getTime() / 1000,
id: 'seg-1',
},
];
vi.mocked(getRecordingSegments).mockResolvedValue(segments);
const seekTime = await createEngine().getMediaSeekTime(
createHASS(),
store,
media,
new Date('2026-03-14T20:10:00Z'),
);
// From startTime (20:05) to target (20:10) = 5 minutes.
expect(seekTime).toBe(5 * 60);
});
});
describe('getMediaMetadata', () => {
it('should return cached results', async () => {
const requestCache = new CameraManagerRequestCache();
const engine = createEngine({ requestCache });
const hass = createHASS();
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
vi.mocked(getEventSummary).mockResolvedValue([
{
camera: 'camera-1',
day: '2026-03-14',
label: 'person',
sub_label: null,
zones: [],
},
]);
vi.mocked(getRecordingsSummary).mockResolvedValue([]);
const query = {
type: QueryType.MediaMetadata as const,
cameraIDs: new Set(['camera-1']),
};
await engine.getMediaMetadata(hass, store, query);
await engine.getMediaMetadata(hass, store, query);
expect(getEventSummary).toHaveBeenCalledTimes(1);
});
it('should aggregate event metadata', async () => {
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
vi.mocked(getEventSummary).mockResolvedValue([
{
camera: 'camera-1',
day: '2026-03-14',
label: 'person',
sub_label: 'john, jane',
zones: ['front_yard', 'driveway'],
},
{
camera: 'camera-1',
day: '2026-03-15',
label: 'car',
sub_label: null,
zones: [],
},
]);
vi.mocked(getRecordingsSummary).mockResolvedValue([]);
const result = await createEngine().getMediaMetadata(createHASS(), store, {
type: QueryType.MediaMetadata,
cameraIDs: new Set(['camera-1']),
});
assert(result);
const queryResult = [...result.values()][0];
expect(queryResult.metadata.what).toEqual(new Set(['person', 'car']));
expect(queryResult.metadata.where).toEqual(new Set(['front_yard', 'driveway']));
expect(queryResult.metadata.tags).toEqual(new Set(['john', 'jane']));
expect(queryResult.metadata.days).toContain('2026-03-14');
expect(queryResult.metadata.days).toContain('2026-03-15');
});
it('should include recording days in metadata', async () => {
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
vi.mocked(getEventSummary).mockResolvedValue([]);
vi.mocked(getRecordingsSummary).mockResolvedValue([
{
day: new Date('2026-03-14'),
events: 3,
hours: [{ hour: 10, duration: 3600, events: 3 }],
},
]);
const result = await createEngine().getMediaMetadata(createHASS(), store, {
type: QueryType.MediaMetadata,
cameraIDs: new Set(['camera-1']),
});
assert(result);
const queryResult = [...result.values()][0];
expect(queryResult.metadata.days).toBeDefined();
assert(queryResult.metadata.days);
expect(queryResult.metadata.days.size).toBeGreaterThan(0);
});
it('should skip events from non-configured cameras', async () => {
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
vi.mocked(getEventSummary).mockResolvedValue([
{
camera: 'other-camera',
day: '2026-03-14',
label: 'dog',
sub_label: null,
zones: [],
},
]);
vi.mocked(getRecordingsSummary).mockResolvedValue([]);
const result = await createEngine().getMediaMetadata(createHASS(), store, {
type: QueryType.MediaMetadata,
cameraIDs: new Set(['camera-1']),
});
assert(result);
const queryResult = [...result.values()][0];
expect(queryResult.metadata.what).toBeUndefined();
});
it('should skip cache when useCache is false', async () => {
const requestCache = new CameraManagerRequestCache();
const engine = createEngine({ requestCache });
const hass = createHASS();
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
vi.mocked(getEventSummary).mockResolvedValue([]);
vi.mocked(getRecordingsSummary).mockResolvedValue([]);
const query = {
type: QueryType.MediaMetadata as const,
cameraIDs: new Set(['camera-1']),
};
await engine.getMediaMetadata(hass, store, query);
await engine.getMediaMetadata(hass, store, query, { useCache: false });
expect(getEventSummary).toHaveBeenCalledTimes(2);
});
it('should handle metadata with empty zones and no sub_label', async () => {
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
vi.mocked(getEventSummary).mockResolvedValue([
{
camera: 'camera-1',
day: '2026-03-14',
label: 'person',
sub_label: null,
zones: [],
},
]);
vi.mocked(getRecordingsSummary).mockResolvedValue([]);
const result = await createEngine().getMediaMetadata(createHASS(), store, {
type: QueryType.MediaMetadata,
cameraIDs: new Set(['camera-1']),
});
assert(result);
const queryResult = [...result.values()][0];
expect(queryResult.metadata.what).toEqual(new Set(['person']));
expect(queryResult.metadata.where).toBeUndefined();
expect(queryResult.metadata.tags).toBeUndefined();
});
it('should handle metadata where recordings return null', async () => {
// Camera with empty name skips recordings (no camera_name to query).
const config = createCameraConfig({
frigate: { camera_name: '', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
vi.mocked(getEventSummary).mockResolvedValue([]);
const result = await createEngine().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 event summary with empty label and day', async () => {
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
vi.mocked(getEventSummary).mockResolvedValue([
{
camera: 'camera-1',
day: '',
label: '',
sub_label: null,
zones: [],
},
]);
vi.mocked(getRecordingsSummary).mockResolvedValue([]);
const result = await createEngine().getMediaMetadata(createHASS(), store, {
type: QueryType.MediaMetadata,
cameraIDs: new Set(['camera-1']),
});
assert(result);
const queryResult = [...result.values()][0];
// Empty strings are falsy, so neither label nor day is added.
expect(queryResult.metadata.what).toBeUndefined();
expect(queryResult.metadata.days).toBeUndefined();
});
it('should re-fetch when cached result has expired', async () => {
const requestCache = new CameraManagerRequestCache();
const engine = createEngine({ requestCache });
const hass = createHASS();
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
vi.mocked(getEventSummary).mockResolvedValue([]);
vi.mocked(getRecordingsSummary).mockResolvedValue([]);
const query = {
type: QueryType.MediaMetadata as const,
cameraIDs: new Set(['camera-1']),
};
// First call populates cache.
await engine.getMediaMetadata(hass, store, query);
// Spy on requestCache.get to return null (simulating expired/null cache).
vi.spyOn(requestCache, 'has').mockReturnValue(true);
vi.spyOn(requestCache, 'get').mockReturnValue(null);
const result = await engine.getMediaMetadata(hass, store, query);
assert(result);
// Even though cache.has returns true, cache.get returns null,
// so it falls through to re-fetch.
expect(getEventSummary).toHaveBeenCalledTimes(2);
});
it('should skip metadata cache write when useCache is false', async () => {
const requestCache = new CameraManagerRequestCache();
const engine = createEngine({ requestCache });
const hass = createHASS();
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
vi.mocked(getEventSummary).mockResolvedValue([]);
vi.mocked(getRecordingsSummary).mockResolvedValue([]);
await engine.getMediaMetadata(
hass,
store,
{
type: QueryType.MediaMetadata,
cameraIDs: new Set(['camera-1']),
},
{ useCache: false },
);
await engine.getMediaMetadata(hass, store, {
type: QueryType.MediaMetadata,
cameraIDs: new Set(['camera-1']),
});
expect(getEventSummary).toHaveBeenCalledTimes(2);
});
});
describe('getMediaCapabilities', () => {
it('should return canFavorite true for event media', () => {
const media = createClipMedia();
const capabilities = createEngine().getMediaCapabilities(media);
expect(capabilities).toEqual({
canFavorite: true,
canDownload: true,
});
});
it('should return canFavorite false for non-event media', () => {
const media = createRecordingMedia();
const capabilities = createEngine().getMediaCapabilities(media);
expect(capabilities).toEqual({
canFavorite: false,
canDownload: true,
});
});
});
describe('getCameraMetadata', () => {
it('should use camera title when set', () => {
const config = createCameraConfig({
title: 'My Camera',
frigate: { camera_name: 'camera-1' },
});
const metadata = createEngine().getCameraMetadata(createHASS(), config);
expect(metadata.title).toBe('My Camera');
expect(metadata.engineIcon).toBe('frigate');
});
it('should fall back to frigate camera_name', () => {
const config = createCameraConfig({
frigate: { camera_name: 'front_door' },
});
const metadata = createEngine().getCameraMetadata(createHASS(), config);
expect(metadata.title).toBe('Front Door');
});
it('should fall back to empty string', () => {
const metadata = createEngine().getCameraMetadata(
createHASS(),
createCameraConfig({ frigate: { camera_name: '' } }),
);
expect(metadata.title).toBe('');
});
it('should fall back to camera id', () => {
const metadata = createEngine().getCameraMetadata(
createHASS(),
createCameraConfig({ id: 'my-camera', frigate: { camera_name: '' } }),
);
expect(metadata.title).toBe('my-camera');
});
});
describe('segment garbage collection', () => {
afterEach(() => {
vi.useRealTimers();
});
it('should garbage collect expired segments', async () => {
vi.useFakeTimers();
const cache = new RecordingSegmentsCache();
const engine = createEngine({ cache });
const hass = createHASS();
const config = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
const start = new Date('2026-03-14T20:00:00Z');
const end = new Date('2026-03-14T21:00:00Z');
// Add segments to the cache so GC has something to process.
cache.add('camera-1', { start, end }, [
{
start_time: start.getTime() / 1000,
end_time: end.getTime() / 1000,
id: 'seg-1',
},
]);
vi.mocked(getRecordingSegments).mockResolvedValue([
{
start_time: start.getTime() / 1000,
end_time: end.getTime() / 1000,
id: 'seg-1',
},
]);
vi.mocked(getRecordingsSummary).mockResolvedValue([
{
day: new Date(2026, 2, 14, 0, 0, 0),
events: 1,
hours: [{ hour: 20, duration: 3600, events: 1 }],
},
]);
// Trigger getRecordingSegments which schedules GC via
// runWhenIdleIfSupported -> throttle.
await engine.getRecordingSegments(hass, store, {
type: QueryType.RecordingSegments,
cameraIDs: new Set(['camera-1']),
start,
end,
});
// In jsdom, requestIdleCallback falls back to setTimeout(fn, 0).
// Then the lodash throttle (leading: false, trailing: true) needs
// the 1-hour timer to fire.
await vi.advanceTimersByTimeAsync(60 * 60 * 1000 + 1);
// getRecordingsSummary is called by getRecordings, which is called
// internally by _garbageCollectSegments. Verifying it was called
// confirms the throttled GC callback fired.
expect(getRecordingsSummary).toHaveBeenCalled();
});
it('should return early when getRecordings returns null', async () => {
vi.useFakeTimers();
const cache = new RecordingSegmentsCache();
const engine = createEngine({ cache });
const hass = createHASS();
// Camera with no camera_name so getRecordings returns null.
const config = createCameraConfig({
frigate: { camera_name: '', client_id: 'client-1' },
});
const store = createStore([{ cameraID: 'camera-1', config }]);
const start = new Date('2026-03-14T20:00:00Z');
const end = new Date('2026-03-14T21:00:00Z');
cache.add('camera-1', { start, end }, [
{
start_time: start.getTime() / 1000,
end_time: end.getTime() / 1000,
id: 'seg-1',
},
]);
vi.mocked(getRecordingSegments).mockResolvedValue([
{
start_time: start.getTime() / 1000,
end_time: end.getTime() / 1000,
id: 'seg-1',
},
]);
await engine.getRecordingSegments(hass, store, {
type: QueryType.RecordingSegments,
cameraIDs: new Set(['camera-1']),
start,
end,
});
await vi.advanceTimersByTimeAsync(60 * 60 * 1000 + 1);
// GC returned early because getRecordings returned null
// (no camera_name). Segments remain in cache.
expect(cache.getSize('camera-1')).toBe(1);
});
});
});