Files
advanced-camera-card/tests/card-controller/issues/issues/media-load.test.ts
T

624 lines
20 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { MediaLoadIssue } from '../../../../src/card-controller/issues/issues/media-load';
import { InternalCallbackActionConfig } from '../../../../src/config/schema/actions/custom/internal';
import { View } from '../../../../src/view/view';
import { createCardAPI, createMediaLoadedInfo } from '../../../test-utils';
import { IMAGE_VIEW_TARGET_ID_SENTINEL } from '../../../../src/view/target-id';
const createAPI = () => createCardAPI();
// @vitest-environment jsdom
describe('MediaLoadIssue', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should have correct key', () => {
const issue = new MediaLoadIssue(createAPI());
expect(issue.key).toBe('media_load');
});
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 MediaLoadIssue(createAPI(), onChange);
issue.detectDynamic({ targetID: 'target-1', view });
expect(issue.hasIssue()).toBe(false);
vi.advanceTimersByTime(10000);
expect(issue.hasIssue()).toBe(true);
expect(onChange).toBeCalled();
});
it('should not start timer when targetID is null (no provider rendering)', () => {
const issue = new MediaLoadIssue(createAPI());
// Media view but no targetID, e.g. viewer showing "No media to display"
// instead of mounting a provider.
issue.detectDynamic({ view: 'media' });
vi.advanceTimersByTime(10000);
expect(issue.hasIssue()).toBe(false);
});
it('should deactivate when targetID becomes null', () => {
const issue = new MediaLoadIssue(createAPI());
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
vi.advanceTimersByTime(10000);
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 MediaLoadIssue(createAPI());
issue.detectDynamic({ view: 'timeline' });
vi.advanceTimersByTime(10000);
expect(issue.hasIssue()).toBe(false);
});
it('should not start timer when view is undefined', () => {
const issue = new MediaLoadIssue(createAPI());
issue.detectDynamic({});
vi.advanceTimersByTime(10000);
expect(issue.hasIssue()).toBe(false);
});
it('should not start timer when media is loaded', () => {
const issue = new MediaLoadIssue(createAPI());
issue.detectDynamic({ view: 'live', mediaLoadedInfo: createMediaLoadedInfo() });
vi.advanceTimersByTime(10000);
expect(issue.hasIssue()).toBe(false);
});
it('should clear timeout when media loads', () => {
const issue = new MediaLoadIssue(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 MediaLoadIssue(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 MediaLoadIssue(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 MediaLoadIssue(createAPI());
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
vi.advanceTimersByTime(10000);
expect(issue.hasIssue()).toBe(true);
// 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);
});
it('should stay active when target changes to errored target', () => {
const issue = new MediaLoadIssue(createAPI());
issue.trigger({ targetID: 'camera-1' });
issue.trigger({ targetID: 'camera-2' });
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
expect(issue.hasIssue()).toBe(true);
// 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 MediaLoadIssue(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 MediaLoadIssue(createAPI(), onChange);
issue.detectDynamic({
targetID: 'camera-1',
view: 'live',
});
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).toBeCalledTimes(1);
});
it('should not restart timer for same target while running', () => {
const onChange = vi.fn();
const issue = new MediaLoadIssue(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).toBeCalledTimes(1);
});
it('should not restart timer when targetID is undefined and matches', () => {
const onChange = vi.fn();
const issue = new MediaLoadIssue(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).toBeCalledTimes(1);
});
it('should not restart timer if already timed out', () => {
const onChange = vi.fn();
const issue = new MediaLoadIssue(createAPI(), onChange);
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
vi.advanceTimersByTime(10000);
expect(onChange).toBeCalledTimes(1);
// Calling detectDynamic again should not restart timer.
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
vi.advanceTimersByTime(10000);
expect(onChange).toBeCalledTimes(1);
});
});
describe('trigger', () => {
it('should activate immediately when target has error and view is a media view', () => {
const issue = new MediaLoadIssue(createAPI());
issue.trigger({ targetID: 'camera-1' });
issue.detectDynamic({
targetID: 'camera-1',
view: 'live',
});
expect(issue.hasIssue()).toBe(true);
});
it('should not activate with only a trigger', () => {
const issue = new MediaLoadIssue(createAPI());
issue.trigger({ targetID: 'camera-1' });
expect(issue.hasIssue()).toBe(false);
});
it('should clear target error when media loads', () => {
const issue = new MediaLoadIssue(createAPI());
issue.trigger({ targetID: 'camera-1' });
issue.detectDynamic({
targetID: 'camera-1',
view: 'live',
});
expect(issue.hasIssue()).toBe(true);
// Media loaded clears the error for this target.
issue.detectDynamic({
targetID: 'camera-1',
view: 'live',
mediaLoadedInfo: createMediaLoadedInfo(),
});
// Target error was cleared by the successful load, so this unloaded state
// falls back to the timer (issue would not activate until after the
// timer is reached).
issue.detectDynamic({
targetID: 'camera-1',
view: 'live',
});
expect(issue.hasIssue()).toBe(false);
});
it('should not activate for a different target', () => {
const issue = new MediaLoadIssue(createAPI());
issue.trigger({ targetID: 'camera-1' });
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('getNotification', () => {
it('should return notification regardless of active state', () => {
const issue = new MediaLoadIssue(createAPI());
const notification = issue.getNotification();
expect(notification).toEqual(
expect.objectContaining({
heading: expect.objectContaining({
text: expect.any(String),
}),
link: expect.objectContaining({
url: expect.any(String),
}),
}),
);
});
it('should include metadata for errored targets', () => {
const issue = new MediaLoadIssue(createAPI());
issue.trigger({ targetID: 'camera.office' });
const notification = issue.getNotification();
expect(notification.metadata).toEqual([
expect.objectContaining({ text: 'camera.office', icon: 'mdi:cctv' }),
]);
});
it('should include the pending timer target in metadata', () => {
const issue = new MediaLoadIssue(createAPI());
// Start a load timer (no explicit error yet, just slow-loading).
issue.detectDynamic({ targetID: 'camera.garden', view: 'live' });
const notification = issue.getNotification();
expect(notification.metadata).toEqual([
expect.objectContaining({ text: 'camera.garden', icon: 'mdi:cctv' }),
]);
});
it('should use camera title when available', () => {
const api = createAPI();
vi.mocked(api.getCameraManager().getCameraMetadata).mockReturnValue({
title: 'Office',
icon: { icon: 'mdi:cctv' },
});
const issue = new MediaLoadIssue(api);
issue.trigger({ targetID: 'camera.office' });
const notification = issue.getNotification();
expect(notification.metadata).toEqual([
expect.objectContaining({ text: 'Office' }),
]);
});
it('should use localized label and image icon for the image-view sentinel', () => {
const issue = new MediaLoadIssue(createAPI());
issue.trigger({ targetID: IMAGE_VIEW_TARGET_ID_SENTINEL });
const notification = issue.getNotification();
expect(notification.metadata).toEqual([
expect.objectContaining({ text: 'Image', icon: 'mdi:image' }),
]);
});
it('should include a retry control with wired callback', async () => {
const api = createCardAPI();
const issue = new MediaLoadIssue(api);
const control = issue.getNotification().controls?.[0];
expect(control).toMatchObject({ icon: 'mdi:refresh', dismiss: true });
const tapAction = control?.actions?.tap_action as InternalCallbackActionConfig;
await tapAction.callback(api);
expect(api.getIssueManager().retry).toBeCalledWith('media_load', true);
});
});
describe('getIssue', () => {
it('should return result when timed out', () => {
const issue = new MediaLoadIssue(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 MediaLoadIssue(createAPI());
expect(issue.getIssue()).toBeNull();
});
});
describe('needsRetry', () => {
it('should return true when issue is active', () => {
const issue = new MediaLoadIssue(createAPI());
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
vi.advanceTimersByTime(10000);
expect(issue.needsRetry()).toBe(true);
});
it('should return false when issue is not active', () => {
const issue = new MediaLoadIssue(createAPI());
expect(issue.needsRetry()).toBe(false);
});
});
describe('retry', () => {
it('should keep issue active after retry so error stays visible', () => {
const onChange = vi.fn();
const issue = new MediaLoadIssue(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 MediaLoadIssue(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<View>());
const issue = new MediaLoadIssue(api);
issue.trigger({ targetID: 'camera-1' });
issue.trigger({ targetID: 'media-1' });
const result = issue.retry();
expect(result).toEqual(false);
expect(api.getViewManager().setViewWithMergedContext).toBeCalledWith({
mediaEpoch: { 'camera-1': 1, 'media-1': 1 },
});
});
it('should bump mediaEpoch for the image-view sentinel', () => {
const api = createAPI();
vi.mocked(api.getViewManager().getView).mockReturnValue(mock<View>());
const issue = new MediaLoadIssue(api);
issue.trigger({ targetID: IMAGE_VIEW_TARGET_ID_SENTINEL });
const result = issue.retry();
expect(result).toEqual(false);
expect(api.getViewManager().setViewWithMergedContext).toBeCalledWith({
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<View>({ context: { mediaEpoch: { 'camera-1': 5, 'camera-2': 3 } } }),
);
const issue = new MediaLoadIssue(api);
issue.trigger({ targetID: 'camera-1' });
const result = issue.retry();
expect(result).toEqual(false);
expect(api.getViewManager().setViewWithMergedContext).toBeCalledWith({
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<View>());
const issue = new MediaLoadIssue(api);
// Start the timer for camera-1 (not yet timed out).
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
issue.retry();
expect(api.getViewManager().setViewWithMergedContext).toBeCalledWith({
mediaEpoch: { 'camera-1': 1 },
});
});
it('should keep errored targets and issue state after retry', () => {
const api = createAPI();
vi.mocked(api.getViewManager().getView).mockReturnValue(mock<View>());
const issue = new MediaLoadIssue(api);
issue.trigger({ targetID: 'camera-1' });
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
expect(issue.hasIssue()).toBe(true);
issue.retry();
// After retry, the issue stays active and the errored target is
// preserved — no new 10s grace period. If media:loaded fires, the
// existing _handleMediaLoaded path will clear everything.
expect(issue.hasIssue()).toBe(true);
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
expect(issue.hasIssue()).toBe(true);
});
});
describe('reset', () => {
it('should stop timer', () => {
const onChange = vi.fn();
const issue = new MediaLoadIssue(createAPI(), onChange);
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
issue.reset();
vi.advanceTimersByTime(10000);
expect(issue.hasIssue()).toBe(false);
expect(onChange).not.toBeCalled();
});
});
describe('suspend', () => {
it('should stop the pending-load timer so it cannot mature offscreen', () => {
const onChange = vi.fn();
const issue = new MediaLoadIssue(createAPI(), onChange);
// 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.toBeCalled();
});
it('should preserve an already-active issue across suspend', () => {
const issue = new MediaLoadIssue(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 MediaLoadIssue(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).toBeCalled();
});
});
});