From 4d47b6ea7380704df72e95a42dda5494a2b16658 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Wed, 5 Apr 2023 18:16:29 -0700 Subject: [PATCH] Add tests for camera manager cache. --- .vscode/settings.json | 3 +- src/camera-manager/cache.ts | 16 ++- src/camera-manager/frigate/engine-frigate.ts | 2 +- tests/camera-manager/cache.test.ts | 137 +++++++++++++++++++ tests/camera-manager/engine-factory.test.ts | 24 ++-- 5 files changed, 162 insertions(+), 20 deletions(-) create mode 100644 tests/camera-manager/cache.test.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index a4a10938..22489f0f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,5 +4,6 @@ "i18n-ally.sortKeys": true, "i18n-ally.keepFulfilled": true, "i18n-ally.editor.preferEditor": true, - "i18n-ally.translate.saveAsCandidates": true + "i18n-ally.translate.saveAsCandidates": true, + "vitest.commandLine": "npx vitest --root /home/dizer/src/frigate-hass-card" } diff --git a/src/camera-manager/cache.ts b/src/camera-manager/cache.ts index a9a060fe..16e6a910 100644 --- a/src/camera-manager/cache.ts +++ b/src/camera-manager/cache.ts @@ -34,6 +34,10 @@ export class MemoryRequestCache return null; } + public clear(): void { + this._data = []; + } + public has(request: Request): boolean { return !!this.get(request); } @@ -92,7 +96,7 @@ class MemoryRangedCache { const output: Data[] = []; for (const data of this._data) { const start = this._timeFunc(data); - if (start > range.start.getTime()) { + if (start >= range.start.getTime()) { if (start > range.end.getTime()) { // Data is kept in order. break; @@ -103,10 +107,6 @@ class MemoryRangedCache { return output; } - public size(): number { - return this._data.length; - } - /** * Remove old data that matches a given predicate. No change to the covered * ranges is made, i.e. this is asserting authoritiatively that this data does @@ -114,7 +114,7 @@ class MemoryRangedCache { * @param predicate A predicate to run on each data element. */ public expireMatches(predicate: (data: Data) => boolean): void { - this._data = this._data.filter(predicate); + this._data = this._data.filter((data) => !predicate(data)); } } @@ -134,6 +134,10 @@ export class RecordingSegmentsCache { cameraSegmentCache.add(range, segments); } + public clear(): void { + this._segments.clear(); + } + public hasCoverage(cameraID: string, range: DateRange): boolean { return !!this._segments.get(cameraID)?.hasCoverage(range); } diff --git a/src/camera-manager/frigate/engine-frigate.ts b/src/camera-manager/frigate/engine-frigate.ts index e4e0d6bd..8000cb64 100644 --- a/src/camera-manager/frigate/engine-frigate.ts +++ b/src/camera-manager/frigate/engine-frigate.ts @@ -1025,7 +1025,7 @@ export class FrigateCameraManagerEngine (segment: RecordingSegment) => { const hourID = getHourID(cameraID, fromUnixTime(segment.start_time)); // ~O(1) lookup time for a JS set. - return goodHours.has(hourID); + return !goodHours.has(hourID); }, ); } diff --git a/tests/camera-manager/cache.test.ts b/tests/camera-manager/cache.test.ts new file mode 100644 index 00000000..6a4786c1 --- /dev/null +++ b/tests/camera-manager/cache.test.ts @@ -0,0 +1,137 @@ +import add from 'date-fns/add'; +import sub from 'date-fns/sub'; +import sortBy from 'lodash-es/sortBy'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + MemoryRequestCache, + RecordingSegmentsCache, +} from '../../src/camera-manager/cache.js'; +import { DateRange } from '../../src/camera-manager/range.js'; +import { RecordingSegment } from '../../src/camera-manager/types.js'; + +describe('MemoryRequestCache', () => { + const cache = new MemoryRequestCache(); + const request = { request: 'foo' }; + const response = { response: 'bar' }; + + beforeEach(() => { + cache.clear(); + }); + + it('should get value when set', () => { + cache.set(request, response); + expect(cache.get(request)).toBe(response); + }); + it('should get similar value when set', () => { + cache.set(request, response); + expect(cache.get({ ...request })).toBe(response); + }); + it('should be empty when cleared', () => { + cache.set(request, response); + expect(cache.get({ ...request })).toBe(response); + cache.clear(); + expect(cache.get(request)).toBeNull(); + }); + it('should have value when set', () => { + cache.set(request, response); + expect(cache.has(request)).toBeTruthy(); + }); + it('should not have value when set expired', () => { + cache.set(request, response, sub(new Date(), { hours: 1 })); + expect(cache.has(request)).toBeFalsy(); + }); + it('should not have value when get expired', () => { + const now = new Date(); + cache.set(request, response, add(now, { hours: 1 })); + expect(cache.has(request)).toBeTruthy(); + + vi.useFakeTimers(); + vi.setSystemTime(add(now, { hours: 2 })); + expect(cache.has(request)).toBeFalsy(); + vi.useRealTimers(); + }); +}); + +describe('RecordingSegmentsCache', () => { + const cache = new RecordingSegmentsCache(); + const now = new Date(); + const range: DateRange = { + start: now, + end: add(now, { hours: 1 }), + }; + const badRange = { start: sub(now, { hours: 1 }), end: now }; + const createSegment = (date: Date, id: string): RecordingSegment => { + return { + start_time: date.getTime() / 1000, + end_time: date.getTime() / 1000 + 10, + id: id, + }; + }; + const segments = [ + createSegment(now, 'segment-1'), + createSegment(add(now, { seconds: 10 }), 'segment-2'), + createSegment(add(now, { seconds: 20 }), 'segment-3'), + ]; + + beforeEach(() => { + cache.clear(); + }); + + it('should get segments when added', () => { + cache.add('camera-1', range, segments); + expect(cache.get('camera-1', range)).toEqual(segments); + }); + it('should get some segments for shorter range', () => { + cache.add('camera-1', range, segments); + expect(cache.get('camera-1', { ...range, end: add(now, { seconds: 5 }) })).toEqual([ + segments[0], + ]); + }); + it('should not get for other range', () => { + cache.add('camera-1', range, segments); + expect(cache.get('camera-1', badRange)).toBeNull(); + }); + + it('should have coverage when added', () => { + cache.add('camera-1', range, segments); + expect(cache.hasCoverage('camera-1', range)).toBeTruthy(); + }); + it('should not have coverage for other camera', () => { + cache.add('camera-1', range, segments); + expect(cache.hasCoverage('camera-2', range)).toBeFalsy(); + }); + it('should not have coverage for other range', () => { + cache.add('camera-1', range, segments); + expect(cache.hasCoverage('camera-1', badRange)).toBeFalsy(); + }); + + it('should be empty when cleared', () => { + cache.add('camera-1', range, segments); + cache.clear(); + expect(cache.get('camera-1', range)).toBeNull(); + expect(cache.hasCoverage('camera-1', range)).toBeFalsy(); + }); + + it('should return internal cache', () => { + cache.add('camera-1', range, segments); + const internalCache = cache.getCache('camera-1'); + expect(internalCache).toBeTruthy(); + expect(internalCache?.get(range)).toEqual(segments); + }); + it('should not return internal cache for wrong camera', () => { + cache.add('camera-1', range, segments); + expect(cache.getCache('camera-2')).toBeNull(); + }); + + it('should return cameraIDs', () => { + cache.add('camera-1', range, segments); + cache.add('camera-2', range, segments); + expect(sortBy(cache.getCameraIDs())).toEqual(sortBy(['camera-1', 'camera-2'])); + }); + + it('should remove expired matches', () => { + cache.add('camera-1', range, segments); + cache.expireMatches('camera-1', (segment) => segment === segments[0]); + expect(sortBy(cache.get('camera-1', range))).toEqual(segments.splice(1)); + }); +}); diff --git a/tests/camera-manager/engine-factory.test.ts b/tests/camera-manager/engine-factory.test.ts index b57acfc9..24da9446 100644 --- a/tests/camera-manager/engine-factory.test.ts +++ b/tests/camera-manager/engine-factory.test.ts @@ -49,20 +49,20 @@ const createEntity = (entity: Partial): Entity => { }; }; -describe('getEngineForCamera()', () => { - it('config:frigate', async () => { +describe('CameraManagerEngineFactory.getEngineForCamera()', () => { + it('should get frigate engine from config', async () => { const config = createCameraConfig({ engine: 'frigate' }); expect(await createFactory().getEngineForCamera(createHASS(), config)).toBe( Engine.Frigate, ); }); - it('config:motionEye', async () => { + it('should get motionEye engine from config', async () => { const config = createCameraConfig({ engine: 'motioneye' }); expect(await createFactory().getEngineForCamera(createHASS(), config)).toBe( Engine.MotionEye, ); }); - it('auto:frigate', async () => { + it('should get frigate engine from auto config', async () => { const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' }); const entityRegistryManager = new EntityRegistryManager(new EntityCache()); @@ -76,7 +76,7 @@ describe('getEngineForCamera()', () => { }).getEngineForCamera(createHASS(), config), ).toBe(Engine.Frigate); }); - it('auto:motioneye', async () => { + it('should get motioneye engine from auto config', async () => { const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' }); const entityRegistryManager = new EntityRegistryManager(new EntityCache()); @@ -92,7 +92,7 @@ describe('getEngineForCamera()', () => { }).getEngineForCamera(createHASS(), config), ).toBe(Engine.MotionEye); }); - it('auto:motioneye', async () => { + it('should get generic engine from auto config', async () => { const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' }); const entityRegistryManager = new EntityRegistryManager(new EntityCache()); @@ -106,7 +106,7 @@ describe('getEngineForCamera()', () => { }).getEngineForCamera(createHASS(), config), ).toBe(Engine.Generic); }); - it('config:frigate:camera_name', async () => { + it('should get frigate engine from config with camera_name', async () => { const config = createCameraConfig({ frigate: { client_id: 'bar', camera_name: 'foo' }, }); @@ -114,7 +114,7 @@ describe('getEngineForCamera()', () => { Engine.Frigate, ); }); - it('config:frigate:throw', async () => { + it('should throw error on invalid entity', async () => { const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' }); const entityRegistryManager = new EntityRegistryManager(new EntityCache()); @@ -128,18 +128,18 @@ describe('getEngineForCamera()', () => { }); }); -describe('createEngine()', () => { - it('generic', async () => { +describe('CameraManagerEngineFactory.createEngine()', () => { + it('should create generic engine', async () => { expect(await createFactory().createEngine(Engine.Generic)).toBeInstanceOf( GenericCameraManagerEngine, ); }); - it('frigate', async () => { + it('should create frigate engine', async () => { expect(await createFactory().createEngine(Engine.Frigate)).toBeInstanceOf( FrigateCameraManagerEngine, ); }); - it('motioneye', async () => { + it('should create motioneye engine', async () => { expect(await createFactory().createEngine(Engine.MotionEye)).toBeInstanceOf( MotionEyeCameraManagerEngine, );