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