refactor: Refactor media loading manager for improved robustness (#2464)

This commit is contained in:
Dermot Duffy authored and dermotduffy committed 2026-06-30 17:45:12 -07:00
1 parent 47bcce93d3
commit bc366626f1
51 files changed
+1897 -1128

No files matched your search

+335 -34
View File
@@ -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
View File
@@ -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 => {
+39
View File
@@ -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);
});
});
+2 -23
View File
@@ -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();
});
});
-17
View File
@@ -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,
-71
View File
@@ -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', () => {
+69
View File
@@ -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();
},
);
});
});