refactor: Refactor media loading manager for improved robustness (#2464)
This commit is contained in:
1 parent
47bcce93d3
commit
bc366626f1
51 files changed
+1897
-1128
No files matched your search
@@ -1,61 +1,362 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { MediaLoadedInfoManager } from '../../src/card-controller/media-info-manager';
|
||||
import { createCardAPI, createMediaLoadedInfo } from '../test-utils.js';
|
||||
import {
|
||||
createCardAPI,
|
||||
createMediaLoadedInfo,
|
||||
createMediaLoadedInfoEvent,
|
||||
} from '../test-utils.js';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('MediaLoadedInfoManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should initialize', () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MediaLoadedInfoManager(api);
|
||||
|
||||
manager.initialize();
|
||||
|
||||
expect(api.getConditionStateManager().setState).toBeCalledWith({
|
||||
mediaLoadedInfo: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should set', () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MediaLoadedInfoManager(api);
|
||||
const mediaInfo = createMediaLoadedInfo();
|
||||
describe('set', () => {
|
||||
it('should surface info and fire condition state for the selected target', () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MediaLoadedInfoManager(api);
|
||||
const owner = document.createElement('div');
|
||||
const info = createMediaLoadedInfo({ targetID: 'target-1' });
|
||||
|
||||
manager.set(mediaInfo);
|
||||
manager.setSelected('target-1');
|
||||
vi.clearAllMocks();
|
||||
manager.set(info, owner);
|
||||
|
||||
expect(manager.has()).toBeTruthy();
|
||||
expect(manager.get()).toBe(mediaInfo);
|
||||
expect(api.getConditionStateManager().setState).toBeCalledWith(
|
||||
expect.objectContaining({ mediaLoadedInfo: mediaInfo }),
|
||||
);
|
||||
expect(api.getStyleManager().setExpandedMode).toBeCalled();
|
||||
expect(api.getCardElementManager().update).toBeCalled();
|
||||
expect(manager.has()).toBeTruthy();
|
||||
expect(manager.get()).toBe(info);
|
||||
expect(api.getConditionStateManager().setState).toBeCalledWith({
|
||||
mediaLoadedInfo: info,
|
||||
});
|
||||
expect(api.getStyleManager().setExpandedMode).toBeCalled();
|
||||
expect(api.getCardElementManager().update).toBeCalled();
|
||||
});
|
||||
|
||||
it('should cache info for non-selected targets without side effects', () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MediaLoadedInfoManager(api);
|
||||
const owner = document.createElement('div');
|
||||
const info = createMediaLoadedInfo({ targetID: 'target-1' });
|
||||
|
||||
manager.set(info, owner);
|
||||
|
||||
expect(manager.has()).toBeFalsy();
|
||||
expect(manager.get()).toBeNull();
|
||||
expect(api.getConditionStateManager().setState).not.toBeCalled();
|
||||
expect(api.getStyleManager().setExpandedMode).not.toBeCalled();
|
||||
expect(api.getCardElementManager().update).not.toBeCalled();
|
||||
|
||||
manager.setSelected('target-1');
|
||||
|
||||
expect(manager.has()).toBeTruthy();
|
||||
expect(manager.get()).toBe(info);
|
||||
expect(api.getConditionStateManager().setState).toBeCalledWith({
|
||||
mediaLoadedInfo: info,
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject info missing dimensions', () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MediaLoadedInfoManager(api);
|
||||
const owner = document.createElement('div');
|
||||
const info = createMediaLoadedInfo({
|
||||
width: 0,
|
||||
height: 0,
|
||||
targetID: 'target-1',
|
||||
});
|
||||
|
||||
manager.setSelected('target-1');
|
||||
vi.clearAllMocks();
|
||||
manager.set(info, owner);
|
||||
|
||||
expect(manager.has()).toBeFalsy();
|
||||
expect(manager.get()).toBeNull();
|
||||
expect(api.getConditionStateManager().setState).not.toBeCalled();
|
||||
expect(api.getStyleManager().setExpandedMode).not.toBeCalled();
|
||||
expect(api.getCardElementManager().update).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should reject info without a targetID', () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MediaLoadedInfoManager(api);
|
||||
const owner = document.createElement('div');
|
||||
const info = createMediaLoadedInfo({ targetID: undefined });
|
||||
|
||||
manager.setSelected('target-1');
|
||||
manager.set(info, owner);
|
||||
|
||||
expect(manager.has()).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should not set invalid media info', () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MediaLoadedInfoManager(api);
|
||||
const mediaLoadedInfo = createMediaLoadedInfo({ width: 0, height: 0 });
|
||||
describe('setSelected', () => {
|
||||
it('should switch between targets', () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MediaLoadedInfoManager(api);
|
||||
const owner1 = document.createElement('div');
|
||||
const owner2 = document.createElement('div');
|
||||
const info1 = createMediaLoadedInfo({ targetID: 'target-1' });
|
||||
const info2 = createMediaLoadedInfo({ targetID: 'target-2' });
|
||||
|
||||
manager.set(mediaLoadedInfo);
|
||||
manager.set(info1, owner1);
|
||||
manager.set(info2, owner2);
|
||||
|
||||
expect(manager.has()).toBeFalsy();
|
||||
expect(manager.get()).toBeNull();
|
||||
expect(api.getConditionStateManager().setState).not.toBeCalled();
|
||||
manager.setSelected('target-1');
|
||||
expect(manager.get()).toBe(info1);
|
||||
|
||||
manager.setSelected('target-2');
|
||||
expect(manager.get()).toBe(info2);
|
||||
|
||||
manager.setSelected(null);
|
||||
expect(manager.get()).toBeNull();
|
||||
});
|
||||
|
||||
it('should be a no-op when re-selecting the same target', () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MediaLoadedInfoManager(api);
|
||||
|
||||
manager.setSelected('target-1');
|
||||
vi.clearAllMocks();
|
||||
manager.setSelected('target-1');
|
||||
|
||||
expect(api.getConditionStateManager().setState).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should emit null condition state when selecting a target with no info', () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MediaLoadedInfoManager(api);
|
||||
|
||||
manager.setSelected('target-1');
|
||||
|
||||
expect(api.getConditionStateManager().setState).toBeCalledWith({
|
||||
mediaLoadedInfo: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should get last known', () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MediaLoadedInfoManager(api);
|
||||
const mediaLoadedInfo = createMediaLoadedInfo();
|
||||
describe('getLastKnown', () => {
|
||||
it('should return the last known info for the selected target', () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MediaLoadedInfoManager(api);
|
||||
const owner = document.createElement('div');
|
||||
const info = createMediaLoadedInfo({ targetID: 'target-1' });
|
||||
|
||||
manager.set(mediaLoadedInfo);
|
||||
manager.setSelected('target-1');
|
||||
manager.set(info, owner);
|
||||
|
||||
expect(manager.has()).toBeTruthy();
|
||||
expect(manager.getLastKnown()).toBe(info);
|
||||
});
|
||||
|
||||
manager.clear();
|
||||
it('should return null when nothing is selected', () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MediaLoadedInfoManager(api);
|
||||
|
||||
expect(manager.has()).toBeFalsy();
|
||||
expect(manager.getLastKnown()).toBe(mediaLoadedInfo);
|
||||
expect(api.getConditionStateManager().setState).toBeCalledWith(
|
||||
expect.objectContaining({ mediaLoadedInfo }),
|
||||
);
|
||||
expect(manager.getLastKnown()).toBeNull();
|
||||
});
|
||||
|
||||
it('should preserve last known across clear', () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MediaLoadedInfoManager(api);
|
||||
const owner = document.createElement('div');
|
||||
const info = createMediaLoadedInfo({ targetID: 'target-1' });
|
||||
|
||||
manager.setSelected('target-1');
|
||||
manager.set(info, owner);
|
||||
manager.clear();
|
||||
|
||||
expect(manager.has()).toBeFalsy();
|
||||
expect(manager.getLastKnown()).toBe(info);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleLoadEvent', () => {
|
||||
it('should register info from a valid event', () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MediaLoadedInfoManager(api);
|
||||
const source = document.createElement('div');
|
||||
const ac = new AbortController();
|
||||
const info = createMediaLoadedInfo({ targetID: 'target-1' });
|
||||
|
||||
manager.setSelected('target-1');
|
||||
vi.clearAllMocks();
|
||||
manager.handleLoadEvent(
|
||||
createMediaLoadedInfoEvent({ source, info, signal: ac.signal }),
|
||||
);
|
||||
|
||||
expect(manager.get()).toBe(info);
|
||||
expect(api.getConditionStateManager().setState).toBeCalledWith({
|
||||
mediaLoadedInfo: info,
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore events whose composedPath()[0] is not an HTMLElement', () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MediaLoadedInfoManager(api);
|
||||
const ac = new AbortController();
|
||||
const info = createMediaLoadedInfo({ targetID: 'target-1' });
|
||||
const ev = createMediaLoadedInfoEvent({ info, signal: ac.signal });
|
||||
// Force the path to look like a non-HTMLElement (e.g. a window).
|
||||
Object.defineProperty(ev, 'composedPath', { value: () => [window] });
|
||||
|
||||
manager.setSelected('target-1');
|
||||
manager.handleLoadEvent(ev);
|
||||
|
||||
expect(manager.has()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should ignore events whose info has no targetID', () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MediaLoadedInfoManager(api);
|
||||
const source = document.createElement('div');
|
||||
const ac = new AbortController();
|
||||
const info = createMediaLoadedInfo({ targetID: undefined });
|
||||
|
||||
manager.setSelected('target-1');
|
||||
manager.handleLoadEvent(
|
||||
createMediaLoadedInfoEvent({ source, info, signal: ac.signal }),
|
||||
);
|
||||
|
||||
expect(manager.has()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should clear an entry on signal abort', () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MediaLoadedInfoManager(api);
|
||||
const source = document.createElement('div');
|
||||
const ac = new AbortController();
|
||||
const info = createMediaLoadedInfo({ targetID: 'target-1' });
|
||||
|
||||
manager.setSelected('target-1');
|
||||
manager.handleLoadEvent(
|
||||
createMediaLoadedInfoEvent({ source, info, signal: ac.signal }),
|
||||
);
|
||||
expect(manager.has()).toBeTruthy();
|
||||
|
||||
ac.abort();
|
||||
|
||||
expect(manager.has()).toBeFalsy();
|
||||
expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith({
|
||||
mediaLoadedInfo: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not clear an entry whose owner has been replaced when an older signal aborts', () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MediaLoadedInfoManager(api);
|
||||
|
||||
const owner1 = document.createElement('div');
|
||||
const owner2 = document.createElement('div');
|
||||
const ac1 = new AbortController();
|
||||
const ac2 = new AbortController();
|
||||
const info1 = createMediaLoadedInfo({ targetID: 'target-1' });
|
||||
const info2 = createMediaLoadedInfo({ targetID: 'target-1' });
|
||||
|
||||
manager.setSelected('target-1');
|
||||
manager.handleLoadEvent(
|
||||
createMediaLoadedInfoEvent({ source: owner1, info: info1, signal: ac1.signal }),
|
||||
);
|
||||
manager.handleLoadEvent(
|
||||
createMediaLoadedInfoEvent({ source: owner2, info: info2, signal: ac2.signal }),
|
||||
);
|
||||
|
||||
expect(manager.get()).toBe(info2);
|
||||
|
||||
// The first owner's signal aborts, but it shouldn't blow away the entry
|
||||
// owner2 has since taken over.
|
||||
ac1.abort();
|
||||
|
||||
expect(manager.get()).toBe(info2);
|
||||
});
|
||||
|
||||
it('should not fire condition state when clearing a non-selected target', () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MediaLoadedInfoManager(api);
|
||||
const source = document.createElement('div');
|
||||
const ac = new AbortController();
|
||||
const info = createMediaLoadedInfo({ targetID: 'target-1' });
|
||||
|
||||
// Select a different target than the one being cleared.
|
||||
manager.setSelected('target-other');
|
||||
manager.handleLoadEvent(
|
||||
createMediaLoadedInfoEvent({ source, info, signal: ac.signal }),
|
||||
);
|
||||
vi.clearAllMocks();
|
||||
|
||||
ac.abort();
|
||||
|
||||
expect(api.getConditionStateManager().setState).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
it('should drop active entries and fire condition state when selected target had info', () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MediaLoadedInfoManager(api);
|
||||
const owner = document.createElement('div');
|
||||
const info = createMediaLoadedInfo({ targetID: 'target-1' });
|
||||
|
||||
manager.setSelected('target-1');
|
||||
manager.set(info, owner);
|
||||
vi.clearAllMocks();
|
||||
|
||||
manager.clear();
|
||||
|
||||
expect(manager.has()).toBeFalsy();
|
||||
expect(api.getConditionStateManager().setState).toBeCalledWith({
|
||||
mediaLoadedInfo: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should be a no-op on condition state when nothing is selected', () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MediaLoadedInfoManager(api);
|
||||
|
||||
manager.clear();
|
||||
|
||||
expect(api.getConditionStateManager().setState).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should not fire condition state when the selected target has no info', () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MediaLoadedInfoManager(api);
|
||||
|
||||
manager.setSelected('target-1');
|
||||
vi.clearAllMocks();
|
||||
manager.clear();
|
||||
|
||||
expect(api.getConditionStateManager().setState).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('initialize', () => {
|
||||
it('should clear active state, last-known and selected', () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MediaLoadedInfoManager(api);
|
||||
const owner = document.createElement('div');
|
||||
const info = createMediaLoadedInfo({ targetID: 'target-1' });
|
||||
|
||||
manager.setSelected('target-1');
|
||||
manager.set(info, owner);
|
||||
|
||||
manager.initialize();
|
||||
|
||||
expect(manager.get()).toBeNull();
|
||||
expect(manager.getLastKnown()).toBeNull();
|
||||
|
||||
// Re-selecting `target-1` after initialize should produce no last-known.
|
||||
manager.setSelected('target-1');
|
||||
expect(manager.getLastKnown()).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -43,7 +43,7 @@ describe('should act correctly when view is set', () => {
|
||||
|
||||
expect(manager.getView()).toBe(view);
|
||||
expect(manager.hasView()).toBeTruthy();
|
||||
expect(api.getMediaLoadedInfoManager().clear).toBeCalled();
|
||||
expect(api.getMediaLoadedInfoManager().setSelected).toBeCalledWith('camera');
|
||||
expect(api.getCardElementManager().scrollReset).toBeCalled();
|
||||
expect(api.getStyleManager().setExpandedMode).toBeCalled();
|
||||
expect(api.getConditionStateManager()?.setState).toBeCalledWith({
|
||||
@@ -55,7 +55,7 @@ describe('should act correctly when view is set', () => {
|
||||
expect(api.getCardElementManager().update).toBeCalled();
|
||||
});
|
||||
|
||||
it('should set view with minor changes without media clearing or scroll', () => {
|
||||
it('should set view with minor changes without scroll', () => {
|
||||
const view_1 = createView({
|
||||
view: 'live',
|
||||
camera: 'camera',
|
||||
@@ -68,7 +68,6 @@ describe('should act correctly when view is set', () => {
|
||||
|
||||
manager.setViewDefault();
|
||||
|
||||
vi.mocked(api.getMediaLoadedInfoManager().clear).mockClear();
|
||||
vi.mocked(api.getCardElementManager().scrollReset).mockClear();
|
||||
|
||||
const view_2 = createView({
|
||||
@@ -82,9 +81,7 @@ describe('should act correctly when view is set', () => {
|
||||
|
||||
expect(manager.getView()).toBe(view_2);
|
||||
|
||||
// The new view is neither a major media change, nor a different view name,
|
||||
// so media clearing and scrolling should not happen.
|
||||
expect(api.getMediaLoadedInfoManager().clear).not.toBeCalled();
|
||||
// Same view name, so scrolling should not happen.
|
||||
expect(api.getCardElementManager().scrollReset).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { LiveController } from '../../../src/components-lib/live/live-controller';
|
||||
import { dispatchExistingMediaLoadedInfoAsEvent } from '../../../src/utils/media-info';
|
||||
import {
|
||||
IntersectionObserverMock,
|
||||
callIntersectionHandler,
|
||||
createLitElement,
|
||||
createMediaLoadedInfo,
|
||||
createMediaLoadedInfoEvent,
|
||||
createParent,
|
||||
} from '../../test-utils';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('LiveController', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal('IntersectionObserver', IntersectionObserverMock);
|
||||
});
|
||||
|
||||
it('should be constructable', () => {
|
||||
const controller = new LiveController(createLitElement());
|
||||
expect(controller).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should connect and disconnect', () => {
|
||||
const host = createLitElement();
|
||||
const parent = createParent({ children: [host] });
|
||||
const eventListener = vi.fn();
|
||||
parent.addEventListener('advanced-camera-card:media:loaded', eventListener);
|
||||
|
||||
const controller = new LiveController(host);
|
||||
expect(host.addController).toBeCalled();
|
||||
|
||||
controller.hostConnected();
|
||||
|
||||
callIntersectionHandler(false);
|
||||
|
||||
dispatchExistingMediaLoadedInfoAsEvent(host, createMediaLoadedInfo());
|
||||
|
||||
expect(eventListener).toBeCalledTimes(0);
|
||||
|
||||
controller.hostDisconnected();
|
||||
dispatchExistingMediaLoadedInfoAsEvent(host, createMediaLoadedInfo());
|
||||
|
||||
expect(eventListener).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('should handle background / foreground', () => {
|
||||
it('should start in the foreground', () => {
|
||||
const controller = new LiveController(createLitElement());
|
||||
expect(controller.isInBackground()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should handle changing to background', () => {
|
||||
const element = createLitElement();
|
||||
const controller = new LiveController(element);
|
||||
expect(controller.isInBackground()).toBeFalsy();
|
||||
expect(element.requestUpdate).toBeCalledTimes(0);
|
||||
|
||||
callIntersectionHandler(true);
|
||||
expect(controller.isInBackground()).toBeFalsy();
|
||||
expect(element.requestUpdate).toBeCalledTimes(0);
|
||||
|
||||
callIntersectionHandler(false);
|
||||
expect(controller.isInBackground()).toBeTruthy();
|
||||
expect(element.requestUpdate).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should dispatch media loaded on background change', () => {
|
||||
const host = createLitElement();
|
||||
const parent = createParent({ children: [host] });
|
||||
const eventListener = vi.fn();
|
||||
parent.addEventListener('advanced-camera-card:media:loaded', eventListener);
|
||||
|
||||
const controller = new LiveController(host);
|
||||
const mediaLoadedInfo = createMediaLoadedInfo();
|
||||
|
||||
controller.hostConnected();
|
||||
|
||||
callIntersectionHandler(false);
|
||||
expect(controller.isInBackground()).toBeTruthy();
|
||||
|
||||
host.dispatchEvent(createMediaLoadedInfoEvent(mediaLoadedInfo));
|
||||
expect(eventListener).toBeCalledTimes(0);
|
||||
|
||||
callIntersectionHandler(true);
|
||||
expect(eventListener).toBeCalledTimes(1);
|
||||
expect(eventListener).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
detail: mediaLoadedInfo,
|
||||
}),
|
||||
);
|
||||
|
||||
host.dispatchEvent(createMediaLoadedInfoEvent(mediaLoadedInfo));
|
||||
expect(eventListener).toBeCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { MediaLoadedInfo } from '../../src/types';
|
||||
import {
|
||||
callResizeHandler,
|
||||
createLitElement,
|
||||
createMediaLoadedInfoEvent,
|
||||
getResizeObserver,
|
||||
ResizeObserverMock,
|
||||
} from '../test-utils';
|
||||
@@ -676,9 +677,7 @@ describe('MediaDimensionsContainerController', () => {
|
||||
height: 160,
|
||||
};
|
||||
innerContainer.dispatchEvent(
|
||||
new CustomEvent<MediaLoadedInfo>('advanced-camera-card:media:loaded', {
|
||||
detail: mediaLoadedInfo,
|
||||
}),
|
||||
createMediaLoadedInfoEvent({ info: mediaLoadedInfo }),
|
||||
);
|
||||
|
||||
expect(host.hasAttribute('rotated')).toBeTruthy();
|
||||
@@ -713,18 +712,14 @@ describe('MediaDimensionsContainerController', () => {
|
||||
height: 160,
|
||||
};
|
||||
innerContainer.dispatchEvent(
|
||||
new CustomEvent<MediaLoadedInfo>('advanced-camera-card:media:loaded', {
|
||||
detail: mediaLoadedInfo,
|
||||
}),
|
||||
createMediaLoadedInfoEvent({ info: mediaLoadedInfo }),
|
||||
);
|
||||
|
||||
expect(host.hasAttribute('rotated')).toBeTruthy();
|
||||
host.removeAttribute('rotated');
|
||||
|
||||
innerContainer.dispatchEvent(
|
||||
new CustomEvent<MediaLoadedInfo>('advanced-camera-card:media:loaded', {
|
||||
detail: mediaLoadedInfo,
|
||||
}),
|
||||
createMediaLoadedInfoEvent({ info: mediaLoadedInfo }),
|
||||
);
|
||||
|
||||
expect(host.hasAttribute('rotated')).toBeFalsy();
|
||||
|
||||
@@ -6,8 +6,6 @@ import {
|
||||
MediaGridConstructorOptions,
|
||||
MediaGridController,
|
||||
} from '../../src/components-lib/media-grid-controller';
|
||||
import { MediaLoadedInfo } from '../../src/types';
|
||||
import { dispatchExistingMediaLoadedInfoAsEvent } from '../../src/utils/media-info';
|
||||
import {
|
||||
MutationObserverMock,
|
||||
ResizeObserverMock,
|
||||
@@ -82,11 +80,6 @@ const triggerResizeObserver = (hostOrCell: 'cell' | 'host'): void => {
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('MediaGridController', () => {
|
||||
const mediaLoadedInfo: MediaLoadedInfo = {
|
||||
width: 10,
|
||||
height: 20,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal('MutationObserver', MutationObserverMock);
|
||||
@@ -175,59 +168,6 @@ describe('MediaGridController', () => {
|
||||
expect(controller.getSelected()).toBe('0');
|
||||
});
|
||||
|
||||
it('should dispatch media loaded info on selection', () => {
|
||||
const children = createChildren();
|
||||
const slot = createSlot();
|
||||
const host = createSlotHost({ slot: slot, children: children });
|
||||
const controller = createController(slot);
|
||||
|
||||
const mediaLoadedInfoHandler = vi.fn();
|
||||
host.addEventListener('advanced-camera-card:media:loaded', mediaLoadedInfoHandler);
|
||||
dispatchExistingMediaLoadedInfoAsEvent(children[0], mediaLoadedInfo);
|
||||
|
||||
controller.selectCell('0');
|
||||
expect(mediaLoadedInfoHandler).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
detail: mediaLoadedInfo,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should dispatch media loaded info when cell is selected', () => {
|
||||
const children = createChildren();
|
||||
const slot = createSlot();
|
||||
const host = createSlotHost({ slot: slot, children: children });
|
||||
const controller = createController(slot);
|
||||
|
||||
controller.selectCell('0');
|
||||
|
||||
const mediaLoadedInfoHandler = vi.fn();
|
||||
host.addEventListener('advanced-camera-card:media:loaded', mediaLoadedInfoHandler);
|
||||
dispatchExistingMediaLoadedInfoAsEvent(children[0], mediaLoadedInfo);
|
||||
|
||||
expect(mediaLoadedInfoHandler).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
detail: mediaLoadedInfo,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not dispatch media loaded info when cell is not selected', () => {
|
||||
const children = createChildren();
|
||||
const slot = createSlot();
|
||||
const host = createSlotHost({ slot: slot, children: children });
|
||||
const controller = createController(host);
|
||||
|
||||
controller.selectCell('1');
|
||||
|
||||
const mediaLoadedInfoHandler = vi.fn();
|
||||
host.addEventListener('advanced-camera-card:media:loaded', mediaLoadedInfoHandler);
|
||||
dispatchExistingMediaLoadedInfoAsEvent(children[0], mediaLoadedInfo);
|
||||
|
||||
// Another element is selected, so the event should not have propagated.
|
||||
expect(mediaLoadedInfoHandler).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should unselect', () => {
|
||||
const children = createChildren();
|
||||
const slot = createSlot();
|
||||
@@ -235,12 +175,10 @@ describe('MediaGridController', () => {
|
||||
const controller = createController(host);
|
||||
|
||||
const unselectedHandler = vi.fn();
|
||||
const unloadMediaHandler = vi.fn();
|
||||
host.addEventListener(
|
||||
'advanced-camera-card:media-grid:unselected',
|
||||
unselectedHandler,
|
||||
);
|
||||
host.addEventListener('advanced-camera-card:media:unloaded', unloadMediaHandler);
|
||||
|
||||
controller.selectCell('0');
|
||||
expect(controller.getSelected()).toBe('0');
|
||||
@@ -257,15 +195,13 @@ describe('MediaGridController', () => {
|
||||
expect(child.getAttribute('unselected')).toEqual('');
|
||||
}
|
||||
|
||||
// Expect handlers to have been called.
|
||||
// The grid signals its own state change via media-grid:unselected.
|
||||
expect(unselectedHandler).toBeCalledTimes(1);
|
||||
expect(unloadMediaHandler).toBeCalledTimes(1);
|
||||
|
||||
// Unselecting a second time should do nothing.
|
||||
controller.unselectAll();
|
||||
|
||||
expect(unselectedHandler).toBeCalledTimes(1);
|
||||
expect(unloadMediaHandler).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should select in constructor', () => {
|
||||
@@ -315,7 +251,6 @@ describe('MediaGridController', () => {
|
||||
const children = createChildren();
|
||||
const parent = createParent({ children: children });
|
||||
const controller = createController(parent, { selected: '1' });
|
||||
dispatchExistingMediaLoadedInfoAsEvent(children[0], mediaLoadedInfo);
|
||||
|
||||
expect(controller.getSelected()).toBe('1');
|
||||
expect(controller.getGridSize()).toBe(3);
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { MediaLoadedInfoSinkController } from '../../src/components-lib/media-loaded-info-sink-controller';
|
||||
import {
|
||||
createLitElement,
|
||||
createMediaLoadedInfo,
|
||||
createMediaLoadedInfoEvent,
|
||||
} from '../test-utils';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('MediaLoadedInfoSinkController', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should register itself with the host', () => {
|
||||
const host = createLitElement();
|
||||
const controller = new MediaLoadedInfoSinkController(host, {
|
||||
getTargetID: () => 'target-1',
|
||||
});
|
||||
|
||||
expect(host.addController).toBeCalledWith(controller);
|
||||
});
|
||||
|
||||
it('should default to an empty info', () => {
|
||||
const host = createLitElement();
|
||||
const controller = new MediaLoadedInfoSinkController(host, {
|
||||
getTargetID: () => 'target-1',
|
||||
});
|
||||
|
||||
expect(controller.has()).toBeFalsy();
|
||||
expect(controller.get()).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when getTargetID returns null', () => {
|
||||
const host = createLitElement();
|
||||
const controller = new MediaLoadedInfoSinkController(host, {
|
||||
getTargetID: () => null,
|
||||
});
|
||||
controller.hostConnected();
|
||||
|
||||
host.dispatchEvent(createMediaLoadedInfoEvent());
|
||||
|
||||
expect(controller.has()).toBeFalsy();
|
||||
expect(controller.get()).toBeNull();
|
||||
});
|
||||
|
||||
describe('per-target caching', () => {
|
||||
it('should expose only the selected target', () => {
|
||||
let selected: string | null = 'target-A';
|
||||
const host = createLitElement();
|
||||
const controller = new MediaLoadedInfoSinkController(host, {
|
||||
getTargetID: () => selected,
|
||||
});
|
||||
controller.hostConnected();
|
||||
|
||||
const infoA = createMediaLoadedInfo({ targetID: 'target-A' });
|
||||
const infoB = createMediaLoadedInfo({ targetID: 'target-B' });
|
||||
host.dispatchEvent(createMediaLoadedInfoEvent({ info: infoA }));
|
||||
host.dispatchEvent(createMediaLoadedInfoEvent({ info: infoB }));
|
||||
|
||||
// Both cached, but only the selected one is exposed.
|
||||
expect(controller.get()).toBe(infoA);
|
||||
|
||||
// Selecting the other target switches what `get()` returns — without a
|
||||
// new event arriving for it.
|
||||
selected = 'target-B';
|
||||
controller.hostUpdated();
|
||||
expect(controller.get()).toBe(infoB);
|
||||
});
|
||||
|
||||
it('should fire callback when selection changes the active info', () => {
|
||||
let selected: string | null = 'target-A';
|
||||
const callback = vi.fn();
|
||||
const host = createLitElement();
|
||||
const controller = new MediaLoadedInfoSinkController(host, {
|
||||
getTargetID: () => selected,
|
||||
callback,
|
||||
});
|
||||
controller.hostConnected();
|
||||
|
||||
const infoA = createMediaLoadedInfo({ targetID: 'target-A' });
|
||||
const infoB = createMediaLoadedInfo({ targetID: 'target-B' });
|
||||
host.dispatchEvent(createMediaLoadedInfoEvent({ info: infoA }));
|
||||
host.dispatchEvent(createMediaLoadedInfoEvent({ info: infoB }));
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Selection change to a target whose info is already cached.
|
||||
selected = 'target-B';
|
||||
controller.hostUpdated();
|
||||
|
||||
expect(callback).toBeCalledWith(infoB);
|
||||
expect(host.requestUpdate).toBeCalled();
|
||||
});
|
||||
|
||||
it('should not fire callback for non-selected target loads', () => {
|
||||
const callback = vi.fn();
|
||||
const host = createLitElement();
|
||||
const controller = new MediaLoadedInfoSinkController(host, {
|
||||
getTargetID: () => 'target-A',
|
||||
callback,
|
||||
});
|
||||
controller.hostConnected();
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Load for an unselected target — cached but inactive.
|
||||
host.dispatchEvent(
|
||||
createMediaLoadedInfoEvent({
|
||||
info: createMediaLoadedInfo({ targetID: 'target-B' }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(callback).not.toBeCalled();
|
||||
expect(host.requestUpdate).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should fire callback for selected target loads', () => {
|
||||
const callback = vi.fn();
|
||||
const host = createLitElement();
|
||||
const controller = new MediaLoadedInfoSinkController(host, {
|
||||
getTargetID: () => 'target-A',
|
||||
callback,
|
||||
});
|
||||
controller.hostConnected();
|
||||
vi.clearAllMocks();
|
||||
|
||||
const info = createMediaLoadedInfo({ targetID: 'target-A' });
|
||||
host.dispatchEvent(createMediaLoadedInfoEvent({ info }));
|
||||
|
||||
expect(callback).toBeCalledWith(info);
|
||||
expect(host.requestUpdate).toBeCalled();
|
||||
});
|
||||
|
||||
it('should not re-fire callback when hostUpdated runs without a targetID change', () => {
|
||||
const callback = vi.fn();
|
||||
const host = createLitElement();
|
||||
const controller = new MediaLoadedInfoSinkController(host, {
|
||||
getTargetID: () => 'target-A',
|
||||
callback,
|
||||
});
|
||||
controller.hostConnected();
|
||||
|
||||
const info = createMediaLoadedInfo({ targetID: 'target-A' });
|
||||
host.dispatchEvent(createMediaLoadedInfoEvent({ info }));
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Repeated host updates with no targetID change: no callback re-fire.
|
||||
controller.hostUpdated();
|
||||
controller.hostUpdated();
|
||||
|
||||
expect(callback).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should not fire callback when selection switches between empty targets', () => {
|
||||
let selected: string | null = 'target-A';
|
||||
const callback = vi.fn();
|
||||
const host = createLitElement();
|
||||
const controller = new MediaLoadedInfoSinkController(host, {
|
||||
getTargetID: () => selected,
|
||||
callback,
|
||||
});
|
||||
controller.hostConnected();
|
||||
controller.hostUpdated();
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Switch to another target with no cached info — active stays null.
|
||||
selected = 'target-B';
|
||||
controller.hostUpdated();
|
||||
|
||||
expect(callback).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should ignore events whose info has no targetID', () => {
|
||||
const callback = vi.fn();
|
||||
const host = createLitElement();
|
||||
const controller = new MediaLoadedInfoSinkController(host, {
|
||||
getTargetID: () => 'target-A',
|
||||
callback,
|
||||
});
|
||||
controller.hostConnected();
|
||||
vi.clearAllMocks();
|
||||
|
||||
host.dispatchEvent(
|
||||
createMediaLoadedInfoEvent({
|
||||
info: createMediaLoadedInfo({ targetID: undefined }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(callback).not.toBeCalled();
|
||||
expect(controller.get()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hostConnected / hostDisconnected', () => {
|
||||
it('should add the listener on connect and remove + clear on disconnect', () => {
|
||||
const host = createLitElement();
|
||||
const controller = new MediaLoadedInfoSinkController(host, {
|
||||
getTargetID: () => 'target-1',
|
||||
});
|
||||
controller.hostConnected();
|
||||
|
||||
const info = createMediaLoadedInfo({ targetID: 'target-1' });
|
||||
host.dispatchEvent(createMediaLoadedInfoEvent({ info }));
|
||||
expect(controller.get()).toBe(info);
|
||||
|
||||
controller.hostDisconnected();
|
||||
expect(controller.has()).toBeFalsy();
|
||||
|
||||
// Further events should not be observed.
|
||||
host.dispatchEvent(
|
||||
createMediaLoadedInfoEvent({
|
||||
info: createMediaLoadedInfo({ targetID: 'target-1', width: 500 }),
|
||||
}),
|
||||
);
|
||||
expect(controller.get()).toBeNull();
|
||||
});
|
||||
|
||||
it('should not fire callback or requestUpdate on disconnect', () => {
|
||||
// Testing the asymmetry described in the controller's class doc: abort
|
||||
// path notifies consumers (host still rendering), disconnect path does
|
||||
// not (host detaching, no UI to update).
|
||||
const callback = vi.fn();
|
||||
const host = createLitElement();
|
||||
const controller = new MediaLoadedInfoSinkController(host, {
|
||||
getTargetID: () => 'target-1',
|
||||
callback,
|
||||
});
|
||||
controller.hostConnected();
|
||||
host.dispatchEvent(createMediaLoadedInfoEvent());
|
||||
vi.clearAllMocks();
|
||||
|
||||
controller.hostDisconnected();
|
||||
|
||||
expect(callback).not.toBeCalled();
|
||||
expect(host.requestUpdate).not.toBeCalled();
|
||||
expect(controller.get()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('on signal abort', () => {
|
||||
it('should clear the entry and fire callback(null) for the selected target', () => {
|
||||
const callback = vi.fn();
|
||||
const host = createLitElement();
|
||||
const controller = new MediaLoadedInfoSinkController(host, {
|
||||
getTargetID: () => 'target-A',
|
||||
callback,
|
||||
});
|
||||
controller.hostConnected();
|
||||
|
||||
const ac = new AbortController();
|
||||
host.dispatchEvent(
|
||||
createMediaLoadedInfoEvent({
|
||||
info: createMediaLoadedInfo({ targetID: 'target-A' }),
|
||||
signal: ac.signal,
|
||||
}),
|
||||
);
|
||||
vi.clearAllMocks();
|
||||
|
||||
ac.abort();
|
||||
|
||||
expect(controller.get()).toBeNull();
|
||||
expect(callback).toBeCalledWith(null);
|
||||
expect(host.requestUpdate).toBeCalled();
|
||||
});
|
||||
|
||||
it('should not fire callback when an unselected target aborts', () => {
|
||||
const callback = vi.fn();
|
||||
const host = createLitElement();
|
||||
const controller = new MediaLoadedInfoSinkController(host, {
|
||||
getTargetID: () => 'target-A',
|
||||
callback,
|
||||
});
|
||||
controller.hostConnected();
|
||||
|
||||
const acA = new AbortController();
|
||||
const acB = new AbortController();
|
||||
host.dispatchEvent(
|
||||
createMediaLoadedInfoEvent({
|
||||
info: createMediaLoadedInfo({ targetID: 'target-A' }),
|
||||
signal: acA.signal,
|
||||
}),
|
||||
);
|
||||
host.dispatchEvent(
|
||||
createMediaLoadedInfoEvent({
|
||||
info: createMediaLoadedInfo({ targetID: 'target-B' }),
|
||||
signal: acB.signal,
|
||||
}),
|
||||
);
|
||||
vi.clearAllMocks();
|
||||
|
||||
acB.abort();
|
||||
|
||||
expect(callback).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should not clobber a newer entry when an older signal aborts', () => {
|
||||
const host = createLitElement();
|
||||
const controller = new MediaLoadedInfoSinkController(host, {
|
||||
getTargetID: () => 'target-A',
|
||||
});
|
||||
controller.hostConnected();
|
||||
|
||||
const ac1 = new AbortController();
|
||||
const info1 = createMediaLoadedInfo({ targetID: 'target-A', width: 100 });
|
||||
host.dispatchEvent(
|
||||
createMediaLoadedInfoEvent({ info: info1, signal: ac1.signal }),
|
||||
);
|
||||
|
||||
const ac2 = new AbortController();
|
||||
const info2 = createMediaLoadedInfo({ targetID: 'target-A', width: 200 });
|
||||
host.dispatchEvent(
|
||||
createMediaLoadedInfoEvent({ info: info2, signal: ac2.signal }),
|
||||
);
|
||||
expect(controller.get()).toBe(info2);
|
||||
|
||||
// The first signal aborts after a newer entry has overwritten the cache;
|
||||
// the newer entry must survive.
|
||||
ac1.abort();
|
||||
|
||||
expect(controller.get()).toBe(info2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { MediaLoadedInfoSourceController } from '../../src/components-lib/media-loaded-info-source-controller';
|
||||
import { MediaPlayerController } from '../../src/types';
|
||||
import { createLitElement, createMediaLoadedInfo } from '../test-utils';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('MediaLoadedInfoSourceController', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should register itself with the host', () => {
|
||||
const host = createLitElement();
|
||||
const controller = new MediaLoadedInfoSourceController(host, {
|
||||
getTargetID: () => 'target-1',
|
||||
});
|
||||
|
||||
expect(host.addController).toBeCalledWith(controller);
|
||||
});
|
||||
|
||||
describe('set', () => {
|
||||
it('should reject when getTargetID returns null', () => {
|
||||
const host = createLitElement();
|
||||
const controller = new MediaLoadedInfoSourceController(host, {
|
||||
getTargetID: () => null,
|
||||
});
|
||||
|
||||
const handler = vi.fn();
|
||||
host.addEventListener('advanced-camera-card:media:loaded', handler);
|
||||
|
||||
controller.set(createMediaLoadedInfo());
|
||||
|
||||
expect(handler).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should dispatch a bubbling, composed event with info+targetID and signal', () => {
|
||||
const host = createLitElement();
|
||||
const controller = new MediaLoadedInfoSourceController(host, {
|
||||
getTargetID: () => 'target-1',
|
||||
});
|
||||
|
||||
const handler = vi.fn();
|
||||
host.addEventListener('advanced-camera-card:media:loaded', handler);
|
||||
|
||||
controller.set(createMediaLoadedInfo({ width: 320, height: 240 }));
|
||||
|
||||
expect(handler).toBeCalledTimes(1);
|
||||
const ev = handler.mock.calls[0][0] as CustomEvent;
|
||||
expect(ev.bubbles).toBe(true);
|
||||
expect(ev.composed).toBe(true);
|
||||
|
||||
// The source controller injects targetID from `getTargetID`; the
|
||||
// dispatched info carries it regardless of what the caller passed.
|
||||
expect(ev.detail.info).toEqual({
|
||||
width: 320,
|
||||
height: 240,
|
||||
targetID: 'target-1',
|
||||
});
|
||||
expect(ev.detail.signal).toBeInstanceOf(AbortSignal);
|
||||
expect(ev.detail.signal.aborted).toBe(false);
|
||||
});
|
||||
|
||||
it('should dedup structurally-equal info', () => {
|
||||
const host = createLitElement();
|
||||
const controller = new MediaLoadedInfoSourceController(host, {
|
||||
getTargetID: () => 'target-1',
|
||||
});
|
||||
|
||||
const handler = vi.fn();
|
||||
host.addEventListener('advanced-camera-card:media:loaded', handler);
|
||||
|
||||
const player = mock<MediaPlayerController>();
|
||||
|
||||
controller.set(
|
||||
createMediaLoadedInfo({ mediaPlayerController: player, technology: ['hls'] }),
|
||||
);
|
||||
|
||||
// Same fields, different object identity. Should not redispatch.
|
||||
controller.set(
|
||||
createMediaLoadedInfo({ mediaPlayerController: player, technology: ['hls'] }),
|
||||
);
|
||||
|
||||
expect(handler).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should redispatch when mediaPlayerController reference differs', () => {
|
||||
const host = createLitElement();
|
||||
const controller = new MediaLoadedInfoSourceController(host, {
|
||||
getTargetID: () => 'target-1',
|
||||
});
|
||||
|
||||
const handler = vi.fn();
|
||||
host.addEventListener('advanced-camera-card:media:loaded', handler);
|
||||
|
||||
const player1 = mock<MediaPlayerController>();
|
||||
const player2 = mock<MediaPlayerController>();
|
||||
|
||||
controller.set(createMediaLoadedInfo({ mediaPlayerController: player1 }));
|
||||
controller.set(createMediaLoadedInfo({ mediaPlayerController: player2 }));
|
||||
|
||||
expect(handler).toBeCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should redispatch when getTargetID changes between calls', () => {
|
||||
let targetID: string | null = 'target-1';
|
||||
const host = createLitElement();
|
||||
const controller = new MediaLoadedInfoSourceController(host, {
|
||||
getTargetID: () => targetID,
|
||||
});
|
||||
|
||||
const handler = vi.fn();
|
||||
host.addEventListener('advanced-camera-card:media:loaded', handler);
|
||||
|
||||
controller.set(createMediaLoadedInfo());
|
||||
targetID = 'target-2';
|
||||
controller.set(createMediaLoadedInfo());
|
||||
|
||||
expect(handler).toBeCalledTimes(2);
|
||||
expect((handler.mock.calls[0][0] as CustomEvent).detail.info.targetID).toBe(
|
||||
'target-1',
|
||||
);
|
||||
expect((handler.mock.calls[1][0] as CustomEvent).detail.info.targetID).toBe(
|
||||
'target-2',
|
||||
);
|
||||
});
|
||||
|
||||
it('should abort the prior dispatch when targetID changes between calls', () => {
|
||||
// Without this, the manager would zombie an entry under the old
|
||||
// targetID — its `onAbort` cleanup never fires because we never aborted
|
||||
// the prior signal before overwriting `_abort`.
|
||||
let targetID: string | null = 'target-1';
|
||||
const host = createLitElement();
|
||||
const controller = new MediaLoadedInfoSourceController(host, {
|
||||
getTargetID: () => targetID,
|
||||
});
|
||||
|
||||
const handler = vi.fn();
|
||||
host.addEventListener('advanced-camera-card:media:loaded', handler);
|
||||
|
||||
controller.set(createMediaLoadedInfo());
|
||||
const firstSignal = (handler.mock.calls[0][0] as CustomEvent).detail.signal;
|
||||
|
||||
targetID = 'target-2';
|
||||
controller.set(createMediaLoadedInfo());
|
||||
|
||||
// The prior signal aborted so consumers' cleanup runs.
|
||||
expect(firstSignal.aborted).toBe(true);
|
||||
const secondSignal = (handler.mock.calls[1][0] as CustomEvent).detail.signal;
|
||||
expect(secondSignal.aborted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hostConnected', () => {
|
||||
it('should re-dispatch _lastSet on reconnect with a new AbortController', () => {
|
||||
const host = createLitElement();
|
||||
const controller = new MediaLoadedInfoSourceController(host, {
|
||||
getTargetID: () => 'target-1',
|
||||
});
|
||||
|
||||
const handler = vi.fn();
|
||||
host.addEventListener('advanced-camera-card:media:loaded', handler);
|
||||
|
||||
controller.set(createMediaLoadedInfo());
|
||||
const firstSignal = (handler.mock.calls[0][0] as CustomEvent).detail.signal;
|
||||
|
||||
// Disconnect and reconnect — without a fresh `set`.
|
||||
controller.hostDisconnected();
|
||||
controller.hostConnected();
|
||||
|
||||
expect(handler).toBeCalledTimes(2);
|
||||
const secondSignal = (handler.mock.calls[1][0] as CustomEvent).detail.signal;
|
||||
|
||||
// The original signal aborted on disconnect, the new one is fresh.
|
||||
expect(firstSignal).not.toBe(secondSignal);
|
||||
expect(firstSignal.aborted).toBe(true);
|
||||
expect(secondSignal.aborted).toBe(false);
|
||||
});
|
||||
|
||||
it('should be a no-op when there is nothing to re-dispatch', () => {
|
||||
const host = createLitElement();
|
||||
const controller = new MediaLoadedInfoSourceController(host, {
|
||||
getTargetID: () => 'target-1',
|
||||
});
|
||||
|
||||
const handler = vi.fn();
|
||||
host.addEventListener('advanced-camera-card:media:loaded', handler);
|
||||
|
||||
controller.hostConnected();
|
||||
|
||||
expect(handler).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should not redispatch if a registration is already active', () => {
|
||||
const host = createLitElement();
|
||||
const controller = new MediaLoadedInfoSourceController(host, {
|
||||
getTargetID: () => 'target-1',
|
||||
});
|
||||
|
||||
const handler = vi.fn();
|
||||
host.addEventListener('advanced-camera-card:media:loaded', handler);
|
||||
|
||||
controller.set(createMediaLoadedInfo());
|
||||
// Active registration, no disconnect — connect should be a no-op.
|
||||
controller.hostConnected();
|
||||
|
||||
expect(handler).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not replay stale info after targetID flips during disconnect', () => {
|
||||
// Bug scenario: getTargetID flips while we're disconnected; reconnect
|
||||
// must NOT redispatch the cached info under the stale targetID.
|
||||
let targetID: string | null = 'target-1';
|
||||
const host = createLitElement();
|
||||
const controller = new MediaLoadedInfoSourceController(host, {
|
||||
getTargetID: () => targetID,
|
||||
});
|
||||
|
||||
const handler = vi.fn();
|
||||
host.addEventListener('advanced-camera-card:media:loaded', handler);
|
||||
|
||||
controller.set(createMediaLoadedInfo());
|
||||
expect(handler).toBeCalledTimes(1);
|
||||
|
||||
controller.hostDisconnected();
|
||||
|
||||
// While disconnected, the host's targetID prop flips.
|
||||
targetID = 'target-2';
|
||||
controller.hostConnected();
|
||||
|
||||
// No re-dispatch — the stale cache was discarded.
|
||||
expect(handler).toBeCalledTimes(1);
|
||||
|
||||
// A subsequent set() under the new target dispatches fresh.
|
||||
controller.set(createMediaLoadedInfo({ width: 320, height: 240 }));
|
||||
expect(handler).toBeCalledTimes(2);
|
||||
expect((handler.mock.calls[1][0] as CustomEvent).detail.info.targetID).toBe(
|
||||
'target-2',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hostDisconnected', () => {
|
||||
it('should abort the active controller so consumers clean up', () => {
|
||||
const host = createLitElement();
|
||||
const controller = new MediaLoadedInfoSourceController(host, {
|
||||
getTargetID: () => 'target-1',
|
||||
});
|
||||
|
||||
const handler = vi.fn();
|
||||
host.addEventListener('advanced-camera-card:media:loaded', handler);
|
||||
|
||||
controller.set(createMediaLoadedInfo());
|
||||
const signal = (handler.mock.calls[0][0] as CustomEvent).detail.signal;
|
||||
const cleanup = vi.fn();
|
||||
signal.addEventListener('abort', cleanup);
|
||||
|
||||
controller.hostDisconnected();
|
||||
|
||||
expect(cleanup).toBeCalled();
|
||||
expect(signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('should be safe to call when nothing is active', () => {
|
||||
const host = createLitElement();
|
||||
const controller = new MediaLoadedInfoSourceController(host, {
|
||||
getTargetID: () => 'target-1',
|
||||
});
|
||||
|
||||
// Should not throw.
|
||||
controller.hostDisconnected();
|
||||
});
|
||||
});
|
||||
});
|
||||
+31
-9
@@ -77,7 +77,12 @@ import { Entity, EntityRegistryManager } from '../src/ha/registry/entity/types';
|
||||
import { CurrentUser, HassStateDifference, HomeAssistant } from '../src/ha/types';
|
||||
import { QuerySource } from '../src/query-source';
|
||||
import { Severity } from '../src/severity';
|
||||
import { CapabilitiesRaw, Interaction, MediaLoadedInfo } from '../src/types';
|
||||
import {
|
||||
CapabilitiesRaw,
|
||||
Interaction,
|
||||
MediaLoadedInfo,
|
||||
MediaLoadedInfoEventDetail,
|
||||
} from '../src/types';
|
||||
import {
|
||||
EventViewMedia,
|
||||
ReviewViewMedia,
|
||||
@@ -328,18 +333,35 @@ export const createMediaLoadedInfo = (
|
||||
return {
|
||||
width: 100,
|
||||
height: 100,
|
||||
targetID: 'target-1',
|
||||
...options,
|
||||
};
|
||||
};
|
||||
|
||||
export const createMediaLoadedInfoEvent = (
|
||||
mediaLoadedInfo?: MediaLoadedInfo,
|
||||
): CustomEvent<MediaLoadedInfo> => {
|
||||
return new CustomEvent('advanced-camera-card:media:loaded', {
|
||||
detail: mediaLoadedInfo ?? createMediaLoadedInfo(),
|
||||
composed: true,
|
||||
bubbles: true,
|
||||
});
|
||||
export const createMediaLoadedInfoEvent = (options?: {
|
||||
info?: MediaLoadedInfo;
|
||||
signal?: AbortSignal;
|
||||
// When set, overrides `composedPath()` to return `[source]`. Use this for
|
||||
// tests that hand the event directly to a handler (e.g. `handleLoadEvent`)
|
||||
// instead of dispatching it; jsdom only populates `composedPath` on real
|
||||
// dispatch.
|
||||
source?: HTMLElement;
|
||||
}): CustomEvent<MediaLoadedInfoEventDetail> => {
|
||||
const ev = new CustomEvent<MediaLoadedInfoEventDetail>(
|
||||
'advanced-camera-card:media:loaded',
|
||||
{
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: {
|
||||
info: options?.info ?? createMediaLoadedInfo(),
|
||||
signal: options?.signal ?? new AbortController().signal,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (options?.source) {
|
||||
Object.defineProperty(ev, 'composedPath', { value: () => [options.source] });
|
||||
}
|
||||
return ev;
|
||||
};
|
||||
|
||||
export const createPerformanceConfig = (config: unknown): PerformanceConfig => {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { onAbort } from '../../src/utils/abort-signal';
|
||||
|
||||
describe('onAbort', () => {
|
||||
it('should call the callback when the signal aborts', () => {
|
||||
const ac = new AbortController();
|
||||
const cb = vi.fn();
|
||||
onAbort(ac.signal, cb);
|
||||
|
||||
expect(cb).not.toBeCalled();
|
||||
|
||||
ac.abort();
|
||||
expect(cb).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call the callback synchronously if the signal is already aborted', () => {
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
|
||||
const cb = vi.fn();
|
||||
onAbort(ac.signal, cb);
|
||||
|
||||
expect(cb).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should fire only once even if the signal aborts repeatedly', () => {
|
||||
const ac = new AbortController();
|
||||
const cb = vi.fn();
|
||||
onAbort(ac.signal, cb);
|
||||
|
||||
ac.abort();
|
||||
// Aborting an AbortController again is a no-op, but verify the listener
|
||||
// is registered with `once: true` so any synthetic re-fire would be a
|
||||
// no-op too.
|
||||
ac.signal.dispatchEvent(new Event('abort'));
|
||||
|
||||
expect(cb).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import EmblaCarousel, { EmblaCarouselType } from 'embla-carousel';
|
||||
import { MockedObject, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { CarouselController } from '../../../src/utils/embla/carousel-controller';
|
||||
import AutoMediaLoadedInfo from '../../../src/utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info';
|
||||
import {
|
||||
MutationObserverMock,
|
||||
callMutationHandler,
|
||||
@@ -111,41 +110,23 @@ describe('CarouselController', () => {
|
||||
const children = createTestSlideNodes();
|
||||
const parent = createParent({ children: children });
|
||||
|
||||
const forceSelectListener = vi.fn();
|
||||
parent.addEventListener(
|
||||
'advanced-camera-card:carousel:force-select',
|
||||
forceSelectListener,
|
||||
);
|
||||
|
||||
const carousel = new CarouselController(createRoot(), parent);
|
||||
|
||||
carousel.selectSlide(4);
|
||||
|
||||
expect(getEmblaApi()?.scrollTo).toBeCalledWith(4, false);
|
||||
expect(forceSelectListener).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
detail: { index: 4, element: children[4] },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not select non-existent slide', () => {
|
||||
const children = createTestSlideNodes({ n: 10 });
|
||||
const parent = createParent({ children: children });
|
||||
|
||||
const forceSelectListener = vi.fn();
|
||||
parent.addEventListener(
|
||||
'advanced-camera-card:carousel:force-select',
|
||||
forceSelectListener,
|
||||
);
|
||||
|
||||
const carousel = new CarouselController(createRoot(), parent);
|
||||
|
||||
carousel.selectSlide(11);
|
||||
|
||||
// Should not call scrollTo or fire event because index is out of bounds
|
||||
// Should not call scrollTo because index is out of bounds.
|
||||
expect(getEmblaApi()?.scrollTo).not.toBeCalled();
|
||||
expect(forceSelectListener).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should dispatch select event', () => {
|
||||
@@ -191,7 +172,6 @@ describe('CarouselController', () => {
|
||||
const children = createTestSlideNodes({ n: 1 });
|
||||
const root = createRoot();
|
||||
const parent = createParent({ children: children });
|
||||
const plugins = [AutoMediaLoadedInfo()];
|
||||
|
||||
new CarouselController(root, parent, {
|
||||
direction: 'vertical',
|
||||
@@ -200,7 +180,6 @@ describe('CarouselController', () => {
|
||||
dragFree: true,
|
||||
loop: true,
|
||||
dragEnabled: false,
|
||||
plugins: plugins,
|
||||
textDirection: 'rtl',
|
||||
});
|
||||
|
||||
@@ -219,7 +198,7 @@ describe('CarouselController', () => {
|
||||
watchDrag: false,
|
||||
direction: 'rtl',
|
||||
},
|
||||
plugins,
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import AutoMediaLoadedInfo from '../../../../../src/utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info';
|
||||
import {
|
||||
dispatchExistingMediaLoadedInfoAsEvent,
|
||||
dispatchMediaUnloadedEvent,
|
||||
} from '../../../../../src/utils/media-info';
|
||||
import { createMediaLoadedInfo, createParent } from '../../../../test-utils';
|
||||
import {
|
||||
createEmblaApiInstance,
|
||||
createTestEmblaOptionHandler,
|
||||
createTestSlideNodes,
|
||||
} from '../../test-utils';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('AutoMediaLoadedInfo', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should construct', () => {
|
||||
const plugin = AutoMediaLoadedInfo();
|
||||
expect(plugin.name).toBe('autoMediaLoadedInfo');
|
||||
});
|
||||
|
||||
it('should destroy', () => {
|
||||
const plugin = AutoMediaLoadedInfo();
|
||||
const emblaApi = createEmblaApiInstance();
|
||||
plugin.init(emblaApi, createTestEmblaOptionHandler());
|
||||
plugin.destroy();
|
||||
|
||||
expect(emblaApi.off).toBeCalledWith('init', expect.anything());
|
||||
});
|
||||
|
||||
describe('should correctly propogate media load/unload depending on whether media is currently selected', () => {
|
||||
it.each([
|
||||
['loaded' as const, true],
|
||||
['unloaded' as const, true],
|
||||
['loaded' as const, false],
|
||||
['unloaded' as const, false],
|
||||
])('%s', (type: string, selected: boolean) => {
|
||||
const plugin = AutoMediaLoadedInfo();
|
||||
const children = createTestSlideNodes();
|
||||
const parent = createParent({ children: children });
|
||||
|
||||
const emblaApi = createEmblaApiInstance({
|
||||
containerNode: parent,
|
||||
slideNodes: children,
|
||||
selectedScrollSnap: selected ? 5 : 4,
|
||||
});
|
||||
plugin.init(emblaApi, createTestEmblaOptionHandler());
|
||||
|
||||
const mediaLoadedHandler = vi.fn();
|
||||
parent.addEventListener('advanced-camera-card:media:' + type, mediaLoadedHandler);
|
||||
if (type === 'loaded') {
|
||||
dispatchExistingMediaLoadedInfoAsEvent(children[5], createMediaLoadedInfo());
|
||||
} else if (type === 'unloaded') {
|
||||
dispatchMediaUnloadedEvent(children[5]);
|
||||
}
|
||||
|
||||
if (selected) {
|
||||
expect(mediaLoadedHandler).toBeCalled();
|
||||
} else {
|
||||
expect(mediaLoadedHandler).not.toBeCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('selecting a slide should dispatch a previously saved media loaded info if present', () => {
|
||||
const plugin = AutoMediaLoadedInfo();
|
||||
const children = createTestSlideNodes();
|
||||
const parent = createParent({ children: children });
|
||||
const emblaApi = createEmblaApiInstance({
|
||||
containerNode: parent,
|
||||
slideNodes: children,
|
||||
});
|
||||
plugin.init(emblaApi, createTestEmblaOptionHandler());
|
||||
|
||||
const mediaLoadedHandler = vi.fn();
|
||||
parent.addEventListener('advanced-camera-card:media:loaded', mediaLoadedHandler);
|
||||
dispatchExistingMediaLoadedInfoAsEvent(children[5], createMediaLoadedInfo());
|
||||
|
||||
vi.mocked(emblaApi.selectedScrollSnap).mockReturnValue(4);
|
||||
emblaApi
|
||||
.containerNode()
|
||||
.dispatchEvent(new Event('advanced-camera-card:carousel:force-select'));
|
||||
expect(mediaLoadedHandler).not.toBeCalled();
|
||||
|
||||
vi.mocked(emblaApi.selectedScrollSnap).mockReturnValue(5);
|
||||
emblaApi
|
||||
.containerNode()
|
||||
.dispatchEvent(new Event('advanced-camera-card:carousel:force-select'));
|
||||
expect(mediaLoadedHandler).toBeCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,25 +1,8 @@
|
||||
import { EmblaCarouselType, EmblaEventType } from 'embla-carousel';
|
||||
import { EngineType } from 'embla-carousel/components/Engine';
|
||||
import { LooseOptionsType } from 'embla-carousel/components/Options';
|
||||
import { OptionsHandlerType } from 'embla-carousel/components/OptionsHandler';
|
||||
import { merge } from 'lodash-es';
|
||||
import { vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
export const createTestEmblaOptionHandler = (): OptionsHandlerType => ({
|
||||
mergeOptions: <TypeA extends LooseOptionsType, TypeB extends LooseOptionsType>(
|
||||
optionsA: TypeA,
|
||||
optionsB?: TypeB,
|
||||
): TypeA => {
|
||||
return merge({}, optionsA, optionsB);
|
||||
},
|
||||
optionsAtMedia: <Type extends LooseOptionsType>(options: Type): Type => {
|
||||
return options;
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
optionsMediaQueries: (_optionsList: LooseOptionsType[]): MediaQueryList[] => [],
|
||||
});
|
||||
|
||||
export const callEmblaHandler = (
|
||||
emblaApi: EmblaCarouselType | null,
|
||||
eventName: EmblaEventType,
|
||||
|
||||
@@ -3,11 +3,8 @@ import { mock } from 'vitest-mock-extended';
|
||||
import { MediaLoadedCapabilities, MediaPlayer } from '../../src/types';
|
||||
import {
|
||||
createMediaLoadedInfo,
|
||||
dispatchExistingMediaLoadedInfoAsEvent,
|
||||
dispatchMediaLoadedEvent,
|
||||
dispatchMediaPauseEvent,
|
||||
dispatchMediaPlayEvent,
|
||||
dispatchMediaUnloadedEvent,
|
||||
dispatchMediaVolumeChangeEvent,
|
||||
isValidMediaLoadedInfo,
|
||||
} from '../../src/utils/media-info';
|
||||
@@ -83,74 +80,6 @@ describe('createMediaLoadedInfo', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('dispatchMediaLoadedEvent', () => {
|
||||
const options = {
|
||||
player: mock<MediaPlayer>(),
|
||||
capabilities: mock<MediaLoadedCapabilities>(),
|
||||
};
|
||||
|
||||
it('should dispatch', () => {
|
||||
const handler = vi.fn();
|
||||
const div = document.createElement('div');
|
||||
div.addEventListener('advanced-camera-card:media:loaded', handler);
|
||||
|
||||
// Need to write readonly properties.
|
||||
const img = document.createElement('img');
|
||||
Object.defineProperty(img, 'naturalWidth', { value: 10 });
|
||||
Object.defineProperty(img, 'naturalHeight', { value: 20 });
|
||||
|
||||
dispatchMediaLoadedEvent(div, img, options);
|
||||
expect(handler).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
detail: {
|
||||
width: 10,
|
||||
height: 20,
|
||||
...options,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not dispatch', () => {
|
||||
const handler = vi.fn();
|
||||
const div = document.createElement('div');
|
||||
div.addEventListener('advanced-camera-card:media:loaded', handler);
|
||||
|
||||
dispatchMediaLoadedEvent(div, div, options);
|
||||
expect(handler).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('dispatchExistingMediaLoadedInfoAsEvent', () => {
|
||||
it('should dispatch', () => {
|
||||
const handler = vi.fn();
|
||||
const div = document.createElement('div');
|
||||
div.addEventListener('advanced-camera-card:media:loaded', handler);
|
||||
const info = createTestMediaLoadedInfo();
|
||||
|
||||
dispatchExistingMediaLoadedInfoAsEvent(div, info);
|
||||
expect(handler).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
detail: info,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('dispatchMediaUnloadedEvent', () => {
|
||||
it('should dispatch', () => {
|
||||
const handler = vi.fn();
|
||||
const div = document.createElement('div');
|
||||
div.addEventListener('advanced-camera-card:media:unloaded', handler);
|
||||
|
||||
dispatchMediaUnloadedEvent(div);
|
||||
expect(handler).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('dispatchMediaVolumeChangeEvent', () => {
|
||||
it('should dispatch', () => {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { QueryResults } from '../../src/view/query-results';
|
||||
import {
|
||||
getViewTargetID,
|
||||
IMAGE_VIEW_TARGET_ID_SENTINEL,
|
||||
} from '../../src/view/target-id';
|
||||
import { createView, generateViewMediaArray } from '../test-utils';
|
||||
|
||||
describe('getViewTargetID', () => {
|
||||
describe('live', () => {
|
||||
it('should return the base camera ID', () => {
|
||||
const view = createView({ view: 'live', camera: 'camera.front_door' });
|
||||
expect(getViewTargetID(view)).toBe('camera.front_door');
|
||||
});
|
||||
|
||||
it('should return the base camera ID even though substream override is active', () => {
|
||||
// Substream is a playback-layer detail; targetID stays the base camera
|
||||
// throughout substream toggles so consumers above the provider don't
|
||||
// need to know about substreams.
|
||||
const view = createView({
|
||||
view: 'live',
|
||||
camera: 'camera.front_door',
|
||||
context: {
|
||||
live: {
|
||||
overrides: new Map([['camera.front_door', 'camera.front_door_lq']]),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(getViewTargetID(view)).toBe('camera.front_door');
|
||||
});
|
||||
});
|
||||
|
||||
describe('viewer', () => {
|
||||
it('should return the selected media ID', () => {
|
||||
const media = generateViewMediaArray({ count: 3 });
|
||||
const view = createView({
|
||||
view: 'media',
|
||||
camera: 'camera.front_door',
|
||||
queryResults: new QueryResults({ results: media, selectedIndex: 1 }),
|
||||
});
|
||||
expect(getViewTargetID(view)).toBe(media[1].getID());
|
||||
});
|
||||
|
||||
it('should return null when there is no selection', () => {
|
||||
const view = createView({
|
||||
view: 'media',
|
||||
camera: 'camera.front_door',
|
||||
});
|
||||
expect(getViewTargetID(view)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('image', () => {
|
||||
it('should return the sentinel', () => {
|
||||
const view = createView({ view: 'image', camera: 'camera.front_door' });
|
||||
expect(getViewTargetID(view)).toBe(IMAGE_VIEW_TARGET_ID_SENTINEL);
|
||||
});
|
||||
});
|
||||
|
||||
describe('other view types', () => {
|
||||
it.each(['clips', 'snapshots', 'recordings', 'recording'] as const)(
|
||||
'should return null for %s',
|
||||
(viewName) => {
|
||||
const view = createView({ view: viewName, camera: 'camera.front_door' });
|
||||
expect(getViewTargetID(view)).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user