Files

521 lines
18 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MediaLoadedInfoManager } from '../../src/card-controller/media-info-manager';
import { MediaLoadedInfoSourceController } from '../../src/components-lib/media-loaded-info-source-controller';
import type { MediaLoadedInfoEventDetail } from '../../src/types';
import {
createCardAPI,
createLitElement,
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).toHaveBeenCalledWith({
mediaLoadedInfo: null,
});
});
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.setSelected('target-1');
vi.clearAllMocks();
manager.set(info, owner);
expect(manager.has()).toBeTruthy();
expect(manager.get()).toBe(info);
expect(api.getConditionStateManager().setState).toHaveBeenCalledWith({
mediaLoadedInfo: info,
});
expect(api.getStyleManager().setExpandedMode).toHaveBeenCalled();
expect(api.getCardElementManager().update).toHaveBeenCalled();
});
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.toHaveBeenCalled();
expect(api.getStyleManager().setExpandedMode).not.toHaveBeenCalled();
expect(api.getCardElementManager().update).not.toHaveBeenCalled();
manager.setSelected('target-1');
expect(manager.has()).toBeTruthy();
expect(manager.get()).toBe(info);
expect(api.getConditionStateManager().setState).toHaveBeenCalledWith({
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.toHaveBeenCalled();
expect(api.getStyleManager().setExpandedMode).not.toHaveBeenCalled();
expect(api.getCardElementManager().update).not.toHaveBeenCalled();
});
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();
});
});
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(info1, owner1);
manager.set(info2, owner2);
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.toHaveBeenCalled();
});
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).toHaveBeenCalledWith({
mediaLoadedInfo: null,
});
});
});
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.setSelected('target-1');
manager.set(info, owner);
expect(manager.getLastKnown()).toBe(info);
});
it('should return null when nothing is selected', () => {
const api = createCardAPI();
const manager = new MediaLoadedInfoManager(api);
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).toHaveBeenCalledWith({
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.toHaveBeenCalled();
});
});
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).toHaveBeenCalledWith({
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.toHaveBeenCalled();
});
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.toHaveBeenCalled();
});
});
// The play/pause, mute and screenshot menu buttons render only while there is
// active info for the selected target. These tests wire the real source
// controller to the manager the way the card does and pin the invariants that
// keep the buttons stable: a transient host disconnect/reconnect under the
// same target restores the info, while a genuine target change drops it.
// See: https://github.com/dermotduffy/advanced-camera-card/issues/2563
describe('active info stability across host disconnect and reconnect', () => {
const wire = (target: { id: string | null }) => {
const api = createCardAPI();
const manager = new MediaLoadedInfoManager(api);
const host = createLitElement();
host.addEventListener('advanced-camera-card:media:loaded', (ev) =>
manager.handleLoadEvent(ev as CustomEvent<MediaLoadedInfoEventDetail>),
);
const source = new MediaLoadedInfoSourceController(host, {
getTargetID: () => target.id,
});
return { api, manager, source };
};
it('should restore the active info when the player host transiently disconnects and reconnects', () => {
const target = { id: 'media-1' as string | null };
const { api, manager, source } = wire(target);
manager.setSelected('media-1');
source.set(createMediaLoadedInfo());
expect(manager.has()).toBe(true);
// The host disconnects and reconnects under the same targetID: the info
// is gone for an instant, then replayed on reconnect.
source.hostDisconnected();
expect(manager.has()).toBe(false);
source.hostConnected();
expect(manager.has()).toBe(true);
expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith({
mediaLoadedInfo: expect.objectContaining({ targetID: 'media-1' }),
});
});
it('should not restore the active info when the target genuinely changes', () => {
const target = { id: 'media-1' as string | null };
const { manager, source } = wire(target);
manager.setSelected('media-1');
source.set(createMediaLoadedInfo());
expect(manager.has()).toBe(true);
// The target actually changes across the disconnect/reconnect, so the
// reconnect discards the stale cache instead of replaying it -- the
// restore must not keep info alive when media really unloads.
target.id = 'media-2';
source.hostDisconnected();
source.hostConnected();
manager.setSelected('media-2');
expect(manager.has()).toBe(false);
});
});
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();
});
});
describe('subscribe', () => {
it('should notify of a fresh load, even for a non-selected target', () => {
const api = createCardAPI();
const manager = new MediaLoadedInfoManager(api);
const owner = document.createElement('div');
const info = createMediaLoadedInfo({ targetID: 'target-1' });
const listener = vi.fn();
manager.setSelected('target-2');
manager.subscribe(listener);
manager.set(info, owner);
expect(listener).toHaveBeenCalledWith({
type: 'load',
targetID: 'target-1',
info,
});
});
it('should notify of selection changes', () => {
const api = createCardAPI();
const manager = new MediaLoadedInfoManager(api);
const listener = vi.fn();
manager.subscribe(listener);
manager.setSelected('target-1');
manager.setSelected(null);
expect(listener).toHaveBeenCalledWith({ type: 'select', targetID: 'target-1' });
expect(listener).toHaveBeenCalledWith({ type: 'select', targetID: null });
});
it('should notify of an unload when a target is retired', () => {
const api = createCardAPI();
const manager = new MediaLoadedInfoManager(api);
const source = document.createElement('div');
const info = createMediaLoadedInfo({ targetID: 'target-1' });
const ac = new AbortController();
const listener = vi.fn();
manager.subscribe(listener);
manager.handleLoadEvent(
createMediaLoadedInfoEvent({ source, info, signal: ac.signal }),
);
ac.abort();
expect(listener).toHaveBeenCalledWith({ type: 'unload', targetID: 'target-1' });
});
it('should notify of an unload for each active target on clear', () => {
const api = createCardAPI();
const manager = new MediaLoadedInfoManager(api);
const owner = document.createElement('div');
const listener = vi.fn();
manager.set(createMediaLoadedInfo({ targetID: 'target-1' }), owner);
manager.set(createMediaLoadedInfo({ targetID: 'target-2' }), owner);
manager.subscribe(listener);
manager.clear();
expect(listener).toHaveBeenCalledWith({ type: 'unload', targetID: 'target-1' });
expect(listener).toHaveBeenCalledWith({ type: 'unload', targetID: 'target-2' });
});
it('should notify of unloads and a deselect on initialize', () => {
const api = createCardAPI();
const manager = new MediaLoadedInfoManager(api);
const owner = document.createElement('div');
const listener = vi.fn();
manager.setSelected('target-1');
manager.set(createMediaLoadedInfo({ targetID: 'target-1' }), owner);
manager.subscribe(listener);
manager.initialize();
expect(listener).toHaveBeenCalledWith({ type: 'unload', targetID: 'target-1' });
expect(listener).toHaveBeenCalledWith({ type: 'select', targetID: null });
});
it('should stop notifying after unsubscribe', () => {
const api = createCardAPI();
const manager = new MediaLoadedInfoManager(api);
const owner = document.createElement('div');
const info = createMediaLoadedInfo({ targetID: 'target-1' });
const listener = vi.fn();
manager.subscribe(listener)();
manager.set(info, owner);
expect(listener).not.toHaveBeenCalled();
});
});
});