feat: Automatically recover from frozen live streams (#2569)
- Closes: #2099
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user