Files
advanced-camera-card/tests/components-lib/live/providers/go2rtc-experimental/image-surface-controller.test.ts
T
Dermot Duffy c02c692f68 feat: Add experimental rewrite of go2rtc live provider (MSE/WebRTC/MP4/MJPEG) (#2580)
- Closes #2556
- Closes #2450

**Key intended features:**
 - go2rtc compatible
- 100% test coverage to significantly improve ability to test, maintain
and work around browser weirdnesses (e.g. Safari).
 - Written from the ground up in the style of the rest of the project.

**To use:**
 - Change `live_provider` from `go2rtc` to `go2rtc-experimental`.
2026-07-14 14:22:41 -07:00

233 lines
8.1 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ImageSurfaceController } from '../../../../../src/components-lib/live/providers/go2rtc-experimental/image-surface-controller';
import { createLitElement, flushPromises } from '../../../../test-utils';
const createFrame = (): Blob => new Blob(['frame'], { type: 'image/jpeg' });
// @vitest-environment jsdom
describe('ImageSurfaceController', () => {
let nextId: number;
beforeEach(() => {
nextId = 0;
URL.createObjectURL = vi.fn(() => `blob:mock-${nextId++}`);
URL.revokeObjectURL = vi.fn();
document.body.replaceChildren();
});
const createdURLs = (): string[] =>
vi.mocked(URL.createObjectURL).mock.results.map((result) => result.value);
// A connected visible <img> plus a detached loader whose decode() is stubbed
// (jsdom has none). The loader is injected so a test can hold a decode in
// flight to exercise coalescing.
const createSurfaceController = (options?: {
connected?: boolean;
decode?: () => Promise<void>;
}) => {
const image = document.createElement('img');
if (options?.connected !== false) {
document.body.appendChild(image);
}
const decoder = document.createElement('img');
decoder.decode = vi.fn(options?.decode ?? (() => Promise.resolve()));
const controller = new ImageSurfaceController(createLitElement(), () => image, {
createImage: () => decoder,
});
return { image, decoder, controller };
};
describe('controller', () => {
it('should expose an image media player controller', () => {
const controller = new ImageSurfaceController(createLitElement(), () => null);
expect(controller.getMediaPlayer().getPIPElement()).toBeNull();
});
it('should have liveness when given liveness options', () => {
const controller = new ImageSurfaceController(createLitElement(), () => null, {
livenessOptions: { isFrameExpected: () => true, stallWindowSeconds: 10 },
});
expect(controller.getMediaPlayer().subscribeLiveness).toBeDefined();
});
it('should have no liveness without liveness options', () => {
const controller = new ImageSurfaceController(createLitElement(), () => null);
expect(controller.getMediaPlayer().subscribeLiveness).toBeUndefined();
});
it('should expose the image element', () => {
const { image, controller } = createSurfaceController();
expect(controller.getElement()).toBe(image);
});
});
describe('showFrame', () => {
it('should decode a frame off-DOM and show it via an object URL', async () => {
const { image, decoder, controller } = createSurfaceController();
await controller.showFrame(createFrame());
expect(decoder.decode).toBeCalledTimes(1);
expect(URL.createObjectURL).toBeCalledTimes(1);
expect(image.getAttribute('src')).toBe(createdURLs()[0]);
expect(URL.revokeObjectURL).not.toBeCalled();
});
it('should revoke the previous frame when showing the next', async () => {
const { image, controller } = createSurfaceController();
await controller.showFrame(createFrame());
await controller.showFrame(createFrame());
expect(image.getAttribute('src')).toBe(createdURLs()[1]);
expect(URL.revokeObjectURL).toBeCalledTimes(1);
expect(URL.revokeObjectURL).toBeCalledWith(createdURLs()[0]);
});
it('should keep showing the previous frame until the next has decoded', async () => {
let releaseSecond: () => void = () => {};
let calls = 0;
const { image, controller } = createSurfaceController({
decode: () => {
calls++;
return calls >= 2
? new Promise<void>((resolve) => (releaseSecond = resolve))
: Promise.resolve();
},
});
await controller.showFrame(createFrame());
expect(image.getAttribute('src')).toBe(createdURLs()[0]);
controller.showFrame(createFrame());
await flushPromises();
// The next frame is still decoding off-DOM, so the visible <img> is
// untouched and the previous URL stays valid (revoking it would blank it).
expect(image.getAttribute('src')).toBe(createdURLs()[0]);
expect(URL.revokeObjectURL).not.toBeCalled();
releaseSecond();
await flushPromises();
expect(image.getAttribute('src')).toBe(createdURLs()[1]);
expect(URL.revokeObjectURL).toBeCalledWith(createdURLs()[0]);
});
it('should present only the newest frame while a decode is in flight', async () => {
let releaseDecode: () => void = () => {};
const decoding = new Promise<void>((resolve) => (releaseDecode = resolve));
const { image, controller } = createSurfaceController({ decode: () => decoding });
controller.showFrame(createFrame());
controller.showFrame(createFrame());
controller.showFrame(createFrame());
await flushPromises();
// Only the first frame's decode has started; the rest wait behind it.
expect(URL.createObjectURL).toBeCalledTimes(1);
releaseDecode();
await flushPromises();
// The middle frame was superseded, so only the newest is presented next.
expect(URL.createObjectURL).toBeCalledTimes(2);
expect(image.getAttribute('src')).toBe(createdURLs()[1]);
});
it('should drop an undecodable frame and keep the current one', async () => {
const { image, controller } = createSurfaceController({
decode: () => Promise.reject(new Error('bad frame')),
});
await controller.showFrame(createFrame());
expect(image.hasAttribute('src')).toBe(false);
expect(URL.revokeObjectURL).toBeCalledWith(createdURLs()[0]);
});
it('should not paint a frame that decoded after the surface detached', async () => {
let releaseDecode: () => void = () => {};
const { image, controller } = createSurfaceController({
decode: () => new Promise<void>((resolve) => (releaseDecode = resolve)),
});
controller.showFrame(createFrame());
await flushPromises();
image.remove();
releaseDecode();
await flushPromises();
expect(image.hasAttribute('src')).toBe(false);
expect(URL.revokeObjectURL).toBeCalledWith(createdURLs()[0]);
});
it('should do nothing without an image element', async () => {
const controller = new ImageSurfaceController(createLitElement(), () => null);
await controller.showFrame(createFrame());
expect(URL.createObjectURL).not.toBeCalled();
});
it('should not paint onto a detached element', async () => {
const { controller } = createSurfaceController({ connected: false });
await controller.showFrame(createFrame());
expect(URL.createObjectURL).not.toBeCalled();
});
});
describe('reset', () => {
it('should revoke the current object URL and clear the image', async () => {
const { image, controller } = createSurfaceController();
await controller.showFrame(createFrame());
controller.reset();
expect(URL.revokeObjectURL).toBeCalledWith(createdURLs()[0]);
expect(image.hasAttribute('src')).toBe(false);
});
it('should clear the image with no frame shown', () => {
const { image, controller } = createSurfaceController();
controller.reset();
expect(URL.revokeObjectURL).not.toBeCalled();
expect(image.hasAttribute('src')).toBe(false);
});
it('should drop a frame still decoding so it never paints', async () => {
let releaseDecode: () => void = () => {};
const { image, controller } = createSurfaceController({
decode: () => new Promise<void>((resolve) => (releaseDecode = resolve)),
});
controller.showFrame(createFrame());
await flushPromises();
controller.reset();
releaseDecode();
await flushPromises();
// The in-flight frame was invalidated by reset, so it never painted.
expect(image.hasAttribute('src')).toBe(false);
});
it('should do nothing without an image element', () => {
const controller = new ImageSurfaceController(createLitElement(), () => null);
expect(() => controller.reset()).not.toThrow();
expect(URL.revokeObjectURL).not.toBeCalled();
});
});
});