From c137f4c00c95a7ece6a572c2c33dfcad114544ca Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Wed, 5 Aug 2026 21:35:47 -0700 Subject: [PATCH] fix: Report and retry media failures for every camera in a grid (#2658) - Closes: #2637 - Related: #2099 --- src/card-controller/issues/factory.ts | 2 +- .../issues/issues/media-unavailable.ts | 218 +--- .../media-load-watchdog-controller.ts | 219 ++++ src/components/image-updating-player.ts | 16 + src/components/image.ts | 49 +- src/components/live/grid.ts | 19 +- src/components/live/provider.ts | 81 +- src/components/viewer/grid.ts | 19 +- src/components/viewer/provider.ts | 35 +- src/condition-trigger/conditions/types.ts | 2 +- src/scss/media-grid.scss | 6 + src/scss/notification-block.scss | 4 +- src/view/layout.ts | 85 ++ src/view/view.ts | 2 +- tests/browser/mounted-card.ts | 23 +- tests/browser/test-utils.ts | 4 +- tests/camera-manager/manager.browser.test.ts | 6 +- .../automations-manager.browser.test.ts | 7 +- .../card-element-manager.browser.test.ts | 4 +- .../session-manager.browser.test.ts | 4 +- tests/card-controller/issues/factory.test.ts | 39 +- .../issues/issue-manager.test.ts | 4 + .../issues/initialization.browser.test.ts | 4 +- .../issues/media-unavailable.browser.test.ts | 245 ++++- .../issues/issues/media-unavailable.test.ts | 955 +++++------------- .../keyboard-state-manager.browser.test.ts | 4 +- .../style-manager.browser.test.ts | 7 +- .../media-load-watchdog-controller.test.ts | 455 +++++++++ .../image-updating-player.browser.test.ts | 4 +- tests/dist/dist.browser.test.ts | 6 +- tests/view/layout.test.ts | 191 ++++ 31 files changed, 1719 insertions(+), 1000 deletions(-) create mode 100644 src/components-lib/media-load-watchdog-controller.ts create mode 100644 src/view/layout.ts create mode 100644 tests/components-lib/media-load-watchdog-controller.test.ts create mode 100644 tests/view/layout.test.ts diff --git a/src/card-controller/issues/factory.ts b/src/card-controller/issues/factory.ts index 80e4583d..a445f250 100644 --- a/src/card-controller/issues/factory.ts +++ b/src/card-controller/issues/factory.ts @@ -32,7 +32,7 @@ export const createIssueManager = ( manager.addIssue(new InitializationIssue(api)); manager.addIssue(new LegacyResourceIssue(changeCallback)); manager.addIssue(new MediaQueryIssue(api)); - manager.addIssue(new MediaUnavailableIssue(api, changeCallback)); + manager.addIssue(new MediaUnavailableIssue(api)); return manager; }; diff --git a/src/card-controller/issues/issues/media-unavailable.ts b/src/card-controller/issues/issues/media-unavailable.ts index 01cadd38..b84a7f8e 100644 --- a/src/card-controller/issues/issues/media-unavailable.ts +++ b/src/card-controller/issues/issues/media-unavailable.ts @@ -1,16 +1,13 @@ import type { IssueResolveContext, IssueTriggerContext } from 'issue'; -import type { ConditionState } from '../../../condition-trigger/conditions/types.js'; import type { Notification, NotificationDetail, } from '../../../config/schema/actions/types.js'; import { TROUBLESHOOTING_MEDIA_URL } from '../../../const.js'; import { localize } from '../../../localize/localize.js'; -import type { UnsubscribeCallback } from '../../../types.js'; -import { Timer } from '../../../utils/timer.js'; +import { getDisplayedTargetIDs } from '../../../view/layout.js'; import { IMAGE_VIEW_TARGET_ID_SENTINEL } from '../../../view/target-id.js'; -import { isAnyMediaViewName } from '../../../view/view.js'; import type { CardIssueManagerAPI } from '../../types.js'; import { createRetryControl } from '../retry-control.js'; import type { Issue, IssueDescription } from '../types.js'; @@ -40,6 +37,9 @@ declare module 'issue' { interface IssueResolveContext { media_unavailable: { targetID: string; + + // Optionally limits the clearing to one kind of failure. + reason?: MediaUnavailableIssueReason; }; } } @@ -50,8 +50,6 @@ interface TargetError { description?: string; } -export const MEDIA_LOADING_TIMEOUT_SECONDS = 10; - // The per-cause presentation (localization key + icon), shared by the // notification metadata and the reconnecting placeholder so each cause is // described in exactly one place. @@ -85,33 +83,20 @@ export const MEDIA_UNAVAILABLE_REASONS: Record< }, }; +// Reports media failures for the targets the user can currently see. Failures +// are raised and cleared by the components observing the media (e.g. providers) +// -- player errors and stalls via the liveness detectors, and a load that never +// arrives via each media host's load watchdog. This issue scopes them to the +// displayed targets and drives the throttled reload that retries them. export class MediaUnavailableIssue implements Issue { public readonly key = 'media_unavailable' as const; - private _issueActive = false; private _erroredTargets = new Map(); - // Timer fires when a target has been loading too long without success. - private _timer = new Timer(); - private _timerTargetID: string | null = null; - private _api: CardIssueManagerAPI; - private _onChange: (() => void) | null; - private _unsubscribeCallback: UnsubscribeCallback; - constructor(api: CardIssueManagerAPI, onChange?: () => void) { + constructor(api: CardIssueManagerAPI) { this._api = api; - this._onChange = onChange ?? null; - - // React to a target's media loading; unload / select changes are - // irrelevant here. - this._unsubscribeCallback = this._api - .getMediaLoadedInfoManager() - .subscribe((change) => { - if (change.type === 'load') { - this._onMediaLoad(change.targetID); - } - }); } // ========================================================================= @@ -126,67 +111,29 @@ export class MediaUnavailableIssue implements Issue { }); } - // A target is proven to be delivering media again. Stronger evidence than a - // media load, which only says a player attached, so it clears any recorded - // error. public resolve(context: IssueResolveContext['media_unavailable']): void { + const error = this._erroredTargets.get(context.targetID); + if (!error || (context.reason && context.reason !== error.reason)) { + return; + } + this._erroredTargets.delete(context.targetID); - this._cancelPendingTimer(context.targetID); - } - - // ========================================================================= - // Detection -- called by the manager on every state change. - // ========================================================================= - - public detectDynamic(state: ConditionState): void { - if (!isAnyMediaViewName(state.view)) { - this._deactivate(); - return; - } - - // A known error for the current target activates immediately, even if its - // (frozen) media still reads as loaded (it might be loaded but then - // reported a playback error that stops playback but leaves the player - // attached). Errors are cleared out-of-band, by `resolve` or by - // `_onMediaLoad`. - if (this._hasError(state)) { - this._activate(); - return; - } - - if (state.mediaLoadedInfo) { - // Loaded with no known error: healthy. - this._deactivate(); - return; - } - - this._handlePendingLoad(state); - } - - // A load proves media attached for the target. That ends any wait on it, and - // refutes a `not_loading` error. It is no evidence of recovery for any other - // reason, so those clear only via `resolve`. - private _onMediaLoad(targetID: string): void { - let changed = this._cancelPendingTimer(targetID); - if (this._erroredTargets.get(targetID)?.reason === 'not_loading') { - this._erroredTargets.delete(targetID); - changed = true; - } - if (changed) { - this._onChange?.(); - } } // ========================================================================= // State queries -- called by the manager to read current state. // ========================================================================= + // Reported exactly when a target on screen has a known failure, even if its + // (frozen) media still reads as loaded (it might be loaded but then reported + // a playback error that stops playback but leaves the player attached). + // Failures are cleared out-of-band, by `resolve`. public hasIssue(): boolean { - return this._issueActive; + return !!this._getDisplayedErrors().size; } public getIssue(): IssueDescription | null { - if (!this._issueActive) { + if (!this.hasIssue()) { return null; } return { @@ -197,20 +144,7 @@ export class MediaUnavailableIssue implements Issue { } public getNotification(): Notification { - const targets = new Map(this._erroredTargets); - - // The pending-load timer's target is a slow initial load that has not yet - // errored. Gate on the timer still running: once it is stopped (a hard error - // on another target took over, or the view moved on), _timerTargetID lingers - // and would otherwise paint a stale "not loading" line for a target that has - // since loaded. - if ( - this._timerTargetID && - this._timer.isRunning() && - !targets.has(this._timerTargetID) - ) { - targets.set(this._timerTargetID, { reason: 'not_loading' }); - } + const targets = this._getDisplayedErrors(); // The free-text causes go in the context block rather than on the metadata // lines, which stay short enough to scan when several cameras fail at once. @@ -269,20 +203,11 @@ export class MediaUnavailableIssue implements Issue { // ========================================================================= public needsRetry(): boolean { - return this._issueActive; + return this.hasIssue(); } public retry(): boolean { - // Build the set of targets to retry: all errored targets plus the - // target the pending timer is tracking (so a user-initiated retry - // works even before the timeout fires). A stopped timer leaves - // _timerTargetID behind, so gate on it still running: that target may - // since have loaded. - const retryTargets = new Set(this._erroredTargets.keys()); - if (this._timerTargetID && this._timer.isRunning()) { - retryTargets.add(this._timerTargetID); - } - + const retryTargets = this._getDisplayedErrors(); if (!retryTargets.size) { return false; } @@ -291,16 +216,15 @@ export class MediaUnavailableIssue implements Issue { // only way to rebuild a stream from scratch. const view = this._api.getViewManager().getView(); const mediaEpoch = { ...(view?.context?.mediaEpoch ?? {}) }; - for (const id of retryTargets) { + for (const id of retryTargets.keys()) { mediaEpoch[id] = (mediaEpoch[id] ?? 0) + 1; } - // Intentionally keep _issueActive, _erroredTargets, and the pending - // timer 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 liveness observation - // resolves a stream error. If it fails silently (e.g. bogus stream name), - // the error stays visible immediately -- no new 10s grace period. + // 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 + // liveness observation resolves a stream error. If it fails silently (e.g. + // bogus stream name), the error stays visible immediately. this._api.getViewManager().setViewWithMergedContext({ mediaEpoch }); return false; } @@ -310,83 +234,27 @@ export class MediaUnavailableIssue implements Issue { // ========================================================================= public reset(): void { - this._deactivate(); this._erroredTargets.clear(); } - // Stop reacting to media loads at end of life. - public destroy(): void { - this._unsubscribeCallback(); - } - - // Stop the pending-load timer so offscreen time doesn't count toward the - // 10s threshold. Preserve _issueActive, _erroredTargets, and - // _timerTargetID: already-visible errors remain visible on reattach, and - // retaining _timerTargetID lets the existing active/target-mismatch guard - // in _handlePendingLoad avoid spuriously deactivating the preserved - // issue when the same target is still loading on resume. The timer is - // re-armed with a fresh window by the next detectDynamic pass. - public suspend(): void { - this._timer.stop(); - } - // ========================================================================= // Private helpers. // ========================================================================= - // Stop waiting on a target's load, if it is the one being waited on. Returns - // whether it was. - private _cancelPendingTimer(targetID: string): boolean { - if (this._timerTargetID !== targetID) { - return false; - } - this._timer.stop(); - this._timerTargetID = null; - return true; - } - - // Media not yet loaded and no known error: start (or keep) a timeout to catch - // a slow or failed initial load. No targetID means no provider is rendering - // media (e.g. the viewer shows "No media to display"), so there's nothing to - // wait for. - private _handlePendingLoad(state: ConditionState): void { - if (!state.targetID) { - this._deactivate(); - return; + // The errored targets the user can currently see. An error recorded for a + // target that has since left the screen names something they cannot look at, + // and reloading it would achieve nothing. Read fresh rather than remembered: + // a change in conditions can re-evaluate an override that replaces the + // configured cameras, leaving the view exactly as it was. + private _getDisplayedErrors(): Map { + const view = this._api.getViewManager().getView(); + if (!view) { + return new Map(); } - const targetID = state.targetID; - - // When the target changes, clear the active state so the new target gets - // its own timeout window instead of inheriting the previous target's. - if (this._issueActive && this._timerTargetID !== targetID) { - this._deactivate(); - } - - // Start (or restart) the timer for this target. - if (!this._timer.isRunning() || this._timerTargetID !== targetID) { - this._timerTargetID = targetID; - this._timer.start(MEDIA_LOADING_TIMEOUT_SECONDS, () => { - // Record the error on timeout so retry() knows which epoch to bump. - this.trigger({ targetID, reason: 'not_loading' }); - this._activate(); - this._onChange?.(); - }); - } - } - - private _hasError(state: ConditionState): boolean { - return !!state.targetID && this._erroredTargets.has(state.targetID); - } - - private _activate(): void { - this._timer.stop(); - this._issueActive = true; - } - - private _deactivate(): void { - this._timer.stop(); - this._timerTargetID = null; - this._issueActive = false; + const displayedTargetIDs = getDisplayedTargetIDs(view, this._api.getCameraManager()); + return new Map( + [...this._erroredTargets].filter(([targetID]) => displayedTargetIDs.has(targetID)), + ); } } diff --git a/src/components-lib/media-load-watchdog-controller.ts b/src/components-lib/media-load-watchdog-controller.ts new file mode 100644 index 00000000..51fda764 --- /dev/null +++ b/src/components-lib/media-load-watchdog-controller.ts @@ -0,0 +1,219 @@ +import type { ReactiveController, ReactiveControllerHost } from 'lit'; + +import type { + IssueResolveEventData, + IssueTriggerEventData, +} from '../card-controller/issues/types'; +import type { MediaLoadedInfoEventDetail } from '../types'; +import { onAbort } from '../utils/abort-signal'; +import { Generation } from '../utils/concurrency/generation'; +import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event'; +import { Timer } from '../utils/timer'; + +const MEDIA_LOADED_EVENT = 'advanced-camera-card:media:loaded'; + +export const MEDIA_LOADING_TIMEOUT_SECONDS = 10; + +interface MediaLoadWatchdogConfig { + getTargetID: () => string | null; + + // Whether the host is currently trying to load media. False when loading is + // held back (e.g. lazy loading), or when the host is showing a failure of its + // own rather than waiting for media. + isLoadExpected: () => boolean; + + // Changes when the media underneath is rebuilt without the host itself being + // rebuilt (e.g. a retry re-keying a player below it), so the wait starts + // again. Omitted by a host that a retry replaces outright. + getAttemptID?: () => unknown; +} + +/** + * Watches a media host's load: when the host expects media but none arrives + * within the window, raises a `media_unavailable` issue with reason + * `not_loading`, and clears it again once media does arrive. + * + * Loads are observed from the host's own bubble path, so a `media:loaded` + * dispatched by any descendant player ends the wait, and that media going away + * (its event's abort signal) restarts it if a load is still expected. + * + * A host may be reused for a succession of targets (a viewer slide swiped from + * one media item to the next), so everything learned is scoped to the target it + * was learned about. A target change starts a fresh wait and resolves any + * failure reported for the target left behind. + */ +export class MediaLoadWatchdogController implements ReactiveController { + private _host: ReactiveControllerHost & HTMLElement; + private _config: MediaLoadWatchdogConfig; + + private _timer = new Timer(); + + // What the state below describes: the target, and which attempt at loading it + // (see `getAttemptID`). A change in either means everything known is about + // media the host has moved on from. + private _targetID: string | null = null; + private _attemptID: unknown = undefined; + + private _mediaLoaded = false; + + // Identifies which load `_mediaLoaded` describes. One player can replace + // another for the same target before the first has gone away, and the first + // going away must not be read as the current media being lost. + private _loadGeneration = new Generation(); + + // Whether the timeout has fired for the current attempt, so a hung load is + // reported only once. A retry is a new attempt and is waited on afresh. + private _fired = false; + + // The target this watchdog has an outstanding failure reported for, so that + // failure can be resolved if the host moves on to another target. It + // outlives the attempt that reported it, which keeps the failure on screen + // while a retry runs underneath. + private _reportedTargetID: string | null = null; + + // Media loads, and the media going away, are observed through listeners and + // abort signals that can outlive a disconnect, and a detached host must not be + // waited on. + private _connected = false; + + constructor( + host: ReactiveControllerHost & HTMLElement, + config: MediaLoadWatchdogConfig, + ) { + this._host = host; + this._config = config; + host.addController(this); + } + + public hostConnected(): void { + this._connected = true; + this._host.addEventListener(MEDIA_LOADED_EVENT, this._onMediaLoaded); + this._evaluate(); + } + + public hostDisconnected(): void { + this._connected = false; + this._host.removeEventListener(MEDIA_LOADED_EVENT, this._onMediaLoaded); + this._timer.stop(); + } + + public hostUpdated(): void { + this._evaluate(); + } + + private _forgetLoad(): void { + this._mediaLoaded = false; + this._fired = false; + this._loadGeneration.invalidate(); + this._timer.stop(); + } + + private _onMediaLoaded = (ev: CustomEvent): void => { + const targetID = ev.detail.info.targetID; + + // The host's own target is read fresh so a load arriving as the host + // switches is attributed to whichever target it actually describes. + if (!targetID || targetID !== this._config.getTargetID()) { + return; + } + + // This load replaces whatever was being followed, which may be a different + // target the host has just moved off. + this._syncAttempt(); + this._forgetLoad(); + + this._mediaLoaded = true; + const generation = this._loadGeneration.next(); + + // Media arriving disproves a not-loading failure whoever reported it. + this._resolveFailure(targetID); + + // Media going away is not itself a failure, but a load that is still + // expected afterwards needs a fresh wait. This is recorded whether or not + // the host is attached, since a detached host has no media either. + onAbort(ev.detail.signal, () => { + if (targetID !== this._targetID || !this._loadGeneration.isCurrent(generation)) { + return; + } + + this._mediaLoaded = false; + this._evaluate(); + }); + }; + + // Clear a target's not-loading failure, whichever component reported it. + // Naming the reason leaves a failure reported for any other reason alone. + private _resolveFailure(targetID: string): void { + if (this._reportedTargetID === targetID) { + this._reportedTargetID = null; + } + + fireAdvancedCameraCardEvent(this._host, 'issue:resolve', { + key: 'media_unavailable', + targetID, + reason: 'not_loading', + }); + } + + // Discard everything learned about a previous target, or a previous attempt + // at the same one. + private _syncAttempt(): void { + const targetID = this._config.getTargetID(); + const attemptID = this._config.getAttemptID?.(); + if (targetID === this._targetID && attemptID === this._attemptID) { + return; + } + + // If an abandoned target was previously reported, resolve it so it's not + // stuck forever + if (this._reportedTargetID && this._reportedTargetID !== targetID) { + this._resolveFailure(this._reportedTargetID); + } + + this._targetID = targetID; + this._attemptID = attemptID; + this._forgetLoad(); + } + + private _evaluate(): void { + if (!this._connected) { + return; + } + + this._syncAttempt(); + + if (!this._targetID || this._mediaLoaded || !this._config.isLoadExpected()) { + this._fired = false; + this._timer.stop(); + return; + } + + if (!this._fired && !this._timer.isRunning()) { + this._timer.start(MEDIA_LOADING_TIMEOUT_SECONDS, () => this._onTimeout()); + } + } + + private _onTimeout(): void { + const targetID = this._targetID; + + // Conditions may have changed while the timer matured. + if ( + !this._connected || + !targetID || + targetID !== this._config.getTargetID() || + this._attemptID !== this._config.getAttemptID?.() || + this._mediaLoaded || + !this._config.isLoadExpected() + ) { + return; + } + + this._fired = true; + this._reportedTargetID = targetID; + fireAdvancedCameraCardEvent(this._host, 'issue:trigger', { + key: 'media_unavailable', + targetID, + reason: 'not_loading', + }); + } +} diff --git a/src/components/image-updating-player.ts b/src/components/image-updating-player.ts index f63f1925..b7ec376c 100644 --- a/src/components/image-updating-player.ts +++ b/src/components/image-updating-player.ts @@ -126,6 +126,13 @@ export class AdvancedCameraCardImageUpdatingPlayer private _refImage: Ref = createRef(); + // Whether the currently holds the stock image swapped in by + // `_forceSafeImage` rather than the intended media. The safe image load must + // not be announced as real media arriving, which may otherwise report a + // broken camera as healthy. Cleared on the next render, which restores the + // real source. + private _showingSafeImage = false; + private _cachedValueController = new CachedValueController( this, () => this._getEffectiveRefreshSeconds(), @@ -234,6 +241,10 @@ export class AdvancedCameraCardImageUpdatingPlayer * @param _changedProps The changed properties */ protected willUpdate(changedProps: PropertyValues): void { + // The render (below) restores the real source (via `live()`), so its load + // is the real media again. + this._showingSafeImage = false; + const relevantEntity = this._getRelevantEntityForMode( resolveImageMode({ imageConfig: this.imageConfig, @@ -454,6 +465,7 @@ export class AdvancedCameraCardImageUpdatingPlayer */ private _forceSafeImage(stockOnly?: boolean): void { if (this._refImage.value) { + this._showingSafeImage = true; // Avoid restoring the raw configured URL when proxying is enabled, since // that would bypass the proxied/signed URL path on visibility changes. const configuredURL = @@ -495,6 +507,10 @@ export class AdvancedCameraCardImageUpdatingPlayer ${ref(this._refImage)} src=${live(src)} @load=${(ev: Event) => { + if (this._showingSafeImage) { + return; + } + const mediaLoadedInfo = createMediaLoadedInfo(ev, { mediaPlayerController: this._mediaPlayerController, capabilities: { diff --git a/src/components/image.ts b/src/components/image.ts index b094481a..402633b2 100644 --- a/src/components/image.ts +++ b/src/components/image.ts @@ -12,6 +12,7 @@ import { createRef, ref, type Ref } from 'lit/directives/ref.js'; import type { CameraManager } from '../camera-manager/manager'; import type { ViewManagerEpoch } from '../card-controller/view/types'; +import { MediaLoadWatchdogController } from '../components-lib/media-load-watchdog-controller'; import type { ZoomSettingsObserved } from '../components-lib/zoom/types'; import { handleZoomSettingsObservedEvent } from '../components-lib/zoom/zoom-view-context'; import type { CameraConfig } from '../config/schema/cameras'; @@ -59,6 +60,40 @@ export class AdvancedCameraCardImage extends LitElement implements MediaPlayer { private _refImage: Ref = createRef(); + constructor() { + super(); + + // No lazy loading: Reports the image as a media_unavailable issue if it + // never arrives. + new MediaLoadWatchdogController(this, { + getTargetID: () => IMAGE_VIEW_TARGET_ID_SENTINEL, + isLoadExpected: () => + // A misconfigured image already shows why nothing can be drawn, and + // reloading it cannot change the configuration. + !!this.hass && !this._getConfigurationError(), + + // Player is keyed on epoch. + getAttemptID: () => this._getMediaEpoch(), + }); + } + + private _getMediaEpoch(): number { + const view = this.viewManagerEpoch?.manager.getView(); + return view?.context?.mediaEpoch?.[IMAGE_VIEW_TARGET_ID_SENTINEL] ?? 0; + } + + // Returns the reason no image can be shown at all, or null when one can. + // `camera` mode has nothing to draw from without a camera. + private _getConfigurationError(): string | null { + const mode = resolveImageMode({ + imageConfig: this.imageConfig, + cameraConfig: this.cameraConfig, + }); + return mode === 'camera' && !this.cameraConfig + ? localize('error.no_camera_for_image') + : null; + } + public async getMediaPlayerController(): Promise { await this.updateComplete; return (await this._refImage.value?.getMediaPlayerController()) ?? null; @@ -118,24 +153,18 @@ export class AdvancedCameraCardImage extends LitElement implements MediaPlayer { return; } - // Determine if this image mode requires a camera - const mode = resolveImageMode({ - imageConfig: this.imageConfig, - cameraConfig: this.cameraConfig, - }); - - if (mode === 'camera' && !this.cameraConfig) { - return renderNotificationBlockFromText(localize('error.no_camera_for_image'), { + const configurationError = this._getConfigurationError(); + if (configurationError) { + return renderNotificationBlockFromText(configurationError, { icon: 'mdi:camera-off', }); } const view = this.viewManagerEpoch?.manager.getView(); - const mediaEpoch = view?.context?.mediaEpoch?.[IMAGE_VIEW_TARGET_ID_SENTINEL] ?? 0; return this._renderContainer(html` ${keyed( - mediaEpoch, + this._getMediaEpoch(), html` | null { const view = this.viewManagerEpoch?.manager.getView(); - return ( - !!view?.isGrid() && - !!view?.supportsMultipleDisplayModes() && - !!cameraIDs && - cameraIDs.size > 1 - ); + return view && this.cameraManager + ? getLiveGridCameraIDs(view, this.cameraManager) + : null; } protected willUpdate(changedProps: PropertyValues): void { - if (changedProps.has('viewManagerEpoch') && this._needsGrid()) { + if (changedProps.has('viewManagerEpoch') && this._getGridCameraIDs()) { void import('../media-grid.js'); } } protected render(): TemplateResult | void { - const cameraIDs = this.cameraManager?.getStore().getCameraIDsWithCapability('live'); - if (!cameraIDs?.size || !this._needsGrid()) { + const cameraIDs = this._getGridCameraIDs(); + if (!cameraIDs) { return this._renderCarousel(); } diff --git a/src/components/live/provider.ts b/src/components/live/provider.ts index bf7d4ad9..d18bddeb 100644 --- a/src/components/live/provider.ts +++ b/src/components/live/provider.ts @@ -17,6 +17,7 @@ import { MEDIA_UNAVAILABLE_REASONS } from '../../card-controller/issues/issues/m import { LazyLoadController } from '../../components-lib/lazy-load-controller.js'; import { isAudioIntendedOnLoad } from '../../components-lib/live/audio-intent.js'; import { StreamLivenessController } from '../../components-lib/live/liveness/stream-liveness-controller.js'; +import { MediaLoadWatchdogController } from '../../components-lib/media-load-watchdog-controller.js'; import { MediaLoadedInfoSinkController } from '../../components-lib/media-loaded-info-sink-controller.js'; import type { PartialZoomSettings } from '../../components-lib/zoom/types.js'; import type { LiveConfig } from '../../config/schema/live.js'; @@ -110,6 +111,24 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP private _lazyLoadController: LazyLoadController = new LazyLoadController(this); + constructor() { + super(); + + // Watch for media that fails to load. The constructor registers it as a + // controller on this host. + new MediaLoadWatchdogController(this, { + getTargetID: () => this.targetID ?? null, + isLoadExpected: () => + this._shouldLoad() && + // Don't report media unavailable for configuration errors as retries + // cannot possible help, and a message is already rendered. + !this._getConfigurationError() && + // Specific > generic: Don't replace existing failures flagged with + // liveness detectors. + !this._streamLivenessController.getFailure(), + }); + } + // A note on dynamic imports: // // We gather the dynamic live provider import promises and do not consider the @@ -181,6 +200,33 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP } } + // Whether this provider should be loading media at all. + private _shouldLoad(): boolean { + return this._lazyLoadController.isLoaded(); + } + + // Returns the reason this camera cannot stream at all, or null when it can. + private _getConfigurationError(): string | null { + const cameraConfig = this.camera?.getConfig(); + const provider = getResolvedLiveProvider(cameraConfig); + + if ( + provider !== 'ha' && + provider !== 'image' && + !(cameraConfig?.camera_entity && cameraConfig.always_error_if_entity_unavailable) + ) { + return null; + } + + if (!cameraConfig?.camera_entity) { + return localize('error.no_live_camera'); + } + if (!this.hass?.states[cameraConfig.camera_entity]) { + return localize('error.live_camera_not_found'); + } + return null; + } + override async getUpdateComplete(): Promise { // See 'A note on dynamic imports' above for explanation of why this is // necessary. @@ -234,7 +280,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP protected render(): TemplateResult | void { const cameraConfig = this.camera?.getConfig(); if ( - !this._lazyLoadController?.isLoaded() || + !this._shouldLoad() || !this.hass || !this.liveConfig || !this.camera || @@ -262,31 +308,14 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP const provider = getResolvedLiveProvider(this.camera?.getConfig()); - // `ha`/`image` cannot stream without a camera entity, so validate that - // here. Entity *availability* (including the always_error immediate path) - // is owned by the liveness controller's EntityAvailabilityDetector and - // surfaces via getFailure() below, for all providers. - if ( - provider === 'ha' || - provider === 'image' || - (cameraConfig?.camera_entity && cameraConfig.always_error_if_entity_unavailable) - ) { - if (!cameraConfig?.camera_entity) { - return renderMediaNotification({ - icon: 'mdi:camera', - title: localize('error.configuration_error'), - detail: localize('error.no_live_camera'), - targetTitle: this.cameraTitle, - }); - } - if (!this.hass.states[cameraConfig.camera_entity]) { - return renderMediaNotification({ - icon: 'mdi:camera', - title: localize('error.configuration_error'), - detail: localize('error.live_camera_not_found'), - targetTitle: this.cameraTitle, - }); - } + const configurationError = this._getConfigurationError(); + if (configurationError) { + return renderMediaNotification({ + icon: 'mdi:camera', + title: localize('error.configuration_error'), + detail: configurationError, + targetTitle: this.cameraTitle, + }); } const failure = this._streamLivenessController.getFailure(); diff --git a/src/components/viewer/grid.ts b/src/components/viewer/grid.ts index a9b45eb6..eed0f59d 100644 --- a/src/components/viewer/grid.ts +++ b/src/components/viewer/grid.ts @@ -17,6 +17,7 @@ import type { CardWideConfig } from '../../config/schema/types.js'; import type { ViewerConfig } from '../../config/schema/viewer.js'; import type { ResolvedMediaCache } from '../../ha/resolved-media.js'; import type { HomeAssistant } from '../../ha/types.js'; +import { getViewerGridCameraIDs } from '../../view/layout.js'; import '../../patches/ha-hls-player.js'; @@ -75,19 +76,14 @@ export class AdvancedCameraCardViewerGrid extends LitElement { } protected willUpdate(changedProps: PropertyValues): void { - if (changedProps.has('viewManagerEpoch') && this._needsGrid()) { + if (changedProps.has('viewManagerEpoch') && this._getGridCameraIDs()) { void import('../media-grid.js'); } } - private _needsGrid(): boolean { + private _getGridCameraIDs(): Set | null { const view = this.viewManagerEpoch?.manager.getView(); - const cameraIDs = view?.queryResults?.getCameraIDs(); - return ( - !!view?.isGrid() && - !!view?.supportsMultipleDisplayModes() && - (cameraIDs?.size ?? 0) > 1 - ); + return view ? getViewerGridCameraIDs(view) : null; } private _gridSelectCamera(cameraID: string): void { @@ -103,15 +99,14 @@ export class AdvancedCameraCardViewerGrid extends LitElement { } protected render(): TemplateResult { - const view = this.viewManagerEpoch?.manager.getView(); - const cameraIDs = view?.queryResults?.getCameraIDs(); - if (!cameraIDs || !this._needsGrid()) { + const cameraIDs = this._getGridCameraIDs(); + if (!cameraIDs) { return this._renderCarousel(); } return html` , diff --git a/src/components/viewer/provider.ts b/src/components/viewer/provider.ts index c945c851..8d77e594 100644 --- a/src/components/viewer/provider.ts +++ b/src/components/viewer/provider.ts @@ -14,6 +14,7 @@ import type { CameraManager } from '../../camera-manager/manager.js'; import { QueryType } from '../../camera-manager/types.js'; import type { ViewManagerEpoch } from '../../card-controller/view/types.js'; import { LazyLoadController } from '../../components-lib/lazy-load-controller.js'; +import { MediaLoadWatchdogController } from '../../components-lib/media-load-watchdog-controller.js'; import { getSignedURLErrorText, SignedURLController, @@ -36,6 +37,7 @@ import type { MediaPlayerController, MediaPlayerElement, } from '../../types.js'; +import { Generation } from '../../utils/concurrency/generation.js'; import { classifyMimeType } from '../../utils/mime-type.js'; import { ViewItemClassifier } from '../../view/item-classifier.js'; import type { ViewMedia } from '../../view/item.js'; @@ -77,6 +79,9 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi private _resolvedMedia: ResolvedMedia | null = null; + // Drops a slow resolution for media the provider has since moved off. + private _resolveGeneration = new Generation(); + private _signedURLController = new SignedURLController(this, () => { if (!this.hass || !this._resolvedMedia) { return {}; @@ -99,6 +104,12 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi constructor() { super(); this._lazyLoadController.addListener((loaded) => loaded && this._resolveURL()); + + // Watch for media load failure (including resolving media ID and signing). + new MediaLoadWatchdogController(this, { + getTargetID: () => this.media?.getID() ?? null, + isLoadExpected: () => this._shouldLoad(), + }); } public async getMediaPlayerController(): Promise { @@ -140,19 +151,28 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi private async _resolveURL(): Promise { const contentID = this.media?.getContentID(); - if (!contentID || !this.hass || !this._lazyLoadController?.isLoaded()) { + if (!contentID || !this.hass || !this._shouldLoad()) { + this._resolveGeneration.invalidate(); this._resolvedMedia = null; return; } + const generation = this._resolveGeneration.next(); + // Clear immediately so the SignedURLController doesn't see a stale URL // from the previous media item during the async gap. this._resolvedMedia = null; - this._resolvedMedia = + const resolved = this.resolvedMediaCache?.get(contentID) ?? (await resolveMedia(this.hass, contentID, this.resolvedMediaCache)) ?? null; + + if (!this._resolveGeneration.isCurrent(generation)) { + return; + } + + this._resolvedMedia = resolved; this.requestUpdate(); } @@ -177,6 +197,10 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi } } + private _shouldLoad(): boolean { + return this._lazyLoadController.isLoaded(); + } + private _getRelevantCameraConfig(): CameraConfig | null { const cameraID = this.media?.getCameraID(); return cameraID @@ -233,12 +257,7 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi } protected render(): TemplateResult | void { - if ( - !this._lazyLoadController?.isLoaded() || - !this.media || - !this.hass || - !this.viewerConfig - ) { + if (!this._shouldLoad() || !this.media || !this.hass || !this.viewerConfig) { return; } diff --git a/src/condition-trigger/conditions/types.ts b/src/condition-trigger/conditions/types.ts index 8a3b1db5..a60aa973 100644 --- a/src/condition-trigger/conditions/types.ts +++ b/src/condition-trigger/conditions/types.ts @@ -41,7 +41,7 @@ export interface ConditionState { // Generic media target identifier. See @view/target-id for details. targetID?: string; - triggered?: Set; + triggered?: ReadonlySet; userAgent?: string; view?: AdvancedCameraCardView; } diff --git a/src/scss/media-grid.scss b/src/scss/media-grid.scss index 4d1010b9..4a456418 100644 --- a/src/scss/media-grid.scss +++ b/src/scss/media-grid.scss @@ -37,12 +37,18 @@ // not change in size (even border-box sizing appears to allow size to change // when the element has a non-fixed height). border: var(--advanced-camera-card-grid-border-size) solid transparent; + + // Unselected cells cannot be usefully scrolled, so ensure notifications do + // not show scrollbars. + --advanced-camera-card-notification-block-overflow-y: hidden; } ::slotted([selected]) { border: var(--advanced-camera-card-grid-border-size) solid var(--advanced-camera-card-grid-selected-border-color); + --advanced-camera-card-notification-block-overflow-y: auto; + // Selected width = columns × selection factor, capped at 100%. width: min( 100%, diff --git a/src/scss/notification-block.scss b/src/scss/notification-block.scss index 01c69922..64bf5221 100644 --- a/src/scss/notification-block.scss +++ b/src/scss/notification-block.scss @@ -29,7 +29,9 @@ padding: 24px; max-width: min(92%, 500px); max-height: 100%; - overflow-y: auto; + + // A container that cannot usefully be scrolled can set this to 'hidden'. + overflow-y: var(--advanced-camera-card-notification-block-overflow-y, auto); scrollbar-width: thin; text-align: center; } diff --git a/src/view/layout.ts b/src/view/layout.ts new file mode 100644 index 00000000..4093a780 --- /dev/null +++ b/src/view/layout.ts @@ -0,0 +1,85 @@ +import type { CameraManager } from '../camera-manager/manager'; +import { getViewTargetID } from './target-id'; +import type { View } from './view'; + +const isGridLayout = (view: View): boolean => + // This reflects whether the view 'asks' for a grid. This is inherited as + // the user changes view. + view.isGrid() && + // This reflects whether the current view actually *supports* a grid. + view.supportsMultipleDisplayModes(); + +// The cameras a live grid lays out, one cell each, or null when live is laid +// out as a single carousel instead (incl. when there's <= 1 camera). +export const getLiveGridCameraIDs = ( + view: View, + cameraManager: CameraManager, +): Set | null => { + if (!isGridLayout(view)) { + return null; + } + + const cameraIDs = cameraManager.getStore().getCameraIDsWithCapability('live'); + return cameraIDs.size > 1 ? cameraIDs : null; +}; + +// The cameras a viewer grid lays out, one cell each, or null when the viewer is +// laid out as a single carousel instead (incl. when there's <= 1 camera). The +// cameras come from the query results, so only a camera with media to show gets +// a cell. +export const getViewerGridCameraIDs = (view: View): Set | null => { + if (!isGridLayout(view)) { + return null; + } + + const cameraIDs = view.queryResults?.getCameraIDs(); + return cameraIDs && cameraIDs.size > 1 ? cameraIDs : null; +}; + +// The target shown in each cell of a grid, or null when the view is not laid +// out as a grid. +const getGridTargetIDs = ( + view: View, + cameraManager: CameraManager, +): Set | null => { + if (view.is('live')) { + // A live cell shows its camera, so the camera is the target. + return getLiveGridCameraIDs(view, cameraManager); + } + + if (view.isViewerView()) { + const cameraIDs = getViewerGridCameraIDs(view); + if (!cameraIDs) { + return null; + } + + // A viewer cell shows one media item belonging to its camera. + const targetIDs = new Set(); + for (const cameraID of cameraIDs) { + const targetID = view.queryResults?.getSelectedResult(cameraID)?.getID(); + if (targetID) { + targetIDs.add(targetID); + } + } + return targetIDs; + } + + return null; +}; + +// Every target the current view lays out, one per cell. A grid shows several +// targets at once, so all of them count even when some are scrolled out of the +// viewport: the user chose to put them on screen. Every other layout shows a +// single target at a time, which is the one the view identifies. +export const getDisplayedTargetIDs = ( + view: View, + cameraManager: CameraManager, +): Set => { + const gridTargetIDs = getGridTargetIDs(view, cameraManager); + if (gridTargetIDs) { + return gridTargetIDs; + } + + const targetID = getViewTargetID(view); + return targetID ? new Set([targetID]) : new Set(); +}; diff --git a/src/view/view.ts b/src/view/view.ts index 68094ea2..92d4f79c 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -70,7 +70,7 @@ const isGalleryViewName = (view?: AdvancedCameraCardView): boolean => const isAnyFolderViewName = (view?: AdvancedCameraCardView): boolean => !!view && FOLDER_VIEW_NAMES.includes(view); -export const isAnyMediaViewName = (view?: AdvancedCameraCardView): boolean => +const isAnyMediaViewName = (view?: AdvancedCameraCardView): boolean => isViewerViewName(view) || view === 'live' || view === 'image'; export class View { diff --git a/tests/browser/mounted-card.ts b/tests/browser/mounted-card.ts index e7e3d843..e02e0a81 100644 --- a/tests/browser/mounted-card.ts +++ b/tests/browser/mounted-card.ts @@ -607,10 +607,31 @@ export class MountedCard { control.click(); } + /** + * Click the control that steps a carousel one item along. + * + * These carry the name of whatever they move to rather than a name of their + * own, which several other controls also carry, so they are reached by the + * side they sit on instead. Which side moves forward depends on the reading + * direction of the page, exactly as it does for the user. + */ + public async clickNextPreviousControl(side: 'left' | 'right'): Promise { + const control = await this.waitForRender( + () => deepQuery(this.card, `ha-icon-button.controls.${side}`), + `the ${side} carousel control`, + ); + + await clickElement(control); + } + private async _findControl(name: string): Promise { return await this.waitForRender(() => { const found = deepQueryAll(this.card, '*').find( - (element) => getControlName(element) === name, + (element) => + getControlName(element) === name && + // A control the user can press occupies space. An element that only + // wraps one can carry the same name while having no box of its own. + !!element.getBoundingClientRect().width, ); return found instanceof HTMLElement ? found : null; }, `a control named ${name}`); diff --git a/tests/browser/test-utils.ts b/tests/browser/test-utils.ts index c980af4c..be1ad92a 100644 --- a/tests/browser/test-utils.ts +++ b/tests/browser/test-utils.ts @@ -90,7 +90,7 @@ export const createUnansweredMediaURL = (): string => createMediaURL([]); */ export const createStallingMediaURL = (): string => createMediaURL([HTTP_OK]); -export interface StillCameraHASSOptions { +export interface CameraHASSOptions { // Camera entities beyond the default one. cameras?: string[]; @@ -105,7 +105,7 @@ export interface StillCameraHASSOptions { * A Home Assistant holding the cameras a card is about to be given, which is * the minimum any browser test needs before it can mount anything. */ -export const createStillCameraHASS = (options?: StillCameraHASSOptions): FakeHASS => { +export const createCameraHASS = (options?: CameraHASSOptions): FakeHASS => { const cameras = [STILL_CAMERA_ENTITY, ...(options?.cameras ?? [])]; return new FakeHASS({ diff --git a/tests/camera-manager/manager.browser.test.ts b/tests/camera-manager/manager.browser.test.ts index 666a57f8..5d4c051b 100644 --- a/tests/camera-manager/manager.browser.test.ts +++ b/tests/camera-manager/manager.browser.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { RawAdvancedCameraCardConfig } from '../../src/config/types'; import { MountedCardFactory, type MountedCard } from '../browser/mounted-card'; import { - createStillCameraHASS, + createCameraHASS, createStillImageCameraConfig, createStillImageCardConfig, getBlockNotificationText, @@ -43,7 +43,7 @@ describe('CameraManager', () => { ], view: { issues: { retry_seconds: 0 } }, }), - createStillCameraHASS({ + createCameraHASS({ cameras: [TRIGGERING_CAMERA_ENTITY, OTHER_TRIGGERING_CAMERA_ENTITY], }), ); @@ -64,7 +64,7 @@ describe('CameraManager', () => { createStillImageCardConfig({ cameras: [createSubscribingCameraConfig(TRIGGERING_CAMERA_ENTITY)], }), - createStillCameraHASS({ cameras: [TRIGGERING_CAMERA_ENTITY] }), + createCameraHASS({ cameras: [TRIGGERING_CAMERA_ENTITY] }), ); await vi.waitFor(() => expect(card.getOpenEventSubscriptionCount()).toBe(1)); diff --git a/tests/card-controller/automations-manager.browser.test.ts b/tests/card-controller/automations-manager.browser.test.ts index 1faad4c9..defffaf2 100644 --- a/tests/card-controller/automations-manager.browser.test.ts +++ b/tests/card-controller/automations-manager.browser.test.ts @@ -1,15 +1,12 @@ import { describe, expect, it } from 'vitest'; import { MountedCardFactory, type MountedCard } from '../browser/mounted-card'; -import { - createStillCameraHASS, - createStillImageCardConfig, -} from '../browser/test-utils'; +import { createCameraHASS, createStillImageCardConfig } from '../browser/test-utils'; const TRIGGER_ENTITY = 'input_boolean.zoom'; const mount = async (): Promise => { - const hass = createStillCameraHASS({ entities: { [TRIGGER_ENTITY]: 'off' } }); + const hass = createCameraHASS({ entities: { [TRIGGER_ENTITY]: 'off' } }); return await MountedCardFactory.createFromSource( createStillImageCardConfig({ automations: [ diff --git a/tests/card-controller/card-element-manager.browser.test.ts b/tests/card-controller/card-element-manager.browser.test.ts index d6629584..2babb9d0 100644 --- a/tests/card-controller/card-element-manager.browser.test.ts +++ b/tests/card-controller/card-element-manager.browser.test.ts @@ -4,8 +4,8 @@ import { createLogAction } from '../../src/utils/action'; import { MountedCardFactory, type MountedCard } from '../browser/mounted-card'; import { CARD_INITIALIZED_MESSAGE, + createCameraHASS, createInitializedAutomation, - createStillCameraHASS, createStillImageCardConfig, deepQueryAll, getFocusedElement, @@ -28,7 +28,7 @@ const mountCard = async (): Promise => { }, ], }), - createStillCameraHASS(), + createCameraHASS(), ); await card.events.waitForFirst('advanced-camera-card:media:loaded'); diff --git a/tests/card-controller/initialization/session-manager.browser.test.ts b/tests/card-controller/initialization/session-manager.browser.test.ts index 40293d64..78511053 100644 --- a/tests/card-controller/initialization/session-manager.browser.test.ts +++ b/tests/card-controller/initialization/session-manager.browser.test.ts @@ -4,8 +4,8 @@ import type { RawAdvancedCameraCardConfig } from '../../../src/config/types'; import { MountedCardFactory, type MountedCard } from '../../browser/mounted-card'; import { CARD_INITIALIZED_MESSAGE, + createCameraHASS, createInitializedAutomation, - createStillCameraHASS, createStillImageCameraConfig, createStillImageCardConfig, isMediaLoadedInfoEventDetail, @@ -26,7 +26,7 @@ const mount = async ( ): Promise => await MountedCardFactory.createFromSource( createConfig(overrides), - createStillCameraHASS({ cameras: [OTHER_CAMERA_ENTITY] }), + createCameraHASS({ cameras: [OTHER_CAMERA_ENTITY] }), ); // The cameras the card has actually loaded media for, in order. Media that diff --git a/tests/card-controller/issues/factory.test.ts b/tests/card-controller/issues/factory.test.ts index e19c9067..f1a44583 100644 --- a/tests/card-controller/issues/factory.test.ts +++ b/tests/card-controller/issues/factory.test.ts @@ -5,6 +5,7 @@ import { createIssueManager } from '../../../src/card-controller/issues/factory' import { IssueManager } from '../../../src/card-controller/issues/issue-manager'; import { ConditionStateManager } from '../../../src/condition-trigger/conditions/state-manager'; import { createCardAPI } from '../../test-utils'; +import { createView } from '../../view/test-utils'; import { createSubscriptionHealth } from '../test-utils'; describe('createIssueManager', () => { @@ -77,23 +78,49 @@ describe('createIssueManager', () => { ]); }); - it('should wire changeCallback so timer-based issues activate via evaluate', () => { + it('should wire changeCallback so an issue can ask for a re-evaluation', () => { + const api = createCardAPI(); + vi.mocked(api.getConditionStateManager).mockReturnValue(new ConditionStateManager()); + const health = createSubscriptionHealth(); + + const manager = createIssueManager(api, health); + vi.mocked(api.getCardElementManager().update).mockClear(); + + // A subscription starts failing. Nothing has asked the IssueManager to + // re-evaluate, so it does not know yet. + health.getFailures.mockReturnValue([{ key: 'camera-1', error: 'failed' }]); + expect(api.getCardElementManager().update).not.toHaveBeenCalled(); + + // The callback EventSubscriptionIssue registered is what asks it to + // re-evaluate. + const changeCallback = health.addListener.mock.calls[0][0]; + changeCallback(); + + expect(manager.getStateManager().getIssuePresence().has('event_subscription')).toBe( + true, + ); + expect(api.getCardElementManager().update).toHaveBeenCalled(); + }); + + it('should report a media failure raised by a component', () => { const api = createCardAPI(); const stateManager = new ConditionStateManager(); vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ view: 'live', camera: 'camera-1' }), + ); const manager = createIssueManager(api, createSubscriptionHealth()); - // 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_unavailable')).toBe( false, ); - // After the timeout, the changeCallback fires evaluate which - // updates the card element. - vi.advanceTimersByTime(10000); + manager.trigger('media_unavailable', { + targetID: 'camera-1', + reason: 'not_loading', + }); expect(manager.getStateManager().getIssuePresence().has('media_unavailable')).toBe( true, diff --git a/tests/card-controller/issues/issue-manager.test.ts b/tests/card-controller/issues/issue-manager.test.ts index 29a91933..c88751c0 100644 --- a/tests/card-controller/issues/issue-manager.test.ts +++ b/tests/card-controller/issues/issue-manager.test.ts @@ -24,6 +24,7 @@ import { createMediaLoadedInfo, flushPromises, } from '../../test-utils'; +import { createView } from '../../view/test-utils'; const DEFAULT_RETRY_SECONDS = 1; @@ -120,6 +121,9 @@ describe('IssueManager', () => { const api = createCardAPI(); const conditionStateManager = new ConditionStateManager(); vi.mocked(api.getConditionStateManager).mockReturnValue(conditionStateManager); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ view: 'live', camera: 'camera.office' }), + ); const manager = new IssueManager(api); const issue = new MediaUnavailableIssue(api); diff --git a/tests/card-controller/issues/issues/initialization.browser.test.ts b/tests/card-controller/issues/issues/initialization.browser.test.ts index 428d072f..ea9a8586 100644 --- a/tests/card-controller/issues/issues/initialization.browser.test.ts +++ b/tests/card-controller/issues/issues/initialization.browser.test.ts @@ -3,8 +3,8 @@ import { describe, expect, it, vi } from 'vitest'; import { MountedCardFactory, type MountedCard } from '../../../browser/mounted-card'; import { CARD_INITIALIZED_MESSAGE, + createCameraHASS, createInitializedAutomation, - createStillCameraHASS, createStillImageCameraConfig, createStillImageCardConfig, getBlockNotificationText, @@ -40,7 +40,7 @@ const mountBrokenCard = async (): Promise => view: { issues: { retry_seconds: 0 } }, automations: [createInitializedAutomation()], }), - createStillCameraHASS(), + createCameraHASS(), ); describe('InitializationIssue', () => { 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 18953d2d..f5a95aa0 100644 --- a/tests/card-controller/issues/issues/media-unavailable.browser.test.ts +++ b/tests/card-controller/issues/issues/media-unavailable.browser.test.ts @@ -1,8 +1,8 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, assert, beforeEach, describe, expect, it, vi } from 'vitest'; import { RETRY_EXPONENTIAL_BASE_SECONDS } from '../../../../src/card-controller/issues/issue-manager'; -import { MEDIA_LOADING_TIMEOUT_SECONDS } 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'; import type { RawAdvancedCameraCardConfig } from '../../../../src/config/types'; import { @@ -12,9 +12,9 @@ import { } from '../../../browser/mounted-card'; import { useTestMedia } from '../../../browser/test-media'; import { + createCameraHASS, createFailingMediaURL, createStallingMediaURL, - createStillCameraHASS, createStillImageCameraConfig, createStillImageCardConfig, createTemporarilyFailingMediaURL, @@ -24,10 +24,13 @@ import { getBlockNotificationText, isLiveMediaShowing, STILL_CAMERA_ENTITY, + type CameraHASSOptions, } from '../../../browser/test-utils'; const SECOND_CAMERA_ENTITY = 'camera.hallway'; +const OVERRIDE_ENTITY = 'input_boolean.override'; + const MEDIA_ISSUE_TITLE = 'Media unavailable'; // Holding this reaches the diagnostics view, the only view showing no media @@ -41,6 +44,17 @@ const findIssue = (card: MountedCard): Element | null => const isIssueReported = (card: MountedCard): boolean => !!findIssue(card); +// Resolve once `image` holds a picture that actually loaded. A failed load also +// marks the element complete, so the decoded size is what separates the two. +const waitForImageLoaded = async (image: HTMLImageElement): Promise => + await new Promise((resolve) => { + if (image.complete && image.naturalWidth > 0) { + resolve(); + } else { + image.addEventListener('load', () => resolve(), { once: true }); + } + }); + const waitForIssueReported = async (card: MountedCard): Promise => { await card.waitForRender( () => findIssue(card), @@ -48,9 +62,14 @@ const waitForIssueReported = async (card: MountedCard): Promise => { ); }; -interface MountCardOptions extends MountOptions { - cameras?: string[]; -} +const waitForIssueCleared = async (card: MountedCard): Promise => { + await card.waitForRender( + () => !findIssue(card) || null, + `the ${MEDIA_ISSUE_TITLE} issue being cleared`, + ); +}; + +interface MountCardOptions extends MountOptions, CameraHASSOptions {} /** * Every test here needs the status bar rendered, since that is where an issue @@ -60,11 +79,11 @@ const mountCard = async ( config?: Partial, options?: MountCardOptions, ): Promise => { - const { cameras, ...mountOptions } = options ?? {}; + const { cameras, entities, language, ...mountOptions } = options ?? {}; return await MountedCardFactory.createFromSource( createStillImageCardConfig({ status_bar: { style: 'outside' }, ...config }), - createStillCameraHASS({ cameras }), + createCameraHASS({ cameras, entities, language }), mountOptions, ); }; @@ -77,17 +96,22 @@ const mountCardSingleCamera = async (): Promise => { return card; }; -const mountCardDualCameras = async (): Promise => { - const card = await mountCard( - { - live: { display: { mode: 'grid' } }, - cameras: [ - createStillImageCameraConfig(), - createStillImageCameraConfig(SECOND_CAMERA_ENTITY), - ], - }, +/** + * A grid of the two standard cameras, which differ only in how they are + * configured to behave. + */ +const mountCardGrid = async ( + cameras: RawAdvancedCameraCardConfig[], + options?: { + config?: Partial; + entities?: CameraHASSOptions['entities']; + }, +): Promise => + await mountCard( + { live: { display: { mode: 'grid' } }, cameras, ...options?.config }, { cameras: [SECOND_CAMERA_ENTITY], + ...(options?.entities && { entities: options.entities }), // The grid observes both its own size and its cells', so a cell resize // can resize the host and vice versa. Chromium reports each round it has @@ -100,6 +124,12 @@ const mountCardDualCameras = async (): Promise => { }, ); +const mountCardDualCameras = async (): Promise => { + const card = await mountCardGrid([ + createStillImageCameraConfig(), + createStillImageCameraConfig(SECOND_CAMERA_ENTITY), + ]); + await card.events.waitForFirst('advanced-camera-card:media:loaded'); return card; @@ -182,6 +212,149 @@ describe('MediaUnavailableIssue', () => { expect(isLiveMediaShowing(card.card)).toBe(true); }); + it('should offer no scrollbar on a failed grid camera that is not selected', async () => { + const card = await mountCardDualCameras(); + + card.setEntityState(SECOND_CAMERA_ENTITY, 'unavailable'); + await card.advanceSeconds(LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS); + const block = await card.waitForSelector('advanced-camera-card-notification-block'); + + // Unselected cells cannot usefully be scrolled, so a scrollbar is not offered. + const content = block.shadowRoot?.querySelector('.content'); + assert(content); + + expect(getComputedStyle(content).overflowY).toBe('hidden'); + }); + + it('should trigger an issue for an unselected grid camera and recover it', async () => { + // Every camera in a grid is on screen, so any of them failing triggers an + // issue, not only the camera that happens to be selected. + // See: https://github.com/dermotduffy/advanced-camera-card/issues/2637 + const card = await mountCardGrid([ + createStillImageCameraConfig(), + createStillImageCameraConfig( + SECOND_CAMERA_ENTITY, + createTemporarilyFailingMediaURL(1), + ), + ]); + + await card.events.waitForFirst('advanced-camera-card:issue:trigger'); + await waitForIssueReported(card); + + expect(getBlockNotificationText(card.card)).toContain(SECOND_CAMERA_ENTITY); + + // Nothing here asks the card to try again: the retry runs on its own. + await card.advanceSeconds(RETRY_EXPONENTIAL_BASE_SECONDS); + await waitForIssueCleared(card); + + expect(isLiveMediaShowing(card.card)).toBe(true); + }); + + it('should trigger an issue for a camera added to a grid by an override', async () => { + // Overrides don't recreate views. If an override changes the cameras, + // issues should be created for newly added cameras. + const card = await mountCardGrid([createStillImageCameraConfig()], { + entities: { [OVERRIDE_ENTITY]: 'off' }, + config: { + overrides: [ + { + conditions: [{ condition: 'state', entity: OVERRIDE_ENTITY, state: 'on' }], + set: { + cameras: [ + createStillImageCameraConfig(), + createStillImageCameraConfig( + SECOND_CAMERA_ENTITY, + createFailingMediaURL(), + ), + ], + }, + }, + ], + }, + }); + + await card.events.waitForFirst('advanced-camera-card:media:loaded'); + expect(isIssueReported(card)).toBe(false); + + card.setEntityState(OVERRIDE_ENTITY, 'on'); + + await card.events.waitForFirst('advanced-camera-card:issue:trigger'); + await waitForIssueReported(card); + + expect(getBlockNotificationText(card.card)).toContain(SECOND_CAMERA_ENTITY); + }); + + it('should stay silent about a carousel camera that is not on screen', async () => { + // A carousel camera that has loaded and failed triggers no issue while it + // is off screen. Lazy loading is off so that it does load and fail, rather + // than the test passing because nothing was watching it. + const card = await mountCard( + { + live: { lazy_load: false }, + cameras: [ + createStillImageCameraConfig(), + createStillImageCameraConfig(SECOND_CAMERA_ENTITY), + ], + }, + { cameras: [SECOND_CAMERA_ENTITY] }, + ); + await card.events.waitForFirst('advanced-camera-card:media:loaded'); + + card.setEntityState(SECOND_CAMERA_ENTITY, 'unavailable'); + await card.advanceSeconds(LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS * 4); + + expect(isIssueReported(card)).toBe(false); + + // Step the carousel on to that same camera, which reports the failure it + // already had. The silence above was the scoping, not an absence of + // anything watching. + await card.clickNextPreviousControl('right'); + await waitForIssueReported(card); + + expect(getBlockNotificationText(card.card)).toContain(SECOND_CAMERA_ENTITY); + }); + + it('should keep an issue across a detach and re-attach', async () => { + const card = await mountCardDualCameras(); + + card.setEntityState(SECOND_CAMERA_ENTITY, 'unavailable'); + await card.advanceSeconds(LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS); + await waitForIssueReported(card); + + card.detach(); + card.attach(); + await card.updateComplete; + + // The clock has not moved, so a failure found all over again could not have + // been reported yet. This is the issue from before the detach. + expect(isIssueReported(card)).toBe(true); + }); + + it('should trigger an issue for a grid camera that goes quiet without failing', async () => { + // No active errors, just an unanswered load on an unselected camera. + const card = await mountCardGrid([ + createStillImageCameraConfig(), + createStillImageCameraConfig(SECOND_CAMERA_ENTITY, createUnansweredMediaURL()), + ]); + + // Nothing is waited on until the cell has a player asking for media. + await card.waitForSelector('advanced-camera-card-live-image'); + + await card.advanceSeconds(MEDIA_LOADING_TIMEOUT_SECONDS - 1); + expect(isIssueReported(card)).toBe(false); + + await card.advanceSeconds(1); + await waitForIssueReported(card); + + // The cell itself is still showing that it is waiting, so which camera has + // given up is only knowable from the issue. + await card.clickControl(MEDIA_ISSUE_TITLE); + const notification = await card.waitForSelector('advanced-camera-card-notification'); + + expect(notification.shadowRoot?.textContent).toContain(SECOND_CAMERA_ENTITY); + expect(notification.shadowRoot?.textContent).toContain('Media not loading'); + }); + it('should report a camera whose media fails to load', async () => { const card = await mountCard({ cameras: [ @@ -392,6 +565,44 @@ describe('MediaUnavailableIssue', () => { expect(getBlockNotificationText(card.card)).toContain('Stream stalled'); }); + it('should trigger an issue for a camera picture that falls back to a stock image', async () => { + // A camera-mode image that cannot be fetched swaps in a bundled stock + // picture, so something is always on screen. That substitute must not be + // announced as the camera's media arriving, or the failure it replaced + // would be reported as recovered. + const card = await MountedCardFactory.createFromSource( + createStillImageCardConfig({ + status_bar: { style: 'outside' }, + cameras: [ + { + camera_entity: STILL_CAMERA_ENTITY, + live_provider: 'image', + image: { mode: 'camera' }, + }, + ], + }), + createCameraHASS({ + entities: { + [STILL_CAMERA_ENTITY]: { + state: 'idle', + attributes: { entity_picture: createFailingMediaURL() }, + }, + }, + }), + ); + + const image = await card.waitForSelector('img'); + await card.events.waitForFirst('advanced-camera-card:issue:trigger'); + + // The stock picture is being swapped into the same element. Wait for that + // load specifically: it is the one that could be mistaken for the camera's + // media arriving. + await waitForImageLoaded(image); + + expect(isIssueReported(card)).toBe(true); + expect(card.events.getEntries('advanced-camera-card:media:loaded')).toHaveLength(0); + }); + it('should report a player that reports a playback error', async () => { const card = await mountCard({ // A provider given nothing to play. It reports that it failed without diff --git a/tests/card-controller/issues/issues/media-unavailable.test.ts b/tests/card-controller/issues/issues/media-unavailable.test.ts index e1daaa74..783850bd 100644 --- a/tests/card-controller/issues/issues/media-unavailable.test.ts +++ b/tests/card-controller/issues/issues/media-unavailable.test.ts @@ -1,542 +1,310 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { mock } from 'vitest-mock-extended'; +import { 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 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'; -import { createCardAPI, createMediaLoadedInfo } from '../../../test-utils'; +import { + createCameraManager, + createCapabilities, + createStore, +} from '../../../camera-manager/test-utils'; +import { createCardAPI } from '../../../test-utils'; +import { createView } from '../../../view/test-utils'; -const createAPI = () => createCardAPI(); - -// Deliver a media change to the listener the issue registered with the -// media-loaded manager. -const fireMediaChange = ( - api: ReturnType, - change: MediaLoadedInfoChange, -): void => { - vi.mocked(api.getMediaLoadedInfoManager().subscribe).mock.calls[0]?.[0]?.(change); +const createAPIWithView = (view: View | null): CardController => { + const api = createCardAPI(); + vi.mocked(api.getViewManager().getView).mockReturnValue(view); + return api; }; -// Simulate a media (re)load for `targetID`. -const fireMediaLoad = (api: ReturnType, targetID: string): void => { - fireMediaChange(api, { - type: 'load', - targetID, - info: createMediaLoadedInfo({ targetID }), - }); +const createAPIDisplaying = (...cameraIDs: string[]): CardController => { + const api = createAPIWithView( + createView({ view: 'live', camera: cameraIDs[0], displayMode: 'grid' }), + ); + + vi.mocked(api.getCameraManager).mockReturnValue( + createCameraManager( + createStore( + cameraIDs.map((cameraID) => ({ + cameraID, + capabilities: createCapabilities({ live: true }), + })), + ), + ), + ); + + return api; }; // @vitest-environment jsdom describe('MediaUnavailableIssue', () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - it('should have correct key', () => { - const issue = new MediaUnavailableIssue(createAPI()); - expect(issue.key).toBe('media_unavailable'); + expect(new MediaUnavailableIssue(createCardAPI()).key).toBe('media_unavailable'); }); - describe('detectDynamic', () => { - it.each([ - ['live' as const], - ['clip' as const], - ['folder' as const], - ['media' as const], - ['snapshot' as const], - ['recording' as const], - ['review' as const], - ])('should start timer when view is %s and not loaded', (view) => { - const onChange = vi.fn(); - const issue = new MediaUnavailableIssue(createAPI(), onChange); + describe('activation', () => { + it('should activate for a displayed target that has failed', () => { + const issue = new MediaUnavailableIssue(createAPIDisplaying('camera-1')); - issue.detectDynamic({ targetID: 'target-1', view }); - - expect(issue.hasIssue()).toBe(false); - - vi.advanceTimersByTime(10000); + issue.trigger({ targetID: 'camera-1', reason: 'stalled' }); expect(issue.hasIssue()).toBe(true); - expect(onChange).toHaveBeenCalled(); }); - it('should not start timer when targetID is null (no provider rendering)', () => { - const issue = new MediaUnavailableIssue(createAPI()); + it('should not activate without any failure', () => { + expect(new MediaUnavailableIssue(createAPIDisplaying('camera-1')).hasIssue()).toBe( + false, + ); + }); - // Media view but no targetID, e.g. viewer showing "No media to display" - // instead of mounting a provider. - issue.detectDynamic({ view: 'media' }); + it('should activate for a failed target that is not the selected one', () => { + const issue = new MediaUnavailableIssue( + createAPIDisplaying('camera-1', 'camera-2'), + ); - vi.advanceTimersByTime(10000); + // Every camera of a grid is on screen, so a failure in any of them is + // the user's to see and the card's to reload. + // See: https://github.com/dermotduffy/advanced-camera-card/issues/2637 + issue.trigger({ targetID: 'camera-2', reason: 'server_error' }); + + expect(issue.hasIssue()).toBe(true); + expect(issue.needsRetry()).toBe(true); + }); + + it('should not activate for a failed target that is not displayed', () => { + const issue = new MediaUnavailableIssue(createAPIDisplaying('camera-1')); + + issue.trigger({ targetID: 'camera-2', reason: 'server_error' }); expect(issue.hasIssue()).toBe(false); }); - it('should deactivate when targetID becomes null', () => { - const issue = new MediaUnavailableIssue(createAPI()); + it('should not activate in a view that shows no media', () => { + const issue = new MediaUnavailableIssue( + createAPIWithView(createView({ view: 'timeline' })), + ); - issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); - vi.advanceTimersByTime(10000); + issue.trigger({ targetID: 'camera-1', reason: 'stalled' }); + + expect(issue.hasIssue()).toBe(false); + }); + + it('should not activate without a view', () => { + const issue = new MediaUnavailableIssue(createAPIWithView(null)); + + issue.trigger({ targetID: 'camera-1', reason: 'stalled' }); + + expect(issue.hasIssue()).toBe(false); + }); + + it('should deactivate once the view moves off the failed target', () => { + const api = createAPIDisplaying('camera-1'); + const issue = new MediaUnavailableIssue(api); + + issue.trigger({ targetID: 'camera-1', reason: 'stalled' }); expect(issue.hasIssue()).toBe(true); - // Target cleared (e.g. switched to a view with no media provider). - issue.detectDynamic({ view: 'live' }); - expect(issue.hasIssue()).toBe(false); - }); - - it('should not start timer when view is not a media view', () => { - const issue = new MediaUnavailableIssue(createAPI()); - - issue.detectDynamic({ view: 'timeline' }); - - vi.advanceTimersByTime(10000); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ view: 'live', camera: 'camera-2' }), + ); expect(issue.hasIssue()).toBe(false); }); - it('should not start timer when view is undefined', () => { - const issue = new MediaUnavailableIssue(createAPI()); - - issue.detectDynamic({}); - - vi.advanceTimersByTime(10000); + it('should activate for a camera that arrives after the failure', () => { + const api = createAPIDisplaying('camera-1'); + const issue = new MediaUnavailableIssue(api); + issue.trigger({ targetID: 'camera-2', reason: 'stalled' }); expect(issue.hasIssue()).toBe(false); - }); - it('should not start timer when media is loaded', () => { - const issue = new MediaUnavailableIssue(createAPI()); + // Which cameras a view lays out depends on the cameras that exist, so it + // can change without the view itself changing. + vi.mocked(api.getCameraManager).mockReturnValue( + createCameraManager( + createStore( + ['camera-1', 'camera-2'].map((cameraID) => ({ + cameraID, + capabilities: createCapabilities({ live: true }), + })), + ), + ), + ); - issue.detectDynamic({ view: 'live', mediaLoadedInfo: createMediaLoadedInfo() }); - - vi.advanceTimersByTime(10000); - - expect(issue.hasIssue()).toBe(false); - }); - - it('should clear timeout when media loads', () => { - const issue = new MediaUnavailableIssue(createAPI()); - - issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); - vi.advanceTimersByTime(5000); - - issue.detectDynamic({ view: 'live', mediaLoadedInfo: createMediaLoadedInfo() }); - - vi.advanceTimersByTime(5000); - - expect(issue.hasIssue()).toBe(false); - }); - - it('should clear timeout when view changes to a non-media view', () => { - const issue = new MediaUnavailableIssue(createAPI()); - - issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); - vi.advanceTimersByTime(5000); - - issue.detectDynamic({ view: 'timeline' }); - - vi.advanceTimersByTime(5000); - - expect(issue.hasIssue()).toBe(false); - }); - - it('should remain active across media views for the same target', () => { - const issue = new MediaUnavailableIssue(createAPI()); - - issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); - vi.advanceTimersByTime(10000); - expect(issue.hasIssue()).toBe(true); - - // Same target, different media view -- issue stays active. - issue.detectDynamic({ targetID: 'camera-1', view: 'clip' }); expect(issue.hasIssue()).toBe(true); }); + }); - it('should deactivate when target changes to non-errored target', () => { - const issue = new MediaUnavailableIssue(createAPI()); + describe('naming the failures', () => { + it('should report every failed target that is on screen', () => { + const issue = new MediaUnavailableIssue( + createAPIDisplaying('camera-1', 'camera-2'), + ); - issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); - vi.advanceTimersByTime(10000); - expect(issue.hasIssue()).toBe(true); + issue.trigger({ targetID: 'camera-1', reason: 'stalled' }); + issue.trigger({ targetID: 'camera-2', reason: 'playback_error' }); - // Switch to camera-2 which has no error -- should deactivate and start - // a fresh timer for the new target. - issue.detectDynamic({ targetID: 'camera-2', view: 'live' }); - expect(issue.hasIssue()).toBe(false); - - // camera-2 gets its own timeout window. - vi.advanceTimersByTime(10000); - expect(issue.hasIssue()).toBe(true); + expect(issue.getNotification().metadata).toEqual([ + expect.objectContaining({ text: 'camera-1: Stream stalled' }), + expect.objectContaining({ text: 'camera-2: Playback error' }), + ]); }); - it('should stay active when target changes to errored target', () => { - const issue = new MediaUnavailableIssue(createAPI()); + it('should not name a failed target that has left the screen', () => { + const issue = new MediaUnavailableIssue(createAPIDisplaying('camera-1')); + + issue.trigger({ targetID: 'camera-1', reason: 'stalled' }); + issue.trigger({ targetID: 'camera-2', reason: 'playback_error' }); + + expect(issue.getNotification().metadata).toEqual([ + expect.objectContaining({ text: 'camera-1: Stream stalled' }), + ]); + }); + + it('should not reload a failed target that has left the screen', () => { + const api = createAPIDisplaying('camera-1'); + const issue = new MediaUnavailableIssue(api); 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); + issue.retry(); - // Switch to camera-2 which also has an error -- should stay active. - issue.detectDynamic({ targetID: 'camera-2', view: 'live' }); - expect(issue.hasIssue()).toBe(true); - }); - - it('should clear timed-out state when media loads', () => { - const issue = new MediaUnavailableIssue(createAPI()); - - issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); - vi.advanceTimersByTime(10000); - expect(issue.hasIssue()).toBe(true); - - issue.detectDynamic({ view: 'live', mediaLoadedInfo: createMediaLoadedInfo() }); - expect(issue.hasIssue()).toBe(false); - }); - - it('should restart timer when target changes', () => { - const onChange = vi.fn(); - const issue = new MediaUnavailableIssue(createAPI(), onChange); - - issue.detectDynamic({ - targetID: 'camera-1', - view: 'live', + expect(api.getViewManager().setViewWithMergedContext).toHaveBeenCalledWith({ + mediaEpoch: { 'camera-1': 1 }, }); - vi.advanceTimersByTime(5000); - - // Switch to camera-2: timer restarts from 0 for the new target. - issue.detectDynamic({ - targetID: 'camera-2', - view: 'live', - }); - - // 5 more seconds is not enough for the new 10s timer. - vi.advanceTimersByTime(5000); - expect(issue.hasIssue()).toBe(false); - - // Full 10s from camera-2's timer start. - vi.advanceTimersByTime(5000); - expect(issue.hasIssue()).toBe(true); - expect(onChange).toHaveBeenCalledTimes(1); - }); - - it('should not restart timer for same target while running', () => { - const onChange = vi.fn(); - const issue = new MediaUnavailableIssue(createAPI(), onChange); - - issue.detectDynamic({ - targetID: 'camera-1', - view: 'live', - }); - vi.advanceTimersByTime(5000); - - // Same target again: timer should continue, not restart. - issue.detectDynamic({ - targetID: 'camera-1', - view: 'live', - }); - - // 5 more seconds completes the original 10s timer. - vi.advanceTimersByTime(5000); - expect(issue.hasIssue()).toBe(true); - expect(onChange).toHaveBeenCalledTimes(1); - }); - - it('should not restart timer when targetID is undefined and matches', () => { - const onChange = vi.fn(); - const issue = new MediaUnavailableIssue(createAPI(), onChange); - - issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); - vi.advanceTimersByTime(5000); - - // Same undefined target: timer should continue. - issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); - - vi.advanceTimersByTime(5000); - expect(issue.hasIssue()).toBe(true); - expect(onChange).toHaveBeenCalledTimes(1); - }); - - it('should not restart timer if already timed out', () => { - const onChange = vi.fn(); - const issue = new MediaUnavailableIssue(createAPI(), onChange); - - issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); - vi.advanceTimersByTime(10000); - expect(onChange).toHaveBeenCalledTimes(1); - - // Calling detectDynamic again should not restart timer. - issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); - vi.advanceTimersByTime(10000); - expect(onChange).toHaveBeenCalledTimes(1); - }); - }); - - describe('trigger', () => { - it('should activate immediately when target has error and view is a media view', () => { - const issue = new MediaUnavailableIssue(createAPI()); - - issue.trigger({ targetID: 'camera-1', reason: 'stalled' }); - issue.detectDynamic({ - targetID: 'camera-1', - view: 'live', - }); - - expect(issue.hasIssue()).toBe(true); - }); - - it('should not activate with only a trigger', () => { - const issue = new MediaUnavailableIssue(createAPI()); - - issue.trigger({ targetID: 'camera-1', reason: 'stalled' }); - - expect(issue.hasIssue()).toBe(false); - }); - - it('should clear a not-loading error on a media load', () => { - const api = createAPI(); - const issue = new MediaUnavailableIssue(api); - - issue.trigger({ targetID: 'camera-1', reason: 'not_loading' }); - issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); - expect(issue.hasIssue()).toBe(true); - - // An attached player disproves "media not loading", whoever recorded it. - 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 not clear a stream error on a media load', () => { - const api = createAPI(); - const issue = new MediaUnavailableIssue(api); - - issue.trigger({ targetID: 'camera-1', reason: 'stalled' }); - - // A load only says a player attached -- for a stream that loaded and then - // froze, that includes a reconnect replay of the frozen player. Stream - // errors clear only via resolve, on real evidence of media flowing. - fireMediaLoad(api, 'camera-1'); - issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); - - expect(issue.hasIssue()).toBe(true); - }); - - 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); - }); - - it('should not clear a target error when a different target loads', () => { - const api = createAPI(); - const issue = new MediaUnavailableIssue(api); - - issue.trigger({ targetID: 'camera-1', reason: 'not_loading' }); - - // 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 unload or select changes', () => { - const api = createAPI(); - const issue = new MediaUnavailableIssue(api); - - issue.trigger({ targetID: 'camera-1', reason: 'not_loading' }); - - // Only a 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 MediaUnavailableIssue(createAPI()); - - issue.trigger({ targetID: 'camera-1', reason: 'stalled' }); - issue.detectDynamic({ - targetID: 'camera-2', - view: 'live', - }); - - // camera-2 has no error, so it falls back to timeout behavior. - expect(issue.hasIssue()).toBe(false); }); }); describe('resolve', () => { - it('should clear an errored target', () => { - const issue = new MediaUnavailableIssue(createAPI()); + it('should clear a failure when no reason is named', () => { + const issue = new MediaUnavailableIssue(createAPIDisplaying('camera-1')); - issue.trigger({ targetID: 'camera.office', reason: 'stalled' }); - expect(issue.getNotification().metadata).toEqual([ - expect.objectContaining({ text: 'camera.office: Stream stalled' }), - ]); - - issue.resolve({ targetID: 'camera.office' }); + issue.trigger({ targetID: 'camera-1', reason: 'stalled' }); + issue.resolve({ targetID: 'camera-1' }); + expect(issue.hasIssue()).toBe(false); expect(issue.getNotification().metadata).toBeUndefined(); }); - it('should deactivate a target that is proven to be delivering media again', () => { - const issue = new MediaUnavailableIssue(createAPI()); + it('should clear a failure whose reason matches', () => { + const issue = new MediaUnavailableIssue(createAPIDisplaying('camera-1')); - issue.trigger({ targetID: 'camera-1', reason: 'stalled' }); - issue.detectDynamic({ - targetID: 'camera-1', - view: 'live', - mediaLoadedInfo: createMediaLoadedInfo({ targetID: 'camera-1' }), - }); - expect(issue.hasIssue()).toBe(true); - - issue.resolve({ targetID: 'camera-1' }); - issue.detectDynamic({ - targetID: 'camera-1', - view: 'live', - mediaLoadedInfo: createMediaLoadedInfo({ targetID: 'camera-1' }), - }); + issue.trigger({ targetID: 'camera-1', reason: 'not_loading' }); + issue.resolve({ targetID: 'camera-1', reason: 'not_loading' }); expect(issue.hasIssue()).toBe(false); }); - it('should leave other targets errored', () => { - const issue = new MediaUnavailableIssue(createAPI()); + it('should leave a failure of a different reason alone', () => { + const issue = new MediaUnavailableIssue(createAPIDisplaying('camera-1')); - issue.trigger({ targetID: 'camera.office', reason: 'stalled' }); - - issue.resolve({ targetID: 'camera.garden' }); + // Media arriving refutes a load that never arrived, but says nothing + // about a stall the same media developed since. + issue.trigger({ targetID: 'camera-1', reason: 'stalled' }); + issue.resolve({ targetID: 'camera-1', reason: 'not_loading' }); + expect(issue.hasIssue()).toBe(true); expect(issue.getNotification().metadata).toEqual([ - expect.objectContaining({ text: 'camera.office: Stream stalled' }), + expect.objectContaining({ text: 'camera-1: Stream stalled' }), ]); }); - it('should cancel the pending-load timer for its target', () => { - const onChange = vi.fn(); - const issue = new MediaUnavailableIssue(createAPI(), onChange); + it('should leave other targets alone', () => { + const issue = new MediaUnavailableIssue(createAPIDisplaying('camera-1')); - issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); + issue.trigger({ targetID: 'camera-1', reason: 'stalled' }); + issue.resolve({ targetID: 'camera-2' }); + + expect(issue.getNotification().metadata).toEqual([ + expect.objectContaining({ text: 'camera-1: Stream stalled' }), + ]); + }); + + it('should do nothing for a target that never failed', () => { + const issue = new MediaUnavailableIssue(createAPIDisplaying('camera-1')); issue.resolve({ targetID: 'camera-1' }); - vi.advanceTimersByTime(10000); expect(issue.hasIssue()).toBe(false); - expect(onChange).not.toHaveBeenCalled(); + }); + }); + + describe('trigger', () => { + it('should replace an earlier failure for the same target', () => { + const issue = new MediaUnavailableIssue(createAPIDisplaying('camera-1')); + + // A later, more specific diagnosis supersedes an earlier one. + issue.trigger({ targetID: 'camera-1', reason: 'not_loading' }); + issue.trigger({ targetID: 'camera-1', reason: 'stalled' }); + + expect(issue.getNotification().metadata).toEqual([ + expect.objectContaining({ text: 'camera-1: Stream stalled' }), + ]); + }); + }); + + describe('getIssue', () => { + it('should describe the issue when active', () => { + const issue = new MediaUnavailableIssue(createAPIDisplaying('camera-1')); + + issue.trigger({ targetID: 'camera-1', reason: 'stalled' }); + + expect(issue.getIssue()).toEqual( + expect.objectContaining({ + icon: 'mdi:cctv-off', + severity: 'high', + notification: expect.objectContaining({ + heading: expect.objectContaining({ text: expect.any(String) }), + }), + }), + ); }); - it('should leave the pending-load timer alone for a different target', () => { - const issue = new MediaUnavailableIssue(createAPI()); - - issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); - - issue.resolve({ targetID: 'camera-2' }); - - vi.advanceTimersByTime(10000); - expect(issue.hasIssue()).toBe(true); + it('should return null when not active', () => { + expect( + new MediaUnavailableIssue(createAPIDisplaying('camera-1')).getIssue(), + ).toBeNull(); }); }); describe('getNotification', () => { - it('should return notification regardless of active state', () => { - const issue = new MediaUnavailableIssue(createAPI()); + it('should return a notification regardless of active state', () => { + const issue = new MediaUnavailableIssue(createAPIDisplaying('camera-1')); - const notification = issue.getNotification(); - expect(notification).toEqual( + expect(issue.getNotification()).toEqual( expect.objectContaining({ - heading: expect.objectContaining({ - text: expect.any(String), - }), - link: expect.objectContaining({ - url: expect.any(String), - }), + heading: expect.objectContaining({ text: expect.any(String) }), + link: expect.objectContaining({ url: expect.any(String) }), }), ); }); - it('should include the pending timer target in metadata', () => { - 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: Media not loading', - icon: 'mdi:progress-helper', - }), - ]); - }); - - it('should drop a stale pending-timer target once its timer has stopped', () => { - const issue = new MediaUnavailableIssue(createAPI()); - - // A slow load arms the pending timer for camera.garden. - issue.detectDynamic({ targetID: 'camera.garden', view: 'live' }); - - // The view moves to a different target that already has a hard error. - // That path activates immediately and stops the timer, but the stale - // _timerTargetID (camera.garden) lingers. - issue.trigger({ targetID: 'camera.office', reason: 'playback_error' }); - issue.detectDynamic({ targetID: 'camera.office', view: 'live' }); - - // Only the real error shows; the stale, no-longer-running pending target - // must not paint a "not loading" line. - const notification = issue.getNotification(); - expect(notification.metadata).not.toContainEqual( - expect.objectContaining({ text: 'camera.garden: Media not loading' }), - ); - expect(notification.metadata).toEqual([ - expect.objectContaining({ text: 'camera.office: Playback error' }), - ]); - }); - - it('should use camera title when available', () => { - const api = createAPI(); + it('should use the camera title when available', () => { + const api = createAPIDisplaying('camera.office'); vi.mocked(api.getCameraManager().getCameraMetadata).mockReturnValue({ title: 'Office', icon: { icon: 'mdi:cctv' }, }); const issue = new MediaUnavailableIssue(api); + issue.trigger({ targetID: 'camera.office', reason: 'stalled' }); - const notification = issue.getNotification(); - expect(notification.metadata).toEqual([ + expect(issue.getNotification().metadata).toEqual([ expect.objectContaining({ text: 'Office: Stream stalled' }), ]); }); - it('should use localized label and image icon for the image-view sentinel', () => { - const issue = new MediaUnavailableIssue(createAPI()); + it('should use a localized label and image icon for the image-view sentinel', () => { + const issue = new MediaUnavailableIssue( + createAPIWithView(createView({ view: 'image' })), + ); + issue.trigger({ targetID: IMAGE_VIEW_TARGET_ID_SENTINEL, reason: 'stalled' }); - const notification = issue.getNotification(); - expect(notification.metadata).toEqual([ + expect(issue.getNotification().metadata).toEqual([ expect.objectContaining({ text: 'Image: Stream stalled', icon: 'mdi:image' }), ]); }); @@ -545,24 +313,27 @@ 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'], ['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) => { - const issue = new MediaUnavailableIssue(createAPI()); + const issue = new MediaUnavailableIssue(createAPIDisplaying('camera.office')); + issue.trigger({ targetID: 'camera.office', reason }); - const notification = issue.getNotification(); - expect(notification.metadata).toEqual([ + expect(issue.getNotification().metadata).toEqual([ expect.objectContaining({ text: `camera.office: ${text}`, icon }), ]); }); it('should render the free-text cause as context, keyed by camera title', () => { - const api = createAPI(); + const api = createAPIDisplaying('camera.office'); vi.mocked(api.getCameraManager().getCameraMetadata).mockReturnValue({ title: 'Office', icon: { icon: 'mdi:cctv' }, }); const issue = new MediaUnavailableIssue(api); + issue.trigger({ targetID: 'camera.office', reason: 'playback_error', @@ -581,14 +352,15 @@ describe('MediaUnavailableIssue', () => { }); it('should omit context for targets without a free-text cause', () => { - const issue = new MediaUnavailableIssue(createAPI()); + const issue = new MediaUnavailableIssue(createAPIDisplaying('camera.office')); + issue.trigger({ targetID: 'camera.office', reason: 'stalled' }); expect(issue.getNotification().context).toBeUndefined(); }); - it('should include a retry control with wired callback', async () => { - const api = createCardAPI(); + it('should include a retry control with a wired callback', async () => { + const api = createAPIDisplaying('camera-1'); const issue = new MediaUnavailableIssue(api); const control = issue.getNotification().controls?.[0]; @@ -604,321 +376,100 @@ describe('MediaUnavailableIssue', () => { }); }); - describe('getIssue', () => { - it('should return result when timed out', () => { - const issue = new MediaUnavailableIssue(createAPI()); - - issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); - vi.advanceTimersByTime(10000); - - const result = issue.getIssue(); - expect(result).toEqual( - expect.objectContaining({ - icon: 'mdi:cctv-off', - severity: 'high', - notification: expect.objectContaining({ - link: expect.objectContaining({ - url: expect.any(String), - }), - }), - }), - ); - }); - - it('should return null when not timed out', () => { - const issue = new MediaUnavailableIssue(createAPI()); - - expect(issue.getIssue()).toBeNull(); - }); - }); - describe('needsRetry', () => { - it('should return true when issue is active', () => { - const issue = new MediaUnavailableIssue(createAPI()); + it('should be true while active', () => { + const issue = new MediaUnavailableIssue(createAPIDisplaying('camera-1')); - issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); - vi.advanceTimersByTime(10000); + issue.trigger({ targetID: 'camera-1', reason: 'stalled' }); expect(issue.needsRetry()).toBe(true); }); - it('should return false when issue is not active', () => { - const issue = new MediaUnavailableIssue(createAPI()); - - expect(issue.needsRetry()).toBe(false); + it('should be false while not active', () => { + expect( + new MediaUnavailableIssue(createAPIDisplaying('camera-1')).needsRetry(), + ).toBe(false); }); }); describe('retry', () => { - it('should keep issue active after retry so error stays visible', () => { - const onChange = vi.fn(); - const issue = new MediaUnavailableIssue(createAPI(), onChange); - - issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); - vi.advanceTimersByTime(10000); - expect(issue.hasIssue()).toBe(true); - - issue.retry(); - - // Issue remains active -- no new 10s grace period. The error stays - // visible while the provider re-attempts loading underneath. - expect(issue.hasIssue()).toBe(true); - }); - - it('should return false when no targets have errors', () => { - const api = createAPI(); - const issue = new MediaUnavailableIssue(api); - - expect(issue.retry()).toBe(false); - }); - - it('should bump mediaEpoch for targets with errors and call setViewWithMergedContext', () => { - const api = createAPI(); - vi.mocked(api.getViewManager().getView).mockReturnValue(mock()); + it('should reload every failed target on screen', () => { + const api = createAPIDisplaying('camera-1', 'camera-2'); const issue = new MediaUnavailableIssue(api); issue.trigger({ targetID: 'camera-1', reason: 'stalled' }); - issue.trigger({ targetID: 'media-1', reason: 'stalled' }); + issue.trigger({ targetID: 'camera-2', reason: 'stalled' }); - const result = issue.retry(); - - expect(result).toEqual(false); + expect(issue.retry()).toBe(false); expect(api.getViewManager().setViewWithMergedContext).toHaveBeenCalledWith({ - mediaEpoch: { 'camera-1': 1, 'media-1': 1 }, + mediaEpoch: { 'camera-1': 1, 'camera-2': 1 }, }); }); - it('should bump mediaEpoch for the image-view sentinel', () => { - const api = createAPI(); - vi.mocked(api.getViewManager().getView).mockReturnValue(mock()); + it('should reload the image view', () => { + const api = createAPIWithView(createView({ view: 'image' })); const issue = new MediaUnavailableIssue(api); issue.trigger({ targetID: IMAGE_VIEW_TARGET_ID_SENTINEL, reason: 'stalled' }); - const result = issue.retry(); + issue.retry(); - expect(result).toEqual(false); expect(api.getViewManager().setViewWithMergedContext).toHaveBeenCalledWith({ mediaEpoch: { [IMAGE_VIEW_TARGET_ID_SENTINEL]: 1 }, }); }); - it('should increment existing epoch values from current view context', () => { - const api = createAPI(); - vi.mocked(api.getViewManager().getView).mockReturnValue( - mock({ context: { mediaEpoch: { 'camera-1': 5, 'camera-2': 3 } } }), + it('should increment the epochs already in the view context', () => { + const api = createAPIWithView( + createView({ + view: 'live', + camera: 'camera-1', + context: { mediaEpoch: { 'camera-1': 5, 'camera-2': 3 } }, + }), ); const issue = new MediaUnavailableIssue(api); issue.trigger({ targetID: 'camera-1', reason: 'stalled' }); - const result = issue.retry(); + issue.retry(); - expect(result).toEqual(false); expect(api.getViewManager().setViewWithMergedContext).toHaveBeenCalledWith({ mediaEpoch: { 'camera-1': 6, 'camera-2': 3 }, }); }); - it('should include pending timer target in retry', () => { - const api = createAPI(); - vi.mocked(api.getViewManager().getView).mockReturnValue(mock()); + it('should do nothing when nothing on screen has failed', () => { + const api = createAPIDisplaying('camera-1'); const issue = new MediaUnavailableIssue(api); - // Start the timer for camera-1 (not yet timed out). - issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); - - issue.retry(); - - expect(api.getViewManager().setViewWithMergedContext).toHaveBeenCalledWith({ - mediaEpoch: { 'camera-1': 1 }, - }); + expect(issue.retry()).toBe(false); + expect(api.getViewManager().setViewWithMergedContext).not.toHaveBeenCalled(); }); - it('should not retry a stale pending-timer target once its timer has stopped', () => { - const api = createAPI(); - vi.mocked(api.getViewManager().getView).mockReturnValue(mock()); - const issue = new MediaUnavailableIssue(api); - - // A slow load arms the pending timer for camera.garden. - issue.detectDynamic({ targetID: 'camera.garden', view: 'live' }); - - // The view moves to a target that already has a hard error. That path - // activates immediately and stops the timer, but the stale - // _timerTargetID (camera.garden) lingers -- and that target may since - // have loaded, so reloading it would be gratuitous. - issue.trigger({ targetID: 'camera.office', reason: 'playback_error' }); - issue.detectDynamic({ targetID: 'camera.office', view: 'live' }); - - issue.retry(); - - expect(api.getViewManager().setViewWithMergedContext).toHaveBeenCalledWith({ - mediaEpoch: { 'camera.office': 1 }, - }); - }); - - it('should keep errored targets and issue state after retry', () => { - const api = createAPI(); - vi.mocked(api.getViewManager().getView).mockReturnValue(mock()); - const issue = new MediaUnavailableIssue(api); + it('should keep the failure visible while the reload is attempted', () => { + const issue = new MediaUnavailableIssue(createAPIDisplaying('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. Recovery clears it: a load for a - // not-loading error, a resolve for a stream error. - expect(issue.hasIssue()).toBe(true); - issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); + // Recovery clears it out of band, via a resolve from whatever observes + // the media -- not by the retry itself. expect(issue.hasIssue()).toBe(true); }); }); describe('reset', () => { - it('should stop timer', () => { - const onChange = vi.fn(); - const issue = new MediaUnavailableIssue(createAPI(), onChange); - - issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); - issue.reset(); - - vi.advanceTimersByTime(10000); - - expect(issue.hasIssue()).toBe(false); - expect(onChange).not.toHaveBeenCalled(); - }); - }); - - 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: 'not_loading' }); - fireMediaLoad(api, 'camera-1'); - - expect(onChange).toHaveBeenCalled(); - }); - - it('should not notify onChange when a load changes nothing', () => { - const api = createAPI(); - const onChange = vi.fn(); - const issue = new MediaUnavailableIssue(api, onChange); + it('should forget every failure', () => { + const issue = new MediaUnavailableIssue(createAPIDisplaying('camera-1')); issue.trigger({ targetID: 'camera-1', reason: 'stalled' }); - fireMediaLoad(api, 'camera-1'); + expect(issue.hasIssue()).toBe(true); - expect(onChange).not.toHaveBeenCalled(); - }); - - it('should clear a timer-recorded error when the media later loads', () => { - const api = createAPI(); - const issue = new MediaUnavailableIssue(api); - - // A viewer target has no liveness observer, so a load is the only - // recovery signal it will ever produce. - issue.detectDynamic({ targetID: 'media-1', view: 'clip' }); - vi.advanceTimersByTime(10000); - expect(issue.getNotification().metadata).toEqual([ - expect.objectContaining({ text: 'media-1: Media not loading' }), - ]); - - fireMediaLoad(api, 'media-1'); - - expect(issue.getNotification().metadata).toBeUndefined(); - issue.detectDynamic({ - targetID: 'media-1', - view: 'clip', - mediaLoadedInfo: createMediaLoadedInfo({ targetID: 'media-1' }), - }); - expect(issue.hasIssue()).toBe(false); - }); - - it('should cancel the pending-load timer when its target genuinely loads', () => { - const api = createAPI(); - const issue = new MediaUnavailableIssue(api); - - // A target starts loading, arming the pending-load timer. - issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); - - // It loads in the background, so no further detection pass runs for it - // (detection only ever covers the current target). - fireMediaLoad(api, 'camera-1'); - - vi.advanceTimersByTime(10000); + issue.reset(); expect(issue.hasIssue()).toBe(false); expect(issue.getNotification().metadata).toBeUndefined(); }); - - 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).toHaveBeenCalled(); - }); - }); - - describe('suspend', () => { - it('should stop the pending-load timer so it cannot mature offscreen', () => { - const onChange = vi.fn(); - const issue = new MediaUnavailableIssue(createAPI(), onChange); - - // Enter loading state. Timer arms but has not yet fired. - issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); - vi.advanceTimersByTime(5000); - expect(issue.hasIssue()).toBe(false); - - // Card detaches: timer must stop. - issue.suspend(); - - // Full 10s later (plus margin) the timer has NOT matured -- the user was - // offscreen and that time does not count against them. - vi.advanceTimersByTime(20000); - expect(issue.hasIssue()).toBe(false); - expect(onChange).not.toHaveBeenCalled(); - }); - - it('should preserve an already-active issue across suspend', () => { - const issue = new MediaUnavailableIssue(createAPI()); - - // Issue activates (timeout fires). - issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); - vi.advanceTimersByTime(10000); - expect(issue.hasIssue()).toBe(true); - - // Card detaches -- issue must remain visible on reattach. - issue.suspend(); - - expect(issue.hasIssue()).toBe(true); - }); - - it('should rearm a fresh timer window on resume via detectDynamic', () => { - const onChange = vi.fn(); - const issue = new MediaUnavailableIssue(createAPI(), onChange); - - issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); - vi.advanceTimersByTime(5000); - issue.suspend(); - - // Reattach: the manager's resume() triggers evaluate() → detectDynamic. - // The target is still loading, so the timer arms with a fresh 10s window - // -- not whatever was left when we suspended. - issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); - - vi.advanceTimersByTime(9999); - expect(issue.hasIssue()).toBe(false); - vi.advanceTimersByTime(1); - expect(issue.hasIssue()).toBe(true); - expect(onChange).toHaveBeenCalled(); - }); }); }); diff --git a/tests/card-controller/keyboard-state-manager.browser.test.ts b/tests/card-controller/keyboard-state-manager.browser.test.ts index 6ec80672..0f00ac42 100644 --- a/tests/card-controller/keyboard-state-manager.browser.test.ts +++ b/tests/card-controller/keyboard-state-manager.browser.test.ts @@ -10,8 +10,8 @@ import { import { CARD_INITIALIZED_MESSAGE, clickElement, + createCameraHASS, createInitializedAutomation, - createStillCameraHASS, createStillImageCardConfig, dispatchPointerDown, getFocusedElement, @@ -106,7 +106,7 @@ const mountCard = async (options?: MountCardOptions): Promise => { }, ], }), - createStillCameraHASS({ entities: { [ZOOM_ENTITY]: 'off' } }), + createCameraHASS({ entities: { [ZOOM_ENTITY]: 'off' } }), mountOptions, ); diff --git a/tests/card-controller/style-manager.browser.test.ts b/tests/card-controller/style-manager.browser.test.ts index d0932d74..d16b6ddc 100644 --- a/tests/card-controller/style-manager.browser.test.ts +++ b/tests/card-controller/style-manager.browser.test.ts @@ -1,10 +1,7 @@ import { describe, expect, it } from 'vitest'; import { MountedCardFactory } from '../browser/mounted-card'; -import { - createStillCameraHASS, - createStillImageCardConfig, -} from '../browser/test-utils'; +import { createCameraHASS, createStillImageCardConfig } from '../browser/test-utils'; // A colour the dark theme sets and the card carries no other way. Reading it // back proves the stylesheet reached the card rather than merely compiling. @@ -14,7 +11,7 @@ const DARK_PRIMARY_BACKGROUND = '#111111'; const mountThemed = async (themes: string[]) => await MountedCardFactory.createFromSource( createStillImageCardConfig({ view: { theme: { themes } } }), - createStillCameraHASS(), + createCameraHASS(), ); describe('themes', () => { diff --git a/tests/components-lib/media-load-watchdog-controller.test.ts b/tests/components-lib/media-load-watchdog-controller.test.ts new file mode 100644 index 00000000..ed56ce71 --- /dev/null +++ b/tests/components-lib/media-load-watchdog-controller.test.ts @@ -0,0 +1,455 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { + IssueResolveEventData, + IssueTriggerEventData, +} from '../../src/card-controller/issues/types'; +import { + MEDIA_LOADING_TIMEOUT_SECONDS, + MediaLoadWatchdogController, +} from '../../src/components-lib/media-load-watchdog-controller'; +import { createLitElement, createMediaLoadedInfo } from '../test-utils'; + +const TIMEOUT_MS = MEDIA_LOADING_TIMEOUT_SECONDS * 1000; + +const createHarness = (options?: { + targetID?: string | null; + loadExpected?: boolean; +}) => { + const host = createLitElement(); + let targetID = options?.targetID === undefined ? 'camera-1' : options.targetID; + let loadExpected = options?.loadExpected ?? true; + let attemptID = 0; + + const triggerRequests: IssueTriggerEventData[] = []; + host.addEventListener('advanced-camera-card:issue:trigger', (ev: Event) => { + triggerRequests.push((ev as CustomEvent).detail); + }); + + const resolveRequests: IssueResolveEventData[] = []; + host.addEventListener('advanced-camera-card:issue:resolve', (ev: Event) => { + resolveRequests.push((ev as CustomEvent).detail); + }); + + const controller = new MediaLoadWatchdogController(host, { + getTargetID: () => targetID, + isLoadExpected: () => loadExpected, + getAttemptID: () => attemptID, + }); + + return { + host, + controller, + triggerRequests, + resolveRequests, + setTargetID: (value: string | null): void => { + targetID = value; + }, + setLoadExpected: (value: boolean): void => { + loadExpected = value; + }, + retryMedia: (): void => { + attemptID++; + }, + connect: (): void => controller.hostConnected(), + disconnect: (): void => controller.hostDisconnected(), + update: (): void => controller.hostUpdated(), + + // Deliver a `media:loaded` as a descendant player would, returning the + // controller that makes that media go away. + mediaLoaded: (loadedTargetID: string): AbortController => { + const abort = new AbortController(); + host.dispatchEvent( + new CustomEvent('advanced-camera-card:media:loaded', { + bubbles: true, + composed: true, + detail: { + info: createMediaLoadedInfo({ targetID: loadedTargetID }), + signal: abort.signal, + }, + }), + ); + return abort; + }, + }; +}; + +// @vitest-environment jsdom +describe('MediaLoadWatchdogController', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should register itself with the host', () => { + const harness = createHarness(); + + expect(harness.host.addController).toHaveBeenCalledWith(harness.controller); + }); + + it('should report a load that never arrives', () => { + const harness = createHarness(); + harness.connect(); + + vi.advanceTimersByTime(TIMEOUT_MS); + + expect(harness.triggerRequests).toEqual([ + { key: 'media_unavailable', targetID: 'camera-1', reason: 'not_loading' }, + ]); + }); + + it('should not report a load that arrives in time', () => { + const harness = createHarness(); + harness.connect(); + + vi.advanceTimersByTime(TIMEOUT_MS - 1); + harness.mediaLoaded('camera-1'); + vi.advanceTimersByTime(TIMEOUT_MS); + + expect(harness.triggerRequests).toEqual([]); + }); + + it('should report only once for a target that stays hung', () => { + const harness = createHarness(); + harness.connect(); + + vi.advanceTimersByTime(TIMEOUT_MS); + harness.update(); + vi.advanceTimersByTime(TIMEOUT_MS * 5); + + expect(harness.triggerRequests).toHaveLength(1); + }); + + it('should not wait when no load is expected', () => { + const harness = createHarness({ loadExpected: false }); + harness.connect(); + + vi.advanceTimersByTime(TIMEOUT_MS); + + expect(harness.triggerRequests).toEqual([]); + }); + + it('should not wait without a target', () => { + const harness = createHarness({ targetID: null }); + harness.connect(); + + vi.advanceTimersByTime(TIMEOUT_MS); + + expect(harness.triggerRequests).toEqual([]); + }); + + it('should give a fresh window to a load that becomes expected again', () => { + const harness = createHarness({ loadExpected: false }); + harness.connect(); + + vi.advanceTimersByTime(TIMEOUT_MS); + harness.setLoadExpected(true); + harness.update(); + + vi.advanceTimersByTime(TIMEOUT_MS - 1); + expect(harness.triggerRequests).toEqual([]); + + vi.advanceTimersByTime(1); + expect(harness.triggerRequests).toHaveLength(1); + }); + + it('should not report once the host stops expecting a load', () => { + const harness = createHarness(); + harness.connect(); + + vi.advanceTimersByTime(TIMEOUT_MS - 1); + harness.setLoadExpected(false); + vi.advanceTimersByTime(1); + + expect(harness.triggerRequests).toEqual([]); + }); + + describe('clearing a reported failure', () => { + it('should clear the failure when media arrives', () => { + const harness = createHarness(); + harness.connect(); + + vi.advanceTimersByTime(TIMEOUT_MS); + harness.mediaLoaded('camera-1'); + + expect(harness.resolveRequests).toEqual([ + { key: 'media_unavailable', targetID: 'camera-1', reason: 'not_loading' }, + ]); + }); + + it('should clear a failure it never reported itself', () => { + const harness = createHarness(); + harness.connect(); + + // Another component can report the same kind of failure for this target. + harness.mediaLoaded('camera-1'); + + expect(harness.resolveRequests).toEqual([ + { key: 'media_unavailable', targetID: 'camera-1', reason: 'not_loading' }, + ]); + }); + + it('should not clear anything for a load belonging to another target', () => { + const harness = createHarness(); + harness.connect(); + + harness.mediaLoaded('camera-2'); + + expect(harness.resolveRequests).toEqual([]); + }); + }); + + describe('media going away', () => { + it('should wait again when loaded media goes away', () => { + const harness = createHarness(); + harness.connect(); + + const abort = harness.mediaLoaded('camera-1'); + vi.advanceTimersByTime(TIMEOUT_MS * 2); + expect(harness.triggerRequests).toEqual([]); + + abort.abort(); + vi.advanceTimersByTime(TIMEOUT_MS); + + expect(harness.triggerRequests).toHaveLength(1); + }); + + it('should ignore an older load going away after a newer one', () => { + const harness = createHarness(); + harness.connect(); + + const stale = harness.mediaLoaded('camera-1'); + harness.mediaLoaded('camera-1'); + + stale.abort(); + vi.advanceTimersByTime(TIMEOUT_MS); + + expect(harness.triggerRequests).toEqual([]); + }); + + it('should wait again when media went away while the host was disconnected', () => { + const harness = createHarness(); + harness.connect(); + + const abort = harness.mediaLoaded('camera-1'); + harness.disconnect(); + abort.abort(); + + // A detached host is not waited on, so nothing is reported yet. + vi.advanceTimersByTime(TIMEOUT_MS); + expect(harness.triggerRequests).toEqual([]); + + // The media that went away is gone on return, so the wait resumes rather + // than the host being taken to still have it. + harness.connect(); + vi.advanceTimersByTime(TIMEOUT_MS); + + expect(harness.triggerRequests).toEqual([ + { key: 'media_unavailable', targetID: 'camera-1', reason: 'not_loading' }, + ]); + }); + }); + + describe('a host reused for another target', () => { + it('should not treat a load for another target as its own', () => { + const harness = createHarness(); + harness.connect(); + + harness.mediaLoaded('camera-2'); + vi.advanceTimersByTime(TIMEOUT_MS); + + expect(harness.triggerRequests).toEqual([ + { key: 'media_unavailable', targetID: 'camera-1', reason: 'not_loading' }, + ]); + }); + + it('should not carry a previous load over to the next target', () => { + const harness = createHarness(); + harness.connect(); + harness.mediaLoaded('camera-1'); + + harness.setTargetID('camera-2'); + harness.update(); + vi.advanceTimersByTime(TIMEOUT_MS); + + expect(harness.triggerRequests).toEqual([ + { key: 'media_unavailable', targetID: 'camera-2', reason: 'not_loading' }, + ]); + }); + + it('should give the next target a full window rather than what remained', () => { + const harness = createHarness(); + harness.connect(); + + vi.advanceTimersByTime(TIMEOUT_MS - 1); + harness.setTargetID('camera-2'); + harness.update(); + + vi.advanceTimersByTime(TIMEOUT_MS - 1); + expect(harness.triggerRequests).toEqual([]); + + vi.advanceTimersByTime(1); + expect(harness.triggerRequests).toEqual([ + { key: 'media_unavailable', targetID: 'camera-2', reason: 'not_loading' }, + ]); + }); + + it('should report the next target after the previous one was reported', () => { + const harness = createHarness(); + harness.connect(); + + vi.advanceTimersByTime(TIMEOUT_MS); + harness.setTargetID('camera-2'); + harness.update(); + vi.advanceTimersByTime(TIMEOUT_MS); + + expect(harness.triggerRequests).toEqual([ + { key: 'media_unavailable', targetID: 'camera-1', reason: 'not_loading' }, + { key: 'media_unavailable', targetID: 'camera-2', reason: 'not_loading' }, + ]); + }); + + it('should resolve a failure it reported for the previous target', () => { + const harness = createHarness(); + harness.connect(); + + vi.advanceTimersByTime(TIMEOUT_MS); + harness.setTargetID('camera-2'); + harness.update(); + + // Nothing watches camera-1 now, so nothing could ever say it recovered. + expect(harness.resolveRequests).toEqual([ + { key: 'media_unavailable', targetID: 'camera-1', reason: 'not_loading' }, + ]); + }); + + it('should resolve a failure when the next target loads immediately', () => { + const harness = createHarness(); + harness.connect(); + + vi.advanceTimersByTime(TIMEOUT_MS); + harness.setTargetID('camera-2'); + harness.mediaLoaded('camera-2'); + + expect(harness.resolveRequests).toEqual([ + { key: 'media_unavailable', targetID: 'camera-1', reason: 'not_loading' }, + { key: 'media_unavailable', targetID: 'camera-2', reason: 'not_loading' }, + ]); + }); + + it('should resolve nothing when it reported no failure', () => { + const harness = createHarness(); + harness.connect(); + + harness.setTargetID('camera-2'); + harness.update(); + + expect(harness.resolveRequests).toEqual([]); + }); + }); + + describe('connection', () => { + it('should not wait while disconnected', () => { + const harness = createHarness(); + harness.connect(); + + vi.advanceTimersByTime(TIMEOUT_MS - 1); + harness.disconnect(); + vi.advanceTimersByTime(TIMEOUT_MS); + + expect(harness.triggerRequests).toEqual([]); + }); + + it('should give a fresh window on reconnect', () => { + const harness = createHarness(); + harness.connect(); + harness.disconnect(); + harness.connect(); + + vi.advanceTimersByTime(TIMEOUT_MS - 1); + expect(harness.triggerRequests).toEqual([]); + + vi.advanceTimersByTime(1); + expect(harness.triggerRequests).toHaveLength(1); + }); + + it('should ignore a host update while disconnected', () => { + const harness = createHarness(); + + harness.update(); + vi.advanceTimersByTime(TIMEOUT_MS); + + expect(harness.triggerRequests).toEqual([]); + }); + }); + + describe('a rebuilt attempt at the same target', () => { + it('should wait again after the media underneath is rebuilt', () => { + const harness = createHarness(); + harness.connect(); + harness.mediaLoaded('camera-1'); + + harness.retryMedia(); + harness.update(); + vi.advanceTimersByTime(TIMEOUT_MS); + + expect(harness.triggerRequests).toHaveLength(1); + }); + + it('should report again after a retry of a target already reported', () => { + const harness = createHarness(); + harness.connect(); + + vi.advanceTimersByTime(TIMEOUT_MS); + harness.retryMedia(); + harness.update(); + vi.advanceTimersByTime(TIMEOUT_MS); + + expect(harness.triggerRequests).toHaveLength(2); + }); + + it('should not report an attempt the retry has already replaced', () => { + const harness = createHarness(); + harness.connect(); + + // The retry lands while the window for the previous attempt is still + // maturing, and the host has not been updated yet. + vi.advanceTimersByTime(TIMEOUT_MS - 1); + harness.retryMedia(); + vi.advanceTimersByTime(1); + + expect(harness.triggerRequests).toEqual([]); + }); + + it('should keep a reported failure visible while the retry runs', () => { + const harness = createHarness(); + harness.connect(); + + vi.advanceTimersByTime(TIMEOUT_MS); + harness.retryMedia(); + harness.update(); + + // The target is unchanged, so the failure still describes it until the + // retried media either arrives or hangs in its turn. + expect(harness.resolveRequests).toEqual([]); + }); + + it('should give the rebuilt attempt a full window', () => { + const harness = createHarness(); + harness.connect(); + + vi.advanceTimersByTime(TIMEOUT_MS - 1); + harness.retryMedia(); + harness.update(); + + vi.advanceTimersByTime(TIMEOUT_MS - 1); + expect(harness.triggerRequests).toEqual([]); + + vi.advanceTimersByTime(1); + expect(harness.triggerRequests).toHaveLength(1); + }); + }); +}); diff --git a/tests/components/image-updating-player.browser.test.ts b/tests/components/image-updating-player.browser.test.ts index 7be59e76..fd5d834c 100644 --- a/tests/components/image-updating-player.browser.test.ts +++ b/tests/components/image-updating-player.browser.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'; import type { MediaLoadedInfoEventDetail } from '../../src/types'; import { MountedCardFactory, type MountedCard } from '../browser/mounted-card'; import { - createStillCameraHASS, + createCameraHASS, createStillImageCardConfig, isMediaLoadedInfoEventDetail, STILL_CAMERA_ENTITY, @@ -22,7 +22,7 @@ interface RenderedElement extends Element { } const mount = async (): Promise => { - const hass = createStillCameraHASS({ entities: { [UNRELATED_ENTITY]: 'off' } }); + const hass = createCameraHASS({ entities: { [UNRELATED_ENTITY]: 'off' } }); return await MountedCardFactory.createFromSource(createStillImageCardConfig(), hass); }; diff --git a/tests/dist/dist.browser.test.ts b/tests/dist/dist.browser.test.ts index 4d0ea1cc..a3274c44 100644 --- a/tests/dist/dist.browser.test.ts +++ b/tests/dist/dist.browser.test.ts @@ -11,7 +11,7 @@ import { type MountOptions, } from '../browser/mounted-card'; import { - createStillCameraHASS, + createCameraHASS, createStillImageCardConfig, isLiveMediaShowing, } from '../browser/test-utils'; @@ -99,7 +99,7 @@ class BuildMountedCardFactory extends MountedCardFactory { * rather than from `src/`. */ const mountBuiltCard = async ( - hass: FakeHASS = createStillCameraHASS(), + hass: FakeHASS = createCameraHASS(), ): Promise => await BuildMountedCardFactory.createFromBuild( `/${PUBLIC_ENTRY}?hacstag=${HACSTAG}`, @@ -243,7 +243,7 @@ describe('the built card', () => { // Clear resource timings to only measure the impact of mounting the card. performance.clearResourceTimings(); - const mounted = await mountBuiltCard(createStillCameraHASS({ language: 'de' })); + const mounted = await mountBuiltCard(createCameraHASS({ language: 'de' })); await mounted.events.waitForFirst('advanced-camera-card:media:loaded'); expect(getLanguageChunks()).toEqual([expect.stringMatching(/^lang-de-/)]); diff --git a/tests/view/layout.test.ts b/tests/view/layout.test.ts new file mode 100644 index 00000000..13e0b11a --- /dev/null +++ b/tests/view/layout.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from 'vitest'; +import { mock } from 'vitest-mock-extended'; + +import type { CameraManager } from '../../src/camera-manager/manager'; +import type { CameraManagerReadOnlyConfigStore } from '../../src/camera-manager/store'; +import { + getDisplayedTargetIDs, + getLiveGridCameraIDs, + getViewerGridCameraIDs, +} from '../../src/view/layout'; +import { QueryResults } from '../../src/view/query-results'; +import { IMAGE_VIEW_TARGET_ID_SENTINEL } from '../../src/view/target-id'; +import { createView, generateViewMediaArray } from './test-utils'; + +// A camera manager whose store reports the given live-capable cameras. +const createCameraManager = (cameraIDs: string[]): CameraManager => { + const cameraManager = mock(); + const store = mock(); + store.getCameraIDsWithCapability.mockReturnValue(new Set(cameraIDs)); + cameraManager.getStore.mockReturnValue(store); + return cameraManager; +}; + +// Query results spanning `cameraIDs`, each camera with its own media. +const createQueryResults = (cameraIDs: string[]): QueryResults => + new QueryResults({ + results: generateViewMediaArray({ cameraIDs, count: 1 }), + selectedIndex: 0, + }); + +describe('getLiveGridCameraIDs', () => { + it('should return every live camera when laid out as a grid', () => { + const view = createView({ view: 'live', displayMode: 'grid' }); + + expect( + getLiveGridCameraIDs(view, createCameraManager(['kitchen', 'office'])), + ).toEqual(new Set(['kitchen', 'office'])); + }); + + it('should return null when not laid out as a grid', () => { + const view = createView({ view: 'live', displayMode: 'single' }); + + expect( + getLiveGridCameraIDs(view, createCameraManager(['kitchen', 'office'])), + ).toBeNull(); + }); + + it('should return null for a view that inherited a grid it cannot use', () => { + const view = createView({ view: 'image', displayMode: 'grid' }); + + expect( + getLiveGridCameraIDs(view, createCameraManager(['kitchen', 'office'])), + ).toBeNull(); + }); +}); + +describe('getViewerGridCameraIDs', () => { + it('should return every camera with media when laid out as a grid', () => { + const view = createView({ + view: 'media', + displayMode: 'grid', + queryResults: createQueryResults(['kitchen', 'office']), + }); + + expect(getViewerGridCameraIDs(view)).toEqual(new Set(['kitchen', 'office'])); + }); + + it('should return null when not laid out as a grid', () => { + const view = createView({ + view: 'media', + displayMode: 'single', + queryResults: createQueryResults(['kitchen', 'office']), + }); + + expect(getViewerGridCameraIDs(view)).toBeNull(); + }); + + it('should return null for a view that inherited a grid it cannot use', () => { + const view = createView({ + view: 'image', + displayMode: 'grid', + queryResults: createQueryResults(['kitchen', 'office']), + }); + + expect(getViewerGridCameraIDs(view)).toBeNull(); + }); +}); + +describe('getDisplayedTargetIDs', () => { + it('should return every camera of a live grid', () => { + const view = createView({ view: 'live', displayMode: 'grid' }); + + expect( + getDisplayedTargetIDs(view, createCameraManager(['kitchen', 'office'])), + ).toEqual(new Set(['kitchen', 'office'])); + }); + + it('should return the selected camera of a live carousel', () => { + const view = createView({ + view: 'live', + camera: 'kitchen', + displayMode: 'single', + }); + + expect( + getDisplayedTargetIDs(view, createCameraManager(['kitchen', 'office'])), + ).toEqual(new Set(['kitchen'])); + }); + + it('should return the selected media of every camera of a viewer grid', () => { + const view = createView({ + view: 'media', + displayMode: 'grid', + queryResults: createQueryResults(['kitchen', 'office']), + }); + + expect(getDisplayedTargetIDs(view, createCameraManager([]))).toEqual( + new Set(['id-kitchen-0', 'id-office-0']), + ); + }); + + it('should return the selected media of a viewer carousel', () => { + const view = createView({ + view: 'media', + displayMode: 'single', + queryResults: createQueryResults(['kitchen', 'office']), + }); + + expect(getDisplayedTargetIDs(view, createCameraManager([]))).toEqual( + new Set(['id-kitchen-0']), + ); + }); + + it('should skip a viewer grid camera without a selected media', () => { + const queryResults = createQueryResults(['kitchen', 'office']); + queryResults.resetSelectedResult('office'); + const view = createView({ + view: 'media', + displayMode: 'grid', + queryResults, + }); + + expect(getDisplayedTargetIDs(view, createCameraManager([]))).toEqual( + new Set(['id-kitchen-0']), + ); + }); + + it('should return the lone camera of a live grid that needs no layout', () => { + const view = createView({ + view: 'live', + camera: 'kitchen', + displayMode: 'grid', + }); + + expect(getDisplayedTargetIDs(view, createCameraManager(['kitchen']))).toEqual( + new Set(['kitchen']), + ); + }); + + it('should return the lone media of a viewer grid that needs no layout', () => { + const view = createView({ + view: 'media', + displayMode: 'grid', + queryResults: createQueryResults(['kitchen']), + }); + + expect(getDisplayedTargetIDs(view, createCameraManager([]))).toEqual( + new Set(['id-kitchen-0']), + ); + }); + + it('should return nothing for a viewer grid without query results', () => { + const view = createView({ view: 'media', displayMode: 'grid' }); + + expect(getDisplayedTargetIDs(view, createCameraManager([]))).toEqual(new Set()); + }); + + it('should return the sentinel for the image view', () => { + const view = createView({ view: 'image' }); + + expect(getDisplayedTargetIDs(view, createCameraManager([]))).toEqual( + new Set([IMAGE_VIEW_TARGET_ID_SENTINEL]), + ); + }); + + it('should return nothing for a view that displays no media', () => { + const view = createView({ view: 'timeline' }); + + expect(getDisplayedTargetIDs(view, createCameraManager([]))).toEqual(new Set()); + }); +});