feat: Implement basic general folder support (#2051)

- Related: #1748
This commit is contained in:
Dermot Duffy
2025-05-21 19:59:21 -07:00
committed by GitHub
parent 2eb0d9e35e
commit c6a4c8aea2
350 changed files with 12837 additions and 4509 deletions
@@ -0,0 +1,234 @@
import { describe, expect, it } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { ViewManagerInterface } from '../../../src/card-controller/view/types';
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: [{ id: 'grandparent' }],
}),
});
const viewManager = mock<ViewManagerInterface>();
viewManager.getView.mockReturnValue(view);
controller.itemClickHandler(viewManager, folderItem, event);
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: [{ id: 'grandparent' }, { id: folderItem.getID() }],
});
});
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: [{ 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();
});
});
});
});
@@ -0,0 +1,650 @@
import { LitElement } from 'lit';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import {
GalleryColumnCountRoundMethod,
GalleryCoreController,
} from '../../../src/components-lib/gallery/gallery-core-controller';
import { GalleryExtendEvent } from '../../../src/components/gallery/types';
import { scrollIntoView } from '../../../src/utils/scroll';
import { sleep } from '../../../src/utils/sleep';
import {
callIntersectionHandler,
createLitElement,
createSlot,
createSlotHost,
createTouch,
createTouchEvent,
flushPromises,
IntersectionObserverMock,
ResizeObserverMock,
} from '../../test-utils';
import { callResizeHandler } from '../../utils/embla/test-utils';
vi.mock('lodash-es', async () => {
return {
...(await vi.importActual('lodash-es')),
throttle: vi.fn((fn) => fn),
};
});
vi.mock('../../../src/utils/sleep');
vi.mock('../../../src/utils/scroll');
// @vitest-environment jsdom
describe('GalleryCoreController', () => {
const createController = (options?: {
host?: LitElement;
getSlot?: () => HTMLSlotElement | null;
getSentinelBottom?: () => HTMLElement | null;
showLoaderTopCallback?: (show: boolean) => void;
showSentinelBottomCallback?: (show: boolean) => void;
}) => {
return new GalleryCoreController(
options?.host ?? createLitElement(),
options?.getSlot ?? (() => null),
options?.getSentinelBottom ?? (() => null),
options?.showLoaderTopCallback ?? (() => null),
options?.showSentinelBottomCallback ?? (() => null),
);
};
beforeAll(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
vi.stubGlobal('IntersectionObserver', IntersectionObserverMock);
});
beforeEach(() => {
vi.clearAllMocks();
});
afterAll(() => {
vi.unstubAllGlobals();
});
it('should add controller', () => {
const host = createLitElement();
const controller = createController({ host });
expect(controller).toBeInstanceOf(GalleryCoreController);
expect(host.addController).toBeCalledWith(controller);
});
it('should remove controller', () => {
const host = createLitElement();
const controller = createController({ host });
expect(controller).toBeInstanceOf(GalleryCoreController);
controller.removeController();
expect(host.removeController).toBeCalledWith(controller);
});
it('should remove controller', () => {
const host = createLitElement();
const controller = createController({ host });
expect(controller).toBeInstanceOf(GalleryCoreController);
controller.removeController();
expect(host.removeController).toBeCalledWith(controller);
});
describe('should observe sentintel when host updated', () => {
it('should disconnect and observe sentinel when valid sentinel exists', () => {
const sentinel = document.createElement('div');
const getSentinelBottom = vi.fn(() => sentinel);
const host = createLitElement();
const controller = createController({
host,
getSentinelBottom,
});
controller.hostUpdated();
expect(
vi.mocked(IntersectionObserver).mock.results[0].value.disconnect,
).toBeCalled();
expect(
vi.mocked(IntersectionObserver).mock.results[0].value.observe,
).toBeCalledWith(sentinel);
});
it('should not observe when sentinel is null', () => {
const getSentinelBottom = vi.fn(() => null);
const host = createLitElement();
const controller = createController({
host,
getSentinelBottom,
});
controller.hostUpdated();
expect(
vi.mocked(IntersectionObserver).mock.results[0].value.disconnect,
).toBeCalled();
expect(
vi.mocked(IntersectionObserver).mock.results[0].value.observe,
).not.toBeCalled();
});
});
it('should attach listeners on connect', () => {
const host = createLitElement();
host.addEventListener = vi.fn();
const controller = createController({ host });
controller.hostConnected();
expect(vi.mocked(ResizeObserver).mock.results[0].value.observe).toBeCalledWith(host);
expect(host.addEventListener).toBeCalledWith('wheel', expect.anything(), {
passive: true,
});
expect(host.addEventListener).toBeCalledWith('touchstart', expect.anything(), {
passive: true,
});
expect(host.addEventListener).toBeCalledWith('touchend', expect.anything());
expect(host.requestUpdate).toBeCalled();
});
it('should detach listeners on disconnect', () => {
const host = createLitElement();
host.removeEventListener = vi.fn();
const controller = createController({ host });
controller.hostDisconnected();
expect(vi.mocked(ResizeObserver).mock.results[0].value.disconnect).toBeCalled();
expect(
vi.mocked(IntersectionObserver).mock.results[0].value.disconnect,
).toBeCalled();
expect(host.removeEventListener).toBeCalledWith('wheel', expect.anything());
expect(host.removeEventListener).toBeCalledWith('touchstart', expect.anything());
expect(host.removeEventListener).toBeCalledWith('touchend', expect.anything());
});
describe('should set the number of columns', () => {
it('should update columns based on columnWidth and host width', () => {
const host = createLitElement();
Object.defineProperty(host, 'clientWidth', { value: 492 });
const controller = createController({ host });
controller.setOptions({ columnWidth: 250 });
expect(host.style.getPropertyValue('--advanced-camera-card-gallery-columns')).toBe(
'1',
);
});
it('should calculate at least 1 column', () => {
const host = createLitElement();
Object.defineProperty(host, 'clientWidth', { value: 100 });
const controller = createController({ host });
controller.setOptions({ columnWidth: 200 });
expect(host.style.getPropertyValue('--advanced-camera-card-gallery-columns')).toBe(
'1',
);
});
describe('should respect column count round method', () => {
it.each([
['ceil' as const, 2],
['floor' as const, 1],
])(
'%s',
async (
columnCountRoundMethod: GalleryColumnCountRoundMethod,
expectedColumns: number,
) => {
const host = createLitElement();
Object.defineProperty(host, 'clientWidth', { value: 100 });
const controller = createController({ host });
controller.setOptions({ columnWidth: 51, columnCountRoundMethod });
expect(
host.style.getPropertyValue('--advanced-camera-card-gallery-columns'),
).toBe(String(expectedColumns));
},
);
});
it('should not update columns when columnWidth is not set', () => {
const host = createLitElement();
Object.defineProperty(host, 'clientWidth', { value: 500 });
const controller = createController({ host });
controller.setOptions({});
expect(host.style.getPropertyValue('--advanced-camera-card-gallery-columns')).toBe(
'',
);
});
it('should update columns on resize', () => {
const host = createLitElement();
const controller = createController({ host });
controller.setOptions({ columnWidth: 250 });
const width = 1000;
Object.defineProperty(host, 'clientWidth', { value: width });
callResizeHandler([{ target: host, width, height: 200 }]);
expect(host.style.getPropertyValue('--advanced-camera-card-gallery-columns')).toBe(
'4',
);
});
});
describe('should extend gallery', () => {
describe('should extend gallery up', () => {
describe('should extend gallery up with wheel events', () => {
it('should extend up with wheel event upwards', async () => {
const host = createLitElement();
host.scrollTop = 0;
const showLoaderTop = vi.fn();
const controller = createController({
host,
showLoaderTopCallback: showLoaderTop,
});
controller.setOptions({ extendUp: true });
controller.hostConnected();
const extendUpCallback = vi
.fn()
.mockImplementation((ev: CustomEvent<GalleryExtendEvent>) => {
expect(showLoaderTop).toHaveBeenLastCalledWith(true);
ev.detail.resolve();
});
host.addEventListener(
'advanced-camera-card:gallery:extend:up',
extendUpCallback,
);
host.dispatchEvent(new WheelEvent('wheel', { deltaY: -100 }));
await flushPromises();
expect(showLoaderTop).toHaveBeenLastCalledWith(false);
});
it('should not extend up with wheel event when not at top of component', async () => {
const host = createLitElement();
host.scrollTop = 100;
const showLoaderTop = vi.fn();
const controller = createController({
host,
showLoaderTopCallback: showLoaderTop,
});
controller.setOptions({ extendUp: true });
controller.hostConnected();
host.dispatchEvent(new WheelEvent('wheel', { deltaY: -100 }));
expect(showLoaderTop).not.toHaveBeenCalled();
});
it('should not extend up with wheel event downwards', async () => {
const host = createLitElement();
host.scrollTop = 0;
const showLoaderTop = vi.fn();
const controller = createController({
host,
showLoaderTopCallback: showLoaderTop,
});
controller.setOptions({ extendUp: true });
controller.hostConnected();
host.dispatchEvent(new WheelEvent('wheel', { deltaY: 100 }));
expect(showLoaderTop).not.toHaveBeenCalled();
});
});
describe('should extend gallery up with touch events', () => {
it('should extend up with touch event upwards', async () => {
const host = createLitElement();
const showLoaderTop = vi.fn();
const controller = createController({
host,
showLoaderTopCallback: showLoaderTop,
});
controller.setOptions({ extendUp: true });
controller.hostConnected();
const extendUpCallback = vi
.fn()
.mockImplementation((ev: CustomEvent<GalleryExtendEvent>) => {
expect(showLoaderTop).toHaveBeenLastCalledWith(true);
ev.detail.resolve();
});
host.addEventListener(
'advanced-camera-card:gallery:extend:up',
extendUpCallback,
);
host.dispatchEvent(
createTouchEvent('touchstart', { touches: [createTouch({ screenY: 0 })] }),
);
host.dispatchEvent(
createTouchEvent('touchend', {
changedTouches: [createTouch({ screenY: 100 })],
}),
);
await flushPromises();
expect(showLoaderTop).toHaveBeenLastCalledWith(false);
});
it('should not extend up with multiple touches', async () => {
const host = createLitElement();
const showLoaderTop = vi.fn();
const controller = createController({
host,
showLoaderTopCallback: showLoaderTop,
});
controller.setOptions({ extendUp: true });
controller.hostConnected();
host.dispatchEvent(
createTouchEvent('touchstart', {
touches: [createTouch({ screenY: 0 }), createTouch({ screenY: 0 })],
}),
);
host.dispatchEvent(
createTouchEvent('touchend', {
changedTouches: [createTouch({ screenY: 100 })],
}),
);
await flushPromises();
expect(showLoaderTop).not.toBeCalled();
});
it('should not extend up with touch when when not at top of component', async () => {
const host = createLitElement();
host.scrollTop = 100;
const showLoaderTop = vi.fn();
const controller = createController({
host,
showLoaderTopCallback: showLoaderTop,
});
controller.setOptions({ extendUp: true });
controller.hostConnected();
host.dispatchEvent(
createTouchEvent('touchstart', {
touches: [createTouch({ screenY: 0 }), createTouch({ screenY: 0 })],
}),
);
host.dispatchEvent(
createTouchEvent('touchend', {
changedTouches: [createTouch({ screenY: 100 })],
}),
);
await flushPromises();
expect(showLoaderTop).not.toBeCalled();
});
it('should not extend up with touch when touches moved downwards', async () => {
const host = createLitElement();
const showLoaderTop = vi.fn();
const controller = createController({
host,
showLoaderTopCallback: showLoaderTop,
});
controller.setOptions({ extendUp: true });
controller.hostConnected();
host.dispatchEvent(
createTouchEvent('touchstart', {
touches: [createTouch({ screenY: 100 })],
}),
);
host.dispatchEvent(
createTouchEvent('touchend', {
changedTouches: [createTouch({ screenY: 0 })],
}),
);
await flushPromises();
expect(showLoaderTop).not.toBeCalled();
});
});
it('should not extend when extending up is disabled', async () => {
const host = createLitElement();
const showLoaderTop = vi.fn();
const controller = createController({
host,
showLoaderTopCallback: showLoaderTop,
});
controller.setOptions({ extendUp: false });
controller.hostConnected();
host.dispatchEvent(new WheelEvent('wheel', { deltaY: -100 }));
expect(showLoaderTop).not.toHaveBeenCalled();
});
it('should not artifically sleep if extend up call takes too long', async () => {
vi.useFakeTimers();
const host = createLitElement();
host.scrollTop = 0;
const showLoaderTop = vi.fn();
const controller = createController({
host,
showLoaderTopCallback: showLoaderTop,
});
controller.setOptions({ extendUp: true });
controller.hostConnected();
const extendUpCallback = vi
.fn()
.mockImplementation((ev: CustomEvent<GalleryExtendEvent>) => {
vi.advanceTimersByTime(10000);
ev.detail.resolve();
});
host.addEventListener(
'advanced-camera-card:gallery:extend:up',
extendUpCallback,
);
host.dispatchEvent(new WheelEvent('wheel', { deltaY: -100 }));
await flushPromises();
expect(sleep).not.toHaveBeenCalled();
});
});
describe('should extend gallery down', () => {
it('should extend down when sentinel intersects', async () => {
const host = createLitElement();
const showSentinelBottom = vi.fn();
const controller = createController({
host,
showSentinelBottomCallback: showSentinelBottom,
});
controller.setOptions({ extendDown: true });
controller.hostConnected();
const extendDownCallback = vi
.fn()
.mockImplementation((ev: CustomEvent<GalleryExtendEvent>) => {
expect(showSentinelBottom).toHaveBeenLastCalledWith(false);
ev.detail.resolve();
});
host.addEventListener(
'advanced-camera-card:gallery:extend:down',
extendDownCallback,
);
callIntersectionHandler(true);
await flushPromises();
expect(showSentinelBottom).toHaveBeenLastCalledWith(false);
});
it('should not extend down when sentinel does not intersect', async () => {
const host = createLitElement();
const showSentinelBottom = vi.fn();
const controller = createController({
host,
showSentinelBottomCallback: showSentinelBottom,
});
controller.setOptions({ extendUp: true });
controller.hostConnected();
callIntersectionHandler(false);
expect(showSentinelBottom).not.toHaveBeenCalled();
});
it('should not extend when extending down is disabled', async () => {
const host = createLitElement();
const showSentinelBottom = vi.fn();
const controller = createController({
host,
showSentinelBottomCallback: showSentinelBottom,
});
controller.setOptions({ extendUp: false });
controller.hostConnected();
callIntersectionHandler(true);
expect(showSentinelBottom).not.toHaveBeenCalled();
});
});
});
describe('should handle content change', () => {
describe('should scroll selected into view', () => {
it('should scroll selected into view on first load', async () => {
const selectedChild = document.createElement('div');
selectedChild.setAttribute('selected', '');
const slot = createSlot();
const host = createSlotHost({
slot,
children: [
document.createElement('div'),
document.createElement('div'),
selectedChild,
],
});
const controller = createController({
host,
getSlot: () => slot,
});
controller.updateContents();
expect(scrollIntoView).toBeCalledWith(selectedChild, {
boundary: host,
block: 'center',
});
});
it('should do nothing on subsequent contents changes', async () => {
const selectedChild = document.createElement('div');
selectedChild.setAttribute('selected', '');
const slot = createSlot();
const host = createSlotHost({
slot,
children: [
document.createElement('div'),
document.createElement('div'),
selectedChild,
],
});
const controller = createController({
host,
getSlot: () => slot,
});
controller.updateContents();
controller.updateContents();
controller.updateContents();
expect(scrollIntoView).toBeCalledTimes(1);
});
it('should do nothing without a selected element', async () => {
const slot = createSlot();
const host = createSlotHost({
slot,
children: [
document.createElement('div'),
document.createElement('div'),
document.createElement('div'),
],
});
const controller = createController({
host,
getSlot: () => slot,
});
controller.updateContents();
expect(scrollIntoView).not.toBeCalled();
});
});
it('should show bottom sentintel if contents change', async () => {
const slot = createSlot();
const host = createSlotHost({
slot,
});
const showSentinelBottom = vi.fn();
const controller = createController({
host,
getSlot: () => slot,
showSentinelBottomCallback: showSentinelBottom,
});
controller.updateContents();
expect(showSentinelBottom).toBeCalledWith(true);
});
it('should do nothing without slot', async () => {
const slot = createSlot();
const host = createSlotHost({
slot,
});
const showSentinelBottom = vi.fn();
const controller = createController({
host,
showSentinelBottomCallback: showSentinelBottom,
});
controller.updateContents();
expect(showSentinelBottom).not.toBeCalled();
expect(scrollIntoView).not.toBeCalled();
});
});
});
@@ -0,0 +1,427 @@
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]);
});
});
});