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,134 @@
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,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]);
});
});
});
@@ -12,11 +12,7 @@ import {
MediaFilterCoreWhen,
MediaFilterMediaType,
} from '../../src/components-lib/media-filter-controller';
import {
EventMediaQueries,
MediaQueries,
RecordingMediaQueries,
} from '../../src/view/media-queries';
import { EventMediaQuery, Query, RecordingMediaQuery } from '../../src/view/query';
import {
createCameraConfig,
createCameraManager,
@@ -139,7 +135,7 @@ describe('MediaFilterController', () => {
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getCameraMetadata).mockReturnValue({
title: 'Kitchen Camera',
icon: 'mdi:camera',
icon: { icon: 'mdi:camera' },
});
vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore());
@@ -157,7 +153,7 @@ describe('MediaFilterController', () => {
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getCameraMetadata).mockReturnValue({
title: 'Kitchen Camera',
icon: 'mdi:camera',
icon: { icon: 'mdi:camera' },
});
vi.mocked(cameraManager.getStore).mockReturnValue(
createCameraStore({
@@ -295,9 +291,7 @@ describe('MediaFilterController', () => {
describe('should get correct controls to show', () => {
it('view with events', () => {
const viewManager = mock<ViewManager>();
viewManager.getView.mockReturnValue(
createView({ query: new EventMediaQueries() }),
);
viewManager.getView.mockReturnValue(createView({ query: new EventMediaQuery() }));
const cameraManager = createCameraManager();
const controller = new MediaFilterController(createLitElement());
@@ -311,7 +305,7 @@ describe('MediaFilterController', () => {
it('view with recordings', () => {
const viewManager = mock<ViewManager>();
viewManager.getView.mockReturnValue(
createView({ query: new RecordingMediaQueries() }),
createView({ query: new RecordingMediaQuery() }),
);
const cameraManager = createCameraManager();
@@ -325,9 +319,7 @@ describe('MediaFilterController', () => {
it('can favorite events', () => {
const viewManager = mock<ViewManager>();
viewManager.getView.mockReturnValue(
createView({ query: new EventMediaQueries() }),
);
viewManager.getView.mockReturnValue(createView({ query: new EventMediaQuery() }));
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue(
createCapabilities({ 'favorite-events': true }),
@@ -344,7 +336,7 @@ describe('MediaFilterController', () => {
it('can favorite recordings', () => {
const viewManager = mock<ViewManager>();
viewManager.getView.mockReturnValue(
createView({ query: new RecordingMediaQueries() }),
createView({ query: new RecordingMediaQuery() }),
);
const cameraManager = createCameraManager();
@@ -436,7 +428,7 @@ describe('MediaFilterController', () => {
}),
});
expect(
viewManager.setViewByParametersWithExistingQuery.mock.calls[0][0]?.params?.query?.getQueries(),
viewManager.setViewByParametersWithExistingQuery.mock.calls[0][0]?.params?.query?.getQuery(),
).toEqual([
{
cameraIDs: new Set(['camera.kitchen']),
@@ -501,7 +493,7 @@ describe('MediaFilterController', () => {
});
expect(
viewManager.setViewByParametersWithExistingQuery.mock.calls[0][0]?.params?.query?.getQueries(),
viewManager.setViewByParametersWithExistingQuery.mock.calls[0][0]?.params?.query?.getQuery(),
).toEqual([
{
cameraIDs: new Set(['camera.kitchen']),
@@ -543,7 +535,7 @@ describe('MediaFilterController', () => {
});
expect(
viewManager.setViewByParametersWithExistingQuery.mock.calls[0][0]?.params?.query?.getQueries(),
viewManager.setViewByParametersWithExistingQuery.mock.calls[0][0]?.params?.query?.getQuery(),
).toEqual([
{
cameraIDs: new Set(['camera.kitchen']),
@@ -607,7 +599,7 @@ describe('MediaFilterController', () => {
},
);
expect(
viewManager.setViewByParametersWithExistingQuery.mock.calls[0][0]?.params?.query?.getQueries(),
viewManager.setViewByParametersWithExistingQuery.mock.calls[0][0]?.params?.query?.getQuery(),
).toEqual([
{
cameraIDs: new Set(['camera.kitchen']),
@@ -640,7 +632,7 @@ describe('MediaFilterController', () => {
);
expect(
viewManager.setViewByParametersWithExistingQuery.mock.calls[0][0]?.params?.query?.getQueries(),
viewManager.setViewByParametersWithExistingQuery.mock.calls[0][0]?.params?.query?.getQuery(),
).toEqual([
{
cameraIDs: new Set(['camera.kitchen']),
@@ -652,14 +644,36 @@ describe('MediaFilterController', () => {
});
describe('should calculate correct defaults', () => {
it('with no queries', () => {
it('with no query', () => {
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore());
const viewManager = mock<ViewManager>();
viewManager.getView.mockReturnValue(createView());
const controller = new MediaFilterController(createLitElement());
controller.setViewManager(viewManager);
controller.computeInitialDefaultsFromView(createCameraManager());
controller.computeInitialDefaultsFromView(cameraManager);
expect(controller.getDefaults()).toBeNull();
});
it('with no raw queries', () => {
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore());
const viewManager = mock<ViewManager>();
viewManager.getView.mockReturnValue(
createView({
query: new EventMediaQuery(),
}),
);
const controller = new MediaFilterController(createLitElement());
controller.setViewManager(viewManager);
controller.computeInitialDefaultsFromView(cameraManager);
expect(controller.getDefaults()).toBeNull();
});
@@ -680,7 +694,7 @@ describe('MediaFilterController', () => {
it.each([
[
'same cameras' as const,
new EventMediaQueries([
new EventMediaQuery([
{
type: QueryType.Event,
cameraIDs: new Set(['camera.kitchen', 'camera.living_room']),
@@ -696,7 +710,7 @@ describe('MediaFilterController', () => {
],
[
'different cameras' as const,
new EventMediaQueries([
new EventMediaQuery([
{
type: QueryType.Event,
cameraIDs: new Set(['camera.kitchen']),
@@ -710,7 +724,7 @@ describe('MediaFilterController', () => {
],
[
'all cameras' as const,
new EventMediaQueries([
new EventMediaQuery([
{
type: QueryType.Event,
cameraIDs: new Set(['camera.kitchen']),
@@ -720,7 +734,7 @@ describe('MediaFilterController', () => {
],
[
'different favorites ' as const,
new EventMediaQueries([
new EventMediaQuery([
{
type: QueryType.Event,
cameraIDs: new Set(['camera.kitchen']),
@@ -741,7 +755,7 @@ describe('MediaFilterController', () => {
],
[
'all favorites' as const,
new EventMediaQueries([
new EventMediaQuery([
{
type: QueryType.Event,
cameraIDs: new Set(['camera.kitchen']),
@@ -759,7 +773,7 @@ describe('MediaFilterController', () => {
],
[
'all not favorites' as const,
new EventMediaQueries([
new EventMediaQuery([
{
type: QueryType.Event,
cameraIDs: new Set(['camera.kitchen']),
@@ -777,7 +791,7 @@ describe('MediaFilterController', () => {
],
[
'same hasClip' as const,
new EventMediaQueries([
new EventMediaQuery([
{
type: QueryType.Event,
cameraIDs: new Set(['camera.kitchen']),
@@ -795,7 +809,7 @@ describe('MediaFilterController', () => {
],
[
'different hasClip' as const,
new EventMediaQueries([
new EventMediaQuery([
{
type: QueryType.Event,
cameraIDs: new Set(['camera.kitchen']),
@@ -811,7 +825,7 @@ describe('MediaFilterController', () => {
],
[
'same hasSnapshot' as const,
new EventMediaQueries([
new EventMediaQuery([
{
type: QueryType.Event,
cameraIDs: new Set(['camera.kitchen']),
@@ -829,7 +843,7 @@ describe('MediaFilterController', () => {
],
[
'different hasSnapshot' as const,
new EventMediaQueries([
new EventMediaQuery([
{
type: QueryType.Event,
cameraIDs: new Set(['camera.kitchen']),
@@ -845,7 +859,7 @@ describe('MediaFilterController', () => {
],
[
'same what' as const,
new EventMediaQueries([
new EventMediaQuery([
{
type: QueryType.Event,
cameraIDs: new Set(['camera.kitchen']),
@@ -863,7 +877,7 @@ describe('MediaFilterController', () => {
],
[
'different what' as const,
new EventMediaQueries([
new EventMediaQuery([
{
type: QueryType.Event,
cameraIDs: new Set(['camera.kitchen']),
@@ -879,7 +893,7 @@ describe('MediaFilterController', () => {
],
[
'same where' as const,
new EventMediaQueries([
new EventMediaQuery([
{
type: QueryType.Event,
cameraIDs: new Set(['camera.kitchen']),
@@ -897,7 +911,7 @@ describe('MediaFilterController', () => {
],
[
'different where' as const,
new EventMediaQueries([
new EventMediaQuery([
{
type: QueryType.Event,
cameraIDs: new Set(['camera.kitchen']),
@@ -913,7 +927,7 @@ describe('MediaFilterController', () => {
],
[
'same tags' as const,
new EventMediaQueries([
new EventMediaQuery([
{
type: QueryType.Event,
cameraIDs: new Set(['camera.kitchen']),
@@ -931,7 +945,7 @@ describe('MediaFilterController', () => {
],
[
'different tags' as const,
new EventMediaQueries([
new EventMediaQuery([
{
type: QueryType.Event,
cameraIDs: new Set(['camera.kitchen']),
@@ -947,7 +961,7 @@ describe('MediaFilterController', () => {
],
[
'recordings' as const,
new RecordingMediaQueries([
new RecordingMediaQuery([
{
type: QueryType.Recording,
cameraIDs: new Set(['camera.kitchen']),
@@ -961,7 +975,7 @@ describe('MediaFilterController', () => {
'%s',
(
_name: string,
mediaQueries: MediaQueries,
mediaQueries: Query,
defaults: MediaFilterCoreDefaults | null,
) => {
const viewManager = mock<ViewManager>();
@@ -15,8 +15,9 @@ import {
createSlotHost,
} from '../test-utils';
vi.mock('lodash-es/throttle', () => ({
default: vi.fn((fn) => fn),
vi.mock('lodash-es', async () => ({
...(await vi.importActual('lodash-es')),
throttle: vi.fn((fn) => fn),
}));
const masonry = mock<ExtendedMasonry>();
@@ -1,9 +1,10 @@
import isEqual from 'lodash-es/isEqual';
import { isEqual } from 'lodash-es';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
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 { 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';
@@ -17,16 +18,18 @@ 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 { MediaPlayerController } from '../../src/types.js';
import { MediaPlayerController, PTZMovementType } from '../../src/types.js';
import { createGeneralAction, createViewAction } from '../../src/utils/action.js';
import { MediaQueriesResults } from '../../src/view/media-queries-results.js';
import { ViewMedia } from '../../src/view/media.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 { View } from '../../src/view/view.js';
import {
createCameraConfig,
createCameraManager,
createCapabilities,
createConfig,
createFolder,
createHASS,
createMediaCapabilities,
createMediaLoadedInfo,
@@ -858,8 +861,12 @@ describe('MenuButtonController', () => {
);
const view = createView({
view: 'media',
queryResults: new MediaQueriesResults({
results: [new ViewMedia('clip', 'camera-1')],
queryResults: new QueryResults({
results: [
new ViewMedia(ViewMediaType.Clip, {
cameraID: 'camera-1',
}),
],
selectedIndex: 0,
}),
});
@@ -893,8 +900,8 @@ describe('MenuButtonController', () => {
createMediaCapabilities({ canDownload: true }),
);
const view = createView({
queryResults: new MediaQueriesResults({
results: [new ViewMedia('clip', 'camera-1')],
queryResults: new QueryResults({
results: [new ViewMedia(ViewMediaType.Clip, { cameraID: 'camera-1' })],
selectedIndex: 0,
}),
});
@@ -915,8 +922,8 @@ describe('MenuButtonController', () => {
);
const view = createView({
view: 'live',
queryResults: new MediaQueriesResults({
results: [new ViewMedia('clip', 'camera-1')],
queryResults: new QueryResults({
results: [new ViewMedia(ViewMediaType.Clip, { cameraID: 'camera-1' })],
selectedIndex: 0,
}),
});
@@ -1499,7 +1506,7 @@ describe('MenuButtonController', () => {
const store = createStore([
{
cameraID: 'camera-1',
capabilities: new Capabilities({ ptz: { left: ['relative'] } }),
capabilities: new Capabilities({ ptz: { left: [PTZMovementType.Relative] } }),
},
]);
@@ -1519,7 +1526,7 @@ describe('MenuButtonController', () => {
const store = createStore([
{
cameraID: 'camera-1',
capabilities: new Capabilities({ ptz: { left: ['relative'] } }),
capabilities: new Capabilities({ ptz: { left: [PTZMovementType.Relative] } }),
},
]);
@@ -1539,7 +1546,7 @@ describe('MenuButtonController', () => {
const store = createStore([
{
cameraID: 'camera-1',
capabilities: new Capabilities({ ptz: { left: ['relative'] } }),
capabilities: new Capabilities({ ptz: { left: [PTZMovementType.Relative] } }),
},
]);
@@ -1573,7 +1580,7 @@ describe('MenuButtonController', () => {
const store = createStore([
{
cameraID: 'camera-1',
capabilities: new Capabilities({ ptz: { left: ['relative'] } }),
capabilities: new Capabilities({ ptz: { left: [PTZMovementType.Relative] } }),
},
]);
@@ -1605,7 +1612,7 @@ describe('MenuButtonController', () => {
const store = createStore([
{
cameraID: 'camera-1',
capabilities: new Capabilities({ ptz: { left: ['relative'] } }),
capabilities: new Capabilities({ ptz: { left: [PTZMovementType.Relative] } }),
},
]);
@@ -1643,7 +1650,7 @@ describe('MenuButtonController', () => {
},
{
cameraID: 'camera-2',
capabilities: new Capabilities({ ptz: { left: ['relative'] } }),
capabilities: new Capabilities({ ptz: { left: [PTZMovementType.Relative] } }),
},
]);
const view = createView({
@@ -1701,7 +1708,7 @@ describe('MenuButtonController', () => {
const view = createView({
view: viewName,
camera: 'camera-1',
queryResults: new MediaQueriesResults({
queryResults: new QueryResults({
results: [new TestViewMedia({ id: 'media-1' })],
selectedIndex: 0,
}),
@@ -1750,6 +1757,184 @@ describe('MenuButtonController', () => {
);
});
describe('should have folders button', () => {
it('should have no folders button without folders', () => {
const foldersManager = mock<FoldersManager>();
foldersManager.getFolders.mockReturnValue(new Map().entries());
const buttons = calculateButtons(controller, {
foldersManager,
});
expect(buttons).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
icon: 'mdi:folder',
}),
]),
);
});
it('should have a folders button for a single folder outside the folder view', () => {
const foldersManager = mock<FoldersManager>();
foldersManager.getFolders.mockReturnValue(
new Map([['folder-0', createFolder({ id: 'folder-0' })]]).entries(),
);
const buttons = calculateButtons(controller, {
foldersManager,
});
expect(buttons).toContainEqual({
icon: 'mdi:folder',
enabled: true,
priority: 50,
style: {},
type: 'custom:advanced-camera-card-menu-icon',
title: 'Folders',
tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'folder' },
});
});
it('should have a folders button for a single folder inside the folder view', () => {
const folder = createFolder({ id: 'folder-0' });
const foldersManager = mock<FoldersManager>();
foldersManager.getFolders.mockReturnValue(
new Map([['folder-0', folder]]).entries(),
);
const buttons = calculateButtons(controller, {
foldersManager,
view: createView({
view: 'folder',
query: new FolderViewQuery({ folder, path: [{ id: 'one' }] }),
}),
});
expect(buttons).toContainEqual({
icon: 'mdi:folder',
enabled: true,
priority: 50,
style: {
color: 'var(--advanced-camera-card-menu-button-active-color)',
},
type: 'custom:advanced-camera-card-menu-icon',
title: 'Folders',
tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'folder' },
});
});
it('should have a folders submenu for multiple folder with a selected folder', () => {
const foldersManager = mock<FoldersManager>();
const selectedFolder = createFolder({ id: 'folder-selected' });
const folders = new Map([
['folder-0', createFolder({ id: 'folder-0' })],
['folder-selected', selectedFolder],
]);
foldersManager.getFolders.mockReturnValue(folders.entries());
const view = createView({
view: 'folder',
query: new FolderViewQuery({
folder: selectedFolder,
path: [{ id: 'id' }],
}),
});
const buttons = calculateButtons(controller, {
foldersManager,
view,
});
expect(buttons).toContainEqual({
icon: 'mdi:folder-multiple',
enabled: true,
priority: 50,
type: 'custom:advanced-camera-card-menu-submenu',
title: 'Folders',
style: {
color: 'var(--advanced-camera-card-menu-button-active-color)',
},
items: [
{
enabled: true,
icon: 'mdi:folder',
selected: false,
style: {},
tap_action: {
action: 'fire-dom-event',
advanced_camera_card_action: 'folder',
folder: 'folder-0',
},
title: 'folder-0',
},
{
enabled: true,
icon: 'mdi:folder',
selected: true,
style: {
color: 'var(--advanced-camera-card-menu-button-active-color)',
},
tap_action: {
action: 'fire-dom-event',
advanced_camera_card_action: 'folder',
folder: 'folder-selected',
},
title: 'folder-selected',
},
],
});
});
it('should have a folders submenu for multiple folder without a selected folder', () => {
const foldersManager = mock<FoldersManager>();
const folders = new Map([
['folder-0', createFolder({ id: 'folder-0' })],
['folder-1', createFolder({ id: 'folder-1' })],
]);
foldersManager.getFolders.mockReturnValue(folders.entries());
const buttons = calculateButtons(controller, {
foldersManager,
});
expect(buttons).toContainEqual({
icon: 'mdi:folder-multiple',
enabled: true,
priority: 50,
type: 'custom:advanced-camera-card-menu-submenu',
title: 'Folders',
style: {},
items: [
{
enabled: true,
icon: 'mdi:folder',
selected: false,
style: {},
tap_action: {
action: 'fire-dom-event',
advanced_camera_card_action: 'folder',
folder: 'folder-0',
},
title: 'folder-0',
},
{
enabled: true,
icon: 'mdi:folder',
selected: false,
style: {},
tap_action: {
action: 'fire-dom-event',
advanced_camera_card_action: 'folder',
folder: 'folder-1',
},
title: 'folder-1',
},
],
});
});
});
describe('should handle dynamic buttons', () => {
it('successfully', () => {
const button: MenuItem = {
@@ -2,7 +2,8 @@ import yaml from 'js-yaml';
import { describe, expect, it } from 'vitest';
import { MessageController } from '../../../src/components-lib/message/controller';
import { TROUBLESHOOTING_URL } from '../../../src/const';
import { Message, MessageType } from '../../../src/types';
import { localize } from '../../../src/localize/localize';
import { Message, MessageType, MessageURL } from '../../../src/types';
describe('MessageController', () => {
describe('should return the correct message string', () => {
@@ -64,7 +65,10 @@ describe('MessageController', () => {
it('should show for errors', () => {
const controller = new MessageController();
const message: Message = { message: 'Error message', type: 'error' };
expect(controller.shouldShowTroubleshootingURL(message)).toBe(true);
expect(controller.getURL(message)).toEqual({
link: TROUBLESHOOTING_URL,
title: localize('error.troubleshooting'),
});
});
describe('should not show for other types', () => {
@@ -77,7 +81,7 @@ describe('MessageController', () => {
icon: 'mdi:car',
type,
};
expect(controller.shouldShowTroubleshootingURL(message)).toBe(false);
expect(controller.getURL(message)).toBeNull();
},
);
});
@@ -86,18 +90,21 @@ describe('MessageController', () => {
it('by default', () => {
const controller = new MessageController();
const message: Message = { message: 'Error message', type: 'error' };
expect(controller.getTroubleshootingURL(message)).toBe(TROUBLESHOOTING_URL);
expect(controller.getURL(message)?.link).toBe(TROUBLESHOOTING_URL);
});
it('when specified', () => {
const controller = new MessageController();
const troubleshootingURL = 'http://localhost/troubleshooting.md';
const url: MessageURL = {
link: 'link',
title: 'title',
};
const message: Message = {
message: 'Error message',
type: 'error',
troubleshootingURL: troubleshootingURL,
url,
};
expect(controller.getTroubleshootingURL(message)).toBe(troubleshootingURL);
expect(controller.getURL(message)).toBe(url);
});
});
});
@@ -0,0 +1,247 @@
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 { 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 have start time in details', () => {
const item = new TestViewMedia({
startTime: new Date('2025-05-18T17:03:00Z'),
});
const controller = new ThumbnailDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).toContainEqual({
title: '2025-05-18 17:03:00',
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 controller = new ThumbnailDetailsController();
controller.calculate(null, item, new Date('2025-05-20T07:14:57Z'));
expect(controller.getDetails()).toContainEqual({
title: '07:14:57',
hint: 'Seek',
icon: { icon: 'mdi:clock-fast' },
});
});
});
});
@@ -0,0 +1,176 @@
import { describe, expect, it } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { CameraManager } from '../../../../src/camera-manager/manager';
import { ThumbnailFeatureController } from '../../../../src/components-lib/thumbnail/feature/controller';
import { ViewFolder } from '../../../../src/view/item';
import { createFolder, TestViewMedia } from '../../../test-utils';
describe('ThumbnailFeatureController', () => {
const itemWithTime = new TestViewMedia({
startTime: new Date('2025-05-18T17:03:00Z'),
title: 'Test Event',
cameraID: 'camera_1',
});
describe('should set title', () => {
it('should set title with start time ', () => {
const controller = new ThumbnailFeatureController();
controller.calculate(null, itemWithTime, false);
expect(controller.getTitle()).toBe('17:03');
});
it('should not set title when details are shown ', () => {
const controller = new ThumbnailFeatureController();
controller.calculate(null, itemWithTime, true);
expect(controller.getTitle()).toBeNull();
});
it('should not set title on media with a thumbnail', () => {
const controller = new ThumbnailFeatureController();
const itemWithThumbnail = new TestViewMedia({
startTime: new Date('2025-05-18T17:03:00Z'),
title: 'Test Event',
cameraID: 'camera_1',
thumbnail: 'thumbnail',
});
controller.calculate(null, itemWithThumbnail, true);
expect(controller.getTitle()).toBeNull();
});
});
describe('should set subtitles', () => {
it('should set subtitle with start date ', () => {
const controller = new ThumbnailFeatureController();
controller.calculate(null, itemWithTime, false);
expect(controller.getSubtitles()).toContain('May 18th');
});
it('should set subtitle with source from item title ', () => {
const controller = new ThumbnailFeatureController();
controller.calculate(null, itemWithTime, false);
expect(controller.getSubtitles()).toContain('Test Event');
});
it('should set subtitle with source from camera title ', () => {
const controller = new ThumbnailFeatureController();
const cameraManager = mock<CameraManager>();
cameraManager.getCameraMetadata.mockReturnValue({
title: 'Camera 1',
icon: { icon: 'mdi:camera' },
});
const itemWithoutTitle = new TestViewMedia({
startTime: new Date('2025-05-18T17:03:00Z'),
title: null,
cameraID: 'camera_1',
});
controller.calculate(cameraManager, itemWithoutTitle, false);
expect(controller.getSubtitles()).toContain('Camera 1');
});
it('should set subtitle falling back to item title when cameraID not found', () => {
const controller = new ThumbnailFeatureController();
const item = new TestViewMedia({
title: 'Title',
cameraID: null,
});
controller.calculate(null, item, false);
expect(controller.getSubtitles()).toContain('Title');
});
it('should not set subtitle on media with a thumbnail', () => {
const controller = new ThumbnailFeatureController();
const itemWithThumbnail = new TestViewMedia({
startTime: new Date('2025-05-18T17:03:00Z'),
title: 'Test Event',
cameraID: 'camera_1',
thumbnail: 'thumbnail',
});
controller.calculate(null, itemWithThumbnail, false);
expect(controller.getSubtitles()).toEqual([]);
});
it('should not set subtitle on folder media', () => {
const controller = new ThumbnailFeatureController();
const itemWithThumbnail = new ViewFolder(createFolder(), {
title: 'Test Folder',
});
controller.calculate(null, itemWithThumbnail, false);
expect(controller.getSubtitles()).toContain('Test Folder');
});
});
describe('should set icon', () => {
it('should set icon without a thumbnail', () => {
const controller = new ThumbnailFeatureController();
const itemWithThumbnail = new TestViewMedia({
thumbnail: null,
icon: 'mdi:cow',
});
controller.calculate(null, itemWithThumbnail, false);
expect(controller.getIcon()).toBe('mdi:cow');
});
it('should not set icon when there is a thumbnail', () => {
const controller = new ThumbnailFeatureController();
const itemWithThumbnail = new TestViewMedia({
thumbnail: 'thumbnail',
icon: 'mdi:cow',
});
controller.calculate(null, itemWithThumbnail, false);
expect(controller.getIcon()).toBeNull();
});
});
describe('should set thumbnail', () => {
it('should set brand thumbnail', () => {
const controller = new ThumbnailFeatureController();
const itemWithThumbnail = new TestViewMedia({
thumbnail: 'https://brands.home-assistant.io//amcrest/icon.png',
});
controller.calculate(null, itemWithThumbnail, false);
expect(controller.getThumbnail()).toBe(
'https://brands.home-assistant.io/brands/_/amcrest/icon.png',
);
expect(controller.getThumbnailClass()).toBe('brand');
});
it('should set other thumbnail', () => {
const controller = new ThumbnailFeatureController();
const itemWithThumbnail = new TestViewMedia({
thumbnail: 'https://card.camera/thumbnail.jpg',
});
controller.calculate(null, itemWithThumbnail, false);
expect(controller.getThumbnail()).toBe('https://card.camera/thumbnail.jpg');
expect(controller.getThumbnailClass()).toBeNull();
});
});
});
@@ -2,11 +2,17 @@ import Panzoom, { PanzoomEventDetail, PanzoomObject } from '@dermotduffy/panzoom
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mock, mockClear } from 'vitest-mock-extended';
import { ZoomController } from '../../../src/components-lib/zoom/zoom-controller';
import { ResizeObserverMock, requestAnimationFrameMock } from '../../test-utils';
import {
ResizeObserverMock,
createTouch,
createTouchEvent,
requestAnimationFrameMock,
} from '../../test-utils';
vi.mock('@dermotduffy/panzoom');
vi.mock('lodash-es/throttle', () => ({
default: vi.fn((fn) => fn),
vi.mock('lodash-es', () => ({
round: vi.fn((fn) => fn),
throttle: vi.fn((fn) => fn),
}));
// https://github.com/jsdom/jsdom/issues/2527
@@ -42,23 +48,6 @@ describe('ZoomController', () => {
return zoom;
};
const createTouch = (target: HTMLElement): Touch => {
return {
clientX: 0,
clientY: 0,
force: 0,
identifier: 0,
pageX: 0,
pageY: 0,
radiusX: 0,
radiusY: 0,
rotationAngle: 0,
screenX: 0,
screenY: 0,
target: target,
};
};
beforeAll(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
window.requestAnimationFrame = requestAnimationFrameMock;
@@ -144,24 +133,23 @@ describe('ZoomController', () => {
createAndRegisterZoom(element);
const ev_1 = new TouchEvent('touchstart', {
bubbles: false,
touches: [createTouch(element), createTouch(element)],
const ev_1 = createTouchEvent('touchstart', {
touches: [createTouch({ target: element }), createTouch({ target: element })],
});
element.dispatchEvent(ev_1);
expect(panzoom.handleDown).toBeCalledWith(ev_1);
panzoom.getScale = vi.fn().mockReturnValue(1.2);
const ev_3 = new TouchEvent('touchstart');
const ev_3 = createTouchEvent('touchstart');
element.dispatchEvent(ev_3);
expect(panzoom.handleDown).toBeCalledWith(ev_3);
const ev_4 = new TouchEvent('touchmove');
const ev_4 = createTouchEvent('touchmove');
element.dispatchEvent(ev_4);
expect(panzoom.handleMove).toBeCalledWith(ev_4);
const ev_5 = new TouchEvent('touchend');
const ev_5 = createTouchEvent('touchend');
element.dispatchEvent(ev_5);
expect(panzoom.handleUp).toBeCalledWith(ev_5);
});
@@ -57,33 +57,68 @@ describe('generateViewContextForZoom', () => {
});
// @vitest-environment jsdom
it('handleZoomSettingsObservedEvent', () => {
const viewManager = mock<ViewManager>();
describe('handleZoomSettingsObservedEvent', () => {
it('should reject observed zoom settings without viewManager ', () => {
handleZoomSettingsObservedEvent(
new CustomEvent('advanced-camera-card:zoom:change', {
detail: {
pan: { x: 1, y: 2 },
zoom: 3,
isDefault: true,
unzoomed: true,
},
}),
);
handleZoomSettingsObservedEvent(
new CustomEvent('advanced-camera-card:zoom:change', {
detail: {
pan: { x: 1, y: 2 },
zoom: 3,
isDefault: true,
unzoomed: true,
},
}),
viewManager,
'target',
);
expect(viewManager.setViewByParameters).toBeCalledWith(
expect.objectContaining({
modifiers: [expect.any(MergeContextViewModifier)],
}),
);
// No observable effect.
});
expect(MergeContextViewModifier).toBeCalledWith({
zoom: {
target: {
observed: { pan: { x: 1, y: 2 }, zoom: 3, isDefault: true, unzoomed: true },
requested: null,
it('should reject observed zoom settings without target ', () => {
const viewManager = mock<ViewManager>();
handleZoomSettingsObservedEvent(
new CustomEvent('advanced-camera-card:zoom:change', {
detail: {
pan: { x: 1, y: 2 },
zoom: 3,
isDefault: true,
unzoomed: true,
},
}),
viewManager,
);
expect(viewManager.setViewByParameters).not.toBeCalled();
});
it('should handle observed zoom settings ', () => {
const viewManager = mock<ViewManager>();
handleZoomSettingsObservedEvent(
new CustomEvent('advanced-camera-card:zoom:change', {
detail: {
pan: { x: 1, y: 2 },
zoom: 3,
isDefault: true,
unzoomed: true,
},
}),
viewManager,
'target',
);
expect(viewManager.setViewByParameters).toBeCalledWith(
expect.objectContaining({
modifiers: [expect.any(MergeContextViewModifier)],
}),
);
expect(MergeContextViewModifier).toBeCalledWith({
zoom: {
target: {
observed: { pan: { x: 1, y: 2 }, zoom: 3, isDefault: true, unzoomed: true },
requested: null,
},
},
},
});
});
});