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:
Dermot Duffy
2026-01-19 13:32:50 -08:00
committed by GitHub
parent 6eb0a87ca8
commit fc32727860
215 changed files with 14491 additions and 6160 deletions
@@ -10,6 +10,7 @@ import { Engine } from '../../src/camera-manager/types.js';
import { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js';
import { CardWideConfig } from '../../src/config/schema/types.js';
import { EntityRegistryManager } from '../../src/ha/registry/entity/types.js';
import { DeviceRegistryManager } from '../../src/ha/registry/device';
import { ResolvedMediaCache } from '../../src/ha/resolved-media.js';
import { EntityRegistryManagerMock } from '../ha/registry/entity/mock.js';
import {
@@ -28,6 +29,7 @@ const createFactory = (options?: {
}): CameraManagerEngineFactory => {
return new CameraManagerEngineFactory(
options?.entityRegistryManager ?? new EntityRegistryManagerMock(),
mock<DeviceRegistryManager>(),
);
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest';
import {
FrigateEventViewMedia,
FrigateReviewViewMedia,
} from '../../../src/camera-manager/frigate/media';
import { ViewMediaType } from '../../../src/view/item';
import { createFrigateEvent, createFrigateReview } from '../../test-utils';
describe('FrigateReviewViewMedia', () => {
it('should get description when scene is present', () => {
const review = createFrigateReview({
data: {
objects: [],
zones: [],
metadata: {
scene: 'A person walking',
title: 'Title',
},
},
});
const media = new FrigateReviewViewMedia(
'camera',
review,
'content_id',
'thumbnail',
);
expect(media.getDescription()).toBe('A person walking');
});
it('should get null description when scene is absent', () => {
const review = createFrigateReview({
data: {
objects: [],
zones: [],
metadata: {
title: 'Title',
// scene is absent.
},
},
});
const media = new FrigateReviewViewMedia(
'camera',
review,
'content_id',
'thumbnail',
);
expect(media.getDescription()).toBeNull();
});
});
describe('FrigateEventViewMedia', () => {
it('should get description when description is present', () => {
const event = createFrigateEvent({
data: {
description: 'A person walking',
},
});
const media = new FrigateEventViewMedia(
ViewMediaType.Clip,
'camera',
event,
'content_id',
'thumbnail',
);
expect(media.getDescription()).toBe('A person walking');
});
it('should get null description when data is absent', () => {
const event = createFrigateEvent();
const media = new FrigateEventViewMedia(
ViewMediaType.Clip,
'camera',
event,
'content_id',
'thumbnail',
);
expect(media.getDescription()).toBeNull();
});
});
+101 -1
View File
@@ -5,17 +5,22 @@ import {
getPTZInfo,
getRecordingSegments,
getRecordingsSummary,
getReviews,
retainEvent,
setReviewsReviewed,
} from '../../../src/camera-manager/frigate/requests';
import {
EventSummary,
eventSummarySchema,
FrigateEvent,
frigateEventsSchema,
FrigateReview,
frigateReviewsSchema,
ptzInfoSchema,
recordingSegmentsSchema,
recordingSummarySchema,
retainResultSchema,
reviewResultSchema,
} from '../../../src/camera-manager/frigate/types';
import { RecordingSegment } from '../../../src/camera-manager/types';
import { homeAssistantWSRequest } from '../../../src/ha/ws-request';
@@ -197,7 +202,7 @@ describe('frigate requests', () => {
);
});
it('should get PTZ info', async () => {
it('should get PTZInfo', async () => {
const ptzInfo = [
{
name: 'camera.office',
@@ -219,4 +224,99 @@ describe('frigate requests', () => {
true,
);
});
it('should get reviews', async () => {
const reviews: FrigateReview[] = [
{
id: 'review_id',
camera: 'camera',
start_time: 0,
end_time: 1,
severity: 'alert',
thumb_path: 'thumb.jpg',
data: {
objects: [],
zones: [],
},
has_been_reviewed: false,
},
];
const hass = createHASS();
vi.mocked(homeAssistantWSRequest).mockResolvedValue(reviews);
expect(
await getReviews(hass, {
instance_id: 'clientID',
cameras: ['camera'],
labels: ['person'],
zones: ['zone'],
severity: 'alert',
after: 0,
before: 1,
limit: 10,
reviewed: false,
}),
).toBe(reviews);
expect(homeAssistantWSRequest).toBeCalledWith(
hass,
frigateReviewsSchema,
expect.objectContaining({
type: 'frigate/reviews/get',
instance_id: 'clientID',
cameras: ['camera'],
labels: ['person'],
zones: ['zone'],
severity: 'alert',
after: 0,
before: 1,
limit: 10,
reviewed: false,
}),
true,
);
});
describe('should set reviews reviewed', async () => {
it('successfully', async () => {
vi.mocked(homeAssistantWSRequest).mockResolvedValue({
success: true,
message: 'success',
});
const hass = createHASS();
setReviewsReviewed(hass, 'clientID', ['review_id'], true);
expect(homeAssistantWSRequest).toBeCalledWith(
hass,
reviewResultSchema,
expect.objectContaining({
type: 'frigate/reviews/viewed',
instance_id: 'clientID',
ids: ['review_id'],
viewed: true,
}),
);
});
it('unsuccessfully', async () => {
vi.mocked(homeAssistantWSRequest).mockResolvedValue({
success: false,
message: 'failed',
});
const hass = createHASS();
await expect(
setReviewsReviewed(hass, 'clientID', ['review_id'], true),
).rejects.toThrowError(/Failed to receive response from Home Assistant/);
expect(homeAssistantWSRequest).toBeCalledWith(
hass,
reviewResultSchema,
expect.objectContaining({
type: 'frigate/reviews/viewed',
instance_id: 'clientID',
ids: ['review_id'],
viewed: true,
}),
);
});
});
});
+110
View File
@@ -8,12 +8,17 @@ import {
getRecordingID,
getRecordingMediaContentID,
getRecordingTitle,
getReviewMediaContentID,
getReviewSeverity,
getReviewThumbnailURL,
getReviewTitle,
} from '../../../src/camera-manager/frigate/util';
import { CameraConfig } from '../../../src/config/schema/cameras';
import {
createCameraConfig,
createFrigateEvent,
createFrigateRecording,
createFrigateReview,
} from '../../test-utils';
describe('getEventTitle', () => {
@@ -152,3 +157,108 @@ describe('getRecordingID', () => {
).toBe('//1682776800000/1682780399000');
});
});
describe('getReviewTitle', () => {
const start = new Date('2023-05-06T10:43:00');
const end = new Date('2023-05-06T10:44:12');
afterEach(() => {
vi.useRealTimers();
});
it('should get finished review title without objects', () => {
expect(
getReviewTitle(
createFrigateReview({
start_time: start.getTime() / 1000,
end_time: end.getTime() / 1000,
data: {
objects: [],
zones: [],
},
}),
),
).toBe('2023-05-06 10:43 [72s]');
});
it('should get finished review title with objects', () => {
expect(
getReviewTitle(
createFrigateReview({
start_time: start.getTime() / 1000,
end_time: end.getTime() / 1000,
data: {
objects: ['person', 'dog'],
},
}),
),
).toBe('Person, Dog');
});
it('should get in-progress review title', () => {
vi.useFakeTimers();
vi.setSystemTime(add(start, { seconds: 60 }));
expect(
getReviewTitle(
createFrigateReview({
start_time: start.getTime() / 1000,
end_time: null,
data: {
objects: [],
},
}),
),
).toBe('2023-05-06 10:43 [60s]');
});
});
describe('getReviewMediaContentID', () => {
it('should get review content ID', () => {
expect(
getReviewMediaContentID(
'clientid',
'kitchen',
createFrigateReview({
start_time: new Date('2023-04-29T14:00:00').getTime() / 1000,
}),
),
).toBe('media-source://frigate/clientid/recordings/kitchen/2023-04-29/14');
});
});
describe('getReviewThumbnailURL', () => {
it('should get thumbnail URL', () => {
expect(
getReviewThumbnailURL(
'clientid',
createFrigateReview({
thumb_path: '/media/frigate/thumb.jpg',
}),
),
).toBe('/api/frigate/clientid/thumb.jpg');
});
it('should return null when no thumb path', () => {
expect(
getReviewThumbnailURL(
'clientid',
createFrigateReview({
thumb_path: null,
}),
),
).toBeNull();
});
});
describe('getReviewSeverity', () => {
it('should get alert severity', () => {
expect(getReviewSeverity('alert')).toBe('high');
});
it('should get detection severity', () => {
expect(getReviewSeverity('detection')).toBe('medium');
});
it('should get significant_motion severity', () => {
expect(getReviewSeverity('significant_motion')).toBe('low');
});
});
@@ -1,6 +1,12 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { FrigateEventWatcher } from '../../../src/camera-manager/frigate/event-watcher.js';
import { FrigateEventChange } from '../../../src/camera-manager/frigate/types.js';
import {
FrigateEventChange,
FrigateReviewChange,
} from '../../../src/camera-manager/frigate/types.js';
import {
FrigateEventWatcher,
FrigateReviewWatcher,
} from '../../../src/camera-manager/frigate/watcher.js';
import { HomeAssistant } from '../../../src/ha/types.js';
import { createHASS } from '../../test-utils.js';
@@ -25,7 +31,41 @@ const createEventChange = (): FrigateEventChange => {
},
};
};
const createReviewChange = (): FrigateReviewChange => {
return {
type: 'new',
before: {
id: '123',
camera: 'front_door',
severity: 'alert',
start_time: 123,
end_time: null,
thumb_path: null,
has_been_reviewed: false,
data: {
metadata: {
title: 'Title before',
scene: 'Scene before',
},
},
},
after: {
id: '123',
camera: 'front_door',
severity: 'alert',
start_time: 123,
end_time: null,
thumb_path: null,
has_been_reviewed: false,
data: {
metadata: {
title: 'Title after',
scene: 'Scene after',
},
},
},
};
};
const callHASubscribeMessageCallback = (
hass: HomeAssistant,
data: unknown,
@@ -99,7 +139,7 @@ describe('FrigateEventWatcher', () => {
expect(callback).not.toBeCalled();
expect(spy).toBeCalledWith(
'Received non-JSON payload as Frigate event',
'Received non-JSON payload from subscription: frigate/events/subscribe',
'NOT_JSON',
);
});
@@ -122,7 +162,7 @@ describe('FrigateEventWatcher', () => {
expect(callback).not.toBeCalled();
expect(spy).toBeCalledWith(
'Received malformed Frigate event from Home Assistant',
'Received malformed message from subscription: frigate/events/subscribe',
data,
);
});
@@ -199,3 +239,95 @@ describe('FrigateEventWatcher', () => {
});
});
});
describe('FrigateReviewWatcher', () => {
it('should subscribe to a given topic once', async () => {
const stateWatcher = new FrigateReviewWatcher();
const hass = createHASS();
await stateWatcher.subscribe(hass, {
instanceID: 'frigate',
callback: vi.fn(),
});
await stateWatcher.subscribe(hass, {
instanceID: 'frigate',
callback: vi.fn(),
});
expect(hass.connection.subscribeMessage).toBeCalledWith(
expect.any(Function),
expect.objectContaining({
type: 'frigate/reviews/subscribe',
}),
);
expect(hass.connection.subscribeMessage).toBeCalledTimes(1);
});
describe('should call handler', () => {
afterEach(() => {
vi.resetAllMocks();
});
it('with a review change', async () => {
const stateWatcher = new FrigateReviewWatcher();
const hass = createHASS();
const callback = vi.fn();
const request = {
instanceID: 'frigate',
callback: callback,
};
await stateWatcher.subscribe(hass, request);
const reviewChange = createReviewChange();
callHASubscribeMessageCallback(hass, JSON.stringify(reviewChange));
expect(callback).toBeCalledWith(reviewChange);
});
it('with a genai review change', async () => {
const stateWatcher = new FrigateReviewWatcher();
const hass = createHASS();
const callback = vi.fn();
const request = {
instanceID: 'frigate',
callback: callback,
};
await stateWatcher.subscribe(hass, request);
const reviewChange = createReviewChange();
reviewChange.type = 'genai';
callHASubscribeMessageCallback(hass, JSON.stringify(reviewChange));
expect(callback).toBeCalledWith(reviewChange);
});
it('with invalid JSON', async () => {
const spy = vi.spyOn(global.console, 'warn').mockImplementation(() => true);
const stateWatcher = new FrigateReviewWatcher();
const hass = createHASS();
const callback = vi.fn();
const request = {
instanceID: 'frigate',
callback: callback,
};
await stateWatcher.subscribe(hass, request);
callHASubscribeMessageCallback(hass, 'NOT_JSON');
expect(callback).not.toBeCalled();
expect(spy).toBeCalledWith(
'Received non-JSON payload from subscription: frigate/reviews/subscribe',
'NOT_JSON',
);
});
});
});
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { GenericCameraManagerEngine } from '../../../src/camera-manager/generic/engine-generic';
import { Engine, QueryResultsType, QueryType } from '../../../src/camera-manager/types';
import { QuerySource } from '../../../src/query-source';
import { StateWatcherSubscriptionInterface } from '../../../src/card-controller/hass/state-watcher';
import { CameraConfig } from '../../../src/config/schema/cameras';
import { RawAdvancedCameraCardConfig } from '../../../src/config/types';
@@ -43,6 +44,14 @@ describe('GenericCameraManagerEngine', () => {
expect(camera.getCapabilities()?.has('trigger')).toBeTruthy();
});
it('should get default query parameters', async () => {
const config = createGenericCameraConfig();
const camera = await createEngine().createCamera(createHASS(), config);
expect(createEngine().getDefaultQueryParameters(camera, QueryType.Event)).toEqual(
{},
);
});
it('should generate default event query', () => {
const engine = createEngine();
expect(
@@ -82,7 +91,11 @@ describe('GenericCameraManagerEngine', () => {
await engine.getEvents(
createHASS(),
createStore([{ cameraID: 'camera-1', engine: engine }]),
{ type: QueryType.Event, cameraIDs: new Set(['camera-1']) },
{
source: QuerySource.Camera,
type: QueryType.Event,
cameraIDs: new Set(['camera-1']),
},
),
).toBeNull();
});
@@ -93,7 +106,11 @@ describe('GenericCameraManagerEngine', () => {
await engine.getRecordings(
createHASS(),
createStore([{ cameraID: 'camera-1', engine: engine }]),
{ type: QueryType.Recording, cameraIDs: new Set(['camera-1']) },
{
source: QuerySource.Camera,
type: QueryType.Recording,
cameraIDs: new Set(['camera-1']),
},
),
).toBeNull();
});
@@ -114,6 +131,32 @@ describe('GenericCameraManagerEngine', () => {
).toBeNull();
});
it('should generate default review query', () => {
const engine = createEngine();
expect(
engine.generateDefaultReviewQuery(
createStore([{ cameraID: 'camera-1', engine: engine }]),
new Set(['camera-1']),
{},
),
).toBeNull();
});
it('should get reviews', async () => {
const engine = createEngine();
expect(
await engine.getReviews(
createHASS(),
createStore([{ cameraID: 'camera-1', engine: engine }]),
{
source: QuerySource.Camera,
type: QueryType.Review,
cameraIDs: new Set(['camera-1']),
},
),
).toBeNull();
});
it('should generate media from events', async () => {
const engine = createEngine();
expect(
@@ -121,6 +164,7 @@ describe('GenericCameraManagerEngine', () => {
createHASS(),
createStore([{ cameraID: 'camera-1', engine: engine }]),
{
source: QuerySource.Camera,
type: QueryType.Event,
cameraIDs: new Set(['camera-1']),
},
@@ -139,6 +183,7 @@ describe('GenericCameraManagerEngine', () => {
createHASS(),
createStore([{ cameraID: 'camera-1', engine: engine }]),
{
source: QuerySource.Camera,
type: QueryType.Recording,
cameraIDs: new Set(['camera-1']),
start: new Date(),
@@ -152,6 +197,25 @@ describe('GenericCameraManagerEngine', () => {
).toBeNull();
});
it('should generate media from reviews', async () => {
const engine = createEngine();
expect(
engine.generateMediaFromReviews(
createHASS(),
createStore([{ cameraID: 'camera-1', engine: engine }]),
{
source: QuerySource.Camera,
type: QueryType.Review,
cameraIDs: new Set(['camera-1']),
},
{
type: QueryResultsType.Review,
engine: Engine.Generic,
},
),
).toBeNull();
});
it('should get media download path', async () => {
expect(
await createEngine().getMediaDownloadPath(
@@ -173,6 +237,17 @@ describe('GenericCameraManagerEngine', () => {
).toBeUndefined();
});
it('should review media', async () => {
expect(
await createEngine().reviewMedia(
createHASS(),
createGenericCameraConfig(),
new TestViewMedia(),
true,
),
).toBeUndefined();
});
it('should get query result max age', () => {
expect(
createEngine().getQueryResultMaxAge({
+108 -11
View File
@@ -11,8 +11,6 @@ import {
QueryResultClassifier,
} from '../../src/camera-manager/manager.js';
import {
CameraEndpoints,
CameraEndpointsContext,
CameraEvent,
CameraManagerCameraMetadata,
Engine,
@@ -27,23 +25,24 @@ import { CardController } from '../../src/card-controller/controller.js';
import { sortItems } from '../../src/card-controller/view/sort.js';
import { CameraConfig } from '../../src/config/schema/cameras.js';
import { HomeAssistant } from '../../src/ha/types.js';
import { QuerySource } from '../../src/query-source.js';
import { Endpoint, PTZMovementType } from '../../src/types.js';
import { ViewFolder, ViewItem, ViewMedia } from '../../src/view/item.js';
import { ViewItemCapabilities } from '../../src/view/types.js';
import {
TestViewMedia,
createInitializedCamera,
createCameraConfig,
createCapabilities,
createCardAPI,
createConfig,
createFolder,
createHASS,
createInitializedCamera,
generateViewMediaArray,
} from '../test-utils.js';
describe('QueryClassifier', async () => {
it('should classify event query', async () => {
describe('QueryClassifier', () => {
it('should classify event query', () => {
expect(CameraQueryClassifier.isEventQuery({ type: QueryType.Event })).toBeTruthy();
expect(
CameraQueryClassifier.isEventQuery({ type: QueryType.Recording }),
@@ -55,7 +54,7 @@ describe('QueryClassifier', async () => {
CameraQueryClassifier.isEventQuery({ type: QueryType.MediaMetadata }),
).toBeFalsy();
});
it('should classify recording query', async () => {
it('should classify recording query', () => {
expect(
CameraQueryClassifier.isRecordingQuery({ type: QueryType.Event }),
).toBeFalsy();
@@ -69,7 +68,7 @@ describe('QueryClassifier', async () => {
CameraQueryClassifier.isRecordingQuery({ type: QueryType.MediaMetadata }),
).toBeFalsy();
});
it('should classify recording segments query', async () => {
it('should classify recording segments query', () => {
expect(
CameraQueryClassifier.isRecordingSegmentsQuery({ type: QueryType.Event }),
).toBeFalsy();
@@ -85,7 +84,7 @@ describe('QueryClassifier', async () => {
CameraQueryClassifier.isRecordingSegmentsQuery({ type: QueryType.MediaMetadata }),
).toBeFalsy();
});
it('should classify media metadata query', async () => {
it('should classify media metadata query', () => {
expect(
CameraQueryClassifier.isMediaMetadataQuery({ type: QueryType.Event }),
).toBeFalsy();
@@ -99,6 +98,19 @@ describe('QueryClassifier', async () => {
CameraQueryClassifier.isMediaMetadataQuery({ type: QueryType.MediaMetadata }),
).toBeTruthy();
});
it('should classify review query', () => {
expect(CameraQueryClassifier.isReviewQuery({ type: QueryType.Event })).toBeFalsy();
expect(
CameraQueryClassifier.isReviewQuery({ type: QueryType.Recording }),
).toBeFalsy();
expect(
CameraQueryClassifier.isReviewQuery({ type: QueryType.RecordingSegments }),
).toBeFalsy();
expect(
CameraQueryClassifier.isReviewQuery({ type: QueryType.MediaMetadata }),
).toBeFalsy();
expect(CameraQueryClassifier.isReviewQuery({ type: QueryType.Review })).toBeTruthy();
});
});
describe('QueryResultClassifier', async () => {
@@ -195,9 +207,32 @@ describe('QueryResultClassifier', async () => {
),
).toBeTruthy();
});
it('should classify review query result', async () => {
expect(
QueryResultClassifier.isReviewQueryResult(createResults(QueryResultsType.Event)),
).toBeFalsy();
expect(
QueryResultClassifier.isReviewQueryResult(
createResults(QueryResultsType.Recording),
),
).toBeFalsy();
expect(
QueryResultClassifier.isReviewQueryResult(
createResults(QueryResultsType.RecordingSegments),
),
).toBeFalsy();
expect(
QueryResultClassifier.isReviewQueryResult(
createResults(QueryResultsType.MediaMetadata),
),
).toBeFalsy();
expect(
QueryResultClassifier.isReviewQueryResult(createResults(QueryResultsType.Review)),
).toBeTruthy();
});
});
describe('CameraManager', async () => {
describe('CameraManager', () => {
const baseCameraConfig = {
id: 'id',
camera_entity: 'camera.foo',
@@ -205,6 +240,7 @@ describe('CameraManager', async () => {
};
const baseEventQuery: EventQuery = {
source: QuerySource.Camera,
type: QueryType.Event as const,
cameraIDs: new Set(['id']),
};
@@ -215,6 +251,7 @@ describe('CameraManager', async () => {
};
const baseRecordingQuery = {
source: QuerySource.Camera as const,
type: QueryType.Recording as const,
cameraIDs: new Set(['id']),
};
@@ -266,7 +303,7 @@ describe('CameraManager', async () => {
return new CameraManager(api, { factory: mockFactory });
};
it('should construct', async () => {
it('should construct', () => {
const manager = new CameraManager(createCardAPI());
expect(manager.getStore()).toBeTruthy();
});
@@ -440,6 +477,11 @@ describe('CameraManager', async () => {
'generateDefaultRecordingSegmentsQuery',
'generateDefaultRecordingSegmentsQueries',
],
[
QueryType.Review as const,
'generateDefaultReviewQuery',
'generateDefaultReviewQueries',
],
])(
'basic %s',
async (
@@ -482,6 +524,33 @@ describe('CameraManager', async () => {
});
});
describe('getDefaultQueryParameters', () => {
it('should return empty object for non-existent camera', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = createCameraManager(api, mock<CameraManagerEngine>());
expect(
manager.getDefaultQueryParameters('not_a_camera', QueryType.Event),
).toEqual({});
});
it('should return parameters from engine for existing camera', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const engine = mock<CameraManagerEngine>();
const manager = createCameraManager(api, engine);
expect(await manager.initializeCamerasFromConfig()).toBeTruthy();
engine.getDefaultQueryParameters.mockReturnValue({ what: new Set(['person']) });
expect(manager.getDefaultQueryParameters('id', QueryType.Event)).toEqual({
what: new Set(['person']),
});
});
});
it('should merge defaults correctly', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
@@ -590,6 +659,34 @@ describe('CameraManager', async () => {
});
});
describe('should review media', () => {
it('without camera', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = createCameraManager(api);
const media = new TestViewMedia();
await manager.reviewMedia(media, true);
});
it('successfully', async () => {
const api = createCardAPI();
const hass = createHASS();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
const engine = mock<CameraManagerEngine>();
vi.mocked(engine.getEngineType).mockReturnValue(Engine.Generic);
const manager = createCameraManager(api, engine);
expect(await manager.initializeCamerasFromConfig()).toBeTruthy();
const media = new TestViewMedia({ cameraID: 'id' });
await manager.reviewMedia(media, true);
expect(engine.reviewMedia).toBeCalledWith(hass, expect.anything(), media, true);
});
});
describe('should get recordings', () => {
it('successfully', async () => {
const api = createCardAPI();
@@ -740,7 +837,7 @@ describe('CameraManager', async () => {
new TestViewMedia({
startTime: add(dateBase, { days: 2 }),
}),
new ViewFolder(createFolder()),
new ViewFolder(createFolder(), []),
];
it('without hass', async () => {
@@ -23,6 +23,7 @@ import {
QueryReturnType,
QueryType,
} from '../../../src/camera-manager/types';
import { QuerySource } from '../../../src/query-source';
import { StateWatcher } from '../../../src/card-controller/hass/state-watcher';
import { BrowseMedia, browseMediaSchema } from '../../../src/ha/browse-media/types';
import { BrowseMediaWalker } from '../../../src/ha/browse-media/walker';
@@ -340,6 +341,7 @@ describe('ReolinkCameraManagerEngine', () => {
expect(
await engine.getEvents(createHASS(), createStore(), {
...query,
source: QuerySource.Camera,
cameraIDs: new Set(['office']),
type: QueryType.Event,
}),
@@ -360,6 +362,7 @@ describe('ReolinkCameraManagerEngine', () => {
createHASS(),
store,
{
source: QuerySource.Camera,
type: QueryType.Event,
cameraIDs: new Set(['office']),
start: new Date('2024-11-04T21:00:00'),
@@ -377,6 +380,7 @@ describe('ReolinkCameraManagerEngine', () => {
cameraIDs: new Set(['office']),
end: new Date('2024-11-04T22:00:00'),
start: new Date('2024-11-04T21:00:00'),
source: 'camera',
type: 'event-query',
},
{
@@ -473,6 +477,7 @@ describe('ReolinkCameraManagerEngine', () => {
createHASS(),
store,
{
source: QuerySource.Camera,
type: QueryType.Event,
cameraIDs: new Set(['office']),
start: new Date('2024-11-04T21:00:00'),
@@ -510,6 +515,7 @@ describe('ReolinkCameraManagerEngine', () => {
const hass = createHASS();
await engine.getEvents(hass, store, {
source: QuerySource.Camera,
type: QueryType.Event,
cameraIDs: new Set(['office']),
start: new Date('2024-11-04T21:00:00'),
@@ -561,6 +567,7 @@ describe('ReolinkCameraManagerEngine', () => {
});
const events = await engine.getEvents(createHASS(), store, {
source: QuerySource.Camera,
type: QueryType.Event,
cameraIDs: new Set(['office']),
start: new Date('2024-11-04T21:00:00'),
@@ -574,6 +581,7 @@ describe('ReolinkCameraManagerEngine', () => {
cameraIDs: new Set(['office']),
end: new Date('2024-11-04T22:00:00'),
start: new Date('2024-11-04T21:00:00'),
source: 'camera',
type: 'event-query',
},
{
@@ -603,6 +611,7 @@ describe('ReolinkCameraManagerEngine', () => {
const store = await createStoreWithReolinkCamera(engine);
const events = await engine.getEvents(createHASS(), store, {
source: QuerySource.Camera,
type: QueryType.Event,
cameraIDs: new Set(['office']),
start: new Date('2024-11-04T21:00:00'),
@@ -616,6 +625,7 @@ describe('ReolinkCameraManagerEngine', () => {
cameraIDs: new Set(['office']),
end: new Date('2024-11-04T22:00:00'),
start: new Date('2024-11-04T21:00:00'),
source: 'camera',
type: 'event-query',
},
{
@@ -638,6 +648,7 @@ describe('ReolinkCameraManagerEngine', () => {
const hass = createHASS();
const events = await engine.getEvents(hass, store, {
source: QuerySource.Camera,
type: QueryType.Event,
cameraIDs: new Set(['office']),
});
@@ -647,6 +658,7 @@ describe('ReolinkCameraManagerEngine', () => {
[
{
cameraIDs: new Set(['office']),
source: 'camera',
type: 'event-query',
},
{
@@ -678,6 +690,7 @@ describe('ReolinkCameraManagerEngine', () => {
});
const events = await engine.getEvents(createHASS(), store, {
source: QuerySource.Camera,
type: QueryType.Event,
cameraIDs: new Set(['office']),
start: new Date('2024-11-04T21:00:00'),
@@ -691,6 +704,7 @@ describe('ReolinkCameraManagerEngine', () => {
cameraIDs: new Set(['office']),
end: new Date('2024-11-04T22:00:00'),
start: new Date('2024-11-04T21:00:00'),
source: 'camera',
type: 'event-query',
},
{
@@ -731,6 +745,7 @@ describe('ReolinkCameraManagerEngine', () => {
});
const events = await engine.getEvents(createHASS(), store, {
source: QuerySource.Camera,
type: QueryType.Event,
cameraIDs: new Set(['office']),
start: new Date('2024-11-04T21:00:00'),
@@ -744,6 +759,7 @@ describe('ReolinkCameraManagerEngine', () => {
cameraIDs: new Set(['office']),
end: new Date('2024-11-04T22:00:00'),
start: new Date('2024-11-04T21:00:00'),
source: 'camera',
type: 'event-query',
},
{
@@ -792,6 +808,7 @@ describe('ReolinkCameraManagerEngine', () => {
});
const events = await engine.getEvents(createHASS(), store, {
source: QuerySource.Camera,
type: QueryType.Event,
cameraIDs: new Set(['office']),
start: new Date('2024-11-04T21:00:00'),
@@ -805,6 +822,7 @@ describe('ReolinkCameraManagerEngine', () => {
cameraIDs: new Set(['office']),
end: new Date('2024-11-04T22:00:00'),
start: new Date('2024-11-04T21:00:00'),
source: 'camera',
type: 'event-query',
},
{
@@ -851,6 +869,7 @@ describe('ReolinkCameraManagerEngine', () => {
});
const events = await engine.getEvents(createHASS(), store, {
source: QuerySource.Camera,
type: QueryType.Event,
cameraIDs: new Set(['office']),
start: new Date('2024-11-04T21:00:00'),
@@ -864,6 +883,7 @@ describe('ReolinkCameraManagerEngine', () => {
cameraIDs: new Set(['office']),
end: new Date('2024-11-04T22:00:00'),
start: new Date('2024-11-04T21:00:00'),
source: 'camera',
type: 'event-query',
},
{
@@ -910,6 +930,7 @@ describe('ReolinkCameraManagerEngine', () => {
});
const events = await engine.getEvents(createHASS(), store, {
source: QuerySource.Camera,
type: QueryType.Event,
cameraIDs: new Set(['office']),
start: new Date('2024-11-04T21:00:00'),
@@ -923,6 +944,7 @@ describe('ReolinkCameraManagerEngine', () => {
cameraIDs: new Set(['office']),
end: new Date('2024-11-04T22:00:00'),
start: new Date('2024-11-04T21:00:00'),
source: 'camera',
type: 'event-query',
},
{
@@ -958,6 +980,7 @@ describe('ReolinkCameraManagerEngine', () => {
describe('should generate media from events', () => {
it('should generate media successfully', () => {
const query: EventQuery = {
source: QuerySource.Camera,
type: QueryType.Event,
cameraIDs: new Set(['office']),
start: new Date('2024-11-04T21:00:00'),
@@ -1004,6 +1027,7 @@ describe('ReolinkCameraManagerEngine', () => {
it('should reject non-reolink results', () => {
const query: EventQuery = {
source: QuerySource.Camera,
type: QueryType.Event,
cameraIDs: new Set(['office']),
start: new Date('2024-11-04T21:00:00'),
@@ -0,0 +1,94 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { supports2WayAudio } from '../../../src/camera-manager/utils/go2rtc/audio';
import { homeAssistantSignAndFetch } from '../../../src/ha/fetch';
import { HomeAssistant } from '../../../src/ha/types';
import { createProxiedEndpointIfNecessary } from '../../../src/ha/web-proxy';
import { Endpoint } from '../../../src/types';
vi.mock('../../../src/ha/fetch');
vi.mock('../../../src/ha/web-proxy');
describe('supports2WayAudio', () => {
const hass = mock<HomeAssistant>();
const endpoint: Endpoint = { endpoint: 'http://go2rtc', sign: true };
beforeEach(() => {
vi.clearAllMocks();
});
it('should return false if no endpoint provided', async () => {
expect(await supports2WayAudio(hass, null)).toBe(false);
});
it('should return false if fetch fails', async () => {
vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue(endpoint);
vi.mocked(homeAssistantSignAndFetch).mockRejectedValue(new Error('fetch error'));
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = await supports2WayAudio(hass, endpoint);
expect(result).toBe(false);
expect(spy).toHaveBeenCalledWith('fetch error');
spy.mockRestore();
});
it('should return false if stream info has no producers', async () => {
vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue(endpoint);
vi.mocked(homeAssistantSignAndFetch).mockResolvedValue({ producers: undefined });
expect(await supports2WayAudio(hass, endpoint)).toBe(false);
});
it('should return false if no producer supports audio', async () => {
vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue(endpoint);
vi.mocked(homeAssistantSignAndFetch).mockResolvedValue({
producers: [
{
medias: ['video,sendonly,h264'],
},
],
});
expect(await supports2WayAudio(hass, endpoint)).toBe(false);
});
it('should return true if producer supports audio and sendonly', async () => {
vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue(endpoint);
vi.mocked(homeAssistantSignAndFetch).mockResolvedValue({
producers: [
{
medias: ['audio,sendonly,opus'],
},
],
});
expect(await supports2WayAudio(hass, endpoint)).toBe(true);
});
it('should return true if producer supports audio and sendrecv', async () => {
vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue(endpoint);
vi.mocked(homeAssistantSignAndFetch).mockResolvedValue({
producers: [
{
medias: ['audio,sendrecv,pcmu'],
},
],
});
expect(await supports2WayAudio(hass, endpoint)).toBe(true);
});
it('should handle missing medias in producer', async () => {
vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue(endpoint);
vi.mocked(homeAssistantSignAndFetch).mockResolvedValue({
producers: [
{
medias: undefined,
},
],
});
expect(await supports2WayAudio(hass, endpoint)).toBe(false);
});
});
+1 -1
View File
@@ -4,7 +4,7 @@ import {
getConfiguredPTZMovementType,
getPTZCapabilitiesFromCameraConfig,
} from '../../../src/camera-manager/utils/ptz';
import { PTZAction } from '../../../src/config/ptz';
import { PTZAction } from '../../../src/config/schema/actions/custom/ptz';
import { createCameraConfig } from '../../test-utils';
const action = {
@@ -6,7 +6,6 @@ describe('should handle camera_select action', () => {
it('with valid camera and view', async () => {
const api = createCardAPI();
vi.mocked(api.getViewManager().getView).mockReturnValue(createView());
vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true);
const action = new CameraSelectAction(
{},
@@ -37,7 +36,6 @@ describe('should handle camera_select action', () => {
camera: 'camera.office',
}),
);
vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true);
const action = new CameraSelectAction(
{},
@@ -61,7 +59,6 @@ describe('should handle camera_select action', () => {
view: 'timeline',
}),
);
vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true);
const action = new CameraSelectAction(
{},
@@ -100,7 +97,6 @@ describe('should handle camera_select action', () => {
view: 'live',
}),
);
vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true);
const action = new CameraSelectAction(
{},
@@ -127,7 +123,6 @@ describe('should handle camera_select action', () => {
it('with triggered camera', async () => {
const api = createCardAPI();
vi.mocked(api.getViewManager().getView).mockReturnValue(createView());
vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true);
vi.mocked(api.getTriggersManager().getMostRecentlyTriggeredCameraID).mockReturnValue(
'camera.office',
);
@@ -157,7 +152,6 @@ describe('should handle camera_select action', () => {
it('without camera or triggered camera', async () => {
const api = createCardAPI();
vi.mocked(api.getViewManager().getView).mockReturnValue(createView());
vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true);
vi.mocked(api.getTriggersManager().getMostRecentlyTriggeredCameraID).mockReturnValue(
'camera.office',
);
@@ -0,0 +1,50 @@
import { describe, expect, it, vi } from 'vitest';
import { InfoAction } from '../../../../src/card-controller/actions/actions/info';
import { QueryResults } from '../../../../src/view/query-results';
import { View } from '../../../../src/view/view';
import { createCardAPI, TestViewMedia } from '../../../test-utils';
describe('InfoAction', () => {
it('should handle info action with media', async () => {
const api = createCardAPI();
const item = new TestViewMedia();
const view = new View({
view: 'clip',
camera: 'camera',
queryResults: new QueryResults({
results: [item],
selectedIndex: 0,
}),
});
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const action = new InfoAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'info',
},
);
await action.execute(api);
expect(api.getOverlayMessageManager().setMessage).toBeCalled();
});
it('should not handle info action without media', async () => {
const api = createCardAPI();
vi.mocked(api.getViewManager().getView).mockReturnValue(null);
const action = new InfoAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'info',
},
);
await action.execute(api);
expect(api.getOverlayMessageManager().setMessage).not.toBeCalled();
});
});
@@ -4,7 +4,7 @@ import {
PartialZoomSettings,
ZoomSettingsObserved,
} from '../../../../src/components-lib/zoom/types';
import { PTZAction } from '../../../../src/config/ptz';
import { PTZAction } from '../../../../src/config/schema/actions/custom/ptz';
import { createCardAPI, createView } from '../../../test-utils';
describe('should handle ptz digital action', () => {
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
import { Capabilities } from '../../../../src/camera-manager/capabilities';
import { PTZMultiAction } from '../../../../src/card-controller/actions/actions/ptz-multi';
import { PTZMovementType } from '../../../../src/types';
import {
createCameraManager,
createCardAPI,
@@ -23,7 +24,7 @@ describe('should handle ptz multi action', () => {
const store = createStore([
{
cameraID: 'camera.office',
capabilities: new Capabilities({ ptz: { left: ['relative'] } }),
capabilities: new Capabilities({ ptz: { left: [PTZMovementType.Relative] } }),
},
]);
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store));
@@ -0,0 +1,142 @@
import { assert, describe, expect, it, vi } from 'vitest';
import { SetReviewAction } from '../../../../src/card-controller/actions/actions/set-review';
import { ViewMedia, ViewMediaType } from '../../../../src/view/item';
import { QueryResults } from '../../../../src/view/query-results';
import { createCardAPI, createView, TestViewMedia } from '../../../test-utils';
describe('SetReviewAction', () => {
it('should mark item as reviewed', async () => {
const api = createCardAPI();
const item = new TestViewMedia({
cameraID: 'camera.office',
mediaType: 'review' as ViewMediaType,
reviewed: false,
});
const queryResults = new QueryResults({ results: [item], selectedIndex: 0 });
const view = createView({ queryResults });
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const action = new SetReviewAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'set_review',
reviewed: true,
},
);
await action.execute(api);
expect(api.getViewItemManager().reviewMedia).toBeCalledWith(item, true);
// Original item is NOT mutated; a clone is created and replaced.
expect(item.isReviewed()).toBe(false);
const setViewParams = vi.mocked(api.getViewManager().setViewByParameters).mock
.calls[0][0];
const newResults = setViewParams?.params?.queryResults;
expect(newResults).toBeInstanceOf(QueryResults);
const newItem = newResults?.getSelectedResult();
expect(newItem).not.toBe(item);
assert(newItem instanceof ViewMedia);
expect(newItem?.isReviewed()).toBe(true);
});
it('should toggle review status when reviewed is not specified', async () => {
const api = createCardAPI();
const item = new TestViewMedia({
cameraID: 'camera.office',
mediaType: 'review' as ViewMediaType,
reviewed: true,
});
const queryResults = new QueryResults({ results: [item], selectedIndex: 0 });
const view = createView({ queryResults });
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const action = new SetReviewAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'set_review',
},
);
await action.execute(api);
// Toggle: isReviewed was true, so reviewed should be set to false.
expect(api.getViewItemManager().reviewMedia).toBeCalledWith(item, false);
// Original item is NOT mutated; a clone is created and replaced.
expect(item.isReviewed()).toBe(true);
const setViewParams = vi.mocked(api.getViewManager().setViewByParameters).mock
.calls[0][0];
const newResults = setViewParams?.params?.queryResults;
expect(newResults).toBeInstanceOf(QueryResults);
const newItem = newResults?.getSelectedResult();
expect(newItem).not.toBe(item);
assert(newItem instanceof ViewMedia);
expect(newItem?.isReviewed()).toBe(false);
});
it('should not act on non-review media', async () => {
const api = createCardAPI();
const item = new TestViewMedia({
cameraID: 'camera.office',
mediaType: ViewMediaType.Clip,
});
const queryResults = new QueryResults({ results: [item], selectedIndex: 0 });
const view = createView({ queryResults });
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const action = new SetReviewAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'set_review',
reviewed: true,
},
);
await action.execute(api);
expect(api.getViewItemManager().reviewMedia).not.toBeCalled();
});
it('should not act without a view', async () => {
const api = createCardAPI();
vi.mocked(api.getViewManager().getView).mockReturnValue(null);
const action = new SetReviewAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'set_review',
reviewed: true,
},
);
await action.execute(api);
expect(api.getViewItemManager().reviewMedia).not.toBeCalled();
});
it('should not act without query results', async () => {
const api = createCardAPI();
const view = createView();
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const action = new SetReviewAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'set_review',
reviewed: true,
},
);
await action.execute(api);
expect(api.getViewItemManager().reviewMedia).not.toBeCalled();
});
});
@@ -6,8 +6,10 @@ import { CustomAction } from '../../../src/card-controller/actions/actions/custo
import { DefaultAction } from '../../../src/card-controller/actions/actions/default';
import { DisplayModeSelectAction } from '../../../src/card-controller/actions/actions/display-mode-select';
import { DownloadAction } from '../../../src/card-controller/actions/actions/download';
import { EffectAction } from '../../../src/card-controller/actions/actions/effect';
import { ExpandAction } from '../../../src/card-controller/actions/actions/expand';
import { FullscreenAction } from '../../../src/card-controller/actions/actions/fullscreen';
import { InfoAction } from '../../../src/card-controller/actions/actions/info';
import { InternalCallbackAction } from '../../../src/card-controller/actions/actions/internal-callback';
import { LogAction } from '../../../src/card-controller/actions/actions/log';
import { MediaPlayerAction } from '../../../src/card-controller/actions/actions/media-player';
@@ -29,6 +31,7 @@ import { PTZDigitalAction } from '../../../src/card-controller/actions/actions/p
import { PTZMultiAction } from '../../../src/card-controller/actions/actions/ptz-multi';
import { ReloadAction } from '../../../src/card-controller/actions/actions/reload';
import { ScreenshotAction } from '../../../src/card-controller/actions/actions/screenshot';
import { SetReviewAction } from '../../../src/card-controller/actions/actions/set-review';
import { SleepAction } from '../../../src/card-controller/actions/actions/sleep';
import { StatusBarAction } from '../../../src/card-controller/actions/actions/status-bar';
import { SubstreamOffAction } from '../../../src/card-controller/actions/actions/substream-off';
@@ -41,7 +44,6 @@ import { ViewAction } from '../../../src/card-controller/actions/actions/view';
import { ActionFactory } from '../../../src/card-controller/actions/factory';
import { INTERNAL_CALLBACK_ACTION } from '../../../src/config/schema/actions/custom/internal';
import { ActionConfig } from '../../../src/config/schema/actions/types';
import { EffectAction } from '../../../src/card-controller/actions/actions/effect';
// @vitest-environment jsdom
describe('ActionFactory', () => {
@@ -101,6 +103,7 @@ describe('ActionFactory', () => {
[{ advanced_camera_card_action: 'folder' as const }, ViewAction],
[{ advanced_camera_card_action: 'folders' as const }, ViewAction],
[{ advanced_camera_card_action: 'fullscreen' as const }, FullscreenAction],
[{ advanced_camera_card_action: 'info' as const }, InfoAction],
[{ advanced_camera_card_action: 'image' as const }, ViewAction],
[
{ advanced_camera_card_action: 'live_substream_off' as const },
@@ -189,6 +192,7 @@ describe('ActionFactory', () => {
InternalCallbackAction,
],
[{ advanced_camera_card_action: 'reload' as const }, ReloadAction],
[{ advanced_camera_card_action: 'set_review' as const }, SetReviewAction],
[{ advanced_camera_card_action: 'effect' as const }, EffectAction],
])(
'advanced_camera_card_action: $advanced_camera_card_action',
@@ -2,12 +2,15 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { CardElementManager } from '../../src/card-controller/card-element-manager';
import { StateWatcher } from '../../src/card-controller/hass/state-watcher';
import { QueryResults } from '../../src/view/query-results';
import {
callStateWatcherCallback,
createCardAPI,
createConfig,
createLitElement,
createStateEntity,
createView,
TestViewMedia,
} from '../test-utils';
// @vitest-environment jsdom
@@ -260,5 +263,75 @@ describe('CardElementManager', () => {
expect(element.requestUpdate).toBeCalled();
});
it('selected media review status changes', () => {
const api = createCardAPI();
const selectedMedia = new TestViewMedia({ id: 'media-1' });
const queryResults = new QueryResults({
results: [selectedMedia],
selectedIndex: 0,
});
const view = createView({ queryResults });
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const element = createLitElement();
const manager = new CardElementManager(
api,
element,
() => undefined,
() => undefined,
);
manager.elementConnected();
// Clear any previous calls from elementConnected.
vi.mocked(element.requestUpdate).mockClear();
// Dispatch the media reviewed event with the selected media item.
element.dispatchEvent(
new CustomEvent('advanced-camera-card:media:reviewed', {
detail: selectedMedia,
}),
);
expect(element.requestUpdate).toBeCalled();
});
it('non-selected media review status changes does not update', () => {
const api = createCardAPI();
const selectedMedia = new TestViewMedia({ id: 'media-1' });
const otherMedia = new TestViewMedia({ id: 'media-2' });
const queryResults = new QueryResults({
results: [selectedMedia, otherMedia],
selectedIndex: 0,
});
const view = createView({ queryResults });
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const element = createLitElement();
const manager = new CardElementManager(
api,
element,
() => undefined,
() => undefined,
);
manager.elementConnected();
// Clear any previous calls from elementConnected.
vi.mocked(element.requestUpdate).mockClear();
// Dispatch the media reviewed event with a DIFFERENT media item.
element.dispatchEvent(
new CustomEvent('advanced-camera-card:media:reviewed', {
detail: otherMedia,
}),
);
// Should NOT update because the reviewed item is not the selected item.
expect(element.requestUpdate).not.toBeCalled();
});
});
});
@@ -1,8 +1,8 @@
import { describe, expect, it, vi } from 'vitest';
import { createCardAPI, createConfig } from '../../test-utils';
import { setKeyboardShortcutsFromConfig } from '../../../src/card-controller/config/load-keyboard-shortcuts';
import { PTZKeyboardShortcutName } from '../../../src/config/keyboard-shortcuts';
import { PTZAction } from '../../../src/config/ptz';
import { PTZAction } from '../../../src/config/schema/actions/custom/ptz';
import { PTZKeyboardShortcutName } from '../../../src/config/schema/view';
import { createCardAPI, createConfig } from '../../test-utils';
describe('setKeyboardShortcutsFromConfig', () => {
it('without shortcuts', () => {
+9 -1
View File
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { CameraManager } from '../../src/camera-manager/manager';
import { ActionsManager } from '../../src/card-controller/actions/actions-manager';
import { AutomationsManager } from '../../src/card-controller/automations-manager';
@@ -21,6 +22,7 @@ import { MediaLoadedInfoManager } from '../../src/card-controller/media-info-man
import { MediaPlayerManager } from '../../src/card-controller/media-player-manager';
import { MessageManager } from '../../src/card-controller/message-manager';
import { MicrophoneManager } from '../../src/card-controller/microphone-manager';
import { OverlayMessageManager } from '../../src/card-controller/overlay-message-manager';
import { QueryStringManager } from '../../src/card-controller/query-string-manager';
import { StatusBarItemManager } from '../../src/card-controller/status-bar-item-manager';
import { StyleManager } from '../../src/card-controller/style-manager';
@@ -33,7 +35,6 @@ import { DeviceRegistryManager } from '../../src/ha/registry/device';
import { EntityRegistryManagerLive } from '../../src/ha/registry/entity';
import { ResolvedMediaCache } from '../../src/ha/resolved-media';
import { EffectsControllerAPI } from '../../src/types';
import { mock } from 'vitest-mock-extended';
vi.mock('../../src/camera-manager/manager');
vi.mock('../../src/card-controller/actions/actions-manager');
@@ -54,6 +55,7 @@ vi.mock('../../src/card-controller/media-info-manager');
vi.mock('../../src/card-controller/media-player-manager');
vi.mock('../../src/card-controller/message-manager');
vi.mock('../../src/card-controller/microphone-manager');
vi.mock('../../src/card-controller/overlay-message-manager');
vi.mock('../../src/card-controller/query-string-manager');
vi.mock('../../src/card-controller/status-bar-item-manager');
vi.mock('../../src/card-controller/style-manager');
@@ -231,6 +233,12 @@ describe('CardController', () => {
);
});
it('getOverlayMessageManager', () => {
expect(createController().getOverlayMessageManager()).toBe(
vi.mocked(OverlayMessageManager).mock.instances[0],
);
});
it('getMicrophoneManager', () => {
expect(createController().getMicrophoneManager()).toBe(
vi.mocked(MicrophoneManager).mock.instances[0],
+61 -28
View File
@@ -4,6 +4,7 @@ import { FoldersExecutor } from '../../../src/card-controller/folders/executor';
import { HAFoldersEngine } from '../../../src/card-controller/folders/ha/engine';
import { FolderQuery } from '../../../src/card-controller/folders/types';
import { FolderConfig } from '../../../src/config/schema/folders';
import { QuerySource } from '../../../src/query-source';
import { Endpoint } from '../../../src/types';
import { ViewFolder } from '../../../src/view/item';
import { createFolder, createHASS, TestViewMedia } from '../../test-utils';
@@ -53,6 +54,34 @@ describe('FoldersExecutor', () => {
});
});
describe('getDefaultQueryParameters', () => {
it('should return null for non-existent folder engine', () => {
const folder: FolderConfig = {
type: 'UNKNOWN',
} as unknown as FolderConfig;
const executor = new FoldersExecutor();
expect(executor.getDefaultQueryParameters(folder)).toBeNull();
});
it('should get default query parameters for HA folder engine', () => {
const folder = createFolder();
const haFolderEngine = mock<HAFoldersEngine>();
const expectedQuery: FolderQuery = {
source: QuerySource.Folder,
folder: folder,
path: [{}],
};
haFolderEngine.getDefaultQueryParameters.mockReturnValue(expectedQuery);
const executor = new FoldersExecutor({
ha: haFolderEngine,
});
expect(executor.getDefaultQueryParameters(folder)).toEqual(expectedQuery);
});
});
describe('getDownloadPath', () => {
it('should not get download path for non-existent folder engine', async () => {
const folder: FolderConfig = {
@@ -105,39 +134,15 @@ describe('FoldersExecutor', () => {
});
});
describe('generateDefaultFolderQuery', () => {
it('should generate default folder query', () => {
const folder: FolderConfig = createFolder();
const query: FolderQuery = {
folder,
path: [{ ha: { id: 'media-source://' } }],
};
const haFolderEngine = mock<HAFoldersEngine>();
haFolderEngine.generateDefaultFolderQuery.mockReturnValue(query);
const executor = new FoldersExecutor({ ha: haFolderEngine });
expect(executor.generateDefaultFolderQuery(folder)).toEqual(query);
});
it('should return null for non-existent folder engine', () => {
const folder: FolderConfig = {
type: 'UNKNOWN',
} as unknown as FolderConfig;
const executor = new FoldersExecutor();
expect(executor.generateDefaultFolderQuery(folder)).toBeNull();
});
});
describe('generateChildFolderQuery', () => {
it('should generate child folder query', () => {
const folder: FolderConfig = createFolder();
const query: FolderQuery = {
source: QuerySource.Folder,
folder,
path: [{ ha: { id: 'media-source://' } }],
};
const viewFolder = new ViewFolder(folder);
const viewFolder = new ViewFolder(folder, []);
const haFolderEngine = mock<HAFoldersEngine>();
haFolderEngine.generateChildFolderQuery.mockReturnValue(query);
@@ -152,10 +157,11 @@ describe('FoldersExecutor', () => {
type: 'UNKNOWN',
} as unknown as FolderConfig;
const query: FolderQuery = {
source: QuerySource.Folder,
folder,
path: [{ ha: { id: 'media-source://' } }],
};
const viewFolder = new ViewFolder(folder);
const viewFolder = new ViewFolder(folder, []);
const executor = new FoldersExecutor();
expect(executor.generateChildFolderQuery(query, viewFolder)).toBeNull();
@@ -176,6 +182,7 @@ describe('FoldersExecutor', () => {
it('should expand folder', async () => {
const folder = createFolder();
const query: FolderQuery = {
source: QuerySource.Folder,
folder,
path: [{ ha: { id: 'media-source://' } }],
};
@@ -184,7 +191,7 @@ describe('FoldersExecutor', () => {
folder,
startTime: new Date('2023-04-29T14:27'),
});
const folderItem = new ViewFolder(folder);
const folderItem = new ViewFolder(folder, []);
const haFolderEngine = mock<HAFoldersEngine>();
haFolderEngine.expandFolder.mockResolvedValue([folderItem, mediaItem, folderItem]);
@@ -201,4 +208,30 @@ describe('FoldersExecutor', () => {
]);
});
});
describe('areResultsFresh', () => {
it('should return true for non-existent engine', () => {
const folder: FolderConfig = {
type: 'UNKNOWN',
} as unknown as FolderConfig;
const query: FolderQuery = { source: QuerySource.Folder, folder, path: [{}] };
const executor = new FoldersExecutor();
expect(executor.areResultsFresh(new Date(), query)).toBe(true);
});
it('should get results from HA engine', () => {
const folder = createFolder();
const query: FolderQuery = { source: QuerySource.Folder, folder, path: [{}] };
const haFolderEngine = mock<HAFoldersEngine>();
haFolderEngine.areResultsFresh.mockReturnValue(false);
const executor = new FoldersExecutor({
ha: haFolderEngine,
});
expect(executor.areResultsFresh(new Date(), query)).toBe(false);
expect(haFolderEngine.areResultsFresh).toHaveBeenCalled();
});
});
});
+100 -58
View File
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { HAFoldersEngine } from '../../../../src/card-controller/folders/ha/engine';
import { FolderQuery } from '../../../../src/card-controller/folders/types';
import { FolderConfig, Matcher } from '../../../../src/config/schema/folders';
@@ -6,6 +6,7 @@ import { BrowseMediaViewFolder } from '../../../../src/ha/browse-media/item';
import { browseMediaSchema } from '../../../../src/ha/browse-media/types';
import { getMediaDownloadPath } from '../../../../src/ha/download';
import { homeAssistantWSRequest } from '../../../../src/ha/ws-request';
import { QuerySource } from '../../../../src/query-source';
import { Endpoint } from '../../../../src/types';
import { ViewFolder, ViewMedia } from '../../../../src/view/item';
import {
@@ -25,7 +26,7 @@ describe('HAFoldersEngine', () => {
describe('getItemCapabilities', () => {
it('should not be able to download a folder', () => {
const item = new ViewFolder(createFolder());
const item = new ViewFolder(createFolder(), []);
const engine = new HAFoldersEngine();
expect(engine.getItemCapabilities(item)).toEqual({
@@ -47,7 +48,7 @@ describe('HAFoldersEngine', () => {
describe('getDownloadPath', () => {
it('should return null if item is not a media item', async () => {
const item = new ViewFolder(createFolder());
const item = new ViewFolder(createFolder(), []);
const engine = new HAFoldersEngine();
expect(await engine.getDownloadPath(createHASS(), item)).toBeNull();
});
@@ -74,66 +75,32 @@ describe('HAFoldersEngine', () => {
});
});
describe('should generate default folder query', () => {
it('should generate default folder query', () => {
const folder: FolderConfig = { type: 'ha', id: 'test' };
describe('getDefaultQueryParameters', () => {
it('should return null for non-ha folder config', () => {
const folder: FolderConfig = {
type: 'UNKNOWN',
} as unknown as FolderConfig;
const engine = new HAFoldersEngine();
const query = engine.generateDefaultFolderQuery(folder);
expect(query).toEqual({
folder,
expect(engine.getDefaultQueryParameters(folder)).toBeNull();
});
it('should return default query parameters for ha folder config', () => {
const folder = createFolder();
const engine = new HAFoldersEngine();
expect(engine.getDefaultQueryParameters(folder)).toEqual({
source: QuerySource.Folder,
folder: folder,
path: [{ ha: { id: 'media-source://' } }],
});
});
it('should reject folders of the wrong type', async () => {
const folder = createFolder({ type: 'UNKNOWN' } as unknown as FolderConfig);
const engine = new HAFoldersEngine();
expect(engine.generateDefaultFolderQuery(folder)).toBeNull();
});
it('should respect path_url as a priority', async () => {
const folder = createFolder({
ha: {
url: [{ id: 'media-source://1' }],
path: [{ id: 'media-source://2' }],
},
});
const engine = new HAFoldersEngine();
expect(engine.generateDefaultFolderQuery(folder)).toEqual({
folder,
path: [{ ha: { id: 'media-source://1' } }, { ha: { id: 'media-source://2' } }],
});
});
it('should respect configured url', async () => {
const folder = createFolder({ ha: { url: [{ id: 'media-source://' }] } });
const engine = new HAFoldersEngine();
expect(engine.generateDefaultFolderQuery(folder)).toEqual({
folder,
path: [{ ha: { id: 'media-source://' } }],
});
});
it('should add default media root as necessary', async () => {
const folder = createFolder({
ha: { path: [{ matchers: [{ type: 'title', title: 'Frigate' }] }] },
});
const engine = new HAFoldersEngine();
expect(engine.generateDefaultFolderQuery(folder)).toEqual({
folder,
path: [
{ ha: { id: 'media-source://' } },
{ ha: { matchers: [{ type: 'title', title: 'Frigate' }] } },
],
});
});
});
describe('should expand folder', () => {
it('should reject folders of the wrong type', async () => {
const query = {
source: QuerySource.Folder,
folder: { type: 'UNKNOWN' },
} as unknown as FolderQuery;
const engine = new HAFoldersEngine();
@@ -143,6 +110,7 @@ describe('HAFoldersEngine', () => {
it('should expand folder with cache by default', async () => {
const query: FolderQuery = {
source: QuerySource.Folder,
folder: { type: 'ha', id: 'test' },
path: [{ ha: { id: 'media-source://id' } }],
};
@@ -180,6 +148,7 @@ describe('HAFoldersEngine', () => {
it('should expand folder without cache when requested', async () => {
const query: FolderQuery = {
source: QuerySource.Folder,
folder: { type: 'ha', id: 'test' },
path: [{ ha: { id: 'media-source://id' } }],
};
@@ -231,10 +200,11 @@ describe('HAFoldersEngine', () => {
});
const query: FolderQuery = {
source: QuerySource.Folder,
folder: { type: 'ha', id: 'test' },
path: [
{
folder: new BrowseMediaViewFolder(createFolder(), browseMedia),
folder: new BrowseMediaViewFolder(createFolder(), [], browseMedia),
},
],
};
@@ -259,6 +229,7 @@ describe('HAFoldersEngine', () => {
it('should not expand without a folder with an id', async () => {
const query: FolderQuery = {
source: QuerySource.Folder,
folder: { type: 'ha', id: 'test' },
// There's no component in the query with an id to start from.
path: [{ ha: {} }],
@@ -267,6 +238,38 @@ describe('HAFoldersEngine', () => {
expect(await engine.expandFolder(createHASS(), query)).toBeNull();
});
it('should early exit when limit is reached', async () => {
const query: FolderQuery = {
source: QuerySource.Folder,
folder: { type: 'ha', id: 'test' },
path: [{ ha: { id: 'media-source://id' } }],
limit: 1,
};
vi.mocked(homeAssistantWSRequest).mockResolvedValueOnce(
createBrowseMedia({
media_content_id: 'media-source://id',
can_expand: true,
children: [
createBrowseMedia({
media_content_id: 'media-source://media-item-1',
title: 'Media Item 1',
}),
createBrowseMedia({
media_content_id: 'media-source://media-item-2',
title: 'Media Item 2',
}),
],
}),
);
const engine = new HAFoldersEngine();
const results = await engine.expandFolder(createHASS(), query);
// Even though there are 2 children, the limit of 1 should trigger earlyExit.
expect(results?.length).toBe(1);
});
// See additional matcher testing in media-matcher.test.ts .
describe('should apply matchers', async () => {
it.each([
@@ -293,6 +296,7 @@ describe('HAFoldersEngine', () => {
],
])('%s', async (_name: string, matcher: Matcher, expectedMatches: number) => {
const query: FolderQuery = {
source: QuerySource.Folder,
folder: { type: 'ha', id: 'test' },
path: [{ ha: { id: 'media-source://' } }, { ha: { matchers: [matcher] } }, {}],
};
@@ -335,10 +339,11 @@ describe('HAFoldersEngine', () => {
it('should return null if folder type is not ha', () => {
const engine = new HAFoldersEngine();
const query: FolderQuery = {
source: QuerySource.Folder,
folder: { type: 'other' } as unknown as FolderConfig,
path: [{ ha: { id: 'root' } }],
};
const folder = new ViewFolder(createFolder());
const folder = new ViewFolder(createFolder(), []);
expect(engine.generateChildFolderQuery(query, folder)).toBeNull();
});
@@ -346,10 +351,11 @@ describe('HAFoldersEngine', () => {
it('should return null if folder has no id', () => {
const engine = new HAFoldersEngine();
const query: FolderQuery = {
source: QuerySource.Folder,
folder: { type: 'ha', id: 'test' },
path: [{ ha: { id: 'root' } }],
};
const folder = new ViewFolder(createFolder(), { id: '' });
const folder = new ViewFolder(createFolder(), [], { id: '' });
expect(engine.generateChildFolderQuery(query, folder)).toBeNull();
});
@@ -364,14 +370,16 @@ describe('HAFoldersEngine', () => {
},
};
const query: FolderQuery = {
source: QuerySource.Folder,
folder: folderConfig,
path: [{ ha: { id: 'media-source://' } }],
};
const folder = new ViewFolder(createFolder(), { id: 'child' });
const folder = new ViewFolder(createFolder(), [], { id: 'child' });
const result = engine.generateChildFolderQuery(query, folder);
expect(result).toEqual({
source: QuerySource.Folder,
folder: folderConfig,
path: [
{ ha: { id: 'media-source://' } },
@@ -387,14 +395,16 @@ describe('HAFoldersEngine', () => {
const engine = new HAFoldersEngine();
const folderConfig: FolderConfig = { type: 'ha', id: 'test' };
const query: FolderQuery = {
source: QuerySource.Folder,
folder: folderConfig,
path: [{ ha: { id: 'media-source://' } }],
};
const folder = new ViewFolder(createFolder(), { id: 'child' });
const folder = new ViewFolder(createFolder(), [], { id: 'child' });
const result = engine.generateChildFolderQuery(query, folder);
expect(result).toEqual({
source: QuerySource.Folder,
folder: folderConfig,
path: [
{ ha: { id: 'media-source://' } },
@@ -406,4 +416,36 @@ describe('HAFoldersEngine', () => {
});
});
});
describe('areResultsFresh', () => {
beforeAll(() => {
vi.useFakeTimers();
});
afterAll(() => {
vi.useRealTimers();
});
it('should return true for fresh results', () => {
const now = new Date('2026-01-02T07:54:32Z');
vi.setSystemTime(now);
const engine = new HAFoldersEngine();
const query = { folder: { type: 'ha' } } as FolderQuery;
const resultsTimestamp = new Date('2026-01-02T07:54:30Z');
expect(engine.areResultsFresh(resultsTimestamp, query)).toBe(true);
});
it('should return false for stale results', () => {
const now = new Date('2026-01-02T07:54:32Z');
vi.setSystemTime(now);
const engine = new HAFoldersEngine();
const query = { folder: { type: 'ha' } } as FolderQuery;
const resultsTimestamp = new Date('2026-01-02T07:53:30Z');
expect(engine.areResultsFresh(resultsTimestamp, query)).toBe(false);
});
});
});
+77 -50
View File
@@ -4,6 +4,7 @@ import { FoldersExecutor } from '../../../src/card-controller/folders/executor';
import { FoldersManager } from '../../../src/card-controller/folders/manager';
import { FolderQuery } from '../../../src/card-controller/folders/types';
import { FolderConfig, FolderConfigWithoutID } from '../../../src/config/schema/folders';
import { QuerySource } from '../../../src/query-source';
import { ResolvedMediaCache } from '../../../src/ha/resolved-media';
import { Endpoint } from '../../../src/types';
import { ViewFolder } from '../../../src/view/item';
@@ -99,6 +100,39 @@ describe('FoldersManager', () => {
expect(manager.getFolderCount()).toBe(0);
});
describe('getDefaultQueryParameters', () => {
it('should return null with no folder provided and no internal folders', () => {
const manager = new FoldersManager(createCardAPI());
expect(manager.getDefaultQueryParameters()).toBeNull();
});
it('should use provided folder', () => {
const folder = createFolder();
const executor = mock<FoldersExecutor>();
const manager = new FoldersManager(createCardAPI(), executor);
const query: FolderQuery = { source: QuerySource.Folder, folder, path: [{}] };
executor.getDefaultQueryParameters.mockReturnValue(query);
expect(manager.getDefaultQueryParameters(folder)).toEqual(query);
expect(executor.getDefaultQueryParameters).toBeCalledWith(folder);
});
it('should fallback to default folder if none provided', () => {
const folder = createFolder();
const executor = mock<FoldersExecutor>();
const manager = new FoldersManager(createCardAPI(), executor);
const query: FolderQuery = { source: QuerySource.Folder, folder, path: [{}] };
manager.addFolders([folder]);
executor.getDefaultQueryParameters.mockReturnValue(query);
expect(manager.getDefaultQueryParameters()).toEqual(query);
expect(executor.getDefaultQueryParameters).toBeCalledWith(manager.getFolder());
});
});
describe('should get folders', () => {
it('should get default folder', () => {
const manager = new FoldersManager(createCardAPI());
@@ -132,63 +166,15 @@ describe('FoldersManager', () => {
});
});
describe('generateDefaultFolderQuery', () => {
it('should generate default folder query with implicit folder', () => {
const folder: FolderConfig = createFolder();
const query: FolderQuery = {
folder,
path: [{ ha: { id: 'media-source://' } }],
};
const executor = mock<FoldersExecutor>();
vi.mocked(executor.generateDefaultFolderQuery).mockReturnValue(query);
const manager = new FoldersManager(createCardAPI(), executor);
manager.addFolders([folder]);
expect(manager.generateDefaultFolderQuery()).toEqual(query);
});
it('should generate default folder query without any folder', () => {
const folder: FolderConfig = createFolder();
const query: FolderQuery = {
folder,
path: [{ ha: { id: 'media-source://' } }],
};
const executor = mock<FoldersExecutor>();
vi.mocked(executor.generateDefaultFolderQuery).mockReturnValue(query);
const manager = new FoldersManager(createCardAPI(), executor);
// Folder is not added to the manager.
expect(manager.generateDefaultFolderQuery()).toBeNull();
});
it('should generate default folder query with explicit folder', () => {
const folder: FolderConfig = createFolder();
const query: FolderQuery = {
folder,
path: [{ ha: { id: 'media-source://' } }],
};
const executor = mock<FoldersExecutor>();
vi.mocked(executor.generateDefaultFolderQuery).mockReturnValue(query);
const manager = new FoldersManager(createCardAPI(), executor);
expect(manager.generateDefaultFolderQuery(folder)).toEqual(query);
});
});
describe('generateChildFolderQuery', () => {
it('should generate child folder query', () => {
const folder: FolderConfig = createFolder();
const query: FolderQuery = {
source: QuerySource.Folder,
folder,
path: [{ ha: { id: 'media-source://' } }],
};
const viewFolder = new ViewFolder(folder);
const viewFolder = new ViewFolder(folder, []);
const executor = mock<FoldersExecutor>();
vi.mocked(executor.generateChildFolderQuery).mockReturnValue(query);
@@ -216,6 +202,7 @@ describe('FoldersManager', () => {
const conditionState = {};
const engineOptions = {};
const query: FolderQuery = {
source: QuerySource.Folder,
folder,
path: [{ ha: { id: 'media-source://' } }],
};
@@ -240,6 +227,7 @@ describe('FoldersManager', () => {
expect(
await manager.expandFolder({
source: QuerySource.Folder,
folder,
path: [{ ha: { id: 'media-source://' } }],
}),
@@ -247,6 +235,26 @@ describe('FoldersManager', () => {
expect(executor.expandFolder).not.toBeCalled();
});
it('should return null for query with unsupported filters', async () => {
const hass = createHASS();
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
const executor = mock<FoldersExecutor>();
const manager = new FoldersManager(api, executor);
const folder = createFolder({ id: 'folder-1' });
const query: FolderQuery = {
source: QuerySource.Folder,
folder,
path: [{ ha: { id: 'media-source://' } }],
favorite: true,
};
expect(await manager.expandFolder(query)).toBeNull();
expect(executor.expandFolder).not.toBeCalled();
});
});
describe('should get item capabilities', () => {
@@ -302,4 +310,23 @@ describe('FoldersManager', () => {
expect(executor.favorite).toBeCalledWith(hass, item, true);
});
});
describe('areResultsFresh', () => {
it('should delegate freshness check to executor', () => {
const api = createCardAPI();
const executor = mock<FoldersExecutor>();
const manager = new FoldersManager(api, executor);
const query: FolderQuery = {
source: QuerySource.Folder,
folder: createFolder(),
path: [{}],
};
const timestamp = new Date();
executor.areResultsFresh.mockReturnValue(true);
expect(manager.areResultsFresh(timestamp, query)).toBe(true);
expect(executor.areResultsFresh).toHaveBeenCalledWith(timestamp, query);
});
});
});
@@ -0,0 +1,51 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { CardElementManager } from '../../src/card-controller/card-element-manager';
import { OverlayMessageManager } from '../../src/card-controller/overlay-message-manager';
import { CardOverlayMessageAPI } from '../../src/card-controller/types';
describe('OverlayMessageManager', () => {
const cardElementManager = mock<CardElementManager>();
const api = mock<CardOverlayMessageAPI>();
beforeEach(() => {
vi.clearAllMocks();
api.getCardElementManager.mockReturnValue(cardElementManager);
});
it('should be constructed', () => {
const manager = new OverlayMessageManager(api);
expect(manager).toBeDefined();
expect(manager.getMessage()).toBeNull();
expect(manager.hasMessage()).toBeFalsy();
});
it('should set and get message', () => {
const manager = new OverlayMessageManager(api);
const message = { message: 'foo' };
manager.setMessage(message);
expect(manager.getMessage()).toBe(message);
expect(manager.hasMessage()).toBeTruthy();
expect(cardElementManager.update).toHaveBeenCalled();
});
it('should reset message', () => {
const manager = new OverlayMessageManager(api);
manager.setMessage({ message: 'foo' });
vi.clearAllMocks();
manager.reset();
expect(manager.getMessage()).toBeNull();
expect(manager.hasMessage()).toBeFalsy();
expect(cardElementManager.update).toHaveBeenCalled();
});
it('should not update if reset is called with no message', () => {
const manager = new OverlayMessageManager(api);
manager.reset();
expect(cardElementManager.update).not.toHaveBeenCalled();
});
});
@@ -240,5 +240,28 @@ describe('StatusBarItemManager', () => {
icon: 'ENGINE_ICON',
});
});
it('should have severity', () => {
const manager = new StatusBarItemManager(createCardAPI());
const selectedResult = new TestViewMedia({
cameraID: 'camera-1',
});
vi.spyOn(selectedResult, 'getSeverity').mockReturnValue('high');
const queryResults = new QueryResults({
results: [selectedResult],
selectedIndex: 0,
});
expect(
manager.calculateItems({
view: createView({ queryResults: queryResults }),
}),
).toContainEqual({
type: 'custom:advanced-camera-card-status-bar-icon' as const,
icon: 'mdi:circle-medium',
severity: 'high',
});
});
});
});
@@ -0,0 +1,152 @@
import { describe, expect, it } from 'vitest';
import { TemplateRenderer } from '../../../src/card-controller/templates/index';
import { createHASS } from '../../test-utils';
describe('TemplateRenderer', () => {
describe('renderRecursively', () => {
it('should render string templates with camera context', () => {
const renderer = new TemplateRenderer();
const hass = createHASS();
const result = renderer.renderRecursively(hass, 'Camera: {{ acc.camera }}', {
conditionState: { camera: 'camera.office' },
});
expect(result).toBe('Camera: camera.office');
});
it('should render string templates with view context', () => {
const renderer = new TemplateRenderer();
const hass = createHASS();
const result = renderer.renderRecursively(hass, 'View: {{ acc.view }}', {
conditionState: { view: 'live' },
});
expect(result).toBe('View: live');
});
it('should render string templates with full advanced_camera_card context', () => {
const renderer = new TemplateRenderer();
const hass = createHASS();
const result = renderer.renderRecursively(
hass,
'{{ advanced_camera_card.camera }} - {{ advanced_camera_card.view }}',
{
conditionState: { camera: 'camera.front', view: 'clips' },
},
);
expect(result).toBe('camera.front - clips');
});
it('should render templates in arrays', () => {
const renderer = new TemplateRenderer();
const hass = createHASS();
const result = renderer.renderRecursively(
hass,
['{{ acc.camera }}', 'static', '{{ acc.view }}'],
{ conditionState: { camera: 'camera.office', view: 'live' } },
);
expect(result).toEqual(['camera.office', 'static', 'live']);
});
it('should render templates in object values', () => {
const renderer = new TemplateRenderer();
const hass = createHASS();
const result = renderer.renderRecursively(
hass,
{ camera: '{{ acc.camera }}', view: '{{ acc.view }}', static: 'value' },
{ conditionState: { camera: 'camera.office', view: 'live' } },
);
expect(result).toEqual({ camera: 'camera.office', view: 'live', static: 'value' });
});
it('should render templates in nested objects', () => {
const renderer = new TemplateRenderer();
const hass = createHASS();
const result = renderer.renderRecursively(
hass,
{ outer: { inner: '{{ acc.camera }}' } },
{ conditionState: { camera: 'camera.office' } },
);
expect(result).toEqual({ outer: { inner: 'camera.office' } });
});
it('should return non-string/array/object values unchanged', () => {
const renderer = new TemplateRenderer();
const hass = createHASS();
expect(renderer.renderRecursively(hass, 42)).toBe(42);
expect(renderer.renderRecursively(hass, true)).toBe(true);
expect(renderer.renderRecursively(hass, null)).toBe(null);
});
it('should render strings without templates unchanged', () => {
const renderer = new TemplateRenderer();
const hass = createHASS();
expect(renderer.renderRecursively(hass, 'hello world')).toBe('hello world');
});
it('should render with triggerData context', () => {
const renderer = new TemplateRenderer();
const hass = createHASS();
const result = renderer.renderRecursively(hass, '{{ acc.trigger.camera.to }}', {
triggerData: { camera: { from: 'camera.front', to: 'camera.backyard' } },
});
expect(result).toBe('camera.backyard');
});
it('should render with mediaData context', () => {
const renderer = new TemplateRenderer();
const hass = createHASS();
const result = renderer.renderRecursively(
hass,
'Title: {{ acc.media.title }}, Folder: {{ acc.media.is_folder }}',
{
mediaData: { title: 'Test Media', is_folder: false },
},
);
expect(result).toBe('Title: Test Media, Folder: false');
});
it('should render with combined context options', () => {
const renderer = new TemplateRenderer();
const hass = createHASS();
const result = renderer.renderRecursively(
hass,
'{{ acc.camera }} - {{ acc.media.title }}',
{
conditionState: { camera: 'camera.office' },
mediaData: { title: 'My Video', is_folder: false },
},
);
expect(result).toBe('camera.office - My Video');
});
it('should return undefined context when no options provided', () => {
const renderer = new TemplateRenderer();
const hass = createHASS();
// Without options, templates referencing acc should render empty.
const result = renderer.renderRecursively(hass, 'Value: {{ acc.camera }}');
expect(result).toBe('Value:');
});
it('should return undefined context when options have no relevant data', () => {
const renderer = new TemplateRenderer();
const hass = createHASS();
// Empty conditionState without camera or view should not create context.
const result = renderer.renderRecursively(hass, 'Value: {{ acc.camera }}', {
conditionState: {},
});
expect(result).toBe('Value:');
});
});
});
+11 -6
View File
@@ -183,16 +183,20 @@ describe('TriggersManager', () => {
describe('media', () => {
it.each([
[false, false, null],
[false, true, 'clip' as const],
[true, false, 'snapshot' as const],
[true, true, 'clip' as const],
// [review, snapshot, clip, expectedView]
[false, false, false, null],
[false, false, true, 'clip' as const],
[false, true, false, 'snapshot' as const],
[false, true, true, 'clip' as const],
[true, false, false, 'review' as const],
[true, true, true, 'review' as const],
])(
'with snapshot %s and clip %s',
'with review %s, snapshot %s and clip %s',
async (
hasReview: boolean,
hasSnapshot: boolean,
hasClip: boolean,
viewName: 'clip' | 'snapshot' | null,
viewName: 'review' | 'clip' | 'snapshot' | null,
) => {
const api = createTriggerAPI({
config: {
@@ -209,6 +213,7 @@ describe('TriggersManager', () => {
cameraID: 'camera_1',
type: 'new',
fidelity: 'high',
review: hasReview,
snapshot: hasSnapshot,
clip: hasClip,
});
@@ -226,7 +226,7 @@ describe('ViewItemManager', () => {
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = new ViewItemManager(api);
const item = new ViewFolder(createFolder());
const item = new ViewFolder(createFolder(), []);
vi.mocked(api.getFoldersManager().getDownloadPath).mockResolvedValue({
sign: false,
@@ -242,7 +242,7 @@ describe('ViewItemManager', () => {
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = new ViewItemManager(api);
const item = new ViewFolder(createFolder(), { title: 'title' });
const item = new ViewFolder(createFolder(), [], { title: 'title' });
vi.mocked(api.getFoldersManager().getDownloadPath).mockResolvedValue({
sign: false,
@@ -284,4 +284,34 @@ describe('ViewItemManager', () => {
expect(api.getFoldersManager().favorite).toBeCalledWith(item, true);
});
});
describe('reviewMedia', () => {
it('should review camera media', async () => {
const api = createCardAPI();
const manager = new ViewItemManager(api);
const item = new TestViewMedia({
cameraID: 'camera.office',
mediaType: 'review' as ViewMediaType,
});
await manager.reviewMedia(item, true);
expect(api.getCameraManager().reviewMedia).toBeCalledWith(item, true);
});
it('should not review non-review media', async () => {
const api = createCardAPI();
const manager = new ViewItemManager(api);
const item = new TestViewMedia({
cameraID: 'camera.office',
mediaType: ViewMediaType.Clip,
});
await manager.reviewMedia(item, true);
expect(api.getCameraManager().reviewMedia).not.toBeCalled();
});
});
});
@@ -2,14 +2,14 @@ import { expect, it } from 'vitest';
import { applyViewModifiers } from '../../../../src/card-controller/view/modifiers';
import { MergeContextViewModifier } from '../../../../src/card-controller/view/modifiers/merge-context';
import { SetQueryViewModifier } from '../../../../src/card-controller/view/modifiers/set-query';
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';
it('should apply view modifiers', () => {
const view = createView();
const query = new EventMediaQuery();
const query = new UnifiedQuery();
const queryResults = new QueryResults();
const context = {
@@ -1,7 +1,7 @@
import { expect, it } from 'vitest';
import { SetQueryViewModifier } from '../../../../src/card-controller/view/modifiers/set-query';
import { EventMediaQuery } from '../../../../src/view/query';
import { QueryResults } from '../../../../src/view/query-results';
import { UnifiedQuery } from '../../../../src/view/unified-query';
import { createView } from '../../../test-utils';
it('should do nothing without arguments', () => {
@@ -16,7 +16,7 @@ it('should do nothing without arguments', () => {
it('should set query and results', () => {
const view = createView();
const query = new EventMediaQuery();
const query = new UnifiedQuery();
const queryResults = new QueryResults();
const modifier = new SetQueryViewModifier({
@@ -1,429 +0,0 @@
import { add } from 'date-fns';
import { describe, expect, it, vi } from 'vitest';
import { QueryType } from '../../../src/camera-manager/types';
import { FolderQuery } from '../../../src/card-controller/folders/types';
import { QueryExecutor } from '../../../src/card-controller/view/query-executor';
import { ClipsOrSnapshotsOrAll } from '../../../src/types';
import { EventMediaQuery, FolderViewQuery } from '../../../src/view/query';
import {
TestViewMedia,
createCameraManager,
createCardAPI,
createFolder,
createStore,
generateViewMediaArray,
} from '../../test-utils';
import { createPopulatedAPI } from './test-utils';
describe('executeDefaultEventQuery', () => {
it('should return null without cameras', async () => {
const api = createCardAPI();
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
vi.mocked(api.getCameraManager().getStore).mockReturnValue(createStore());
const executor = new QueryExecutor(api);
expect(
await executor.executeDefaultEventQuery({
cameraID: 'camera.office',
}),
).toBeNull();
});
it('should return null without queries', async () => {
const api = createPopulatedAPI();
vi.mocked(api.getCameraManager().generateDefaultEventQueries).mockReturnValue(null);
const executor = new QueryExecutor(api);
expect(
await executor.executeDefaultEventQuery({
cameraID: 'camera.office',
}),
).toBeNull();
});
describe('should return query results for specified camera', async () => {
it.each([['all' as const], ['clips' as const], ['snapshots' as const], [undefined]])(
'%s',
async (mediaType?: ClipsOrSnapshotsOrAll) => {
const api = createPopulatedAPI();
const media = generateViewMediaArray();
const rawQueries = [
{ type: QueryType.Event as const, cameraIDs: new Set(['camera.office']) },
];
vi.mocked(api.getCameraManager().generateDefaultEventQueries).mockReturnValue(
rawQueries,
);
vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media);
const executor = new QueryExecutor(api);
const results = await executor.executeDefaultEventQuery({
cameraID: 'camera.office',
eventsMediaType: mediaType,
});
expect(results?.query.getQuery()).toEqual(rawQueries);
expect(results?.queryResults.getResults()).toEqual(media);
expect(api.getCameraManager().generateDefaultEventQueries).toBeCalledWith(
new Set(['camera.office']),
{
limit: 50,
...(mediaType === 'clips' && { hasClip: true }),
...(mediaType === 'snapshots' && { hasSnapshot: true }),
},
);
},
);
});
it('should return query results for all cameras', async () => {
const api = createPopulatedAPI();
const media = generateViewMediaArray();
const rawQueries = [
{
type: QueryType.Event as const,
cameraIDs: new Set(['camera.office', 'camera.kitchen']),
},
];
vi.mocked(api.getCameraManager().generateDefaultEventQueries).mockReturnValue(
rawQueries,
);
vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media);
const executor = new QueryExecutor(api);
const results = await executor.executeDefaultEventQuery();
expect(results?.query.getQuery()).toEqual(rawQueries);
expect(results?.queryResults.getResults()).toEqual(media);
expect(api.getCameraManager().generateDefaultEventQueries).toBeCalledWith(
new Set(['camera.office', 'camera.kitchen']),
{
limit: 50,
},
);
});
it('should return null when query returns null', async () => {
const api = createPopulatedAPI();
const rawQueries = [
{
type: QueryType.Event as const,
cameraIDs: new Set(['camera.office']),
},
];
vi.mocked(api.getCameraManager().generateDefaultEventQueries).mockReturnValue(
rawQueries,
);
vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(null);
const executor = new QueryExecutor(api);
expect(
await executor.executeDefaultEventQuery({ cameraID: 'camera.office' }),
).toBeNull();
expect(api.getCameraManager().generateDefaultEventQueries).toBeCalledWith(
new Set(['camera.office']),
{
limit: 50,
},
);
});
});
describe('executeDefaultRecordingQuery', () => {
it('should return null without cameras', async () => {
const api = createCardAPI();
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
vi.mocked(api.getCameraManager().getStore).mockReturnValue(createStore());
const executor = new QueryExecutor(api);
expect(
await executor.executeDefaultRecordingQuery({
cameraID: 'camera.office',
}),
).toBeNull();
});
it('should return null without queries', async () => {
const api = createPopulatedAPI();
vi.mocked(api.getCameraManager().generateDefaultRecordingQueries).mockReturnValue(
null,
);
const executor = new QueryExecutor(api);
expect(
await executor.executeDefaultRecordingQuery({
cameraID: 'camera.office',
}),
).toBeNull();
});
it('should return query results for specified camera', async () => {
const api = createPopulatedAPI();
const media = generateViewMediaArray();
const rawQueries = [
{ type: QueryType.Recording as const, cameraIDs: new Set(['camera.office']) },
];
vi.mocked(api.getCameraManager().generateDefaultRecordingQueries).mockReturnValue(
rawQueries,
);
vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media);
const executor = new QueryExecutor(api);
const results = await executor.executeDefaultRecordingQuery({
cameraID: 'camera.office',
});
expect(results?.query.getQuery()).toEqual(rawQueries);
expect(results?.queryResults.getResults()).toEqual(media);
expect(api.getCameraManager().generateDefaultRecordingQueries).toBeCalledWith(
new Set(['camera.office']),
{
limit: 50,
},
);
});
it('should return query results for all cameras', async () => {
const api = createPopulatedAPI();
const media = generateViewMediaArray();
const rawQueries = [
{
type: QueryType.Recording as const,
cameraIDs: new Set(['camera.office', 'camera.kitchen']),
},
];
vi.mocked(api.getCameraManager().generateDefaultRecordingQueries).mockReturnValue(
rawQueries,
);
vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media);
const executor = new QueryExecutor(api);
const results = await executor.executeDefaultRecordingQuery();
expect(results?.query.getQuery()).toEqual(rawQueries);
expect(results?.queryResults.getResults()).toEqual(media);
expect(api.getCameraManager().generateDefaultRecordingQueries).toBeCalledWith(
new Set(['camera.office', 'camera.kitchen']),
{
limit: 50,
},
);
});
it('should return null when query returns null', async () => {
const api = createPopulatedAPI();
const rawQueries = [
{
type: QueryType.Recording as const,
cameraIDs: new Set(['camera.office']),
},
];
vi.mocked(api.getCameraManager().generateDefaultRecordingQueries).mockReturnValue(
rawQueries,
);
vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(null);
const executor = new QueryExecutor(api);
expect(
await executor.executeDefaultRecordingQuery({ cameraID: 'camera.office' }),
).toBeNull();
expect(api.getCameraManager().generateDefaultRecordingQueries).toBeCalledWith(
new Set(['camera.office']),
{
limit: 50,
},
);
});
});
describe('executeQuery', () => {
it('should return null when query is empty', async () => {
const executor = new QueryExecutor(createCardAPI());
expect(await executor.executeQuery(new EventMediaQuery())).toBeNull();
});
describe('should handle result rejections', () => {
it('rejected', async () => {
const api = createPopulatedAPI();
const media = generateViewMediaArray();
vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media);
const query = new EventMediaQuery([
{
type: QueryType.Event as const,
cameraIDs: new Set(['camera.office']),
},
]);
const executor = new QueryExecutor(api);
expect(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
await executor.executeQuery(query, { rejectResults: (_) => true }),
).toBeNull();
});
it('not rejected', async () => {
const api = createPopulatedAPI();
const media = generateViewMediaArray();
vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media);
const query = new EventMediaQuery([
{
type: QueryType.Event as const,
cameraIDs: new Set(['camera.office']),
},
]);
const executor = new QueryExecutor(api);
expect(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
await executor.executeQuery(query, { rejectResults: (_) => false }),
).not.toBeNull();
});
});
describe('should select', () => {
it('by id', async () => {
const api = createPopulatedAPI();
const media = generateViewMediaArray({
cameraIDs: ['camera.office'],
count: 100,
});
vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media);
const query = new EventMediaQuery([
{
type: QueryType.Event as const,
cameraIDs: new Set(['camera.office']),
},
]);
const executor = new QueryExecutor(api);
const results = await executor.executeMediaQuery(query, {
selectResult: { id: 'id-camera.office-42' },
});
expect(results?.queryResults.getSelectedResult()?.getID()).toBe(
'id-camera.office-42',
);
});
});
it('by func', async () => {
const api = createPopulatedAPI();
const media = generateViewMediaArray({
cameraIDs: ['camera.office'],
count: 100,
});
vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media);
const query = new EventMediaQuery([
{
type: QueryType.Event as const,
cameraIDs: new Set(['camera.office']),
},
]);
const executor = new QueryExecutor(api);
const results = await executor.executeMediaQuery(query, {
selectResult: { func: (media) => media.getID() === 'id-camera.office-43' },
});
expect(results?.queryResults.getSelectedResult()?.getID()).toBe(
'id-camera.office-43',
);
});
it('by time', async () => {
const now = new Date('2024-07-21T19:09:37Z');
const api = createPopulatedAPI();
const media = [
new TestViewMedia({
cameraID: 'camera.office',
id: 'id-camera.office-0',
startTime: now,
}),
new TestViewMedia({
cameraID: 'camera.office',
id: 'id-camera.office-1',
startTime: add(now, { seconds: 1 }),
}),
new TestViewMedia({
cameraID: 'camera.office',
id: 'id-camera.office-2',
startTime: add(now, { seconds: 2 }),
}),
];
vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media);
const query = new EventMediaQuery([
{
type: QueryType.Event as const,
cameraIDs: new Set(['camera.office']),
},
]);
const executor = new QueryExecutor(api);
const results = await executor.executeMediaQuery(query, {
selectResult: { time: { time: add(now, { seconds: 1 }) } },
});
expect(results?.queryResults.getSelectedResult()?.getID()).toBe(
'id-camera.office-1',
);
});
describe('should handle folder query', () => {
it('should return null without raw query', async () => {
const executor = new QueryExecutor(createCardAPI());
expect(await executor.executeQuery(new FolderViewQuery())).toBeNull();
});
it('should return null when query expansion fails', async () => {
const api = createCardAPI();
vi.mocked(api.getFoldersManager().getFolder).mockReturnValue(createFolder());
vi.mocked(api.getFoldersManager().expandFolder).mockResolvedValue(null);
const executor = new QueryExecutor(api);
expect(await executor.executeFolderQuery()).toBeNull();
});
});
});
describe('executeFolderQuery', () => {
it('should return null without folders', async () => {
const executor = new QueryExecutor(createCardAPI());
expect(await executor.executeFolderQuery()).toBeNull();
});
it('should execute query against first folder', async () => {
const api = createCardAPI();
const items = [new TestViewMedia()];
const folder = createFolder();
const query: FolderQuery = {
folder,
path: [{ ha: { id: 'path' } }],
};
vi.mocked(api.getFoldersManager().getFolder).mockReturnValue(folder);
vi.mocked(api.getFoldersManager().generateDefaultFolderQuery).mockReturnValue(query);
vi.mocked(api.getFoldersManager().expandFolder).mockResolvedValue(items);
const executor = new QueryExecutor(api);
const result = await executor.executeFolderQuery();
expect(result?.query.getQuery()).toEqual(query);
expect(result?.queryResults.getResults()).toEqual(items);
});
it('should return null without folder results', async () => {
const api = createCardAPI();
const folder = createFolder();
const query: FolderQuery = {
folder,
path: [{ ha: { id: 'path' } }],
};
vi.mocked(api.getFoldersManager().getFolder).mockReturnValue(folder);
vi.mocked(api.getFoldersManager().generateDefaultFolderQuery).mockReturnValue(query);
vi.mocked(api.getFoldersManager().expandFolder).mockResolvedValue(null);
const executor = new QueryExecutor(api);
expect(await executor.executeFolderQuery()).toBeNull();
});
});
+4 -4
View File
@@ -24,8 +24,8 @@ describe('sortMedia', () => {
startTime: new Date('2023-04-29T14:27'),
cameraID: 'camera-1',
});
const folder_1 = new ViewFolder(createFolder(), { id: 'folder_1' });
const folder_2 = new ViewFolder(createFolder(), { id: 'folder_2' });
const folder_1 = new ViewFolder(createFolder(), [], { id: 'folder_1' });
const folder_2 = new ViewFolder(createFolder(), [], { id: 'folder_2' });
it('should sort sorted media', () => {
const media = [media_1, media_2];
@@ -44,10 +44,10 @@ describe('sortMedia', () => {
new TestViewMedia({ id: 'snake' }),
new TestViewMedia({ id: 'zebra' }),
new TestViewMedia({ id: 'aardvark' }),
new ViewFolder(folder, { id: 'folder' }),
new ViewFolder(folder, [], { id: 'folder' }),
]),
).toEqual([
new ViewFolder(folder, { id: 'folder' }),
new ViewFolder(folder, [], { id: 'folder' }),
new TestViewMedia({ id: 'aardvark' }),
new TestViewMedia({ id: 'snake' }),
new TestViewMedia({ id: 'zebra' }),
+25 -25
View File
@@ -13,31 +13,31 @@ export const createPopulatedAPI = (
config?: RawAdvancedCameraCardConfig,
): CardController => {
const api = createCardAPI();
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
createStore([
{
cameraID: 'camera.office',
capabilities: createCapabilities({
live: true,
snapshots: true,
clips: true,
recordings: true,
substream: true,
}),
},
{
cameraID: 'camera.kitchen',
capabilities: createCapabilities({
live: true,
snapshots: true,
clips: true,
recordings: true,
substream: true,
}),
},
]),
);
const store = createStore([
{
cameraID: 'camera.office',
capabilities: createCapabilities({
live: true,
snapshots: true,
clips: true,
recordings: true,
reviews: true,
substream: true,
}),
},
{
cameraID: 'camera.kitchen',
capabilities: createCapabilities({
live: true,
snapshots: true,
clips: true,
recordings: true,
reviews: true,
substream: true,
}),
},
]);
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store));
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig(config));
return api;
};
+63 -85
View File
@@ -1,7 +1,6 @@
import { ViewContext } from 'view';
import { describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { QueryType } from '../../../src/camera-manager/types';
import { CardController } from '../../../src/card-controller/controller';
import { ViewFactory } from '../../../src/card-controller/view/factory';
import { SetQueryViewModifier } from '../../../src/card-controller/view/modifiers/set-query';
@@ -11,11 +10,12 @@ import {
} from '../../../src/card-controller/view/types';
import { ViewManager } from '../../../src/card-controller/view/view-manager';
import { ViewQueryExecutor } from '../../../src/card-controller/view/view-query-executor';
import { AdvancedCameraCardView } from '../../../src/config/schema/common/const';
import { ViewMedia, ViewMediaType } from '../../../src/view/item';
import { EventMediaQuery } from '../../../src/view/query';
import { QueryResults } from '../../../src/view/query-results';
import { UnifiedQuery } from '../../../src/view/unified-query';
import { View } from '../../../src/view/view';
import { createCardAPI, createView } from '../../test-utils';
import { createCardAPI, createEventQuery, createView } from '../../test-utils';
const createInitializedCardAPI = (initialized?: boolean): CardController => {
const api = createCardAPI();
@@ -464,55 +464,12 @@ describe('should initialize', () => {
});
});
it('should adopt query and results when changing to gallery from viewer', async () => {
const baseView = createView({
view: 'media',
camera: 'camera.office',
query: new EventMediaQuery([
{
type: QueryType.Event,
cameraIDs: new Set(['camera.office']),
hasClip: true,
},
]),
queryResults: new QueryResults(),
});
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault
.mockReturnValueOnce(baseView)
.mockReturnValueOnce(createView({ view: 'clips' }));
const viewQueryExecutor = mock<ViewQueryExecutor>();
viewQueryExecutor.getNewQueryModifiers.mockResolvedValue([]);
const manager = new ViewManager(createInitializedCardAPI(), {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
manager.setViewDefault();
expect(manager.getView()?.is('media')).toBeTruthy();
await manager.setViewDefaultWithNewQuery({
params: {
view: 'clips',
},
});
expect(manager.getView()?.is('clips')).toBeTruthy();
expect(manager.getView()?.query).toBe(baseView.query);
expect(manager.getView()?.queryResults).toBe(baseView.queryResults);
expect(viewQueryExecutor.getNewQueryModifiers).not.toHaveBeenCalled();
expect(viewQueryExecutor.getExistingQueryModifiers).not.toHaveBeenCalled();
});
describe('should apply async view modifications', () => {
it('should apply modifications successfully', async () => {
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault.mockReturnValue(createView({ view: 'live' }));
const query = new EventMediaQuery();
const query = new UnifiedQuery();
const queryResults = new QueryResults();
const viewQueryExecutor = mock<ViewQueryExecutor>();
@@ -539,7 +496,7 @@ describe('should apply async view modifications', () => {
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault.mockReturnValueOnce(createView({ view: 'live' }));
const query = new EventMediaQuery();
const query = new UnifiedQuery();
const queryResults = new QueryResults();
const viewQueryExecutor = mock<ViewQueryExecutor>();
@@ -691,47 +648,68 @@ describe('should apply async view modifications', () => {
expect(manager.getView()?.context?.loading?.query).toBe(100);
});
});
});
it('should adopt query and results when changing to gallery from viewer', async () => {
const baseView = createView({
view: 'media',
camera: 'camera.office',
query: new EventMediaQuery([
{
type: QueryType.Event,
cameraIDs: new Set(['camera.office']),
hasClip: true,
},
]),
queryResults: new QueryResults(),
});
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault
.mockReturnValueOnce(baseView)
.mockReturnValueOnce(createView({ view: 'clips' }));
describe('should adopt query results when useful', () => {
const testAdoption = async (
originView: AdvancedCameraCardView | undefined,
targetViewName: AdvancedCameraCardView,
startViewName: AdvancedCameraCardView = 'media',
): Promise<boolean> => {
const startView = createView({
view: startViewName,
camera: 'camera',
query: new UnifiedQuery([createEventQuery('c1')]),
queryResults: new QueryResults(),
context: originView ? { gallery: { originView } } : undefined,
});
const targetView = createView({
view: targetViewName,
camera: 'camera',
});
const viewQueryExecutor = mock<ViewQueryExecutor>();
viewQueryExecutor.getNewQueryModifiers.mockResolvedValue([]);
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault
.mockReturnValueOnce(startView)
.mockReturnValueOnce(targetView);
const manager = new ViewManager(createInitializedCardAPI(), {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
const viewQueryExecutor = mock<ViewQueryExecutor>();
viewQueryExecutor.getNewQueryModifiers.mockResolvedValue([]);
manager.setViewDefault();
expect(manager.getView()?.is('media')).toBeTruthy();
const manager = new ViewManager(createInitializedCardAPI(), {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
await manager.setViewDefaultWithNewQuery({
params: {
view: 'clips',
},
});
manager.setViewDefault();
await manager.setViewDefaultWithNewQuery();
return vi.mocked(viewQueryExecutor.getNewQueryModifiers).mock.calls.length === 0;
};
it('should return false when no originView context', async () => {
expect(await testAdoption(undefined, 'clips')).toBe(false);
});
expect(manager.getView()?.is('clips')).toBeTruthy();
expect(manager.getView()?.query).toBe(baseView.query);
expect(manager.getView()?.queryResults).toBe(baseView.queryResults);
expect(viewQueryExecutor.getNewQueryModifiers).not.toHaveBeenCalled();
expect(viewQueryExecutor.getExistingQueryModifiers).not.toHaveBeenCalled();
it('should return true when originView matches target', async () => {
expect(await testAdoption('clips', 'clips')).toBe(true);
expect(await testAdoption('snapshots', 'snapshots')).toBe(true);
expect(await testAdoption('recordings', 'recordings')).toBe(true);
expect(await testAdoption('reviews', 'reviews')).toBe(true);
});
it('should return false when originView does not match target', async () => {
expect(await testAdoption('clips', 'snapshots')).toBe(false);
expect(await testAdoption('clips', 'recordings')).toBe(false);
expect(await testAdoption('snapshots', 'clips')).toBe(false);
expect(await testAdoption('recordings', 'reviews')).toBe(false);
});
it('should return false when start view is not viewer', async () => {
expect(await testAdoption('clips', 'clips', 'live')).toBe(false);
});
it('should return false when target view is not gallery', async () => {
expect(await testAdoption('clips', 'live')).toBe(false);
});
});
});
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,7 @@
import { ReactiveControllerHost } from 'lit';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { CachedValueController } from '../src/components-lib/cached-value-controller';
import { CachedValueController } from '../../src/components-lib/cached-value-controller';
// @vitest-environment jsdom
describe('CachedValueController', () => {
@@ -1,134 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { ViewManagerEpoch } from '../../../src/card-controller/view/types';
import {
getUpFolderMediaItem,
upFolderClickHandler,
} from '../../../src/components-lib/folder/up-folder';
import { ViewFolder } from '../../../src/view/item';
import { EventMediaQuery, FolderViewQuery } from '../../../src/view/query';
import {
createCardAPI,
createFolder,
createView,
TestViewMedia,
} from '../../test-utils';
describe('upFolderClickHandler', () => {
const item = new TestViewMedia();
it('should ignore non-folder query', () => {
const api = createCardAPI();
const view = createView({
query: new EventMediaQuery(),
});
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const epoch: ViewManagerEpoch = {
manager: api.getViewManager(),
};
upFolderClickHandler(item, new Event('click'), epoch);
expect(api.getViewManager().setViewByParametersWithExistingQuery).not.toBeCalled();
});
it('should ignore folder query without raw query', () => {
const api = createCardAPI();
const view = createView({
query: new FolderViewQuery(),
});
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const epoch: ViewManagerEpoch = {
manager: api.getViewManager(),
};
upFolderClickHandler(item, new Event('click'), epoch);
expect(api.getViewManager().setViewByParametersWithExistingQuery).not.toBeCalled();
});
it('should ignore folder query wihout parent to go up to', () => {
const api = createCardAPI();
const view = createView({
query: new FolderViewQuery({ folder: createFolder(), path: ['path'] }),
});
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const epoch: ViewManagerEpoch = {
manager: api.getViewManager(),
};
upFolderClickHandler(item, new Event('click'), epoch);
expect(api.getViewManager().setViewByParametersWithExistingQuery).not.toBeCalled();
});
it('should go up in the folder hierarchy', () => {
const api = createCardAPI();
const folder = createFolder();
const view = createView({
query: new FolderViewQuery({
folder,
path: ['one', 'two', 'three'],
}),
});
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const epoch: ViewManagerEpoch = {
manager: api.getViewManager(),
};
upFolderClickHandler(item, new Event('click'), epoch);
expect(api.getViewManager().setViewByParametersWithExistingQuery).toBeCalledWith({
params: {
query: expect.any(FolderViewQuery),
},
});
const query = vi.mocked(api.getViewManager().setViewByParametersWithExistingQuery)
.mock.calls[0][0]?.params?.query;
expect(query?.getQuery()).toEqual({
folder,
path: ['one', 'two'],
});
});
});
describe('getUpFolderMediaItem', () => {
it('should ignore non-folder query', () => {
const view = createView({
query: new EventMediaQuery(),
});
expect(getUpFolderMediaItem(view)).toBeNull();
});
it('should ignore folder query without raw query', () => {
const view = createView({
query: new FolderViewQuery(),
});
expect(getUpFolderMediaItem(view)).toBeNull();
});
it('should ignore folder query without parents', () => {
const view = createView({
query: new FolderViewQuery({ folder: createFolder(), path: ['one'] }),
});
expect(getUpFolderMediaItem(view)).toBeNull();
});
it('should get up folder media', () => {
const view = createView({
query: new FolderViewQuery({
folder: createFolder(),
path: ['one', 'two', 'three'],
}),
});
const folderMedia = getUpFolderMediaItem(view);
expect(folderMedia).toBeInstanceOf(ViewFolder);
expect(folderMedia?.getIcon()).toBe('mdi:arrow-up-left');
});
});
@@ -0,0 +1,322 @@
import { assert, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import {
ViewManagerEpoch,
ViewManagerInterface,
} from '../../../src/card-controller/view/types';
import { GalleryController } from '../../../src/components-lib/gallery/controller';
import { THUMBNAIL_WIDTH_DEFAULT } from '../../../src/config/schema/common/controls/thumbnails';
import { FolderConfig } from '../../../src/config/schema/folders';
import { MediaGalleryThumbnailsConfig } from '../../../src/config/schema/media-gallery';
import { ViewFolder, ViewItem, ViewMedia, ViewMediaType } from '../../../src/view/item';
import { QueryResults } from '../../../src/view/query-results';
import { UnifiedQuery } from '../../../src/view/unified-query';
import { UnifiedQueryRunner } from '../../../src/view/unified-query-runner';
import { View } from '../../../src/view/view';
// @vitest-environment jsdom
const createThumbnailConfig = (
config?: Partial<MediaGalleryThumbnailsConfig>,
): MediaGalleryThumbnailsConfig => ({
size: THUMBNAIL_WIDTH_DEFAULT,
show_details: true,
show_favorite_control: true,
show_timeline_control: true,
show_download_control: true,
show_review_control: true,
show_info_control: true,
...config,
});
describe('GalleryController', () => {
it('should construct', () => {
const host = document.createElement('div');
const controller = new GalleryController(host);
expect(controller).toBeTruthy();
expect(controller.getItems()).toBeNull();
});
describe('setItemsFromView', () => {
it('should set items from view', () => {
const host = document.createElement('div');
const controller = new GalleryController(host);
const view = mock<View>();
view.queryResults = mock<QueryResults>();
const item1 = new ViewMedia(ViewMediaType.Clip);
const item2 = new ViewMedia(ViewMediaType.Clip);
vi.mocked(view.queryResults.getResults).mockReturnValue([item1, item2]);
controller.setItemsFromView(view);
// Items should be reversed (newest first)
expect(controller.getItems()).toEqual([item2, item1]);
});
it('should handle null view or results', () => {
const host = document.createElement('div');
const controller = new GalleryController(host);
controller.setItemsFromView(null);
expect(controller.getItems()).toBeNull();
const view = mock<View>();
view.queryResults = null;
controller.setItemsFromView(view);
expect(controller.getItems()).toBeNull();
});
it('should not update items if results are the same', () => {
const host = document.createElement('div');
const controller = new GalleryController(host);
const view = mock<View>();
view.queryResults = mock<QueryResults>();
const item1 = new ViewMedia(ViewMediaType.Clip);
vi.mocked(view.queryResults.getResults).mockReturnValue([item1]);
controller.setItemsFromView(view);
const itemsFirst = controller.getItems();
// Second call with same results object
controller.setItemsFromView(view, view);
expect(controller.getItems()).toBe(itemsFirst);
});
it('should update items if results change', () => {
const host = document.createElement('div');
const controller = new GalleryController(host);
const view1 = mock<View>();
view1.queryResults = mock<QueryResults>();
const item1 = new ViewMedia(ViewMediaType.Clip);
vi.mocked(view1.queryResults.getResults).mockReturnValue([item1]);
controller.setItemsFromView(view1);
const view2 = mock<View>();
view2.queryResults = mock<QueryResults>();
const item2 = new ViewMedia(ViewMediaType.Clip);
vi.mocked(view2.queryResults.getResults).mockReturnValue([item2]);
controller.setItemsFromView(view2, view1);
expect(controller.getItems()).toEqual([item2]);
});
});
it('should set thumbnail size', () => {
const host = document.createElement('div');
const controller = new GalleryController(host);
controller.setThumbnailSize(150);
expect(host.style.getPropertyValue('--advanced-camera-card-thumbnail-size')).toBe(
'150px',
);
controller.setThumbnailSize();
expect(host.style.getPropertyValue('--advanced-camera-card-thumbnail-size')).toBe(
`${THUMBNAIL_WIDTH_DEFAULT}px`,
);
});
describe('getColumnWidth', () => {
it('should return default width if no config', () => {
const host = document.createElement('div');
const controller = new GalleryController(host);
expect(controller.getColumnWidth()).toBe(THUMBNAIL_WIDTH_DEFAULT);
});
it('should return size if details are hidden', () => {
const host = document.createElement('div');
const controller = new GalleryController(host);
expect(
controller.getColumnWidth(
createThumbnailConfig({ size: 123, show_details: false }),
),
).toBe(123);
});
it('should return gallery width if details are shown and items are not all folders', () => {
const host = document.createElement('div');
const controller = new GalleryController(host);
const view = mock<View>();
const item = new ViewMedia(ViewMediaType.Clip);
view.queryResults = mock<QueryResults>();
vi.mocked(view.queryResults.getResults).mockReturnValue([item]);
controller.setItemsFromView(view);
expect(
controller.getColumnWidth(createThumbnailConfig({ show_details: true })),
).toBe(300);
});
it('should return folder width if details are shown and items are all folders', () => {
const host = document.createElement('div');
const controller = new GalleryController(host);
const view = mock<View>();
const item = new ViewFolder(mock<FolderConfig>(), []);
view.queryResults = mock<QueryResults>();
vi.mocked(view.queryResults.getResults).mockReturnValue([item]);
controller.setItemsFromView(view);
expect(
controller.getColumnWidth(createThumbnailConfig({ show_details: true })),
).toBe(200);
});
});
it('should get column count round method', () => {
const host = document.createElement('div');
const controller = new GalleryController(host);
expect(
controller.getColumnCountRoundMethod(
createThumbnailConfig({ show_details: true }),
),
).toBe('floor');
expect(
controller.getColumnCountRoundMethod(
createThumbnailConfig({ show_details: false }),
),
).toBe('ceil');
expect(controller.getColumnCountRoundMethod()).toBe('ceil');
});
describe('extend', () => {
it('should handle missing view or query', async () => {
const host = document.createElement('div');
const controller = new GalleryController(host);
const runner = mock<UnifiedQueryRunner>();
const manager = mock<ViewManagerInterface>();
const epoch = mock<ViewManagerEpoch>({
manager: manager,
});
manager.getView.mockReturnValue(null);
await controller.extend(runner, epoch, 'earlier');
expect(runner.extend).not.toHaveBeenCalled();
const view = mock<View>();
view.query = null;
manager.getView.mockReturnValue(view);
await controller.extend(runner, epoch, 'earlier');
expect(runner.extend).not.toHaveBeenCalled();
});
it('should handle missing results', async () => {
const host = document.createElement('div');
const controller = new GalleryController(host);
const runner = mock<UnifiedQueryRunner>();
const manager = mock<ViewManagerInterface>();
const epoch = mock<ViewManagerEpoch>({
manager: manager,
});
const view = mock<View>();
view.query = mock<UnifiedQuery>();
view.queryResults = mock<QueryResults>();
vi.mocked(view.queryResults.getResults).mockReturnValue(null);
manager.getView.mockReturnValue(view);
await controller.extend(runner, epoch, 'earlier');
expect(runner.extend).not.toHaveBeenCalled();
});
it('should extend and update view', async () => {
const host = document.createElement('div');
const controller = new GalleryController(host);
const runner = mock<UnifiedQueryRunner>();
const manager = mock<ViewManagerInterface>();
const epoch = mock<ViewManagerEpoch>({
manager: manager,
});
const view = mock<View>();
const query = mock<UnifiedQuery>();
const selectedResult = mock<ViewItem>();
const results = [selectedResult];
view.query = query;
view.queryResults = mock<QueryResults>();
vi.mocked(view.queryResults.getResults).mockReturnValue(results);
vi.mocked(view.queryResults.getSelectedResult).mockReturnValue(selectedResult);
manager.getView.mockReturnValue(view);
const extendedQuery = mock<UnifiedQuery>();
const extendedResults = [selectedResult, new ViewMedia(ViewMediaType.Clip)];
runner.extend.mockResolvedValue({
query: extendedQuery,
results: extendedResults,
});
await controller.extend(runner, epoch, 'earlier');
expect(runner.extend).toHaveBeenCalledWith(query, results, 'earlier', {
useCache: true,
});
expect(manager.setViewByParameters).toHaveBeenCalled();
const setViewCalls = vi.mocked(manager.setViewByParameters).mock.calls;
const setViewParams = setViewCalls[0][0];
assert(setViewParams && setViewParams.params);
const newQueryResults = setViewParams.params.queryResults;
assert(newQueryResults);
expect(newQueryResults.getResults()).toEqual(extendedResults);
expect(newQueryResults.getSelectedResult()).toBe(selectedResult);
});
it('should handle extend failure', async () => {
const host = document.createElement('div');
const controller = new GalleryController(host);
const runner = mock<UnifiedQueryRunner>();
const manager = mock<ViewManagerInterface>();
const epoch = mock<ViewManagerEpoch>({
manager: manager,
});
const view = mock<View>();
view.query = mock<UnifiedQuery>();
view.queryResults = mock<QueryResults>();
vi.mocked(view.queryResults.getResults).mockReturnValue([
new ViewMedia(ViewMediaType.Clip),
]);
manager.getView.mockReturnValue(view);
const error = new Error('test error');
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
runner.extend.mockRejectedValue(error);
await controller.extend(runner, epoch, 'earlier');
expect(spy).toHaveBeenCalledWith(error.message);
spy.mockRestore();
expect(manager.setViewByParameters).not.toHaveBeenCalled();
});
it('should not update view if extend returns null', async () => {
const host = document.createElement('div');
const controller = new GalleryController(host);
const runner = mock<UnifiedQueryRunner>();
const manager = mock<ViewManagerInterface>();
const epoch = mock<ViewManagerEpoch>({
manager: manager,
});
const view = mock<View>();
view.query = mock<UnifiedQuery>();
view.queryResults = mock<QueryResults>();
vi.mocked(view.queryResults.getResults).mockReturnValue([
new ViewMedia(ViewMediaType.Clip),
]);
manager.getView.mockReturnValue(view);
runner.extend.mockResolvedValue(null);
await controller.extend(runner, epoch, 'earlier');
expect(manager.setViewByParameters).not.toHaveBeenCalled();
});
});
});
@@ -1,275 +0,0 @@
import { describe, expect, it } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { ViewManagerInterface } from '../../../src/card-controller/view/types';
import { FoldersManager } from '../../../src/card-controller/folders/manager';
import {
FOLDER_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN,
FolderGalleryController,
} from '../../../src/components-lib/gallery/folder-gallery-controller';
import { THUMBNAIL_WIDTH_DEFAULT } from '../../../src/config/schema/common/controls/thumbnails';
import { ViewFolder } from '../../../src/view/item';
import { FolderViewQuery } from '../../../src/view/query';
import { QueryResults } from '../../../src/view/query-results';
import { createFolder, createView, TestViewMedia } from '../../test-utils';
// @vitest-environment jsdom
describe('FolderGalleryController', () => {
describe('should set thumbnail size', () => {
it('should set thumbnail size explicitly', () => {
const host = document.createElement('div');
const controller = new FolderGalleryController(host);
controller.setThumbnailSize(100);
expect(host.style.getPropertyValue('--advanced-camera-card-thumbnail-size')).toBe(
'100px',
);
});
it('should set thumbnail size implicitly', () => {
const host = document.createElement('div');
const controller = new FolderGalleryController(host);
controller.setThumbnailSize();
expect(host.style.getPropertyValue('--advanced-camera-card-thumbnail-size')).toBe(
`${THUMBNAIL_WIDTH_DEFAULT}px`,
);
});
});
describe('should get column width', () => {
it('should get default column width', () => {
const host = document.createElement('div');
const controller = new FolderGalleryController(host);
expect(controller.getColumnWidth()).toBe(THUMBNAIL_WIDTH_DEFAULT);
});
it('should get column width with defailts', () => {
const host = document.createElement('div');
const controller = new FolderGalleryController(host);
expect(
controller.getColumnWidth({
size: 100,
show_details: true,
show_favorite_control: true,
show_timeline_control: true,
show_download_control: true,
}),
).toBe(FOLDER_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN);
});
it('should get column width with explicit size', () => {
const host = document.createElement('div');
const controller = new FolderGalleryController(host);
expect(
controller.getColumnWidth({
size: 142,
show_details: false,
show_favorite_control: true,
show_timeline_control: true,
show_download_control: true,
}),
).toBe(142);
});
});
describe('should get column count round method', () => {
it('should get default column count round method', () => {
const host = document.createElement('div');
const controller = new FolderGalleryController(host);
expect(controller.getColumnCountRoundMethod()).toBe('ceil');
});
it('should get column count round method when details being shown', () => {
const host = document.createElement('div');
const controller = new FolderGalleryController(host);
expect(
controller.getColumnCountRoundMethod({
size: 100,
show_details: true,
show_favorite_control: true,
show_timeline_control: true,
show_download_control: true,
}),
).toBe('floor');
});
});
describe('should handle item clicks', () => {
it('should ignore calls without view', () => {
const host = document.createElement('div');
const controller = new FolderGalleryController(host);
const viewManager = mock<ViewManagerInterface>();
const item = new TestViewMedia();
controller.itemClickHandler(viewManager, item, new Event('click'));
expect(viewManager.setViewByParameters).not.toHaveBeenCalled();
expect(viewManager.setViewByParametersWithExistingQuery).not.toHaveBeenCalled();
});
it('should handle media item click', () => {
const host = document.createElement('div');
const item_1 = new TestViewMedia();
const item_2 = new TestViewMedia();
const controller = new FolderGalleryController(host);
const event = new Event('click');
const view = createView({
queryResults: new QueryResults({ results: [item_1, item_2], selectedIndex: 0 }),
});
const viewManager = mock<ViewManagerInterface>();
viewManager.getView.mockReturnValue(view);
controller.itemClickHandler(viewManager, item_2, event);
expect(viewManager.setViewByParameters).toHaveBeenCalledWith({
params: {
view: 'media',
queryResults: expect.any(QueryResults),
},
});
const newQueryResults =
viewManager.setViewByParameters.mock.calls[0][0]?.params?.queryResults;
expect(newQueryResults).toBeInstanceOf(QueryResults);
expect(newQueryResults?.getSelectedResult()).toBe(item_2);
});
describe('should handle folder click', () => {
it('should handle normal folder click', () => {
const folder = createFolder();
const folderItem = new ViewFolder(folder, {
id: 'parent',
});
const controller = new FolderGalleryController(document.createElement('div'));
const event = new Event('click');
const view = createView({
queryResults: new QueryResults({
results: [new TestViewMedia(), folderItem],
selectedIndex: 0,
}),
query: new FolderViewQuery({
folder,
path: [{ ha: { id: 'grandparent' } }],
}),
});
const viewManager = mock<ViewManagerInterface>();
viewManager.getView.mockReturnValue(view);
const foldersManager = mock<FoldersManager>();
foldersManager.generateChildFolderQuery.mockReturnValue({
folder,
path: [
{ ha: { id: 'grandparent' } },
{ folder: folderItem, ha: { id: 'parent' } },
],
});
controller.itemClickHandler(viewManager, folderItem, event, foldersManager);
expect(viewManager.setViewByParametersWithExistingQuery).toHaveBeenCalledWith({
params: {
query: expect.any(FolderViewQuery),
},
});
const newQuery =
viewManager.setViewByParametersWithExistingQuery.mock.calls[0][0]?.params
?.query;
expect(newQuery).toBeInstanceOf(FolderViewQuery);
expect(newQuery?.getQuery()).toEqual({
folder,
path: [
{ ha: { id: 'grandparent' } },
{ folder: folderItem, ha: { id: 'parent' } },
],
});
});
it('should ignore folder click without folders manager', () => {
const folder = createFolder();
const folderItem = new ViewFolder(folder, {
id: 'parent',
});
const controller = new FolderGalleryController(document.createElement('div'));
const event = new Event('click');
const view = createView({
queryResults: new QueryResults({
results: [new TestViewMedia(), folderItem],
selectedIndex: 0,
}),
query: new FolderViewQuery({
folder,
path: [{ ha: { id: 'grandparent' } }],
}),
});
const viewManager = mock<ViewManagerInterface>();
viewManager.getView.mockReturnValue(view);
controller.itemClickHandler(viewManager, folderItem, event);
expect(viewManager.setViewByParametersWithExistingQuery).not.toHaveBeenCalled();
});
it('should handle folder click without query', () => {
const controller = new FolderGalleryController(document.createElement('div'));
const view = createView({
query: null,
});
const viewManager = mock<ViewManagerInterface>();
viewManager.getView.mockReturnValue(view);
controller.itemClickHandler(
viewManager,
new ViewFolder(createFolder(), {
id: 'parent',
}),
new Event('click'),
);
expect(viewManager.setViewByParametersWithExistingQuery).not.toBeCalled();
expect(viewManager.setViewByParameters).not.toBeCalled();
});
it('should handle folder click without path', () => {
const controller = new FolderGalleryController(document.createElement('div'));
const folder = createFolder();
const view = createView({
query: new FolderViewQuery({
folder,
path: [{ ha: { id: 'id' } }],
}),
});
const viewManager = mock<ViewManagerInterface>();
viewManager.getView.mockReturnValue(view);
controller.itemClickHandler(
viewManager,
new ViewFolder(folder),
new Event('click'),
);
expect(viewManager.setViewByParametersWithExistingQuery).not.toBeCalled();
expect(viewManager.setViewByParameters).not.toBeCalled();
});
});
});
});
@@ -1,427 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { ExtendedMediaQueryResult } from '../../../src/camera-manager/manager';
import {
EventQuery,
QueryType,
RecordingQuery,
} from '../../../src/camera-manager/types';
import { ViewManagerEpoch } from '../../../src/card-controller/view/types';
import { ViewManager } from '../../../src/card-controller/view/view-manager';
import {
MEDIA_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN,
MediaGalleryController,
} from '../../../src/components-lib/gallery/media-gallery-controller';
import { THUMBNAIL_WIDTH_DEFAULT } from '../../../src/config/schema/common/controls/thumbnails';
import {
EventMediaQuery,
FolderViewQuery,
RecordingMediaQuery,
} from '../../../src/view/query';
import { QueryResults } from '../../../src/view/query-results';
import {
createCameraManager,
createLitElement,
createView,
TestViewMedia,
} from '../../test-utils';
// @vitest-environment jsdom
describe('MediaGalleryController', () => {
afterEach(() => {
vi.restoreAllMocks();
});
describe('getMedia', () => {
it('should return null initially', () => {
expect(new MediaGalleryController(createLitElement()).getMedia()).toBeNull();
});
});
describe('setMediaFromView', () => {
it('should set media from view if media is null', () => {
const controller = new MediaGalleryController(createLitElement());
controller.setMediaFromView(null);
expect(controller.getMedia()).toBeNull();
});
it('should set media from view if results are null', () => {
const controller = new MediaGalleryController(createLitElement());
controller.setMediaFromView(createView({ queryResults: new QueryResults() }));
expect(controller.getMedia()).toEqual([]);
});
it('should update media when query results first set', () => {
const media_1 = new TestViewMedia({ id: 'one' });
const media_2 = new TestViewMedia({ id: 'two' });
const newView = createView({
queryResults: new QueryResults({ results: [media_1, media_2] }),
});
const controller = new MediaGalleryController(createLitElement());
controller.setMediaFromView(newView);
expect(controller.getMedia()).toEqual([media_2, media_1]);
});
it('should update media when query results change', () => {
const media_1 = new TestViewMedia({ id: 'one' });
const media_2 = new TestViewMedia({ id: 'two' });
const media_3 = new TestViewMedia({ id: 'three' });
const oldView = createView({
queryResults: new QueryResults({ results: [media_1, media_2] }),
});
const newView = createView({
queryResults: new QueryResults({ results: [media_2, media_3] }),
});
const controller = new MediaGalleryController(createLitElement());
controller.setMediaFromView(newView, oldView);
expect(controller.getMedia()).toEqual([media_3, media_2]);
});
it('should not update media if query results are the same', () => {
const media_1 = new TestViewMedia({ id: 'one' });
const media_2 = new TestViewMedia({ id: 'two' });
const results = [media_1, media_2];
const oldView = createView({
queryResults: new QueryResults({ results }),
});
const newView = createView({
queryResults: new QueryResults({ results }),
});
const controller = new MediaGalleryController(createLitElement());
controller.setMediaFromView(oldView);
const media = controller.getMedia();
controller.setMediaFromView(newView, oldView);
expect(controller.getMedia()).toBe(media);
});
});
describe('should set thumbnail size', () => {
it('should set thumbnail size explicitly', () => {
const host = document.createElement('div');
const controller = new MediaGalleryController(host);
controller.setThumbnailSize(100);
expect(host.style.getPropertyValue('--advanced-camera-card-thumbnail-size')).toBe(
'100px',
);
});
it('should set thumbnail size implicitly', () => {
const host = document.createElement('div');
const controller = new MediaGalleryController(host);
controller.setThumbnailSize();
expect(host.style.getPropertyValue('--advanced-camera-card-thumbnail-size')).toBe(
`${THUMBNAIL_WIDTH_DEFAULT}px`,
);
});
});
describe('should get column width', () => {
it('should get default column width', () => {
const host = document.createElement('div');
const controller = new MediaGalleryController(host);
expect(controller.getColumnWidth()).toBe(THUMBNAIL_WIDTH_DEFAULT);
});
it('should get column width with defailts', () => {
const host = document.createElement('div');
const controller = new MediaGalleryController(host);
expect(
controller.getColumnWidth({
size: 100,
show_details: true,
show_favorite_control: true,
show_timeline_control: true,
show_download_control: true,
}),
).toBe(MEDIA_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN);
});
it('should get column width with explicit size', () => {
const host = document.createElement('div');
const controller = new MediaGalleryController(host);
expect(
controller.getColumnWidth({
size: 142,
show_details: false,
show_favorite_control: true,
show_timeline_control: true,
show_download_control: true,
}),
).toBe(142);
});
});
describe('should get column count round method', () => {
it('should get default column count round method', () => {
const host = document.createElement('div');
const controller = new MediaGalleryController(host);
expect(controller.getColumnCountRoundMethod()).toBe('ceil');
});
it('should get column count round method when details being shown', () => {
const host = document.createElement('div');
const controller = new MediaGalleryController(host);
expect(
controller.getColumnCountRoundMethod({
size: 100,
show_details: true,
show_favorite_control: true,
show_timeline_control: true,
show_download_control: true,
}),
).toBe('floor');
});
});
describe('extendMediaGallery', () => {
it('should do nothing if no view is available', async () => {
const controller = new MediaGalleryController(createLitElement());
const cameraManager = createCameraManager();
const viewManagerEpoch = mock<ViewManagerEpoch>();
viewManagerEpoch.manager.getView = vi.fn().mockReturnValue(null);
await controller.extendMediaGallery(cameraManager, viewManagerEpoch, 'earlier');
expect(cameraManager.extendMediaQueries).not.toHaveBeenCalled();
});
it('should do nothing if no queries are available', async () => {
const controller = new MediaGalleryController(createLitElement());
const cameraManager = createCameraManager();
const viewManagerEpoch = mock<ViewManagerEpoch>();
viewManagerEpoch.manager.getView = vi.fn().mockReturnValue(
createView({
query: new EventMediaQuery(),
queryResults: new QueryResults({ results: [new TestViewMedia()] }),
}),
);
await controller.extendMediaGallery(cameraManager, viewManagerEpoch, 'earlier');
expect(cameraManager.extendMediaQueries).not.toHaveBeenCalled();
});
it('should do nothing if no query results are available', async () => {
const controller = new MediaGalleryController(createLitElement());
const cameraManager = createCameraManager();
const viewManagerEpoch = mock<ViewManagerEpoch>();
viewManagerEpoch.manager.getView = vi.fn().mockReturnValue(createView());
await controller.extendMediaGallery(cameraManager, viewManagerEpoch, 'earlier');
expect(cameraManager.extendMediaQueries).not.toHaveBeenCalled();
});
it('should do nothing if non-media query is present', async () => {
const controller = new MediaGalleryController(createLitElement());
const cameraManager = createCameraManager();
const viewManagerEpoch = mock<ViewManagerEpoch>();
viewManagerEpoch.manager.getView = vi
.fn()
.mockReturnValue(createView({ query: new FolderViewQuery() }));
await controller.extendMediaGallery(cameraManager, viewManagerEpoch, 'earlier');
expect(cameraManager.extendMediaQueries).not.toHaveBeenCalled();
});
it('should successfully extend event media queries', async () => {
const controller = new MediaGalleryController(createLitElement());
const cameraManager = createCameraManager();
const viewManagerEpoch = mock<ViewManagerEpoch>({ manager: mock<ViewManager>() });
const existingRawQueries: EventQuery[] = [
{ type: QueryType.Event, cameraIDs: new Set(['camera.office']) },
];
const existingMedia = [new TestViewMedia()];
const baseView = createView({
query: new EventMediaQuery(existingRawQueries),
queryResults: new QueryResults({ results: existingMedia }),
});
viewManagerEpoch.manager.getView = vi.fn().mockReturnValue(baseView);
const newQueries: EventQuery[] = [
{ type: QueryType.Event, cameraIDs: new Set(['camera.office']) },
];
const newResults = [new TestViewMedia()];
const extension: ExtendedMediaQueryResult<EventQuery> = {
queries: newQueries,
results: newResults,
};
vi.mocked(cameraManager.extendMediaQueries).mockResolvedValue(extension);
await controller.extendMediaGallery(cameraManager, viewManagerEpoch, 'earlier');
expect(cameraManager.extendMediaQueries).toBeCalledWith(
existingRawQueries,
existingMedia,
'earlier',
{ useCache: true },
);
expect(viewManagerEpoch.manager.setViewByParameters).toBeCalledWith({
baseView,
params: {
query: expect.any(EventMediaQuery),
queryResults: expect.any(QueryResults),
},
});
const callArguments = vi.mocked(viewManagerEpoch.manager.setViewByParameters).mock
.lastCall?.[0];
expect(callArguments?.params?.query?.getQuery()).toEqual(newQueries);
expect(callArguments?.params?.queryResults?.getResults()).toEqual(newResults);
});
});
it('should successfully extend recording media queries', async () => {
const controller = new MediaGalleryController(createLitElement());
const cameraManager = createCameraManager();
const viewManagerEpoch = mock<ViewManagerEpoch>({ manager: mock<ViewManager>() });
const existingRawQueries: RecordingQuery[] = [
{ type: QueryType.Recording, cameraIDs: new Set(['camera.office']) },
];
const existingMedia = [new TestViewMedia()];
const baseView = createView({
query: new RecordingMediaQuery(existingRawQueries),
queryResults: new QueryResults({ results: existingMedia }),
});
viewManagerEpoch.manager.getView = vi.fn().mockReturnValue(baseView);
const newQueries: RecordingQuery[] = [
{ type: QueryType.Recording, cameraIDs: new Set(['camera.office']) },
];
const newResults = [new TestViewMedia()];
const extension: ExtendedMediaQueryResult<RecordingQuery> = {
queries: newQueries,
results: newResults,
};
vi.mocked(cameraManager.extendMediaQueries).mockResolvedValue(extension);
await controller.extendMediaGallery(cameraManager, viewManagerEpoch, 'earlier');
expect(cameraManager.extendMediaQueries).toBeCalledWith(
existingRawQueries,
existingMedia,
'earlier',
{ useCache: true },
);
expect(viewManagerEpoch.manager.setViewByParameters).toBeCalledWith({
baseView,
params: {
query: expect.any(RecordingMediaQuery),
queryResults: expect.any(QueryResults),
},
});
const callArguments = vi.mocked(viewManagerEpoch.manager.setViewByParameters).mock
.lastCall?.[0];
expect(callArguments?.params?.query?.getQuery()).toEqual(newQueries);
expect(callArguments?.params?.queryResults?.getResults()).toEqual(newResults);
});
it('should handle errors gracefully', async () => {
const controller = new MediaGalleryController(createLitElement());
const cameraManager = createCameraManager();
const viewManagerEpoch = mock<ViewManagerEpoch>({ manager: mock<ViewManager>() });
viewManagerEpoch.manager.getView = vi.fn().mockReturnValue(
createView({
query: new EventMediaQuery([
{ type: QueryType.Event, cameraIDs: new Set(['camera.office']) },
]),
queryResults: new QueryResults({ results: [new TestViewMedia()] }),
}),
);
vi.mocked(cameraManager.extendMediaQueries).mockRejectedValue(
new Error('Test error'),
);
const consoleSpy = vi.spyOn(global.console, 'warn').mockReturnValue(undefined);
await controller.extendMediaGallery(cameraManager, viewManagerEpoch, 'earlier');
expect(viewManagerEpoch.manager.setViewByParameters).not.toHaveBeenCalled();
expect(consoleSpy).toBeCalledWith('Test error');
});
it('should handle null extension gracefully', async () => {
const controller = new MediaGalleryController(createLitElement());
const cameraManager = createCameraManager();
const viewManagerEpoch = mock<ViewManagerEpoch>({ manager: mock<ViewManager>() });
viewManagerEpoch.manager.getView = vi.fn().mockReturnValue(
createView({
query: new EventMediaQuery([
{ type: QueryType.Event, cameraIDs: new Set(['camera.office']) },
]),
queryResults: new QueryResults({ results: [new TestViewMedia()] }),
}),
);
vi.mocked(cameraManager.extendMediaQueries).mockResolvedValue(null);
await controller.extendMediaGallery(cameraManager, viewManagerEpoch, 'earlier');
expect(viewManagerEpoch.manager.setViewByParameters).not.toHaveBeenCalled();
});
describe('should handle item click', () => {
it('should do nothing without a view', () => {
const controller = new MediaGalleryController(createLitElement());
const viewManager = mock<ViewManager>();
controller.itemClickHandler(viewManager, 0, new Event('click'));
expect(viewManager.setViewByParameters).not.toHaveBeenCalled();
});
it('should change view to selected item', () => {
const media = [
new TestViewMedia({ id: 'zero' }),
new TestViewMedia({ id: 'one' }),
];
const view = createView({
queryResults: new QueryResults({ results: media, selectedIndex: 0 }),
});
const controller = new MediaGalleryController(createLitElement());
controller.setMediaFromView(view);
const viewManager = mock<ViewManager>();
viewManager.getView.mockReturnValue(view);
controller.itemClickHandler(
viewManager,
// As the media in the gallery is reversed, passing in 0 as a
// reversedIndex argument is requesting the selection of the media item
// with index 1 (from an array of 2 media items).
0,
new Event('click'),
);
expect(viewManager.setViewByParameters).toBeCalledWith({
params: {
view: 'media',
queryResults: expect.any(QueryResults),
},
});
const newQueryResults = vi.mocked(viewManager.setViewByParameters).mock
.lastCall?.[0]?.params?.queryResults;
expect(newQueryResults?.getSelectedResult()).toEqual(media[1]);
});
});
});
@@ -22,6 +22,12 @@ describe('IconController', () => {
);
});
it('should return tplink SVG for tplink icon', () => {
expect(new IconController().getCustomIcon({ icon: 'tplink' })).toMatch(
/tplink.svg$/,
);
});
it('should return iris SVG for iris icon', () => {
expect(new IconController().getCustomIcon({ icon: 'iris' })).toMatch(/iris.svg$/);
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,501 @@
import { format } from 'date-fns';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { CameraManager } from '../../../src/camera-manager/manager';
import { ViewItemManager } from '../../../src/card-controller/view/item-manager';
import { ViewManagerEpoch } from '../../../src/card-controller/view/types';
import {
MediaDetailsController,
OverlayControlsContext,
} from '../../../src/components-lib/media/details-controller';
import { OverlayMessageControl } from '../../../src/types';
import { formatDateAndTime } from '../../../src/utils/basic';
import { ViewFolder, ViewMediaType } from '../../../src/view/item';
import { createCardAPI, createFolder, TestViewMedia } from '../../test-utils';
describe('MediaDetailsController', () => {
describe('should set heading', () => {
it('should set heading on event with what, tags and score', () => {
const item = new TestViewMedia({
what: ['person', 'car'],
tags: ['tag1', 'tag2'],
score: 0.5,
});
const controller = new MediaDetailsController();
controller.calculate(null, item);
expect(controller.getHeading()?.title).toBe('Person, Car: Tag1, Tag2 50.00%');
});
it('should set heading on event with tags', () => {
const item = new TestViewMedia({
tags: ['tag1', 'tag2'],
});
const controller = new MediaDetailsController();
controller.calculate(null, item);
expect(controller.getHeading()?.title).toBe('Tag1, Tag2');
});
it('should set heading on event with what', () => {
const item = new TestViewMedia({
what: ['person', 'car'],
});
const controller = new MediaDetailsController();
controller.calculate(null, item);
expect(controller.getHeading()?.title).toBe('Person, Car');
});
it('should set null heading on event with no other information', () => {
const item = new TestViewMedia({
mediaType: ViewMediaType.Snapshot,
what: null,
tags: null,
score: null,
});
const controller = new MediaDetailsController();
controller.calculate(null, item);
expect(controller.getHeading()).toBeNull();
});
it('should set heading on recording with camera metadata', () => {
const cameraManager = mock<CameraManager>();
cameraManager.getCameraMetadata.mockReturnValue({
title: 'Camera Title',
icon: { icon: 'mdi:cow' },
});
const item = new TestViewMedia({
mediaType: ViewMediaType.Recording,
});
const controller = new MediaDetailsController();
controller.calculate(cameraManager, item);
expect(controller.getHeading()?.title).toBe('Camera Title');
});
it('should set heading on recording without camera metadata', () => {
const item = new TestViewMedia({
mediaType: ViewMediaType.Recording,
});
const controller = new MediaDetailsController();
controller.calculate(null, item);
expect(controller.getHeading()).toBeNull();
});
it('should set no heading on folder', () => {
const item = new ViewFolder(createFolder(), []);
const controller = new MediaDetailsController();
controller.calculate(null, item);
expect(controller.getHeading()).toBeNull();
});
});
describe('should set details', () => {
describe('should have title in details', () => {
it('should have icon with title when there are other details', () => {
const item = new TestViewMedia({
title: 'Test Event',
where: ['where1', 'where2'],
});
const controller = new MediaDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).toContainEqual({
title: 'Test Event',
icon: { icon: 'mdi:rename' },
hint: 'Title',
});
});
it('should not have icon with title when there are no other details', () => {
const item = new TestViewMedia({
title: 'Test Event',
});
const controller = new MediaDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).toEqual([
{
title: 'Test Event',
},
]);
});
it('should not have title with a start time', () => {
const item = new TestViewMedia({
title: 'Test Event',
startTime: new Date('2025-05-22T21:12:00Z'),
});
const controller = new MediaDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).not.toContainEqual(
expect.objectContaining({
title: 'Test Event',
}),
);
});
});
it('should have start time in details', () => {
const startTime = new Date('2025-05-18T17:03:00Z');
const item = new TestViewMedia({
startTime,
});
const controller = new MediaDetailsController();
controller.calculate(null, item);
// Use formatDateAndTime to generate expected value (formats in local time with seconds)
expect(controller.getDetails()).toContainEqual({
title: formatDateAndTime(startTime, true),
hint: 'Start',
icon: { icon: 'mdi:calendar-clock-outline' },
});
});
describe('should have duration in details', () => {
it('should have duration in details', () => {
const item = new TestViewMedia({
startTime: new Date('2025-05-18T17:03:00Z'),
endTime: new Date('2025-05-18T17:04:00Z'),
});
const controller = new MediaDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).toContainEqual({
title: '1m 0s',
hint: 'Duration',
icon: { icon: 'mdi:clock-outline' },
});
});
it('should have in-progress in details', () => {
const item = new TestViewMedia({
startTime: new Date('2025-05-18T17:03:00Z'),
endTime: null,
inProgress: true,
});
const controller = new MediaDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).toContainEqual({
title: 'In Progress',
hint: 'Duration',
icon: { icon: 'mdi:clock-outline' },
});
});
it('should have duration and in-progress in details', () => {
const item = new TestViewMedia({
startTime: new Date('2025-05-18T17:03:00Z'),
endTime: new Date('2025-05-18T17:04:00Z'),
inProgress: true,
});
const controller = new MediaDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).toContainEqual({
title: '1m 0s In Progress',
hint: 'Duration',
icon: { icon: 'mdi:clock-outline' },
});
});
});
it('should have camera title in details', () => {
const cameraManager = mock<CameraManager>();
cameraManager.getCameraMetadata.mockReturnValue({
title: 'Camera Title',
icon: { icon: 'mdi:cow' },
});
const item = new TestViewMedia({
cameraID: 'camera_1',
});
const controller = new MediaDetailsController();
controller.calculate(cameraManager, item);
expect(controller.getDetails()).toContainEqual({
title: 'Camera Title',
hint: 'Camera',
icon: { icon: 'mdi:cctv' },
});
});
it('should have where in details', () => {
const item = new TestViewMedia({
cameraID: 'camera_1',
where: ['where1', 'where2'],
});
const controller = new MediaDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).toContainEqual({
title: 'Where1, Where2',
hint: 'Where',
icon: { icon: 'mdi:map-marker-outline' },
});
});
it('should have tags in details', () => {
const item = new TestViewMedia({
cameraID: 'camera_1',
tags: ['tag1', 'tag2'],
});
const controller = new MediaDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).toContainEqual({
title: 'Tag1, Tag2',
hint: 'Tag',
icon: { icon: 'mdi:tag' },
});
});
it('should have seek in details', () => {
const item = new TestViewMedia();
const seekTime = new Date('2025-05-20T07:14:57Z');
const controller = new MediaDetailsController();
controller.calculate(null, item, seekTime);
// Use format() to generate expected value (formats in local time)
expect(controller.getDetails()).toContainEqual({
title: format(seekTime, 'HH:mm:ss'),
hint: 'Seek',
icon: { icon: 'mdi:clock-fast' },
});
});
it('should set heading on review', () => {
const item = new TestViewMedia({
mediaType: ViewMediaType.Review,
title: 'Review Title',
severity: 'high',
});
const controller = new MediaDetailsController();
controller.calculate(null, item);
const heading = controller.getHeading();
expect(heading?.title).toBe('Review Title');
expect(heading?.emphasis).toBe('high');
expect(heading?.icon).toEqual({ icon: 'mdi:circle-medium' });
expect(heading?.hint).toBe('Severity: High');
});
it('should set heading on review without severity', () => {
const item = new TestViewMedia({
mediaType: ViewMediaType.Review,
title: 'Review Title',
severity: null,
});
const controller = new MediaDetailsController();
controller.calculate(null, item);
const heading = controller.getHeading();
expect(heading?.title).toBe('Review Title');
expect(heading?.emphasis).toBeUndefined();
});
it('should set null heading on review with no title', () => {
const item = new TestViewMedia({
mediaType: ViewMediaType.Review,
title: null,
});
const controller = new MediaDetailsController();
controller.calculate(null, item);
expect(controller.getHeading()).toBeNull();
});
it('should calculate with null item', () => {
const controller = new MediaDetailsController();
controller.calculate(null, undefined);
expect(controller.getHeading()).toBeNull();
expect(controller.getDetails()).toEqual([]);
});
});
describe('should get message', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('should get message', () => {
const item = new TestViewMedia({
title: 'Test Title',
what: ['person'],
description: 'Test Description',
});
const controller = new MediaDetailsController();
controller.calculate(null, item);
const message = controller.getMessage();
expect(message.heading?.title).toBe('Person');
expect(message.details).toContainEqual({
title: 'Test Title',
});
expect(message.text).toBe('Test Description');
});
it('should get message without media', () => {
const item = new ViewFolder(createFolder(), []);
const controller = new MediaDetailsController();
controller.calculate(null, item);
const message = controller.getMessage();
expect(message.text).toBeUndefined();
});
it('should get message with null description', () => {
const item = new TestViewMedia({
description: null,
});
const controller = new MediaDetailsController();
controller.calculate(null, item);
const message = controller.getMessage();
expect(message.text).toBeUndefined();
});
it('should get message with controls', async () => {
const item = new TestViewMedia({
title: 'Test Title',
mediaType: ViewMediaType.Review,
id: 'review_id',
startTime: new Date(),
});
const viewManagerEpoch = mock<ViewManagerEpoch>();
const cardAPI = createCardAPI();
viewManagerEpoch.manager = cardAPI.getViewManager();
const viewItemManager = mock<ViewItemManager>();
const context = {
capabilities: {
canFavorite: true,
canDownload: true,
},
viewItemManager: viewItemManager,
viewManagerEpoch: viewManagerEpoch,
};
const controller = new MediaDetailsController();
controller.calculate(null, item);
const message = controller.getMessage(context);
const controls = message.controls;
expect(controls).toHaveLength(4);
vi.spyOn(console, 'warn').mockImplementation(() => {});
// 1. Review control
expect(controls?.[0].title).toBe('Mark as reviewed');
const reviewResult = await controls?.[0].callback?.();
expect(reviewResult).not.toBeNull();
// 1b. Review control (failure)
viewItemManager.reviewMedia.mockRejectedValue(new Error('fail'));
const reviewFailureResult = await controls?.[0].callback?.();
expect(reviewFailureResult).toBeNull();
// 2. Favorite control
expect(controls?.[1].title).toBe('Media will be indefinitely retained');
const favoriteResult = await controls?.[1].callback?.();
expect(favoriteResult).not.toBeNull();
// 2b. Favorite control (failure)
viewItemManager.favorite.mockRejectedValue(new Error('fail'));
const favoriteFailureResult = await controls?.[1].callback?.();
expect(favoriteFailureResult).toBeNull();
// 3. Download control
expect(controls?.[2].title).toBe('Download media');
const downloadResult = await controls?.[2].callback?.();
expect(downloadResult).toBeNull();
// 4. Timeline control
expect(controls?.[3].title).toBe('See media in timeline');
const timelineResult = await controls?.[3].callback?.();
expect(timelineResult).toBeNull();
});
it('should get message with controls for already reviewed/favorited items', () => {
const item = new TestViewMedia({
mediaType: ViewMediaType.Review,
reviewed: true,
favorite: true,
});
const context = {
capabilities: {
canFavorite: true,
canDownload: false,
},
};
const controller = new MediaDetailsController();
controller.calculate(null, item);
const message = controller.getMessage(context);
const controls = message.controls;
expect(controls).toHaveLength(2);
expect(controls?.[0].title).toBe('Mark as unreviewed');
expect(controls?.[0].icon).toEqual({ icon: 'mdi:check-circle' });
expect(controls?.[1].emphasis).toBe('medium');
expect(controls?.[1].icon).toEqual({ icon: 'mdi:star' });
});
it('should get message with controls when item has no ID', () => {
const item = new TestViewMedia({
id: null,
});
const context = {
capabilities: {
canFavorite: false,
canDownload: true,
},
};
const controller = new MediaDetailsController();
controller.calculate(null, item);
const message = controller.getMessage(context);
expect(message.controls).toHaveLength(0);
});
it('should get message with controls when context has no capabilities', () => {
const item = new TestViewMedia({
id: 'id',
});
const context = {};
const controller = new MediaDetailsController();
controller.calculate(null, item);
const message = controller.getMessage(context);
expect(message.controls).toHaveLength(0);
});
it('should get empty controls when item is null', () => {
const controller = new MediaDetailsController();
// Directly call protected method via casting to test the null item branch.
// Use cast to unknown first to avoid any-related lint errors.
const controls = (
controller as unknown as {
_getControls: (context: OverlayControlsContext) => OverlayMessageControl[];
}
)._getControls({});
expect(controls).toEqual([]);
});
});
});
@@ -5,6 +5,7 @@ import { Capabilities } from '../../src/camera-manager/capabilities.js';
import { CameraManager } from '../../src/camera-manager/manager.js';
import { CameraManagerCameraMetadata } from '../../src/camera-manager/types.js';
import { FoldersManager } from '../../src/card-controller/folders/manager.js';
import { FolderQuery } from '../../src/card-controller/folders/types';
import { FullscreenManager } from '../../src/card-controller/fullscreen/fullscreen-manager.js';
import { MediaPlayerManager } from '../../src/card-controller/media-player-manager.js';
import { MicrophoneManager } from '../../src/card-controller/microphone-manager.js';
@@ -18,11 +19,12 @@ import { ViewDisplayMode } from '../../src/config/schema/common/display.js';
import { MenuItem } from '../../src/config/schema/elements/custom/menu/types.js';
import { AdvancedCameraCardConfig } from '../../src/config/schema/types.js';
import { HomeAssistant } from '../../src/ha/types.js';
import { QuerySource } from '../../src/query-source';
import { MediaPlayerController, PTZMovementType } from '../../src/types.js';
import { createGeneralAction, createViewAction } from '../../src/utils/action.js';
import { ViewMedia, ViewMediaType } from '../../src/view/item.js';
import { QueryResults } from '../../src/view/query-results.js';
import { FolderViewQuery } from '../../src/view/query.js';
import { UnifiedQuery } from '../../src/view/unified-query.js';
import {
getCameraIDsForViewName,
isViewSupportedByCamera,
@@ -559,7 +561,9 @@ describe('MenuButtonController', () => {
describe('should have clips menu button', () => {
it('when in clips view', () => {
const viewManager = mock<ViewManager>();
vi.mocked(isViewSupportedByCamera).mockReturnValue(true);
vi.mocked(isViewSupportedByCamera).mockImplementation(
(view) => view !== 'reviews',
);
const buttons = calculateButtons(controller, {
view: createView({ view: 'clips' }),
viewManager: viewManager,
@@ -579,7 +583,9 @@ describe('MenuButtonController', () => {
it('when not in clips view', () => {
const viewManager = mock<ViewManager>();
vi.mocked(isViewSupportedByCamera).mockReturnValue(true);
vi.mocked(isViewSupportedByCamera).mockImplementation(
(view) => view !== 'reviews',
);
const buttons = calculateButtons(controller, {
viewManager: viewManager,
});
@@ -607,12 +613,38 @@ describe('MenuButtonController', () => {
expect.arrayContaining([expect.objectContaining({ title: 'Clips gallery' })]),
);
});
it('should be hidden when reviews are supported', () => {
vi.mocked(isViewSupportedByCamera).mockImplementation(
(view) => view === 'clips' || view === 'reviews',
);
const buttons = calculateButtons(controller);
expect(buttons).not.toEqual(
expect.arrayContaining([expect.objectContaining({ title: 'Clips gallery' })]),
);
});
it('should be shown when reviews are supported but button is explicitly enabled', () => {
vi.mocked(isViewSupportedByCamera).mockImplementation(
(view) => view === 'clips' || view === 'reviews',
);
const buttons = calculateButtons(controller, {
config: createConfig({ menu: { buttons: { clips: { enabled: true } } } }),
});
expect(buttons).toContainEqual(
expect.objectContaining({ title: 'Clips gallery', enabled: true }),
);
});
});
describe('should have snapshots menu button', () => {
it('when in snapshots view', () => {
const viewManager = mock<ViewManager>();
vi.mocked(isViewSupportedByCamera).mockReturnValue(true);
vi.mocked(isViewSupportedByCamera).mockImplementation(
(view) => view !== 'reviews',
);
const buttons = calculateButtons(controller, {
view: createView({ view: 'snapshots' }),
viewManager: viewManager,
@@ -638,7 +670,9 @@ describe('MenuButtonController', () => {
it('when not in snapshots view', () => {
const viewManager = mock<ViewManager>();
vi.mocked(isViewSupportedByCamera).mockReturnValue(true);
vi.mocked(isViewSupportedByCamera).mockImplementation(
(view) => view !== 'reviews',
);
const buttons = calculateButtons(controller, {
viewManager: viewManager,
});
@@ -674,6 +708,97 @@ describe('MenuButtonController', () => {
]),
);
});
it('should be hidden when reviews are supported', () => {
vi.mocked(isViewSupportedByCamera).mockImplementation(
(view) => view === 'snapshots' || view === 'reviews',
);
const buttons = calculateButtons(controller);
expect(buttons).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ title: 'Snapshots gallery' }),
]),
);
});
it('should be shown when reviews are supported but button is explicitly enabled', () => {
vi.mocked(isViewSupportedByCamera).mockImplementation(
(view) => view === 'snapshots' || view === 'reviews',
);
const buttons = calculateButtons(controller, {
config: createConfig({ menu: { buttons: { snapshots: { enabled: true } } } }),
});
expect(buttons).toContainEqual(
expect.objectContaining({ title: 'Snapshots gallery', enabled: true }),
);
});
});
describe('should have reviews menu button', () => {
it('when in reviews view', () => {
const viewManager = mock<ViewManager>();
vi.mocked(isViewSupportedByCamera).mockReturnValue(true);
const buttons = calculateButtons(controller, {
view: createView({ view: 'reviews' }),
viewManager: viewManager,
});
expect(buttons).toContainEqual({
icon: 'mdi:play-box-multiple',
enabled: true,
priority: 50,
type: 'custom:advanced-camera-card-menu-icon',
title: 'Reviews gallery',
style: { color: 'var(--advanced-camera-card-menu-button-active-color)' },
tap_action: {
action: 'fire-dom-event',
advanced_camera_card_action: 'reviews',
},
hold_action: {
action: 'fire-dom-event',
advanced_camera_card_action: 'review',
},
});
});
it('when not in reviews view', () => {
const viewManager = mock<ViewManager>();
vi.mocked(isViewSupportedByCamera).mockReturnValue(true);
const buttons = calculateButtons(controller, {
viewManager: viewManager,
});
expect(buttons).toContainEqual({
icon: 'mdi:play-box-multiple',
enabled: true,
priority: 50,
type: 'custom:advanced-camera-card-menu-icon',
title: 'Reviews gallery',
style: {},
tap_action: {
action: 'fire-dom-event',
advanced_camera_card_action: 'reviews',
},
hold_action: {
action: 'fire-dom-event',
advanced_camera_card_action: 'review',
},
});
});
it('when not supported', () => {
const viewManager = mock<ViewManager>();
vi.mocked(isViewSupportedByCamera).mockReturnValue(false);
const buttons = calculateButtons(controller, {
viewManager: viewManager,
});
expect(buttons).not.toEqual(
expect.arrayContaining([expect.objectContaining({ title: 'Reviews gallery' })]),
);
});
});
describe('should have recordings menu button', () => {
@@ -1838,11 +1963,16 @@ describe('MenuButtonController', () => {
new Map([['folder-0', folder]]).entries(),
);
const folderNode: FolderQuery = {
source: QuerySource.Folder,
folder: folder,
path: [{ ha: { id: 'one' } }],
};
const buttons = calculateButtons(controller, {
foldersManager,
view: createView({
view: 'folder',
query: new FolderViewQuery({ folder, path: [{ ha: { id: 'one' } }] }),
query: new UnifiedQuery().addNode(folderNode),
}),
});
@@ -1869,12 +1999,14 @@ describe('MenuButtonController', () => {
]);
foldersManager.getFolders.mockReturnValue(folders.entries());
const selectedFolderNode: FolderQuery = {
source: QuerySource.Folder,
folder: selectedFolder,
path: [{ ha: { id: 'id' } }],
};
const view = createView({
view: 'folder',
query: new FolderViewQuery({
folder: selectedFolder,
path: [{ ha: { id: 'id' } }],
}),
query: new UnifiedQuery().addNode(selectedFolderNode),
});
const buttons = calculateButtons(controller, {
@@ -2123,4 +2255,86 @@ describe('MenuButtonController', () => {
});
});
});
describe('should have set review button', () => {
it('when unreviewed', () => {
const selectedItem = new TestViewMedia({
mediaType: ViewMediaType.Review,
reviewed: false,
});
const queryResults = mock<QueryResults>();
queryResults.getSelectedResult.mockReturnValue(selectedItem);
const view = createView({
view: 'media',
queryResults: queryResults,
});
const buttons = calculateButtons(controller, { view: view });
expect(buttons).toContainEqual(
expect.objectContaining({
icon: 'mdi:check-circle-outline',
title: 'Mark as reviewed',
}),
);
});
it('when already reviewed', () => {
const selectedItem = new TestViewMedia({
mediaType: ViewMediaType.Review,
reviewed: true,
});
const queryResults = mock<QueryResults>();
queryResults.getSelectedResult.mockReturnValue(selectedItem);
const view = createView({
view: 'media',
queryResults: queryResults,
});
const buttons = calculateButtons(controller, { view: view });
expect(buttons).toContainEqual(
expect.objectContaining({
icon: 'mdi:check-circle',
title: 'Mark as unreviewed',
}),
);
});
it('when isReviewed returns null', () => {
const selectedItem = new TestViewMedia({
mediaType: ViewMediaType.Review,
reviewed: null,
});
const queryResults = mock<QueryResults>();
queryResults.getSelectedResult.mockReturnValue(selectedItem);
const view = createView({
view: 'media',
queryResults: queryResults,
});
const buttons = calculateButtons(controller, { view: view });
expect(buttons).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
icon: 'mdi:check-circle',
}),
]),
);
expect(buttons).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
icon: 'mdi:check-circle-outline',
}),
]),
);
});
});
});
+429
View File
@@ -0,0 +1,429 @@
import { NonEmptyTuple } from 'type-fest';
import { assert, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { CameraManager } from '../../src/camera-manager/manager';
import { EventQuery, QueryType } from '../../src/camera-manager/types';
import { FoldersManager } from '../../src/card-controller/folders/manager';
import { FolderPathComponent } from '../../src/card-controller/folders/types';
import { ViewManagerEpoch, ViewModifier } from '../../src/card-controller/view/types';
import {
FolderNavigationParamaters,
MediaNavigationParamaters,
getUpFolderItem,
navigateToFolder,
navigateToMedia,
navigateUp,
} from '../../src/components-lib/navigation';
import { QuerySource } from '../../src/query-source';
import { ViewFolder, ViewMedia } from '../../src/view/item';
import { UnifiedQuery } from '../../src/view/unified-query';
import { UnifiedQueryBuilder } from '../../src/view/unified-query-builder';
import {
createCardAPI,
createFolder,
createView,
createViewWithMedia,
} from '../test-utils';
const createFolderQuery = (
folder: ReturnType<typeof createFolder>,
path: NonEmptyTuple<FolderPathComponent> = [{}],
): UnifiedQuery => {
const query = new UnifiedQuery();
query.addNode({
source: QuerySource.Folder,
folder,
path,
});
return query;
};
const createCameraQuery = (): UnifiedQuery => {
const query = new UnifiedQuery();
const eventNode: EventQuery = {
source: QuerySource.Camera,
type: QueryType.Event,
cameraIDs: new Set(['camera1']),
hasClip: true,
};
query.addNode(eventNode);
return query;
};
describe('navigateUp', () => {
it('should do nothing with null options', () => {
navigateUp(null);
// No error thrown
});
it('should ignore non-folder query', () => {
const api = createCardAPI();
const view = createView({
query: createCameraQuery(),
});
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const epoch: ViewManagerEpoch = {
manager: api.getViewManager(),
};
const builder = new UnifiedQueryBuilder(
mock<CameraManager>(),
mock<FoldersManager>(),
);
const options: FolderNavigationParamaters = {
builder,
viewManagerEpoch: epoch,
};
navigateUp(options);
expect(api.getViewManager().setViewByParametersWithExistingQuery).not.toBeCalled();
});
it('should ignore folder query without parent to go up to', () => {
const api = createCardAPI();
const folder = createFolder();
const view = createView({
query: createFolderQuery(folder, [{ ha: { id: 'root' } }]),
});
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const epoch: ViewManagerEpoch = {
manager: api.getViewManager(),
};
const builder = new UnifiedQueryBuilder(
mock<CameraManager>(),
mock<FoldersManager>(),
);
const options: FolderNavigationParamaters = {
builder,
viewManagerEpoch: epoch,
};
navigateUp(options);
expect(api.getViewManager().setViewByParametersWithExistingQuery).not.toBeCalled();
});
it('should go up in the folder hierarchy', () => {
const api = createCardAPI();
const folder = createFolder();
const view = createView({
query: createFolderQuery(folder, [
{ ha: { id: 'one' } },
{ ha: { id: 'two' } },
{ ha: { id: 'three' } },
]),
});
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const epoch: ViewManagerEpoch = {
manager: api.getViewManager(),
};
const builder = new UnifiedQueryBuilder(
mock<CameraManager>(),
mock<FoldersManager>(),
);
const options: FolderNavigationParamaters = {
builder,
viewManagerEpoch: epoch,
};
navigateUp(options);
expect(api.getViewManager().setViewByParametersWithExistingQuery).toBeCalledWith({
params: {
query: expect.any(UnifiedQuery),
},
});
const query = vi.mocked(api.getViewManager().setViewByParametersWithExistingQuery)
.mock.calls[0][0]?.params?.query as UnifiedQuery;
const nodes = query.getNodes();
expect(nodes).toHaveLength(1);
expect(nodes[0]).toMatchObject({
source: QuerySource.Folder,
folder,
path: [{ ha: { id: 'one' } }, { ha: { id: 'two' } }],
});
});
it('should go up in the folder hierarchy with limit', () => {
const api = createCardAPI();
const folder = createFolder();
const view = createView({
query: createFolderQuery(folder, [{ ha: { id: 'one' } }, { ha: { id: 'two' } }]),
});
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const epoch: ViewManagerEpoch = {
manager: api.getViewManager(),
};
const builder = new UnifiedQueryBuilder(
mock<CameraManager>(),
mock<FoldersManager>(),
);
const options: FolderNavigationParamaters = {
builder,
viewManagerEpoch: epoch,
limit: 50,
};
navigateUp(options);
const query = vi.mocked(api.getViewManager().setViewByParametersWithExistingQuery)
.mock.calls[0][0]?.params?.query as UnifiedQuery;
expect(query.getNodes()[0].limit).toBe(50);
});
});
describe('navigateToFolder', () => {
it('should do nothing with null options', () => {
const folder = createFolder();
const item = new ViewFolder(folder, [{ ha: { id: 'root' } }]);
navigateToFolder(item, null);
// No error thrown
});
it('should navigate into folder', () => {
const api = createCardAPI();
const folder = createFolder();
const view = createView({
query: createFolderQuery(folder, [{ ha: { id: 'root' } }]),
});
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const epoch: ViewManagerEpoch = {
manager: api.getViewManager(),
};
const builder = new UnifiedQueryBuilder(
mock<CameraManager>(),
mock<FoldersManager>(),
);
const options: FolderNavigationParamaters = {
builder,
viewManagerEpoch: epoch,
};
const item = new ViewFolder(folder, [{ ha: { id: 'root' } }]);
navigateToFolder(item, options);
expect(api.getViewManager().setViewByParametersWithExistingQuery).toBeCalledWith({
params: {
query: expect.any(UnifiedQuery),
},
});
const query = vi.mocked(api.getViewManager().setViewByParametersWithExistingQuery)
.mock.calls[0][0]?.params?.query;
const nodes = query?.getNodes();
expect(nodes).toHaveLength(1);
expect(nodes?.[0]).toMatchObject({
source: QuerySource.Folder,
folder,
});
expect(nodes?.[0]).toHaveProperty('path');
expect((nodes?.[0] as { path: readonly unknown[] }).path).toHaveLength(2);
});
it('should navigate into folder with limit', () => {
const api = createCardAPI();
const folder = createFolder();
const epoch: ViewManagerEpoch = {
manager: api.getViewManager(),
};
const builder = new UnifiedQueryBuilder(
mock<CameraManager>(),
mock<FoldersManager>(),
);
const options: FolderNavigationParamaters = {
builder,
viewManagerEpoch: epoch,
limit: 100,
};
const item = new ViewFolder(folder, [{ ha: { id: 'root' } }]);
navigateToFolder(item, options);
const query = vi.mocked(api.getViewManager().setViewByParametersWithExistingQuery)
.mock.calls[0][0]?.params?.query;
expect(query?.getNodes()[0].limit).toBe(100);
});
});
describe('getUpFolderItem', () => {
it('should return null for null query', () => {
expect(getUpFolderItem(null)).toBeNull();
});
it('should return null for non-folder query', () => {
expect(getUpFolderItem(createCameraQuery())).toBeNull();
});
it('should return null for folder query with single path element', () => {
const folder = createFolder();
expect(
getUpFolderItem(createFolderQuery(folder, [{ ha: { id: 'root' } }])),
).toBeNull();
});
it('should return ViewFolder for navigable folder query', () => {
const folder = createFolder();
const query = createFolderQuery(folder, [
{ ha: { id: 'one' } },
{ ha: { id: 'two' } },
{ ha: { id: 'three' } },
]);
const folderItem = getUpFolderItem(query);
expect(folderItem).toBeInstanceOf(ViewFolder);
expect(folderItem?.getIcon()).toBe('mdi:arrow-up-left');
});
});
describe('navigateToMedia', () => {
it('should do nothing with null options', () => {
navigateToMedia(mock<ViewMedia>(), null);
// No error thrown
});
it('should navigate with viewManagerEpoch', () => {
const api = createCardAPI();
const view = createViewWithMedia();
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const epoch: ViewManagerEpoch = {
manager: api.getViewManager(),
};
const media = mock<ViewMedia>();
const options: MediaNavigationParamaters = {
viewManagerEpoch: epoch,
};
navigateToMedia(media, options);
expect(api.getViewManager().setViewByParameters).toBeCalledWith(
expect.objectContaining({
params: expect.objectContaining({
view: 'media',
queryResults: expect.anything(),
}),
}),
);
});
it('should select the correct media', () => {
const api = createCardAPI();
const view = createViewWithMedia();
const media = view.queryResults?.getResult(2);
assert(media instanceof ViewMedia);
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const options: MediaNavigationParamaters = {
viewManagerEpoch: {
manager: api.getViewManager(),
},
};
navigateToMedia(media, options);
const call = vi.mocked(api.getViewManager().setViewByParameters).mock.calls[0]?.[0];
expect(call?.params?.queryResults?.getSelectedIndex()).toBe(2);
});
it('should set camera', () => {
const api = createCardAPI();
const view = createViewWithMedia();
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const media = mock<ViewMedia>();
vi.mocked(media.getCameraID).mockReturnValue('camera1');
const options: MediaNavigationParamaters = {
viewManagerEpoch: {
manager: api.getViewManager(),
},
};
navigateToMedia(media, options);
expect(api.getViewManager().setViewByParameters).toBeCalledWith(
expect.objectContaining({
params: expect.objectContaining({
view: 'media',
queryResults: expect.anything(),
camera: 'camera1',
}),
}),
);
});
it('should navigate with modifiers', () => {
const api = createCardAPI();
const view = createViewWithMedia();
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const media = mock<ViewMedia>();
const modifier = mock<ViewModifier>();
const options: MediaNavigationParamaters = {
viewManagerEpoch: {
manager: api.getViewManager(),
},
modifiers: [modifier],
};
navigateToMedia(media, options);
expect(api.getViewManager().setViewByParameters).toBeCalledWith(
expect.objectContaining({
modifiers: [modifier],
}),
);
});
it('should do nothing if queryResults are missing', () => {
const api = createCardAPI();
const view = createView();
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const media = mock<ViewMedia>();
const options: MediaNavigationParamaters = {
viewManagerEpoch: {
manager: api.getViewManager(),
},
};
navigateToMedia(media, options);
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
});
it('should do nothing if view is missing', () => {
const api = createCardAPI();
vi.mocked(api.getViewManager().getView).mockReturnValue(null);
const media = mock<ViewMedia>();
const options: MediaNavigationParamaters = {
viewManagerEpoch: {
manager: api.getViewManager(),
},
};
navigateToMedia(media, options);
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
});
});
@@ -1,270 +0,0 @@
import { format } from 'date-fns';
import { describe, expect, it } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { CameraManager } from '../../../src/camera-manager/manager';
import { ThumbnailDetailsController } from '../../../src/components-lib/thumbnail/details-controller';
import { formatDateAndTime } from '../../../src/utils/basic';
import { ViewFolder, ViewMediaType } from '../../../src/view/item';
import { createFolder, TestViewMedia } from '../../test-utils';
describe('ThumbnailDetailsController', () => {
describe('should set heading', () => {
it('should set heading on event with what, tags and score', () => {
const item = new TestViewMedia({
what: ['person', 'car'],
tags: ['tag1', 'tag2'],
score: 0.5,
});
const controller = new ThumbnailDetailsController();
controller.calculate(null, item);
expect(controller.getHeading()).toBe('Person, Car: Tag1, Tag2 50.00%');
});
it('should set heading on event with tags', () => {
const item = new TestViewMedia({
tags: ['tag1', 'tag2'],
});
const controller = new ThumbnailDetailsController();
controller.calculate(null, item);
expect(controller.getHeading()).toBe('Tag1, Tag2');
});
it('should set heading on event with what', () => {
const item = new TestViewMedia({
what: ['person', 'car'],
});
const controller = new ThumbnailDetailsController();
controller.calculate(null, item);
expect(controller.getHeading()).toBe('Person, Car');
});
it('should set null heading on event with no other information', () => {
const item = new TestViewMedia({
mediaType: ViewMediaType.Snapshot,
what: null,
tags: null,
score: null,
});
const controller = new ThumbnailDetailsController();
controller.calculate(null, item);
expect(controller.getHeading()).toBeNull();
});
it('should set heading on recording with camera metadata', () => {
const cameraManager = mock<CameraManager>();
cameraManager.getCameraMetadata.mockReturnValue({
title: 'Camera Title',
icon: { icon: 'mdi:cow' },
});
const item = new TestViewMedia({
mediaType: ViewMediaType.Recording,
});
const controller = new ThumbnailDetailsController();
controller.calculate(cameraManager, item);
expect(controller.getHeading()).toBe('Camera Title');
});
it('should set heading on recording without camera metadata', () => {
const item = new TestViewMedia({
mediaType: ViewMediaType.Recording,
});
const controller = new ThumbnailDetailsController();
controller.calculate(null, item);
expect(controller.getHeading()).toBeNull();
});
it('should set no heading on folder', () => {
const item = new ViewFolder(createFolder());
const controller = new ThumbnailDetailsController();
controller.calculate(null, item);
expect(controller.getHeading()).toBeNull();
});
});
describe('should set details', () => {
describe('should have title in details', () => {
it('should have icon with title when there are other details', () => {
const item = new TestViewMedia({
title: 'Test Event',
where: ['where1', 'where2'],
});
const controller = new ThumbnailDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).toContainEqual({
title: 'Test Event',
icon: { icon: 'mdi:rename' },
hint: 'Title',
});
});
it('should not have icon with title when there are no other details', () => {
const item = new TestViewMedia({
title: 'Test Event',
});
const controller = new ThumbnailDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).toEqual([
{
title: 'Test Event',
},
]);
});
it('should not have title with a start time', () => {
const item = new TestViewMedia({
title: 'Test Event',
startTime: new Date('2025-05-22T21:12:00Z'),
});
const controller = new ThumbnailDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).not.toContainEqual(
expect.objectContaining({
title: 'Test Event',
}),
);
});
});
it('should have start time in details', () => {
const startTime = new Date('2025-05-18T17:03:00Z');
const item = new TestViewMedia({
startTime,
});
const controller = new ThumbnailDetailsController();
controller.calculate(null, item);
// Use formatDateAndTime to generate expected value (formats in local time with seconds)
expect(controller.getDetails()).toContainEqual({
title: formatDateAndTime(startTime, true),
hint: 'Start',
icon: { icon: 'mdi:calendar-clock-outline' },
});
});
describe('should have duration in details', () => {
it('should have duration in details', () => {
const item = new TestViewMedia({
startTime: new Date('2025-05-18T17:03:00Z'),
endTime: new Date('2025-05-18T17:04:00Z'),
});
const controller = new ThumbnailDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).toContainEqual({
title: '1m 0s',
hint: 'Duration',
icon: { icon: 'mdi:clock-outline' },
});
});
it('should have in-progress in details', () => {
const item = new TestViewMedia({
startTime: new Date('2025-05-18T17:03:00Z'),
endTime: null,
inProgress: true,
});
const controller = new ThumbnailDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).toContainEqual({
title: 'In Progress',
hint: 'Duration',
icon: { icon: 'mdi:clock-outline' },
});
});
it('should have duration and in-progress in details', () => {
const item = new TestViewMedia({
startTime: new Date('2025-05-18T17:03:00Z'),
endTime: new Date('2025-05-18T17:04:00Z'),
inProgress: true,
});
const controller = new ThumbnailDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).toContainEqual({
title: '1m 0s In Progress',
hint: 'Duration',
icon: { icon: 'mdi:clock-outline' },
});
});
});
it('should have camera title in details', () => {
const cameraManager = mock<CameraManager>();
cameraManager.getCameraMetadata.mockReturnValue({
title: 'Camera Title',
icon: { icon: 'mdi:cow' },
});
const item = new TestViewMedia({
cameraID: 'camera_1',
});
const controller = new ThumbnailDetailsController();
controller.calculate(cameraManager, item);
expect(controller.getDetails()).toContainEqual({
title: 'Camera Title',
hint: 'Camera',
icon: { icon: 'mdi:cctv' },
});
});
it('should have where in details', () => {
const item = new TestViewMedia({
cameraID: 'camera_1',
where: ['where1', 'where2'],
});
const controller = new ThumbnailDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).toContainEqual({
title: 'Where1, Where2',
hint: 'Where',
icon: { icon: 'mdi:map-marker-outline' },
});
});
it('should have tags in details', () => {
const item = new TestViewMedia({
cameraID: 'camera_1',
tags: ['tag1', 'tag2'],
});
const controller = new ThumbnailDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).toContainEqual({
title: 'Tag1, Tag2',
hint: 'Tag',
icon: { icon: 'mdi:tag' },
});
});
it('should have seek in details', () => {
const item = new TestViewMedia();
const seekTime = new Date('2025-05-20T07:14:57Z');
const controller = new ThumbnailDetailsController();
controller.calculate(null, item, seekTime);
// Use format() to generate expected value (formats in local time)
expect(controller.getDetails()).toContainEqual({
title: format(seekTime, 'HH:mm:ss'),
hint: 'Seek',
icon: { icon: 'mdi:clock-fast' },
});
});
});
});
@@ -115,7 +115,7 @@ describe('ThumbnailFeatureController', () => {
it('should not set subtitle on folder media', () => {
const controller = new ThumbnailFeatureController();
const itemWithThumbnail = new ViewFolder(createFolder(), {
const itemWithThumbnail = new ViewFolder(createFolder(), [], {
title: 'Test Folder',
});
@@ -152,7 +152,7 @@ describe('ThumbnailFeatureController', () => {
});
describe('should set thumbnail', () => {
it('should set brand thumbnail', () => {
it('should set placeholder thumbnail', () => {
const controller = new ThumbnailFeatureController();
const itemWithThumbnail = new TestViewMedia({
thumbnail: 'https://brands.home-assistant.io//amcrest/icon.png',
@@ -163,7 +163,7 @@ describe('ThumbnailFeatureController', () => {
expect(controller.getThumbnail()).toBe(
'https://brands.home-assistant.io/brands/_/amcrest/icon.png',
);
expect(controller.getThumbnailClass()).toBe('brand');
expect(controller.getThumbnailClass()).toBe('placeholder');
});
it('should set other thumbnail', () => {
File diff suppressed because it is too large Load Diff
+105 -1
View File
@@ -1512,10 +1512,17 @@ describe('should handle version specific upgrades', () => {
expect(config).toEqual({
type: 'custom:advanced-camera-card',
cameras: [{}],
...(mediaEventType !== 'all' && {
cameras_global: {
media: {
events_type: mediaEventType,
},
},
}),
live: {
controls: {
thumbnails: {
events_media_type: mediaEventType,
// v8.0.0+ migration moves events_media_type to cameras_global .
},
},
},
@@ -3587,4 +3594,101 @@ describe('should handle version specific upgrades', () => {
postUpgradeChecks(config);
});
});
describe('v8.0.0+', () => {
describe('live.controls.thumbnails.media_type -> cameras_global.media.type', () => {
it.each([['events' as const], ['recordings' as const]])(
'%s',
(mediaType: string) => {
const config = {
type: 'custom:advanced-camera-card',
cameras: [{}],
live: {
controls: {
thumbnails: {
media_type: mediaType,
},
},
},
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config).toEqual({
type: 'custom:advanced-camera-card',
cameras: [{}],
live: {
controls: {
thumbnails: {},
},
},
cameras_global: {
media: {
type: mediaType,
},
},
});
postUpgradeChecks(config);
},
);
});
describe('live.controls.thumbnails.events_media_type -> cameras_global.media.events_type', () => {
it.each([['clips' as const], ['snapshots' as const]])(
'%s',
(eventsType: string) => {
const config = {
type: 'custom:advanced-camera-card',
cameras: [{}],
live: {
controls: {
thumbnails: {
events_media_type: eventsType,
},
},
},
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config).toEqual({
type: 'custom:advanced-camera-card',
cameras: [{}],
live: {
controls: {
thumbnails: {},
},
},
cameras_global: {
media: {
events_type: eventsType,
},
},
});
postUpgradeChecks(config);
},
);
it('all', () => {
const config = {
type: 'custom:advanced-camera-card',
cameras: [{}],
live: {
controls: {
thumbnails: {
events_media_type: 'all',
},
},
},
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config).toEqual({
type: 'custom:advanced-camera-card',
cameras: [{}],
live: {
controls: {
thumbnails: {},
},
},
});
postUpgradeChecks(config);
});
});
});
});
+39 -16
View File
@@ -53,10 +53,14 @@ describe('config defaults', () => {
media_resolution: 'low',
},
triggers: {
events: ['events', 'clips', 'snapshots'],
events: [],
entities: [],
motion: false,
occupancy: false,
reviews: {
severities: ['high'],
description: true,
},
},
},
debug: {
@@ -92,18 +96,17 @@ describe('config defaults', () => {
position: 'bottom-right',
},
thumbnails: {
media_type: 'events',
events_media_type: 'all',
mode: 'right',
show_details: true,
show_download_control: true,
show_download_control: false,
show_favorite_control: true,
show_timeline_control: true,
show_info_control: true,
show_review_control: true,
show_timeline_control: false,
size: 100,
},
timeline: {
clustering_threshold: 3,
events_media_type: 'all',
format: {
'24h': true,
},
@@ -135,9 +138,11 @@ describe('config defaults', () => {
},
thumbnails: {
show_details: false,
show_download_control: true,
show_download_control: false,
show_favorite_control: true,
show_timeline_control: true,
show_info_control: true,
show_review_control: true,
show_timeline_control: false,
size: 100,
},
},
@@ -164,14 +169,15 @@ describe('config defaults', () => {
thumbnails: {
mode: 'right',
show_details: true,
show_download_control: true,
show_download_control: false,
show_favorite_control: true,
show_timeline_control: true,
show_info_control: true,
show_review_control: true,
show_timeline_control: false,
size: 100,
},
timeline: {
clustering_threshold: 3,
events_media_type: 'all',
format: {
'24h': true,
},
@@ -202,7 +208,7 @@ describe('config defaults', () => {
priority: 50,
},
clips: {
enabled: true,
enabled: false,
priority: 50,
},
display_mode: {
@@ -229,6 +235,10 @@ describe('config defaults', () => {
enabled: false,
priority: 50,
},
info: {
enabled: true,
priority: 50,
},
iris: {
enabled: true,
priority: 50,
@@ -266,12 +276,20 @@ describe('config defaults', () => {
enabled: false,
priority: 50,
},
reviews: {
enabled: true,
priority: 50,
},
set_review: {
enabled: true,
priority: 50,
},
screenshot: {
enabled: false,
priority: 50,
},
snapshots: {
enabled: true,
enabled: false,
priority: 50,
},
substreams: {
@@ -314,6 +332,10 @@ describe('config defaults', () => {
enabled: true,
priority: 50,
},
severity: {
enabled: true,
priority: 50,
},
technology: {
enabled: true,
priority: 50,
@@ -333,13 +355,14 @@ describe('config defaults', () => {
thumbnails: {
mode: 'right',
show_details: true,
show_download_control: true,
show_download_control: false,
show_favorite_control: true,
show_timeline_control: true,
show_info_control: true,
show_review_control: true,
show_timeline_control: false,
size: 100,
},
},
events_media_type: 'all',
format: {
'24h': true,
},
@@ -21,6 +21,7 @@ describe('BrowseMediaViewItemFactory', () => {
const browseMedia = createBrowseMedia({ can_expand: true });
const result = BrowseMediaViewItemFactory.create(browseMedia, {
folder: createFolder(),
path: [],
});
expect(result).toBeInstanceOf(BrowseMediaViewFolder);
});
+3 -3
View File
@@ -304,7 +304,7 @@ describe('BrowseMediaViewFolder', () => {
const folder = createFolder();
const browseMedia = createBrowseMedia();
const viewMedia = new BrowseMediaViewFolder(folder, browseMedia);
const viewMedia = new BrowseMediaViewFolder(folder, [], browseMedia);
expect(viewMedia.getFolder()).toEqual(folder);
});
@@ -314,7 +314,7 @@ describe('BrowseMediaViewFolder', () => {
children_media_class: 'album',
});
const viewMedia = new BrowseMediaViewFolder(createFolder(), browseMedia);
const viewMedia = new BrowseMediaViewFolder(createFolder(), [], browseMedia);
expect(viewMedia.getIcon()).toBe('mdi:album');
});
@@ -323,7 +323,7 @@ describe('BrowseMediaViewFolder', () => {
children_media_class: 'unknown',
});
const viewMedia = new BrowseMediaViewFolder(createFolder(), browseMedia);
const viewMedia = new BrowseMediaViewFolder(createFolder(), [], browseMedia);
expect(viewMedia.getIcon()).toBeNull();
});
});
+132 -5
View File
@@ -6,11 +6,21 @@ import { mock } from 'vitest-mock-extended';
import { Camera } from '../src/camera-manager/camera';
import { Capabilities } from '../src/camera-manager/capabilities';
import { CameraManagerEngine } from '../src/camera-manager/engine';
import { FrigateEvent, FrigateRecording } from '../src/camera-manager/frigate/types';
import {
FrigateEvent,
FrigateRecording,
FrigateReview,
} from '../src/camera-manager/frigate/types';
import { GenericCameraManagerEngine } from '../src/camera-manager/generic/engine-generic';
import { CameraManager } from '../src/camera-manager/manager';
import { CameraManagerStore } from '../src/camera-manager/store';
import { CameraEventCallback } from '../src/camera-manager/types';
import {
CameraEventCallback,
EventQuery,
QueryType,
RecordingQuery,
ReviewQuery,
} from '../src/camera-manager/types';
import { ActionsManager } from '../src/card-controller/actions/actions-manager';
import { AutomationsManager } from '../src/card-controller/automations-manager';
import { CameraURLManager } from '../src/card-controller/camera-url-manager';
@@ -20,6 +30,7 @@ import { CardController } from '../src/card-controller/controller';
import { DefaultManager } from '../src/card-controller/default-manager';
import { ExpandManager } from '../src/card-controller/expand-manager';
import { FoldersManager } from '../src/card-controller/folders/manager';
import { FolderQuery } from '../src/card-controller/folders/types';
import { FullscreenManager } from '../src/card-controller/fullscreen/fullscreen-manager';
import { HASSManager } from '../src/card-controller/hass/hass-manager';
import { StateWatcherSubscriptionInterface } from '../src/card-controller/hass/state-watcher';
@@ -30,6 +41,7 @@ import { MediaLoadedInfoManager } from '../src/card-controller/media-info-manage
import { MediaPlayerManager } from '../src/card-controller/media-player-manager';
import { MessageManager } from '../src/card-controller/message-manager';
import { MicrophoneManager } from '../src/card-controller/microphone-manager';
import { OverlayMessageManager } from '../src/card-controller/overlay-message-manager';
import { QueryStringManager } from '../src/card-controller/query-string-manager';
import { StatusBarItemManager } from '../src/card-controller/status-bar-item-manager';
import { StyleManager } from '../src/card-controller/style-manager';
@@ -57,13 +69,20 @@ import {
import { Device } from '../src/ha/registry/device/types';
import { Entity, EntityRegistryManager } from '../src/ha/registry/entity/types';
import { CurrentUser, HassStateDifference, HomeAssistant } from '../src/ha/types';
import { QuerySource } from '../src/query-source';
import {
CapabilitiesRaw,
EffectsControllerAPI,
Interaction,
MediaLoadedInfo,
Severity,
} from '../src/types';
import { EventViewMedia, ViewMedia, ViewMediaType } from '../src/view/item';
import {
EventViewMedia,
ReviewViewMedia,
ViewMedia,
ViewMediaType,
} from '../src/view/item';
import { QueryResults } from '../src/view/query-results';
import { ViewItemCapabilities } from '../src/view/types';
import { View, ViewParameters } from '../src/view/view';
@@ -111,7 +130,9 @@ export const createHASS = (states?: HassEntities, user?: CurrentUser): HomeAssis
hass.user = user;
}
hass.connection.subscribeMessage = vi.fn();
hass.connection.sendMessagePromise = vi.fn();
// ha-nunjucks calls sendMessagePromise to fetch label registry; return empty array to prevent crash.
hass.connection.sendMessagePromise = vi.fn().mockResolvedValue([]);
return hass;
};
@@ -190,6 +211,24 @@ export const createFrigateRecording = (recording?: Partial<FrigateRecording>) =>
};
};
export const createFrigateReview = (review?: Partial<FrigateReview>) => {
return {
id: 'review_id',
camera: 'camera',
severity: 'alert' as const,
start_time: 1683395000,
end_time: 1683397124,
thumb_path: 'thumb.jpg',
has_been_reviewed: false,
data: {
objects: ['person'],
zones: [],
audio: [],
},
...review,
};
};
export const createView = (options?: Partial<ViewParameters>): View => {
return new View({
view: 'live',
@@ -321,7 +360,7 @@ export const generateViewMediaArray = (options?: {
// ViewMedia itself has no native way to set startTime and ID that aren't linked
// to an engine.
export class TestViewMedia extends ViewMedia implements EventViewMedia {
export class TestViewMedia extends ViewMedia implements EventViewMedia, ReviewViewMedia {
protected _icon: string | null = null;
protected _id: string | null;
protected _startTime: Date | null;
@@ -334,6 +373,10 @@ export class TestViewMedia extends ViewMedia implements EventViewMedia {
protected _score: number | null = null;
protected _tags: string[] | null = null;
protected _where: string[] | null = null;
protected _severity: Severity | null = null;
protected _reviewed: boolean | null = null;
protected _description: string | null = null;
protected _favorite: boolean | null = null;
constructor(options?: {
id?: string | null;
@@ -345,12 +388,16 @@ export class TestViewMedia extends ViewMedia implements EventViewMedia {
inProgress?: boolean;
contentID?: string;
title?: string | null;
description?: string | null;
thumbnail?: string | null;
icon?: string | null;
what?: string[] | null;
score?: number | null;
tags?: string[] | null;
where?: string[] | null;
severity?: Severity | null;
reviewed?: boolean | null;
favorite?: boolean | null;
}) {
super(options?.mediaType ?? ViewMediaType.Clip, {
...(options?.cameraID !== null &&
@@ -363,12 +410,16 @@ export class TestViewMedia extends ViewMedia implements EventViewMedia {
this._inProgress = options?.inProgress !== undefined ? options.inProgress : false;
this._contentID = options?.contentID ?? null;
this._title = options?.title !== undefined ? options.title : null;
this._description = options?.description !== undefined ? options.description : null;
this._thumbnail = options?.thumbnail !== undefined ? options.thumbnail : null;
this._icon = options?.icon !== undefined ? options.icon : null;
this._what = options?.what !== undefined ? options.what : null;
this._score = options?.score !== undefined ? options.score : null;
this._tags = options?.tags !== undefined ? options.tags : null;
this._where = options?.where !== undefined ? options.where : null;
this._severity = options?.severity !== undefined ? options.severity : null;
this._reviewed = options?.reviewed !== undefined ? options.reviewed : null;
this._favorite = options?.favorite !== undefined ? options.favorite : null;
}
public getIcon(): string | null {
return this._icon;
@@ -391,6 +442,9 @@ export class TestViewMedia extends ViewMedia implements EventViewMedia {
public getTitle(): string | null {
return this._title;
}
public getDescription(): string | null {
return this._description;
}
public getThumbnail(): string | null {
return this._thumbnail;
}
@@ -410,6 +464,21 @@ export class TestViewMedia extends ViewMedia implements EventViewMedia {
public isGroupableWith(_that: EventViewMedia): boolean {
return false;
}
public getSeverity(): Severity | null {
return this._severity;
}
public isReviewed(): boolean | null {
return this._reviewed;
}
public setReviewed(reviewed: boolean): void {
this._reviewed = reviewed;
}
public isFavorite(): boolean | null {
return this._favorite;
}
public setFavorite(favorite: boolean): void {
this._favorite = favorite;
}
}
export const ResizeObserverMock = vi.fn(() => ({
@@ -602,6 +671,7 @@ export const createCardAPI = (): CardController => {
api.getMediaPlayerManager.mockReturnValue(mock<MediaPlayerManager>());
api.getMessageManager.mockReturnValue(mock<MessageManager>());
api.getMicrophoneManager.mockReturnValue(mock<MicrophoneManager>());
api.getOverlayMessageManager.mockReturnValue(mock<OverlayMessageManager>());
api.getQueryStringManager.mockReturnValue(mock<QueryStringManager>());
api.getStatusBarItemManager.mockReturnValue(mock<StatusBarItemManager>());
api.getStyleManager.mockReturnValue(mock<StyleManager>());
@@ -718,3 +788,60 @@ export const createRichBrowseMedia = (
},
};
};
export const createEventQuery = (
cameraID: string,
options?: Partial<EventQuery>,
): EventQuery => ({
source: QuerySource.Camera,
type: QueryType.Event,
cameraIDs: new Set([cameraID]),
...options,
});
export const createReviewQuery = (
cameraID: string,
options?: Partial<ReviewQuery>,
): ReviewQuery => ({
source: QuerySource.Camera,
type: QueryType.Review,
cameraIDs: new Set([cameraID]),
...options,
});
export const createRecordingQuery = (
cameraID: string,
options?: Partial<RecordingQuery>,
): RecordingQuery => ({
source: QuerySource.Camera,
type: QueryType.Recording,
cameraIDs: new Set([cameraID]),
...options,
});
export const createFolderQuery = (folderId: string): FolderQuery => ({
source: QuerySource.Folder,
folder: { id: folderId, type: 'ha', title: folderId },
path: [{ ha: { id: 'Root' } }],
});
export const isEventQuery = (node: {
source: QuerySource;
type?: QueryType;
}): node is EventQuery =>
node.source === QuerySource.Camera && node.type === QueryType.Event;
export const isRecordingQuery = (node: {
source: QuerySource;
type?: QueryType;
}): node is RecordingQuery =>
node.source === QuerySource.Camera && node.type === QueryType.Recording;
export const isReviewQuery = (node: {
source: QuerySource;
type?: QueryType;
}): node is ReviewQuery =>
node.source === QuerySource.Camera && node.type === QueryType.Review;
export const isFolderQuery = (node: { source: QuerySource }): node is FolderQuery =>
node.source === QuerySource.Folder;
+27
View File
@@ -16,6 +16,7 @@ import {
createPTZDigitalAction,
createPTZMultiAction,
createSelectOptionAction,
createSetReviewAction,
createViewAction,
getActionConfigGivenAction,
hasAction,
@@ -317,6 +318,32 @@ describe('createEffectAction', () => {
});
});
describe('createSetReviewAction', () => {
it('should create set review action', () => {
expect(createSetReviewAction(true)).toEqual({
action: 'fire-dom-event',
advanced_camera_card_action: 'set_review',
reviewed: true,
});
});
it('should create set review action with false', () => {
expect(createSetReviewAction(false)).toEqual({
action: 'fire-dom-event',
advanced_camera_card_action: 'set_review',
reviewed: false,
});
});
it('should create set review action with undefined', () => {
expect(createSetReviewAction()).toEqual({
action: 'fire-dom-event',
advanced_camera_card_action: 'set_review',
reviewed: undefined,
});
});
});
describe('getActionConfigGivenAction', () => {
const action = createViewAction('clips');
+20 -11
View File
@@ -143,7 +143,8 @@ describe('CarouselController', () => {
carousel.selectSlide(11);
expect(getEmblaApi()?.scrollTo).toBeCalledWith(11, false);
// Should not call scrollTo or fire event because index is out of bounds
expect(getEmblaApi()?.scrollTo).not.toBeCalled();
expect(forceSelectListener).not.toBeCalled();
});
@@ -257,7 +258,7 @@ describe('CarouselController', () => {
);
});
it('should recreate carousel when children are added', () => {
it('should reinit carousel when children are added', () => {
const children = createTestSlideNodes();
const root = createRoot();
const parent = createParent({ children: children });
@@ -269,13 +270,17 @@ describe('CarouselController', () => {
expect(originalEmblaApi).toBeTruthy();
originalEmblaApi?.slideNodes.mockReturnValue(children);
parent.appendChild(document.createElement('div'));
const newChild = document.createElement('div');
parent.appendChild(newChild);
callMutationHandler();
expect(originalEmblaApi?.destroy).toBeCalled();
expect(getEmblaApi()).not.toBe(originalEmblaApi);
// Should call reInit instead of destroy/recreate
expect(originalEmblaApi?.reInit).toBeCalledWith({
slides: [...children, newChild],
});
expect(EmblaCarousel).toBeCalledTimes(2);
// Should still be same carousel instance (no new creation)
expect(EmblaCarousel).toBeCalledTimes(1);
});
it('should not recreate carousel when children have not changed', () => {
@@ -298,7 +303,7 @@ describe('CarouselController', () => {
expect(EmblaCarousel).toBeCalledTimes(1);
});
it('should recreate carousel when children are added to slot', () => {
it('should reinit carousel when children are added to slot', () => {
const children = createTestSlideNodes();
const slot = createSlot();
const host = createSlotHost({ slot: slot, children: children });
@@ -312,12 +317,16 @@ describe('CarouselController', () => {
originalEmblaApi?.slideNodes.mockReturnValue(children);
host.appendChild(document.createElement('div'));
const newChild = document.createElement('div');
host.appendChild(newChild);
slot.dispatchEvent(new Event('slotchange'));
expect(originalEmblaApi?.destroy).toBeCalled();
expect(getEmblaApi()).not.toBe(originalEmblaApi);
// Should call reInit instead of destroy/recreate
expect(originalEmblaApi?.reInit).toBeCalledWith({
slides: [...children, newChild],
});
expect(EmblaCarousel).toBeCalledTimes(2);
// Should still be same carousel instance (no new creation)
expect(EmblaCarousel).toBeCalledTimes(1);
});
});
@@ -0,0 +1,119 @@
import { describe, expect, it, vi } from 'vitest';
import { findBestMediaTimeIndex } from '../../src/utils/find-best-media-time-index';
import { ViewFolder } from '../../src/view/item';
import { TestViewMedia } from '../test-utils';
describe('findBestMediaTimeIndex', () => {
it('should handle non-media items', () => {
const folder = new ViewFolder({ id: 'folder', type: 'ha', title: 'folder' }, []);
const media = new TestViewMedia({
startTime: new Date('2024-01-01T10:00:00'),
endTime: new Date('2024-01-01T11:00:00'),
});
const index = findBestMediaTimeIndex(
[folder, media],
new Date('2024-01-01T10:30:00'),
);
expect(index).toBe(1);
});
it('should find longest match', () => {
const media1 = new TestViewMedia({
startTime: new Date('2024-01-01T10:00:00'),
endTime: new Date('2024-01-01T10:30:00'),
});
const media2 = new TestViewMedia({
startTime: new Date('2024-01-01T10:00:00'),
endTime: new Date('2024-01-01T11:00:00'),
});
const index = findBestMediaTimeIndex(
[media1, media2],
new Date('2024-01-01T10:15:00'),
);
expect(index).toBe(1);
});
it('should favor specified camera', () => {
const media1 = new TestViewMedia({
cameraID: 'camera1',
startTime: new Date('2024-01-01T10:00:00'),
endTime: new Date('2024-01-01T11:00:00'),
});
const media2 = new TestViewMedia({
cameraID: 'camera2',
startTime: new Date('2024-01-01T10:00:00'),
endTime: new Date('2024-01-01T10:30:00'),
});
// Even though media1 is longer, favor media2's camera.
const index = findBestMediaTimeIndex(
[media1, media2],
new Date('2024-01-01T10:15:00'),
'camera2',
);
expect(index).toBe(1);
});
it('should handle multiple matches from non-favored cameras', () => {
const media1 = new TestViewMedia({
cameraID: 'camera1',
startTime: new Date('2024-01-01T10:00:00'),
endTime: new Date('2024-01-01T10:30:00'),
});
const media2 = new TestViewMedia({
cameraID: 'camera3',
startTime: new Date('2024-01-01T10:00:00'),
endTime: new Date('2024-01-01T11:00:00'),
});
const index = findBestMediaTimeIndex(
[media1, media2],
new Date('2024-01-01T10:15:00'),
'camera2', // Favored camera not present
);
expect(index).toBe(1); // Longest match wins
});
it('should handle missing start or end time', () => {
const media = new TestViewMedia({
startTime: null,
endTime: null,
});
vi.spyOn(media, 'includesTime').mockReturnValue(true);
const index = findBestMediaTimeIndex([media], new Date());
expect(index).toBeNull();
});
it('should return null when no match', () => {
const media = new TestViewMedia({
startTime: new Date('2024-01-01T10:00:00'),
endTime: new Date('2024-01-01T11:00:00'),
});
const index = findBestMediaTimeIndex([media], new Date('2024-01-01T12:00:00'));
expect(index).toBeNull();
});
it('should reject longer match if favored camera already matched', () => {
const media1 = new TestViewMedia({
cameraID: 'favored',
startTime: new Date('2024-01-01T10:00:00'),
endTime: new Date('2024-01-01T10:30:00'),
});
const media2 = new TestViewMedia({
cameraID: 'not-favored',
startTime: new Date('2024-01-01T10:00:00'),
endTime: new Date('2024-01-01T11:00:00'),
});
// media2 is longer, but media1 is favored and already matches.
const index = findBestMediaTimeIndex(
[media1, media2],
new Date('2024-01-01T10:15:00'),
'favored',
);
expect(index).toBe(0);
});
});
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { getFolderID } from '../../src/utils/folder';
describe('getFolderID', () => {
it('should get folder id from config', () => {
expect(getFolderID({ id: 'my-folder' })).toBe('my-folder');
});
it('should get folder id from index when no id in config', () => {
expect(getFolderID({}, 5)).toBe('folder/5');
});
it('should use default index 0 when index is missing', () => {
expect(getFolderID({})).toBe('folder/0');
});
it('should handle null config', () => {
expect(getFolderID(null, 2)).toBe('folder/2');
});
it('should handle undefined config', () => {
expect(getFolderID(undefined, 3)).toBe('folder/3');
});
});
+94
View File
@@ -0,0 +1,94 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as go2rtcAudio from '../../src/camera-manager/utils/go2rtc/audio';
import {
getResolvedLiveProvider,
liveProviderSupports2WayAudio,
} from '../../src/utils/live-provider';
import { createCameraConfig, createHASS } from '../test-utils';
vi.mock('../../src/camera-manager/utils/go2rtc/audio');
describe('live-provider utils', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getResolvedLiveProvider', () => {
it('should resolve webrtc-card with entity', () => {
const config = createCameraConfig({
live_provider: 'auto',
webrtc_card: { entity: 'camera.test' },
});
expect(getResolvedLiveProvider(config)).toBe('webrtc-card');
});
it('should resolve webrtc-card with url', () => {
const config = createCameraConfig({
live_provider: 'auto',
webrtc_card: { url: 'http://test' },
});
expect(getResolvedLiveProvider(config)).toBe('webrtc-card');
});
it('should resolve ha', () => {
const config = createCameraConfig({
live_provider: 'auto',
camera_entity: 'camera.test',
});
expect(getResolvedLiveProvider(config)).toBe('ha');
});
it('should resolve jsmpeg', () => {
const config = createCameraConfig({
live_provider: 'auto',
frigate: { camera_name: 'test' },
});
expect(getResolvedLiveProvider(config)).toBe('jsmpeg');
});
it('should resolve image by default for auto', () => {
const config = createCameraConfig({
live_provider: 'auto',
});
expect(getResolvedLiveProvider(config)).toBe('image');
});
it('should return explicitly configured provider', () => {
const config = createCameraConfig({
live_provider: 'go2rtc',
});
expect(getResolvedLiveProvider(config)).toBe('go2rtc');
});
it('should return image if config is undefined', () => {
expect(getResolvedLiveProvider(undefined)).toBe('image');
});
});
describe('liveProviderSupports2WayAudio', () => {
it('should return false if resolved provider is not go2rtc', async () => {
const config = createCameraConfig({
live_provider: 'ha',
});
const hass = createHASS();
const result = await liveProviderSupports2WayAudio(hass, config);
expect(result).toBe(false);
});
it('should return result from go2rtcSupports2WayAudio', async () => {
const config = createCameraConfig({
live_provider: 'go2rtc',
});
const hass = createHASS();
vi.mocked(go2rtcAudio.supports2WayAudio).mockResolvedValue(true);
const result = await liveProviderSupports2WayAudio(hass, config);
expect(result).toBe(true);
expect(go2rtcAudio.supports2WayAudio).toHaveBeenCalledWith(
hass,
undefined,
undefined,
);
});
});
});
+236
View File
@@ -0,0 +1,236 @@
import { describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { ViewItemManager } from '../../src/card-controller/view/item-manager';
import { RemoveContextViewModifier } from '../../src/card-controller/view/modifiers/remove-context';
import { ViewManagerEpoch } from '../../src/card-controller/view/types';
import { ViewManager } from '../../src/card-controller/view/view-manager';
import {
downloadMedia,
navigateToTimeline,
toggleFavorite,
toggleReviewed,
} from '../../src/utils/media-actions';
import { ViewItem, ViewMediaType } from '../../src/view/item';
import { QueryResults } from '../../src/view/query-results';
import { View } from '../../src/view/view';
import { TestViewMedia } from '../test-utils';
describe('MediaActions', () => {
describe('toggleReviewed', () => {
it('should return false if item is not review', async () => {
const item = new TestViewMedia({ mediaType: ViewMediaType.Clip });
const options = { viewItemManager: mock<ViewItemManager>() };
expect(await toggleReviewed(item, options)).toBe(false);
});
it('should return false if manager is missing', async () => {
const item = new TestViewMedia({ mediaType: ViewMediaType.Review });
const options = {};
expect(await toggleReviewed(item, options)).toBe(false);
});
it('should toggle review status and update view', async () => {
const item = new TestViewMedia({
mediaType: ViewMediaType.Review,
reviewed: false,
});
const viewItemManager = mock<ViewItemManager>();
const viewManagerEpoch = mock<ViewManagerEpoch>();
const viewManager = mock<ViewManager>();
const view = mock<View>();
const queryResults = mock<QueryResults>();
viewManagerEpoch.manager = viewManager;
viewManager.getView.mockReturnValue(view);
view.queryResults = queryResults;
queryResults.clone.mockReturnValue(queryResults);
queryResults.removeItem.mockReturnValue(queryResults);
const options = { viewItemManager, viewManagerEpoch };
expect(await toggleReviewed(item, options)).toBe(true);
expect(viewItemManager.reviewMedia).toHaveBeenCalledWith(item, true);
expect(item.isReviewed()).toBe(true);
expect(viewManager.setViewByParameters).toHaveBeenCalled();
});
it('should not update view if queryResults is missing', async () => {
const item = new TestViewMedia({
mediaType: ViewMediaType.Review,
reviewed: false,
});
const viewItemManager = mock<ViewItemManager>();
const viewManagerEpoch = mock<ViewManagerEpoch>();
const viewManager = mock<ViewManager>();
const view = mock<View>();
viewManagerEpoch.manager = viewManager;
viewManager.getView.mockReturnValue(view);
view.queryResults = null;
const options = { viewItemManager, viewManagerEpoch };
expect(await toggleReviewed(item, options)).toBe(true);
expect(viewManager.setViewByParameters).not.toHaveBeenCalled();
});
it('should handle manager error', async () => {
const item = new TestViewMedia({
mediaType: ViewMediaType.Review,
reviewed: false,
});
const viewItemManager = mock<ViewItemManager>();
const error = new Error('fail');
viewItemManager.reviewMedia.mockRejectedValue(error);
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const options = { viewItemManager };
expect(await toggleReviewed(item, options)).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith(error.message);
consoleSpy.mockRestore();
});
});
describe('toggleFavorite', () => {
it('should return false if item is not media', async () => {
const options = { viewItemManager: mock<ViewItemManager>() };
expect(await toggleFavorite(null as unknown as ViewItem, options)).toBe(false);
});
it('should return false if manager is missing', async () => {
const item = new TestViewMedia({ mediaType: ViewMediaType.Clip });
const options = {};
expect(await toggleFavorite(item, options)).toBe(false);
});
it('should toggle favorite status', async () => {
const item = new TestViewMedia({
mediaType: ViewMediaType.Clip,
favorite: false,
});
const viewItemManager = mock<ViewItemManager>();
const options = { viewItemManager };
expect(await toggleFavorite(item, options)).toBe(true);
expect(viewItemManager.favorite).toHaveBeenCalledWith(item, true);
});
it('should handle manager error', async () => {
const item = new TestViewMedia({
mediaType: ViewMediaType.Clip,
favorite: false,
});
const viewItemManager = mock<ViewItemManager>();
const error = new Error('fail');
viewItemManager.favorite.mockRejectedValue(error);
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const options = { viewItemManager };
expect(await toggleFavorite(item, options)).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith(error.message);
consoleSpy.mockRestore();
});
});
describe('downloadMedia', () => {
it('should return false if manager is missing', async () => {
const item = new TestViewMedia({ mediaType: ViewMediaType.Clip });
const options = {};
expect(await downloadMedia(item, options)).toBe(false);
});
it('should download media', async () => {
const item = new TestViewMedia({ mediaType: ViewMediaType.Clip });
const viewItemManager = mock<ViewItemManager>();
const options = { viewItemManager };
expect(await downloadMedia(item, options)).toBe(true);
expect(viewItemManager.download).toHaveBeenCalledWith(item);
});
it('should handle manager error', async () => {
const item = new TestViewMedia({ mediaType: ViewMediaType.Clip });
const viewItemManager = mock<ViewItemManager>();
const error = new Error('fail');
viewItemManager.download.mockRejectedValue(error);
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const options = { viewItemManager };
expect(await downloadMedia(item, options)).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith(error.message);
consoleSpy.mockRestore();
});
});
describe('navigateToTimeline', () => {
it('should return early if epoch is missing', () => {
const item = new TestViewMedia({ mediaType: ViewMediaType.Clip });
const options = {};
expect(navigateToTimeline(item, options)).toBeUndefined();
});
it('should navigate to timeline with correct parameters', () => {
const item = new TestViewMedia({ mediaType: ViewMediaType.Clip });
const viewManagerEpoch = mock<ViewManagerEpoch>();
const viewManager = mock<ViewManager>();
const view = mock<View>();
const queryResults = mock<QueryResults>();
viewManagerEpoch.manager = viewManager;
viewManager.getView.mockReturnValue(view);
view.queryResults = queryResults;
queryResults.clone.mockReturnValue(queryResults);
// Make selectResultIfFound call the predicate to gain coverage.
queryResults.selectResultIfFound.mockImplementation((predicate) => {
predicate(item);
return queryResults;
});
const options = { viewManagerEpoch };
navigateToTimeline(item, options);
expect(viewManager.setViewByParameters).toHaveBeenCalledWith({
params: {
view: 'timeline',
queryResults: queryResults,
},
modifiers: [expect.any(RemoveContextViewModifier)],
});
});
it('should handle missing view/queryResults during navigation', () => {
const item = new TestViewMedia({ mediaType: ViewMediaType.Clip });
const viewManagerEpoch = mock<ViewManagerEpoch>();
const viewManager = mock<ViewManager>();
viewManagerEpoch.manager = viewManager;
viewManager.getView.mockReturnValue(null);
const options = { viewManagerEpoch };
navigateToTimeline(item, options);
expect(viewManager.setViewByParameters).toHaveBeenCalledWith({
params: {
view: 'timeline',
queryResults: undefined,
},
modifiers: [new RemoveContextViewModifier(['timeline'])],
});
});
});
});
+3 -3
View File
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { AdvancedCameraCardMediaPlayer, MediaLoadedCapabilities } from '../../src/types';
import { MediaLoadedCapabilities, MediaPlayer } from '../../src/types';
import {
createMediaLoadedInfo,
dispatchExistingMediaLoadedInfoAsEvent,
@@ -14,7 +14,7 @@ import {
import { createMediaLoadedInfo as createTestMediaLoadedInfo } from '../test-utils.js';
const options = {
player: mock<AdvancedCameraCardMediaPlayer>(),
player: mock<MediaPlayer>(),
capabilities: mock<MediaLoadedCapabilities>(),
};
@@ -86,7 +86,7 @@ describe('createMediaLoadedInfo', () => {
// @vitest-environment jsdom
describe('dispatchMediaLoadedEvent', () => {
const options = {
player: mock<AdvancedCameraCardMediaPlayer>(),
player: mock<MediaPlayer>(),
capabilities: mock<MediaLoadedCapabilities>(),
};
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it, vi } from 'vitest';
import {
dispatchDismissOverlayMessageEvent,
dispatchShowOverlayMessageEvent,
} from '../../src/utils/overlay-message';
// @vitest-environment jsdom
describe('overlay-message utils', () => {
it('should dispatch show overlay message event', () => {
const element = document.createElement('div');
const message = { text: 'test' };
const handler = vi.fn();
element.addEventListener('advanced-camera-card:overlay-message:show', handler);
dispatchShowOverlayMessageEvent(element, message);
expect(handler).toHaveBeenCalled();
const event = handler.mock.calls[0][0];
expect(event.detail).toBe(message);
});
it('should dispatch dismiss overlay message event', () => {
const element = document.createElement('div');
const handler = vi.fn();
element.addEventListener('advanced-camera-card:overlay-message:dismiss', handler);
dispatchDismissOverlayMessageEvent(element);
expect(handler).toHaveBeenCalled();
});
});
+89
View File
@@ -0,0 +1,89 @@
import { Task } from '@lit-labs/task';
import { html, render, TemplateResult } from 'lit';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { renderProgressIndicator } from '../../src/components/progress-indicator';
import { renderTask } from '../../src/utils/task';
vi.mock('../../src/components/progress-indicator');
const getRenderedContent = (template: TemplateResult): string => {
const container = document.createElement('div');
render(template, container);
return container.textContent || '';
};
// @vitest-environment jsdom
describe('task utilities', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should render initial state', () => {
const task = mock<Task<unknown[], string>>();
task.render.mockImplementation((renderers) => renderers.initial?.());
vi.mocked(renderProgressIndicator).mockReturnValue(html`progress`);
const result = renderTask(task, (r) => html`${r}`);
expect(getRenderedContent(result)).toContain('progress');
});
it('should render pending state', () => {
const task = mock<Task<unknown[], string>>();
task.render.mockImplementation((renderers) => renderers.pending?.());
vi.mocked(renderProgressIndicator).mockReturnValue(html`progress`);
const result = renderTask(task, (r) => html`${r}`);
expect(getRenderedContent(result)).toContain('progress');
});
it('should render error state', () => {
const task = mock<Task<unknown[], string>>();
task.render.mockImplementation((renderers) =>
renderers.error?.(new Error('test error')),
);
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const errorFunc = vi.fn().mockReturnValue(html`error`);
const result = renderTask(task, (r) => html`${r}`, { errorFunc });
expect(errorFunc).toHaveBeenCalledWith(new Error('test error'));
expect(consoleSpy).toHaveBeenCalled();
expect(getRenderedContent(result)).toContain('error');
});
it('should render complete state', () => {
const task = mock<Task<unknown[], string>>();
task.render.mockImplementation((renderers) => renderers.complete?.('result'));
const result = renderTask(task, (r) => html`complete: ${r}`);
expect(getRenderedContent(result)).toContain('complete: result');
});
it('should use custom inProgressFunc', () => {
const task = mock<Task<unknown[], string>>();
task.render.mockImplementation((renderers) => renderers.initial?.());
const inProgressFunc = () => html`custom progress`;
const result = renderTask(task, (r) => html`${r}`, { inProgressFunc });
expect(getRenderedContent(result)).toContain('custom progress');
});
it('should use cardWideConfig', () => {
const task = mock<Task<unknown[], string>>();
task.render.mockImplementation((renderers) => renderers.initial?.());
const cardWideConfig = {};
vi.mocked(renderProgressIndicator).mockReturnValue(html`progress`);
renderTask(task, (r) => html`${r}`, { cardWideConfig });
expect(renderProgressIndicator).toHaveBeenCalledWith({
cardWideConfig,
});
});
});
+233
View File
@@ -0,0 +1,233 @@
import { Task } from '@lit-labs/task';
import { ReactiveControllerHost } from '@lit/reactive-element';
import { afterEach, assert, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { createFetchThumbnailTask } from '../../src/utils/thumbnail';
import { createHASS, flushPromises } from '../test-utils';
vi.mock('@lit-labs/task');
describe('thumbnail utilities', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it('should handle absolute thumbnail URL', async () => {
const host = mock<ReactiveControllerHost>();
const hass = createHASS();
const thumbnailURL = 'http://example.com/thumb.jpg';
createFetchThumbnailTask(
host,
() => hass,
() => thumbnailURL,
);
const call = vi.mocked(Task).mock.calls[0];
assert(call);
const options = call[1];
const result = await options.task([true, thumbnailURL]);
expect(result).toBe(thumbnailURL);
expect(hass.fetchWithAuth).not.toHaveBeenCalled();
});
it('should handle data thumbnail URL', async () => {
const host = mock<ReactiveControllerHost>();
const hass = createHASS();
const thumbnailURL = 'data:image/jpeg;base64,...';
createFetchThumbnailTask(
host,
() => hass,
() => thumbnailURL,
);
const call = vi.mocked(Task).mock.calls[0];
assert(call);
const options = call[1];
const result = await options.task([true, thumbnailURL]);
expect(result).toBe(thumbnailURL);
});
it('should fetch relative thumbnail URL', async () => {
const host = mock<ReactiveControllerHost>();
const hass = createHASS();
const thumbnailURL = '/api/frigate/thumb.jpg';
const dataURL = 'data:image/jpeg;base64,encoded';
const mockResponse = mock<Response>();
Object.defineProperty(mockResponse, 'ok', { value: true });
const mockBlob = new Blob(['test'], { type: 'image/jpeg' });
mockResponse.blob.mockResolvedValue(mockBlob);
vi.mocked(hass.fetchWithAuth).mockResolvedValue(mockResponse);
const mockFileReader = mock<FileReader>({
result: dataURL,
});
vi.stubGlobal(
'FileReader',
vi.fn(() => mockFileReader),
);
createFetchThumbnailTask(
host,
() => hass,
() => thumbnailURL,
);
const call = vi.mocked(Task).mock.calls[0];
assert(call);
const options = call[1];
const runPromise = options.task([true, thumbnailURL]);
await flushPromises();
mockFileReader.onload?.(mock<ProgressEvent<FileReader>>());
const result = await runPromise;
expect(result).toBe(dataURL);
expect(hass.fetchWithAuth).toHaveBeenCalledWith(thumbnailURL);
});
it('should handle fetch failure', async () => {
const host = mock<ReactiveControllerHost>();
const hass = createHASS();
const thumbnailURL = '/api/thumb.jpg';
const mockResponse = mock<Response>();
Object.defineProperty(mockResponse, 'ok', { value: false });
Object.defineProperty(mockResponse, 'statusText', { value: 'Not Found' });
vi.mocked(hass.fetchWithAuth).mockResolvedValue(mockResponse);
createFetchThumbnailTask(
host,
() => hass,
() => thumbnailURL,
);
const call = vi.mocked(Task).mock.calls[0];
assert(call);
const options = call[1];
await expect(options.task([true, thumbnailURL])).rejects.toThrow('Not Found');
});
it('should handle reader error', async () => {
const host = mock<ReactiveControllerHost>();
const hass = createHASS();
const thumbnailURL = '/api/thumb.jpg';
const mockResponse = mock<Response>();
Object.defineProperty(mockResponse, 'ok', { value: true });
mockResponse.blob.mockResolvedValue(new Blob());
vi.mocked(hass.fetchWithAuth).mockResolvedValue(mockResponse);
const mockFileReader = mock<FileReader>();
vi.stubGlobal(
'FileReader',
vi.fn(() => mockFileReader),
);
createFetchThumbnailTask(
host,
() => hass,
() => thumbnailURL,
);
const call = vi.mocked(Task).mock.calls[0];
assert(call);
const options = call[1];
const runPromise = options.task([true, thumbnailURL]);
await flushPromises();
mockFileReader.onerror?.(
new Error('Reader error') as unknown as ProgressEvent<FileReader>,
);
await expect(runPromise).rejects.toThrow('Reader error');
});
it('should handle non-string reader result', async () => {
const host = mock<ReactiveControllerHost>();
const hass = createHASS();
const thumbnailURL = '/api/thumb.jpg';
const mockResponse = mock<Response>();
Object.defineProperty(mockResponse, 'ok', { value: true });
mockResponse.blob.mockResolvedValue(new Blob());
vi.mocked(hass.fetchWithAuth).mockResolvedValue(mockResponse);
const mockFileReader = mock<FileReader>({
result: null as unknown as string, // Non-string result
});
vi.stubGlobal(
'FileReader',
vi.fn(() => mockFileReader),
);
createFetchThumbnailTask(
host,
() => hass,
() => thumbnailURL,
);
const call = vi.mocked(Task).mock.calls[0];
assert(call);
const options = call[1];
const runPromise = options.task([true, thumbnailURL]);
await flushPromises();
mockFileReader.onload?.(mock<ProgressEvent<FileReader>>());
const result = await runPromise;
expect(result).toBeNull();
});
it('should return null if no hass or no url', async () => {
const host = mock<ReactiveControllerHost>();
createFetchThumbnailTask(
host,
() => undefined,
() => undefined,
);
const call = vi.mocked(Task).mock.calls[0];
assert(call);
const options = call[1];
const result = await options.task([false, undefined]);
expect(result).toBeNull();
});
it('should have correct task arguments', () => {
const host = mock<ReactiveControllerHost>();
const hass = createHASS();
const thumbnailURL = 'http://example.com/thumb.jpg';
createFetchThumbnailTask(
host,
() => hass,
() => thumbnailURL,
);
const call = vi.mocked(Task).mock.calls[0];
assert(call && call[1].args);
const args = call[1].args();
expect(args).toEqual([true, thumbnailURL]);
vi.mocked(Task).mockClear();
createFetchThumbnailTask(
host,
() => undefined,
() => undefined,
);
const call2 = vi.mocked(Task).mock.calls[0];
assert(call2 && call2[1].args);
const args2 = call2[1].args();
expect(args2).toEqual([false, undefined]);
});
});
+38 -2
View File
@@ -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
View File
@@ -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);
});
});
-138
View File
@@ -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();
});
});
+147 -3
View File
@@ -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);
});
});
});
-322
View File
@@ -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
+224
View File
@@ -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');
});
});
});
+334
View File
@@ -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);
});
});
});
+2
View File
@@ -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
View File
@@ -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', () => {