349 lines
11 KiB
TypeScript
349 lines
11 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 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' });
|
|
});
|
|
});
|