diff --git a/docs/configuration/view.md b/docs/configuration/view.md index 1f10cd99..68a2d359 100644 --- a/docs/configuration/view.md +++ b/docs/configuration/view.md @@ -50,10 +50,10 @@ view: # [...] ``` -| Option | Default | Description | -| ------------------ | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `interaction_mode` | `all` | Whether scheduled retries should happen when the card is being interacted with. If `all`, retries will always happen regardless. If `inactive` retries will only happen if the card has _not_ had human interaction recently (as defined by `view.interaction_seconds`). If `active` retries will only happen if the card _has_ had human interaction recently. User-initiated retries are always allowed. | -| `retry_seconds` | `auto` | Controls automatic retry attempts when an issue is detected (e.g. media not loading, query error). When `auto`, the card uses an exponential backoff schedule starting at ~30 seconds and capped at 10 minutes, with jitter to avoid multiple cards retrying in lockstep. A positive number sets a fixed retry interval in seconds. `0` disables automatic retries entirely. User-initiated retries (e.g. clicking a notification) always run regardless. | +| Option | Default | Description | +| ------------------ | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `interaction_mode` | `all` | Whether scheduled retries should happen when the card is being interacted with. If `all`, retries will always happen regardless. If `inactive` retries will only happen if the card has _not_ had human interaction recently (as defined by `view.interaction_seconds`). If `active` retries will only happen if the card _has_ had human interaction recently. User-initiated retries are always allowed. | +| `retry_seconds` | `auto` | Controls automatic retry attempts when an issue is detected (e.g. media not loading, query error). When `auto`, the card uses an exponential backoff schedule starting at ~5 seconds and capped at 10 minutes, with jitter to avoid multiple cards retrying in lockstep. A positive number sets a fixed retry interval in seconds. `0` disables automatic retries entirely. User-initiated retries (e.g. clicking a notification) always run regardless. This value controls how often a retry is attempted rather than whether a given problem is _ready_ to be retried: media that has failed is retried on this schedule, whereas media that is merely still loading is left alone for a grace period first, since restarting it would discard a load that is still in progress (see [media unavailable](../troubleshooting.md?id=media-unavailable)). | ## `keyboard_shortcuts` diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 2ace6f11..75f0f514 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -85,12 +85,20 @@ appears in the status bar. The card retries automatically with a back-off (and the notification offers a manual retry button). A live stream additionally reconnects on its own once its camera becomes available again. +A retry rebuilds the media from scratch, discarding the prior attempt. Media +that is merely loading slowly has not failed -- it is still loading -- so +restarting it would throw away the very attempt that may be about to succeed. A +camera reported as **Media not loading** is therefore left running for at least +30 seconds after that message appears before the card rebuilds it, unlike media +that has _actually_ failed, which is rebuilt as soon as the schedule allows. The +manual retry button rebuilds immediately regardless. + Reported reasons why media may be unavailable: | Reason | Meaning | | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Camera entity unavailable** | The camera's `camera_entity` reported `unavailable` in Home Assistant, e.g. the camera or its integration (such as Frigate) restarted, or the camera lost power or network. A short grace period is allowed before this is reported, and the card recovers automatically once the entity returns. Set [`always_error_if_entity_unavailable`](./configuration/cameras/README.md?id=cameras) to report it immediately instead. | -| **Media not loading** | The media did not finish loading within the expected time: a slow or failed initial load. Applies to live streams, the viewer, and image views. | +| **Media not loading** | The media did not finish loading within the expected time: a slow or failed initial load. Applies to live streams, the viewer, and image views. Nothing has necessarily failed yet, so the card keeps waiting on the existing load attempt already underway rather than restarting it immediately. | | **Playback error** | The live provider reported an error while trying to play the stream. | | **Stream stalled** | The stream loaded and was playing, but stopped delivering new frames with no error raised (a silent freeze). The card notices the lack of progress and reconnects. | diff --git a/src/card-controller/issues/issue-manager.ts b/src/card-controller/issues/issue-manager.ts index 668c7a27..a1cc504f 100644 --- a/src/card-controller/issues/issue-manager.ts +++ b/src/card-controller/issues/issue-manager.ts @@ -203,16 +203,6 @@ export class IssueManager { this._retryTimer.reset(); return; } - if (!this._stateManager.canRetryNow()) { - // An issue still has a failing problem but cannot retry right now. Cancel - // the pending timer so it does not fire a retry the issue cannot take -- - // but do not reset(), which is reserved for the resolved case above. - // Consequently no retry is scheduled here; scheduling resumes on a later - // evaluate() (any condition-state change re-runs this), once the issue - // reports it can retry again or has resolved. - this._retryTimer.cancel(); - return; - } if (this._retryTimer.isRunning()) { return; } @@ -238,15 +228,19 @@ export class IssueManager { : retryConfig, ); - // Schedule without advancing: the backoff only escalates if the retry - // actually runs (via the explicit advance() below), not when it's gated. + // Scheduled whether or not a retry can run right now: a hold can end with + // nothing else happening on the card (e.g. media given time to finish + // loading), so the timer has to come back and look rather than waiting to + // be woken. Scheduled without advancing: the backoff only escalates if the + // retry actually runs (via the explicit advance() below), not when it's + // held. this._retryTimer.schedule( () => { if (!this._stateManager.needsRetry()) { this._retryTimer.reset(); return; } - if (this._isScheduledRetryAllowed()) { + if (this._isScheduledRetryAllowed() && this._stateManager.canRetryNow()) { this._stateManager.retry(); // This attempt counts: advance the backoff so the next schedule @@ -256,8 +250,11 @@ export class IssueManager { this._retryTimer.advance(); this.evaluate(); } else { - // Retry was gated (e.g. user interaction). Not a failed attempt; the - // backoff stays put and we re-arm at the same delay. + // Retry was gated: the user is interacting, or every issue that needs + // one is holding off (e.g. media still loading). Nothing was + // attempted, so the backoff stays put and we re-arm at the same delay + // rather than escalating towards the ten minute cap for retries that + // never ran. this._scheduleRetryIfNeeded(); } }, diff --git a/src/card-controller/issues/issues/media-unavailable.ts b/src/card-controller/issues/issues/media-unavailable.ts index 21c91285..5cd37897 100644 --- a/src/card-controller/issues/issues/media-unavailable.ts +++ b/src/card-controller/issues/issues/media-unavailable.ts @@ -1,3 +1,4 @@ +import { add } from 'date-fns'; import type { IssueResolveContext, IssueTriggerContext } from 'issue'; import type { @@ -58,25 +59,43 @@ declare module 'issue' { interface TargetError { reason: MediaUnavailableIssueReason; description?: string; + + // The earliest the card should rebuild this target's media. Carried forward + // across repeat reports of the same failure, and refreshed when a rebuild + // starts a new attempt. + rebuildNotBefore: Date; } -// The per-cause presentation (localization key + icon), shared by the -// notification metadata and the reconnecting placeholder so each cause is +// The per-cause presentation (localization key + icon) and handling, shared by +// the notification metadata and the reconnecting placeholder so each cause is // described in exactly one place. `resetOnLoad` means a media load will reset -// this issue reason. +// this issue reason. `rebuildGraceSeconds` is how long the media is left alone +// before the card rebuilds it: media that has failed has nothing to protect and +// is rebuilt at once, so only a load still in progress asks for any. export const MEDIA_UNAVAILABLE_REASONS: Record< MediaUnavailableIssueReason, - { localizationKey: string; icon: string; resetOnLoad: boolean } + { + localizationKey: string; + icon: string; + resetOnLoad: boolean; + rebuildGraceSeconds: number; + } > = { entity_unavailable: { localizationKey: 'issues.media_unavailable.reasons.entity_unavailable', icon: 'mdi:cctv-off', resetOnLoad: false, + rebuildGraceSeconds: 0, }, not_loading: { localizationKey: 'issues.media_unavailable.reasons.not_loading', icon: 'mdi:progress-helper', resetOnLoad: true, + // A load that has not arrived yet has not necessarily failed: the provider is + // mounted and still trying, so rebuilding it destroys an attempt that may be + // about to succeed. Hold a rebuild off for at least this long, three load + // windows, to give that attempt time to finish. + rebuildGraceSeconds: 30, }, playback_error: { localizationKey: 'issues.media_unavailable.reasons.playback_error', @@ -84,16 +103,19 @@ export const MEDIA_UNAVAILABLE_REASONS: Record< // A player can load media and still fail to play it. resetOnLoad: false, + rebuildGraceSeconds: 0, }, server_error: { localizationKey: 'issues.media_unavailable.reasons.server_error', icon: 'mdi:server-network-off', resetOnLoad: true, + rebuildGraceSeconds: 0, }, stalled: { localizationKey: 'issues.media_unavailable.reasons.stalled', icon: 'mdi:motion-pause', resetOnLoad: false, + rebuildGraceSeconds: 0, }, unsupported: { localizationKey: 'issues.media_unavailable.reasons.unsupported', @@ -102,6 +124,7 @@ export const MEDIA_UNAVAILABLE_REASONS: Record< // Substitute pictures are never announced as loaded media, so a load means // the requested media was delivered in some supported way after all. resetOnLoad: true, + rebuildGraceSeconds: 0, }, }; @@ -127,12 +150,30 @@ export class MediaUnavailableIssue implements Issue { // ========================================================================= public trigger(context: IssueTriggerContext['media_unavailable']): void { + // A target already failing this same way keeps the deadline it was given, + // so repeat reports of one failure cannot push a rebuild out indefinitely. + const existing = this._erroredTargets.get(context.targetID); this._erroredTargets.set(context.targetID, { reason: context.reason, description: context.description, + rebuildNotBefore: + existing?.reason === context.reason + ? existing.rebuildNotBefore + : this._getRebuildDeadline(context.reason), }); } + private _getRebuildDeadline(reason: MediaUnavailableIssueReason): Date { + return add(new Date(), { + seconds: MEDIA_UNAVAILABLE_REASONS[reason].rebuildGraceSeconds, + }); + } + + // Whether this target's media has waited long enough to be rebuilt. + private _isRebuildDue(error: TargetError): boolean { + return new Date() >= error.rebuildNotBefore; + } + public resolve(context: IssueResolveContext['media_unavailable']): void { const error = this._erroredTargets.get(context.targetID); if (!error) { @@ -236,8 +277,21 @@ export class MediaUnavailableIssue implements Issue { return this.hasIssue(); } - public retry(): boolean { - const retryTargets = this._getDisplayedErrors(); + // False while everything on screen is still within its grace period, so the + // manager treats the moment as one where nothing was attempted rather than as + // a failed attempt that should lengthen the wait for the next one. + public canRetryNow(): boolean { + return [...this._getDisplayedErrors().values()].some((error) => + this._isRebuildDue(error), + ); + } + + public retry(force?: boolean): boolean { + const retryTargets = new Map( + [...this._getDisplayedErrors()].filter( + ([, error]) => force || this._isRebuildDue(error), + ), + ); if (!retryTargets.size) { return false; } @@ -250,6 +304,10 @@ export class MediaUnavailableIssue implements Issue { mediaEpoch[id] = (mediaEpoch[id] ?? 0) + 1; } + for (const error of retryTargets.values()) { + error.rebuildNotBefore = this._getRebuildDeadline(error.reason); + } + // Intentionally keep _erroredTargets in place. The issue stays visible // while the provider re-attempts loading underneath. If the retry succeeds, // the fresh load clears a not-loading error and the rebuilt provider's diff --git a/src/card-controller/issues/state-manager.ts b/src/card-controller/issues/state-manager.ts index 13a2be2c..67401374 100644 --- a/src/card-controller/issues/state-manager.ts +++ b/src/card-controller/issues/state-manager.ts @@ -141,7 +141,7 @@ export class IssueStateManager implements IssueReadOnlyState { if (!force && !this._canRetryNow(issue)) { continue; } - if (issue.retry?.()) { + if (issue.retry?.(force)) { return; } } diff --git a/src/card-controller/issues/types.ts b/src/card-controller/issues/types.ts index 24dee78b..b8079d32 100644 --- a/src/card-controller/issues/types.ts +++ b/src/card-controller/issues/types.ts @@ -92,9 +92,10 @@ export interface Issue { // retries bypass it. Defaults to needsRetry() when not implemented. canRetryNow?(): boolean; - // Called by the manager when a retry is due. Returns true to stop the retry - // loop (exclusive), false to allow subsequent issues to also retry. - retry?(): boolean; + // Called by the manager when a retry is due. `force` marks a retry the user + // asked for by hand. Returns true to stop the retry loop (exclusive), false + // to allow subsequent issues to also retry. + retry?(force?: boolean): boolean; // Optional user-initiated fix. Not called by the issue infrastructure -- // callers (e.g. notification control actions) invoke this directly. diff --git a/src/components/image-updating-player.ts b/src/components/image-updating-player.ts index 2599c5ac..0d545f50 100644 --- a/src/components/image-updating-player.ts +++ b/src/components/image-updating-player.ts @@ -537,7 +537,7 @@ export class AdvancedCameraCardImageUpdatingPlayer this._imageLoadError = true; } - this._dispatchError('not_loading'); + this._dispatchError('server_error'); }} /> ` diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 6f9addc2..11f24cff 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -909,7 +909,7 @@ "entity_unavailable": "Camera entity unavailable", "not_loading": "Media not loading", "playback_error": "Playback error", - "server_error": "Streaming server error", + "server_error": "Media server error", "stalled": "Stream stalled", "unsupported": "Stream not supported" }, diff --git a/tests/card-controller/issues/issue-manager.test.ts b/tests/card-controller/issues/issue-manager.test.ts index cf5166e7..fc564280 100644 --- a/tests/card-controller/issues/issue-manager.test.ts +++ b/tests/card-controller/issues/issue-manager.test.ts @@ -841,8 +841,8 @@ describe('IssueManager', () => { }); }); - describe('in-flight retry', () => { - it('should hold the backoff and not arm a timer while a retry is in flight', () => { + describe('when an issue cannot retry yet', () => { + it('should not retry while one is in flight without exponentially backing off', () => { vi.spyOn(Math, 'random').mockReturnValue(0.5); const api = createCardAPI(); const config = createConfig(); @@ -872,8 +872,8 @@ describe('IssueManager', () => { expect(issue.retry).toHaveBeenCalledTimes(1); // The attempt is now in flight: the problem is still unresolved - // (needsRetry) but cannot be retried right now (canRetryNow). The running - // timer is canceled and no further attempt fires, however long we wait. + // (needsRetry) but cannot be retried right now (canRetryNow). No further + // attempt fires, however long we wait. canRetryNow.mockReturnValue(false); manager.evaluate(); vi.advanceTimersByTime(RETRY_EXPONENTIAL_MAX_SECONDS * 1000); @@ -890,6 +890,39 @@ describe('IssueManager', () => { vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 2 * 0.75 * 1000); expect(issue.retry).toHaveBeenCalledTimes(2); }); + + it('should retry on its own once the issue becomes retryable', () => { + const api = createCardAPI(); + const config = createConfig(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue({ + ...config, + view: { + ...config.view, + issues: { interaction_mode: 'all', retry_seconds: 'auto' }, + }, + }); + const manager = new IssueManager(api); + + const canRetryNow = vi.fn().mockReturnValue(false); + const issue = createIssue('media_unavailable', { + hasIssue: vi.fn().mockReturnValue(true), + needsRetry: vi.fn().mockReturnValue(true), + canRetryNow, + retry: vi.fn().mockReturnValue(false), + }); + manager.addIssue(issue); + + manager.evaluate(); + + vi.advanceTimersByTime(RETRY_EXPONENTIAL_MAX_SECONDS * 1000); + expect(issue.retry).not.toHaveBeenCalled(); + + canRetryNow.mockReturnValue(true); + + // Deliberately no evaluate() here: nothing on the card has changed. + vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 1000); + expect(issue.retry).toHaveBeenCalledTimes(1); + }); }); describe('reset', () => { diff --git a/tests/card-controller/issues/issues/media-unavailable.browser.test.ts b/tests/card-controller/issues/issues/media-unavailable.browser.test.ts index bda19426..92092e0a 100644 --- a/tests/card-controller/issues/issues/media-unavailable.browser.test.ts +++ b/tests/card-controller/issues/issues/media-unavailable.browser.test.ts @@ -1,6 +1,7 @@ import { afterEach, assert, beforeEach, describe, expect, it, vi } from 'vitest'; import { RETRY_EXPONENTIAL_BASE_SECONDS } from '../../../../src/card-controller/issues/issue-manager'; +import { MEDIA_UNAVAILABLE_REASONS } from '../../../../src/card-controller/issues/issues/media-unavailable'; import { LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS } from '../../../../src/components-lib/live/liveness/detectors/entity-availability'; import { MEDIA_LOADING_TIMEOUT_SECONDS } from '../../../../src/components-lib/media-load-watchdog-controller'; import { FRAME_STALL_SECONDS } from '../../../../src/components-lib/media-player/frame-stall-watchdog'; @@ -437,6 +438,31 @@ describe('MediaUnavailableIssue', () => { expect(isIssueReported(card)).toBe(true); }); + it('should wait out the grace period before rebuilding a slow camera', async () => { + const retrySeconds = 5; + const mediaURL = createUnansweredMediaURL(); + const card = await mountCard({ + view: { issues: { retry_seconds: retrySeconds } }, + cameras: [createStillImageCameraConfig(CAMERA_ENTITY, mediaURL)], + }); + + await card.waitForSelector('img'); + await card.advanceSeconds(MEDIA_LOADING_TIMEOUT_SECONDS); + expect(isIssueReported(card)).toBe(true); + + // Several retries fall due during the grace period, but rebuilding would + // discard a load that may still complete. + await card.advanceSeconds( + MEDIA_UNAVAILABLE_REASONS.not_loading.rebuildGraceSeconds - 1, + ); + expect(getTestMediaRequestCount(mediaURL)).toBe(1); + + // Past the grace period the attempt has had long enough, and the next retry + // replaces it. + await card.advanceSeconds(retrySeconds + 1); + expect(getTestMediaRequestCount(mediaURL)).toBeGreaterThan(1); + }); + it('should keep retrying a camera that is still broken', async () => { // Must use the real clock: fake time moves the card's timers instantly, so // a request would never get a chance to answer between one retry and the @@ -470,12 +496,14 @@ describe('MediaUnavailableIssue', () => { expect(isLiveMediaShowing(card.card)).toBe(true); // Exactly the two attempts that failed, so the retry ran once rather than - // spinning until something happened to work. + // spinning until something happened to work. A refused request is reported + // as a server error rather than a slow load, which is what allows it to be + // retried without waiting. expect( card.events .getEntries('advanced-camera-card:issue:trigger') .map((entry) => getIssueReason(entry.detail)), - ).toEqual(['not_loading', 'not_loading']); + ).toEqual(['server_error', 'server_error']); }); it('should re-attempt when the retry control is used', async () => { diff --git a/tests/card-controller/issues/issues/media-unavailable.test.ts b/tests/card-controller/issues/issues/media-unavailable.test.ts index 988573b4..ec1f8fc7 100644 --- a/tests/card-controller/issues/issues/media-unavailable.test.ts +++ b/tests/card-controller/issues/issues/media-unavailable.test.ts @@ -1,7 +1,10 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { CardController } from '../../../../src/card-controller/controller'; -import { MediaUnavailableIssue } from '../../../../src/card-controller/issues/issues/media-unavailable'; +import { + MEDIA_UNAVAILABLE_REASONS, + MediaUnavailableIssue, +} from '../../../../src/card-controller/issues/issues/media-unavailable'; 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'; @@ -13,6 +16,9 @@ import { import { createCardAPI } from '../../../test-utils'; import { createView } from '../../../view/test-utils'; +// Read from the policy itself rather than restated here. +const LOADING_GRACE_SECONDS = MEDIA_UNAVAILABLE_REASONS.not_loading.rebuildGraceSeconds; + const createAPIWithView = (view: View | null): CardController => { const api = createCardAPI(); vi.mocked(api.getViewManager().getView).mockReturnValue(view); @@ -339,7 +345,7 @@ describe('MediaUnavailableIssue', () => { ['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'], - ['server_error' as const, 'Streaming server error', 'mdi:server-network-off'], + ['server_error' as const, 'Media server error', 'mdi:server-network-off'], ['stalled' as const, 'Stream stalled', 'mdi:motion-pause'], ['unsupported' as const, 'Stream not supported', 'mdi:video-off-outline'], ])('should give the %s cause its own text and icon', (reason, text, icon) => { @@ -485,6 +491,154 @@ describe('MediaUnavailableIssue', () => { }); }); + describe('rebuilding a load that has not arrived', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should not rebuild media that is still loading', () => { + const api = createAPIDisplaying('camera-1'); + const issue = new MediaUnavailableIssue(api); + + issue.trigger({ targetID: 'camera-1', reason: 'not_loading' }); + + vi.advanceTimersByTime(LOADING_GRACE_SECONDS * 1000 - 1); + + expect(issue.retry()).toBe(false); + expect(api.getViewManager().setViewWithMergedContext).not.toHaveBeenCalled(); + }); + + it('should rebuild media that has still not arrived once the grace period has passed', () => { + const api = createAPIDisplaying('camera-1'); + const issue = new MediaUnavailableIssue(api); + + issue.trigger({ targetID: 'camera-1', reason: 'not_loading' }); + + vi.advanceTimersByTime(LOADING_GRACE_SECONDS * 1000); + + issue.retry(); + + expect(api.getViewManager().setViewWithMergedContext).toHaveBeenCalledWith({ + mediaEpoch: { 'camera-1': 1 }, + }); + }); + + it('should rebuild media that has failed at once', () => { + const api = createAPIDisplaying('camera-1'); + const issue = new MediaUnavailableIssue(api); + + issue.trigger({ targetID: 'camera-1', reason: 'stalled' }); + + issue.retry(); + + expect(api.getViewManager().setViewWithMergedContext).toHaveBeenCalledWith({ + mediaEpoch: { 'camera-1': 1 }, + }); + }); + + it('should rebuild media that is still loading when the user asks', () => { + const api = createAPIDisplaying('camera-1'); + const issue = new MediaUnavailableIssue(api); + + issue.trigger({ targetID: 'camera-1', reason: 'not_loading' }); + + issue.retry(true); + + expect(api.getViewManager().setViewWithMergedContext).toHaveBeenCalledWith({ + mediaEpoch: { 'camera-1': 1 }, + }); + }); + + it('should only rebuild media that is ready for a retry', () => { + const api = createAPIDisplaying('camera-1', 'camera-2'); + const issue = new MediaUnavailableIssue(api); + + issue.trigger({ targetID: 'camera-1', reason: 'not_loading' }); + issue.trigger({ targetID: 'camera-2', reason: 'stalled' }); + + issue.retry(); + + expect(api.getViewManager().setViewWithMergedContext).toHaveBeenCalledWith({ + mediaEpoch: { 'camera-2': 1 }, + }); + }); + + it('should not let repeated reports of one failure postpone the rebuild', () => { + const api = createAPIDisplaying('camera-1'); + const issue = new MediaUnavailableIssue(api); + + issue.trigger({ targetID: 'camera-1', reason: 'not_loading' }); + + vi.advanceTimersByTime(LOADING_GRACE_SECONDS * 0.5 * 1000); + issue.trigger({ targetID: 'camera-1', reason: 'not_loading' }); + vi.advanceTimersByTime(LOADING_GRACE_SECONDS * 0.5 * 1000); + + issue.retry(); + + expect(api.getViewManager().setViewWithMergedContext).toHaveBeenCalledWith({ + mediaEpoch: { 'camera-1': 1 }, + }); + }); + + it('should rebuild at once when media that was still loading is reported as failed', () => { + const api = createAPIDisplaying('camera-1'); + const issue = new MediaUnavailableIssue(api); + + issue.trigger({ targetID: 'camera-1', reason: 'not_loading' }); + issue.trigger({ targetID: 'camera-1', reason: 'stalled' }); + + issue.retry(); + + expect(api.getViewManager().setViewWithMergedContext).toHaveBeenCalledWith({ + mediaEpoch: { 'camera-1': 1 }, + }); + }); + + it('should not allow a retry while everything on screen is still loading', () => { + const api = createAPIDisplaying('camera-1'); + const issue = new MediaUnavailableIssue(api); + + issue.trigger({ targetID: 'camera-1', reason: 'not_loading' }); + + expect(issue.canRetryNow()).toBe(false); + + vi.advanceTimersByTime(LOADING_GRACE_SECONDS * 1000); + + expect(issue.canRetryNow()).toBe(true); + }); + + it('should allow a retry when any media has failed', () => { + const api = createAPIDisplaying('camera-1', 'camera-2'); + const issue = new MediaUnavailableIssue(api); + + issue.trigger({ targetID: 'camera-1', reason: 'not_loading' }); + issue.trigger({ targetID: 'camera-2', reason: 'stalled' }); + + expect(issue.canRetryNow()).toBe(true); + }); + + it('should give a rebuilt load a fresh grace period', () => { + const api = createAPIDisplaying('camera-1'); + const issue = new MediaUnavailableIssue(api); + + issue.trigger({ targetID: 'camera-1', reason: 'not_loading' }); + vi.advanceTimersByTime(LOADING_GRACE_SECONDS * 1000); + issue.retry(); + + vi.mocked(api.getViewManager().setViewWithMergedContext).mockClear(); + + issue.trigger({ targetID: 'camera-1', reason: 'not_loading' }); + vi.advanceTimersByTime(LOADING_GRACE_SECONDS * 1000 - 1); + issue.retry(); + + expect(api.getViewManager().setViewWithMergedContext).not.toHaveBeenCalled(); + }); + }); + describe('reset', () => { it('should forget every failure', () => { const issue = new MediaUnavailableIssue(createAPIDisplaying('camera-1'));