test: Expand testing to cover 100% of camera manager files (#2420)

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