diff --git a/.vscode/settings.json b/.vscode/settings.json index a4a10938..31c57af5 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 ." } diff --git a/src/camera-manager/cache.ts b/src/camera-manager/cache.ts index a9a060fe..7607aef7 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,7 +107,7 @@ class MemoryRangedCache { return output; } - public size(): number { + public getSize(): number { return this._data.length; } @@ -114,7 +118,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 +138,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); } @@ -142,8 +150,8 @@ export class RecordingSegmentsCache { return this._segments.get(cameraID)?.get(range) ?? null; } - public getCache(cameraID: string): MemoryRangedCache | null { - return this._segments.get(cameraID) ?? null; + public getSize(cameraID: string): number | null { + return this._segments.get(cameraID)?.getSize() ?? null; } public getCameraIDs(): string[] { diff --git a/src/camera-manager/frigate/engine-frigate.ts b/src/camera-manager/frigate/engine-frigate.ts index e4e0d6bd..b501cbbf 100644 --- a/src/camera-manager/frigate/engine-frigate.ts +++ b/src/camera-manager/frigate/engine-frigate.ts @@ -990,7 +990,7 @@ export class FrigateCameraManagerEngine const countSegments = () => sum( cameraIDs.map( - (cameraID) => this._recordingSegmentsCache.getCache(cameraID)?.size() ?? 0, + (cameraID) => this._recordingSegmentsCache.getSize(cameraID) ?? 0, ), ); const segmentsStart = countSegments(); @@ -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..70889913 --- /dev/null +++ b/tests/camera-manager/cache.test.ts @@ -0,0 +1,135 @@ +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 get size', () => { + cache.add('camera-1', range, segments); + expect(cache.getSize("camera-1")).toBe(3); + }); + it('should not size for other camera', () => { + cache.add('camera-1', range, segments); + expect(cache.getSize("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, );