feat: Add support for Frigate reviews / detections [initial PR] (#2315)

- Add support for Frigate reviews / detections.
 - Add support for GenAI metadata.
- Significant internal refactor to more flexible "UnifiedQuery" to allow
mixing cameras with simple metadata and review metadata (e.g. a timeline
view of a Frigate camera with reviews, and a Reolink camera with simple
metadata).
 - Add support for folder media as camera media.

There are a few more PRs to commit prior to this going live, but
commiting this for now due to the scale of the change.

BREAKING CHANGE: `media_type` and `events_type` are retired under
`live`, `viewer` and `timeline` configuration sections, instead media
type is associated (once) with the camera under `media`.
This commit is contained in:
Dermot Duffy
2026-01-19 13:32:50 -08:00
committed by GitHub
parent 6eb0a87ca8
commit fc32727860
215 changed files with 14491 additions and 6160 deletions
@@ -0,0 +1,109 @@
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';
// @vitest-environment jsdom
describe('CachedValueController', () => {
afterEach(() => {
vi.useRealTimers();
});
it('should construct', () => {
const host = mock<ReactiveControllerHost>();
const callback = vi.fn();
const controller = new CachedValueController(host, 10, callback);
expect(controller).toBeTruthy();
});
it('should remove host', () => {
const host = mock<ReactiveControllerHost>();
const callback = vi.fn();
const controller = new CachedValueController(host, 10, callback);
controller.removeController();
expect(host.removeController).toBeCalled();
});
it('should have timer', () => {
const host = mock<ReactiveControllerHost>();
const callback = vi.fn();
const startCallback = vi.fn();
const stopCallback = vi.fn();
vi.useFakeTimers();
const controller = new CachedValueController(
host,
10,
callback,
startCallback,
stopCallback,
);
controller.startTimer();
expect(startCallback).toBeCalled();
callback.mockReturnValue(3);
vi.runOnlyPendingTimers();
expect(callback).toBeCalled();
expect(host.requestUpdate).toBeCalled();
expect(controller.value).toBe(3);
callback.mockReturnValue(4);
vi.runOnlyPendingTimers();
expect(callback).toBeCalled();
expect(host.requestUpdate).toBeCalled();
expect(controller.value).toBe(4);
expect(controller.hasTimer()).toBeTruthy();
controller.stopTimer();
expect(stopCallback).toBeCalled();
callback.mockReset();
vi.runOnlyPendingTimers();
expect(callback).not.toBeCalled();
});
it('should clear value', () => {
const host = mock<ReactiveControllerHost>();
const callback = vi.fn().mockReturnValue(42);
vi.useFakeTimers();
const controller = new CachedValueController(host, 10, callback);
controller.startTimer();
vi.runOnlyPendingTimers();
expect(controller.value).equal(42);
controller.clearValue();
expect(controller.value).toBeUndefined();
});
it('should connect and disconnect host', () => {
const host = mock<ReactiveControllerHost>();
const callback = vi.fn().mockReturnValue(43);
const startCallback = vi.fn();
const stopCallback = vi.fn();
const controller = new CachedValueController(
host,
10,
callback,
startCallback,
stopCallback,
);
controller.hostConnected();
expect(controller.value).equal(43);
expect(startCallback).toBeCalled();
expect(host.requestUpdate).toBeCalled();
controller.hostDisconnected();
expect(controller.value).toBeUndefined();
expect(stopCallback).toBeCalled();
});
});
@@ -1,134 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { ViewManagerEpoch } from '../../../src/card-controller/view/types';
import {
getUpFolderMediaItem,
upFolderClickHandler,
} from '../../../src/components-lib/folder/up-folder';
import { ViewFolder } from '../../../src/view/item';
import { EventMediaQuery, FolderViewQuery } from '../../../src/view/query';
import {
createCardAPI,
createFolder,
createView,
TestViewMedia,
} from '../../test-utils';
describe('upFolderClickHandler', () => {
const item = new TestViewMedia();
it('should ignore non-folder query', () => {
const api = createCardAPI();
const view = createView({
query: new EventMediaQuery(),
});
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const epoch: ViewManagerEpoch = {
manager: api.getViewManager(),
};
upFolderClickHandler(item, new Event('click'), epoch);
expect(api.getViewManager().setViewByParametersWithExistingQuery).not.toBeCalled();
});
it('should ignore folder query without raw query', () => {
const api = createCardAPI();
const view = createView({
query: new FolderViewQuery(),
});
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const epoch: ViewManagerEpoch = {
manager: api.getViewManager(),
};
upFolderClickHandler(item, new Event('click'), epoch);
expect(api.getViewManager().setViewByParametersWithExistingQuery).not.toBeCalled();
});
it('should ignore folder query wihout parent to go up to', () => {
const api = createCardAPI();
const view = createView({
query: new FolderViewQuery({ folder: createFolder(), path: ['path'] }),
});
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const epoch: ViewManagerEpoch = {
manager: api.getViewManager(),
};
upFolderClickHandler(item, new Event('click'), epoch);
expect(api.getViewManager().setViewByParametersWithExistingQuery).not.toBeCalled();
});
it('should go up in the folder hierarchy', () => {
const api = createCardAPI();
const folder = createFolder();
const view = createView({
query: new FolderViewQuery({
folder,
path: ['one', 'two', 'three'],
}),
});
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const epoch: ViewManagerEpoch = {
manager: api.getViewManager(),
};
upFolderClickHandler(item, new Event('click'), epoch);
expect(api.getViewManager().setViewByParametersWithExistingQuery).toBeCalledWith({
params: {
query: expect.any(FolderViewQuery),
},
});
const query = vi.mocked(api.getViewManager().setViewByParametersWithExistingQuery)
.mock.calls[0][0]?.params?.query;
expect(query?.getQuery()).toEqual({
folder,
path: ['one', 'two'],
});
});
});
describe('getUpFolderMediaItem', () => {
it('should ignore non-folder query', () => {
const view = createView({
query: new EventMediaQuery(),
});
expect(getUpFolderMediaItem(view)).toBeNull();
});
it('should ignore folder query without raw query', () => {
const view = createView({
query: new FolderViewQuery(),
});
expect(getUpFolderMediaItem(view)).toBeNull();
});
it('should ignore folder query without parents', () => {
const view = createView({
query: new FolderViewQuery({ folder: createFolder(), path: ['one'] }),
});
expect(getUpFolderMediaItem(view)).toBeNull();
});
it('should get up folder media', () => {
const view = createView({
query: new FolderViewQuery({
folder: createFolder(),
path: ['one', 'two', 'three'],
}),
});
const folderMedia = getUpFolderMediaItem(view);
expect(folderMedia).toBeInstanceOf(ViewFolder);
expect(folderMedia?.getIcon()).toBe('mdi:arrow-up-left');
});
});
@@ -0,0 +1,322 @@
import { assert, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import {
ViewManagerEpoch,
ViewManagerInterface,
} from '../../../src/card-controller/view/types';
import { GalleryController } from '../../../src/components-lib/gallery/controller';
import { THUMBNAIL_WIDTH_DEFAULT } from '../../../src/config/schema/common/controls/thumbnails';
import { FolderConfig } from '../../../src/config/schema/folders';
import { MediaGalleryThumbnailsConfig } from '../../../src/config/schema/media-gallery';
import { ViewFolder, ViewItem, ViewMedia, ViewMediaType } from '../../../src/view/item';
import { QueryResults } from '../../../src/view/query-results';
import { UnifiedQuery } from '../../../src/view/unified-query';
import { UnifiedQueryRunner } from '../../../src/view/unified-query-runner';
import { View } from '../../../src/view/view';
// @vitest-environment jsdom
const createThumbnailConfig = (
config?: Partial<MediaGalleryThumbnailsConfig>,
): MediaGalleryThumbnailsConfig => ({
size: THUMBNAIL_WIDTH_DEFAULT,
show_details: true,
show_favorite_control: true,
show_timeline_control: true,
show_download_control: true,
show_review_control: true,
show_info_control: true,
...config,
});
describe('GalleryController', () => {
it('should construct', () => {
const host = document.createElement('div');
const controller = new GalleryController(host);
expect(controller).toBeTruthy();
expect(controller.getItems()).toBeNull();
});
describe('setItemsFromView', () => {
it('should set items from view', () => {
const host = document.createElement('div');
const controller = new GalleryController(host);
const view = mock<View>();
view.queryResults = mock<QueryResults>();
const item1 = new ViewMedia(ViewMediaType.Clip);
const item2 = new ViewMedia(ViewMediaType.Clip);
vi.mocked(view.queryResults.getResults).mockReturnValue([item1, item2]);
controller.setItemsFromView(view);
// Items should be reversed (newest first)
expect(controller.getItems()).toEqual([item2, item1]);
});
it('should handle null view or results', () => {
const host = document.createElement('div');
const controller = new GalleryController(host);
controller.setItemsFromView(null);
expect(controller.getItems()).toBeNull();
const view = mock<View>();
view.queryResults = null;
controller.setItemsFromView(view);
expect(controller.getItems()).toBeNull();
});
it('should not update items if results are the same', () => {
const host = document.createElement('div');
const controller = new GalleryController(host);
const view = mock<View>();
view.queryResults = mock<QueryResults>();
const item1 = new ViewMedia(ViewMediaType.Clip);
vi.mocked(view.queryResults.getResults).mockReturnValue([item1]);
controller.setItemsFromView(view);
const itemsFirst = controller.getItems();
// Second call with same results object
controller.setItemsFromView(view, view);
expect(controller.getItems()).toBe(itemsFirst);
});
it('should update items if results change', () => {
const host = document.createElement('div');
const controller = new GalleryController(host);
const view1 = mock<View>();
view1.queryResults = mock<QueryResults>();
const item1 = new ViewMedia(ViewMediaType.Clip);
vi.mocked(view1.queryResults.getResults).mockReturnValue([item1]);
controller.setItemsFromView(view1);
const view2 = mock<View>();
view2.queryResults = mock<QueryResults>();
const item2 = new ViewMedia(ViewMediaType.Clip);
vi.mocked(view2.queryResults.getResults).mockReturnValue([item2]);
controller.setItemsFromView(view2, view1);
expect(controller.getItems()).toEqual([item2]);
});
});
it('should set thumbnail size', () => {
const host = document.createElement('div');
const controller = new GalleryController(host);
controller.setThumbnailSize(150);
expect(host.style.getPropertyValue('--advanced-camera-card-thumbnail-size')).toBe(
'150px',
);
controller.setThumbnailSize();
expect(host.style.getPropertyValue('--advanced-camera-card-thumbnail-size')).toBe(
`${THUMBNAIL_WIDTH_DEFAULT}px`,
);
});
describe('getColumnWidth', () => {
it('should return default width if no config', () => {
const host = document.createElement('div');
const controller = new GalleryController(host);
expect(controller.getColumnWidth()).toBe(THUMBNAIL_WIDTH_DEFAULT);
});
it('should return size if details are hidden', () => {
const host = document.createElement('div');
const controller = new GalleryController(host);
expect(
controller.getColumnWidth(
createThumbnailConfig({ size: 123, show_details: false }),
),
).toBe(123);
});
it('should return gallery width if details are shown and items are not all folders', () => {
const host = document.createElement('div');
const controller = new GalleryController(host);
const view = mock<View>();
const item = new ViewMedia(ViewMediaType.Clip);
view.queryResults = mock<QueryResults>();
vi.mocked(view.queryResults.getResults).mockReturnValue([item]);
controller.setItemsFromView(view);
expect(
controller.getColumnWidth(createThumbnailConfig({ show_details: true })),
).toBe(300);
});
it('should return folder width if details are shown and items are all folders', () => {
const host = document.createElement('div');
const controller = new GalleryController(host);
const view = mock<View>();
const item = new ViewFolder(mock<FolderConfig>(), []);
view.queryResults = mock<QueryResults>();
vi.mocked(view.queryResults.getResults).mockReturnValue([item]);
controller.setItemsFromView(view);
expect(
controller.getColumnWidth(createThumbnailConfig({ show_details: true })),
).toBe(200);
});
});
it('should get column count round method', () => {
const host = document.createElement('div');
const controller = new GalleryController(host);
expect(
controller.getColumnCountRoundMethod(
createThumbnailConfig({ show_details: true }),
),
).toBe('floor');
expect(
controller.getColumnCountRoundMethod(
createThumbnailConfig({ show_details: false }),
),
).toBe('ceil');
expect(controller.getColumnCountRoundMethod()).toBe('ceil');
});
describe('extend', () => {
it('should handle missing view or query', async () => {
const host = document.createElement('div');
const controller = new GalleryController(host);
const runner = mock<UnifiedQueryRunner>();
const manager = mock<ViewManagerInterface>();
const epoch = mock<ViewManagerEpoch>({
manager: manager,
});
manager.getView.mockReturnValue(null);
await controller.extend(runner, epoch, 'earlier');
expect(runner.extend).not.toHaveBeenCalled();
const view = mock<View>();
view.query = null;
manager.getView.mockReturnValue(view);
await controller.extend(runner, epoch, 'earlier');
expect(runner.extend).not.toHaveBeenCalled();
});
it('should handle missing results', async () => {
const host = document.createElement('div');
const controller = new GalleryController(host);
const runner = mock<UnifiedQueryRunner>();
const manager = mock<ViewManagerInterface>();
const epoch = mock<ViewManagerEpoch>({
manager: manager,
});
const view = mock<View>();
view.query = mock<UnifiedQuery>();
view.queryResults = mock<QueryResults>();
vi.mocked(view.queryResults.getResults).mockReturnValue(null);
manager.getView.mockReturnValue(view);
await controller.extend(runner, epoch, 'earlier');
expect(runner.extend).not.toHaveBeenCalled();
});
it('should extend and update view', async () => {
const host = document.createElement('div');
const controller = new GalleryController(host);
const runner = mock<UnifiedQueryRunner>();
const manager = mock<ViewManagerInterface>();
const epoch = mock<ViewManagerEpoch>({
manager: manager,
});
const view = mock<View>();
const query = mock<UnifiedQuery>();
const selectedResult = mock<ViewItem>();
const results = [selectedResult];
view.query = query;
view.queryResults = mock<QueryResults>();
vi.mocked(view.queryResults.getResults).mockReturnValue(results);
vi.mocked(view.queryResults.getSelectedResult).mockReturnValue(selectedResult);
manager.getView.mockReturnValue(view);
const extendedQuery = mock<UnifiedQuery>();
const extendedResults = [selectedResult, new ViewMedia(ViewMediaType.Clip)];
runner.extend.mockResolvedValue({
query: extendedQuery,
results: extendedResults,
});
await controller.extend(runner, epoch, 'earlier');
expect(runner.extend).toHaveBeenCalledWith(query, results, 'earlier', {
useCache: true,
});
expect(manager.setViewByParameters).toHaveBeenCalled();
const setViewCalls = vi.mocked(manager.setViewByParameters).mock.calls;
const setViewParams = setViewCalls[0][0];
assert(setViewParams && setViewParams.params);
const newQueryResults = setViewParams.params.queryResults;
assert(newQueryResults);
expect(newQueryResults.getResults()).toEqual(extendedResults);
expect(newQueryResults.getSelectedResult()).toBe(selectedResult);
});
it('should handle extend failure', async () => {
const host = document.createElement('div');
const controller = new GalleryController(host);
const runner = mock<UnifiedQueryRunner>();
const manager = mock<ViewManagerInterface>();
const epoch = mock<ViewManagerEpoch>({
manager: manager,
});
const view = mock<View>();
view.query = mock<UnifiedQuery>();
view.queryResults = mock<QueryResults>();
vi.mocked(view.queryResults.getResults).mockReturnValue([
new ViewMedia(ViewMediaType.Clip),
]);
manager.getView.mockReturnValue(view);
const error = new Error('test error');
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
runner.extend.mockRejectedValue(error);
await controller.extend(runner, epoch, 'earlier');
expect(spy).toHaveBeenCalledWith(error.message);
spy.mockRestore();
expect(manager.setViewByParameters).not.toHaveBeenCalled();
});
it('should not update view if extend returns null', async () => {
const host = document.createElement('div');
const controller = new GalleryController(host);
const runner = mock<UnifiedQueryRunner>();
const manager = mock<ViewManagerInterface>();
const epoch = mock<ViewManagerEpoch>({
manager: manager,
});
const view = mock<View>();
view.query = mock<UnifiedQuery>();
view.queryResults = mock<QueryResults>();
vi.mocked(view.queryResults.getResults).mockReturnValue([
new ViewMedia(ViewMediaType.Clip),
]);
manager.getView.mockReturnValue(view);
runner.extend.mockResolvedValue(null);
await controller.extend(runner, epoch, 'earlier');
expect(manager.setViewByParameters).not.toHaveBeenCalled();
});
});
});
@@ -1,275 +0,0 @@
import { describe, expect, it } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { ViewManagerInterface } from '../../../src/card-controller/view/types';
import { FoldersManager } from '../../../src/card-controller/folders/manager';
import {
FOLDER_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN,
FolderGalleryController,
} from '../../../src/components-lib/gallery/folder-gallery-controller';
import { THUMBNAIL_WIDTH_DEFAULT } from '../../../src/config/schema/common/controls/thumbnails';
import { ViewFolder } from '../../../src/view/item';
import { FolderViewQuery } from '../../../src/view/query';
import { QueryResults } from '../../../src/view/query-results';
import { createFolder, createView, TestViewMedia } from '../../test-utils';
// @vitest-environment jsdom
describe('FolderGalleryController', () => {
describe('should set thumbnail size', () => {
it('should set thumbnail size explicitly', () => {
const host = document.createElement('div');
const controller = new FolderGalleryController(host);
controller.setThumbnailSize(100);
expect(host.style.getPropertyValue('--advanced-camera-card-thumbnail-size')).toBe(
'100px',
);
});
it('should set thumbnail size implicitly', () => {
const host = document.createElement('div');
const controller = new FolderGalleryController(host);
controller.setThumbnailSize();
expect(host.style.getPropertyValue('--advanced-camera-card-thumbnail-size')).toBe(
`${THUMBNAIL_WIDTH_DEFAULT}px`,
);
});
});
describe('should get column width', () => {
it('should get default column width', () => {
const host = document.createElement('div');
const controller = new FolderGalleryController(host);
expect(controller.getColumnWidth()).toBe(THUMBNAIL_WIDTH_DEFAULT);
});
it('should get column width with defailts', () => {
const host = document.createElement('div');
const controller = new FolderGalleryController(host);
expect(
controller.getColumnWidth({
size: 100,
show_details: true,
show_favorite_control: true,
show_timeline_control: true,
show_download_control: true,
}),
).toBe(FOLDER_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN);
});
it('should get column width with explicit size', () => {
const host = document.createElement('div');
const controller = new FolderGalleryController(host);
expect(
controller.getColumnWidth({
size: 142,
show_details: false,
show_favorite_control: true,
show_timeline_control: true,
show_download_control: true,
}),
).toBe(142);
});
});
describe('should get column count round method', () => {
it('should get default column count round method', () => {
const host = document.createElement('div');
const controller = new FolderGalleryController(host);
expect(controller.getColumnCountRoundMethod()).toBe('ceil');
});
it('should get column count round method when details being shown', () => {
const host = document.createElement('div');
const controller = new FolderGalleryController(host);
expect(
controller.getColumnCountRoundMethod({
size: 100,
show_details: true,
show_favorite_control: true,
show_timeline_control: true,
show_download_control: true,
}),
).toBe('floor');
});
});
describe('should handle item clicks', () => {
it('should ignore calls without view', () => {
const host = document.createElement('div');
const controller = new FolderGalleryController(host);
const viewManager = mock<ViewManagerInterface>();
const item = new TestViewMedia();
controller.itemClickHandler(viewManager, item, new Event('click'));
expect(viewManager.setViewByParameters).not.toHaveBeenCalled();
expect(viewManager.setViewByParametersWithExistingQuery).not.toHaveBeenCalled();
});
it('should handle media item click', () => {
const host = document.createElement('div');
const item_1 = new TestViewMedia();
const item_2 = new TestViewMedia();
const controller = new FolderGalleryController(host);
const event = new Event('click');
const view = createView({
queryResults: new QueryResults({ results: [item_1, item_2], selectedIndex: 0 }),
});
const viewManager = mock<ViewManagerInterface>();
viewManager.getView.mockReturnValue(view);
controller.itemClickHandler(viewManager, item_2, event);
expect(viewManager.setViewByParameters).toHaveBeenCalledWith({
params: {
view: 'media',
queryResults: expect.any(QueryResults),
},
});
const newQueryResults =
viewManager.setViewByParameters.mock.calls[0][0]?.params?.queryResults;
expect(newQueryResults).toBeInstanceOf(QueryResults);
expect(newQueryResults?.getSelectedResult()).toBe(item_2);
});
describe('should handle folder click', () => {
it('should handle normal folder click', () => {
const folder = createFolder();
const folderItem = new ViewFolder(folder, {
id: 'parent',
});
const controller = new FolderGalleryController(document.createElement('div'));
const event = new Event('click');
const view = createView({
queryResults: new QueryResults({
results: [new TestViewMedia(), folderItem],
selectedIndex: 0,
}),
query: new FolderViewQuery({
folder,
path: [{ ha: { id: 'grandparent' } }],
}),
});
const viewManager = mock<ViewManagerInterface>();
viewManager.getView.mockReturnValue(view);
const foldersManager = mock<FoldersManager>();
foldersManager.generateChildFolderQuery.mockReturnValue({
folder,
path: [
{ ha: { id: 'grandparent' } },
{ folder: folderItem, ha: { id: 'parent' } },
],
});
controller.itemClickHandler(viewManager, folderItem, event, foldersManager);
expect(viewManager.setViewByParametersWithExistingQuery).toHaveBeenCalledWith({
params: {
query: expect.any(FolderViewQuery),
},
});
const newQuery =
viewManager.setViewByParametersWithExistingQuery.mock.calls[0][0]?.params
?.query;
expect(newQuery).toBeInstanceOf(FolderViewQuery);
expect(newQuery?.getQuery()).toEqual({
folder,
path: [
{ ha: { id: 'grandparent' } },
{ folder: folderItem, ha: { id: 'parent' } },
],
});
});
it('should ignore folder click without folders manager', () => {
const folder = createFolder();
const folderItem = new ViewFolder(folder, {
id: 'parent',
});
const controller = new FolderGalleryController(document.createElement('div'));
const event = new Event('click');
const view = createView({
queryResults: new QueryResults({
results: [new TestViewMedia(), folderItem],
selectedIndex: 0,
}),
query: new FolderViewQuery({
folder,
path: [{ ha: { id: 'grandparent' } }],
}),
});
const viewManager = mock<ViewManagerInterface>();
viewManager.getView.mockReturnValue(view);
controller.itemClickHandler(viewManager, folderItem, event);
expect(viewManager.setViewByParametersWithExistingQuery).not.toHaveBeenCalled();
});
it('should handle folder click without query', () => {
const controller = new FolderGalleryController(document.createElement('div'));
const view = createView({
query: null,
});
const viewManager = mock<ViewManagerInterface>();
viewManager.getView.mockReturnValue(view);
controller.itemClickHandler(
viewManager,
new ViewFolder(createFolder(), {
id: 'parent',
}),
new Event('click'),
);
expect(viewManager.setViewByParametersWithExistingQuery).not.toBeCalled();
expect(viewManager.setViewByParameters).not.toBeCalled();
});
it('should handle folder click without path', () => {
const controller = new FolderGalleryController(document.createElement('div'));
const folder = createFolder();
const view = createView({
query: new FolderViewQuery({
folder,
path: [{ ha: { id: 'id' } }],
}),
});
const viewManager = mock<ViewManagerInterface>();
viewManager.getView.mockReturnValue(view);
controller.itemClickHandler(
viewManager,
new ViewFolder(folder),
new Event('click'),
);
expect(viewManager.setViewByParametersWithExistingQuery).not.toBeCalled();
expect(viewManager.setViewByParameters).not.toBeCalled();
});
});
});
});
@@ -1,427 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { ExtendedMediaQueryResult } from '../../../src/camera-manager/manager';
import {
EventQuery,
QueryType,
RecordingQuery,
} from '../../../src/camera-manager/types';
import { ViewManagerEpoch } from '../../../src/card-controller/view/types';
import { ViewManager } from '../../../src/card-controller/view/view-manager';
import {
MEDIA_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN,
MediaGalleryController,
} from '../../../src/components-lib/gallery/media-gallery-controller';
import { THUMBNAIL_WIDTH_DEFAULT } from '../../../src/config/schema/common/controls/thumbnails';
import {
EventMediaQuery,
FolderViewQuery,
RecordingMediaQuery,
} from '../../../src/view/query';
import { QueryResults } from '../../../src/view/query-results';
import {
createCameraManager,
createLitElement,
createView,
TestViewMedia,
} from '../../test-utils';
// @vitest-environment jsdom
describe('MediaGalleryController', () => {
afterEach(() => {
vi.restoreAllMocks();
});
describe('getMedia', () => {
it('should return null initially', () => {
expect(new MediaGalleryController(createLitElement()).getMedia()).toBeNull();
});
});
describe('setMediaFromView', () => {
it('should set media from view if media is null', () => {
const controller = new MediaGalleryController(createLitElement());
controller.setMediaFromView(null);
expect(controller.getMedia()).toBeNull();
});
it('should set media from view if results are null', () => {
const controller = new MediaGalleryController(createLitElement());
controller.setMediaFromView(createView({ queryResults: new QueryResults() }));
expect(controller.getMedia()).toEqual([]);
});
it('should update media when query results first set', () => {
const media_1 = new TestViewMedia({ id: 'one' });
const media_2 = new TestViewMedia({ id: 'two' });
const newView = createView({
queryResults: new QueryResults({ results: [media_1, media_2] }),
});
const controller = new MediaGalleryController(createLitElement());
controller.setMediaFromView(newView);
expect(controller.getMedia()).toEqual([media_2, media_1]);
});
it('should update media when query results change', () => {
const media_1 = new TestViewMedia({ id: 'one' });
const media_2 = new TestViewMedia({ id: 'two' });
const media_3 = new TestViewMedia({ id: 'three' });
const oldView = createView({
queryResults: new QueryResults({ results: [media_1, media_2] }),
});
const newView = createView({
queryResults: new QueryResults({ results: [media_2, media_3] }),
});
const controller = new MediaGalleryController(createLitElement());
controller.setMediaFromView(newView, oldView);
expect(controller.getMedia()).toEqual([media_3, media_2]);
});
it('should not update media if query results are the same', () => {
const media_1 = new TestViewMedia({ id: 'one' });
const media_2 = new TestViewMedia({ id: 'two' });
const results = [media_1, media_2];
const oldView = createView({
queryResults: new QueryResults({ results }),
});
const newView = createView({
queryResults: new QueryResults({ results }),
});
const controller = new MediaGalleryController(createLitElement());
controller.setMediaFromView(oldView);
const media = controller.getMedia();
controller.setMediaFromView(newView, oldView);
expect(controller.getMedia()).toBe(media);
});
});
describe('should set thumbnail size', () => {
it('should set thumbnail size explicitly', () => {
const host = document.createElement('div');
const controller = new MediaGalleryController(host);
controller.setThumbnailSize(100);
expect(host.style.getPropertyValue('--advanced-camera-card-thumbnail-size')).toBe(
'100px',
);
});
it('should set thumbnail size implicitly', () => {
const host = document.createElement('div');
const controller = new MediaGalleryController(host);
controller.setThumbnailSize();
expect(host.style.getPropertyValue('--advanced-camera-card-thumbnail-size')).toBe(
`${THUMBNAIL_WIDTH_DEFAULT}px`,
);
});
});
describe('should get column width', () => {
it('should get default column width', () => {
const host = document.createElement('div');
const controller = new MediaGalleryController(host);
expect(controller.getColumnWidth()).toBe(THUMBNAIL_WIDTH_DEFAULT);
});
it('should get column width with defailts', () => {
const host = document.createElement('div');
const controller = new MediaGalleryController(host);
expect(
controller.getColumnWidth({
size: 100,
show_details: true,
show_favorite_control: true,
show_timeline_control: true,
show_download_control: true,
}),
).toBe(MEDIA_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN);
});
it('should get column width with explicit size', () => {
const host = document.createElement('div');
const controller = new MediaGalleryController(host);
expect(
controller.getColumnWidth({
size: 142,
show_details: false,
show_favorite_control: true,
show_timeline_control: true,
show_download_control: true,
}),
).toBe(142);
});
});
describe('should get column count round method', () => {
it('should get default column count round method', () => {
const host = document.createElement('div');
const controller = new MediaGalleryController(host);
expect(controller.getColumnCountRoundMethod()).toBe('ceil');
});
it('should get column count round method when details being shown', () => {
const host = document.createElement('div');
const controller = new MediaGalleryController(host);
expect(
controller.getColumnCountRoundMethod({
size: 100,
show_details: true,
show_favorite_control: true,
show_timeline_control: true,
show_download_control: true,
}),
).toBe('floor');
});
});
describe('extendMediaGallery', () => {
it('should do nothing if no view is available', async () => {
const controller = new MediaGalleryController(createLitElement());
const cameraManager = createCameraManager();
const viewManagerEpoch = mock<ViewManagerEpoch>();
viewManagerEpoch.manager.getView = vi.fn().mockReturnValue(null);
await controller.extendMediaGallery(cameraManager, viewManagerEpoch, 'earlier');
expect(cameraManager.extendMediaQueries).not.toHaveBeenCalled();
});
it('should do nothing if no queries are available', async () => {
const controller = new MediaGalleryController(createLitElement());
const cameraManager = createCameraManager();
const viewManagerEpoch = mock<ViewManagerEpoch>();
viewManagerEpoch.manager.getView = vi.fn().mockReturnValue(
createView({
query: new EventMediaQuery(),
queryResults: new QueryResults({ results: [new TestViewMedia()] }),
}),
);
await controller.extendMediaGallery(cameraManager, viewManagerEpoch, 'earlier');
expect(cameraManager.extendMediaQueries).not.toHaveBeenCalled();
});
it('should do nothing if no query results are available', async () => {
const controller = new MediaGalleryController(createLitElement());
const cameraManager = createCameraManager();
const viewManagerEpoch = mock<ViewManagerEpoch>();
viewManagerEpoch.manager.getView = vi.fn().mockReturnValue(createView());
await controller.extendMediaGallery(cameraManager, viewManagerEpoch, 'earlier');
expect(cameraManager.extendMediaQueries).not.toHaveBeenCalled();
});
it('should do nothing if non-media query is present', async () => {
const controller = new MediaGalleryController(createLitElement());
const cameraManager = createCameraManager();
const viewManagerEpoch = mock<ViewManagerEpoch>();
viewManagerEpoch.manager.getView = vi
.fn()
.mockReturnValue(createView({ query: new FolderViewQuery() }));
await controller.extendMediaGallery(cameraManager, viewManagerEpoch, 'earlier');
expect(cameraManager.extendMediaQueries).not.toHaveBeenCalled();
});
it('should successfully extend event media queries', async () => {
const controller = new MediaGalleryController(createLitElement());
const cameraManager = createCameraManager();
const viewManagerEpoch = mock<ViewManagerEpoch>({ manager: mock<ViewManager>() });
const existingRawQueries: EventQuery[] = [
{ type: QueryType.Event, cameraIDs: new Set(['camera.office']) },
];
const existingMedia = [new TestViewMedia()];
const baseView = createView({
query: new EventMediaQuery(existingRawQueries),
queryResults: new QueryResults({ results: existingMedia }),
});
viewManagerEpoch.manager.getView = vi.fn().mockReturnValue(baseView);
const newQueries: EventQuery[] = [
{ type: QueryType.Event, cameraIDs: new Set(['camera.office']) },
];
const newResults = [new TestViewMedia()];
const extension: ExtendedMediaQueryResult<EventQuery> = {
queries: newQueries,
results: newResults,
};
vi.mocked(cameraManager.extendMediaQueries).mockResolvedValue(extension);
await controller.extendMediaGallery(cameraManager, viewManagerEpoch, 'earlier');
expect(cameraManager.extendMediaQueries).toBeCalledWith(
existingRawQueries,
existingMedia,
'earlier',
{ useCache: true },
);
expect(viewManagerEpoch.manager.setViewByParameters).toBeCalledWith({
baseView,
params: {
query: expect.any(EventMediaQuery),
queryResults: expect.any(QueryResults),
},
});
const callArguments = vi.mocked(viewManagerEpoch.manager.setViewByParameters).mock
.lastCall?.[0];
expect(callArguments?.params?.query?.getQuery()).toEqual(newQueries);
expect(callArguments?.params?.queryResults?.getResults()).toEqual(newResults);
});
});
it('should successfully extend recording media queries', async () => {
const controller = new MediaGalleryController(createLitElement());
const cameraManager = createCameraManager();
const viewManagerEpoch = mock<ViewManagerEpoch>({ manager: mock<ViewManager>() });
const existingRawQueries: RecordingQuery[] = [
{ type: QueryType.Recording, cameraIDs: new Set(['camera.office']) },
];
const existingMedia = [new TestViewMedia()];
const baseView = createView({
query: new RecordingMediaQuery(existingRawQueries),
queryResults: new QueryResults({ results: existingMedia }),
});
viewManagerEpoch.manager.getView = vi.fn().mockReturnValue(baseView);
const newQueries: RecordingQuery[] = [
{ type: QueryType.Recording, cameraIDs: new Set(['camera.office']) },
];
const newResults = [new TestViewMedia()];
const extension: ExtendedMediaQueryResult<RecordingQuery> = {
queries: newQueries,
results: newResults,
};
vi.mocked(cameraManager.extendMediaQueries).mockResolvedValue(extension);
await controller.extendMediaGallery(cameraManager, viewManagerEpoch, 'earlier');
expect(cameraManager.extendMediaQueries).toBeCalledWith(
existingRawQueries,
existingMedia,
'earlier',
{ useCache: true },
);
expect(viewManagerEpoch.manager.setViewByParameters).toBeCalledWith({
baseView,
params: {
query: expect.any(RecordingMediaQuery),
queryResults: expect.any(QueryResults),
},
});
const callArguments = vi.mocked(viewManagerEpoch.manager.setViewByParameters).mock
.lastCall?.[0];
expect(callArguments?.params?.query?.getQuery()).toEqual(newQueries);
expect(callArguments?.params?.queryResults?.getResults()).toEqual(newResults);
});
it('should handle errors gracefully', async () => {
const controller = new MediaGalleryController(createLitElement());
const cameraManager = createCameraManager();
const viewManagerEpoch = mock<ViewManagerEpoch>({ manager: mock<ViewManager>() });
viewManagerEpoch.manager.getView = vi.fn().mockReturnValue(
createView({
query: new EventMediaQuery([
{ type: QueryType.Event, cameraIDs: new Set(['camera.office']) },
]),
queryResults: new QueryResults({ results: [new TestViewMedia()] }),
}),
);
vi.mocked(cameraManager.extendMediaQueries).mockRejectedValue(
new Error('Test error'),
);
const consoleSpy = vi.spyOn(global.console, 'warn').mockReturnValue(undefined);
await controller.extendMediaGallery(cameraManager, viewManagerEpoch, 'earlier');
expect(viewManagerEpoch.manager.setViewByParameters).not.toHaveBeenCalled();
expect(consoleSpy).toBeCalledWith('Test error');
});
it('should handle null extension gracefully', async () => {
const controller = new MediaGalleryController(createLitElement());
const cameraManager = createCameraManager();
const viewManagerEpoch = mock<ViewManagerEpoch>({ manager: mock<ViewManager>() });
viewManagerEpoch.manager.getView = vi.fn().mockReturnValue(
createView({
query: new EventMediaQuery([
{ type: QueryType.Event, cameraIDs: new Set(['camera.office']) },
]),
queryResults: new QueryResults({ results: [new TestViewMedia()] }),
}),
);
vi.mocked(cameraManager.extendMediaQueries).mockResolvedValue(null);
await controller.extendMediaGallery(cameraManager, viewManagerEpoch, 'earlier');
expect(viewManagerEpoch.manager.setViewByParameters).not.toHaveBeenCalled();
});
describe('should handle item click', () => {
it('should do nothing without a view', () => {
const controller = new MediaGalleryController(createLitElement());
const viewManager = mock<ViewManager>();
controller.itemClickHandler(viewManager, 0, new Event('click'));
expect(viewManager.setViewByParameters).not.toHaveBeenCalled();
});
it('should change view to selected item', () => {
const media = [
new TestViewMedia({ id: 'zero' }),
new TestViewMedia({ id: 'one' }),
];
const view = createView({
queryResults: new QueryResults({ results: media, selectedIndex: 0 }),
});
const controller = new MediaGalleryController(createLitElement());
controller.setMediaFromView(view);
const viewManager = mock<ViewManager>();
viewManager.getView.mockReturnValue(view);
controller.itemClickHandler(
viewManager,
// As the media in the gallery is reversed, passing in 0 as a
// reversedIndex argument is requesting the selection of the media item
// with index 1 (from an array of 2 media items).
0,
new Event('click'),
);
expect(viewManager.setViewByParameters).toBeCalledWith({
params: {
view: 'media',
queryResults: expect.any(QueryResults),
},
});
const newQueryResults = vi.mocked(viewManager.setViewByParameters).mock
.lastCall?.[0]?.params?.queryResults;
expect(newQueryResults?.getSelectedResult()).toEqual(media[1]);
});
});
});
@@ -22,6 +22,12 @@ describe('IconController', () => {
);
});
it('should return tplink SVG for tplink icon', () => {
expect(new IconController().getCustomIcon({ icon: 'tplink' })).toMatch(
/tplink.svg$/,
);
});
it('should return iris SVG for iris icon', () => {
expect(new IconController().getCustomIcon({ icon: 'iris' })).toMatch(/iris.svg$/);
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,501 @@
import { format } from 'date-fns';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { CameraManager } from '../../../src/camera-manager/manager';
import { ViewItemManager } from '../../../src/card-controller/view/item-manager';
import { ViewManagerEpoch } from '../../../src/card-controller/view/types';
import {
MediaDetailsController,
OverlayControlsContext,
} from '../../../src/components-lib/media/details-controller';
import { OverlayMessageControl } from '../../../src/types';
import { formatDateAndTime } from '../../../src/utils/basic';
import { ViewFolder, ViewMediaType } from '../../../src/view/item';
import { createCardAPI, createFolder, TestViewMedia } from '../../test-utils';
describe('MediaDetailsController', () => {
describe('should set heading', () => {
it('should set heading on event with what, tags and score', () => {
const item = new TestViewMedia({
what: ['person', 'car'],
tags: ['tag1', 'tag2'],
score: 0.5,
});
const controller = new MediaDetailsController();
controller.calculate(null, item);
expect(controller.getHeading()?.title).toBe('Person, Car: Tag1, Tag2 50.00%');
});
it('should set heading on event with tags', () => {
const item = new TestViewMedia({
tags: ['tag1', 'tag2'],
});
const controller = new MediaDetailsController();
controller.calculate(null, item);
expect(controller.getHeading()?.title).toBe('Tag1, Tag2');
});
it('should set heading on event with what', () => {
const item = new TestViewMedia({
what: ['person', 'car'],
});
const controller = new MediaDetailsController();
controller.calculate(null, item);
expect(controller.getHeading()?.title).toBe('Person, Car');
});
it('should set null heading on event with no other information', () => {
const item = new TestViewMedia({
mediaType: ViewMediaType.Snapshot,
what: null,
tags: null,
score: null,
});
const controller = new MediaDetailsController();
controller.calculate(null, item);
expect(controller.getHeading()).toBeNull();
});
it('should set heading on recording with camera metadata', () => {
const cameraManager = mock<CameraManager>();
cameraManager.getCameraMetadata.mockReturnValue({
title: 'Camera Title',
icon: { icon: 'mdi:cow' },
});
const item = new TestViewMedia({
mediaType: ViewMediaType.Recording,
});
const controller = new MediaDetailsController();
controller.calculate(cameraManager, item);
expect(controller.getHeading()?.title).toBe('Camera Title');
});
it('should set heading on recording without camera metadata', () => {
const item = new TestViewMedia({
mediaType: ViewMediaType.Recording,
});
const controller = new MediaDetailsController();
controller.calculate(null, item);
expect(controller.getHeading()).toBeNull();
});
it('should set no heading on folder', () => {
const item = new ViewFolder(createFolder(), []);
const controller = new MediaDetailsController();
controller.calculate(null, item);
expect(controller.getHeading()).toBeNull();
});
});
describe('should set details', () => {
describe('should have title in details', () => {
it('should have icon with title when there are other details', () => {
const item = new TestViewMedia({
title: 'Test Event',
where: ['where1', 'where2'],
});
const controller = new MediaDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).toContainEqual({
title: 'Test Event',
icon: { icon: 'mdi:rename' },
hint: 'Title',
});
});
it('should not have icon with title when there are no other details', () => {
const item = new TestViewMedia({
title: 'Test Event',
});
const controller = new MediaDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).toEqual([
{
title: 'Test Event',
},
]);
});
it('should not have title with a start time', () => {
const item = new TestViewMedia({
title: 'Test Event',
startTime: new Date('2025-05-22T21:12:00Z'),
});
const controller = new MediaDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).not.toContainEqual(
expect.objectContaining({
title: 'Test Event',
}),
);
});
});
it('should have start time in details', () => {
const startTime = new Date('2025-05-18T17:03:00Z');
const item = new TestViewMedia({
startTime,
});
const controller = new MediaDetailsController();
controller.calculate(null, item);
// Use formatDateAndTime to generate expected value (formats in local time with seconds)
expect(controller.getDetails()).toContainEqual({
title: formatDateAndTime(startTime, true),
hint: 'Start',
icon: { icon: 'mdi:calendar-clock-outline' },
});
});
describe('should have duration in details', () => {
it('should have duration in details', () => {
const item = new TestViewMedia({
startTime: new Date('2025-05-18T17:03:00Z'),
endTime: new Date('2025-05-18T17:04:00Z'),
});
const controller = new MediaDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).toContainEqual({
title: '1m 0s',
hint: 'Duration',
icon: { icon: 'mdi:clock-outline' },
});
});
it('should have in-progress in details', () => {
const item = new TestViewMedia({
startTime: new Date('2025-05-18T17:03:00Z'),
endTime: null,
inProgress: true,
});
const controller = new MediaDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).toContainEqual({
title: 'In Progress',
hint: 'Duration',
icon: { icon: 'mdi:clock-outline' },
});
});
it('should have duration and in-progress in details', () => {
const item = new TestViewMedia({
startTime: new Date('2025-05-18T17:03:00Z'),
endTime: new Date('2025-05-18T17:04:00Z'),
inProgress: true,
});
const controller = new MediaDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).toContainEqual({
title: '1m 0s In Progress',
hint: 'Duration',
icon: { icon: 'mdi:clock-outline' },
});
});
});
it('should have camera title in details', () => {
const cameraManager = mock<CameraManager>();
cameraManager.getCameraMetadata.mockReturnValue({
title: 'Camera Title',
icon: { icon: 'mdi:cow' },
});
const item = new TestViewMedia({
cameraID: 'camera_1',
});
const controller = new MediaDetailsController();
controller.calculate(cameraManager, item);
expect(controller.getDetails()).toContainEqual({
title: 'Camera Title',
hint: 'Camera',
icon: { icon: 'mdi:cctv' },
});
});
it('should have where in details', () => {
const item = new TestViewMedia({
cameraID: 'camera_1',
where: ['where1', 'where2'],
});
const controller = new MediaDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).toContainEqual({
title: 'Where1, Where2',
hint: 'Where',
icon: { icon: 'mdi:map-marker-outline' },
});
});
it('should have tags in details', () => {
const item = new TestViewMedia({
cameraID: 'camera_1',
tags: ['tag1', 'tag2'],
});
const controller = new MediaDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).toContainEqual({
title: 'Tag1, Tag2',
hint: 'Tag',
icon: { icon: 'mdi:tag' },
});
});
it('should have seek in details', () => {
const item = new TestViewMedia();
const seekTime = new Date('2025-05-20T07:14:57Z');
const controller = new MediaDetailsController();
controller.calculate(null, item, seekTime);
// Use format() to generate expected value (formats in local time)
expect(controller.getDetails()).toContainEqual({
title: format(seekTime, 'HH:mm:ss'),
hint: 'Seek',
icon: { icon: 'mdi:clock-fast' },
});
});
it('should set heading on review', () => {
const item = new TestViewMedia({
mediaType: ViewMediaType.Review,
title: 'Review Title',
severity: 'high',
});
const controller = new MediaDetailsController();
controller.calculate(null, item);
const heading = controller.getHeading();
expect(heading?.title).toBe('Review Title');
expect(heading?.emphasis).toBe('high');
expect(heading?.icon).toEqual({ icon: 'mdi:circle-medium' });
expect(heading?.hint).toBe('Severity: High');
});
it('should set heading on review without severity', () => {
const item = new TestViewMedia({
mediaType: ViewMediaType.Review,
title: 'Review Title',
severity: null,
});
const controller = new MediaDetailsController();
controller.calculate(null, item);
const heading = controller.getHeading();
expect(heading?.title).toBe('Review Title');
expect(heading?.emphasis).toBeUndefined();
});
it('should set null heading on review with no title', () => {
const item = new TestViewMedia({
mediaType: ViewMediaType.Review,
title: null,
});
const controller = new MediaDetailsController();
controller.calculate(null, item);
expect(controller.getHeading()).toBeNull();
});
it('should calculate with null item', () => {
const controller = new MediaDetailsController();
controller.calculate(null, undefined);
expect(controller.getHeading()).toBeNull();
expect(controller.getDetails()).toEqual([]);
});
});
describe('should get message', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('should get message', () => {
const item = new TestViewMedia({
title: 'Test Title',
what: ['person'],
description: 'Test Description',
});
const controller = new MediaDetailsController();
controller.calculate(null, item);
const message = controller.getMessage();
expect(message.heading?.title).toBe('Person');
expect(message.details).toContainEqual({
title: 'Test Title',
});
expect(message.text).toBe('Test Description');
});
it('should get message without media', () => {
const item = new ViewFolder(createFolder(), []);
const controller = new MediaDetailsController();
controller.calculate(null, item);
const message = controller.getMessage();
expect(message.text).toBeUndefined();
});
it('should get message with null description', () => {
const item = new TestViewMedia({
description: null,
});
const controller = new MediaDetailsController();
controller.calculate(null, item);
const message = controller.getMessage();
expect(message.text).toBeUndefined();
});
it('should get message with controls', async () => {
const item = new TestViewMedia({
title: 'Test Title',
mediaType: ViewMediaType.Review,
id: 'review_id',
startTime: new Date(),
});
const viewManagerEpoch = mock<ViewManagerEpoch>();
const cardAPI = createCardAPI();
viewManagerEpoch.manager = cardAPI.getViewManager();
const viewItemManager = mock<ViewItemManager>();
const context = {
capabilities: {
canFavorite: true,
canDownload: true,
},
viewItemManager: viewItemManager,
viewManagerEpoch: viewManagerEpoch,
};
const controller = new MediaDetailsController();
controller.calculate(null, item);
const message = controller.getMessage(context);
const controls = message.controls;
expect(controls).toHaveLength(4);
vi.spyOn(console, 'warn').mockImplementation(() => {});
// 1. Review control
expect(controls?.[0].title).toBe('Mark as reviewed');
const reviewResult = await controls?.[0].callback?.();
expect(reviewResult).not.toBeNull();
// 1b. Review control (failure)
viewItemManager.reviewMedia.mockRejectedValue(new Error('fail'));
const reviewFailureResult = await controls?.[0].callback?.();
expect(reviewFailureResult).toBeNull();
// 2. Favorite control
expect(controls?.[1].title).toBe('Media will be indefinitely retained');
const favoriteResult = await controls?.[1].callback?.();
expect(favoriteResult).not.toBeNull();
// 2b. Favorite control (failure)
viewItemManager.favorite.mockRejectedValue(new Error('fail'));
const favoriteFailureResult = await controls?.[1].callback?.();
expect(favoriteFailureResult).toBeNull();
// 3. Download control
expect(controls?.[2].title).toBe('Download media');
const downloadResult = await controls?.[2].callback?.();
expect(downloadResult).toBeNull();
// 4. Timeline control
expect(controls?.[3].title).toBe('See media in timeline');
const timelineResult = await controls?.[3].callback?.();
expect(timelineResult).toBeNull();
});
it('should get message with controls for already reviewed/favorited items', () => {
const item = new TestViewMedia({
mediaType: ViewMediaType.Review,
reviewed: true,
favorite: true,
});
const context = {
capabilities: {
canFavorite: true,
canDownload: false,
},
};
const controller = new MediaDetailsController();
controller.calculate(null, item);
const message = controller.getMessage(context);
const controls = message.controls;
expect(controls).toHaveLength(2);
expect(controls?.[0].title).toBe('Mark as unreviewed');
expect(controls?.[0].icon).toEqual({ icon: 'mdi:check-circle' });
expect(controls?.[1].emphasis).toBe('medium');
expect(controls?.[1].icon).toEqual({ icon: 'mdi:star' });
});
it('should get message with controls when item has no ID', () => {
const item = new TestViewMedia({
id: null,
});
const context = {
capabilities: {
canFavorite: false,
canDownload: true,
},
};
const controller = new MediaDetailsController();
controller.calculate(null, item);
const message = controller.getMessage(context);
expect(message.controls).toHaveLength(0);
});
it('should get message with controls when context has no capabilities', () => {
const item = new TestViewMedia({
id: 'id',
});
const context = {};
const controller = new MediaDetailsController();
controller.calculate(null, item);
const message = controller.getMessage(context);
expect(message.controls).toHaveLength(0);
});
it('should get empty controls when item is null', () => {
const controller = new MediaDetailsController();
// Directly call protected method via casting to test the null item branch.
// Use cast to unknown first to avoid any-related lint errors.
const controls = (
controller as unknown as {
_getControls: (context: OverlayControlsContext) => OverlayMessageControl[];
}
)._getControls({});
expect(controls).toEqual([]);
});
});
});
@@ -5,6 +5,7 @@ import { Capabilities } from '../../src/camera-manager/capabilities.js';
import { CameraManager } from '../../src/camera-manager/manager.js';
import { CameraManagerCameraMetadata } from '../../src/camera-manager/types.js';
import { FoldersManager } from '../../src/card-controller/folders/manager.js';
import { FolderQuery } from '../../src/card-controller/folders/types';
import { FullscreenManager } from '../../src/card-controller/fullscreen/fullscreen-manager.js';
import { MediaPlayerManager } from '../../src/card-controller/media-player-manager.js';
import { MicrophoneManager } from '../../src/card-controller/microphone-manager.js';
@@ -18,11 +19,12 @@ import { ViewDisplayMode } from '../../src/config/schema/common/display.js';
import { MenuItem } from '../../src/config/schema/elements/custom/menu/types.js';
import { AdvancedCameraCardConfig } from '../../src/config/schema/types.js';
import { HomeAssistant } from '../../src/ha/types.js';
import { QuerySource } from '../../src/query-source';
import { MediaPlayerController, PTZMovementType } from '../../src/types.js';
import { createGeneralAction, createViewAction } from '../../src/utils/action.js';
import { ViewMedia, ViewMediaType } from '../../src/view/item.js';
import { QueryResults } from '../../src/view/query-results.js';
import { FolderViewQuery } from '../../src/view/query.js';
import { UnifiedQuery } from '../../src/view/unified-query.js';
import {
getCameraIDsForViewName,
isViewSupportedByCamera,
@@ -559,7 +561,9 @@ describe('MenuButtonController', () => {
describe('should have clips menu button', () => {
it('when in clips view', () => {
const viewManager = mock<ViewManager>();
vi.mocked(isViewSupportedByCamera).mockReturnValue(true);
vi.mocked(isViewSupportedByCamera).mockImplementation(
(view) => view !== 'reviews',
);
const buttons = calculateButtons(controller, {
view: createView({ view: 'clips' }),
viewManager: viewManager,
@@ -579,7 +583,9 @@ describe('MenuButtonController', () => {
it('when not in clips view', () => {
const viewManager = mock<ViewManager>();
vi.mocked(isViewSupportedByCamera).mockReturnValue(true);
vi.mocked(isViewSupportedByCamera).mockImplementation(
(view) => view !== 'reviews',
);
const buttons = calculateButtons(controller, {
viewManager: viewManager,
});
@@ -607,12 +613,38 @@ describe('MenuButtonController', () => {
expect.arrayContaining([expect.objectContaining({ title: 'Clips gallery' })]),
);
});
it('should be hidden when reviews are supported', () => {
vi.mocked(isViewSupportedByCamera).mockImplementation(
(view) => view === 'clips' || view === 'reviews',
);
const buttons = calculateButtons(controller);
expect(buttons).not.toEqual(
expect.arrayContaining([expect.objectContaining({ title: 'Clips gallery' })]),
);
});
it('should be shown when reviews are supported but button is explicitly enabled', () => {
vi.mocked(isViewSupportedByCamera).mockImplementation(
(view) => view === 'clips' || view === 'reviews',
);
const buttons = calculateButtons(controller, {
config: createConfig({ menu: { buttons: { clips: { enabled: true } } } }),
});
expect(buttons).toContainEqual(
expect.objectContaining({ title: 'Clips gallery', enabled: true }),
);
});
});
describe('should have snapshots menu button', () => {
it('when in snapshots view', () => {
const viewManager = mock<ViewManager>();
vi.mocked(isViewSupportedByCamera).mockReturnValue(true);
vi.mocked(isViewSupportedByCamera).mockImplementation(
(view) => view !== 'reviews',
);
const buttons = calculateButtons(controller, {
view: createView({ view: 'snapshots' }),
viewManager: viewManager,
@@ -638,7 +670,9 @@ describe('MenuButtonController', () => {
it('when not in snapshots view', () => {
const viewManager = mock<ViewManager>();
vi.mocked(isViewSupportedByCamera).mockReturnValue(true);
vi.mocked(isViewSupportedByCamera).mockImplementation(
(view) => view !== 'reviews',
);
const buttons = calculateButtons(controller, {
viewManager: viewManager,
});
@@ -674,6 +708,97 @@ describe('MenuButtonController', () => {
]),
);
});
it('should be hidden when reviews are supported', () => {
vi.mocked(isViewSupportedByCamera).mockImplementation(
(view) => view === 'snapshots' || view === 'reviews',
);
const buttons = calculateButtons(controller);
expect(buttons).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ title: 'Snapshots gallery' }),
]),
);
});
it('should be shown when reviews are supported but button is explicitly enabled', () => {
vi.mocked(isViewSupportedByCamera).mockImplementation(
(view) => view === 'snapshots' || view === 'reviews',
);
const buttons = calculateButtons(controller, {
config: createConfig({ menu: { buttons: { snapshots: { enabled: true } } } }),
});
expect(buttons).toContainEqual(
expect.objectContaining({ title: 'Snapshots gallery', enabled: true }),
);
});
});
describe('should have reviews menu button', () => {
it('when in reviews view', () => {
const viewManager = mock<ViewManager>();
vi.mocked(isViewSupportedByCamera).mockReturnValue(true);
const buttons = calculateButtons(controller, {
view: createView({ view: 'reviews' }),
viewManager: viewManager,
});
expect(buttons).toContainEqual({
icon: 'mdi:play-box-multiple',
enabled: true,
priority: 50,
type: 'custom:advanced-camera-card-menu-icon',
title: 'Reviews gallery',
style: { color: 'var(--advanced-camera-card-menu-button-active-color)' },
tap_action: {
action: 'fire-dom-event',
advanced_camera_card_action: 'reviews',
},
hold_action: {
action: 'fire-dom-event',
advanced_camera_card_action: 'review',
},
});
});
it('when not in reviews view', () => {
const viewManager = mock<ViewManager>();
vi.mocked(isViewSupportedByCamera).mockReturnValue(true);
const buttons = calculateButtons(controller, {
viewManager: viewManager,
});
expect(buttons).toContainEqual({
icon: 'mdi:play-box-multiple',
enabled: true,
priority: 50,
type: 'custom:advanced-camera-card-menu-icon',
title: 'Reviews gallery',
style: {},
tap_action: {
action: 'fire-dom-event',
advanced_camera_card_action: 'reviews',
},
hold_action: {
action: 'fire-dom-event',
advanced_camera_card_action: 'review',
},
});
});
it('when not supported', () => {
const viewManager = mock<ViewManager>();
vi.mocked(isViewSupportedByCamera).mockReturnValue(false);
const buttons = calculateButtons(controller, {
viewManager: viewManager,
});
expect(buttons).not.toEqual(
expect.arrayContaining([expect.objectContaining({ title: 'Reviews gallery' })]),
);
});
});
describe('should have recordings menu button', () => {
@@ -1838,11 +1963,16 @@ describe('MenuButtonController', () => {
new Map([['folder-0', folder]]).entries(),
);
const folderNode: FolderQuery = {
source: QuerySource.Folder,
folder: folder,
path: [{ ha: { id: 'one' } }],
};
const buttons = calculateButtons(controller, {
foldersManager,
view: createView({
view: 'folder',
query: new FolderViewQuery({ folder, path: [{ ha: { id: 'one' } }] }),
query: new UnifiedQuery().addNode(folderNode),
}),
});
@@ -1869,12 +1999,14 @@ describe('MenuButtonController', () => {
]);
foldersManager.getFolders.mockReturnValue(folders.entries());
const selectedFolderNode: FolderQuery = {
source: QuerySource.Folder,
folder: selectedFolder,
path: [{ ha: { id: 'id' } }],
};
const view = createView({
view: 'folder',
query: new FolderViewQuery({
folder: selectedFolder,
path: [{ ha: { id: 'id' } }],
}),
query: new UnifiedQuery().addNode(selectedFolderNode),
});
const buttons = calculateButtons(controller, {
@@ -2123,4 +2255,86 @@ describe('MenuButtonController', () => {
});
});
});
describe('should have set review button', () => {
it('when unreviewed', () => {
const selectedItem = new TestViewMedia({
mediaType: ViewMediaType.Review,
reviewed: false,
});
const queryResults = mock<QueryResults>();
queryResults.getSelectedResult.mockReturnValue(selectedItem);
const view = createView({
view: 'media',
queryResults: queryResults,
});
const buttons = calculateButtons(controller, { view: view });
expect(buttons).toContainEqual(
expect.objectContaining({
icon: 'mdi:check-circle-outline',
title: 'Mark as reviewed',
}),
);
});
it('when already reviewed', () => {
const selectedItem = new TestViewMedia({
mediaType: ViewMediaType.Review,
reviewed: true,
});
const queryResults = mock<QueryResults>();
queryResults.getSelectedResult.mockReturnValue(selectedItem);
const view = createView({
view: 'media',
queryResults: queryResults,
});
const buttons = calculateButtons(controller, { view: view });
expect(buttons).toContainEqual(
expect.objectContaining({
icon: 'mdi:check-circle',
title: 'Mark as unreviewed',
}),
);
});
it('when isReviewed returns null', () => {
const selectedItem = new TestViewMedia({
mediaType: ViewMediaType.Review,
reviewed: null,
});
const queryResults = mock<QueryResults>();
queryResults.getSelectedResult.mockReturnValue(selectedItem);
const view = createView({
view: 'media',
queryResults: queryResults,
});
const buttons = calculateButtons(controller, { view: view });
expect(buttons).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
icon: 'mdi:check-circle',
}),
]),
);
expect(buttons).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
icon: 'mdi:check-circle-outline',
}),
]),
);
});
});
});
+429
View File
@@ -0,0 +1,429 @@
import { NonEmptyTuple } from 'type-fest';
import { assert, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { CameraManager } from '../../src/camera-manager/manager';
import { EventQuery, QueryType } from '../../src/camera-manager/types';
import { FoldersManager } from '../../src/card-controller/folders/manager';
import { FolderPathComponent } from '../../src/card-controller/folders/types';
import { ViewManagerEpoch, ViewModifier } from '../../src/card-controller/view/types';
import {
FolderNavigationParamaters,
MediaNavigationParamaters,
getUpFolderItem,
navigateToFolder,
navigateToMedia,
navigateUp,
} from '../../src/components-lib/navigation';
import { QuerySource } from '../../src/query-source';
import { ViewFolder, ViewMedia } from '../../src/view/item';
import { UnifiedQuery } from '../../src/view/unified-query';
import { UnifiedQueryBuilder } from '../../src/view/unified-query-builder';
import {
createCardAPI,
createFolder,
createView,
createViewWithMedia,
} from '../test-utils';
const createFolderQuery = (
folder: ReturnType<typeof createFolder>,
path: NonEmptyTuple<FolderPathComponent> = [{}],
): UnifiedQuery => {
const query = new UnifiedQuery();
query.addNode({
source: QuerySource.Folder,
folder,
path,
});
return query;
};
const createCameraQuery = (): UnifiedQuery => {
const query = new UnifiedQuery();
const eventNode: EventQuery = {
source: QuerySource.Camera,
type: QueryType.Event,
cameraIDs: new Set(['camera1']),
hasClip: true,
};
query.addNode(eventNode);
return query;
};
describe('navigateUp', () => {
it('should do nothing with null options', () => {
navigateUp(null);
// No error thrown
});
it('should ignore non-folder query', () => {
const api = createCardAPI();
const view = createView({
query: createCameraQuery(),
});
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const epoch: ViewManagerEpoch = {
manager: api.getViewManager(),
};
const builder = new UnifiedQueryBuilder(
mock<CameraManager>(),
mock<FoldersManager>(),
);
const options: FolderNavigationParamaters = {
builder,
viewManagerEpoch: epoch,
};
navigateUp(options);
expect(api.getViewManager().setViewByParametersWithExistingQuery).not.toBeCalled();
});
it('should ignore folder query without parent to go up to', () => {
const api = createCardAPI();
const folder = createFolder();
const view = createView({
query: createFolderQuery(folder, [{ ha: { id: 'root' } }]),
});
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const epoch: ViewManagerEpoch = {
manager: api.getViewManager(),
};
const builder = new UnifiedQueryBuilder(
mock<CameraManager>(),
mock<FoldersManager>(),
);
const options: FolderNavigationParamaters = {
builder,
viewManagerEpoch: epoch,
};
navigateUp(options);
expect(api.getViewManager().setViewByParametersWithExistingQuery).not.toBeCalled();
});
it('should go up in the folder hierarchy', () => {
const api = createCardAPI();
const folder = createFolder();
const view = createView({
query: createFolderQuery(folder, [
{ ha: { id: 'one' } },
{ ha: { id: 'two' } },
{ ha: { id: 'three' } },
]),
});
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const epoch: ViewManagerEpoch = {
manager: api.getViewManager(),
};
const builder = new UnifiedQueryBuilder(
mock<CameraManager>(),
mock<FoldersManager>(),
);
const options: FolderNavigationParamaters = {
builder,
viewManagerEpoch: epoch,
};
navigateUp(options);
expect(api.getViewManager().setViewByParametersWithExistingQuery).toBeCalledWith({
params: {
query: expect.any(UnifiedQuery),
},
});
const query = vi.mocked(api.getViewManager().setViewByParametersWithExistingQuery)
.mock.calls[0][0]?.params?.query as UnifiedQuery;
const nodes = query.getNodes();
expect(nodes).toHaveLength(1);
expect(nodes[0]).toMatchObject({
source: QuerySource.Folder,
folder,
path: [{ ha: { id: 'one' } }, { ha: { id: 'two' } }],
});
});
it('should go up in the folder hierarchy with limit', () => {
const api = createCardAPI();
const folder = createFolder();
const view = createView({
query: createFolderQuery(folder, [{ ha: { id: 'one' } }, { ha: { id: 'two' } }]),
});
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const epoch: ViewManagerEpoch = {
manager: api.getViewManager(),
};
const builder = new UnifiedQueryBuilder(
mock<CameraManager>(),
mock<FoldersManager>(),
);
const options: FolderNavigationParamaters = {
builder,
viewManagerEpoch: epoch,
limit: 50,
};
navigateUp(options);
const query = vi.mocked(api.getViewManager().setViewByParametersWithExistingQuery)
.mock.calls[0][0]?.params?.query as UnifiedQuery;
expect(query.getNodes()[0].limit).toBe(50);
});
});
describe('navigateToFolder', () => {
it('should do nothing with null options', () => {
const folder = createFolder();
const item = new ViewFolder(folder, [{ ha: { id: 'root' } }]);
navigateToFolder(item, null);
// No error thrown
});
it('should navigate into folder', () => {
const api = createCardAPI();
const folder = createFolder();
const view = createView({
query: createFolderQuery(folder, [{ ha: { id: 'root' } }]),
});
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const epoch: ViewManagerEpoch = {
manager: api.getViewManager(),
};
const builder = new UnifiedQueryBuilder(
mock<CameraManager>(),
mock<FoldersManager>(),
);
const options: FolderNavigationParamaters = {
builder,
viewManagerEpoch: epoch,
};
const item = new ViewFolder(folder, [{ ha: { id: 'root' } }]);
navigateToFolder(item, options);
expect(api.getViewManager().setViewByParametersWithExistingQuery).toBeCalledWith({
params: {
query: expect.any(UnifiedQuery),
},
});
const query = vi.mocked(api.getViewManager().setViewByParametersWithExistingQuery)
.mock.calls[0][0]?.params?.query;
const nodes = query?.getNodes();
expect(nodes).toHaveLength(1);
expect(nodes?.[0]).toMatchObject({
source: QuerySource.Folder,
folder,
});
expect(nodes?.[0]).toHaveProperty('path');
expect((nodes?.[0] as { path: readonly unknown[] }).path).toHaveLength(2);
});
it('should navigate into folder with limit', () => {
const api = createCardAPI();
const folder = createFolder();
const epoch: ViewManagerEpoch = {
manager: api.getViewManager(),
};
const builder = new UnifiedQueryBuilder(
mock<CameraManager>(),
mock<FoldersManager>(),
);
const options: FolderNavigationParamaters = {
builder,
viewManagerEpoch: epoch,
limit: 100,
};
const item = new ViewFolder(folder, [{ ha: { id: 'root' } }]);
navigateToFolder(item, options);
const query = vi.mocked(api.getViewManager().setViewByParametersWithExistingQuery)
.mock.calls[0][0]?.params?.query;
expect(query?.getNodes()[0].limit).toBe(100);
});
});
describe('getUpFolderItem', () => {
it('should return null for null query', () => {
expect(getUpFolderItem(null)).toBeNull();
});
it('should return null for non-folder query', () => {
expect(getUpFolderItem(createCameraQuery())).toBeNull();
});
it('should return null for folder query with single path element', () => {
const folder = createFolder();
expect(
getUpFolderItem(createFolderQuery(folder, [{ ha: { id: 'root' } }])),
).toBeNull();
});
it('should return ViewFolder for navigable folder query', () => {
const folder = createFolder();
const query = createFolderQuery(folder, [
{ ha: { id: 'one' } },
{ ha: { id: 'two' } },
{ ha: { id: 'three' } },
]);
const folderItem = getUpFolderItem(query);
expect(folderItem).toBeInstanceOf(ViewFolder);
expect(folderItem?.getIcon()).toBe('mdi:arrow-up-left');
});
});
describe('navigateToMedia', () => {
it('should do nothing with null options', () => {
navigateToMedia(mock<ViewMedia>(), null);
// No error thrown
});
it('should navigate with viewManagerEpoch', () => {
const api = createCardAPI();
const view = createViewWithMedia();
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const epoch: ViewManagerEpoch = {
manager: api.getViewManager(),
};
const media = mock<ViewMedia>();
const options: MediaNavigationParamaters = {
viewManagerEpoch: epoch,
};
navigateToMedia(media, options);
expect(api.getViewManager().setViewByParameters).toBeCalledWith(
expect.objectContaining({
params: expect.objectContaining({
view: 'media',
queryResults: expect.anything(),
}),
}),
);
});
it('should select the correct media', () => {
const api = createCardAPI();
const view = createViewWithMedia();
const media = view.queryResults?.getResult(2);
assert(media instanceof ViewMedia);
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const options: MediaNavigationParamaters = {
viewManagerEpoch: {
manager: api.getViewManager(),
},
};
navigateToMedia(media, options);
const call = vi.mocked(api.getViewManager().setViewByParameters).mock.calls[0]?.[0];
expect(call?.params?.queryResults?.getSelectedIndex()).toBe(2);
});
it('should set camera', () => {
const api = createCardAPI();
const view = createViewWithMedia();
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const media = mock<ViewMedia>();
vi.mocked(media.getCameraID).mockReturnValue('camera1');
const options: MediaNavigationParamaters = {
viewManagerEpoch: {
manager: api.getViewManager(),
},
};
navigateToMedia(media, options);
expect(api.getViewManager().setViewByParameters).toBeCalledWith(
expect.objectContaining({
params: expect.objectContaining({
view: 'media',
queryResults: expect.anything(),
camera: 'camera1',
}),
}),
);
});
it('should navigate with modifiers', () => {
const api = createCardAPI();
const view = createViewWithMedia();
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const media = mock<ViewMedia>();
const modifier = mock<ViewModifier>();
const options: MediaNavigationParamaters = {
viewManagerEpoch: {
manager: api.getViewManager(),
},
modifiers: [modifier],
};
navigateToMedia(media, options);
expect(api.getViewManager().setViewByParameters).toBeCalledWith(
expect.objectContaining({
modifiers: [modifier],
}),
);
});
it('should do nothing if queryResults are missing', () => {
const api = createCardAPI();
const view = createView();
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const media = mock<ViewMedia>();
const options: MediaNavigationParamaters = {
viewManagerEpoch: {
manager: api.getViewManager(),
},
};
navigateToMedia(media, options);
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
});
it('should do nothing if view is missing', () => {
const api = createCardAPI();
vi.mocked(api.getViewManager().getView).mockReturnValue(null);
const media = mock<ViewMedia>();
const options: MediaNavigationParamaters = {
viewManagerEpoch: {
manager: api.getViewManager(),
},
};
navigateToMedia(media, options);
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
});
});
@@ -1,270 +0,0 @@
import { format } from 'date-fns';
import { describe, expect, it } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { CameraManager } from '../../../src/camera-manager/manager';
import { ThumbnailDetailsController } from '../../../src/components-lib/thumbnail/details-controller';
import { formatDateAndTime } from '../../../src/utils/basic';
import { ViewFolder, ViewMediaType } from '../../../src/view/item';
import { createFolder, TestViewMedia } from '../../test-utils';
describe('ThumbnailDetailsController', () => {
describe('should set heading', () => {
it('should set heading on event with what, tags and score', () => {
const item = new TestViewMedia({
what: ['person', 'car'],
tags: ['tag1', 'tag2'],
score: 0.5,
});
const controller = new ThumbnailDetailsController();
controller.calculate(null, item);
expect(controller.getHeading()).toBe('Person, Car: Tag1, Tag2 50.00%');
});
it('should set heading on event with tags', () => {
const item = new TestViewMedia({
tags: ['tag1', 'tag2'],
});
const controller = new ThumbnailDetailsController();
controller.calculate(null, item);
expect(controller.getHeading()).toBe('Tag1, Tag2');
});
it('should set heading on event with what', () => {
const item = new TestViewMedia({
what: ['person', 'car'],
});
const controller = new ThumbnailDetailsController();
controller.calculate(null, item);
expect(controller.getHeading()).toBe('Person, Car');
});
it('should set null heading on event with no other information', () => {
const item = new TestViewMedia({
mediaType: ViewMediaType.Snapshot,
what: null,
tags: null,
score: null,
});
const controller = new ThumbnailDetailsController();
controller.calculate(null, item);
expect(controller.getHeading()).toBeNull();
});
it('should set heading on recording with camera metadata', () => {
const cameraManager = mock<CameraManager>();
cameraManager.getCameraMetadata.mockReturnValue({
title: 'Camera Title',
icon: { icon: 'mdi:cow' },
});
const item = new TestViewMedia({
mediaType: ViewMediaType.Recording,
});
const controller = new ThumbnailDetailsController();
controller.calculate(cameraManager, item);
expect(controller.getHeading()).toBe('Camera Title');
});
it('should set heading on recording without camera metadata', () => {
const item = new TestViewMedia({
mediaType: ViewMediaType.Recording,
});
const controller = new ThumbnailDetailsController();
controller.calculate(null, item);
expect(controller.getHeading()).toBeNull();
});
it('should set no heading on folder', () => {
const item = new ViewFolder(createFolder());
const controller = new ThumbnailDetailsController();
controller.calculate(null, item);
expect(controller.getHeading()).toBeNull();
});
});
describe('should set details', () => {
describe('should have title in details', () => {
it('should have icon with title when there are other details', () => {
const item = new TestViewMedia({
title: 'Test Event',
where: ['where1', 'where2'],
});
const controller = new ThumbnailDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).toContainEqual({
title: 'Test Event',
icon: { icon: 'mdi:rename' },
hint: 'Title',
});
});
it('should not have icon with title when there are no other details', () => {
const item = new TestViewMedia({
title: 'Test Event',
});
const controller = new ThumbnailDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).toEqual([
{
title: 'Test Event',
},
]);
});
it('should not have title with a start time', () => {
const item = new TestViewMedia({
title: 'Test Event',
startTime: new Date('2025-05-22T21:12:00Z'),
});
const controller = new ThumbnailDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).not.toContainEqual(
expect.objectContaining({
title: 'Test Event',
}),
);
});
});
it('should have start time in details', () => {
const startTime = new Date('2025-05-18T17:03:00Z');
const item = new TestViewMedia({
startTime,
});
const controller = new ThumbnailDetailsController();
controller.calculate(null, item);
// Use formatDateAndTime to generate expected value (formats in local time with seconds)
expect(controller.getDetails()).toContainEqual({
title: formatDateAndTime(startTime, true),
hint: 'Start',
icon: { icon: 'mdi:calendar-clock-outline' },
});
});
describe('should have duration in details', () => {
it('should have duration in details', () => {
const item = new TestViewMedia({
startTime: new Date('2025-05-18T17:03:00Z'),
endTime: new Date('2025-05-18T17:04:00Z'),
});
const controller = new ThumbnailDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).toContainEqual({
title: '1m 0s',
hint: 'Duration',
icon: { icon: 'mdi:clock-outline' },
});
});
it('should have in-progress in details', () => {
const item = new TestViewMedia({
startTime: new Date('2025-05-18T17:03:00Z'),
endTime: null,
inProgress: true,
});
const controller = new ThumbnailDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).toContainEqual({
title: 'In Progress',
hint: 'Duration',
icon: { icon: 'mdi:clock-outline' },
});
});
it('should have duration and in-progress in details', () => {
const item = new TestViewMedia({
startTime: new Date('2025-05-18T17:03:00Z'),
endTime: new Date('2025-05-18T17:04:00Z'),
inProgress: true,
});
const controller = new ThumbnailDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).toContainEqual({
title: '1m 0s In Progress',
hint: 'Duration',
icon: { icon: 'mdi:clock-outline' },
});
});
});
it('should have camera title in details', () => {
const cameraManager = mock<CameraManager>();
cameraManager.getCameraMetadata.mockReturnValue({
title: 'Camera Title',
icon: { icon: 'mdi:cow' },
});
const item = new TestViewMedia({
cameraID: 'camera_1',
});
const controller = new ThumbnailDetailsController();
controller.calculate(cameraManager, item);
expect(controller.getDetails()).toContainEqual({
title: 'Camera Title',
hint: 'Camera',
icon: { icon: 'mdi:cctv' },
});
});
it('should have where in details', () => {
const item = new TestViewMedia({
cameraID: 'camera_1',
where: ['where1', 'where2'],
});
const controller = new ThumbnailDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).toContainEqual({
title: 'Where1, Where2',
hint: 'Where',
icon: { icon: 'mdi:map-marker-outline' },
});
});
it('should have tags in details', () => {
const item = new TestViewMedia({
cameraID: 'camera_1',
tags: ['tag1', 'tag2'],
});
const controller = new ThumbnailDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).toContainEqual({
title: 'Tag1, Tag2',
hint: 'Tag',
icon: { icon: 'mdi:tag' },
});
});
it('should have seek in details', () => {
const item = new TestViewMedia();
const seekTime = new Date('2025-05-20T07:14:57Z');
const controller = new ThumbnailDetailsController();
controller.calculate(null, item, seekTime);
// Use format() to generate expected value (formats in local time)
expect(controller.getDetails()).toContainEqual({
title: format(seekTime, 'HH:mm:ss'),
hint: 'Seek',
icon: { icon: 'mdi:clock-fast' },
});
});
});
});
@@ -115,7 +115,7 @@ describe('ThumbnailFeatureController', () => {
it('should not set subtitle on folder media', () => {
const controller = new ThumbnailFeatureController();
const itemWithThumbnail = new ViewFolder(createFolder(), {
const itemWithThumbnail = new ViewFolder(createFolder(), [], {
title: 'Test Folder',
});
@@ -152,7 +152,7 @@ describe('ThumbnailFeatureController', () => {
});
describe('should set thumbnail', () => {
it('should set brand thumbnail', () => {
it('should set placeholder thumbnail', () => {
const controller = new ThumbnailFeatureController();
const itemWithThumbnail = new TestViewMedia({
thumbnail: 'https://brands.home-assistant.io//amcrest/icon.png',
@@ -163,7 +163,7 @@ describe('ThumbnailFeatureController', () => {
expect(controller.getThumbnail()).toBe(
'https://brands.home-assistant.io/brands/_/amcrest/icon.png',
);
expect(controller.getThumbnailClass()).toBe('brand');
expect(controller.getThumbnailClass()).toBe('placeholder');
});
it('should set other thumbnail', () => {
File diff suppressed because it is too large Load Diff