Files
advanced-camera-card/tests/components-lib/media-actions-controller.test.ts
T
Dermot Duffy cc376a8be1 feat: Rename the card to advanced-camera-card (#1873)
Whilst the Frigate support in this card is the best among camera
engines, the name incorrectly suggests that Frigate is a requirement.
Instead, to broaden the appeal, change to more camera agnostic name.
This does not suggest any change in priority, role or support for
Frigate.

This change is likely to be bug prone, due to the size of the rename --
the code contains 1500+ references to "Frigate" most of which make sense
to rename, some which do not, all of which needed human assessment.

- Closes #1298


BREAKING CHANGE: References to `frigate-card` in all kinds of
configuration need to be updated to `advanced-camera-card`. An automated
config upgrade should take care of the majority of usecases (click `Edit
-> Upgrade -> Save`), though may not be perfect.
2025-02-06 19:32:32 -08:00

625 lines
20 KiB
TypeScript

import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { MicrophoneState } from '../../src/card-controller/types';
import {
MediaActionsController,
MediaActionsControllerOptions,
} from '../../src/components-lib/media-actions-controller';
import { AdvancedCameraCardMediaPlayer } from '../../src/types';
import {
IntersectionObserverMock,
MutationObserverMock,
callIntersectionHandler,
callMutationHandler,
createParent,
flushPromises,
} from '../test-utils';
import { callVisibilityHandler, createTestSlideNodes } from '../utils/embla/test-utils';
const getPlayer = (
element: HTMLElement,
selector: string,
): (HTMLElement & AdvancedCameraCardMediaPlayer) | null => {
return element.querySelector(selector);
};
const createPlayer = (): HTMLElement & AdvancedCameraCardMediaPlayer => {
const player = document.createElement('video');
player['play'] = vi.fn();
player['pause'] = vi.fn();
player['mute'] = vi.fn();
player['unmute'] = vi.fn();
player['isMuted'] = vi.fn().mockReturnValue(true);
player['seek'] = vi.fn();
player['getScreenshotURL'] = vi.fn();
player['setControls'] = vi.fn();
player['isPaused'] = vi.fn();
return player as unknown as HTMLElement & AdvancedCameraCardMediaPlayer;
};
const createPlayerSlideNodes = (n = 10): HTMLElement[] => {
const divs = createTestSlideNodes({ n: n });
for (const div of divs) {
div.appendChild(createPlayer());
}
return divs;
};
// @vitest-environment jsdom
describe('MediaActionsController', () => {
beforeAll(() => {
vi.stubGlobal('IntersectionObserver', IntersectionObserverMock);
vi.stubGlobal('MutationObserver', MutationObserverMock);
});
afterAll(() => {
vi.restoreAllMocks();
});
beforeEach(() => {
vi.clearAllMocks();
});
describe('should initialize', () => {
it('should have root', async () => {
const controller = new MediaActionsController();
controller.initialize(createParent());
expect(controller.hasRoot()).toBeTruthy();
});
it('should do nothing without options', async () => {
const controller = new MediaActionsController();
const children = createPlayerSlideNodes();
const parent = createParent({ children: children });
controller.initialize(parent);
await controller.setTarget(0, true);
expect(getPlayer(children[0], 'video')?.play).not.toBeCalled();
});
it('should re-initialize after mutation', async () => {
const controller = new MediaActionsController();
controller.setOptions({
playerSelector: 'video',
autoPlayConditions: ['selected' as const],
});
const parent = createParent({ children: createPlayerSlideNodes(1) });
controller.initialize(parent);
const newPlayer = createPlayer();
const newChild = document.createElement('div');
newChild.appendChild(newPlayer);
parent.append(newChild);
await callMutationHandler();
await controller.setTarget(1, true);
expect(newPlayer.play).toBeCalled();
});
});
describe('should destroy', () => {
it('should do nothing after destroy', async () => {
const controller = new MediaActionsController();
controller.setOptions({
playerSelector: 'video',
autoPlayConditions: ['selected' as const],
});
const children = createPlayerSlideNodes();
const parent = createParent({ children: children });
controller.initialize(parent);
controller.destroy();
await controller.setTarget(0, true);
expect(getPlayer(children[0], 'video')?.play).not.toBeCalled();
});
});
describe('should respond to setting target', () => {
it.each([
['should play', { autoPlayConditions: ['selected' as const] }, 'play', true],
['should not play', { autoPlayConditions: [] }, 'play', false],
['should unmute', { autoUnmuteConditions: ['selected' as const] }, 'unmute', true],
['should not unmute', { autoUnmuteConditions: [] }, 'unmute', false],
])(
'%s',
async (
_: string,
options: Partial<MediaActionsControllerOptions>,
func: string,
called: boolean,
) => {
const controller = new MediaActionsController();
controller.setOptions({
playerSelector: 'video',
...options,
});
const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children }));
await controller.setTarget(0, true);
expect(getPlayer(children[0], 'video')?.[func]).toBeCalledTimes(called ? 1 : 0);
},
);
it('should not reselect previously selected target', async () => {
const controller = new MediaActionsController();
controller.setOptions({
autoPlayConditions: ['selected' as const],
playerSelector: 'video',
});
const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children }));
await controller.setTarget(0, true);
expect(getPlayer(children[0], 'video')?.play).toBeCalledTimes(1);
await controller.setTarget(0, true);
expect(getPlayer(children[0], 'video')?.play).toBeCalledTimes(1);
});
it('should unselect before selecting a new target', async () => {
const controller = new MediaActionsController();
controller.setOptions({
autoPauseConditions: ['unselected' as const],
autoMuteConditions: ['unselected' as const],
playerSelector: 'video',
});
const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children }));
await controller.setTarget(0, true);
await controller.setTarget(1, true);
expect(getPlayer(children[0], 'video')?.pause).toBeCalled();
expect(getPlayer(children[0], 'video')?.mute).toBeCalled();
});
it('should select after target was previously visible', async () => {
const controller = new MediaActionsController();
controller.setOptions({
autoPlayConditions: ['selected' as const],
autoUnmuteConditions: ['selected' as const],
playerSelector: 'video',
});
const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children }));
await controller.setTarget(0, false);
expect(getPlayer(children[0], 'video')?.play).not.toBeCalled();
expect(getPlayer(children[0], 'video')?.unmute).not.toBeCalled();
await controller.setTarget(0, true);
expect(getPlayer(children[0], 'video')?.play).toBeCalled();
expect(getPlayer(children[0], 'video')?.unmute).toBeCalled();
});
});
it('should take no action after target unset', async () => {
const controller = new MediaActionsController();
controller.setOptions({
autoPlayConditions: ['selected' as const, 'visible' as const],
autoUnmuteConditions: ['selected' as const, 'visible' as const],
playerSelector: 'video',
});
const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children }));
await controller.setTarget(0, true);
expect(getPlayer(children[0], 'video')?.play).toBeCalledTimes(1);
expect(getPlayer(children[0], 'video')?.unmute).toBeCalledTimes(1);
controller.unsetTarget();
getPlayer(children[0], 'video')?.dispatchEvent(
new Event('advanced-camera-card:media:loaded'),
);
await flushPromises();
// Play/Mute will not have been called again.
expect(getPlayer(children[0], 'video')?.play).toBeCalledTimes(1);
expect(getPlayer(children[0], 'video')?.unmute).toBeCalledTimes(1);
});
describe('should respond to media loaded', () => {
it('should play after media load', async () => {
const controller = new MediaActionsController();
controller.setOptions({
autoPlayConditions: ['selected' as const],
playerSelector: 'video',
});
const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children }));
await controller.setTarget(0, true);
expect(getPlayer(children[0], 'video')?.play).toBeCalledTimes(1);
getPlayer(children[0], 'video')?.dispatchEvent(
new Event('advanced-camera-card:media:loaded'),
);
await flushPromises();
expect(getPlayer(children[0], 'video')?.play).toBeCalledTimes(2);
});
it('should unmute after media load', async () => {
const controller = new MediaActionsController();
controller.setOptions({
autoUnmuteConditions: ['selected' as const],
playerSelector: 'video',
});
const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children }));
await controller.setTarget(0, true);
expect(getPlayer(children[0], 'video')?.unmute).toBeCalledTimes(1);
getPlayer(children[0], 'video')?.dispatchEvent(
new Event('advanced-camera-card:media:loaded'),
);
await flushPromises();
expect(getPlayer(children[0], 'video')?.unmute).toBeCalledTimes(2);
});
it('should take no action on unrelated media load', async () => {
const controller = new MediaActionsController();
controller.setOptions({
autoPlayConditions: ['selected' as const, 'visible' as const],
autoUnmuteConditions: ['selected' as const, 'visible' as const],
playerSelector: 'video',
});
const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children }));
await controller.setTarget(0, true);
getPlayer(children[9], 'video')?.dispatchEvent(
new Event('advanced-camera-card:media:loaded'),
);
await flushPromises();
expect(getPlayer(children[9], 'video')?.play).not.toBeCalled();
expect(getPlayer(children[9], 'video')?.unmute).not.toBeCalled();
});
it('should play and unmute on unselected but targeted media load', async () => {
const controller = new MediaActionsController();
controller.setOptions({
autoPlayConditions: ['visible' as const],
autoUnmuteConditions: ['visible' as const],
playerSelector: 'video',
});
const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children }));
await controller.setTarget(0, false);
expect(getPlayer(children[0], 'video')?.play).toBeCalledTimes(1);
expect(getPlayer(children[0], 'video')?.unmute).toBeCalledTimes(1);
getPlayer(children[0], 'video')?.dispatchEvent(
new Event('advanced-camera-card:media:loaded'),
);
await flushPromises();
expect(getPlayer(children[0], 'video')?.play).toBeCalledTimes(2);
expect(getPlayer(children[0], 'video')?.unmute).toBeCalledTimes(2);
});
});
describe('should take action on unselect', () => {
it.each([
['should pause', { autoPauseConditions: ['unselected' as const] }, 'pause', true],
['should not pause', { autoPauseConditions: [] }, 'pause', false],
['should mute', { autoMuteConditions: ['unselected' as const] }, 'mute', true],
['should not mute', { autoMuteConditions: [] }, 'mute', false],
])(
'%s',
async (
_: string,
options: Partial<MediaActionsControllerOptions>,
func: string,
called: boolean,
) => {
const controller = new MediaActionsController();
controller.setOptions({
playerSelector: 'video',
...options,
});
const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children }));
await controller.setTarget(0, true);
await controller.setTarget(0, false);
expect(getPlayer(children[0], 'video')?.[func]).toBeCalledTimes(called ? 1 : 0);
},
);
});
describe('should take action on page being visible', () => {
it.each([
['should play', { autoPlayConditions: ['visible' as const] }, 'play', true],
['should not play', { autoPlayConditions: [] }, 'play', false],
['should unmute', { autoUnmuteConditions: ['visible' as const] }, 'unmute', true],
['should not unmute', { autoUnmuteConditions: [] }, 'unmute', false],
])(
'%s',
async (
_: string,
options: Partial<MediaActionsControllerOptions>,
func: string,
called: boolean,
) => {
vi.spyOn(global.document, 'addEventListener');
const controller = new MediaActionsController();
controller.setOptions({
playerSelector: 'video',
...options,
});
const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children }));
await controller.setTarget(0, true);
// Not configured to take action on selection.
expect(getPlayer(children[0], 'video')?.[func]).not.toBeCalled();
Object.defineProperty(document, 'visibilityState', {
value: 'visible',
writable: true,
});
await callVisibilityHandler();
// Not configured to take action on selection.
expect(getPlayer(children[0], 'video')?.[func]).toBeCalledTimes(called ? 1 : 0);
},
);
});
describe('should take action on page being hiddne', () => {
beforeAll(() => {
vi.spyOn(global.document, 'addEventListener');
});
it.each([
['should pause', { autoPauseConditions: ['hidden' as const] }, 'pause', true],
['should not pause', { autoPauseConditions: [] }, 'pause', false],
['should mute', { autoMuteConditions: ['hidden' as const] }, 'mute', true],
['should not mute', { autoMuteConditions: [] }, 'mute', false],
])(
'%s',
async (
_: string,
options: Partial<MediaActionsControllerOptions>,
func: string,
called: boolean,
) => {
const controller = new MediaActionsController();
controller.setOptions({
playerSelector: 'video',
...options,
});
const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children }));
await controller.setTarget(0, true);
// Not configured to take action on selection.
expect(getPlayer(children[0], 'video')?.[func]).not.toBeCalled();
Object.defineProperty(document, 'visibilityState', {
value: 'hidden',
writable: true,
});
await callVisibilityHandler();
// Not configured to take action on selection.
expect(getPlayer(children[0], 'video')?.[func]).toBeCalledTimes(called ? 1 : 0);
},
);
});
describe('should take action on page intersecting with viewport', () => {
it.each([
['should play', { autoPlayConditions: ['visible' as const] }, 'play', true],
['should not play', { autoPlayConditions: [] }, 'play', false],
['should unmute', { autoUnmuteConditions: ['visible' as const] }, 'unmute', true],
['should not unmute', { autoUnmuteConditions: [] }, 'unmute', false],
])(
'%s',
async (
_: string,
options: Partial<MediaActionsControllerOptions>,
func: string,
called: boolean,
) => {
const controller = new MediaActionsController();
controller.setOptions({
playerSelector: 'video',
...options,
});
const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children }));
await controller.setTarget(0, true);
// Not configured to take action on selection.
expect(getPlayer(children[0], 'video')?.[func]).not.toBeCalled();
// There's always a first call to an intersection observer handler. In
// this case the MediaActionsController ignores it.
await callIntersectionHandler(false);
await callIntersectionHandler(true);
// Not configured to take action on selection.
expect(getPlayer(children[0], 'video')?.[func]).toBeCalledTimes(called ? 1 : 0);
},
);
});
describe('should take action on page not intersecting with viewport', () => {
it.each([
['should play', { autoPlayConditions: ['visible' as const] }, 'play', true],
['should not play', { autoPlayConditions: [] }, 'play', false],
['should unmute', { autoUnmuteConditions: ['visible' as const] }, 'unmute', true],
['should not unmute', { autoUnmuteConditions: [] }, 'unmute', false],
])(
'%s',
async (
_: string,
options: Partial<MediaActionsControllerOptions>,
func: string,
called: boolean,
) => {
const controller = new MediaActionsController();
controller.setOptions({
playerSelector: 'video',
...options,
});
const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children }));
await controller.setTarget(0, true);
// Not configured to take action on selection.
expect(getPlayer(children[0], 'video')?.[func]).not.toBeCalled();
// There's always a first call to an intersection observer handler. In
// this case the MediaActionsController ignores it.
await callIntersectionHandler(false);
await callIntersectionHandler(true);
// Not configured to take action on selection.
expect(getPlayer(children[0], 'video')?.[func]).toBeCalledTimes(called ? 1 : 0);
},
);
});
describe('should take action on microphone state changes', () => {
beforeAll(() => {
vi.useFakeTimers();
});
afterAll(() => {
vi.useRealTimers();
});
const createMicrophoneState = (
state?: Partial<MicrophoneState>,
): MicrophoneState => {
return {
muted: true,
forbidden: false,
connected: false,
...state,
};
};
it('should unmute when microphone unmuted', async () => {
const controller = new MediaActionsController();
controller.setOptions({
autoUnmuteConditions: ['microphone' as const],
playerSelector: 'video',
microphoneState: createMicrophoneState({ muted: true }),
});
const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children }));
await controller.setTarget(0, true);
controller.setOptions({
autoUnmuteConditions: ['microphone' as const],
playerSelector: 'video',
microphoneState: createMicrophoneState({ muted: false }),
});
expect(getPlayer(children[0], 'video')?.unmute).toBeCalled();
});
it('should mute after delay after microphone muted', async () => {
const controller = new MediaActionsController();
controller.setOptions({
autoMuteConditions: ['microphone' as const],
playerSelector: 'video',
microphoneState: createMicrophoneState({ muted: false }),
});
const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children }));
await controller.setTarget(0, true);
controller.setOptions({
autoMuteConditions: ['microphone' as const],
playerSelector: 'video',
microphoneState: createMicrophoneState({ muted: true }),
});
vi.runOnlyPendingTimers();
expect(getPlayer(children[0], 'video')?.mute).toBeCalled();
});
it('should not mute after delay after microphone muted', async () => {
const controller = new MediaActionsController();
controller.setOptions({
autoMuteConditions: [],
playerSelector: 'video',
microphoneState: createMicrophoneState({ muted: false }),
});
const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children }));
await controller.setTarget(0, true);
controller.setOptions({
autoMuteConditions: ['microphone' as const],
playerSelector: 'video',
microphoneState: createMicrophoneState({ muted: true }),
});
vi.runOnlyPendingTimers();
expect(getPlayer(children[0], 'video')?.mute).not.toBeCalled();
});
});
});