feat: Automatically recover from frozen live streams (#2569)
- Closes: #2099
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { StateWatcherSubscriptionInterface } from '../../../../../src/card-controller/hass/state-watcher';
|
||||
import {
|
||||
EntityAvailabilityDetector,
|
||||
LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS,
|
||||
} from '../../../../../src/components-lib/live/liveness/detectors/entity-availability';
|
||||
import type { HomeAssistant } from '../../../../../src/ha/types';
|
||||
import {
|
||||
callStateWatcherCallback,
|
||||
createHASS,
|
||||
createStateEntity,
|
||||
} from '../../../../test-utils';
|
||||
|
||||
const ENTITY = 'camera.office';
|
||||
const GRACE_MS = LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS * 1000;
|
||||
|
||||
const setup = (options?: {
|
||||
alwaysError?: boolean;
|
||||
entity?: string | null;
|
||||
initialState?: string;
|
||||
}) => {
|
||||
const entity = options?.entity === undefined ? ENTITY : options.entity;
|
||||
let currentEntity = entity;
|
||||
const hass: HomeAssistant = createHASS(
|
||||
entity
|
||||
? { [entity]: createStateEntity({ state: options?.initialState ?? 'idle' }) }
|
||||
: {},
|
||||
);
|
||||
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
|
||||
stateWatcher.subscribe.mockReturnValue(true);
|
||||
const onChange = vi.fn();
|
||||
|
||||
const detector = new EntityAvailabilityDetector({
|
||||
getHASS: () => hass,
|
||||
getStateWatcher: () => stateWatcher,
|
||||
getCameraEntity: () => currentEntity,
|
||||
isAlwaysError: () => options?.alwaysError ?? false,
|
||||
onChange,
|
||||
});
|
||||
|
||||
const setEntityState = (state: string): void => {
|
||||
if (entity) {
|
||||
hass.states[entity] = createStateEntity({ state });
|
||||
}
|
||||
};
|
||||
const setCameraEntity = (value: string | null): void => {
|
||||
currentEntity = value;
|
||||
};
|
||||
const fireStateChange = (state: string): void =>
|
||||
callStateWatcherCallback(stateWatcher, {
|
||||
entityID: entity ?? ENTITY,
|
||||
newState: createStateEntity({ state }),
|
||||
});
|
||||
|
||||
return {
|
||||
detector,
|
||||
onChange,
|
||||
stateWatcher,
|
||||
setEntityState,
|
||||
setCameraEntity,
|
||||
fireStateChange,
|
||||
};
|
||||
};
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('EntityAvailabilityDetector', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should subscribe to the camera entity and start unknown', () => {
|
||||
const { detector, stateWatcher, onChange } = setup();
|
||||
|
||||
detector.subscribe();
|
||||
|
||||
expect(stateWatcher.subscribe).toHaveBeenCalledWith(expect.any(Function), [ENTITY]);
|
||||
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not subscribe when the camera has no entity', () => {
|
||||
const { detector, stateWatcher } = setup({ entity: null });
|
||||
|
||||
detector.subscribe();
|
||||
|
||||
expect(stateWatcher.subscribe).not.toHaveBeenCalled();
|
||||
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
|
||||
});
|
||||
|
||||
it('should report not live after the grace window when the entity stays unavailable', () => {
|
||||
const { detector, onChange, fireStateChange } = setup();
|
||||
detector.subscribe();
|
||||
|
||||
fireStateChange('unavailable');
|
||||
|
||||
// Still live during the grace window.
|
||||
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
|
||||
vi.advanceTimersByTime(GRACE_MS);
|
||||
|
||||
expect(detector.getVerdict()).toEqual({
|
||||
state: 'not_live',
|
||||
authority: 'indirect',
|
||||
renderPlaceholder: true,
|
||||
reason: 'entity_unavailable',
|
||||
});
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should tolerate an unavailable blip shorter than the grace window', () => {
|
||||
const { detector, onChange, fireStateChange } = setup();
|
||||
detector.subscribe();
|
||||
|
||||
fireStateChange('unavailable');
|
||||
vi.advanceTimersByTime(GRACE_MS - 1000);
|
||||
fireStateChange('streaming');
|
||||
vi.advanceTimersByTime(GRACE_MS);
|
||||
|
||||
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should report not live immediately when always_error is set', () => {
|
||||
const { detector, onChange, fireStateChange } = setup({
|
||||
alwaysError: true,
|
||||
});
|
||||
detector.subscribe();
|
||||
|
||||
fireStateChange('unavailable');
|
||||
|
||||
expect(detector.getVerdict()).toEqual({
|
||||
state: 'not_live',
|
||||
authority: 'hard',
|
||||
renderPlaceholder: true,
|
||||
reason: 'entity_unavailable',
|
||||
});
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should act on the state from the event, not a lagging wrapper hass', () => {
|
||||
const { detector, setEntityState, fireStateChange } = setup({ alwaysError: true });
|
||||
detector.subscribe();
|
||||
|
||||
// The wrapper's hass (what getHASS reads) has not yet propagated the change,
|
||||
// so it still reports the pre-change available state, while the event carries
|
||||
// the fresh unavailable state. The detector must act on the event.
|
||||
setEntityState('streaming');
|
||||
fireStateChange('unavailable');
|
||||
|
||||
expect(detector.getVerdict()).toEqual({
|
||||
state: 'not_live',
|
||||
authority: 'hard',
|
||||
renderPlaceholder: true,
|
||||
reason: 'entity_unavailable',
|
||||
});
|
||||
});
|
||||
|
||||
it('should report live again when the entity returns', () => {
|
||||
const { detector, onChange, fireStateChange } = setup();
|
||||
detector.subscribe();
|
||||
fireStateChange('unavailable');
|
||||
vi.advanceTimersByTime(GRACE_MS);
|
||||
expect(detector.getVerdict().state).toBe('not_live');
|
||||
onChange.mockClear();
|
||||
|
||||
fireStateChange('streaming');
|
||||
|
||||
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should retain the verdict on unsubscribe and stop watching', () => {
|
||||
const { detector, stateWatcher, fireStateChange } = setup();
|
||||
detector.subscribe();
|
||||
fireStateChange('unavailable');
|
||||
vi.advanceTimersByTime(GRACE_MS);
|
||||
|
||||
detector.unsubscribe();
|
||||
|
||||
expect(stateWatcher.unsubscribe).toHaveBeenCalled();
|
||||
|
||||
// Verdict retained so a reconnect resumes where it left off.
|
||||
expect(detector.getVerdict()).toEqual({
|
||||
state: 'not_live',
|
||||
authority: 'indirect',
|
||||
renderPlaceholder: true,
|
||||
reason: 'entity_unavailable',
|
||||
});
|
||||
});
|
||||
|
||||
it('should cancel the pending grace timer on unsubscribe', () => {
|
||||
const { detector, onChange, fireStateChange } = setup();
|
||||
detector.subscribe();
|
||||
fireStateChange('unavailable');
|
||||
|
||||
detector.unsubscribe();
|
||||
vi.advanceTimersByTime(GRACE_MS);
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
|
||||
});
|
||||
|
||||
it('should discard the verdict on reset', () => {
|
||||
const { detector, setEntityState, fireStateChange } = setup();
|
||||
detector.subscribe();
|
||||
fireStateChange('unavailable');
|
||||
vi.advanceTimersByTime(GRACE_MS);
|
||||
expect(detector.getVerdict().state).toBe('not_live');
|
||||
|
||||
// Re-point at the (now available) entity from a fresh state.
|
||||
setEntityState('streaming');
|
||||
detector.reset();
|
||||
|
||||
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
|
||||
});
|
||||
|
||||
it('should do nothing on reset before subscribe', () => {
|
||||
const { detector, stateWatcher } = setup();
|
||||
|
||||
detector.reset();
|
||||
|
||||
expect(stateWatcher.subscribe).not.toHaveBeenCalled();
|
||||
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
|
||||
});
|
||||
|
||||
it('should drop the subscription when the camera entity is removed', () => {
|
||||
const { detector, stateWatcher, setCameraEntity } = setup();
|
||||
detector.subscribe();
|
||||
|
||||
setCameraEntity(null);
|
||||
detector.reset();
|
||||
|
||||
expect(stateWatcher.unsubscribe).toHaveBeenCalled();
|
||||
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
|
||||
});
|
||||
|
||||
it('should not restart the grace timer while it is already running', () => {
|
||||
const { detector, onChange, fireStateChange } = setup();
|
||||
detector.subscribe();
|
||||
|
||||
fireStateChange('unavailable');
|
||||
|
||||
// Still unavailable, grace timer already running
|
||||
fireStateChange('unavailable');
|
||||
vi.advanceTimersByTime(GRACE_MS);
|
||||
|
||||
expect(detector.getVerdict()).toEqual({
|
||||
state: 'not_live',
|
||||
authority: 'indirect',
|
||||
renderPlaceholder: true,
|
||||
reason: 'entity_unavailable',
|
||||
});
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should stay not live on further unavailable events after the grace window', () => {
|
||||
const { detector, onChange, fireStateChange } = setup();
|
||||
detector.subscribe();
|
||||
fireStateChange('unavailable');
|
||||
vi.advanceTimersByTime(GRACE_MS);
|
||||
onChange.mockClear();
|
||||
|
||||
// Still unavailable, verdict already not live
|
||||
fireStateChange('unavailable');
|
||||
|
||||
expect(detector.getVerdict()).toEqual({
|
||||
state: 'not_live',
|
||||
authority: 'indirect',
|
||||
renderPlaceholder: true,
|
||||
reason: 'entity_unavailable',
|
||||
});
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { MediaPlayerLivenessDetector } from '../../../../../src/components-lib/live/liveness/detectors/media-player-liveness';
|
||||
import type { LivenessCallback, MediaPlayerController } from '../../../../../src/types';
|
||||
import {
|
||||
callIntersectionHandler,
|
||||
createMediaLoadedInfo,
|
||||
createMediaLoadedInfoEvent,
|
||||
IntersectionObserverMock,
|
||||
} from '../../../../test-utils';
|
||||
|
||||
const createPlayer = (): {
|
||||
player: MediaPlayerController;
|
||||
unsubscribe: ReturnType<typeof vi.fn>;
|
||||
fireMediaPlayerLiveness: (isLive: boolean) => void;
|
||||
} => {
|
||||
const player = mock<MediaPlayerController>();
|
||||
const unsubscribe = vi.fn();
|
||||
let captured: LivenessCallback | null = null;
|
||||
player.subscribeLiveness = vi.fn((callback: LivenessCallback) => {
|
||||
captured = callback;
|
||||
return unsubscribe;
|
||||
});
|
||||
return {
|
||||
player,
|
||||
unsubscribe,
|
||||
fireMediaPlayerLiveness: (isLive: boolean) => captured?.(isLive),
|
||||
};
|
||||
};
|
||||
|
||||
const setup = () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.append(host);
|
||||
const onChange = vi.fn();
|
||||
const detector = new MediaPlayerLivenessDetector(host, onChange);
|
||||
|
||||
const loadMedia = (player?: MediaPlayerController, signal?: AbortSignal): void => {
|
||||
host.dispatchEvent(
|
||||
createMediaLoadedInfoEvent({
|
||||
info: createMediaLoadedInfo({ mediaPlayerController: player }),
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return { host, onChange, detector, loadMedia };
|
||||
};
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('MediaPlayerLivenessDetector', () => {
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal('IntersectionObserver', IntersectionObserverMock);
|
||||
vi.spyOn(global.document, 'addEventListener');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
value: 'visible',
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should start unknown', () => {
|
||||
const { detector } = setup();
|
||||
|
||||
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
|
||||
});
|
||||
|
||||
it('should watch liveness only once both visible and media are loaded', async () => {
|
||||
const { detector, loadMedia } = setup();
|
||||
const { player } = createPlayer();
|
||||
detector.subscribe();
|
||||
|
||||
// Media loaded but not yet visible -> not watched.
|
||||
loadMedia(player);
|
||||
expect(player.subscribeLiveness).not.toHaveBeenCalled();
|
||||
|
||||
// Becoming visible (initial intersection baseline, via emitInitial) -> watch.
|
||||
await callIntersectionHandler(true);
|
||||
expect(player.subscribeLiveness).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should report a stall as not live with a reconnecting placeholder', async () => {
|
||||
const { detector, onChange, loadMedia } = setup();
|
||||
const { player, fireMediaPlayerLiveness } = createPlayer();
|
||||
detector.subscribe();
|
||||
loadMedia(player);
|
||||
await callIntersectionHandler(true);
|
||||
|
||||
fireMediaPlayerLiveness(false);
|
||||
|
||||
expect(detector.getVerdict()).toEqual({
|
||||
state: 'not_live',
|
||||
authority: 'direct',
|
||||
renderPlaceholder: true,
|
||||
reason: 'stalled',
|
||||
});
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should report live again when the stream recovers', async () => {
|
||||
const { detector, loadMedia } = setup();
|
||||
const { player, fireMediaPlayerLiveness } = createPlayer();
|
||||
detector.subscribe();
|
||||
loadMedia(player);
|
||||
await callIntersectionHandler(true);
|
||||
|
||||
fireMediaPlayerLiveness(false);
|
||||
fireMediaPlayerLiveness(true);
|
||||
|
||||
expect(detector.getVerdict()).toEqual({ state: 'live', authority: 'direct' });
|
||||
});
|
||||
|
||||
it('should not re-notify when the liveness verdict is unchanged', async () => {
|
||||
const { detector, onChange, loadMedia } = setup();
|
||||
const { player, fireMediaPlayerLiveness } = createPlayer();
|
||||
detector.subscribe();
|
||||
loadMedia(player);
|
||||
await callIntersectionHandler(true);
|
||||
|
||||
fireMediaPlayerLiveness(false);
|
||||
fireMediaPlayerLiveness(false);
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should stop watching when it becomes not visible', async () => {
|
||||
const { detector, loadMedia } = setup();
|
||||
const { player, unsubscribe } = createPlayer();
|
||||
detector.subscribe();
|
||||
loadMedia(player);
|
||||
await callIntersectionHandler(true);
|
||||
|
||||
await callIntersectionHandler(false);
|
||||
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should report unknown after going off-screen while confirmed live', async () => {
|
||||
const { detector, onChange, loadMedia } = setup();
|
||||
const { player, fireMediaPlayerLiveness } = createPlayer();
|
||||
detector.subscribe();
|
||||
loadMedia(player);
|
||||
await callIntersectionHandler(true);
|
||||
|
||||
fireMediaPlayerLiveness(true);
|
||||
expect(detector.getVerdict()).toEqual({ state: 'live', authority: 'direct' });
|
||||
onChange.mockClear();
|
||||
|
||||
await callIntersectionHandler(false);
|
||||
|
||||
// Off-screen: no current frame evidence, so drop the stale `live` to
|
||||
// `unknown` rather than suppressing the entity proxy with stale confidence.
|
||||
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not watch a player without the liveness capability', async () => {
|
||||
const { detector, onChange, loadMedia } = setup();
|
||||
const player = mock<MediaPlayerController>();
|
||||
player.subscribeLiveness = undefined;
|
||||
detector.subscribe();
|
||||
|
||||
loadMedia(player);
|
||||
await callIntersectionHandler(true);
|
||||
|
||||
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should hold the not-live verdict when the frozen media unmounts', async () => {
|
||||
const { detector, loadMedia } = setup();
|
||||
const { player, unsubscribe, fireMediaPlayerLiveness } = createPlayer();
|
||||
const abort = new AbortController();
|
||||
detector.subscribe();
|
||||
loadMedia(player, abort.signal);
|
||||
await callIntersectionHandler(true);
|
||||
fireMediaPlayerLiveness(false);
|
||||
|
||||
// The placeholder unmounts the frozen stream -> the media aborts.
|
||||
abort.abort();
|
||||
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Verdict held, not reset to live -- recovery is the throttled remount.
|
||||
expect(detector.getVerdict()).toEqual({
|
||||
state: 'not_live',
|
||||
authority: 'direct',
|
||||
renderPlaceholder: true,
|
||||
reason: 'stalled',
|
||||
});
|
||||
});
|
||||
|
||||
it('should drop a confirmed-live verdict to unknown when the media unmounts externally', async () => {
|
||||
const { detector, loadMedia } = setup();
|
||||
const { player, fireMediaPlayerLiveness } = createPlayer();
|
||||
const abort = new AbortController();
|
||||
detector.subscribe();
|
||||
loadMedia(player, abort.signal);
|
||||
|
||||
await callIntersectionHandler(true);
|
||||
fireMediaPlayerLiveness(true);
|
||||
|
||||
expect(detector.getVerdict()).toEqual({ state: 'live', authority: 'direct' });
|
||||
|
||||
// The media unmounts while confirmed live (an ordinary unload, not our own
|
||||
// not-live placeholder). Drop the stale `live` to `unknown` so it does not
|
||||
// suppress other detectors (e.g. entity availability).
|
||||
abort.abort();
|
||||
|
||||
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
|
||||
});
|
||||
|
||||
it('should discard the verdict on reset', async () => {
|
||||
const { detector, loadMedia } = setup();
|
||||
const { player, unsubscribe, fireMediaPlayerLiveness } = createPlayer();
|
||||
detector.subscribe();
|
||||
loadMedia(player);
|
||||
|
||||
await callIntersectionHandler(true);
|
||||
fireMediaPlayerLiveness(false);
|
||||
|
||||
detector.reset();
|
||||
|
||||
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should tear down the watch and stop listening on unsubscribe', async () => {
|
||||
const { detector, loadMedia } = setup();
|
||||
const first = createPlayer();
|
||||
detector.subscribe();
|
||||
loadMedia(first.player);
|
||||
|
||||
await callIntersectionHandler(true);
|
||||
|
||||
detector.unsubscribe();
|
||||
expect(first.unsubscribe).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A later media:loaded is ignored (listener removed).
|
||||
const second = createPlayer();
|
||||
loadMedia(second.player);
|
||||
expect(second.player.subscribeLiveness).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not watch when media loads without a player controller', async () => {
|
||||
const { detector, onChange, loadMedia } = setup();
|
||||
detector.subscribe();
|
||||
|
||||
loadMedia(undefined);
|
||||
await callIntersectionHandler(true);
|
||||
|
||||
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should ignore a stale media abort after a newer load', async () => {
|
||||
const { detector, loadMedia } = setup();
|
||||
const first = createPlayer();
|
||||
const second = createPlayer();
|
||||
const abortFirst = new AbortController();
|
||||
detector.subscribe();
|
||||
|
||||
loadMedia(first.player, abortFirst.signal);
|
||||
loadMedia(second.player);
|
||||
await callIntersectionHandler(true);
|
||||
|
||||
// The stale abort of the first load must not drop the current player.
|
||||
abortFirst.abort();
|
||||
second.fireMediaPlayerLiveness(false);
|
||||
|
||||
expect(detector.getVerdict()).toEqual({
|
||||
state: 'not_live',
|
||||
authority: 'direct',
|
||||
renderPlaceholder: true,
|
||||
reason: 'stalled',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ProviderErrorDetector } from '../../../../../src/components-lib/live/liveness/detectors/provider-error';
|
||||
|
||||
const LIVE_ERROR_EVENT = 'advanced-camera-card:live:error';
|
||||
|
||||
const createHostInDocument = (): HTMLElement => {
|
||||
const host = document.createElement('div');
|
||||
document.body.append(host);
|
||||
return host;
|
||||
};
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('ProviderErrorDetector', () => {
|
||||
it('should start unknown', () => {
|
||||
const detector = new ProviderErrorDetector(document.createElement('div'), vi.fn());
|
||||
|
||||
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
|
||||
});
|
||||
|
||||
it('should report not live on a provider error, leaving the provider mounted', () => {
|
||||
const host = createHostInDocument();
|
||||
const onChange = vi.fn();
|
||||
const detector = new ProviderErrorDetector(host, onChange);
|
||||
detector.subscribe();
|
||||
|
||||
host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true }));
|
||||
|
||||
// not_live but no renderPlaceholder: the provider renders its own error.
|
||||
expect(detector.getVerdict()).toEqual({
|
||||
state: 'not_live',
|
||||
authority: 'hard',
|
||||
reason: 'playback_error',
|
||||
});
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should notify only on the transition to not live', () => {
|
||||
const host = createHostInDocument();
|
||||
const onChange = vi.fn();
|
||||
const detector = new ProviderErrorDetector(host, onChange);
|
||||
detector.subscribe();
|
||||
|
||||
host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true }));
|
||||
host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should stop the error from propagating past the host', () => {
|
||||
const host = createHostInDocument();
|
||||
const parentListener = vi.fn();
|
||||
document.body.addEventListener(LIVE_ERROR_EVENT, parentListener);
|
||||
const detector = new ProviderErrorDetector(host, vi.fn());
|
||||
detector.subscribe();
|
||||
|
||||
host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true }));
|
||||
|
||||
expect(parentListener).not.toHaveBeenCalled();
|
||||
|
||||
document.body.removeEventListener(LIVE_ERROR_EVENT, parentListener);
|
||||
});
|
||||
|
||||
it('should discard the verdict on reset', () => {
|
||||
const host = createHostInDocument();
|
||||
const detector = new ProviderErrorDetector(host, vi.fn());
|
||||
detector.subscribe();
|
||||
host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true }));
|
||||
expect(detector.getVerdict().state).toBe('not_live');
|
||||
|
||||
detector.reset();
|
||||
|
||||
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
|
||||
});
|
||||
|
||||
it('should ignore errors after unsubscribe', () => {
|
||||
const host = createHostInDocument();
|
||||
const onChange = vi.fn();
|
||||
const detector = new ProviderErrorDetector(host, onChange);
|
||||
detector.subscribe();
|
||||
detector.unsubscribe();
|
||||
|
||||
host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true }));
|
||||
|
||||
expect(detector.getVerdict().state).toBe('unknown');
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,348 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { Camera } from '../../../../src/camera-manager/camera';
|
||||
import type { StateWatcherSubscriptionInterface } from '../../../../src/card-controller/hass/state-watcher';
|
||||
import { LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS } from '../../../../src/components-lib/live/liveness/detectors/entity-availability';
|
||||
import { StreamLivenessController } from '../../../../src/components-lib/live/liveness/stream-liveness-controller';
|
||||
import type { LivenessCallback, MediaPlayerController } from '../../../../src/types';
|
||||
import {
|
||||
callIntersectionHandler,
|
||||
callStateWatcherCallback,
|
||||
createCameraConfig,
|
||||
createHASS,
|
||||
createLitElement,
|
||||
createMediaLoadedInfo,
|
||||
createMediaLoadedInfoEvent,
|
||||
createStateEntity,
|
||||
IntersectionObserverMock,
|
||||
} from '../../../test-utils';
|
||||
|
||||
const LIVE_ERROR_EVENT = 'advanced-camera-card:live:error';
|
||||
const ISSUE_TRIGGER_EVENT = 'advanced-camera-card:issue:trigger';
|
||||
|
||||
const setup = (options?: { targetID?: string | null }) => {
|
||||
const host = createLitElement();
|
||||
document.body.append(host);
|
||||
|
||||
const controller = new StreamLivenessController(host, {
|
||||
getTargetID: () =>
|
||||
options?.targetID === undefined ? 'camera.office' : options.targetID,
|
||||
getHASS: () => null,
|
||||
getCamera: () => null,
|
||||
getStateWatcher: () => null,
|
||||
});
|
||||
|
||||
const issueTriggers: unknown[] = [];
|
||||
host.addEventListener(ISSUE_TRIGGER_EVENT, (ev) =>
|
||||
issueTriggers.push((ev as CustomEvent).detail),
|
||||
);
|
||||
|
||||
const failViaProviderError = (): void => {
|
||||
host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true }));
|
||||
};
|
||||
|
||||
return { host, controller, issueTriggers, failViaProviderError };
|
||||
};
|
||||
|
||||
const createPlayer = (): {
|
||||
player: MediaPlayerController;
|
||||
fireMediaPlayerLiveness: (isLive: boolean) => void;
|
||||
} => {
|
||||
const player = mock<MediaPlayerController>();
|
||||
let captured: LivenessCallback | null = null;
|
||||
player.subscribeLiveness = vi.fn((callback: LivenessCallback) => {
|
||||
captured = callback;
|
||||
return vi.fn();
|
||||
});
|
||||
return { player, fireMediaPlayerLiveness: (isLive: boolean) => captured?.(isLive) };
|
||||
};
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('StreamLivenessController', () => {
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal('IntersectionObserver', IntersectionObserverMock);
|
||||
vi.spyOn(global.document, 'addEventListener');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
value: 'visible',
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should register itself as a controller on the host', () => {
|
||||
const { host, controller } = setup();
|
||||
|
||||
expect(host.addController).toHaveBeenCalledWith(controller);
|
||||
});
|
||||
|
||||
it('should be live with no placeholder by default', () => {
|
||||
const { controller } = setup();
|
||||
|
||||
expect(controller.isLive()).toBe(true);
|
||||
expect(controller.getPlaceholder()).toBeNull();
|
||||
});
|
||||
|
||||
it('should aggregate a detector losing liveness only after host connect', () => {
|
||||
const { controller, failViaProviderError } = setup();
|
||||
|
||||
// Not yet connected -> the provider-error listener is not attached.
|
||||
failViaProviderError();
|
||||
expect(controller.isLive()).toBe(true);
|
||||
|
||||
controller.hostConnected();
|
||||
failViaProviderError();
|
||||
|
||||
expect(controller.isLive()).toBe(false);
|
||||
});
|
||||
|
||||
it('should fire the media_unavailable issue and request an update when liveness is lost', () => {
|
||||
const { host, controller, issueTriggers, failViaProviderError } = setup();
|
||||
controller.hostConnected();
|
||||
|
||||
failViaProviderError();
|
||||
|
||||
expect(issueTriggers).toEqual([
|
||||
{ key: 'media_unavailable', targetID: 'camera.office', reason: 'playback_error' },
|
||||
]);
|
||||
expect(host.requestUpdate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not request a placeholder when the detector renders its own error', () => {
|
||||
const { controller, failViaProviderError } = setup();
|
||||
controller.hostConnected();
|
||||
|
||||
failViaProviderError();
|
||||
|
||||
// Provider-error is not-live but does not want a placeholder.
|
||||
expect(controller.isLive()).toBe(false);
|
||||
expect(controller.getPlaceholder()).toBeNull();
|
||||
});
|
||||
|
||||
it('should not fire the issue without a target', () => {
|
||||
const { host, controller, issueTriggers, failViaProviderError } = setup({
|
||||
targetID: null,
|
||||
});
|
||||
controller.hostConnected();
|
||||
|
||||
failViaProviderError();
|
||||
|
||||
expect(issueTriggers).toEqual([]);
|
||||
expect(host.requestUpdate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should request a placeholder when a detector reports a silent freeze', async () => {
|
||||
const { host, controller, issueTriggers } = setup();
|
||||
const { player, fireMediaPlayerLiveness } = createPlayer();
|
||||
controller.hostConnected();
|
||||
|
||||
host.dispatchEvent(
|
||||
createMediaLoadedInfoEvent({
|
||||
info: createMediaLoadedInfo({ mediaPlayerController: player }),
|
||||
}),
|
||||
);
|
||||
await callIntersectionHandler(true);
|
||||
fireMediaPlayerLiveness(false);
|
||||
|
||||
expect(controller.isLive()).toBe(false);
|
||||
expect(controller.getPlaceholder()).toEqual({ reason: 'stalled' });
|
||||
expect(issueTriggers).toEqual([
|
||||
{ key: 'media_unavailable', targetID: 'camera.office', reason: 'stalled' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should stop aggregating detector inputs after host disconnect', () => {
|
||||
const { controller, failViaProviderError } = setup();
|
||||
controller.hostConnected();
|
||||
controller.hostDisconnected();
|
||||
|
||||
failViaProviderError();
|
||||
|
||||
expect(controller.isLive()).toBe(true);
|
||||
});
|
||||
|
||||
it('should reset all detectors', () => {
|
||||
const { controller, failViaProviderError } = setup();
|
||||
controller.hostConnected();
|
||||
failViaProviderError();
|
||||
expect(controller.isLive()).toBe(false);
|
||||
|
||||
controller.reset();
|
||||
|
||||
expect(controller.isLive()).toBe(true);
|
||||
});
|
||||
|
||||
it('should request an update without an issue when liveness recovers', async () => {
|
||||
const { host, controller, issueTriggers } = setup();
|
||||
const { player, fireMediaPlayerLiveness } = createPlayer();
|
||||
controller.hostConnected();
|
||||
host.dispatchEvent(
|
||||
createMediaLoadedInfoEvent({
|
||||
info: createMediaLoadedInfo({ mediaPlayerController: player }),
|
||||
}),
|
||||
);
|
||||
await callIntersectionHandler(true);
|
||||
fireMediaPlayerLiveness(false);
|
||||
issueTriggers.length = 0;
|
||||
vi.mocked(host.requestUpdate).mockClear();
|
||||
|
||||
fireMediaPlayerLiveness(true);
|
||||
|
||||
expect(issueTriggers).toEqual([]);
|
||||
expect(host.requestUpdate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should surface an always_error unavailable entity as a placeholder', () => {
|
||||
const host = createLitElement();
|
||||
document.body.append(host);
|
||||
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
|
||||
stateWatcher.subscribe.mockReturnValue(true);
|
||||
const camera = mock<Camera>();
|
||||
camera.getConfig.mockReturnValue(
|
||||
createCameraConfig({
|
||||
camera_entity: 'camera.office',
|
||||
always_error_if_entity_unavailable: true,
|
||||
}),
|
||||
);
|
||||
let currentCamera: Camera | null = camera;
|
||||
const hass = createHASS({
|
||||
'camera.office': createStateEntity({ state: 'unavailable' }),
|
||||
});
|
||||
|
||||
const controller = new StreamLivenessController(host, {
|
||||
getTargetID: () => 'camera.office',
|
||||
getHASS: () => hass,
|
||||
getCamera: () => currentCamera,
|
||||
getStateWatcher: () => stateWatcher,
|
||||
});
|
||||
const issueTriggers: unknown[] = [];
|
||||
host.addEventListener(ISSUE_TRIGGER_EVENT, (ev) =>
|
||||
issueTriggers.push((ev as CustomEvent).detail),
|
||||
);
|
||||
|
||||
controller.hostConnected();
|
||||
|
||||
expect(controller.isLive()).toBe(false);
|
||||
expect(controller.getPlaceholder()).toEqual({ reason: 'entity_unavailable' });
|
||||
expect(issueTriggers).toEqual([
|
||||
{
|
||||
key: 'media_unavailable',
|
||||
targetID: 'camera.office',
|
||||
reason: 'entity_unavailable',
|
||||
},
|
||||
]);
|
||||
|
||||
// The camera reference is removed while the entity is still watched; the
|
||||
// always_error lookup must tolerate a now-null camera and leave the
|
||||
// already-not-live verdict unchanged.
|
||||
currentCamera = null;
|
||||
callStateWatcherCallback(stateWatcher, {
|
||||
entityID: 'camera.office',
|
||||
newState: createStateEntity({ state: 'unavailable' }),
|
||||
});
|
||||
|
||||
expect(controller.isLive()).toBe(false);
|
||||
});
|
||||
|
||||
it('should keep a frame-confirmed stream live despite an unavailable entity', async () => {
|
||||
vi.useFakeTimers();
|
||||
const host = createLitElement();
|
||||
document.body.append(host);
|
||||
|
||||
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
|
||||
stateWatcher.subscribe.mockReturnValue(true);
|
||||
|
||||
const camera = mock<Camera>();
|
||||
camera.getConfig.mockReturnValue(
|
||||
createCameraConfig({ camera_entity: 'camera.office' }),
|
||||
);
|
||||
|
||||
const hass = createHASS({
|
||||
'camera.office': createStateEntity({ state: 'unavailable' }),
|
||||
});
|
||||
|
||||
const controller = new StreamLivenessController(host, {
|
||||
getTargetID: () => 'camera.office',
|
||||
getHASS: () => hass,
|
||||
getCamera: () => camera,
|
||||
getStateWatcher: () => stateWatcher,
|
||||
});
|
||||
|
||||
const issueTriggers: unknown[] = [];
|
||||
host.addEventListener(ISSUE_TRIGGER_EVENT, (ev) =>
|
||||
issueTriggers.push((ev as CustomEvent).detail),
|
||||
);
|
||||
const { player, fireMediaPlayerLiveness } = createPlayer();
|
||||
|
||||
controller.hostConnected();
|
||||
host.dispatchEvent(
|
||||
createMediaLoadedInfoEvent({
|
||||
info: createMediaLoadedInfo({ mediaPlayerController: player }),
|
||||
}),
|
||||
);
|
||||
await callIntersectionHandler(true);
|
||||
|
||||
// Frames confirm the stream is live, then the entity blips unavailable past
|
||||
// its grace window.
|
||||
fireMediaPlayerLiveness(true);
|
||||
vi.advanceTimersByTime(LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS * 1000);
|
||||
|
||||
// Direct frame evidence outranks the entity proxy: no teardown, no issue.
|
||||
expect(controller.isLive()).toBe(true);
|
||||
expect(controller.getPlaceholder()).toBeNull();
|
||||
expect(issueTriggers).toEqual([]);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should report not live despite confirmed frames when always_error overrides', async () => {
|
||||
const host = createLitElement();
|
||||
document.body.append(host);
|
||||
|
||||
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
|
||||
stateWatcher.subscribe.mockReturnValue(true);
|
||||
|
||||
const camera = mock<Camera>();
|
||||
camera.getConfig.mockReturnValue(
|
||||
createCameraConfig({
|
||||
camera_entity: 'camera.office',
|
||||
always_error_if_entity_unavailable: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const hass = createHASS({
|
||||
'camera.office': createStateEntity({ state: 'unavailable' }),
|
||||
});
|
||||
|
||||
const controller = new StreamLivenessController(host, {
|
||||
getTargetID: () => 'camera.office',
|
||||
getHASS: () => hass,
|
||||
getCamera: () => camera,
|
||||
getStateWatcher: () => stateWatcher,
|
||||
});
|
||||
const { player, fireMediaPlayerLiveness } = createPlayer();
|
||||
|
||||
controller.hostConnected();
|
||||
host.dispatchEvent(
|
||||
createMediaLoadedInfoEvent({
|
||||
info: createMediaLoadedInfo({ mediaPlayerController: player }),
|
||||
}),
|
||||
);
|
||||
await callIntersectionHandler(true);
|
||||
fireMediaPlayerLiveness(true);
|
||||
|
||||
// The always_error opt-in is authoritative: an unavailable entity overrides
|
||||
// even confirmed frames.
|
||||
expect(controller.isLive()).toBe(false);
|
||||
expect(controller.getPlaceholder()).toEqual({ reason: 'entity_unavailable' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user