feat: Add support for Frigate reviews / detections [initial PR] (#2315)
- Add support for Frigate reviews / detections. - Add support for GenAI metadata. - Significant internal refactor to more flexible "UnifiedQuery" to allow mixing cameras with simple metadata and review metadata (e.g. a timeline view of a Frigate camera with reviews, and a Reolink camera with simple metadata). - Add support for folder media as camera media. There are a few more PRs to commit prior to this going live, but commiting this for now due to the scale of the change. BREAKING CHANGE: `media_type` and `events_type` are retired under `live`, `viewer` and `timeline` configuration sections, instead media type is associated (once) with the camera under `media`.
This commit is contained in:
@@ -6,11 +6,11 @@ import { createFolder, TestViewMedia } from '../test-utils';
|
||||
describe('ViewItemClassifier', () => {
|
||||
it('isMedia', () => {
|
||||
expect(ViewItemClassifier.isMedia(new TestViewMedia())).toBe(true);
|
||||
expect(ViewItemClassifier.isMedia(new ViewFolder(createFolder()))).toBe(false);
|
||||
expect(ViewItemClassifier.isMedia(new ViewFolder(createFolder(), []))).toBe(false);
|
||||
});
|
||||
|
||||
it('isFolder', () => {
|
||||
expect(ViewItemClassifier.isFolder(new ViewFolder(createFolder()))).toBe(true);
|
||||
expect(ViewItemClassifier.isFolder(new ViewFolder(createFolder(), []))).toBe(true);
|
||||
expect(ViewItemClassifier.isFolder(new TestViewMedia())).toBe(false);
|
||||
});
|
||||
|
||||
@@ -73,4 +73,40 @@ describe('ViewItemClassifier', () => {
|
||||
).toBe(expectedResult);
|
||||
});
|
||||
});
|
||||
|
||||
describe('supportsTimeline', () => {
|
||||
it('should return false when item is not a media item with a start time', () => {
|
||||
expect(ViewItemClassifier.supportsTimeline(null)).toBe(false);
|
||||
expect(ViewItemClassifier.supportsTimeline(undefined)).toBe(false);
|
||||
expect(
|
||||
ViewItemClassifier.supportsTimeline(new ViewFolder(createFolder(), [])),
|
||||
).toBe(false);
|
||||
expect(
|
||||
ViewItemClassifier.supportsTimeline(
|
||||
new TestViewMedia({ mediaType: ViewMediaType.Clip, startTime: null }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when item is a media item with a start time', () => {
|
||||
expect(
|
||||
ViewItemClassifier.supportsTimeline(
|
||||
new TestViewMedia({ mediaType: ViewMediaType.Clip, startTime: new Date() }),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
ViewItemClassifier.supportsTimeline(
|
||||
new TestViewMedia({ mediaType: ViewMediaType.Review, startTime: new Date() }),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
ViewItemClassifier.supportsTimeline(
|
||||
new TestViewMedia({
|
||||
mediaType: ViewMediaType.Recording,
|
||||
startTime: new Date(),
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+29
-1
@@ -35,13 +35,28 @@ describe('ViewMedia', () => {
|
||||
expect(media.inProgress()).toBeNull();
|
||||
expect(media.getContentID()).toBeNull();
|
||||
expect(media.getTitle()).toBeNull();
|
||||
expect(media.getDescription()).toBeNull();
|
||||
expect(media.getThumbnail()).toBeNull();
|
||||
expect(media.getTitle()).toBeNull();
|
||||
expect(media.includesTime(new Date())).toBeFalsy();
|
||||
expect(media.getWhere()).toBeNull();
|
||||
expect(media.setFavorite(true)).toBeUndefined();
|
||||
expect(media.isFavorite()).toBeNull();
|
||||
expect(media.isReviewed()).toBeNull();
|
||||
expect(media.getIcon()).toBeNull();
|
||||
expect(media.getSeverity()).toBeNull();
|
||||
});
|
||||
|
||||
it('should clone', () => {
|
||||
const media = new ViewMedia(ViewMediaType.Clip, {
|
||||
cameraID: 'camera',
|
||||
});
|
||||
|
||||
const clone = media.clone();
|
||||
|
||||
expect(clone).not.toBe(media);
|
||||
expect(clone).toBeInstanceOf(ViewMedia);
|
||||
expect(clone.getCameraID()).toBe('camera');
|
||||
});
|
||||
|
||||
it('should correctly determine if a media item includes a time', () => {
|
||||
@@ -70,7 +85,7 @@ describe('ViewMedia', () => {
|
||||
describe('ViewFolder', () => {
|
||||
it('should construct', () => {
|
||||
const folder = createFolder();
|
||||
const item = new ViewFolder(folder, {
|
||||
const item = new ViewFolder(folder, [], {
|
||||
icon: 'icon',
|
||||
id: 'id',
|
||||
title: 'title',
|
||||
@@ -80,8 +95,21 @@ describe('ViewFolder', () => {
|
||||
expect(item.getFolder()).toEqual(folder);
|
||||
expect(item.getID()).toBe('id');
|
||||
expect(item.getTitle()).toBe('title');
|
||||
expect(item.getDescription()).toBeNull();
|
||||
expect(item.getThumbnail()).toBe('thumbnail');
|
||||
expect(item.getIcon()).toBe('icon');
|
||||
expect(item.isFavorite()).toBeNull();
|
||||
expect(item.getSeverity()).toBeNull();
|
||||
});
|
||||
|
||||
it('should clone', () => {
|
||||
const folder = createFolder();
|
||||
const item = new ViewFolder(folder, []);
|
||||
|
||||
const clone = item.clone();
|
||||
|
||||
expect(clone).not.toBe(item);
|
||||
expect(clone).toBeInstanceOf(ViewFolder);
|
||||
expect(clone.getFolder()).toEqual(folder);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { QueryType } from '../../src/camera-manager/types';
|
||||
import {
|
||||
EventMediaQuery,
|
||||
FolderViewQuery,
|
||||
RecordingMediaQuery,
|
||||
} from '../../src/view/query';
|
||||
import { QueryClassifier } from '../../src/view/query-classifier';
|
||||
import { createFolder } from '../test-utils';
|
||||
|
||||
describe('QueryClassifier', () => {
|
||||
it('isEventQuery', () => {
|
||||
expect(QueryClassifier.isEventQuery(new EventMediaQuery())).toBeTruthy();
|
||||
expect(QueryClassifier.isEventQuery(new RecordingMediaQuery())).toBeFalsy();
|
||||
});
|
||||
|
||||
it('isRecordingQuery', () => {
|
||||
expect(QueryClassifier.isRecordingQuery(new RecordingMediaQuery())).toBeTruthy();
|
||||
expect(QueryClassifier.isRecordingQuery(new EventMediaQuery())).toBeFalsy();
|
||||
});
|
||||
|
||||
it('getQueryType', () => {
|
||||
expect(QueryClassifier.getQueryType(new EventMediaQuery())).toBe('event');
|
||||
expect(QueryClassifier.getQueryType(new FolderViewQuery())).toBe('folder');
|
||||
expect(QueryClassifier.getQueryType(new RecordingMediaQuery())).toBe('recording');
|
||||
expect(QueryClassifier.getQueryType()).toBeNull();
|
||||
});
|
||||
|
||||
it('isClipsQuery', () => {
|
||||
expect(
|
||||
QueryClassifier.isClipsQuery(
|
||||
new EventMediaQuery([
|
||||
{ type: QueryType.Event, cameraIDs: new Set(['camera']), hasClip: true },
|
||||
]),
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
QueryClassifier.isClipsQuery(
|
||||
new EventMediaQuery([
|
||||
{ type: QueryType.Event, cameraIDs: new Set(['camera']), hasClip: true },
|
||||
{ type: QueryType.Event, cameraIDs: new Set(['camera']), hasClip: false },
|
||||
]),
|
||||
),
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
QueryClassifier.isClipsQuery(
|
||||
new EventMediaQuery([
|
||||
{ type: QueryType.Event, cameraIDs: new Set(['camera']), hasClip: true },
|
||||
{ type: QueryType.Event, cameraIDs: new Set(['camera']) },
|
||||
]),
|
||||
),
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it('isSnapshotQuery', () => {
|
||||
expect(
|
||||
QueryClassifier.isSnapshotQuery(
|
||||
new EventMediaQuery([
|
||||
{ type: QueryType.Event, cameraIDs: new Set(['camera']), hasSnapshot: true },
|
||||
]),
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
QueryClassifier.isSnapshotQuery(
|
||||
new EventMediaQuery([
|
||||
{ type: QueryType.Event, cameraIDs: new Set(['camera']), hasSnapshot: true },
|
||||
{ type: QueryType.Event, cameraIDs: new Set(['camera']), hasSnapshot: false },
|
||||
]),
|
||||
),
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
QueryClassifier.isSnapshotQuery(
|
||||
new EventMediaQuery([
|
||||
{ type: QueryType.Event, cameraIDs: new Set(['camera']), hasSnapshot: true },
|
||||
{ type: QueryType.Event, cameraIDs: new Set(['camera']) },
|
||||
]),
|
||||
),
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it('isFolderQuery', () => {
|
||||
expect(
|
||||
QueryClassifier.isFolderQuery(
|
||||
new EventMediaQuery([
|
||||
{ type: QueryType.Event, cameraIDs: new Set(['camera']), hasSnapshot: true },
|
||||
]),
|
||||
),
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
QueryClassifier.isFolderQuery(
|
||||
new RecordingMediaQuery([
|
||||
{ type: QueryType.Recording, cameraIDs: new Set(['camera']) },
|
||||
]),
|
||||
),
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
QueryClassifier.isFolderQuery(new FolderViewQuery({ folder: createFolder() })),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('getMediaType', () => {
|
||||
expect(
|
||||
QueryClassifier.getMediaType(
|
||||
new EventMediaQuery([
|
||||
{ type: QueryType.Event, cameraIDs: new Set(['camera']), hasSnapshot: true },
|
||||
{ type: QueryType.Event, cameraIDs: new Set(['camera']), hasSnapshot: true },
|
||||
]),
|
||||
),
|
||||
).toBe('snapshots');
|
||||
|
||||
expect(
|
||||
QueryClassifier.getMediaType(
|
||||
new EventMediaQuery([
|
||||
{ type: QueryType.Event, cameraIDs: new Set(['camera']), hasClip: true },
|
||||
{ type: QueryType.Event, cameraIDs: new Set(['camera']), hasClip: true },
|
||||
]),
|
||||
),
|
||||
).toBe('clips');
|
||||
|
||||
expect(
|
||||
QueryClassifier.getMediaType(
|
||||
new RecordingMediaQuery([
|
||||
{ type: QueryType.Recording, cameraIDs: new Set(['camera']) },
|
||||
{ type: QueryType.Recording, cameraIDs: new Set(['camera']) },
|
||||
]),
|
||||
),
|
||||
).toBe('recordings');
|
||||
|
||||
expect(
|
||||
QueryClassifier.getMediaType(
|
||||
new EventMediaQuery([
|
||||
{ type: QueryType.Event, cameraIDs: new Set(['camera']), hasSnapshot: true },
|
||||
{ type: QueryType.Event, cameraIDs: new Set(['camera']), hasSnapshot: false },
|
||||
]),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { assert, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ViewFolder, ViewItem } from '../../src/view/item';
|
||||
import { QueryResults } from '../../src/view/query-results';
|
||||
import { createFolder, generateViewMediaArray } from '../test-utils';
|
||||
import { createFolder, generateViewMediaArray, TestViewMedia } from '../test-utils';
|
||||
|
||||
describe('dispatchViewContextChangeEvent', () => {
|
||||
beforeEach(() => {
|
||||
@@ -281,7 +281,7 @@ describe('dispatchViewContextChangeEvent', () => {
|
||||
});
|
||||
|
||||
it('should keep selected folder in main slice but not camera slices', () => {
|
||||
const folder = new ViewFolder(createFolder());
|
||||
const folder = new ViewFolder(createFolder(), []);
|
||||
const results = new QueryResults({
|
||||
results: [
|
||||
folder,
|
||||
@@ -295,4 +295,148 @@ describe('dispatchViewContextChangeEvent', () => {
|
||||
expect(results.getSelectedResult()).toBe(folder);
|
||||
expect(results.getSelectedResult('camera.office')).not.toBe(folder);
|
||||
});
|
||||
|
||||
describe('removeItem', () => {
|
||||
it('should remove item from results', () => {
|
||||
const testResults = generateViewMediaArray();
|
||||
const results = new QueryResults({ results: testResults });
|
||||
|
||||
const itemToRemove = testResults[50];
|
||||
expect(results.getResults()?.includes(itemToRemove)).toBeTruthy();
|
||||
expect(results.getResultsCount()).toBe(200);
|
||||
|
||||
results.removeItem(itemToRemove);
|
||||
|
||||
expect(results.getResults()?.includes(itemToRemove)).toBeFalsy();
|
||||
expect(results.getResultsCount()).toBe(199);
|
||||
});
|
||||
|
||||
it('should not remove item that is not in results', () => {
|
||||
const testResults = generateViewMediaArray();
|
||||
const results = new QueryResults({ results: testResults });
|
||||
|
||||
const outsideItem = generateViewMediaArray({ cameraIDs: ['other'] })[0];
|
||||
const countBefore = results.getResultsCount();
|
||||
|
||||
results.removeItem(outsideItem);
|
||||
|
||||
expect(results.getResultsCount()).toBe(countBefore);
|
||||
});
|
||||
|
||||
it('should adjust selection when removing selected item', () => {
|
||||
const testResults = generateViewMediaArray();
|
||||
const results = new QueryResults({ results: testResults, selectedIndex: 50 });
|
||||
|
||||
const itemToRemove = testResults[50];
|
||||
results.removeItem(itemToRemove);
|
||||
|
||||
// After removing selected item at index 50, Math.min(50, 199-1) = 50
|
||||
// which is still valid in the new 199-element array
|
||||
expect(results.getSelectedIndex()).toBe(50);
|
||||
});
|
||||
|
||||
it('should adjust selection when removing item before selected', () => {
|
||||
const testResults = generateViewMediaArray();
|
||||
const results = new QueryResults({ results: testResults, selectedIndex: 50 });
|
||||
|
||||
const itemToRemove = testResults[10];
|
||||
results.removeItem(itemToRemove);
|
||||
|
||||
// Selection should decrement
|
||||
expect(results.getSelectedIndex()).toBe(49);
|
||||
});
|
||||
|
||||
it('should not change selection when removing item after selected', () => {
|
||||
const testResults = generateViewMediaArray();
|
||||
const results = new QueryResults({ results: testResults, selectedIndex: 50 });
|
||||
|
||||
const itemToRemove = testResults[100];
|
||||
results.removeItem(itemToRemove);
|
||||
|
||||
// Selection should stay the same
|
||||
expect(results.getSelectedIndex()).toBe(50);
|
||||
});
|
||||
|
||||
it('should set selection to null when removing last remaining selected item', () => {
|
||||
const testResults = generateViewMediaArray({ cameraIDs: ['camera'], count: 1 });
|
||||
const results = new QueryResults({ results: testResults, selectedIndex: 0 });
|
||||
|
||||
results.removeItem(testResults[0]);
|
||||
|
||||
// Selection should be null when no items remain
|
||||
expect(results.getSelectedIndex()).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle removing folder item (non-media)', () => {
|
||||
const folder = new ViewFolder(createFolder(), []);
|
||||
const testResults = generateViewMediaArray();
|
||||
const results = new QueryResults({ results: [folder, ...testResults] });
|
||||
|
||||
const countBefore = results.getResultsCount();
|
||||
results.removeItem(folder);
|
||||
|
||||
expect(results.getResultsCount()).toBe(countBefore - 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceItem', () => {
|
||||
it('should replace item in slice', () => {
|
||||
const testResults = generateViewMediaArray();
|
||||
const results = new QueryResults({ results: testResults });
|
||||
const slice = results.getSlice('office');
|
||||
|
||||
assert(slice);
|
||||
|
||||
const oldItem = slice.getResults()[42];
|
||||
const newItem = oldItem.clone();
|
||||
|
||||
expect(slice?.replaceItem(oldItem, newItem)).toBeTruthy();
|
||||
expect(slice?.getResults()[42]).toBe(newItem);
|
||||
});
|
||||
|
||||
it('should fail to replace item not in camera slice', () => {
|
||||
const results = new QueryResults({ results: generateViewMediaArray() });
|
||||
const slice = results.getSlice('office');
|
||||
|
||||
assert(slice);
|
||||
|
||||
const foreignItem = new TestViewMedia({
|
||||
cameraID: 'other',
|
||||
});
|
||||
const newItem = foreignItem.clone();
|
||||
|
||||
expect(slice.replaceItem(foreignItem, newItem)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should replace item in main slice', () => {
|
||||
const testResults = generateViewMediaArray();
|
||||
const results = new QueryResults({ results: testResults });
|
||||
|
||||
const oldItem = testResults[42];
|
||||
const newItem = oldItem.clone();
|
||||
|
||||
expect(results.replaceItem(oldItem, newItem)).toBe(results);
|
||||
expect(results.getResults()?.[42]).toBe(newItem);
|
||||
expect(results.getSlice('kitchen')?.getResults().includes(newItem)).toBeTruthy();
|
||||
expect(results.getSlice('kitchen')?.getResults().includes(oldItem)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should fail to replace item not in main slice', () => {
|
||||
const results = new QueryResults({ results: generateViewMediaArray() });
|
||||
|
||||
const outsideItem = generateViewMediaArray({ cameraIDs: ['other'] })[0];
|
||||
const newItem = outsideItem.clone();
|
||||
|
||||
expect(results.replaceItem(outsideItem, newItem)).toBe(results);
|
||||
});
|
||||
|
||||
it('should replace folder in main slice', () => {
|
||||
const folder = new ViewFolder(createFolder(), []);
|
||||
const results = new QueryResults({ results: [folder] });
|
||||
|
||||
const newFolder = folder.clone();
|
||||
expect(results.replaceItem(folder, newFolder)).toBe(results);
|
||||
expect(results.getResults()?.[0]).toBe(newFolder);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,322 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
EventQuery,
|
||||
PartialEventQuery,
|
||||
PartialRecordingQuery,
|
||||
QueryType,
|
||||
RecordingQuery,
|
||||
} from '../../src/camera-manager/types';
|
||||
import { FolderQuery } from '../../src/card-controller/folders/types';
|
||||
import { setify } from '../../src/utils/basic';
|
||||
import {
|
||||
EventMediaQuery,
|
||||
FolderViewQuery,
|
||||
RecordingMediaQuery,
|
||||
} from '../../src/view/query';
|
||||
import { createFolder } from '../test-utils';
|
||||
|
||||
describe('EventMediaQuery', () => {
|
||||
const createRawEventQueries = (
|
||||
cameraIDs: string | Set<string>,
|
||||
query?: PartialEventQuery,
|
||||
): EventQuery[] => {
|
||||
return [
|
||||
{
|
||||
type: QueryType.Event,
|
||||
cameraIDs: setify(cameraIDs),
|
||||
...query,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
it('should construct', () => {
|
||||
const rawQueries = createRawEventQueries('office');
|
||||
const query = new EventMediaQuery(rawQueries);
|
||||
expect(query.getQuery()).toBe(rawQueries);
|
||||
});
|
||||
|
||||
it('should set', () => {
|
||||
const rawQueries = createRawEventQueries('office');
|
||||
const query = new EventMediaQuery(rawQueries);
|
||||
|
||||
const newRawQueries = createRawEventQueries('kitchen');
|
||||
query.setQuery(newRawQueries);
|
||||
expect(query.getQuery()).toBe(newRawQueries);
|
||||
});
|
||||
|
||||
it('should determine if queries exist for CameraIDs', () => {
|
||||
const rawQueries = createRawEventQueries(new Set(['office', 'kitchen']));
|
||||
const query = new EventMediaQuery(rawQueries);
|
||||
|
||||
expect(query.hasQueriesForCameraIDs(new Set(['office']))).toBeTruthy();
|
||||
expect(query.hasQueriesForCameraIDs(new Set(['office', 'kitchen']))).toBeTruthy();
|
||||
expect(query.hasQueriesForCameraIDs(new Set(['office', 'front_door']))).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should convert to clips querys', () => {
|
||||
const rawQueries = createRawEventQueries('office', { hasSnapshot: true });
|
||||
const query = new EventMediaQuery(rawQueries);
|
||||
|
||||
expect(query.convertToClipsQueries().getQuery()).toEqual([
|
||||
{
|
||||
type: QueryType.Event,
|
||||
cameraIDs: new Set(['office']),
|
||||
hasClip: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should convert when queries are null', () => {
|
||||
const query = new EventMediaQuery();
|
||||
expect(query.convertToClipsQueries().getQuery()).toBeNull();
|
||||
});
|
||||
|
||||
describe('should determine equality', () => {
|
||||
it('should return true when query is equal', () => {
|
||||
const rawQueries_1 = createRawEventQueries('office', { hasSnapshot: true });
|
||||
const query_1 = new EventMediaQuery(rawQueries_1);
|
||||
|
||||
const rawQueries_2 = createRawEventQueries('office', { hasSnapshot: true });
|
||||
const query_2 = new EventMediaQuery(rawQueries_2);
|
||||
|
||||
expect(query_1.isEqual(query_2)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return false when query is not equal', () => {
|
||||
const rawQueries_1 = createRawEventQueries('office', { hasSnapshot: true });
|
||||
const query_1 = new EventMediaQuery(rawQueries_1);
|
||||
|
||||
const rawQueries_2 = createRawEventQueries('office', { hasSnapshot: false });
|
||||
const query_2 = new EventMediaQuery(rawQueries_2);
|
||||
|
||||
expect(query_1.isEqual(query_2)).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should clone', () => {
|
||||
const rawQueries = createRawEventQueries('office', { hasSnapshot: true });
|
||||
const query = new EventMediaQuery(rawQueries);
|
||||
expect(query.clone().getQuery()).toEqual(query.getQuery());
|
||||
});
|
||||
|
||||
it('should get camera IDs when queries are null', () => {
|
||||
expect(new EventMediaQuery().getQueryCameraIDs()).toBeNull();
|
||||
});
|
||||
|
||||
it('should get camera IDs', () => {
|
||||
const cameraIDs = ['office', 'kitchen'];
|
||||
const query = new EventMediaQuery(createRawEventQueries(new Set(cameraIDs)));
|
||||
expect(query.getQueryCameraIDs()).toEqual(new Set(cameraIDs));
|
||||
});
|
||||
|
||||
it('should set camera IDs when queries are null', () => {
|
||||
expect(
|
||||
new EventMediaQuery().setQueryCameraIDs(new Set(['office'])).getQueryCameraIDs(),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('should set camera IDs', () => {
|
||||
const query = new EventMediaQuery(createRawEventQueries('sitting_room'));
|
||||
const newCameraIDs = new Set(['office', 'kitchen']);
|
||||
expect(query.setQueryCameraIDs(newCameraIDs).getQueryCameraIDs()).toEqual(
|
||||
newCameraIDs,
|
||||
);
|
||||
});
|
||||
|
||||
describe('should determine when queries are a superset', () => {
|
||||
it('should return true with itself', () => {
|
||||
const query_1 = new EventMediaQuery([
|
||||
{
|
||||
type: QueryType.Event,
|
||||
cameraIDs: new Set(['office']),
|
||||
start: new Date('2025-03-07T00:00:00.000Z'),
|
||||
end: new Date('2025-03-08T00:00:00.000Z'),
|
||||
},
|
||||
]);
|
||||
expect(query_1.isSupersetOf(query_1)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return true with an identical but shorter query', () => {
|
||||
const query_1 = new EventMediaQuery([
|
||||
{
|
||||
type: QueryType.Event,
|
||||
cameraIDs: new Set(['office']),
|
||||
start: new Date('2025-03-07T00:00:00.000Z'),
|
||||
end: new Date('2025-03-08T00:00:00.000Z'),
|
||||
},
|
||||
]);
|
||||
const query_2 = new EventMediaQuery([
|
||||
{
|
||||
type: QueryType.Event,
|
||||
cameraIDs: new Set(['office']),
|
||||
start: new Date('2025-03-07T01:00:00.000Z'),
|
||||
end: new Date('2025-03-07T23:00:00.000Z'),
|
||||
},
|
||||
]);
|
||||
expect(query_1.isSupersetOf(query_2)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return false with an identical but longer query', () => {
|
||||
const query_1 = new EventMediaQuery([
|
||||
{
|
||||
type: QueryType.Event,
|
||||
cameraIDs: new Set(['office']),
|
||||
start: new Date('2025-03-07T00:00:00.000Z'),
|
||||
end: new Date('2025-03-08T00:00:00.000Z'),
|
||||
},
|
||||
]);
|
||||
const query_2 = new EventMediaQuery([
|
||||
{
|
||||
type: QueryType.Event,
|
||||
cameraIDs: new Set(['office']),
|
||||
start: new Date('2025-03-06T00:00:00.000Z'),
|
||||
end: new Date('2025-03-08T00:00:00.000Z'),
|
||||
},
|
||||
]);
|
||||
expect(query_1.isSupersetOf(query_2)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should return false with a non-matching query', () => {
|
||||
const query_1 = new EventMediaQuery([
|
||||
{
|
||||
type: QueryType.Event,
|
||||
cameraIDs: new Set(['office']),
|
||||
start: new Date('2025-03-07T00:00:00.000Z'),
|
||||
end: new Date('2025-03-08T00:00:00.000Z'),
|
||||
},
|
||||
]);
|
||||
const query_2 = new EventMediaQuery([
|
||||
{
|
||||
type: QueryType.Event,
|
||||
cameraIDs: new Set(['DIFFERENT']),
|
||||
start: new Date('2025-03-07T00:00:00.000Z'),
|
||||
end: new Date('2025-03-08T00:00:00.000Z'),
|
||||
},
|
||||
]);
|
||||
expect(query_1.isSupersetOf(query_2)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should return true with a matching query where the source has multiple', () => {
|
||||
const query_1 = new EventMediaQuery([
|
||||
{
|
||||
type: QueryType.Event,
|
||||
cameraIDs: new Set(['office']),
|
||||
start: new Date('2025-03-07T00:00:00.000Z'),
|
||||
end: new Date('2025-03-08T00:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
type: QueryType.Event,
|
||||
cameraIDs: new Set(['kitchen']),
|
||||
start: new Date('2025-03-07T00:00:00.000Z'),
|
||||
end: new Date('2025-03-08T00:00:00.000Z'),
|
||||
},
|
||||
]);
|
||||
const query_2 = new EventMediaQuery([
|
||||
{
|
||||
type: QueryType.Event,
|
||||
cameraIDs: new Set(['office']),
|
||||
start: new Date('2025-03-07T00:00:00.000Z'),
|
||||
end: new Date('2025-03-08T00:00:00.000Z'),
|
||||
},
|
||||
]);
|
||||
expect(query_1.isSupersetOf(query_2)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return false with a matching query where the target has multiple', () => {
|
||||
const query_1 = new EventMediaQuery([
|
||||
{
|
||||
type: QueryType.Event,
|
||||
cameraIDs: new Set(['office']),
|
||||
start: new Date('2025-03-07T00:00:00.000Z'),
|
||||
end: new Date('2025-03-08T00:00:00.000Z'),
|
||||
},
|
||||
]);
|
||||
const query_2 = new EventMediaQuery([
|
||||
{
|
||||
type: QueryType.Event,
|
||||
cameraIDs: new Set(['office']),
|
||||
start: new Date('2025-03-07T00:00:00.000Z'),
|
||||
end: new Date('2025-03-08T00:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
type: QueryType.Event,
|
||||
cameraIDs: new Set(['kitchen']),
|
||||
start: new Date('2025-03-07T00:00:00.000Z'),
|
||||
end: new Date('2025-03-08T00:00:00.000Z'),
|
||||
},
|
||||
]);
|
||||
expect(query_1.isSupersetOf(query_2)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should return true when queries do not have start or end', () => {
|
||||
const query_1 = new EventMediaQuery([
|
||||
{
|
||||
type: QueryType.Event,
|
||||
cameraIDs: new Set(['office']),
|
||||
},
|
||||
]);
|
||||
const query_2 = new EventMediaQuery([
|
||||
{
|
||||
type: QueryType.Event,
|
||||
cameraIDs: new Set(['office']),
|
||||
},
|
||||
]);
|
||||
expect(query_1.isSupersetOf(query_2)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return true when target has no queries', () => {
|
||||
const query_1 = new EventMediaQuery([
|
||||
{
|
||||
type: QueryType.Event,
|
||||
cameraIDs: new Set(['office']),
|
||||
},
|
||||
]);
|
||||
const query_2 = new EventMediaQuery();
|
||||
expect(query_1.isSupersetOf(query_2)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return false when source has no queries', () => {
|
||||
const query_1 = new EventMediaQuery();
|
||||
const query_2 = new EventMediaQuery([
|
||||
{
|
||||
type: QueryType.Event,
|
||||
cameraIDs: new Set(['office']),
|
||||
},
|
||||
]);
|
||||
expect(query_1.isSupersetOf(query_2)).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('RecordingMediaQuery', () => {
|
||||
const createRawRecordingQueries = (
|
||||
cameraIDs: string | Set<string>,
|
||||
query?: PartialRecordingQuery,
|
||||
): RecordingQuery[] => {
|
||||
return [
|
||||
{
|
||||
type: QueryType.Recording,
|
||||
cameraIDs: setify(cameraIDs),
|
||||
...query,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
it('should construct', () => {
|
||||
const rawQueries = createRawRecordingQueries('office');
|
||||
const query = new RecordingMediaQuery(rawQueries);
|
||||
expect(query.getQuery()).toBe(rawQueries);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FolderViewQuery', () => {
|
||||
it('should construct', () => {
|
||||
const rawQuery: FolderQuery = {
|
||||
folder: createFolder(),
|
||||
path: [{}],
|
||||
};
|
||||
|
||||
const query = new FolderViewQuery(rawQuery);
|
||||
expect(query.getQuery()).toBe(rawQuery);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,224 @@
|
||||
import { assert, describe, expect, it } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { CameraManager } from '../../src/camera-manager/manager';
|
||||
import { FoldersManager } from '../../src/card-controller/folders/manager';
|
||||
import { ConditionStateManagerReadonlyInterface } from '../../src/conditions/types';
|
||||
import { QuerySource } from '../../src/query-source';
|
||||
import { ViewMedia } from '../../src/view/item';
|
||||
import { UnifiedQuery } from '../../src/view/unified-query';
|
||||
import { UnifiedQueryRunner } from '../../src/view/unified-query-runner';
|
||||
import { createEventQuery, createFolderQuery } from '../test-utils';
|
||||
|
||||
describe('UnifiedQueryRunner', () => {
|
||||
describe('execute', () => {
|
||||
it('should aggregate results from camera and folder sources', async () => {
|
||||
const cameraManager = mock<CameraManager>();
|
||||
const foldersManager = mock<FoldersManager>();
|
||||
const conditionStateManager = mock<ConditionStateManagerReadonlyInterface>();
|
||||
|
||||
const runner = new UnifiedQueryRunner(
|
||||
cameraManager,
|
||||
foldersManager,
|
||||
conditionStateManager,
|
||||
);
|
||||
|
||||
const cameraItem = mock<ViewMedia>();
|
||||
const folderItem = mock<ViewMedia>();
|
||||
|
||||
cameraManager.executeMediaQueries.mockResolvedValue([cameraItem]);
|
||||
foldersManager.expandFolder.mockResolvedValue([folderItem]);
|
||||
|
||||
const query = new UnifiedQuery();
|
||||
query.addNode(createEventQuery('camera1'));
|
||||
query.addNode(createFolderQuery('f1'));
|
||||
|
||||
const results = await runner.execute(query);
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results).toContain(cameraItem);
|
||||
expect(results).toContain(folderItem);
|
||||
|
||||
expect(cameraManager.executeMediaQueries).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ source: QuerySource.Camera })],
|
||||
expect.anything(),
|
||||
);
|
||||
expect(foldersManager.expandFolder).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ source: QuerySource.Folder }),
|
||||
undefined,
|
||||
expect.objectContaining({ useCache: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty query gracefully', async () => {
|
||||
const cameraManager = mock<CameraManager>();
|
||||
const foldersManager = mock<FoldersManager>();
|
||||
const runner = new UnifiedQueryRunner(
|
||||
cameraManager,
|
||||
foldersManager,
|
||||
mock<ConditionStateManagerReadonlyInterface>(),
|
||||
);
|
||||
|
||||
const results = await runner.execute(new UnifiedQuery());
|
||||
expect(results).toHaveLength(0);
|
||||
expect(cameraManager.executeMediaQueries).not.toHaveBeenCalled();
|
||||
expect(foldersManager.expandFolder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle null returns from managers', async () => {
|
||||
const cameraManager = mock<CameraManager>();
|
||||
const foldersManager = mock<FoldersManager>();
|
||||
const runner = new UnifiedQueryRunner(
|
||||
cameraManager,
|
||||
foldersManager,
|
||||
mock<ConditionStateManagerReadonlyInterface>(),
|
||||
);
|
||||
|
||||
cameraManager.executeMediaQueries.mockResolvedValue(null);
|
||||
foldersManager.expandFolder.mockResolvedValue(null);
|
||||
|
||||
const query = new UnifiedQuery();
|
||||
query.addNode(createEventQuery('camera1'));
|
||||
query.addNode(createFolderQuery('f1'));
|
||||
|
||||
const results = await runner.execute(query);
|
||||
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should pass options to managers', async () => {
|
||||
const cameraManager = mock<CameraManager>();
|
||||
const runner = new UnifiedQueryRunner(
|
||||
cameraManager,
|
||||
mock<FoldersManager>(),
|
||||
mock<ConditionStateManagerReadonlyInterface>(),
|
||||
);
|
||||
|
||||
const query = new UnifiedQuery();
|
||||
query.addNode(createEventQuery('camera1'));
|
||||
|
||||
await runner.execute(query, { useCache: false });
|
||||
|
||||
expect(cameraManager.executeMediaQueries).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ useCache: false }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('areResultsFresh', () => {
|
||||
it('should return true if managers report fresh', () => {
|
||||
const cameraManager = mock<CameraManager>();
|
||||
const foldersManager = mock<FoldersManager>();
|
||||
cameraManager.areMediaQueriesResultsFresh.mockReturnValue(true);
|
||||
foldersManager.areResultsFresh.mockReturnValue(true);
|
||||
|
||||
const runner = new UnifiedQueryRunner(
|
||||
cameraManager,
|
||||
foldersManager,
|
||||
mock<ConditionStateManagerReadonlyInterface>(),
|
||||
);
|
||||
|
||||
const query = new UnifiedQuery();
|
||||
query.addNode(createEventQuery('camera1'));
|
||||
query.addNode(createFolderQuery('f1'));
|
||||
|
||||
expect(runner.areResultsFresh(new Date(), query)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if any manager reports stale', () => {
|
||||
const cameraManager = mock<CameraManager>();
|
||||
cameraManager.areMediaQueriesResultsFresh.mockReturnValue(false);
|
||||
|
||||
const runner = new UnifiedQueryRunner(
|
||||
cameraManager,
|
||||
mock<FoldersManager>(),
|
||||
mock<ConditionStateManagerReadonlyInterface>(),
|
||||
);
|
||||
|
||||
const query = new UnifiedQuery([createEventQuery('camera1')]);
|
||||
expect(runner.areResultsFresh(new Date(), query)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if folder manager reports stale', () => {
|
||||
const foldersManager = mock<FoldersManager>();
|
||||
foldersManager.areResultsFresh.mockReturnValue(false);
|
||||
|
||||
const runner = new UnifiedQueryRunner(
|
||||
mock<CameraManager>(),
|
||||
foldersManager,
|
||||
mock<ConditionStateManagerReadonlyInterface>(),
|
||||
);
|
||||
|
||||
const query = new UnifiedQuery([createFolderQuery('f1')]);
|
||||
expect(runner.areResultsFresh(new Date(), query)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extend', () => {
|
||||
it('should extend media queries and preserve folder queries', async () => {
|
||||
const cameraManager = mock<CameraManager>();
|
||||
const runner = new UnifiedQueryRunner(
|
||||
cameraManager,
|
||||
mock<FoldersManager>(),
|
||||
mock<ConditionStateManagerReadonlyInterface>(),
|
||||
);
|
||||
|
||||
const existingItem = mock<ViewMedia>();
|
||||
const newItem = mock<ViewMedia>();
|
||||
const extendedMediaMatch = createEventQuery('camera1');
|
||||
|
||||
cameraManager.extendMediaQueries.mockResolvedValue({
|
||||
queries: [extendedMediaMatch],
|
||||
results: [existingItem, newItem],
|
||||
});
|
||||
|
||||
const query = new UnifiedQuery();
|
||||
query.addNode(createEventQuery('camera1'));
|
||||
query.addNode(createFolderQuery('f1'));
|
||||
|
||||
const result = await runner.extend(query, [existingItem], 'later');
|
||||
|
||||
assert(result);
|
||||
expect(result.results).toHaveLength(2);
|
||||
expect(result.query.getNodeCount()).toBe(2);
|
||||
expect(result.query.getMediaQueries()).toHaveLength(1);
|
||||
expect(result.query.getFolderQueries()).toHaveLength(1);
|
||||
|
||||
expect(cameraManager.extendMediaQueries).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ source: QuerySource.Camera })],
|
||||
[existingItem],
|
||||
'later',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return null for query with no media nodes', async () => {
|
||||
const runner = new UnifiedQueryRunner(
|
||||
mock<CameraManager>(),
|
||||
mock<FoldersManager>(),
|
||||
mock<ConditionStateManagerReadonlyInterface>(),
|
||||
);
|
||||
|
||||
const query = new UnifiedQuery([createFolderQuery('f1')]);
|
||||
const result = await runner.extend(query, [], 'earlier');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null if camera manager fails to extend', async () => {
|
||||
const cameraManager = mock<CameraManager>();
|
||||
cameraManager.extendMediaQueries.mockResolvedValue(null);
|
||||
|
||||
const runner = new UnifiedQueryRunner(
|
||||
cameraManager,
|
||||
mock<FoldersManager>(),
|
||||
mock<ConditionStateManagerReadonlyInterface>(),
|
||||
);
|
||||
|
||||
const query = new UnifiedQuery([createEventQuery('camera1')]);
|
||||
const result = await runner.extend(query, [], 'earlier');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { assert, describe, expect, it } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { CameraManager } from '../../src/camera-manager/manager';
|
||||
import { CameraManagerStore } from '../../src/camera-manager/store';
|
||||
import { FoldersManager } from '../../src/card-controller/folders/manager';
|
||||
import { UnifiedQueryBuilder } from '../../src/view/unified-query-builder';
|
||||
import { UnifiedQueryTransformer } from '../../src/view/unified-query-transformer';
|
||||
import {
|
||||
createCapabilities,
|
||||
createFolder,
|
||||
isEventQuery,
|
||||
isFolderQuery,
|
||||
isRecordingQuery,
|
||||
} from '../test-utils';
|
||||
|
||||
const createMocks = () => {
|
||||
const cameraManager = mock<CameraManager>();
|
||||
const foldersManager = mock<FoldersManager>();
|
||||
const store = mock<CameraManagerStore>();
|
||||
|
||||
cameraManager.getStore.mockReturnValue(store);
|
||||
store.getCameraIDs.mockReturnValue(new Set(['camera.office']));
|
||||
store.getAllDependentCameras.mockImplementation((id) => new Set([id]));
|
||||
cameraManager.getCameraCapabilities.mockReturnValue(
|
||||
createCapabilities({ clips: true, snapshots: true, recordings: true }),
|
||||
);
|
||||
|
||||
return { cameraManager, foldersManager, store };
|
||||
};
|
||||
|
||||
describe('UnifiedQueryTransformer', () => {
|
||||
describe('stripTimeRange', () => {
|
||||
it('should remove start and end from camera queries', () => {
|
||||
const { cameraManager, foldersManager } = createMocks();
|
||||
const builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
|
||||
const query = builder.buildClipsQuery(new Set(['camera.office']), {
|
||||
start: new Date('2024-01-01'),
|
||||
end: new Date('2024-01-02'),
|
||||
limit: 10,
|
||||
});
|
||||
assert(query);
|
||||
|
||||
const stripped = UnifiedQueryTransformer.stripTimeRange(query);
|
||||
const node = stripped.getNodes()[0];
|
||||
assert(isEventQuery(node));
|
||||
|
||||
expect(node).not.toHaveProperty('start');
|
||||
expect(node).not.toHaveProperty('end');
|
||||
expect(node.limit).toBe(10); // Other props preserved
|
||||
});
|
||||
|
||||
it('should not affect folder queries', () => {
|
||||
const { cameraManager, foldersManager } = createMocks();
|
||||
const builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
|
||||
const folder = createFolder({ id: 'f1', title: 'Test' });
|
||||
const query = builder.buildFolderQueryWithPath(folder, [{ ha: { id: 'Root' } }]);
|
||||
|
||||
const stripped = UnifiedQueryTransformer.stripTimeRange(query);
|
||||
const node = stripped.getNodes()[0];
|
||||
assert(isFolderQuery(node));
|
||||
expect(node.folder.id).toBe('f1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rebuildQuery', () => {
|
||||
it('should apply new options to camera queries', () => {
|
||||
const { cameraManager, foldersManager } = createMocks();
|
||||
const builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
|
||||
const query = builder.buildClipsQuery(new Set(['camera.office']));
|
||||
assert(query);
|
||||
|
||||
const start = new Date('2024-06-01');
|
||||
const end = new Date('2024-06-02');
|
||||
|
||||
const rebuilt = UnifiedQueryTransformer.rebuildQuery(query, {
|
||||
start,
|
||||
end,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
const node = rebuilt.getNodes()[0];
|
||||
assert(isEventQuery(node));
|
||||
expect(node.start).toEqual(start);
|
||||
expect(node.end).toEqual(end);
|
||||
expect(node.limit).toBe(100);
|
||||
});
|
||||
|
||||
it('should not affect folder queries', () => {
|
||||
const { cameraManager, foldersManager } = createMocks();
|
||||
const builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
|
||||
const folder = createFolder({ id: 'f1', title: 'Test' });
|
||||
const query = builder.buildFolderQueryWithPath(folder, [{ ha: { id: 'Root' } }]);
|
||||
|
||||
const rebuilt = UnifiedQueryTransformer.rebuildQuery(query, {
|
||||
start: new Date(),
|
||||
end: new Date(),
|
||||
});
|
||||
|
||||
const node = rebuilt.getNodes()[0];
|
||||
assert(isFolderQuery(node));
|
||||
expect(node).not.toHaveProperty('start');
|
||||
expect(node).not.toHaveProperty('end');
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertToClips', () => {
|
||||
it('should convert event query to clips', () => {
|
||||
const { cameraManager, foldersManager } = createMocks();
|
||||
const builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
|
||||
const query = builder.buildSnapshotsQuery(new Set(['camera.office']));
|
||||
assert(query);
|
||||
|
||||
const converted = UnifiedQueryTransformer.convertToClips(query);
|
||||
const node = converted.getNodes()[0];
|
||||
assert(isEventQuery(node));
|
||||
|
||||
expect(node.hasClip).toBe(true);
|
||||
expect(node.hasSnapshot).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not affect recording queries', () => {
|
||||
const { cameraManager, foldersManager } = createMocks();
|
||||
const builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
|
||||
const query = builder.buildRecordingsQuery(new Set(['camera.office']));
|
||||
assert(query);
|
||||
|
||||
const converted = UnifiedQueryTransformer.convertToClips(query);
|
||||
const node = converted.getNodes()[0];
|
||||
assert(isRecordingQuery(node));
|
||||
expect(node).not.toHaveProperty('hasClip');
|
||||
});
|
||||
|
||||
it('should not affect folder queries', () => {
|
||||
const { cameraManager, foldersManager } = createMocks();
|
||||
const builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
|
||||
const folder = createFolder({ id: 'f1', title: 'Test' });
|
||||
const query = builder.buildFolderQueryWithPath(folder, [{ ha: { id: 'Root' } }]);
|
||||
|
||||
const converted = UnifiedQueryTransformer.convertToClips(query);
|
||||
const node = converted.getNodes()[0];
|
||||
assert(isFolderQuery(node));
|
||||
expect(node.folder.id).toBe('f1');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,334 @@
|
||||
import { assert, describe, expect, it } from 'vitest';
|
||||
import { QueryType } from '../../src/camera-manager/types';
|
||||
import { QuerySource } from '../../src/query-source';
|
||||
import { UnifiedQuery } from '../../src/view/unified-query';
|
||||
import {
|
||||
createEventQuery,
|
||||
createFolderQuery,
|
||||
createRecordingQuery,
|
||||
createReviewQuery,
|
||||
isEventQuery,
|
||||
isFolderQuery,
|
||||
} from '../test-utils';
|
||||
|
||||
describe('UnifiedQuery', () => {
|
||||
describe('Node Management', () => {
|
||||
it('should construct empty', () => {
|
||||
const query = new UnifiedQuery();
|
||||
expect(query.hasNodes()).toBe(false);
|
||||
});
|
||||
|
||||
it('should construct with initial nodes', () => {
|
||||
const nodes = [createEventQuery('front')];
|
||||
const query = new UnifiedQuery(nodes);
|
||||
expect(query.hasNodes()).toBe(true);
|
||||
});
|
||||
|
||||
it('should add node', () => {
|
||||
const query = new UnifiedQuery();
|
||||
query.addNode(createEventQuery('front'));
|
||||
expect(query.getMediaQueries()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should allow multiple nodes for same camera', () => {
|
||||
const query = new UnifiedQuery();
|
||||
query.addNode(createEventQuery('front'));
|
||||
query.addNode(createReviewQuery('front'));
|
||||
query.addNode(createRecordingQuery('front'));
|
||||
expect(query.getMediaQueries()).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Lookup Methods', () => {
|
||||
it('should get queries by folder', () => {
|
||||
const query = new UnifiedQuery();
|
||||
query.addNode(createEventQuery('front'));
|
||||
query.addNode(createFolderQuery('clips'));
|
||||
query.addNode(createFolderQuery('recordings'));
|
||||
|
||||
const clipsQueries = query.getFolderQueries('clips');
|
||||
expect(clipsQueries).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should get all media types', () => {
|
||||
const query = new UnifiedQuery();
|
||||
query.addNode(createEventQuery('front', { hasClip: true }));
|
||||
query.addNode(createEventQuery('back', { hasSnapshot: true }));
|
||||
query.addNode(createRecordingQuery('garage'));
|
||||
query.addNode(createReviewQuery('office'));
|
||||
|
||||
const types = query.getAllMediaTypes();
|
||||
expect(types.size).toBe(4);
|
||||
expect(types.has('clips')).toBe(true);
|
||||
expect(types.has('snapshots')).toBe(true);
|
||||
expect(types.has('recordings')).toBe(true);
|
||||
expect(types.has('reviews')).toBe(true);
|
||||
});
|
||||
|
||||
it('should ignore non-media nodes in getAllMediaTypes', () => {
|
||||
const query = new UnifiedQuery();
|
||||
query.addNode(createEventQuery('front', { hasClip: true }));
|
||||
query.addNode(createFolderQuery('clips'));
|
||||
|
||||
const types = query.getAllMediaTypes();
|
||||
expect(types.size).toBe(1);
|
||||
expect(types.has('clips')).toBe(true);
|
||||
});
|
||||
|
||||
it('should ignore unhandled media types in getAllMediaTypes', () => {
|
||||
const query = new UnifiedQuery();
|
||||
query.addNode({
|
||||
source: QuerySource.Camera,
|
||||
type: QueryType.RecordingSegments,
|
||||
cameraIDs: new Set(['camera1']),
|
||||
});
|
||||
|
||||
const types = query.getAllMediaTypes();
|
||||
expect(types.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should filter media queries by camera ID', () => {
|
||||
const query = new UnifiedQuery();
|
||||
query.addNode(createEventQuery('front'));
|
||||
query.addNode(createEventQuery('back'));
|
||||
|
||||
expect(query.getMediaQueries({ cameraID: 'front' })).toHaveLength(1);
|
||||
expect(query.getMediaQueries({ cameraID: 'garage' })).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should check hasMediaQueriesOfType', () => {
|
||||
const query = new UnifiedQuery();
|
||||
query.addNode(createEventQuery('front'));
|
||||
|
||||
expect(query.hasMediaQueriesOfType(QueryType.Event)).toBe(true);
|
||||
expect(query.hasMediaQueriesOfType(QueryType.Review)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Camera Operations', () => {
|
||||
it('should get all camera IDs', () => {
|
||||
const query = new UnifiedQuery();
|
||||
query.addNode(createEventQuery('front'));
|
||||
query.addNode(createReviewQuery('back'));
|
||||
query.addNode(createFolderQuery('clips'));
|
||||
|
||||
const cameraIDs = query.getAllCameraIDs();
|
||||
expect(cameraIDs.size).toBe(2);
|
||||
expect(cameraIDs.has('front')).toBe(true);
|
||||
expect(cameraIDs.has('back')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Clone', () => {
|
||||
it('should create deep clone', () => {
|
||||
const query = new UnifiedQuery();
|
||||
query.addNode(
|
||||
createEventQuery('front', {
|
||||
what: new Set(['person']),
|
||||
where: new Set(['driveway']),
|
||||
}),
|
||||
);
|
||||
|
||||
const cloned = query.clone();
|
||||
|
||||
// Modify original
|
||||
const originalEvent = query.getMediaQueries()[0];
|
||||
assert(isEventQuery(originalEvent));
|
||||
originalEvent.what?.add('car');
|
||||
|
||||
// Clone should be unchanged
|
||||
const clonedEvent = cloned.getMediaQueries()[0];
|
||||
assert(isEventQuery(clonedEvent));
|
||||
expect(clonedEvent.what?.has('car')).toBe(false);
|
||||
expect(clonedEvent.what?.has('person')).toBe(true);
|
||||
});
|
||||
|
||||
it('should clone path arrays', () => {
|
||||
const query = new UnifiedQuery();
|
||||
query.addNode(createFolderQuery('clips'));
|
||||
|
||||
const cloned = query.clone();
|
||||
|
||||
// Modify original
|
||||
const originalFolder = query.getFolderQueries()[0];
|
||||
assert(isFolderQuery(originalFolder));
|
||||
|
||||
// We need to bypass readonly for testing the clone's independence
|
||||
(originalFolder as { path: unknown }).path = [
|
||||
...originalFolder.path,
|
||||
{ ha: { id: 'Child' } },
|
||||
];
|
||||
|
||||
// Clone should be unchanged
|
||||
const clonedFolder = cloned.getFolderQueries()[0];
|
||||
assert(isFolderQuery(clonedFolder));
|
||||
expect(clonedFolder.path).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Equality', () => {
|
||||
it('should return true for equal queries', () => {
|
||||
const query1 = new UnifiedQuery();
|
||||
query1.addNode(
|
||||
createEventQuery('front', {
|
||||
what: new Set(['person']),
|
||||
}),
|
||||
);
|
||||
|
||||
const query2 = new UnifiedQuery();
|
||||
query2.addNode(
|
||||
createEventQuery('front', {
|
||||
what: new Set(['person']),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(query1.isEqual(query2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for different node counts', () => {
|
||||
const query1 = new UnifiedQuery();
|
||||
query1.addNode(createEventQuery('front'));
|
||||
|
||||
const query2 = new UnifiedQuery();
|
||||
query2.addNode(createEventQuery('front'));
|
||||
query2.addNode(createEventQuery('back'));
|
||||
|
||||
expect(query1.isEqual(query2)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for different node values', () => {
|
||||
const query1 = new UnifiedQuery();
|
||||
query1.addNode(createEventQuery('front', { hasClip: true }));
|
||||
|
||||
const query2 = new UnifiedQuery();
|
||||
query2.addNode(createEventQuery('front', { hasClip: false }));
|
||||
|
||||
expect(query1.isEqual(query2)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle Set comparison correctly', () => {
|
||||
const query1 = new UnifiedQuery();
|
||||
query1.addNode(
|
||||
createEventQuery('front', {
|
||||
what: new Set(['person', 'car']),
|
||||
}),
|
||||
);
|
||||
|
||||
const query2 = new UnifiedQuery();
|
||||
query2.addNode(
|
||||
createEventQuery('front', {
|
||||
what: new Set(['car', 'person']), // Same values, different order
|
||||
}),
|
||||
);
|
||||
|
||||
expect(query1.isEqual(query2)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSupersetOf', () => {
|
||||
it('should return true for identical queries', () => {
|
||||
const query1 = new UnifiedQuery();
|
||||
query1.addNode(createEventQuery('front'));
|
||||
|
||||
const query2 = new UnifiedQuery();
|
||||
query2.addNode(createEventQuery('front'));
|
||||
|
||||
expect(query1.isSupersetOf(query2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when superset contains all nodes', () => {
|
||||
const superset = new UnifiedQuery();
|
||||
superset.addNode(createEventQuery('front'));
|
||||
superset.addNode(createEventQuery('back'));
|
||||
|
||||
const subset = new UnifiedQuery();
|
||||
subset.addNode(createEventQuery('front'));
|
||||
|
||||
expect(superset.isSupersetOf(subset)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when target has node not in source', () => {
|
||||
const query1 = new UnifiedQuery();
|
||||
query1.addNode(createEventQuery('front'));
|
||||
|
||||
const query2 = new UnifiedQuery();
|
||||
query2.addNode(createEventQuery('front'));
|
||||
query2.addNode(createEventQuery('back'));
|
||||
|
||||
expect(query1.isSupersetOf(query2)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when time range is wider', () => {
|
||||
const superset = new UnifiedQuery();
|
||||
superset.addNode(
|
||||
createEventQuery('front', {
|
||||
start: new Date('2024-01-01'),
|
||||
end: new Date('2024-01-31'),
|
||||
}),
|
||||
);
|
||||
|
||||
const subset = new UnifiedQuery();
|
||||
subset.addNode(
|
||||
createEventQuery('front', {
|
||||
start: new Date('2024-01-10'),
|
||||
end: new Date('2024-01-20'),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(superset.isSupersetOf(subset)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when superset time range is narrower', () => {
|
||||
const narrower = new UnifiedQuery();
|
||||
narrower.addNode(
|
||||
createEventQuery('front', {
|
||||
start: new Date('2024-01-10'),
|
||||
end: new Date('2024-01-20'),
|
||||
}),
|
||||
);
|
||||
|
||||
const wider = new UnifiedQuery();
|
||||
wider.addNode(
|
||||
createEventQuery('front', {
|
||||
start: new Date('2024-01-01'),
|
||||
end: new Date('2024-01-31'),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(narrower.isSupersetOf(wider)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle null times correctly', () => {
|
||||
const query1 = new UnifiedQuery();
|
||||
query1.addNode(createEventQuery('front'));
|
||||
|
||||
const query2 = new UnifiedQuery();
|
||||
query2.addNode(createEventQuery('front'));
|
||||
|
||||
expect(query1.isSupersetOf(query2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for empty target query', () => {
|
||||
const superset = new UnifiedQuery();
|
||||
superset.addNode(createEventQuery('front'));
|
||||
|
||||
const empty = new UnifiedQuery();
|
||||
|
||||
expect(superset.isSupersetOf(empty)).toBe(true);
|
||||
});
|
||||
|
||||
it('should support folder nodes', () => {
|
||||
const superset = new UnifiedQuery();
|
||||
superset.addNode(createFolderQuery('f1'));
|
||||
|
||||
const subset = new UnifiedQuery();
|
||||
subset.addNode(createFolderQuery('f1'));
|
||||
|
||||
expect(superset.isSupersetOf(subset)).toBe(true);
|
||||
|
||||
const nonSubset = new UnifiedQuery();
|
||||
nonSubset.addNode(createFolderQuery('f2'));
|
||||
expect(superset.isSupersetOf(nonSubset)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -79,6 +79,8 @@ describe('getCameraIDsForViewName', () => {
|
||||
['snapshots' as const, 'snapshots' as const],
|
||||
['recording' as const, 'recordings' as const],
|
||||
['recordings' as const, 'recordings' as const],
|
||||
['review' as const, 'reviews' as const],
|
||||
['reviews' as const, 'reviews' as const],
|
||||
])('%s', (viewName: AdvancedCameraCardView, capabilityKey: CapabilityKey) => {
|
||||
const cameraManager = createCameraManager();
|
||||
vi.mocked(cameraManager.getStore).mockReturnValue(
|
||||
|
||||
+16
-12
@@ -1,11 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { EventMediaQuery } from '../../src/view/query';
|
||||
import { UnifiedQuery } from '../../src/view/unified-query';
|
||||
import { QueryResults } from '../../src/view/query-results';
|
||||
import { createView } from '../test-utils';
|
||||
|
||||
describe('View Basics', () => {
|
||||
it('should construct from parameters', () => {
|
||||
const query = new EventMediaQuery();
|
||||
const query = new UnifiedQuery();
|
||||
const queryResults = new QueryResults();
|
||||
const context = {};
|
||||
|
||||
@@ -29,7 +29,7 @@ describe('View Basics', () => {
|
||||
const view = createView({
|
||||
view: 'live',
|
||||
camera: 'camera',
|
||||
query: new EventMediaQuery(),
|
||||
query: new UnifiedQuery(),
|
||||
queryResults: new QueryResults(),
|
||||
context: {},
|
||||
});
|
||||
@@ -52,7 +52,7 @@ describe('View Basics', () => {
|
||||
const view = createView({
|
||||
view: 'live',
|
||||
camera: 'camera-1',
|
||||
query: new EventMediaQuery(),
|
||||
query: new UnifiedQuery(),
|
||||
queryResults: new QueryResults(),
|
||||
context: {},
|
||||
displayMode: 'single',
|
||||
@@ -61,7 +61,7 @@ describe('View Basics', () => {
|
||||
const evolved = view.evolve({
|
||||
view: 'clips',
|
||||
camera: 'camera-2',
|
||||
query: new EventMediaQuery(),
|
||||
query: new UnifiedQuery(),
|
||||
queryResults: new QueryResults(),
|
||||
context: {},
|
||||
displayMode: 'grid',
|
||||
@@ -78,7 +78,7 @@ describe('View Basics', () => {
|
||||
const view = createView({
|
||||
view: 'live',
|
||||
camera: 'camera-1',
|
||||
query: new EventMediaQuery(),
|
||||
query: new UnifiedQuery(),
|
||||
queryResults: new QueryResults(),
|
||||
context: {},
|
||||
});
|
||||
@@ -184,15 +184,16 @@ describe('View Basics', () => {
|
||||
|
||||
describe('should detect gallery views', () => {
|
||||
it('should detect gallery views', () => {
|
||||
expect(createView({ view: 'clips' }).isMediaGalleryView()).toBeTruthy();
|
||||
expect(createView({ view: 'folders' }).isMediaGalleryView()).toBeTruthy();
|
||||
expect(createView({ view: 'snapshots' }).isMediaGalleryView()).toBeTruthy();
|
||||
expect(createView({ view: 'recordings' }).isMediaGalleryView()).toBeTruthy();
|
||||
expect(createView({ view: 'clips' }).isGalleryView()).toBeTruthy();
|
||||
expect(createView({ view: 'folders' }).isGalleryView()).toBeTruthy();
|
||||
expect(createView({ view: 'snapshots' }).isGalleryView()).toBeTruthy();
|
||||
expect(createView({ view: 'recordings' }).isGalleryView()).toBeTruthy();
|
||||
expect(createView({ view: 'reviews' }).isGalleryView()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should not detect gallery view', () => {
|
||||
expect(createView({ view: 'live' }).isMediaGalleryView()).toBeFalsy();
|
||||
expect(createView({ view: 'timeline' }).isMediaGalleryView()).toBeFalsy();
|
||||
expect(createView({ view: 'live' }).isGalleryView()).toBeFalsy();
|
||||
expect(createView({ view: 'timeline' }).isGalleryView()).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -202,6 +203,7 @@ describe('View Basics', () => {
|
||||
expect(createView({ view: 'snapshot' }).isViewerView()).toBeTruthy();
|
||||
expect(createView({ view: 'media' }).isViewerView()).toBeTruthy();
|
||||
expect(createView({ view: 'recording' }).isViewerView()).toBeTruthy();
|
||||
expect(createView({ view: 'review' }).isViewerView()).toBeTruthy();
|
||||
expect(createView({ view: 'folder' }).isAnyMediaView()).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -222,6 +224,8 @@ describe('View Basics', () => {
|
||||
expect(createView({ view: 'recordings' }).getDefaultMediaType()).toBe(
|
||||
'recordings',
|
||||
);
|
||||
expect(createView({ view: 'review' }).getDefaultMediaType()).toBe('reviews');
|
||||
expect(createView({ view: 'reviews' }).getDefaultMediaType()).toBe('reviews');
|
||||
});
|
||||
|
||||
it('should not get default media type', () => {
|
||||
|
||||
Reference in New Issue
Block a user