Files
advanced-camera-card/tests/utils/basic.test.ts
T
Dermot Duffy 11cc543406 fix: Hold the 2-way audio backchannel open for the shortest possible time (#2697)
The card claimed a camera's ONVIF audio backchannel in two places that
had
nothing to do with a call: the capability probe at camera init
(`&microphone`),
and a pre-armed `sendonly` audio transceiver on every live WebRTC offer.
Merely
looking at a dashboard occupied the camera's speaker line. Outbound
audio now
travels on its own audio-only WebRTC connection, opened when a call is
answered
and closed when it ends.

- The backchannel is claimed only for the duration of a call. Idle
viewing
  claims nothing.
- Two-way audio now works in `mse`, `mp4` and `mjpeg` modes (note: the
outbound
  audio still traverses WebRTC).
- No renegotiation and no video blink at call start or end.
- A call that cannot carry audio now reports it and ends, instead of
showing a
  live microphone that goes nowhere.
- `live.microphone.always_connected` is now purely about the browser
microphone
  permission prompt.
- Call setup measured at 66ms (LAN) and ~260ms (cellular) for ICE and
DTLS, plus
  ~300ms for `go2rtc` to open an RTSP backchannel.

Verified against a live Frigate + `go2rtc` instance, and by unit tests
at 100%
coverage.

 - Closes #2691
 - Closes #2039
 - Closes #2178

Ref #2299 -- the probe no longer opens a backchannel, but it still runs
per
camera on every load and reconnect, and still dials the camera on the
direct-`go2rtc` path. Caching remains to be done.

Ref AlexxIT/go2rtc#1860 -- once a call has opened a backchannel,
`go2rtc` keeps
that media set up on the camera's RTSP session for the life of the
producer.

Diagnoses #2678
2026-08-21 20:13:58 -07:00

602 lines
17 KiB
TypeScript

import { afterAll, describe, expect, it, vi } from 'vitest';
import { AdvancedCameraCardError } from '../../src/types.js';
import {
allPromises,
arefloatsApproximatelyEqual,
arrayify,
arrayifyWithFalsy,
arrayMove,
aspectRatioToStyle,
contentsChanged,
dayToDate,
desparsifyArrays,
errorToConsole,
forceReflow,
formatDate,
formatDateAndTime,
generateFloatApproximatelyEqualsCustomizer,
getChildrenFromElement,
getDurationString,
getErrorDescription,
ignoreFunctionIdentity,
isHoverableDevice,
isHTMLElement,
isSuperset,
isTruthy,
isValidDate,
prettifyTitle,
recursivelyMergeObjectsConcatenatingArraysUniquely,
recursivelyMergeObjectsNotArrays,
runWhenIdleIfSupported,
setify,
setOrRemoveAttribute,
setOrRemoveStyleProperty,
} from '../../src/utils/basic.js';
import { createSlot, createSlotHost, stubMatchMedia } from '../test-utils.js';
describe('prettifyTitle', () => {
it('should return undefined when passed undefined', () => {
expect(prettifyTitle(undefined)).toBe(undefined);
});
it('should prettify words', () => {
expect(prettifyTitle('this is_a string')).toBe('This Is A String');
});
it('should handle leading spaces', () => {
expect(prettifyTitle(' this is_a string')).toBe('This Is A String');
});
it('should handle empty string', () => {
expect(prettifyTitle('')).toBeUndefined();
});
});
describe('arrayMove', () => {
it('should move array item', () => {
const data = [1, 2, 3];
expect(arrayMove(data, 1, 0)).toEqual([2, 1, 3]);
});
});
describe('arrayify', () => {
it('should convert non array to array', () => {
expect(arrayify(1)).toEqual([1]);
});
it('should return array', () => {
const data = [1, 2, 3];
expect(arrayify(data)).toBe(data);
});
it('should handle undefined', () => {
expect(arrayify()).toEqual([]);
});
});
describe('arrayifyWithFalsy', () => {
it('should wrap a non-array in an array', () => {
expect(arrayifyWithFalsy(1)).toEqual([1]);
});
it('should return an existing array unchanged', () => {
const data = [1, 2, 3];
expect(arrayifyWithFalsy(data)).toBe(data);
});
it('should preserve falsy values instead of dropping them', () => {
expect(arrayifyWithFalsy(0)).toEqual([0]);
expect(arrayifyWithFalsy(false)).toEqual([false]);
expect(arrayifyWithFalsy('')).toEqual(['']);
expect(arrayifyWithFalsy(null)).toEqual([null]);
});
});
describe('setify', () => {
it('should convert non set to set', () => {
expect(setify(1)).toEqual(new Set([1]));
});
it('should return set', () => {
const data = new Set([1, 2, 3]);
expect(setify(data)).toBe(data);
});
});
describe('contentsChanged', () => {
it('should have changed contents', () => {
expect(contentsChanged([1, 2], [2, 1])).toBeTruthy();
});
it('should not have changed contents', () => {
expect(contentsChanged([1, 2], [1, 2])).toBeFalsy();
});
});
describe('getErrorDescription', () => {
it('should prefer the message', () => {
expect(getErrorDescription(new Error('the peer connection is closed'))).toBe(
'the peer connection is closed',
);
});
it('should fall back to the name when there is no message', () => {
expect(getErrorDescription({ name: 'InvalidStateError' })).toBe('InvalidStateError');
});
it('should return null when there is neither', () => {
expect(getErrorDescription({})).toBeNull();
});
it('should return null for a value that is not error-like', () => {
expect(getErrorDescription('a bare string')).toBeNull();
});
});
describe('errorToConsole', () => {
const spy = vi.spyOn(global.console, 'warn').mockImplementation(() => true);
afterAll(() => {
vi.restoreAllMocks();
});
it('should log given error', () => {
const error = new Error('ERROR');
errorToConsole(error);
expect(spy).toHaveBeenCalledWith('ERROR');
});
it('should log with context given advanced camera card error', () => {
const data = { foo: 2 };
const error = new AdvancedCameraCardError('ERROR', { foo: 2 });
errorToConsole(error);
expect(spy).toHaveBeenCalledWith(error, data);
});
it('should log with custom function', () => {
const func = vi.fn();
const error = new Error('ERROR');
errorToConsole(error, func);
expect(func).toHaveBeenCalledWith('ERROR');
});
it('should log string', () => {
errorToConsole('string message');
expect(spy).toHaveBeenCalledWith('string message');
});
it('should do nothing given a falsy value', () => {
spy.mockClear();
errorToConsole(null);
expect(spy).not.toHaveBeenCalled();
});
});
// @vitest-environment jsdom
describe('isHoverableDevice', () => {
afterAll(() => {
vi.restoreAllMocks();
});
it('should return hoverable', () => {
stubMatchMedia().mockReturnValue(<MediaQueryList>{ matches: true });
expect(isHoverableDevice()).toBeTruthy();
});
it('should return not hoverable', () => {
stubMatchMedia().mockReturnValue(<MediaQueryList>{ matches: false });
expect(isHoverableDevice()).toBeFalsy();
});
});
describe('formatDateAndTime', () => {
it('should format date and time', () => {
const date = new Date(2023, 3, 14, 13, 35, 0);
expect(formatDateAndTime(date)).toBe('2023-04-14 13:35');
});
it('should format date and time with seconds', () => {
const date = new Date(2023, 3, 14, 13, 35, 1);
expect(formatDateAndTime(date, true)).toBe('2023-04-14 13:35:01');
});
});
describe('formatDate', () => {
it('should format date', () => {
const date = new Date(2023, 3, 14, 13, 35, 0);
expect(formatDate(date)).toBe('2023-04-14');
});
});
// @vitest-environment jsdom
describe('runWhenIdleIfSupported', () => {
const originalRequestIdleCallback = window.requestIdleCallback;
afterAll(() => {
window.requestIdleCallback = originalRequestIdleCallback;
});
it('should run directly when not supported', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).requestIdleCallback = undefined;
const func = vi.fn();
runWhenIdleIfSupported(func);
expect(func).toHaveBeenCalled();
});
it('should run idle when supported', () => {
const requestIdle = vi.fn();
window.requestIdleCallback = requestIdle;
const func = vi.fn();
runWhenIdleIfSupported(func);
expect(requestIdle).toHaveBeenCalledWith(func, {});
});
it('should run idle with timeout when supported', () => {
const requestIdle = vi.fn();
window.requestIdleCallback = requestIdle;
const func = vi.fn();
runWhenIdleIfSupported(func, 10);
expect(requestIdle).toHaveBeenCalledWith(func, { timeout: 10 });
});
});
describe('getDurationString', () => {
it('should return duration', () => {
const start = new Date(2023, 3, 14, 13, 35, 0);
const end = new Date(2023, 3, 14, 15, 37, 20);
expect(getDurationString(start, end)).toBe('2h 2m 20s');
});
it('should return very short duration', () => {
const start = new Date(2023, 3, 14, 13, 35, 10);
const end = new Date(2023, 3, 14, 13, 35, 12);
expect(getDurationString(start, end)).toBe('2s');
});
it('should return 0s for no delta', () => {
const start = new Date(2023, 3, 14, 13, 35, 10);
expect(getDurationString(start, start)).toBe('0s');
});
});
describe('allPromises', () => {
it('should await all promises', async () => {
const results = await allPromises([1, 2, 3], async (n) => n * 2);
expect(results).toEqual([2, 4, 6]);
});
});
describe('dayToDate', () => {
it('should return correct date', () => {
expect(dayToDate('2023-04-14')).toEqual(new Date(2023, 3, 14));
});
});
describe('isSuperset', () => {
it('should return is a superset', () => {
expect(isSuperset(new Set([1, 2, 3, 4]), new Set([2, 3]))).toBeTruthy();
});
it('should return is not a superset', () => {
expect(isSuperset(new Set([1, 2, 3, 4]), new Set([2, 3, 5]))).toBeFalsy();
});
});
describe('isValidDate', () => {
it('should be valid date', () => {
expect(isValidDate(new Date(2023, 3, 28))).toBeTruthy();
});
it('should be invalid date', () => {
expect(isValidDate(new Date('moo'))).toBeFalsy();
});
});
describe('setOrRemoveAttribute', () => {
it('should set attribute without value', () => {
const element = document.createElement('div');
setOrRemoveAttribute(element, true, 'key');
expect(element.getAttribute('key')).toBe('');
});
it('should set attribute with value', () => {
const element = document.createElement('div');
setOrRemoveAttribute(element, true, 'key', 'value');
expect(element.getAttribute('key')).toBe('value');
});
it('should remove attribute date', () => {
const element = document.createElement('div');
element.setAttribute('key', 'value');
setOrRemoveAttribute(element, false, 'key');
expect(element.getAttribute('key')).toBeFalsy();
});
});
describe('setOrRemoveStyleProperty', () => {
it('should set style property without value', () => {
const element = document.createElement('div');
setOrRemoveStyleProperty(element, true, 'width');
expect(element.style.getPropertyValue('width')).toBe('');
});
it('should set style property with value', () => {
const element = document.createElement('div');
setOrRemoveStyleProperty(element, true, 'width', '100px');
expect(element.style.getPropertyValue('width')).toBe('100px');
});
it('should remove style property', () => {
const element = document.createElement('div');
element.style.setProperty('width', '100px');
setOrRemoveStyleProperty(element, false, 'width');
expect(element.style.getPropertyValue('width')).toBeFalsy();
});
});
describe('isTruthy', () => {
it('should return true for true', () => {
expect(isTruthy(true)).toBeTruthy();
});
it('should return false for false', () => {
expect(isTruthy(false)).toBeFalsy();
});
});
describe('isHTMLElement', () => {
it('should return true for HTMLElement', () => {
const htmlElement = document.createElement('div');
expect(isHTMLElement(htmlElement)).toBeTruthy();
});
it('should return false for Element', () => {
const svgElement = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
expect(isHTMLElement(svgElement)).toBeFalsy();
});
});
describe('getChildrenFromElement', () => {
it('should return children for simple parent', () => {
const children = [document.createElement('div'), document.createElement('div')];
const parent = document.createElement('div');
children.forEach((child) => parent.appendChild(child));
expect(getChildrenFromElement(parent)).toEqual(children);
});
it('should return children for slot', () => {
const children = [document.createElement('div'), document.createElement('div')];
const slot = createSlot();
createSlotHost({ slot: slot, children: children });
expect(getChildrenFromElement(slot)).toEqual(children);
});
});
describe('recursivelyMergeObjectsNotArrays', () => {
it('should recursively merge objects but replace arrays', () => {
expect(
recursivelyMergeObjectsNotArrays<Record<string, unknown>>(
{},
{
a: {
b: {
c: 3,
d: {
e: 4,
},
array: [1, 2, 3],
},
other: {
field: 7,
},
},
},
{
a: {
b: {
array: [4],
d: {
e: 5,
},
},
},
},
),
).toEqual({
a: {
b: {
c: 3,
array: [4],
d: {
e: 5,
},
},
other: {
field: 7,
},
},
});
});
});
describe('recursivelyMergeObjectsConcatenatingArraysUniquely', () => {
it('should recursively merge objects but uniquely concat arrays', () => {
expect(
recursivelyMergeObjectsConcatenatingArraysUniquely<Record<string, unknown>>(
{},
{
a: {
b: {
c: 3,
d: {
e: 4,
},
array: [5, 1, 2, 3, 4],
},
other: {
field: 7,
},
},
},
{
a: {
b: {
array: [4, 4, 5],
d: {
e: 5,
},
},
},
},
),
).toEqual({
a: {
b: {
c: 3,
array: [5, 1, 2, 3, 4],
d: {
e: 5,
},
},
other: {
field: 7,
},
},
});
});
});
describe('aspectRatioToStyle', () => {
it('default', () => {
expect(aspectRatioToStyle()).toEqual({ 'aspect-ratio': 'auto' });
});
it('default static', () => {
expect(aspectRatioToStyle({ defaultStatic: true })).toEqual({
'aspect-ratio': '16 / 9',
});
});
it('valid ratio', () => {
expect(aspectRatioToStyle({ ratio: [4, 3] })).toEqual({ 'aspect-ratio': '4 / 3' });
});
it('invalid ratio', () => {
expect(aspectRatioToStyle({ ratio: [4] })).toEqual({ 'aspect-ratio': 'auto' });
});
it.each([[[0, 0]], [[-4, 3]], [[Infinity, 3]]])(
'should ignore a ratio with a side that describes no shape: %s',
(ratio: number[]) => {
expect(aspectRatioToStyle({ ratio })).toEqual({ 'aspect-ratio': 'auto' });
},
);
});
describe('desparsifyArrays', () => {
it('number', () => {
expect(desparsifyArrays(1)).toBe(1);
});
it('string', () => {
expect(desparsifyArrays('foo')).toBe('foo');
});
describe('array', () => {
it('simple', () => {
expect(desparsifyArrays([1, 2, undefined, 3])).toEqual([1, 2, 3]);
});
it('nested', () => {
expect(
desparsifyArrays([
1,
2,
undefined,
{
subArray: [undefined, 3],
},
4,
]),
).toEqual([1, 2, { subArray: [3] }, 4]);
});
});
describe('object', () => {
it('simple', () => {
expect(
desparsifyArrays({ foo: [1, 2, undefined, 3], bar: [undefined, 4] }),
).toEqual({
foo: [1, 2, 3],
bar: [4],
});
});
it('nested', () => {
expect(
desparsifyArrays({ foo: { bar: [1, undefined, 2], empty: [undefined] } }),
).toEqual({
foo: {
bar: [1, 2],
empty: [],
},
});
});
});
});
describe('arefloatsApproximatelyEqual', () => {
describe('without precision', () => {
it('equals', () => {
expect(arefloatsApproximatelyEqual(1.1, 1.2)).toBeTruthy();
});
it('not equals', () => {
expect(arefloatsApproximatelyEqual(0.5, 1.5)).toBeFalsy();
});
});
describe('with precision', () => {
it('equals', () => {
expect(arefloatsApproximatelyEqual(1.00001, 1.00002, 4)).toBeTruthy();
});
it('not equals', () => {
expect(arefloatsApproximatelyEqual(0.5, 1.5, 4)).toBeFalsy();
});
});
});
describe('generateFloatApproximatelyEqualsCustomizer', () => {
describe('with incorrect types', () => {
it('undefined a', () => {
expect(
generateFloatApproximatelyEqualsCustomizer(4)(undefined, 1.2),
).toBeUndefined();
});
it('undefined b', () => {
expect(
generateFloatApproximatelyEqualsCustomizer(4)(1.2, undefined),
).toBeUndefined();
});
it('strings', () => {
expect(
generateFloatApproximatelyEqualsCustomizer(4)('foo', 'bar'),
).toBeUndefined();
});
});
describe('with correct types', () => {
it('equals', () => {
expect(
generateFloatApproximatelyEqualsCustomizer(4)(1.00001, 1.00002),
).toBeTruthy();
});
it('not equals', () => {
expect(
generateFloatApproximatelyEqualsCustomizer(5)(1.00001, 1.00002),
).toBeFalsy();
});
});
});
describe('ignoreFunctionIdentity', () => {
it('should treat two functions as equal regardless of identity', () => {
expect(
ignoreFunctionIdentity(
() => 1,
() => 2,
),
).toBe(true);
});
it('should treat a function appearing as a real change', () => {
expect(ignoreFunctionIdentity(() => 1, undefined)).toBe(false);
});
it('should treat a function disappearing as a real change', () => {
expect(ignoreFunctionIdentity(undefined, () => 1)).toBe(false);
});
it('should defer to default equality for non-functions', () => {
expect(ignoreFunctionIdentity(1, 2)).toBeUndefined();
});
});
describe('forceReflow', () => {
it('should access offsetHeight to trigger reflow', () => {
const element = document.createElement('div');
const offsetHeightSpy = vi
.spyOn(element, 'offsetHeight', 'get')
.mockReturnValue(100);
forceReflow(element);
expect(offsetHeightSpy).toHaveBeenCalled();
});
});