fix: Report and retry media failures for every camera in a grid (#2658)

- Closes: #2637
 - Related: #2099
This commit is contained in:
Dermot Duffy
2026-08-05 21:35:47 -07:00
committed by GitHub
parent 9f88aacefe
commit c137f4c00c
31 changed files with 1719 additions and 1000 deletions
+22 -1
View File
@@ -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<void> {
const control = await this.waitForRender(
() => deepQuery<HTMLElement>(this.card, `ha-icon-button.controls.${side}`),
`the ${side} carousel control`,
);
await clickElement(control);
}
private async _findControl(name: string): Promise<HTMLElement> {
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}`);
+2 -2
View File
@@ -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({
+3 -3
View File
@@ -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));
@@ -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<MountedCard> => {
const hass = createStillCameraHASS({ entities: { [TRIGGER_ENTITY]: 'off' } });
const hass = createCameraHASS({ entities: { [TRIGGER_ENTITY]: 'off' } });
return await MountedCardFactory.createFromSource(
createStillImageCardConfig({
automations: [
@@ -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<MountedCard> => {
},
],
}),
createStillCameraHASS(),
createCameraHASS(),
);
await card.events.waitForFirst('advanced-camera-card:media:loaded');
@@ -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<MountedCard> =>
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
+33 -6
View File
@@ -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,
@@ -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);
@@ -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<MountedCard> =>
view: { issues: { retry_seconds: 0 } },
automations: [createInitializedAutomation()],
}),
createStillCameraHASS(),
createCameraHASS(),
);
describe('InitializationIssue', () => {
@@ -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<void> =>
await new Promise<void>((resolve) => {
if (image.complete && image.naturalWidth > 0) {
resolve();
} else {
image.addEventListener('load', () => resolve(), { once: true });
}
});
const waitForIssueReported = async (card: MountedCard): Promise<void> => {
await card.waitForRender(
() => findIssue(card),
@@ -48,9 +62,14 @@ const waitForIssueReported = async (card: MountedCard): Promise<void> => {
);
};
interface MountCardOptions extends MountOptions {
cameras?: string[];
}
const waitForIssueCleared = async (card: MountedCard): Promise<void> => {
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<RawAdvancedCameraCardConfig>,
options?: MountCardOptions,
): Promise<MountedCard> => {
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<MountedCard> => {
return card;
};
const mountCardDualCameras = async (): Promise<MountedCard> => {
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<RawAdvancedCameraCardConfig>;
entities?: CameraHASSOptions['entities'];
},
): Promise<MountedCard> =>
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<MountedCard> => {
},
);
const mountCardDualCameras = async (): Promise<MountedCard> => {
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<HTMLImageElement>('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
File diff suppressed because it is too large Load Diff
@@ -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<MountedCard> => {
},
],
}),
createStillCameraHASS({ entities: { [ZOOM_ENTITY]: 'off' } }),
createCameraHASS({ entities: { [ZOOM_ENTITY]: 'off' } }),
mountOptions,
);
@@ -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', () => {
@@ -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<IssueTriggerEventData>).detail);
});
const resolveRequests: IssueResolveEventData[] = [];
host.addEventListener('advanced-camera-card:issue:resolve', (ev: Event) => {
resolveRequests.push((ev as CustomEvent<IssueResolveEventData>).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);
});
});
});
@@ -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<MountedCard> => {
const hass = createStillCameraHASS({ entities: { [UNRELATED_ENTITY]: 'off' } });
const hass = createCameraHASS({ entities: { [UNRELATED_ENTITY]: 'off' } });
return await MountedCardFactory.createFromSource(createStillImageCardConfig(), hass);
};
+3 -3
View File
@@ -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<MountedCard> =>
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-/)]);
+191
View File
@@ -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<CameraManager>();
const store = mock<CameraManagerReadOnlyConfigStore>();
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());
});
});