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

1966 lines
66 KiB
TypeScript

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