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:
@@ -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',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user