feat: Automatically recover from frozen live streams (#2569)
- Closes: #2099
This commit is contained in:
@@ -94,6 +94,7 @@ describe('Camera', () => {
|
||||
}),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
expect(camera.isInitialized()).toBe(false);
|
||||
|
||||
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
|
||||
await camera.initialize({
|
||||
@@ -101,6 +102,7 @@ describe('Camera', () => {
|
||||
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
|
||||
});
|
||||
|
||||
expect(camera.isInitialized()).toBe(true);
|
||||
expect(stateWatcher.subscribe).toBeCalledWith(expect.any(Function), [
|
||||
'camera.foo',
|
||||
]);
|
||||
@@ -128,6 +130,7 @@ describe('Camera', () => {
|
||||
|
||||
expect(stateWatcher.subscribe).not.toBeCalled();
|
||||
expect(camera.getCapabilities()).toBeNull();
|
||||
expect(camera.isInitialized()).toBe(false);
|
||||
});
|
||||
|
||||
it('should set capabilities and use go2rtc metadata endpoint', async () => {
|
||||
|
||||
@@ -263,7 +263,7 @@ describe('FrigateCamera', () => {
|
||||
expect(camera.getConfig().frigate.client_id).toBe('remote_x');
|
||||
});
|
||||
|
||||
it('leaves client_id untouched when the camera entity is unavailable', async () => {
|
||||
it('falls back to "frigate" when the camera entity is unavailable', async () => {
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
camera_entity: 'camera.front_door',
|
||||
@@ -285,7 +285,7 @@ describe('FrigateCamera', () => {
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
expect(camera.getConfig().frigate.client_id).toBeUndefined();
|
||||
expect(camera.getConfig().frigate.client_id).toBe('frigate');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -230,7 +230,7 @@ describe('InitializationManager', () => {
|
||||
|
||||
// First call (languages/side-load) succeeds, second (cameras) fails.
|
||||
initializer.initializeMultipleIfNecessary
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockRejectedValueOnce(new Error('cameras failed'));
|
||||
|
||||
await manager.initializeMandatory();
|
||||
@@ -250,10 +250,12 @@ describe('InitializationManager', () => {
|
||||
const initializer = mock<Initializer>();
|
||||
const manager = new InitializationManager(api, initializer);
|
||||
|
||||
initializer.initializeMultipleIfNecessary.mockResolvedValue(true);
|
||||
|
||||
// First initializeIfNecessary call (view) succeeds, second
|
||||
// (initial_trigger) fails.
|
||||
initializer.initializeIfNecessary
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockRejectedValueOnce(new Error('triggers failed'));
|
||||
|
||||
await manager.initializeMandatory();
|
||||
@@ -272,6 +274,7 @@ describe('InitializationManager', () => {
|
||||
|
||||
const initializer = mock<Initializer>();
|
||||
const manager = new InitializationManager(api, initializer);
|
||||
initializer.initializeMultipleIfNecessary.mockResolvedValue(true);
|
||||
initializer.initializeIfNecessary.mockRejectedValueOnce(
|
||||
new Error('view initialization failed'),
|
||||
);
|
||||
@@ -285,6 +288,29 @@ describe('InitializationManager', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should stop without an error when an aspect reports failure', async () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
|
||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
|
||||
|
||||
const initializer = mock<Initializer>();
|
||||
const manager = new InitializationManager(api, initializer);
|
||||
initializer.initializeMultipleIfNecessary.mockResolvedValue(true);
|
||||
|
||||
// An aspect that could not complete (e.g. the view when no view could be
|
||||
// set) reports failure rather than throwing: the chain stops so a later
|
||||
// attempt retries, and no initialization error is raised.
|
||||
initializer.initializeIfNecessary.mockResolvedValueOnce(false);
|
||||
|
||||
await manager.initializeMandatory();
|
||||
|
||||
expect(manager.wasEverInitialized()).toBeFalsy();
|
||||
expect(api.getIssueManager().trigger).not.toBeCalledWith(
|
||||
'initialization',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle non-Error thrown during initialization', async () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('createIssueManager', () => {
|
||||
'initialization',
|
||||
'legacy_resource',
|
||||
'media_query',
|
||||
'media_load',
|
||||
'media_unavailable',
|
||||
'view_incompatible',
|
||||
] as const;
|
||||
|
||||
@@ -84,15 +84,19 @@ describe('createIssueManager', () => {
|
||||
|
||||
const manager = createIssueManager(api, createSubscriptionHealth());
|
||||
|
||||
// Setting view starts the media_load timer (via the condition state
|
||||
// Setting view starts the media_unavailable timer (via the condition state
|
||||
// listener → evaluate → detectDynamic).
|
||||
stateManager.setState({ targetID: 'camera-1', view: 'live' });
|
||||
expect(manager.getStateManager().getIssuePresence().has('media_load')).toBe(false);
|
||||
expect(manager.getStateManager().getIssuePresence().has('media_unavailable')).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
// After the timeout, the changeCallback fires evaluate which
|
||||
// updates the card element.
|
||||
vi.advanceTimersByTime(10000);
|
||||
|
||||
expect(manager.getStateManager().getIssuePresence().has('media_load')).toBe(true);
|
||||
expect(manager.getStateManager().getIssuePresence().has('media_unavailable')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
RETRY_EXPONENTIAL_BASE_SECONDS,
|
||||
RETRY_EXPONENTIAL_MAX_SECONDS,
|
||||
} from '../../../src/card-controller/issues/issue-manager';
|
||||
import { MediaUnavailableIssue } from '../../../src/card-controller/issues/issues/media-unavailable';
|
||||
import { createRetryControl } from '../../../src/card-controller/issues/retry-control';
|
||||
import type {
|
||||
Issue,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
createCardAPI,
|
||||
createConfig,
|
||||
createHASS,
|
||||
createMediaLoadedInfo,
|
||||
flushPromises,
|
||||
} from '../../test-utils';
|
||||
|
||||
@@ -74,7 +76,7 @@ const createRetriableSetup = (options?: {
|
||||
|
||||
const manager = new IssueManager(api);
|
||||
|
||||
const issue = createIssue('media_load', {
|
||||
const issue = createIssue('media_unavailable', {
|
||||
hasIssue: vi.fn().mockReturnValueOnce(false).mockReturnValue(true),
|
||||
needsRetry: vi.fn().mockReturnValue(true),
|
||||
retry: vi.fn().mockReturnValue(false),
|
||||
@@ -110,6 +112,35 @@ describe('IssueManager', () => {
|
||||
expect(issue.detectDynamic).toBeCalled();
|
||||
});
|
||||
|
||||
it('should keep a liveness-triggered error active while its media still reads as loaded', () => {
|
||||
// Regression: a runtime liveness loss (stall / provider error) fires a
|
||||
// trigger while the (now frozen) media is still loaded. The trigger's
|
||||
// synchronous evaluate must not clear the just-set error via the stale
|
||||
// loaded state.
|
||||
const api = createCardAPI();
|
||||
const conditionStateManager = new ConditionStateManager();
|
||||
vi.mocked(api.getConditionStateManager).mockReturnValue(conditionStateManager);
|
||||
|
||||
const manager = new IssueManager(api);
|
||||
const issue = new MediaUnavailableIssue(api);
|
||||
manager.addIssue(issue);
|
||||
|
||||
conditionStateManager.setState({
|
||||
view: 'live',
|
||||
targetID: 'camera.office',
|
||||
mediaLoadedInfo: createMediaLoadedInfo({ targetID: 'camera.office' }),
|
||||
});
|
||||
expect(issue.hasIssue()).toBe(false);
|
||||
|
||||
manager.trigger('media_unavailable', {
|
||||
targetID: 'camera.office',
|
||||
reason: 'stalled',
|
||||
});
|
||||
|
||||
expect(issue.hasIssue()).toBe(true);
|
||||
expect(issue.needsRetry()).toBe(true);
|
||||
});
|
||||
|
||||
describe('addIssue / getStateManager', () => {
|
||||
it('should make added issues accessible via getManager', () => {
|
||||
const manager = new IssueManager(createCardAPI());
|
||||
@@ -240,7 +271,7 @@ describe('IssueManager', () => {
|
||||
|
||||
// Start the timer via evaluate, then immediately retry.
|
||||
manager.evaluate();
|
||||
manager.retry('media_load');
|
||||
manager.retry('media_unavailable');
|
||||
|
||||
expect(issue.retry).toBeCalled();
|
||||
|
||||
@@ -255,12 +286,12 @@ describe('IssueManager', () => {
|
||||
it('should force retry even when needsRetry is false', () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new IssueManager(api);
|
||||
const issue = createIssue('media_load', {
|
||||
const issue = createIssue('media_unavailable', {
|
||||
retry: vi.fn().mockReturnValue(false),
|
||||
});
|
||||
manager.addIssue(issue);
|
||||
|
||||
manager.retry('media_load', true);
|
||||
manager.retry('media_unavailable', true);
|
||||
|
||||
expect(issue.retry).toBeCalled();
|
||||
});
|
||||
@@ -370,12 +401,12 @@ describe('IssueManager', () => {
|
||||
const api = createCardAPI();
|
||||
|
||||
const manager = new IssueManager(api);
|
||||
const issue = createIssue('media_load', {
|
||||
const issue = createIssue('media_unavailable', {
|
||||
hasIssue: vi.fn().mockReturnValue(true),
|
||||
getIssue: vi.fn().mockImplementation(() =>
|
||||
createIssueDescription({
|
||||
notification: {
|
||||
controls: [createRetryControl('media_load')],
|
||||
controls: [createRetryControl('media_unavailable')],
|
||||
},
|
||||
}),
|
||||
),
|
||||
@@ -454,7 +485,7 @@ describe('IssueManager', () => {
|
||||
|
||||
const manager = new IssueManager(api);
|
||||
|
||||
const issue = createIssue('media_load', {
|
||||
const issue = createIssue('media_unavailable', {
|
||||
hasIssue: vi.fn().mockReturnValueOnce(false).mockReturnValue(true),
|
||||
needsRetry: vi.fn().mockReturnValue(true),
|
||||
retry: vi.fn().mockReturnValue(false),
|
||||
@@ -615,21 +646,31 @@ describe('IssueManager', () => {
|
||||
});
|
||||
|
||||
it('should cap the backoff at the max delay', () => {
|
||||
// Drive 5 pre-cap attempts (30, 60, 120, 240, 480 seconds), then assert
|
||||
// the 6th attempt clamps to MAX instead of the would-be 960.
|
||||
// Drive attempts through the exponential region (base, base*2, base*4,
|
||||
// ...) until the delay reaches MAX, then assert the next attempt clamps to
|
||||
// MAX instead of continuing to double, and stays there.
|
||||
vi.spyOn(Math, 'random').mockReturnValue(1);
|
||||
const { manager, issue } = createRetriableSetup({ retrySeconds: 'auto' });
|
||||
manager.evaluate();
|
||||
|
||||
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 1 * 1000);
|
||||
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 2 * 1000);
|
||||
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 4 * 1000);
|
||||
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 8 * 1000);
|
||||
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 16 * 1000);
|
||||
expect(issue.retry).toBeCalledTimes(5);
|
||||
let attempts = 0;
|
||||
for (
|
||||
let delaySeconds = RETRY_EXPONENTIAL_BASE_SECONDS;
|
||||
delaySeconds < RETRY_EXPONENTIAL_MAX_SECONDS;
|
||||
delaySeconds *= 2
|
||||
) {
|
||||
vi.advanceTimersByTime(delaySeconds * 1000);
|
||||
attempts++;
|
||||
}
|
||||
expect(issue.retry).toBeCalledTimes(attempts);
|
||||
|
||||
// The next attempt clamps to MAX instead of the would-be larger delay.
|
||||
vi.advanceTimersByTime(RETRY_EXPONENTIAL_MAX_SECONDS * 1000);
|
||||
expect(issue.retry).toBeCalledTimes(6);
|
||||
expect(issue.retry).toBeCalledTimes(attempts + 1);
|
||||
|
||||
// And it stays capped at MAX rather than growing further.
|
||||
vi.advanceTimersByTime(RETRY_EXPONENTIAL_MAX_SECONDS * 1000);
|
||||
expect(issue.retry).toBeCalledTimes(attempts + 2);
|
||||
});
|
||||
|
||||
it('should reset the attempt counter when the issue clears', () => {
|
||||
@@ -834,7 +875,7 @@ describe('IssueManager', () => {
|
||||
const api = createCardAPI();
|
||||
|
||||
const manager = new IssueManager(api);
|
||||
const issue = createIssue('media_load', { suspend: vi.fn() });
|
||||
const issue = createIssue('media_unavailable', { suspend: vi.fn() });
|
||||
manager.addIssue(issue);
|
||||
|
||||
manager.suspend();
|
||||
|
||||
+196
-88
@@ -1,7 +1,8 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { MediaLoadIssue } from '../../../../src/card-controller/issues/issues/media-load';
|
||||
import { MediaUnavailableIssue } from '../../../../src/card-controller/issues/issues/media-unavailable';
|
||||
import type { MediaLoadedInfoChange } from '../../../../src/card-controller/media-info-manager';
|
||||
import type { InternalCallbackActionConfig } from '../../../../src/config/schema/actions/custom/internal';
|
||||
import { IMAGE_VIEW_TARGET_ID_SENTINEL } from '../../../../src/view/target-id';
|
||||
import type { View } from '../../../../src/view/view';
|
||||
@@ -9,8 +10,32 @@ import { createCardAPI, createMediaLoadedInfo } from '../../../test-utils';
|
||||
|
||||
const createAPI = () => createCardAPI();
|
||||
|
||||
// Deliver a media change to the listener the issue registered with the
|
||||
// media-loaded manager.
|
||||
const fireMediaChange = (
|
||||
api: ReturnType<typeof createAPI>,
|
||||
change: MediaLoadedInfoChange,
|
||||
): void => {
|
||||
vi.mocked(api.getMediaLoadedInfoManager().subscribe).mock.calls[0]?.[0]?.(change);
|
||||
};
|
||||
|
||||
// Simulate a media (re)load for `targetID`. `cached` marks a reconnect replay
|
||||
// (a re-dispatch of the last load, not an actual reload).
|
||||
const fireMediaLoad = (
|
||||
api: ReturnType<typeof createAPI>,
|
||||
targetID: string,
|
||||
cached = false,
|
||||
): void => {
|
||||
fireMediaChange(api, {
|
||||
type: 'load',
|
||||
targetID,
|
||||
info: createMediaLoadedInfo({ targetID }),
|
||||
cached,
|
||||
});
|
||||
};
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('MediaLoadIssue', () => {
|
||||
describe('MediaUnavailableIssue', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
@@ -20,8 +45,8 @@ describe('MediaLoadIssue', () => {
|
||||
});
|
||||
|
||||
it('should have correct key', () => {
|
||||
const issue = new MediaLoadIssue(createAPI());
|
||||
expect(issue.key).toBe('media_load');
|
||||
const issue = new MediaUnavailableIssue(createAPI());
|
||||
expect(issue.key).toBe('media_unavailable');
|
||||
});
|
||||
|
||||
describe('detectDynamic', () => {
|
||||
@@ -35,7 +60,7 @@ describe('MediaLoadIssue', () => {
|
||||
['review' as const],
|
||||
])('should start timer when view is %s and not loaded', (view) => {
|
||||
const onChange = vi.fn();
|
||||
const issue = new MediaLoadIssue(createAPI(), onChange);
|
||||
const issue = new MediaUnavailableIssue(createAPI(), onChange);
|
||||
|
||||
issue.detectDynamic({ targetID: 'target-1', view });
|
||||
|
||||
@@ -48,7 +73,7 @@ describe('MediaLoadIssue', () => {
|
||||
});
|
||||
|
||||
it('should not start timer when targetID is null (no provider rendering)', () => {
|
||||
const issue = new MediaLoadIssue(createAPI());
|
||||
const issue = new MediaUnavailableIssue(createAPI());
|
||||
|
||||
// Media view but no targetID, e.g. viewer showing "No media to display"
|
||||
// instead of mounting a provider.
|
||||
@@ -60,7 +85,7 @@ describe('MediaLoadIssue', () => {
|
||||
});
|
||||
|
||||
it('should deactivate when targetID becomes null', () => {
|
||||
const issue = new MediaLoadIssue(createAPI());
|
||||
const issue = new MediaUnavailableIssue(createAPI());
|
||||
|
||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||
vi.advanceTimersByTime(10000);
|
||||
@@ -72,7 +97,7 @@ describe('MediaLoadIssue', () => {
|
||||
});
|
||||
|
||||
it('should not start timer when view is not a media view', () => {
|
||||
const issue = new MediaLoadIssue(createAPI());
|
||||
const issue = new MediaUnavailableIssue(createAPI());
|
||||
|
||||
issue.detectDynamic({ view: 'timeline' });
|
||||
|
||||
@@ -82,7 +107,7 @@ describe('MediaLoadIssue', () => {
|
||||
});
|
||||
|
||||
it('should not start timer when view is undefined', () => {
|
||||
const issue = new MediaLoadIssue(createAPI());
|
||||
const issue = new MediaUnavailableIssue(createAPI());
|
||||
|
||||
issue.detectDynamic({});
|
||||
|
||||
@@ -92,7 +117,7 @@ describe('MediaLoadIssue', () => {
|
||||
});
|
||||
|
||||
it('should not start timer when media is loaded', () => {
|
||||
const issue = new MediaLoadIssue(createAPI());
|
||||
const issue = new MediaUnavailableIssue(createAPI());
|
||||
|
||||
issue.detectDynamic({ view: 'live', mediaLoadedInfo: createMediaLoadedInfo() });
|
||||
|
||||
@@ -102,7 +127,7 @@ describe('MediaLoadIssue', () => {
|
||||
});
|
||||
|
||||
it('should clear timeout when media loads', () => {
|
||||
const issue = new MediaLoadIssue(createAPI());
|
||||
const issue = new MediaUnavailableIssue(createAPI());
|
||||
|
||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||
vi.advanceTimersByTime(5000);
|
||||
@@ -115,7 +140,7 @@ describe('MediaLoadIssue', () => {
|
||||
});
|
||||
|
||||
it('should clear timeout when view changes to a non-media view', () => {
|
||||
const issue = new MediaLoadIssue(createAPI());
|
||||
const issue = new MediaUnavailableIssue(createAPI());
|
||||
|
||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||
vi.advanceTimersByTime(5000);
|
||||
@@ -128,7 +153,7 @@ describe('MediaLoadIssue', () => {
|
||||
});
|
||||
|
||||
it('should remain active across media views for the same target', () => {
|
||||
const issue = new MediaLoadIssue(createAPI());
|
||||
const issue = new MediaUnavailableIssue(createAPI());
|
||||
|
||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||
vi.advanceTimersByTime(10000);
|
||||
@@ -140,7 +165,7 @@ describe('MediaLoadIssue', () => {
|
||||
});
|
||||
|
||||
it('should deactivate when target changes to non-errored target', () => {
|
||||
const issue = new MediaLoadIssue(createAPI());
|
||||
const issue = new MediaUnavailableIssue(createAPI());
|
||||
|
||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||
vi.advanceTimersByTime(10000);
|
||||
@@ -157,10 +182,10 @@ describe('MediaLoadIssue', () => {
|
||||
});
|
||||
|
||||
it('should stay active when target changes to errored target', () => {
|
||||
const issue = new MediaLoadIssue(createAPI());
|
||||
const issue = new MediaUnavailableIssue(createAPI());
|
||||
|
||||
issue.trigger({ targetID: 'camera-1' });
|
||||
issue.trigger({ targetID: 'camera-2' });
|
||||
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
|
||||
issue.trigger({ targetID: 'camera-2', reason: 'stalled' });
|
||||
|
||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||
expect(issue.hasIssue()).toBe(true);
|
||||
@@ -171,7 +196,7 @@ describe('MediaLoadIssue', () => {
|
||||
});
|
||||
|
||||
it('should clear timed-out state when media loads', () => {
|
||||
const issue = new MediaLoadIssue(createAPI());
|
||||
const issue = new MediaUnavailableIssue(createAPI());
|
||||
|
||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||
vi.advanceTimersByTime(10000);
|
||||
@@ -183,7 +208,7 @@ describe('MediaLoadIssue', () => {
|
||||
|
||||
it('should restart timer when target changes', () => {
|
||||
const onChange = vi.fn();
|
||||
const issue = new MediaLoadIssue(createAPI(), onChange);
|
||||
const issue = new MediaUnavailableIssue(createAPI(), onChange);
|
||||
|
||||
issue.detectDynamic({
|
||||
targetID: 'camera-1',
|
||||
@@ -209,7 +234,7 @@ describe('MediaLoadIssue', () => {
|
||||
|
||||
it('should not restart timer for same target while running', () => {
|
||||
const onChange = vi.fn();
|
||||
const issue = new MediaLoadIssue(createAPI(), onChange);
|
||||
const issue = new MediaUnavailableIssue(createAPI(), onChange);
|
||||
|
||||
issue.detectDynamic({
|
||||
targetID: 'camera-1',
|
||||
@@ -231,7 +256,7 @@ describe('MediaLoadIssue', () => {
|
||||
|
||||
it('should not restart timer when targetID is undefined and matches', () => {
|
||||
const onChange = vi.fn();
|
||||
const issue = new MediaLoadIssue(createAPI(), onChange);
|
||||
const issue = new MediaUnavailableIssue(createAPI(), onChange);
|
||||
|
||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||
vi.advanceTimersByTime(5000);
|
||||
@@ -246,7 +271,7 @@ describe('MediaLoadIssue', () => {
|
||||
|
||||
it('should not restart timer if already timed out', () => {
|
||||
const onChange = vi.fn();
|
||||
const issue = new MediaLoadIssue(createAPI(), onChange);
|
||||
const issue = new MediaUnavailableIssue(createAPI(), onChange);
|
||||
|
||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||
vi.advanceTimersByTime(10000);
|
||||
@@ -261,9 +286,9 @@ describe('MediaLoadIssue', () => {
|
||||
|
||||
describe('trigger', () => {
|
||||
it('should activate immediately when target has error and view is a media view', () => {
|
||||
const issue = new MediaLoadIssue(createAPI());
|
||||
const issue = new MediaUnavailableIssue(createAPI());
|
||||
|
||||
issue.trigger({ targetID: 'camera-1' });
|
||||
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
|
||||
issue.detectDynamic({
|
||||
targetID: 'camera-1',
|
||||
view: 'live',
|
||||
@@ -273,46 +298,97 @@ describe('MediaLoadIssue', () => {
|
||||
});
|
||||
|
||||
it('should not activate with only a trigger', () => {
|
||||
const issue = new MediaLoadIssue(createAPI());
|
||||
const issue = new MediaUnavailableIssue(createAPI());
|
||||
|
||||
issue.trigger({ targetID: 'camera-1' });
|
||||
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
|
||||
|
||||
expect(issue.hasIssue()).toBe(false);
|
||||
});
|
||||
|
||||
it('should clear target error when media loads', () => {
|
||||
const issue = new MediaLoadIssue(createAPI());
|
||||
it('should clear a target error on a genuine media load', () => {
|
||||
const api = createAPI();
|
||||
const issue = new MediaUnavailableIssue(api);
|
||||
|
||||
issue.trigger({ targetID: 'camera-1' });
|
||||
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
|
||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||
expect(issue.hasIssue()).toBe(true);
|
||||
|
||||
// A genuine (re)load for the target clears the error.
|
||||
fireMediaLoad(api, 'camera-1');
|
||||
|
||||
// The error is gone, so this unloaded state falls back to the timer
|
||||
// (would not activate until the timeout).
|
||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||
expect(issue.hasIssue()).toBe(false);
|
||||
});
|
||||
|
||||
it('should keep an errored target active while its media still reads as loaded', () => {
|
||||
const issue = new MediaUnavailableIssue(createAPI());
|
||||
|
||||
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
|
||||
|
||||
// The (now frozen) media still reads as loaded, but the existing loaded
|
||||
// level must not clear the error -- only a genuine media load does. The
|
||||
// loaded media is the frozen stream a liveness detector just condemned.
|
||||
issue.detectDynamic({
|
||||
targetID: 'camera-1',
|
||||
view: 'live',
|
||||
mediaLoadedInfo: createMediaLoadedInfo({ targetID: 'camera-1' }),
|
||||
});
|
||||
|
||||
expect(issue.hasIssue()).toBe(true);
|
||||
});
|
||||
|
||||
// Media loaded clears the error for this target.
|
||||
issue.detectDynamic({
|
||||
targetID: 'camera-1',
|
||||
view: 'live',
|
||||
mediaLoadedInfo: createMediaLoadedInfo(),
|
||||
});
|
||||
it('should not clear a target error when a different target loads', () => {
|
||||
const api = createAPI();
|
||||
const issue = new MediaUnavailableIssue(api);
|
||||
|
||||
// Target error was cleared by the successful load, so this unloaded state
|
||||
// falls back to the timer (issue would not activate until after the
|
||||
// timer is reached).
|
||||
issue.detectDynamic({
|
||||
targetID: 'camera-1',
|
||||
view: 'live',
|
||||
});
|
||||
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
|
||||
|
||||
expect(issue.hasIssue()).toBe(false);
|
||||
// A load for a different target must not clear camera-1's error.
|
||||
fireMediaLoad(api, 'camera-2');
|
||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||
|
||||
expect(issue.hasIssue()).toBe(true);
|
||||
});
|
||||
|
||||
it('should not clear a target error on a reconnect replay', () => {
|
||||
const api = createAPI();
|
||||
const issue = new MediaUnavailableIssue(api);
|
||||
|
||||
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
|
||||
|
||||
fireMediaLoad(
|
||||
api,
|
||||
'camera-1',
|
||||
|
||||
// A cached replay (reconnect re-dispatch) did not actually reload the
|
||||
// media, so it must not clear the error.
|
||||
true,
|
||||
);
|
||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||
|
||||
expect(issue.hasIssue()).toBe(true);
|
||||
});
|
||||
|
||||
it('should not clear a target error on unload or select changes', () => {
|
||||
const api = createAPI();
|
||||
const issue = new MediaUnavailableIssue(api);
|
||||
|
||||
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
|
||||
|
||||
// Only a genuine load clears; unload / select changes are irrelevant.
|
||||
fireMediaChange(api, { type: 'unload', targetID: 'camera-1' });
|
||||
fireMediaChange(api, { type: 'select', targetID: 'camera-1' });
|
||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||
|
||||
expect(issue.hasIssue()).toBe(true);
|
||||
});
|
||||
|
||||
it('should not activate for a different target', () => {
|
||||
const issue = new MediaLoadIssue(createAPI());
|
||||
const issue = new MediaUnavailableIssue(createAPI());
|
||||
|
||||
issue.trigger({ targetID: 'camera-1' });
|
||||
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
|
||||
issue.detectDynamic({
|
||||
targetID: 'camera-2',
|
||||
view: 'live',
|
||||
@@ -325,7 +401,7 @@ describe('MediaLoadIssue', () => {
|
||||
|
||||
describe('getNotification', () => {
|
||||
it('should return notification regardless of active state', () => {
|
||||
const issue = new MediaLoadIssue(createAPI());
|
||||
const issue = new MediaUnavailableIssue(createAPI());
|
||||
|
||||
const notification = issue.getNotification();
|
||||
expect(notification).toEqual(
|
||||
@@ -340,25 +416,18 @@ describe('MediaLoadIssue', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should include metadata for errored targets', () => {
|
||||
const issue = new MediaLoadIssue(createAPI());
|
||||
issue.trigger({ targetID: 'camera.office' });
|
||||
|
||||
const notification = issue.getNotification();
|
||||
expect(notification.metadata).toEqual([
|
||||
expect.objectContaining({ text: 'camera.office', icon: 'mdi:cctv' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should include the pending timer target in metadata', () => {
|
||||
const issue = new MediaLoadIssue(createAPI());
|
||||
const issue = new MediaUnavailableIssue(createAPI());
|
||||
|
||||
// Start a load timer (no explicit error yet, just slow-loading).
|
||||
issue.detectDynamic({ targetID: 'camera.garden', view: 'live' });
|
||||
|
||||
const notification = issue.getNotification();
|
||||
expect(notification.metadata).toEqual([
|
||||
expect.objectContaining({ text: 'camera.garden', icon: 'mdi:cctv' }),
|
||||
expect.objectContaining({
|
||||
text: 'camera.garden: Media not loading',
|
||||
icon: 'mdi:progress-helper',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -368,28 +437,43 @@ describe('MediaLoadIssue', () => {
|
||||
title: 'Office',
|
||||
icon: { icon: 'mdi:cctv' },
|
||||
});
|
||||
const issue = new MediaLoadIssue(api);
|
||||
issue.trigger({ targetID: 'camera.office' });
|
||||
const issue = new MediaUnavailableIssue(api);
|
||||
issue.trigger({ targetID: 'camera.office', reason: 'stalled' });
|
||||
|
||||
const notification = issue.getNotification();
|
||||
expect(notification.metadata).toEqual([
|
||||
expect.objectContaining({ text: 'Office' }),
|
||||
expect.objectContaining({ text: 'Office: Stream stalled' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should use localized label and image icon for the image-view sentinel', () => {
|
||||
const issue = new MediaLoadIssue(createAPI());
|
||||
issue.trigger({ targetID: IMAGE_VIEW_TARGET_ID_SENTINEL });
|
||||
const issue = new MediaUnavailableIssue(createAPI());
|
||||
issue.trigger({ targetID: IMAGE_VIEW_TARGET_ID_SENTINEL, reason: 'stalled' });
|
||||
|
||||
const notification = issue.getNotification();
|
||||
expect(notification.metadata).toEqual([
|
||||
expect.objectContaining({ text: 'Image', icon: 'mdi:image' }),
|
||||
expect.objectContaining({ text: 'Image: Stream stalled', icon: 'mdi:image' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['entity_unavailable' as const, 'Camera entity unavailable', 'mdi:cctv-off'],
|
||||
['not_loading' as const, 'Media not loading', 'mdi:progress-helper'],
|
||||
['playback_error' as const, 'Playback error', 'mdi:alert-circle'],
|
||||
['stalled' as const, 'Stream stalled', 'mdi:motion-pause'],
|
||||
])('should give the %s cause its own text and icon', (reason, text, icon) => {
|
||||
const issue = new MediaUnavailableIssue(createAPI());
|
||||
issue.trigger({ targetID: 'camera.office', reason });
|
||||
|
||||
const notification = issue.getNotification();
|
||||
expect(notification.metadata).toEqual([
|
||||
expect.objectContaining({ text: `camera.office: ${text}`, icon }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should include a retry control with wired callback', async () => {
|
||||
const api = createCardAPI();
|
||||
const issue = new MediaLoadIssue(api);
|
||||
const issue = new MediaUnavailableIssue(api);
|
||||
|
||||
const control = issue.getNotification().controls?.[0];
|
||||
expect(control).toMatchObject({ icon: 'mdi:refresh', dismiss: true });
|
||||
@@ -397,13 +481,13 @@ describe('MediaLoadIssue', () => {
|
||||
const tapAction = control?.actions?.tap_action as InternalCallbackActionConfig;
|
||||
await tapAction.callback(api);
|
||||
|
||||
expect(api.getIssueManager().retry).toBeCalledWith('media_load', true);
|
||||
expect(api.getIssueManager().retry).toBeCalledWith('media_unavailable', true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIssue', () => {
|
||||
it('should return result when timed out', () => {
|
||||
const issue = new MediaLoadIssue(createAPI());
|
||||
const issue = new MediaUnavailableIssue(createAPI());
|
||||
|
||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||
vi.advanceTimersByTime(10000);
|
||||
@@ -423,7 +507,7 @@ describe('MediaLoadIssue', () => {
|
||||
});
|
||||
|
||||
it('should return null when not timed out', () => {
|
||||
const issue = new MediaLoadIssue(createAPI());
|
||||
const issue = new MediaUnavailableIssue(createAPI());
|
||||
|
||||
expect(issue.getIssue()).toBeNull();
|
||||
});
|
||||
@@ -431,7 +515,7 @@ describe('MediaLoadIssue', () => {
|
||||
|
||||
describe('needsRetry', () => {
|
||||
it('should return true when issue is active', () => {
|
||||
const issue = new MediaLoadIssue(createAPI());
|
||||
const issue = new MediaUnavailableIssue(createAPI());
|
||||
|
||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||
vi.advanceTimersByTime(10000);
|
||||
@@ -440,7 +524,7 @@ describe('MediaLoadIssue', () => {
|
||||
});
|
||||
|
||||
it('should return false when issue is not active', () => {
|
||||
const issue = new MediaLoadIssue(createAPI());
|
||||
const issue = new MediaUnavailableIssue(createAPI());
|
||||
|
||||
expect(issue.needsRetry()).toBe(false);
|
||||
});
|
||||
@@ -449,7 +533,7 @@ describe('MediaLoadIssue', () => {
|
||||
describe('retry', () => {
|
||||
it('should keep issue active after retry so error stays visible', () => {
|
||||
const onChange = vi.fn();
|
||||
const issue = new MediaLoadIssue(createAPI(), onChange);
|
||||
const issue = new MediaUnavailableIssue(createAPI(), onChange);
|
||||
|
||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||
vi.advanceTimersByTime(10000);
|
||||
@@ -464,7 +548,7 @@ describe('MediaLoadIssue', () => {
|
||||
|
||||
it('should return false when no targets have errors', () => {
|
||||
const api = createAPI();
|
||||
const issue = new MediaLoadIssue(api);
|
||||
const issue = new MediaUnavailableIssue(api);
|
||||
|
||||
expect(issue.retry()).toBe(false);
|
||||
});
|
||||
@@ -472,10 +556,10 @@ describe('MediaLoadIssue', () => {
|
||||
it('should bump mediaEpoch for targets with errors and call setViewWithMergedContext', () => {
|
||||
const api = createAPI();
|
||||
vi.mocked(api.getViewManager().getView).mockReturnValue(mock<View>());
|
||||
const issue = new MediaLoadIssue(api);
|
||||
const issue = new MediaUnavailableIssue(api);
|
||||
|
||||
issue.trigger({ targetID: 'camera-1' });
|
||||
issue.trigger({ targetID: 'media-1' });
|
||||
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
|
||||
issue.trigger({ targetID: 'media-1', reason: 'stalled' });
|
||||
|
||||
const result = issue.retry();
|
||||
|
||||
@@ -488,9 +572,9 @@ describe('MediaLoadIssue', () => {
|
||||
it('should bump mediaEpoch for the image-view sentinel', () => {
|
||||
const api = createAPI();
|
||||
vi.mocked(api.getViewManager().getView).mockReturnValue(mock<View>());
|
||||
const issue = new MediaLoadIssue(api);
|
||||
const issue = new MediaUnavailableIssue(api);
|
||||
|
||||
issue.trigger({ targetID: IMAGE_VIEW_TARGET_ID_SENTINEL });
|
||||
issue.trigger({ targetID: IMAGE_VIEW_TARGET_ID_SENTINEL, reason: 'stalled' });
|
||||
|
||||
const result = issue.retry();
|
||||
|
||||
@@ -505,9 +589,9 @@ describe('MediaLoadIssue', () => {
|
||||
vi.mocked(api.getViewManager().getView).mockReturnValue(
|
||||
mock<View>({ context: { mediaEpoch: { 'camera-1': 5, 'camera-2': 3 } } }),
|
||||
);
|
||||
const issue = new MediaLoadIssue(api);
|
||||
const issue = new MediaUnavailableIssue(api);
|
||||
|
||||
issue.trigger({ targetID: 'camera-1' });
|
||||
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
|
||||
|
||||
const result = issue.retry();
|
||||
|
||||
@@ -520,7 +604,7 @@ describe('MediaLoadIssue', () => {
|
||||
it('should include pending timer target in retry', () => {
|
||||
const api = createAPI();
|
||||
vi.mocked(api.getViewManager().getView).mockReturnValue(mock<View>());
|
||||
const issue = new MediaLoadIssue(api);
|
||||
const issue = new MediaUnavailableIssue(api);
|
||||
|
||||
// Start the timer for camera-1 (not yet timed out).
|
||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||
@@ -535,17 +619,17 @@ describe('MediaLoadIssue', () => {
|
||||
it('should keep errored targets and issue state after retry', () => {
|
||||
const api = createAPI();
|
||||
vi.mocked(api.getViewManager().getView).mockReturnValue(mock<View>());
|
||||
const issue = new MediaLoadIssue(api);
|
||||
const issue = new MediaUnavailableIssue(api);
|
||||
|
||||
issue.trigger({ targetID: 'camera-1' });
|
||||
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
|
||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||
expect(issue.hasIssue()).toBe(true);
|
||||
|
||||
issue.retry();
|
||||
|
||||
// After retry, the issue stays active and the errored target is preserved
|
||||
// -- no new 10s grace period. If media:loaded fires, the existing
|
||||
// _handleMediaLoaded path will clear everything.
|
||||
// -- no new 10s grace period. A genuine media load would clear everything
|
||||
// (_onMediaLoad drops the errored target).
|
||||
expect(issue.hasIssue()).toBe(true);
|
||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||
expect(issue.hasIssue()).toBe(true);
|
||||
@@ -555,7 +639,7 @@ describe('MediaLoadIssue', () => {
|
||||
describe('reset', () => {
|
||||
it('should stop timer', () => {
|
||||
const onChange = vi.fn();
|
||||
const issue = new MediaLoadIssue(createAPI(), onChange);
|
||||
const issue = new MediaUnavailableIssue(createAPI(), onChange);
|
||||
|
||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||
issue.reset();
|
||||
@@ -567,10 +651,34 @@ describe('MediaLoadIssue', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('media loads', () => {
|
||||
it('should notify onChange when a media load clears an error', () => {
|
||||
const api = createAPI();
|
||||
const onChange = vi.fn();
|
||||
const issue = new MediaUnavailableIssue(api, onChange);
|
||||
|
||||
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
|
||||
fireMediaLoad(api, 'camera-1');
|
||||
|
||||
expect(onChange).toBeCalled();
|
||||
});
|
||||
|
||||
it('should unsubscribe from media loads on destroy', () => {
|
||||
const api = createAPI();
|
||||
const unsubscribe = vi.fn();
|
||||
vi.mocked(api.getMediaLoadedInfoManager().subscribe).mockReturnValue(unsubscribe);
|
||||
const issue = new MediaUnavailableIssue(api);
|
||||
|
||||
issue.destroy();
|
||||
|
||||
expect(unsubscribe).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('suspend', () => {
|
||||
it('should stop the pending-load timer so it cannot mature offscreen', () => {
|
||||
const onChange = vi.fn();
|
||||
const issue = new MediaLoadIssue(createAPI(), onChange);
|
||||
const issue = new MediaUnavailableIssue(createAPI(), onChange);
|
||||
|
||||
// Enter loading state. Timer arms but has not yet fired.
|
||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||
@@ -588,7 +696,7 @@ describe('MediaLoadIssue', () => {
|
||||
});
|
||||
|
||||
it('should preserve an already-active issue across suspend', () => {
|
||||
const issue = new MediaLoadIssue(createAPI());
|
||||
const issue = new MediaUnavailableIssue(createAPI());
|
||||
|
||||
// Issue activates (timeout fires).
|
||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||
@@ -603,7 +711,7 @@ describe('MediaLoadIssue', () => {
|
||||
|
||||
it('should rearm a fresh timer window on resume via detectDynamic', () => {
|
||||
const onChange = vi.fn();
|
||||
const issue = new MediaLoadIssue(createAPI(), onChange);
|
||||
const issue = new MediaUnavailableIssue(createAPI(), onChange);
|
||||
|
||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||
vi.advanceTimersByTime(5000);
|
||||
@@ -7,7 +7,7 @@ import { createCardAPI } from '../../test-utils';
|
||||
|
||||
describe('createRetryControl', () => {
|
||||
it('should return a control with expected icon, tooltip, and dismiss', () => {
|
||||
const control = createRetryControl('media_load');
|
||||
const control = createRetryControl('media_unavailable');
|
||||
|
||||
expect(control.icon).toBe('mdi:refresh');
|
||||
expect(control.tooltip).toBe('Retry');
|
||||
|
||||
@@ -43,7 +43,7 @@ describe('IssueStateManager', () => {
|
||||
|
||||
mockConfigUpgrade = mock<Issue>({ key: 'config_upgrade' });
|
||||
mockLegacyResource = mock<Issue>({ key: 'legacy_resource' });
|
||||
mockMediaLoad = mock<Issue>({ key: 'media_load' });
|
||||
mockMediaLoad = mock<Issue>({ key: 'media_unavailable' });
|
||||
});
|
||||
|
||||
it('should register all provided issues on construction', () => {
|
||||
@@ -52,7 +52,7 @@ describe('IssueStateManager', () => {
|
||||
|
||||
expect(presence.has('config_upgrade')).toBe(false);
|
||||
expect(presence.has('legacy_resource')).toBe(false);
|
||||
expect(presence.has('media_load')).toBe(false);
|
||||
expect(presence.has('media_unavailable')).toBe(false);
|
||||
});
|
||||
|
||||
describe('detectStatic', () => {
|
||||
@@ -97,10 +97,13 @@ describe('IssueStateManager', () => {
|
||||
it('should call trigger on the matching issue', () => {
|
||||
const manager = createManager();
|
||||
|
||||
manager.trigger('media_load', { targetID: 'cam1' });
|
||||
manager.trigger('media_unavailable', { targetID: 'cam1', reason: 'stalled' });
|
||||
|
||||
assert(mockMediaLoad.trigger);
|
||||
expect(mockMediaLoad.trigger).toBeCalledWith({ targetID: 'cam1' });
|
||||
expect(mockMediaLoad.trigger).toBeCalledWith({
|
||||
targetID: 'cam1',
|
||||
reason: 'stalled',
|
||||
});
|
||||
});
|
||||
|
||||
it('should do nothing for unknown key', () => {
|
||||
@@ -210,7 +213,7 @@ describe('IssueStateManager', () => {
|
||||
|
||||
const manager = createManager();
|
||||
|
||||
expect(manager.getNotification('media_load')).toBe(notification);
|
||||
expect(manager.getNotification('media_unavailable')).toBe(notification);
|
||||
});
|
||||
|
||||
it('should return null for unknown key', () => {
|
||||
@@ -297,7 +300,7 @@ describe('IssueStateManager', () => {
|
||||
vi.mocked(mockMediaLoad.needsRetry).mockReturnValue(true);
|
||||
vi.mocked(mockMediaLoad.retry).mockReturnValue(false);
|
||||
|
||||
createManager().retry('media_load');
|
||||
createManager().retry('media_unavailable');
|
||||
|
||||
expect(mockMediaLoad.retry).toBeCalled();
|
||||
});
|
||||
@@ -306,7 +309,7 @@ describe('IssueStateManager', () => {
|
||||
assert(mockMediaLoad.needsRetry);
|
||||
vi.mocked(mockMediaLoad.needsRetry).mockReturnValue(false);
|
||||
|
||||
createManager().retry('media_load');
|
||||
createManager().retry('media_unavailable');
|
||||
|
||||
assert(mockMediaLoad.retry);
|
||||
expect(mockMediaLoad.retry).not.toBeCalled();
|
||||
@@ -318,7 +321,7 @@ describe('IssueStateManager', () => {
|
||||
vi.mocked(mockMediaLoad.needsRetry).mockReturnValue(false);
|
||||
vi.mocked(mockMediaLoad.retry).mockReturnValue(false);
|
||||
|
||||
createManager().retry('media_load', true);
|
||||
createManager().retry('media_unavailable', true);
|
||||
|
||||
expect(mockMediaLoad.retry).toBeCalled();
|
||||
});
|
||||
@@ -374,7 +377,7 @@ describe('IssueStateManager', () => {
|
||||
manager.detectDynamic({ view: 'live' });
|
||||
|
||||
expect(spy).toBeCalledWith(
|
||||
'Advanced Camera Card [issue=media_load]: Stream issue',
|
||||
'Advanced Camera Card [issue=media_unavailable]: Stream issue',
|
||||
);
|
||||
spy.mockRestore();
|
||||
});
|
||||
@@ -387,9 +390,11 @@ describe('IssueStateManager', () => {
|
||||
vi.mocked(mockMediaLoad.getIssue).mockReturnValue(result);
|
||||
|
||||
const manager = createManager();
|
||||
manager.trigger('media_load', { targetID: 'cam1' });
|
||||
manager.trigger('media_unavailable', { targetID: 'cam1', reason: 'stalled' });
|
||||
|
||||
expect(spy).toBeCalledWith('Advanced Camera Card [issue=media_load]: Triggered');
|
||||
expect(spy).toBeCalledWith(
|
||||
'Advanced Camera Card [issue=media_unavailable]: Triggered',
|
||||
);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
@@ -398,7 +403,7 @@ describe('IssueStateManager', () => {
|
||||
vi.mocked(mockMediaLoad.getIssue).mockReturnValue(null);
|
||||
|
||||
const manager = createManager();
|
||||
manager.trigger('media_load', { targetID: 'cam1' });
|
||||
manager.trigger('media_unavailable', { targetID: 'cam1', reason: 'stalled' });
|
||||
|
||||
expect(spy).not.toBeCalled();
|
||||
spy.mockRestore();
|
||||
@@ -503,7 +508,7 @@ describe('IssueStateManager', () => {
|
||||
|
||||
const manager = createManager();
|
||||
manager.detectDynamic({ view: 'live' });
|
||||
manager.reset('media_load');
|
||||
manager.reset('media_unavailable');
|
||||
// After reset, the issue reports cleared on next detect, releasing the
|
||||
// dedupe.
|
||||
manager.detectDynamic({ view: 'live' });
|
||||
@@ -518,7 +523,7 @@ describe('IssueStateManager', () => {
|
||||
describe('reset', () => {
|
||||
it('should reset a specific issue by key', () => {
|
||||
const manager = createManager();
|
||||
manager.reset('media_load');
|
||||
manager.reset('media_unavailable');
|
||||
|
||||
assert(mockMediaLoad.reset);
|
||||
expect(mockMediaLoad.reset).toBeCalled();
|
||||
|
||||
@@ -360,4 +360,116 @@ describe('MediaLoadedInfoManager', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -732,7 +732,7 @@ describe('should initialize', () => {
|
||||
viewFactory: viewFactory,
|
||||
});
|
||||
|
||||
await manager.initialize();
|
||||
expect(await manager.initialize()).toBe(true);
|
||||
|
||||
expect(manager.getView()).toBe(view);
|
||||
});
|
||||
@@ -745,7 +745,23 @@ describe('should initialize', () => {
|
||||
true,
|
||||
);
|
||||
|
||||
await manager.initialize();
|
||||
expect(await manager.initialize()).toBe(true);
|
||||
|
||||
expect(manager.hasView()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should report failure when no view could be set', async () => {
|
||||
const viewFactory = mock<ViewFactory>();
|
||||
viewFactory.getViewDefault.mockReturnValue(createView());
|
||||
|
||||
// Cameras not being initialized causes the default view set to decline to
|
||||
// run, so initialization must report failure to allow a later retry.
|
||||
const api = createInitializedCardAPI(false);
|
||||
const manager = new ViewManager(api, {
|
||||
viewFactory: viewFactory,
|
||||
});
|
||||
|
||||
expect(await manager.initialize()).toBe(false);
|
||||
|
||||
expect(manager.hasView()).toBeFalsy();
|
||||
});
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -60,6 +60,9 @@ describe('MediaLoadedInfoSourceController', () => {
|
||||
});
|
||||
expect(ev.detail.signal).toBeInstanceOf(AbortSignal);
|
||||
expect(ev.detail.signal.aborted).toBe(false);
|
||||
|
||||
// A fresh load omits `cached` (only a replay marks it true).
|
||||
expect(ev.detail.cached).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should dedup structurally-equal info', () => {
|
||||
@@ -176,6 +179,10 @@ describe('MediaLoadedInfoSourceController', () => {
|
||||
expect(firstSignal).not.toBe(secondSignal);
|
||||
expect(firstSignal.aborted).toBe(true);
|
||||
expect(secondSignal.aborted).toBe(false);
|
||||
|
||||
// The fresh load omits `cached`; the reconnect replay marks it `true`.
|
||||
expect((handler.mock.calls[0][0] as CustomEvent).detail.cached).toBeUndefined();
|
||||
expect((handler.mock.calls[1][0] as CustomEvent).detail.cached).toBe(true);
|
||||
});
|
||||
|
||||
it('should be a no-op when there is nothing to re-dispatch', () => {
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
FRAME_STALL_SECONDS,
|
||||
FrameStallWatchdog,
|
||||
type FrameStallWatchdogConfig,
|
||||
} from '../../../src/components-lib/media-player/frame-stall-watchdog';
|
||||
|
||||
const STALL_MS = FRAME_STALL_SECONDS * 1000;
|
||||
|
||||
const createConfig = (
|
||||
overrides?: Partial<FrameStallWatchdogConfig>,
|
||||
): FrameStallWatchdogConfig => ({
|
||||
isPlaybackExpected: vi.fn().mockReturnValue(true),
|
||||
startSource: vi.fn().mockReturnValue(true),
|
||||
stopSource: vi.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('FrameStallWatchdog', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('source lifecycle', () => {
|
||||
it('should start the source only on the first subscriber', () => {
|
||||
const config = createConfig();
|
||||
const watchdog = new FrameStallWatchdog(config);
|
||||
|
||||
watchdog.subscribe(vi.fn());
|
||||
watchdog.subscribe(vi.fn());
|
||||
|
||||
expect(config.startSource).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should stop the source only on the last unsubscribe', () => {
|
||||
const config = createConfig();
|
||||
const watchdog = new FrameStallWatchdog(config);
|
||||
|
||||
const unsubscribeFirst = watchdog.subscribe(vi.fn());
|
||||
const unsubscribeSecond = watchdog.subscribe(vi.fn());
|
||||
|
||||
unsubscribeFirst();
|
||||
expect(config.stopSource).not.toHaveBeenCalled();
|
||||
|
||||
unsubscribeSecond();
|
||||
expect(config.stopSource).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should default to an always-available source needing no teardown', () => {
|
||||
const watchdog = new FrameStallWatchdog({ isPlaybackExpected: () => true });
|
||||
const callback = vi.fn();
|
||||
|
||||
const unsubscribe = watchdog.subscribe(callback);
|
||||
vi.advanceTimersByTime(STALL_MS);
|
||||
unsubscribe(); // no stopSource configured -> no throw
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stall detection', () => {
|
||||
it('should report stalled when no frame arrives within the window', () => {
|
||||
const config = createConfig();
|
||||
const watchdog = new FrameStallWatchdog(config);
|
||||
const callback = vi.fn();
|
||||
|
||||
watchdog.subscribe(callback);
|
||||
vi.advanceTimersByTime(STALL_MS);
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
expect(callback).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('should stay live while frames keep arriving within the window', () => {
|
||||
const config = createConfig();
|
||||
const watchdog = new FrameStallWatchdog(config);
|
||||
const callback = vi.fn();
|
||||
|
||||
watchdog.subscribe(callback);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
vi.advanceTimersByTime(STALL_MS - 1000);
|
||||
watchdog.notifyFrame();
|
||||
}
|
||||
vi.advanceTimersByTime(STALL_MS - 1000);
|
||||
|
||||
// Confirmed live on the first frame, and never stalled thereafter.
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
expect(callback).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should report live again once a frame arrives after a stall', () => {
|
||||
const config = createConfig();
|
||||
const watchdog = new FrameStallWatchdog(config);
|
||||
const callback = vi.fn();
|
||||
|
||||
watchdog.subscribe(callback);
|
||||
vi.advanceTimersByTime(STALL_MS);
|
||||
expect(callback).toHaveBeenNthCalledWith(1, false);
|
||||
|
||||
watchdog.notifyFrame();
|
||||
expect(callback).toHaveBeenNthCalledWith(2, true);
|
||||
});
|
||||
|
||||
it('should not report stalled while the source is legitimately idle', () => {
|
||||
const config = createConfig({
|
||||
isPlaybackExpected: vi.fn().mockReturnValue(false),
|
||||
});
|
||||
const watchdog = new FrameStallWatchdog(config);
|
||||
const callback = vi.fn();
|
||||
|
||||
watchdog.subscribe(callback);
|
||||
vi.advanceTimersByTime(STALL_MS);
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should re-arm while idle so a freeze that becomes actionable later is caught', () => {
|
||||
const isPlaybackExpected = vi.fn().mockReturnValue(false);
|
||||
const config = createConfig({ isPlaybackExpected });
|
||||
const watchdog = new FrameStallWatchdog(config);
|
||||
const callback = vi.fn();
|
||||
|
||||
watchdog.subscribe(callback);
|
||||
|
||||
// Idle window: no stall reported, but the watchdog re-arms rather than
|
||||
// stopping.
|
||||
vi.advanceTimersByTime(STALL_MS);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
|
||||
// The source becomes actionable while still frozen (holding a frame), with
|
||||
// no new frame to kick the timer. The re-armed timer catches the freeze on
|
||||
// the next window.
|
||||
isPlaybackExpected.mockReturnValue(true);
|
||||
vi.advanceTimersByTime(STALL_MS);
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
expect(callback).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('should broadcast a stall to every subscriber', () => {
|
||||
const config = createConfig();
|
||||
const watchdog = new FrameStallWatchdog(config);
|
||||
const first = vi.fn();
|
||||
const second = vi.fn();
|
||||
|
||||
watchdog.subscribe(first);
|
||||
watchdog.subscribe(second);
|
||||
vi.advanceTimersByTime(STALL_MS);
|
||||
|
||||
expect(first).toHaveBeenCalledTimes(1);
|
||||
expect(first).toHaveBeenCalledWith(false);
|
||||
expect(second).toHaveBeenCalledTimes(1);
|
||||
expect(second).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('should not report live merely on subscribing before any frame', () => {
|
||||
const config = createConfig();
|
||||
const watchdog = new FrameStallWatchdog(config);
|
||||
const callback = vi.fn();
|
||||
|
||||
// Unconfirmed until a real frame arrives: subscribing alone does not
|
||||
// report live.
|
||||
watchdog.subscribe(callback);
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should confirm live on the first frame but not re-notify on the next', () => {
|
||||
const config = createConfig();
|
||||
const watchdog = new FrameStallWatchdog(config);
|
||||
const callback = vi.fn();
|
||||
|
||||
watchdog.subscribe(callback);
|
||||
watchdog.notifyFrame();
|
||||
watchdog.notifyFrame();
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
expect(callback).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('no available source', () => {
|
||||
it('should never report stalled when the source is unavailable', () => {
|
||||
const config = createConfig({ startSource: vi.fn().mockReturnValue(false) });
|
||||
const watchdog = new FrameStallWatchdog(config);
|
||||
const callback = vi.fn();
|
||||
|
||||
watchdog.subscribe(callback);
|
||||
vi.advanceTimersByTime(STALL_MS);
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
expect(config.isPlaybackExpected).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should ignore notifyFrame when the source is unavailable', () => {
|
||||
const config = createConfig({ startSource: vi.fn().mockReturnValue(false) });
|
||||
const watchdog = new FrameStallWatchdog(config);
|
||||
const callback = vi.fn();
|
||||
|
||||
watchdog.subscribe(callback);
|
||||
watchdog.notifyFrame();
|
||||
vi.advanceTimersByTime(STALL_MS);
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not stop a source that was never started on unsubscribe', () => {
|
||||
const config = createConfig({ startSource: vi.fn().mockReturnValue(false) });
|
||||
const watchdog = new FrameStallWatchdog(config);
|
||||
|
||||
const unsubscribe = watchdog.subscribe(vi.fn());
|
||||
unsubscribe();
|
||||
|
||||
expect(config.stopSource).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('teardown', () => {
|
||||
it('should not report a stall after the last subscriber leaves', () => {
|
||||
const config = createConfig();
|
||||
const watchdog = new FrameStallWatchdog(config);
|
||||
const callback = vi.fn();
|
||||
|
||||
const unsubscribe = watchdog.subscribe(callback);
|
||||
unsubscribe();
|
||||
vi.advanceTimersByTime(STALL_MS);
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should stop cleanly if the last subscriber unsubscribes during a recovery notification', () => {
|
||||
const config = createConfig();
|
||||
const watchdog = new FrameStallWatchdog(config);
|
||||
let unsubscribe: () => void = () => {};
|
||||
const callback = vi.fn((isLive: boolean) => {
|
||||
if (isLive) {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
unsubscribe = watchdog.subscribe(callback);
|
||||
|
||||
// Stall -> callback(false)
|
||||
vi.advanceTimersByTime(STALL_MS);
|
||||
|
||||
// Recovery -> callback(true) -> unsubscribe -> stop
|
||||
watchdog.notifyFrame();
|
||||
|
||||
expect(config.stopSource).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The timer armed by notifyFrame must have been stopped, so no further
|
||||
// stall fires with no subscribers.
|
||||
callback.mockClear();
|
||||
vi.advanceTimersByTime(STALL_MS);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import type JSMpeg from '@cycjimmy/jsmpeg-player';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { FRAME_STALL_SECONDS } from '../../../src/components-lib/media-player/frame-stall-watchdog';
|
||||
import { JSMPEGMediaPlayerController } from '../../../src/components-lib/media-player/jsmpeg';
|
||||
import { createLitElement } from '../../test-utils';
|
||||
|
||||
@@ -260,4 +261,64 @@ describe('JSMPEGMediaPlayerController', () => {
|
||||
|
||||
expect(controller.getPIPElement()).toBeNull();
|
||||
});
|
||||
|
||||
describe('subscribeLiveness', () => {
|
||||
const STALL_MS = FRAME_STALL_SECONDS * 1000;
|
||||
|
||||
const setup = (options?: { paused?: boolean; hasPlayer?: boolean }) => {
|
||||
let videoElement: JSMpeg.VideoElement | null = null;
|
||||
if (options?.hasPlayer !== false) {
|
||||
videoElement = mock<JSMpeg.VideoElement>();
|
||||
videoElement.player = mock<JSMpeg.Player>();
|
||||
videoElement.player.paused = options?.paused ?? false;
|
||||
}
|
||||
const controller = new JSMPEGMediaPlayerController(
|
||||
createLitElement(),
|
||||
() => videoElement,
|
||||
() => mock<HTMLCanvasElement>(),
|
||||
);
|
||||
return { controller };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should report a stall when frame decodes stop', () => {
|
||||
const { controller } = setup();
|
||||
const callback = vi.fn();
|
||||
|
||||
controller.subscribeLiveness(callback);
|
||||
controller.notifyFrameDecoded();
|
||||
vi.advanceTimersByTime(STALL_MS);
|
||||
|
||||
// The first decoded frame confirms live, then decodes stop -> stall.
|
||||
expect(callback).toHaveBeenNthCalledWith(1, true);
|
||||
expect(callback).toHaveBeenNthCalledWith(2, false);
|
||||
});
|
||||
|
||||
it('should not report a stall while paused', () => {
|
||||
const { controller } = setup({ paused: true });
|
||||
const callback = vi.fn();
|
||||
|
||||
controller.subscribeLiveness(callback);
|
||||
vi.advanceTimersByTime(STALL_MS);
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not report a stall without a player', () => {
|
||||
const { controller } = setup({ hasPlayer: false });
|
||||
const callback = vi.fn();
|
||||
|
||||
controller.subscribeLiveness(callback);
|
||||
vi.advanceTimersByTime(STALL_MS);
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { FRAME_STALL_SECONDS } from '../../../src/components-lib/media-player/frame-stall-watchdog';
|
||||
import { VideoMediaPlayerController } from '../../../src/components-lib/media-player/video';
|
||||
import {
|
||||
hideMediaControlsTemporarily,
|
||||
@@ -16,6 +17,48 @@ class NotAllowedError extends Error {
|
||||
name = 'NotAllowedError';
|
||||
}
|
||||
|
||||
const STALL_MS = FRAME_STALL_SECONDS * 1000;
|
||||
|
||||
// A real jsdom <video> (so `'requestVideoFrameCallback' in video` is genuinely
|
||||
// false unless we add it) with controllable properties and a driveable frame
|
||||
// callback.
|
||||
const createVideo = (options?: {
|
||||
readyState?: number;
|
||||
paused?: boolean;
|
||||
seeking?: boolean;
|
||||
ended?: boolean;
|
||||
rvfc?: boolean;
|
||||
}): {
|
||||
video: HTMLVideoElement;
|
||||
deliverFrame: () => void;
|
||||
cancel: ReturnType<typeof vi.fn>;
|
||||
} => {
|
||||
const video = document.createElement('video');
|
||||
const define = (prop: string, value: unknown): void => {
|
||||
Object.defineProperty(video, prop, { value, configurable: true });
|
||||
};
|
||||
define('readyState', options?.readyState ?? HTMLMediaElement.HAVE_CURRENT_DATA);
|
||||
define('paused', options?.paused ?? false);
|
||||
define('seeking', options?.seeking ?? false);
|
||||
define('ended', options?.ended ?? false);
|
||||
|
||||
let frameCallback: (() => void) | null = null;
|
||||
const cancel = vi.fn();
|
||||
if (options?.rvfc !== false) {
|
||||
let handle = 0;
|
||||
define(
|
||||
'requestVideoFrameCallback',
|
||||
vi.fn((cb: () => void) => {
|
||||
frameCallback = cb;
|
||||
return ++handle;
|
||||
}),
|
||||
);
|
||||
define('cancelVideoFrameCallback', cancel);
|
||||
}
|
||||
|
||||
return { video, deliverFrame: () => frameCallback?.(), cancel };
|
||||
};
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('VideoMediaPlayerController', () => {
|
||||
beforeEach(() => {
|
||||
@@ -287,4 +330,145 @@ describe('VideoMediaPlayerController', () => {
|
||||
expect(controller.getPIPElement()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('subscribeLiveness', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should report a stall when frames stop arriving', () => {
|
||||
const { video, deliverFrame } = createVideo();
|
||||
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
||||
const callback = vi.fn();
|
||||
|
||||
controller.subscribeLiveness(callback);
|
||||
deliverFrame();
|
||||
vi.advanceTimersByTime(STALL_MS);
|
||||
|
||||
// The first frame confirms live, then no further frame arrives -> stall.
|
||||
expect(callback).toHaveBeenNthCalledWith(1, true);
|
||||
expect(callback).toHaveBeenNthCalledWith(2, false);
|
||||
});
|
||||
|
||||
it('should not report a stall while a paused video still holds a frame', () => {
|
||||
// A genuine user pause: paused with a current frame is idle, never
|
||||
// reported -- not even after the stall window.
|
||||
const { video } = createVideo({ paused: true });
|
||||
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
||||
const callback = vi.fn();
|
||||
|
||||
controller.subscribeLiveness(callback);
|
||||
vi.advanceTimersByTime(STALL_MS);
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should report a video stuck paused with no current frame', () => {
|
||||
// The observed go2rtc failure: its internal reconnect leaves the <video>
|
||||
// paused at readyState 0 (spinning). That is not a genuine pause -- there
|
||||
// is no frame to pause on -- so playback is still expected and a missing
|
||||
// frame is a stall.
|
||||
const { video } = createVideo({
|
||||
paused: true,
|
||||
readyState: HTMLMediaElement.HAVE_NOTHING,
|
||||
});
|
||||
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
||||
const callback = vi.fn();
|
||||
|
||||
controller.subscribeLiveness(callback);
|
||||
vi.advanceTimersByTime(STALL_MS);
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
expect(callback).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('should report no stall when requestVideoFrameCallback is unavailable', () => {
|
||||
const { video } = createVideo({ rvfc: false });
|
||||
expect('requestVideoFrameCallback' in video).toBe(false);
|
||||
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
||||
const callback = vi.fn();
|
||||
|
||||
controller.subscribeLiveness(callback);
|
||||
vi.advanceTimersByTime(STALL_MS);
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should stop watching and cancel the frame callback on unsubscribe', () => {
|
||||
const { video, cancel } = createVideo();
|
||||
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
||||
const callback = vi.fn();
|
||||
|
||||
const unsubscribe = controller.subscribeLiveness(callback);
|
||||
unsubscribe();
|
||||
vi.advanceTimersByTime(STALL_MS);
|
||||
|
||||
expect(cancel).toHaveBeenCalled();
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not re-arm when the last subscriber unsubscribes during recovery', () => {
|
||||
const { video, deliverFrame, cancel } = createVideo();
|
||||
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
||||
let unsubscribe: () => void = () => {};
|
||||
const callback = vi.fn((isLive: boolean) => {
|
||||
if (isLive) {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
unsubscribe = controller.subscribeLiveness(callback);
|
||||
|
||||
// Stall -> callback(false)
|
||||
vi.advanceTimersByTime(STALL_MS);
|
||||
|
||||
// Recovery -> callback(true) -> unsubscribe -> source stopped
|
||||
deliverFrame();
|
||||
|
||||
expect(cancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip re-arming and report no stall when the video is gone between frames', () => {
|
||||
const { video, deliverFrame } = createVideo();
|
||||
let currentVideo: HTMLVideoElement | null = video;
|
||||
const controller = new VideoMediaPlayerController(
|
||||
createLitElement(),
|
||||
() => currentVideo,
|
||||
);
|
||||
const callback = vi.fn();
|
||||
|
||||
controller.subscribeLiveness(callback);
|
||||
deliverFrame();
|
||||
|
||||
// Remove video
|
||||
currentVideo = null;
|
||||
|
||||
// Re-arm skipped: no video
|
||||
deliverFrame();
|
||||
|
||||
// The first frame reported live; the stall timer then fires with no
|
||||
// video, so no stall is ever reported.
|
||||
vi.advanceTimersByTime(STALL_MS);
|
||||
expect(callback).not.toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('should cancel nothing on unsubscribe when the video is already gone', () => {
|
||||
const { video, cancel } = createVideo();
|
||||
let currentVideo: HTMLVideoElement | null = video;
|
||||
const controller = new VideoMediaPlayerController(
|
||||
createLitElement(),
|
||||
() => currentVideo,
|
||||
);
|
||||
|
||||
const unsubscribe = controller.subscribeLiveness(vi.fn());
|
||||
currentVideo = null;
|
||||
unsubscribe();
|
||||
|
||||
expect(cancel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -220,4 +220,51 @@ describe('VisibilityObserver', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitInitial', () => {
|
||||
it('should emit the initial visible value on the baseline callback', async () => {
|
||||
const onChange = vi.fn();
|
||||
const observer = new VisibilityObserver(onChange, { emitInitial: true });
|
||||
observer.setRoot(createParent());
|
||||
|
||||
await callIntersectionHandler(true);
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should emit the initial hidden value on the baseline callback', async () => {
|
||||
const onChange = vi.fn();
|
||||
const observer = new VisibilityObserver(onChange, { emitInitial: true });
|
||||
observer.setRoot(createParent());
|
||||
|
||||
await callIntersectionHandler(false);
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('should not re-emit an unchanged state after the initial emit', async () => {
|
||||
const onChange = vi.fn();
|
||||
const observer = new VisibilityObserver(onChange, { emitInitial: true });
|
||||
observer.setRoot(createParent());
|
||||
|
||||
await callIntersectionHandler(true);
|
||||
await callIntersectionHandler(true);
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should still emit subsequent transitions after the initial emit', async () => {
|
||||
const onChange = vi.fn();
|
||||
const observer = new VisibilityObserver(onChange, { emitInitial: true });
|
||||
observer.setRoot(createParent());
|
||||
|
||||
await callIntersectionHandler(true);
|
||||
await callIntersectionHandler(false);
|
||||
|
||||
expect(onChange).toHaveBeenNthCalledWith(1, true);
|
||||
expect(onChange).toHaveBeenNthCalledWith(2, false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,38 @@ describe('Initializer', () => {
|
||||
expect(initializer.isInitialized('foo')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should not initialize when the initializer reports failure', async () => {
|
||||
const initializer = new Initializer();
|
||||
|
||||
expect(await initializer.initializeIfNecessary('foo', async () => false)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(initializer.isInitialized('foo')).toBeFalsy();
|
||||
|
||||
expect(await initializer.initializeIfNecessary('foo', async () => true)).toBe(true);
|
||||
expect(initializer.isInitialized('foo')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should report whether all of multiple aspects initialized', async () => {
|
||||
const initializer = new Initializer();
|
||||
|
||||
expect(
|
||||
await initializer.initializeMultipleIfNecessary({
|
||||
foo: async () => false,
|
||||
bar: async () => {},
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(initializer.isInitialized('foo')).toBeFalsy();
|
||||
expect(initializer.isInitialized('bar')).toBeTruthy();
|
||||
|
||||
expect(
|
||||
await initializer.initializeMultipleIfNecessary({
|
||||
foo: async () => true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(initializer.isInitialized('foo')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should not initialize with failed initializer', async () => {
|
||||
const initializer = new Initializer();
|
||||
|
||||
@@ -89,7 +121,7 @@ describe('Initializer', () => {
|
||||
initializer.uninitialize('foo');
|
||||
|
||||
finishInitializer();
|
||||
await initializing;
|
||||
expect(await initializing).toBe(false);
|
||||
|
||||
expect(initializer.isInitialized('foo')).toBeFalsy();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user