refactor: Refactor media loading manager for improved robustness (#2464)
This commit is contained in:
committed by
dermotduffy
parent
47bcce93d3
commit
bc366626f1
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user