Files
advanced-camera-card/tests/components-lib/live/liveness/stream-liveness-controller.test.ts
T

517 lines
17 KiB
TypeScript

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 {
dispatchLiveErrorEvent,
type LiveError,
} from '../../../../src/components-lib/live/utils/dispatch-live-error';
import type { LivenessCallback, MediaPlayerController } from '../../../../src/types';
import { createCameraConfig } from '../../../config/test-utils';
import {
callIntersectionHandler,
callStateWatcherCallback,
createHASS,
createLitElement,
createMediaLoadedInfo,
createMediaLoadedInfoEvent,
createStateEntity,
IntersectionObserverMock,
} from '../../../test-utils';
const ISSUE_TRIGGER_EVENT = 'advanced-camera-card:issue:trigger';
const ISSUE_RESOLVE_EVENT = 'advanced-camera-card:issue:resolve';
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 issueResolves: unknown[] = [];
host.addEventListener(ISSUE_RESOLVE_EVENT, (ev) =>
issueResolves.push((ev as CustomEvent).detail),
);
const failViaProviderError = (error?: LiveError): void => {
dispatchLiveErrorEvent(host, error);
};
return { host, controller, issueTriggers, issueResolves, 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.getFailure()).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; its cause is
// still available for a wrapper that fills the frame itself.
expect(controller.isLive()).toBe(false);
expect(controller.getFailure()).toEqual({
reason: 'playback_error',
renderPlaceholder: false,
});
});
it("should carry the provider's error description into the failure and the issue", () => {
const { controller, issueTriggers, failViaProviderError } = setup();
controller.hostConnected();
failViaProviderError({
description: 'Failed to start WebRTC stream: no candidates',
});
expect(controller.getFailure()).toEqual({
reason: 'playback_error',
description: 'Failed to start WebRTC stream: no candidates',
renderPlaceholder: false,
});
expect(issueTriggers).toEqual([
{
key: 'media_unavailable',
targetID: 'camera.office',
reason: 'playback_error',
description: 'Failed to start WebRTC stream: no candidates',
},
]);
});
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.getFailure()).toEqual({
reason: 'stalled',
renderPlaceholder: true,
});
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.getFailure()).toEqual({
reason: 'entity_unavailable',
renderPlaceholder: true,
});
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.getFailure()).toBeNull();
expect(issueTriggers).toEqual([]);
vi.useRealTimers();
});
describe('resolving the issue', () => {
const setupWithLiveStream = async () => {
const result = setup();
const { player, fireMediaPlayerLiveness } = createPlayer();
result.controller.hostConnected();
result.host.dispatchEvent(
createMediaLoadedInfoEvent({
info: createMediaLoadedInfo({ mediaPlayerController: player }),
}),
);
await callIntersectionHandler(true);
return { ...result, fireMediaPlayerLiveness };
};
it('should resolve when frames confirm the stream is flowing', async () => {
const { issueResolves, fireMediaPlayerLiveness } = await setupWithLiveStream();
fireMediaPlayerLiveness(true);
expect(issueResolves).toEqual([
{ key: 'media_unavailable', targetID: 'camera.office' },
]);
});
it('should not resolve while a hard failure exists', async () => {
const { issueResolves, failViaProviderError, fireMediaPlayerLiveness } =
await setupWithLiveStream();
// A provider has authoritatively condemned the stream. Frames continuing
// to arrive must not talk the card out of it.
failViaProviderError();
fireMediaPlayerLiveness(true);
expect(issueResolves).toEqual([]);
});
it('should not resolve without a target', async () => {
const result = setup({ targetID: null });
const { player, fireMediaPlayerLiveness } = createPlayer();
result.controller.hostConnected();
result.host.dispatchEvent(
createMediaLoadedInfoEvent({
info: createMediaLoadedInfo({ mediaPlayerController: player }),
}),
);
await callIntersectionHandler(true);
fireMediaPlayerLiveness(true);
expect(result.issueResolves).toEqual([]);
});
it('should not resolve on reconnect without fresh evidence', async () => {
const { controller, issueResolves, fireMediaPlayerLiveness } =
await setupWithLiveStream();
fireMediaPlayerLiveness(true);
issueResolves.length = 0;
// Away and back with nothing observed in between: the previous `live` is
// a memory of the old watch, not evidence about the new one.
controller.hostDisconnected();
controller.hostConnected();
expect(issueResolves).toEqual([]);
});
it('should not announce recovery for a stream that was reset away', async () => {
const { controller, issueResolves, fireMediaPlayerLiveness } =
await setupWithLiveStream();
fireMediaPlayerLiveness(true);
issueResolves.length = 0;
// The stream this `live` describes is being torn down, so resetting must
// not report it as recovered.
controller.reset();
expect(issueResolves).toEqual([]);
});
it('should not mix a stale live verdict with a freshly reset one', async () => {
const { controller, host, issueResolves, issueTriggers, failViaProviderError } =
setup();
const { player, fireMediaPlayerLiveness } = createPlayer();
controller.hostConnected();
host.dispatchEvent(
createMediaLoadedInfoEvent({
info: createMediaLoadedInfo({ mediaPlayerController: player }),
}),
);
await callIntersectionHandler(true);
// Frames say live, then a provider error condemns the stream. Both
// verdicts are held at once, by different detectors.
fireMediaPlayerLiveness(true);
failViaProviderError();
issueResolves.length = 0;
issueTriggers.length = 0;
// Resetting clears them in turn. If any detector announced part-way
// through, the cleared provider error would leave the stale `live`
// unopposed and the card would report a recovery that never happened.
controller.reset();
expect(issueResolves).toEqual([]);
expect(issueTriggers).toEqual([]);
});
it('should re-read the detectors once they have all been reset', async () => {
const { controller, host, fireMediaPlayerLiveness } = await setupWithLiveStream();
fireMediaPlayerLiveness(true);
vi.mocked(host.requestUpdate).mockClear();
controller.reset();
// Anything the detectors say while being reset is ignored, so this is the
// single read the controller makes once they are all done.
expect(host.requestUpdate).toHaveBeenCalledTimes(1);
expect(controller.isLive()).toBe(true);
});
});
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.getFailure()).toEqual({
reason: 'entity_unavailable',
renderPlaceholder: true,
});
});
});