test: Add browser tests for the media viewing gallery and viewer (#2661)
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { FrigateEvent } from '../../../src/camera-manager/frigate/types';
|
||||
import type { PartialAdvancedCameraCardConfig } from '../../../src/config/types';
|
||||
import {
|
||||
createTestFrigateEvent,
|
||||
EVENT_TIME_NEWER,
|
||||
EVENT_TIME_OLDER,
|
||||
mountCardWithFrigate,
|
||||
} from '../../browser/fake-frigate';
|
||||
import type { MountedCard } from '../../browser/mounted-card';
|
||||
import {
|
||||
clickThumbnail,
|
||||
deepQuery,
|
||||
getBlockNotificationText,
|
||||
getMediaViewerMediaURLs,
|
||||
getThumbnails,
|
||||
waitForThumbnails,
|
||||
} from '../../browser/test-utils';
|
||||
|
||||
const NO_MEDIA_TEXT = 'No media to display';
|
||||
|
||||
const mountCard = async (
|
||||
events: FrigateEvent[],
|
||||
config?: PartialAdvancedCameraCardConfig,
|
||||
): Promise<MountedCard> =>
|
||||
(await mountCardWithFrigate(events, { view: { default: 'clips' }, ...config })).card;
|
||||
|
||||
describe('AdvancedCameraCardGallery', () => {
|
||||
it('should show a thumbnail for every event the camera detected', async () => {
|
||||
const card = await mountCard([
|
||||
createTestFrigateEvent('older', EVENT_TIME_OLDER),
|
||||
createTestFrigateEvent('newer', EVENT_TIME_NEWER),
|
||||
]);
|
||||
|
||||
await waitForThumbnails(card, 2);
|
||||
|
||||
expect(getThumbnails(card.card)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should show the picture Frigate has of each event', async () => {
|
||||
const card = await mountCard([createTestFrigateEvent('newer', EVENT_TIME_NEWER)]);
|
||||
|
||||
await waitForThumbnails(card, 1);
|
||||
const thumbnail = getThumbnails(card.card)[0];
|
||||
|
||||
// A thumbnail that never arrives is drawn as an icon and nothing else says
|
||||
// so, so counting thumbnails says nothing about whether there is a picture
|
||||
// in them. The card fetches one with the user's credentials and embeds what
|
||||
// comes back, which is why the result is a data URL rather than the path
|
||||
// asked for.
|
||||
const image = await card.waitForRender(
|
||||
() => deepQuery<HTMLImageElement>(thumbnail, 'img'),
|
||||
'the thumbnail picture',
|
||||
);
|
||||
|
||||
expect(image.src).toMatch(/^data:image\/png/);
|
||||
});
|
||||
|
||||
it('should say there is nothing to view when the camera has no events', async () => {
|
||||
const card = await mountCard([]);
|
||||
|
||||
// The element renders before its text, so waiting for the element alone can
|
||||
// read it while it is still empty.
|
||||
await card.waitForRender(
|
||||
() => getBlockNotificationText(card.card).includes(NO_MEDIA_TEXT) || null,
|
||||
`the "${NO_MEDIA_TEXT}" notification`,
|
||||
);
|
||||
|
||||
expect(getThumbnails(card.card)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should open the clips gallery from the live view', async () => {
|
||||
const card = await mountCard([createTestFrigateEvent('newer', EVENT_TIME_NEWER)], {
|
||||
view: { default: 'live' },
|
||||
|
||||
// The clips button is hidden by default.
|
||||
menu: { style: 'outside', buttons: { clips: { enabled: true } } },
|
||||
});
|
||||
|
||||
await card.events.waitForFirst('advanced-camera-card:media:loaded');
|
||||
|
||||
await card.clickControl('Clips gallery');
|
||||
await card.waitForSelector('advanced-camera-card-gallery');
|
||||
|
||||
await waitForThumbnails(card, 1);
|
||||
expect(getThumbnails(card.card)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should open the viewer on the media that was clicked', async () => {
|
||||
const card = await mountCard([
|
||||
createTestFrigateEvent('older', EVENT_TIME_OLDER),
|
||||
createTestFrigateEvent('newer', EVENT_TIME_NEWER),
|
||||
]);
|
||||
|
||||
await waitForThumbnails(card, 2);
|
||||
|
||||
// The newest event is shown first, so index 1 is the older of the two.
|
||||
await clickThumbnail(card.card, 1);
|
||||
await card.events.waitForFirst('advanced-camera-card:media:loaded');
|
||||
|
||||
await card.waitForSelector('advanced-camera-card-viewer-carousel');
|
||||
|
||||
expect(getMediaViewerMediaURLs(card.card)).toEqual([
|
||||
expect.stringContaining('clip.webm?event=older'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should show the media filter', async () => {
|
||||
const card = await mountCard([createTestFrigateEvent('newer', EVENT_TIME_NEWER)]);
|
||||
|
||||
await card.waitForSelector('advanced-camera-card-media-filter');
|
||||
|
||||
expect(deepQuery(card.card, 'advanced-camera-card-media-filter')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should not show the media filter when its mode is none', async () => {
|
||||
const card = await mountCard([createTestFrigateEvent('newer', EVENT_TIME_NEWER)], {
|
||||
media_gallery: { controls: { filter: { mode: 'none' } } },
|
||||
});
|
||||
|
||||
await waitForThumbnails(card, 1);
|
||||
|
||||
// The gallery still renders, so a missing filter is not a missing gallery.
|
||||
expect(deepQuery(card.card, 'advanced-camera-card-gallery')).not.toBeNull();
|
||||
expect(deepQuery(card.card, 'advanced-camera-card-media-filter')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -3,10 +3,10 @@ import { describe, expect, it } from 'vitest';
|
||||
import type { MediaLoadedInfoEventDetail } from '../../src/types';
|
||||
import { MountedCardFactory, type MountedCard } from '../browser/mounted-card';
|
||||
import {
|
||||
createCameraHASS,
|
||||
CAMERA_ENTITY,
|
||||
createGenericCameraHASS,
|
||||
createStillImageCardConfig,
|
||||
isMediaLoadedInfoEventDetail,
|
||||
STILL_CAMERA_ENTITY,
|
||||
} from '../browser/test-utils';
|
||||
|
||||
const UNRELATED_ENTITY = 'input_boolean.unrelated';
|
||||
@@ -22,7 +22,7 @@ interface RenderedElement extends Element {
|
||||
}
|
||||
|
||||
const mount = async (): Promise<MountedCard> => {
|
||||
const hass = createCameraHASS({ entities: { [UNRELATED_ENTITY]: 'off' } });
|
||||
const hass = createGenericCameraHASS({ entities: { [UNRELATED_ENTITY]: 'off' } });
|
||||
return await MountedCardFactory.createFromSource(createStillImageCardConfig(), hass);
|
||||
};
|
||||
|
||||
@@ -54,7 +54,7 @@ describe('AdvancedCameraCardImageUpdatingPlayer', () => {
|
||||
|
||||
const loads = getMediaLoadedInfos(card);
|
||||
expect(loads).toHaveLength(1);
|
||||
expect(loads[0].info.targetID).toBe(STILL_CAMERA_ENTITY);
|
||||
expect(loads[0].info.targetID).toBe(CAMERA_ENTITY);
|
||||
});
|
||||
|
||||
it('should announce the size of the media itself', async () => {
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { assert, describe, expect, it, onTestFinished, vi } from 'vitest';
|
||||
|
||||
import { MEDIA_LOADING_TIMEOUT_SECONDS } from '../../../src/components-lib/media-load-watchdog-controller';
|
||||
import type { PartialAdvancedCameraCardConfig } from '../../../src/config/types';
|
||||
import {
|
||||
createTestFrigateEvent,
|
||||
EVENT_TIME_NEWER,
|
||||
EVENT_TIME_OLDER,
|
||||
mountCardWithFrigate,
|
||||
} from '../../browser/fake-frigate';
|
||||
import type { MountedCard } from '../../browser/mounted-card';
|
||||
import { useTestMedia } from '../../browser/test-media';
|
||||
import {
|
||||
clickElement,
|
||||
clickNextPreviousMedia,
|
||||
clickThumbnail,
|
||||
createFailingMediaURL,
|
||||
deepQuery,
|
||||
getMediaViewerMediaURLs,
|
||||
getSelectedThumbnail,
|
||||
getStatusBarItem,
|
||||
getStatusBarStrings,
|
||||
getThumbnails,
|
||||
waitForMediaViewerMedia,
|
||||
waitForThumbnails,
|
||||
} from '../../browser/test-utils';
|
||||
|
||||
const EVENTS = [
|
||||
createTestFrigateEvent('older', EVENT_TIME_OLDER),
|
||||
createTestFrigateEvent('newer', EVENT_TIME_NEWER),
|
||||
];
|
||||
|
||||
// What the status bar calls a media failure, which is where the viewer shows
|
||||
// the issue: the media it cannot show is still on screen behind the carousel.
|
||||
const MEDIA_ISSUE_TITLE = 'Media unavailable';
|
||||
|
||||
// The thumbnail carousel sits in a drawer by default, which is harder to click.
|
||||
const CONFIG_THUMBNAILS_BELOW: PartialAdvancedCameraCardConfig = {
|
||||
media_viewer: { controls: { thumbnails: { mode: 'below' } } },
|
||||
};
|
||||
|
||||
interface ViewerOptions {
|
||||
config?: PartialAdvancedCameraCardConfig;
|
||||
|
||||
// Which gallery thumbnail to open the viewer on. The newest event is first.
|
||||
thumbnail?: number;
|
||||
}
|
||||
|
||||
const mountViewer = async (
|
||||
events = EVENTS,
|
||||
options?: ViewerOptions,
|
||||
): Promise<MountedCard> => {
|
||||
const { card } = await mountCardWithFrigate(events, {
|
||||
view: { default: 'clips' },
|
||||
...options?.config,
|
||||
});
|
||||
|
||||
await waitForThumbnails(card, events.length);
|
||||
await clickThumbnail(card.card, options?.thumbnail ?? 0);
|
||||
await card.events.waitForFirst('advanced-camera-card:media:loaded');
|
||||
|
||||
return card;
|
||||
};
|
||||
|
||||
// One test here has a clip that never loads, which is served from within the
|
||||
// page rather than by the dev server.
|
||||
useTestMedia();
|
||||
|
||||
describe('AdvancedCameraCardViewerCarousel', () => {
|
||||
it('should play the clip that was opened', async () => {
|
||||
const card = await mountViewer();
|
||||
|
||||
expect(getMediaViewerMediaURLs(card.card)).toEqual([
|
||||
expect.stringContaining('clip.webm?event=newer'),
|
||||
]);
|
||||
expect(deepQuery(card.card, 'advanced-camera-card-video-player')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should show a snapshot as a still image', async () => {
|
||||
const card = await mountViewer(EVENTS, {
|
||||
config: { view: { default: 'snapshots' } },
|
||||
});
|
||||
|
||||
expect(getMediaViewerMediaURLs(card.card)).toEqual([
|
||||
expect.stringContaining('still-red.png?event=newer'),
|
||||
]);
|
||||
expect(deepQuery(card.card, 'advanced-camera-card-image-player')).not.toBeNull();
|
||||
expect(deepQuery(card.card, 'advanced-camera-card-video-player')).toBeNull();
|
||||
});
|
||||
|
||||
it('should show the next media when the next control is used', async () => {
|
||||
// Opened on the older event, so there is a newer one to move on to.
|
||||
const card = await mountViewer(EVENTS, { thumbnail: 1 });
|
||||
|
||||
await clickNextPreviousMedia(card.card, 'right');
|
||||
|
||||
await waitForMediaViewerMedia(card, 'clip.webm?event=newer');
|
||||
expect(getSelectedThumbnail(card.card)).toBe(getThumbnails(card.card)[1]);
|
||||
});
|
||||
|
||||
it('should show the media selected in the thumbnail carousel', async () => {
|
||||
const card = await mountViewer(EVENTS, { config: CONFIG_THUMBNAILS_BELOW });
|
||||
|
||||
// The viewer runs oldest first (gallery is the opposite). This is the oldest.
|
||||
await clickThumbnail(card.card, 0);
|
||||
|
||||
await waitForMediaViewerMedia(card, 'clip.webm?event=older');
|
||||
expect(getSelectedThumbnail(card.card)).toBe(getThumbnails(card.card)[0]);
|
||||
});
|
||||
|
||||
it('should name the media being viewed in the status bar', async () => {
|
||||
const card = await mountViewer(
|
||||
[
|
||||
createTestFrigateEvent('older', EVENT_TIME_OLDER, { label: 'car' }),
|
||||
createTestFrigateEvent('newer', EVENT_TIME_NEWER, { label: 'person' }),
|
||||
],
|
||||
{ config: { status_bar: { style: 'outside' }, ...CONFIG_THUMBNAILS_BELOW } },
|
||||
);
|
||||
|
||||
expect(getStatusBarStrings(card.card).join(' ')).toContain('Person');
|
||||
|
||||
await clickThumbnail(card.card, 0);
|
||||
await waitForMediaViewerMedia(card, 'clip.webm?event=older');
|
||||
|
||||
expect(getStatusBarStrings(card.card).join(' ')).toContain('Car');
|
||||
});
|
||||
|
||||
it('should play the clip when a snapshot is clicked', async () => {
|
||||
const card = await mountViewer(EVENTS, {
|
||||
config: { view: { default: 'snapshots' } },
|
||||
});
|
||||
|
||||
const snapshot = deepQuery(card.card, 'advanced-camera-card-image-player');
|
||||
assert(snapshot);
|
||||
|
||||
await clickElement(snapshot);
|
||||
|
||||
await waitForMediaViewerMedia(card, 'clip.webm?event=newer');
|
||||
});
|
||||
|
||||
it('should pause the media that is moved away from', async () => {
|
||||
// Opened on the older event, so there is a newer one to move on to.
|
||||
const card = await mountViewer(EVENTS, { thumbnail: 1 });
|
||||
|
||||
// `auto_play` covers the selected media, so the clip starts on its own.
|
||||
await card.events.waitForFirst('advanced-camera-card:media:play');
|
||||
|
||||
await clickNextPreviousMedia(card.card, 'right');
|
||||
|
||||
// Leaving a clip playing behind the one on screen would have two videos
|
||||
// running at once, and the user hearing the one they cannot see.
|
||||
await card.events.waitForFirst('advanced-camera-card:media:pause');
|
||||
});
|
||||
|
||||
it('should report media that cannot be loaded', async () => {
|
||||
vi.useFakeTimers();
|
||||
onTestFinished(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const { card, frigate } = await mountCardWithFrigate(EVENTS, {
|
||||
view: { default: 'clips' },
|
||||
status_bar: { style: 'outside' },
|
||||
});
|
||||
frigate.setMediaURL('newer', 'clips', createFailingMediaURL());
|
||||
|
||||
await waitForThumbnails(card, EVENTS.length);
|
||||
await clickThumbnail(card.card, 0);
|
||||
|
||||
// The card starts its load timer when the player is rendered, so waiting for
|
||||
// the player means the timer is running and the clock can be jumped.
|
||||
await card.waitForSelector('video');
|
||||
|
||||
// A clip that refuses to load says nothing of its own, so the card has only
|
||||
// silence to go on and triggers an issue once it has waited long enough.
|
||||
await card.advanceSeconds(MEDIA_LOADING_TIMEOUT_SECONDS);
|
||||
|
||||
await card.waitForRender(
|
||||
() => getStatusBarItem(card.card, MEDIA_ISSUE_TITLE),
|
||||
`the ${MEDIA_ISSUE_TITLE} issue being reported`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return to the gallery from the viewer', async () => {
|
||||
const card = await mountViewer(EVENTS, {
|
||||
config: {
|
||||
// The clips button is hidden by default.
|
||||
menu: { style: 'outside', buttons: { clips: { enabled: true } } },
|
||||
},
|
||||
});
|
||||
|
||||
await card.clickControl('Clips gallery');
|
||||
|
||||
await card.waitForSelector('advanced-camera-card-gallery');
|
||||
expect(deepQuery(card.card, 'advanced-camera-card-viewer-carousel')).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user