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