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).toBeCalledWith({ 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).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(); }); }); 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.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, }); }); }); 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).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(); }); }); // 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), ); 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).toBeCalledWith({ type: 'load', targetID: 'target-1', info, cached: false, }); }); it('should mark a cached load', () => { 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, true); expect(listener).toBeCalledWith({ type: 'load', targetID: 'target-1', info, cached: true, }); }); 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).toBeCalledWith({ type: 'select', targetID: 'target-1' }); expect(listener).toBeCalledWith({ 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).toBeCalledWith({ 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).toBeCalledWith({ type: 'unload', targetID: 'target-1' }); expect(listener).toBeCalledWith({ 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).toBeCalledWith({ type: 'unload', targetID: 'target-1' }); expect(listener).toBeCalledWith({ 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.toBeCalled(); }); }); });