feat: Add 'call' support to improve 2-way audio experience (#2486)
Draws significant inspiration (and direct styling) from https://github.com/dermotduffy/advanced-camera-card/pull/2447 . Thank you @Maudfer ! BREAKING CHANGE: The microphone condition previously bundled two unrelated signals — whether a two-way-audio session was connected and whether the microphone was muted. Connection state is now its own dedicated call condition, and microphone is reserved purely for mute state. Configs are upgraded automatically (the card rewrites affected conditions under overrides, elements, and automations). If you maintain config by hand, convert as follows: If you only used connected: # Before ```yaml condition: microphone connected: true ``` # After ```yaml condition: call call: true ``` If you used both connected and muted — they must be split into two conditions, since they no longer live together: # Before ```yaml condition: microphone connected: true muted: false ``` # After ```yaml condition: and conditions: - condition: call call: true - condition: microphone muted: false ```
This commit is contained in:
committed by
dermotduffy
parent
bb061a1a55
commit
abcba884e5
@@ -0,0 +1,18 @@
|
||||
import { expect, it } from 'vitest';
|
||||
import { CallEndAction } from '../../../../src/card-controller/actions/actions/call-end';
|
||||
import { createCardAPI } from '../../../test-utils';
|
||||
|
||||
it('should handle call_end action', async () => {
|
||||
const api = createCardAPI();
|
||||
const action = new CallEndAction(
|
||||
{},
|
||||
{
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'call_end',
|
||||
},
|
||||
);
|
||||
|
||||
await action.execute(api);
|
||||
|
||||
expect(api.getCallManager().end).toBeCalled();
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { expect, it } from 'vitest';
|
||||
import { CallStartAction } from '../../../../src/card-controller/actions/actions/call-start';
|
||||
import { createCardAPI } from '../../../test-utils';
|
||||
|
||||
it('should handle call_start action without a camera or stream', async () => {
|
||||
const api = createCardAPI();
|
||||
const action = new CallStartAction(
|
||||
{},
|
||||
{
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'call_start',
|
||||
},
|
||||
);
|
||||
|
||||
await action.execute(api);
|
||||
|
||||
expect(api.getCallManager().start).toBeCalledWith(undefined, undefined);
|
||||
});
|
||||
|
||||
it('should handle call_start action with a camera and stream', async () => {
|
||||
const api = createCardAPI();
|
||||
const action = new CallStartAction(
|
||||
{},
|
||||
{
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'call_start',
|
||||
camera: 'camera.front',
|
||||
stream: 'camera.front_doorbell',
|
||||
},
|
||||
);
|
||||
|
||||
await action.execute(api);
|
||||
|
||||
expect(api.getCallManager().start).toBeCalledWith(
|
||||
'camera.front',
|
||||
'camera.front_doorbell',
|
||||
);
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, it } from 'vitest';
|
||||
import { SubstreamOffAction } from '../../../../src/card-controller/actions/actions/substream-off';
|
||||
import { SubstreamViewModifier } from '../../../../src/card-controller/view/modifiers/substream';
|
||||
import { createCardAPI } from '../../../test-utils';
|
||||
import { SubstreamOffViewModifier } from '../../../../src/card-controller/view/modifiers/substream-off';
|
||||
|
||||
it('should handle live_substream_off action', async () => {
|
||||
const api = createCardAPI();
|
||||
@@ -16,6 +16,6 @@ it('should handle live_substream_off action', async () => {
|
||||
await action.execute(api);
|
||||
|
||||
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
|
||||
modifiers: [expect.any(SubstreamOffViewModifier)],
|
||||
modifiers: [expect.any(SubstreamViewModifier)],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { CameraManagerStore } from '../../../../src/camera-manager/store';
|
||||
import { SubstreamOnAction } from '../../../../src/card-controller/actions/actions/substream-on';
|
||||
import { createCardAPI } from '../../../test-utils';
|
||||
import { SubstreamOnViewModifier } from '../../../../src/card-controller/view/modifiers/substream-on';
|
||||
import { applyViewModifiers } from '../../../../src/card-controller/view/modifiers';
|
||||
import { getStreamCameraID } from '../../../../src/view/substream';
|
||||
import { View } from '../../../../src/view/view';
|
||||
import {
|
||||
createCameraConfig,
|
||||
createCameraManager,
|
||||
createCapabilities,
|
||||
createCardAPI,
|
||||
createStore,
|
||||
createView,
|
||||
} from '../../../test-utils';
|
||||
|
||||
it('should handle live_substream_on action', async () => {
|
||||
const api = createCardAPI();
|
||||
const action = new SubstreamOnAction(
|
||||
const createAction = (): SubstreamOnAction =>
|
||||
new SubstreamOnAction(
|
||||
{},
|
||||
{
|
||||
action: 'fire-dom-event',
|
||||
@@ -13,9 +22,88 @@ it('should handle live_substream_on action', async () => {
|
||||
},
|
||||
);
|
||||
|
||||
await action.execute(api);
|
||||
// A store where `camera.office` has one substream dependency, `camera.kitchen`.
|
||||
const createStoreWithSubstreams = (): CameraManagerStore =>
|
||||
createStore([
|
||||
{
|
||||
cameraID: 'camera.office',
|
||||
capabilities: createCapabilities({ live: true, substream: true }),
|
||||
config: createCameraConfig({ dependencies: { all_cameras: true } }),
|
||||
},
|
||||
{
|
||||
cameraID: 'camera.kitchen',
|
||||
capabilities: createCapabilities({ substream: true }),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
|
||||
modifiers: [expect.any(SubstreamOnViewModifier)],
|
||||
// Runs the on-action for `view`, applies the modifier it produces (via the real
|
||||
// `applyViewModifiers`), and returns the resulting engaged stream.
|
||||
const getStreamAfterSubstreamOn = async (
|
||||
view: View,
|
||||
store: CameraManagerStore = createStoreWithSubstreams(),
|
||||
): Promise<string | null> => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
|
||||
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store));
|
||||
|
||||
await createAction().execute(api);
|
||||
|
||||
const params = vi.mocked(api.getViewManager().setViewByParameters).mock.calls[0]?.[0];
|
||||
applyViewModifiers(view, params?.modifiers);
|
||||
return getStreamCameraID(view);
|
||||
};
|
||||
|
||||
describe('SubstreamOnAction', () => {
|
||||
it('should advance to the next dependency', async () => {
|
||||
expect(
|
||||
await getStreamAfterSubstreamOn(
|
||||
createView({ view: 'live', camera: 'camera.office' }),
|
||||
),
|
||||
).toBe('camera.kitchen');
|
||||
});
|
||||
|
||||
it('should wrap back to the parent camera', async () => {
|
||||
const view = createView({
|
||||
view: 'live',
|
||||
camera: 'camera.office',
|
||||
context: {
|
||||
live: { overrides: new Map([['camera.office', 'camera.kitchen']]) },
|
||||
},
|
||||
});
|
||||
|
||||
expect(await getStreamAfterSubstreamOn(view)).toBe('camera.office');
|
||||
});
|
||||
|
||||
it('should treat a malformed override as the start of the cycle', async () => {
|
||||
const view = createView({
|
||||
view: 'live',
|
||||
camera: 'camera.office',
|
||||
context: {
|
||||
live: { overrides: new Map([['camera.office', 'NOT_A_REAL_CAMERA']]) },
|
||||
},
|
||||
});
|
||||
|
||||
expect(await getStreamAfterSubstreamOn(view)).toBe('camera.office');
|
||||
});
|
||||
|
||||
it('should engage no substream when there are no usable dependencies', async () => {
|
||||
const view = createView({ view: 'live', camera: 'camera.office' });
|
||||
|
||||
expect(await getStreamAfterSubstreamOn(view, createStore())).toBe('camera.office');
|
||||
});
|
||||
|
||||
it('should engage no substream when the view has no camera', async () => {
|
||||
expect(
|
||||
await getStreamAfterSubstreamOn(createView({ view: 'live', camera: null })),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('should do nothing without a view', async () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getViewManager().getView).mockReturnValue(null);
|
||||
|
||||
await createAction().execute(api);
|
||||
|
||||
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, it } from 'vitest';
|
||||
import { SubstreamSelectAction } from '../../../../src/card-controller/actions/actions/substream-select';
|
||||
import { SubstreamViewModifier } from '../../../../src/card-controller/view/modifiers/substream';
|
||||
import { createCardAPI } from '../../../test-utils';
|
||||
import { SubstreamSelectViewModifier } from '../../../../src/card-controller/view/modifiers/substream-select';
|
||||
|
||||
it('should handle live_substream_select action', async () => {
|
||||
const api = createCardAPI();
|
||||
@@ -16,9 +16,7 @@ it('should handle live_substream_select action', async () => {
|
||||
|
||||
await action.execute(api);
|
||||
|
||||
expect(api.getViewManager().setViewByParameters).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
modifiers: expect.arrayContaining([expect.any(SubstreamSelectViewModifier)]),
|
||||
}),
|
||||
);
|
||||
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
|
||||
modifiers: [expect.any(SubstreamViewModifier)],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { CallEndAction } from '../../../src/card-controller/actions/actions/call-end';
|
||||
import { CallServiceAction } from '../../../src/card-controller/actions/actions/call-service';
|
||||
import { CallStartAction } from '../../../src/card-controller/actions/actions/call-start';
|
||||
import { CameraSelectAction } from '../../../src/card-controller/actions/actions/camera-select';
|
||||
import { CameraUIAction } from '../../../src/card-controller/actions/actions/camera-ui';
|
||||
import { CustomAction } from '../../../src/card-controller/actions/actions/custom';
|
||||
@@ -87,6 +89,8 @@ describe('ActionFactory', () => {
|
||||
|
||||
describe('custom actions', () => {
|
||||
it.each([
|
||||
[{ advanced_camera_card_action: 'call_end' as const }, CallEndAction],
|
||||
[{ advanced_camera_card_action: 'call_start' as const }, CallStartAction],
|
||||
[{ advanced_camera_card_action: 'camera_select' as const }, CameraSelectAction],
|
||||
[{ advanced_camera_card_action: 'camera_ui' as const }, CameraUIAction],
|
||||
[{ advanced_camera_card_action: 'clip' as const }, ViewAction],
|
||||
|
||||
@@ -0,0 +1,735 @@
|
||||
import { assert, describe, expect, it, vi } from 'vitest';
|
||||
import { CameraManagerStore } from '../../../src/camera-manager/store';
|
||||
import { CallManager } from '../../../src/card-controller/call/manager';
|
||||
import { CardController } from '../../../src/card-controller/controller';
|
||||
import { SubstreamViewModifier } from '../../../src/card-controller/view/modifiers/substream';
|
||||
import { ConditionStateChange } from '../../../src/conditions/types';
|
||||
import { View } from '../../../src/view/view';
|
||||
import {
|
||||
createCameraConfig,
|
||||
createCameraManager,
|
||||
createCapabilities,
|
||||
createCardAPI,
|
||||
createStore,
|
||||
createView,
|
||||
} from '../../test-utils';
|
||||
|
||||
// A store with a single 2-way-audio-capable camera.
|
||||
const createCallableStore = (cameraID = 'camera.office'): CameraManagerStore =>
|
||||
createStore([
|
||||
{
|
||||
cameraID,
|
||||
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
|
||||
},
|
||||
]);
|
||||
|
||||
const createAPI = (options?: {
|
||||
view?: View | null;
|
||||
store?: CameraManagerStore;
|
||||
microphoneSupported?: boolean;
|
||||
microphoneForbidden?: boolean;
|
||||
microphoneConnected?: boolean;
|
||||
}): CardController => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getViewManager().getView).mockReturnValue(options?.view ?? null);
|
||||
vi.mocked(api.getCameraManager).mockReturnValue(
|
||||
createCameraManager(options?.store ?? createCallableStore()),
|
||||
);
|
||||
vi.mocked(api.getMicrophoneManager().isSupported).mockReturnValue(
|
||||
options?.microphoneSupported ?? true,
|
||||
);
|
||||
vi.mocked(api.getMicrophoneManager().isForbidden).mockReturnValue(
|
||||
options?.microphoneForbidden ?? false,
|
||||
);
|
||||
vi.mocked(api.getMicrophoneManager().isConnected).mockReturnValue(
|
||||
options?.microphoneConnected ?? true,
|
||||
);
|
||||
return api;
|
||||
};
|
||||
|
||||
// The condition-state listener a CallManager registers in its constructor.
|
||||
const getConditionStateListener = (
|
||||
api: CardController,
|
||||
): ((change: ConditionStateChange) => void) => {
|
||||
const listener = vi.mocked(api.getConditionStateManager().addListener).mock
|
||||
.calls[0]?.[0];
|
||||
assert(listener);
|
||||
return listener;
|
||||
};
|
||||
|
||||
describe('isActive', () => {
|
||||
it('should report inactive before a call starts', () => {
|
||||
expect(new CallManager(createCardAPI()).isActive()).toBe(false);
|
||||
});
|
||||
|
||||
it('should report active during a call', async () => {
|
||||
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
|
||||
const manager = new CallManager(api);
|
||||
|
||||
await manager.start();
|
||||
|
||||
expect(manager.isActive()).toBe(true);
|
||||
// The call runs on the parent camera's own stream, so callCameraID is
|
||||
// absent.
|
||||
expect(manager.getCall()).toEqual({
|
||||
cameraID: 'camera.office',
|
||||
previousView: expect.any(View),
|
||||
});
|
||||
expect(manager.getCall()?.previousView?.view).toBe('live');
|
||||
});
|
||||
});
|
||||
|
||||
describe('start', () => {
|
||||
it('should do nothing without a view camera', async () => {
|
||||
const api = createAPI({ view: createView({ camera: null }) });
|
||||
|
||||
await new CallManager(api).start();
|
||||
|
||||
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should do nothing when already active for the camera', async () => {
|
||||
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
|
||||
const manager = new CallManager(api);
|
||||
|
||||
await manager.start();
|
||||
vi.mocked(api.getViewManager().setViewByParameters).mockClear();
|
||||
await manager.start();
|
||||
|
||||
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should start a call on the selected camera', async () => {
|
||||
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
|
||||
|
||||
await new CallManager(api).start();
|
||||
|
||||
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
|
||||
modifiers: [expect.any(SubstreamViewModifier)],
|
||||
force: true,
|
||||
});
|
||||
expect(api.getConditionStateManager().setState).toBeCalledWith({ call: true });
|
||||
});
|
||||
|
||||
it('should navigate to the live view when started from elsewhere', async () => {
|
||||
const api = createAPI({
|
||||
view: createView({ camera: 'camera.office', view: 'clips' }),
|
||||
});
|
||||
|
||||
await new CallManager(api).start();
|
||||
|
||||
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
|
||||
params: { view: 'live', camera: 'camera.office' },
|
||||
modifiers: [expect.any(SubstreamViewModifier)],
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should evolve the current view without params when already in live', async () => {
|
||||
const api = createAPI({
|
||||
view: createView({ camera: 'camera.office', view: 'live' }),
|
||||
});
|
||||
|
||||
await new CallManager(api).start();
|
||||
|
||||
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
|
||||
modifiers: [expect.any(SubstreamViewModifier)],
|
||||
force: true,
|
||||
});
|
||||
expect(api.getViewManager().setViewByParameters).not.toBeCalledWith(
|
||||
expect.objectContaining({ params: expect.anything() }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should record the view present when the call started', async () => {
|
||||
const api = createAPI({
|
||||
view: createView({ camera: 'camera.office', view: 'clips' }),
|
||||
});
|
||||
const manager = new CallManager(api);
|
||||
|
||||
await manager.start();
|
||||
|
||||
const call = manager.getCall();
|
||||
expect(call?.previousView?.view).toBe('clips');
|
||||
expect(call?.previousView?.camera).toBe('camera.office');
|
||||
// Query results are dropped so they are re-fetched fresh on restore.
|
||||
expect(call?.previousView?.queryResults).toBeNull();
|
||||
});
|
||||
|
||||
it('should record the live view when the call starts from live', async () => {
|
||||
const api = createAPI({
|
||||
view: createView({ camera: 'camera.office', view: 'live' }),
|
||||
});
|
||||
const manager = new CallManager(api);
|
||||
|
||||
await manager.start();
|
||||
|
||||
const call = manager.getCall();
|
||||
expect(call?.previousView?.view).toBe('live');
|
||||
expect(call?.previousView?.camera).toBe('camera.office');
|
||||
});
|
||||
|
||||
it('should keep the original pre-call view when a call supersedes another', async () => {
|
||||
const api = createAPI({
|
||||
view: createView({ camera: 'camera.office', view: 'clips' }),
|
||||
store: createStore([
|
||||
{
|
||||
cameraID: 'camera.office',
|
||||
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
|
||||
},
|
||||
{
|
||||
cameraID: 'camera.garage',
|
||||
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
|
||||
},
|
||||
]),
|
||||
});
|
||||
const manager = new CallManager(api);
|
||||
|
||||
await manager.start();
|
||||
await manager.start('camera.garage');
|
||||
|
||||
const call = manager.getCall();
|
||||
expect(call?.cameraID).toBe('camera.garage');
|
||||
expect(call?.previousView?.view).toBe('clips');
|
||||
expect(call?.previousView?.camera).toBe('camera.office');
|
||||
});
|
||||
|
||||
it('should start a call from a non-camera view when a camera is explicit', async () => {
|
||||
const api = createAPI({
|
||||
view: createView({ camera: null, view: 'folder' }),
|
||||
store: createCallableStore('camera.office'),
|
||||
});
|
||||
const manager = new CallManager(api);
|
||||
|
||||
await manager.start('camera.office');
|
||||
|
||||
const call = manager.getCall();
|
||||
expect(call?.cameraID).toBe('camera.office');
|
||||
expect(call?.previousView?.view).toBe('folder');
|
||||
expect(call?.previousView?.camera).toBeNull();
|
||||
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
|
||||
params: { view: 'live', camera: 'camera.office' },
|
||||
modifiers: [expect.any(SubstreamViewModifier)],
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should start the call on an explicit camera and navigate there', async () => {
|
||||
const api = createAPI({
|
||||
view: createView({ camera: 'camera.office' }),
|
||||
store: createStore([
|
||||
{
|
||||
cameraID: 'camera.office',
|
||||
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
|
||||
},
|
||||
{
|
||||
cameraID: 'camera.garage',
|
||||
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
|
||||
},
|
||||
]),
|
||||
});
|
||||
const manager = new CallManager(api);
|
||||
|
||||
await manager.start('camera.garage');
|
||||
|
||||
const call = manager.getCall();
|
||||
expect(call?.cameraID).toBe('camera.garage');
|
||||
expect(call?.previousView?.view).toBe('live');
|
||||
expect(call?.previousView?.camera).toBe('camera.office');
|
||||
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
|
||||
params: { view: 'live', camera: 'camera.garage' },
|
||||
modifiers: [expect.any(SubstreamViewModifier)],
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should start a call on an explicit stream of the parent camera', async () => {
|
||||
const api = createAPI({
|
||||
view: createView({ camera: 'camera.office' }),
|
||||
store: createStore([
|
||||
{
|
||||
cameraID: 'camera.office',
|
||||
config: createCameraConfig({
|
||||
dependencies: { cameras: ['camera.doorbell'] },
|
||||
}),
|
||||
capabilities: createCapabilities({ live: true }),
|
||||
},
|
||||
{
|
||||
cameraID: 'camera.doorbell',
|
||||
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
|
||||
},
|
||||
]),
|
||||
});
|
||||
const manager = new CallManager(api);
|
||||
|
||||
await manager.start('camera.office', 'camera.doorbell');
|
||||
|
||||
const call = manager.getCall();
|
||||
expect(call?.cameraID).toBe('camera.office');
|
||||
expect(call?.callCameraID).toBe('camera.doorbell');
|
||||
expect(call?.previousView?.view).toBe('live');
|
||||
});
|
||||
|
||||
it('should abort when the requested camera is not a live camera', async () => {
|
||||
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
|
||||
|
||||
await new CallManager(api).start('camera.unknown');
|
||||
|
||||
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
|
||||
expect(api.getNotificationManager().setNotification).toBeCalled();
|
||||
});
|
||||
|
||||
it('should abort when the requested stream is not 2-way audio of the parent camera', async () => {
|
||||
const api = createAPI({
|
||||
view: createView({ camera: 'camera.office' }),
|
||||
store: createStore([
|
||||
{
|
||||
cameraID: 'camera.office',
|
||||
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
|
||||
},
|
||||
{
|
||||
cameraID: 'camera.unrelated',
|
||||
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
await new CallManager(api).start('camera.office', 'camera.unrelated');
|
||||
|
||||
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
|
||||
expect(api.getNotificationManager().setNotification).toBeCalled();
|
||||
});
|
||||
|
||||
it('should supersede an active call on a different camera', async () => {
|
||||
const api = createAPI({
|
||||
view: createView({ camera: 'camera.office' }),
|
||||
store: createStore([
|
||||
{
|
||||
cameraID: 'camera.office',
|
||||
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
|
||||
},
|
||||
{
|
||||
cameraID: 'camera.garage',
|
||||
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
|
||||
},
|
||||
]),
|
||||
});
|
||||
const manager = new CallManager(api);
|
||||
|
||||
await manager.start();
|
||||
await manager.start('camera.garage');
|
||||
|
||||
const call = manager.getCall();
|
||||
expect(call?.cameraID).toBe('camera.garage');
|
||||
expect(call?.previousView?.view).toBe('live');
|
||||
expect(call?.previousView?.camera).toBe('camera.office');
|
||||
expect(api.getConditionStateManager().setState).toBeCalledWith({ call: false });
|
||||
expect(api.getConditionStateManager().setState).toBeCalledWith({ call: true });
|
||||
});
|
||||
|
||||
it('should restart on the same camera with a different stream', async () => {
|
||||
const api = createAPI({
|
||||
store: createStore([
|
||||
{
|
||||
cameraID: 'camera.office',
|
||||
config: createCameraConfig({
|
||||
dependencies: { cameras: ['camera.doorbell', 'camera.intercom'] },
|
||||
}),
|
||||
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
|
||||
},
|
||||
{
|
||||
cameraID: 'camera.doorbell',
|
||||
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
|
||||
},
|
||||
{
|
||||
cameraID: 'camera.intercom',
|
||||
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
// The first call engages `camera.doorbell`; the view then reflects that
|
||||
// substream, as it would at runtime when the second call_start arrives.
|
||||
vi.mocked(api.getViewManager().getView)
|
||||
.mockReturnValueOnce(createView({ camera: 'camera.office' }))
|
||||
.mockReturnValue(
|
||||
createView({
|
||||
camera: 'camera.office',
|
||||
context: {
|
||||
live: { overrides: new Map([['camera.office', 'camera.doorbell']]) },
|
||||
},
|
||||
}),
|
||||
);
|
||||
const manager = new CallManager(api);
|
||||
|
||||
await manager.start('camera.office', 'camera.doorbell');
|
||||
await manager.start('camera.office', 'camera.intercom');
|
||||
|
||||
// The restarted call carries the new stream; the recorded pre-call view
|
||||
// keeps the genuine pre-call substream (none -- the camera's own stream), not the
|
||||
// superseded call's engaged `camera.doorbell`.
|
||||
const call = manager.getCall();
|
||||
expect(call?.cameraID).toBe('camera.office');
|
||||
expect(call?.callCameraID).toBe('camera.intercom');
|
||||
expect(
|
||||
call?.previousView?.context?.live?.overrides?.get('camera.office'),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should abort when no stream supports 2-way audio', async () => {
|
||||
const api = createAPI({
|
||||
view: createView({ camera: 'camera.office' }),
|
||||
store: createStore([
|
||||
{ cameraID: 'camera.office', capabilities: createCapabilities({ live: true }) },
|
||||
]),
|
||||
});
|
||||
|
||||
await new CallManager(api).start();
|
||||
|
||||
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
|
||||
expect(api.getNotificationManager().setNotification).toBeCalled();
|
||||
});
|
||||
|
||||
it('should engage the active substream when it is call-capable', async () => {
|
||||
const api = createAPI({
|
||||
view: createView({
|
||||
camera: 'camera.office',
|
||||
context: { live: { overrides: new Map([['camera.office', 'camera.sub']]) } },
|
||||
}),
|
||||
store: createStore([
|
||||
{
|
||||
cameraID: 'camera.office',
|
||||
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
|
||||
},
|
||||
{
|
||||
cameraID: 'camera.sub',
|
||||
capabilities: createCapabilities({ '2-way-audio': true }),
|
||||
},
|
||||
]),
|
||||
});
|
||||
const manager = new CallManager(api);
|
||||
|
||||
await manager.start();
|
||||
|
||||
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
|
||||
modifiers: [expect.any(SubstreamViewModifier)],
|
||||
force: true,
|
||||
});
|
||||
// The pre-call substream is captured in the recorded view's context so it
|
||||
// can be restored on call end.
|
||||
expect(
|
||||
manager.getCall()?.previousView?.context?.live?.overrides?.get('camera.office'),
|
||||
).toBe('camera.sub');
|
||||
});
|
||||
|
||||
it('should fall back to a call-capable dependency when the parent lacks audio', async () => {
|
||||
const api = createAPI({
|
||||
view: createView({ camera: 'camera.office' }),
|
||||
store: createStore([
|
||||
{
|
||||
cameraID: 'camera.office',
|
||||
config: createCameraConfig({ dependencies: { cameras: ['camera.doorbell'] } }),
|
||||
capabilities: createCapabilities({ live: true }),
|
||||
},
|
||||
{
|
||||
cameraID: 'camera.doorbell',
|
||||
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
await new CallManager(api).start();
|
||||
|
||||
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
|
||||
modifiers: [expect.any(SubstreamViewModifier)],
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should abort when the microphone is unsupported', async () => {
|
||||
const api = createAPI({
|
||||
view: createView({ camera: 'camera.office' }),
|
||||
microphoneSupported: false,
|
||||
});
|
||||
|
||||
await new CallManager(api).start();
|
||||
|
||||
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
|
||||
expect(api.getNotificationManager().setNotification).toBeCalled();
|
||||
});
|
||||
|
||||
it('should abort when the microphone is forbidden', async () => {
|
||||
const api = createAPI({
|
||||
view: createView({ camera: 'camera.office' }),
|
||||
microphoneForbidden: true,
|
||||
});
|
||||
|
||||
await new CallManager(api).start();
|
||||
|
||||
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
|
||||
expect(api.getNotificationManager().setNotification).toBeCalled();
|
||||
});
|
||||
|
||||
it('should connect the microphone when not already connected', async () => {
|
||||
const api = createAPI({
|
||||
view: createView({ camera: 'camera.office' }),
|
||||
microphoneConnected: false,
|
||||
});
|
||||
vi.mocked(api.getMicrophoneManager().connect).mockResolvedValue();
|
||||
|
||||
await new CallManager(api).start();
|
||||
|
||||
expect(api.getMicrophoneManager().connect).toBeCalled();
|
||||
expect(api.getViewManager().setViewByParameters).toBeCalled();
|
||||
});
|
||||
|
||||
it('should abort when connecting the microphone fails', async () => {
|
||||
const api = createAPI({
|
||||
view: createView({ camera: 'camera.office' }),
|
||||
microphoneConnected: false,
|
||||
});
|
||||
vi.mocked(api.getMicrophoneManager().connect).mockRejectedValue(new Error());
|
||||
|
||||
await new CallManager(api).start();
|
||||
|
||||
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
|
||||
expect(api.getNotificationManager().setNotification).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('end', () => {
|
||||
it('should do nothing when no call is active', () => {
|
||||
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
|
||||
|
||||
new CallManager(api).end();
|
||||
|
||||
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should end an active call', async () => {
|
||||
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
|
||||
const manager = new CallManager(api);
|
||||
await manager.start();
|
||||
vi.mocked(api.getViewManager().setViewByParameters).mockClear();
|
||||
|
||||
manager.end();
|
||||
|
||||
expect(manager.isActive()).toBe(false);
|
||||
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
|
||||
modifiers: [expect.any(SubstreamViewModifier)],
|
||||
force: true,
|
||||
});
|
||||
expect(api.getConditionStateManager().setState).toBeCalledWith({ call: false });
|
||||
});
|
||||
|
||||
it('should restore the pre-call substream when ending', async () => {
|
||||
const api = createAPI({
|
||||
view: createView({
|
||||
camera: 'camera.office',
|
||||
context: { live: { overrides: new Map([['camera.office', 'camera.sub']]) } },
|
||||
}),
|
||||
store: createStore([
|
||||
{
|
||||
cameraID: 'camera.office',
|
||||
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
|
||||
},
|
||||
{
|
||||
cameraID: 'camera.sub',
|
||||
capabilities: createCapabilities({ '2-way-audio': true }),
|
||||
},
|
||||
]),
|
||||
});
|
||||
const manager = new CallManager(api);
|
||||
await manager.start();
|
||||
vi.mocked(api.getViewManager().setViewByParameters).mockClear();
|
||||
|
||||
manager.end();
|
||||
|
||||
// The recorded pre-call substream (`camera.sub`) is reinstated.
|
||||
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
|
||||
modifiers: [expect.any(SubstreamViewModifier)],
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return to the pre-call view on an explicit end', async () => {
|
||||
const api = createAPI({
|
||||
view: createView({ camera: 'camera.office', view: 'clips' }),
|
||||
});
|
||||
const manager = new CallManager(api);
|
||||
await manager.start();
|
||||
|
||||
manager.end();
|
||||
|
||||
expect(api.getViewManager().setViewByParametersWithExistingQuery).toBeCalledWith({
|
||||
baseView: expect.any(View),
|
||||
force: true,
|
||||
});
|
||||
const restored = vi.mocked(api.getViewManager().setViewByParametersWithExistingQuery)
|
||||
.mock.calls[0]?.[0];
|
||||
expect(restored?.baseView?.view).toBe('clips');
|
||||
expect(restored?.baseView?.camera).toBe('camera.office');
|
||||
});
|
||||
|
||||
it('should not navigate on an explicit end when the call started from live', async () => {
|
||||
const api = createAPI({
|
||||
view: createView({ camera: 'camera.office', view: 'live' }),
|
||||
});
|
||||
const manager = new CallManager(api);
|
||||
await manager.start();
|
||||
vi.mocked(api.getViewManager().setViewByParameters).mockClear();
|
||||
|
||||
manager.end();
|
||||
|
||||
// No navigation: only the substream is undone.
|
||||
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
|
||||
modifiers: [expect.any(SubstreamViewModifier)],
|
||||
force: true,
|
||||
});
|
||||
expect(api.getViewManager().setViewByParametersWithExistingQuery).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should return to a camera-less pre-call view on an explicit end', async () => {
|
||||
const api = createAPI({
|
||||
view: createView({ camera: null, view: 'folder' }),
|
||||
store: createCallableStore('camera.office'),
|
||||
});
|
||||
const manager = new CallManager(api);
|
||||
await manager.start('camera.office');
|
||||
|
||||
manager.end();
|
||||
|
||||
const restored = vi.mocked(api.getViewManager().setViewByParametersWithExistingQuery)
|
||||
.mock.calls[0]?.[0];
|
||||
expect(restored?.baseView?.view).toBe('folder');
|
||||
expect(restored?.baseView?.camera).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('condition state changes', () => {
|
||||
it('should end the call when the selected camera changes away', async () => {
|
||||
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
|
||||
const manager = new CallManager(api);
|
||||
await manager.start();
|
||||
vi.mocked(api.getViewManager().setViewByParameters).mockClear();
|
||||
|
||||
getConditionStateListener(api)({
|
||||
old: { camera: 'camera.office', view: 'live' },
|
||||
change: { camera: 'camera.other' },
|
||||
new: { camera: 'camera.other', view: 'live' },
|
||||
});
|
||||
|
||||
expect(manager.isActive()).toBe(false);
|
||||
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
|
||||
modifiers: [expect.any(SubstreamViewModifier)],
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not restore the pre-call view when the call auto-ends', async () => {
|
||||
const api = createAPI({
|
||||
view: createView({ camera: 'camera.office', view: 'clips' }),
|
||||
});
|
||||
const manager = new CallManager(api);
|
||||
await manager.start();
|
||||
vi.mocked(api.getViewManager().setViewByParameters).mockClear();
|
||||
|
||||
getConditionStateListener(api)({
|
||||
old: { camera: 'camera.office', view: 'live' },
|
||||
change: { camera: 'camera.other' },
|
||||
new: { camera: 'camera.other', view: 'live' },
|
||||
});
|
||||
|
||||
expect(manager.isActive()).toBe(false);
|
||||
expect(api.getViewManager().setViewByParametersWithExistingQuery).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should end the call when the view leaves live', async () => {
|
||||
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
|
||||
const manager = new CallManager(api);
|
||||
await manager.start();
|
||||
vi.mocked(api.getViewManager().setViewByParameters).mockClear();
|
||||
|
||||
getConditionStateListener(api)({
|
||||
old: { camera: 'camera.office', view: 'live' },
|
||||
change: { view: 'clips' },
|
||||
new: { camera: 'camera.office', view: 'clips' },
|
||||
});
|
||||
|
||||
expect(manager.isActive()).toBe(false);
|
||||
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
|
||||
modifiers: [expect.any(SubstreamViewModifier)],
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep the call when the selected camera is unchanged', async () => {
|
||||
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
|
||||
const manager = new CallManager(api);
|
||||
await manager.start();
|
||||
|
||||
getConditionStateListener(api)({
|
||||
old: { camera: 'camera.office' },
|
||||
change: { view: 'live' },
|
||||
new: { camera: 'camera.office', view: 'live' },
|
||||
});
|
||||
|
||||
expect(manager.isActive()).toBe(true);
|
||||
});
|
||||
|
||||
it('should no-op when no call is active', () => {
|
||||
const api = createAPI();
|
||||
new CallManager(api);
|
||||
|
||||
getConditionStateListener(api)({
|
||||
old: {},
|
||||
change: { camera: 'camera.other' },
|
||||
new: { camera: 'camera.other' },
|
||||
});
|
||||
|
||||
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should end the call when the substream changes away', async () => {
|
||||
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
|
||||
const manager = new CallManager(api);
|
||||
await manager.start();
|
||||
|
||||
getConditionStateListener(api)({
|
||||
old: { camera: 'camera.office', view: 'live' },
|
||||
change: { substreamID: 'camera.sub' },
|
||||
new: { camera: 'camera.office', substreamID: 'camera.sub', view: 'live' },
|
||||
});
|
||||
|
||||
expect(manager.isActive()).toBe(false);
|
||||
});
|
||||
|
||||
it('should keep the call when the substream is unchanged', async () => {
|
||||
const api = createAPI({
|
||||
view: createView({
|
||||
camera: 'camera.office',
|
||||
context: { live: { overrides: new Map([['camera.office', 'camera.sub']]) } },
|
||||
}),
|
||||
store: createStore([
|
||||
{
|
||||
cameraID: 'camera.office',
|
||||
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
|
||||
},
|
||||
{
|
||||
cameraID: 'camera.sub',
|
||||
capabilities: createCapabilities({ '2-way-audio': true }),
|
||||
},
|
||||
]),
|
||||
});
|
||||
const manager = new CallManager(api);
|
||||
await manager.start();
|
||||
|
||||
getConditionStateListener(api)({
|
||||
old: { camera: 'camera.office', substreamID: 'camera.sub' },
|
||||
change: { view: 'live' },
|
||||
new: { camera: 'camera.office', substreamID: 'camera.sub', view: 'live' },
|
||||
});
|
||||
|
||||
expect(manager.isActive()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { CameraManager } from '../../src/camera-manager/manager';
|
||||
import { ActionsManager } from '../../src/card-controller/actions/actions-manager';
|
||||
import { AutomationsManager } from '../../src/card-controller/automations-manager';
|
||||
import { CallManager } from '../../src/card-controller/call/manager';
|
||||
import { CameraURLManager } from '../../src/card-controller/camera-url-manager';
|
||||
import {
|
||||
CardElementManager,
|
||||
@@ -39,6 +40,7 @@ import { ResolvedMediaCache } from '../../src/ha/resolved-media';
|
||||
vi.mock('../../src/camera-manager/manager');
|
||||
vi.mock('../../src/card-controller/actions/actions-manager');
|
||||
vi.mock('../../src/card-controller/automations-manager');
|
||||
vi.mock('../../src/card-controller/call/manager');
|
||||
vi.mock('../../src/card-controller/camera-url-manager');
|
||||
vi.mock('../../src/card-controller/card-element-manager');
|
||||
vi.mock('../../src/card-controller/config/config-manager');
|
||||
@@ -115,6 +117,12 @@ describe('CardController', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should return getCallManager', () => {
|
||||
expect(createController().getCallManager()).toBe(
|
||||
vi.mocked(CallManager).mock.instances[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('should return getDefaultManager', () => {
|
||||
expect(createController().getDefaultManager()).toBe(
|
||||
vi.mocked(DefaultManager).mock.instances[0],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { CardController } from '../../../src/card-controller/controller';
|
||||
import { LockManager } from '../../../src/card-controller/lock/manager';
|
||||
import {
|
||||
createCameraAction,
|
||||
@@ -8,33 +9,50 @@ import {
|
||||
createMediaPlayerAction,
|
||||
createViewAction,
|
||||
} from '../../../src/utils/action';
|
||||
import { createCardAPI } from '../../test-utils';
|
||||
import { createCardAPI, createConfig } from '../../test-utils';
|
||||
|
||||
const setCallLock = (api: CardController, lock: boolean): void => {
|
||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
|
||||
createConfig({ live: { controls: { call: { lock } } } }),
|
||||
);
|
||||
};
|
||||
|
||||
describe('LockManager', () => {
|
||||
it('should report unlocked when no lock source is active', () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(false);
|
||||
setCallLock(api, true);
|
||||
vi.mocked(api.getCallManager().isActive).mockReturnValue(false);
|
||||
|
||||
expect(new LockManager(api).isLocked()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should report locked when the microphone is locking', () => {
|
||||
it('should report locked when a call is active', () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(true);
|
||||
setCallLock(api, true);
|
||||
vi.mocked(api.getCallManager().isActive).mockReturnValue(true);
|
||||
|
||||
expect(new LockManager(api).isLocked()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should report unlocked when a call is active but the lock is disabled', () => {
|
||||
const api = createCardAPI();
|
||||
setCallLock(api, false);
|
||||
vi.mocked(api.getCallManager().isActive).mockReturnValue(true);
|
||||
|
||||
expect(new LockManager(api).isLocked()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should reuse lock manager epoch until the lock state changes', () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(false);
|
||||
setCallLock(api, true);
|
||||
vi.mocked(api.getCallManager().isActive).mockReturnValue(false);
|
||||
const manager = new LockManager(api);
|
||||
|
||||
const unlockedEpoch = manager.getEpoch();
|
||||
expect(manager.getEpoch()).toBe(unlockedEpoch);
|
||||
expect(unlockedEpoch.locked).toBeFalsy();
|
||||
|
||||
vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(true);
|
||||
vi.mocked(api.getCallManager().isActive).mockReturnValue(true);
|
||||
const lockedEpoch = manager.getEpoch();
|
||||
expect(lockedEpoch).not.toBe(unlockedEpoch);
|
||||
expect(lockedEpoch.locked).toBeTruthy();
|
||||
@@ -43,16 +61,18 @@ describe('LockManager', () => {
|
||||
|
||||
it('should not filter actions when unlocked', () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(false);
|
||||
setCallLock(api, true);
|
||||
vi.mocked(api.getCallManager().isActive).mockReturnValue(false);
|
||||
|
||||
const actions = [createGeneralAction('reload'), createLogAction('Allowed')];
|
||||
|
||||
expect(new LockManager(api).getAllowedActions(actions)).toBe(actions);
|
||||
});
|
||||
|
||||
it('should reject microphone-session-disruptive actions when locked', () => {
|
||||
it('should reject call-disruptive actions when locked', () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(true);
|
||||
setCallLock(api, true);
|
||||
vi.mocked(api.getCallManager().isActive).mockReturnValue(true);
|
||||
const manager = new LockManager(api);
|
||||
|
||||
for (const action of [
|
||||
@@ -72,7 +92,8 @@ describe('LockManager', () => {
|
||||
|
||||
it('should preserve non-disruptive actions when locked', () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(true);
|
||||
setCallLock(api, true);
|
||||
vi.mocked(api.getCallManager().isActive).mockReturnValue(true);
|
||||
const manager = new LockManager(api);
|
||||
|
||||
for (const action of [
|
||||
@@ -93,7 +114,8 @@ describe('LockManager', () => {
|
||||
|
||||
it('should preserve non-disruptive actions from a mixed action list when locked', () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(true);
|
||||
setCallLock(api, true);
|
||||
vi.mocked(api.getCallManager().isActive).mockReturnValue(true);
|
||||
|
||||
const manager = new LockManager(api);
|
||||
const allowedAction = createLogAction('Allowed');
|
||||
@@ -105,7 +127,8 @@ describe('LockManager', () => {
|
||||
|
||||
it('should report whether all configured actions are blocked', () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(true);
|
||||
setCallLock(api, true);
|
||||
vi.mocked(api.getCallManager().isActive).mockReturnValue(true);
|
||||
|
||||
const manager = new LockManager(api);
|
||||
|
||||
@@ -126,7 +149,8 @@ describe('LockManager', () => {
|
||||
|
||||
it('should never report all-actions-blocked when unlocked', () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(false);
|
||||
setCallLock(api, true);
|
||||
vi.mocked(api.getCallManager().isActive).mockReturnValue(false);
|
||||
|
||||
const manager = new LockManager(api);
|
||||
|
||||
|
||||
@@ -244,82 +244,6 @@ describe('MicrophoneManager', () => {
|
||||
expect(api.getCardElementManager().update).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('isLocking', () => {
|
||||
it('should not lock when muted', () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MicrophoneManager(api);
|
||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
|
||||
createConfig({
|
||||
live: {
|
||||
microphone: {
|
||||
lock: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(manager.isMuted()).toBeTruthy();
|
||||
expect(manager.isLocking()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should lock when unmuted and lock is enabled', async () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MicrophoneManager(api);
|
||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
|
||||
createConfig({
|
||||
live: {
|
||||
microphone: {
|
||||
lock: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
|
||||
createMockStream(),
|
||||
);
|
||||
|
||||
await manager.unmute();
|
||||
|
||||
expect(manager.isMuted()).toBeFalsy();
|
||||
expect(manager.isLocking()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should not lock when unmuted but lock is disabled', async () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MicrophoneManager(api);
|
||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
|
||||
createConfig({
|
||||
live: {
|
||||
microphone: {
|
||||
lock: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
|
||||
createMockStream(),
|
||||
);
|
||||
|
||||
await manager.unmute();
|
||||
|
||||
expect(manager.isMuted()).toBeFalsy();
|
||||
expect(manager.isLocking()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should not lock when config is unavailable', async () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MicrophoneManager(api);
|
||||
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
|
||||
createMockStream(),
|
||||
);
|
||||
|
||||
await manager.unmute();
|
||||
|
||||
expect(manager.isMuted()).toBeFalsy();
|
||||
expect(manager.isLocking()).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('should require initialization', async () => {
|
||||
it('should require when configured and supported', async () => {
|
||||
const api = createCardAPI();
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { CardController } from '../../src/card-controller/controller';
|
||||
import { QueryStringManager } from '../../src/card-controller/query-string-manager';
|
||||
import { SubstreamSelectViewModifier } from '../../src/card-controller/view/modifiers/substream-select';
|
||||
import { SubstreamViewModifier } from '../../src/card-controller/view/modifiers/substream';
|
||||
import { createCardAPI, createConfig } from '../test-utils';
|
||||
|
||||
const setQueryString = (qs: string): void => {
|
||||
@@ -154,7 +154,7 @@ describe('QueryStringManager', () => {
|
||||
expect(manager.hasViewRelatedActionsToRun()).toBeFalsy();
|
||||
|
||||
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({
|
||||
modifiers: [expect.any(SubstreamSelectViewModifier)],
|
||||
modifiers: [expect.any(SubstreamViewModifier)],
|
||||
params: {},
|
||||
});
|
||||
|
||||
@@ -250,7 +250,7 @@ describe('QueryStringManager', () => {
|
||||
params: {
|
||||
camera: 'camera.kitchen',
|
||||
},
|
||||
modifiers: [expect.any(SubstreamSelectViewModifier)],
|
||||
modifiers: [expect.any(SubstreamViewModifier)],
|
||||
});
|
||||
expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled();
|
||||
});
|
||||
|
||||
@@ -312,6 +312,7 @@ describe('StatusBarItemManager', () => {
|
||||
|
||||
const items = manager.calculateItems({
|
||||
statusConfig: {
|
||||
auto_hide: [],
|
||||
position: 'bottom',
|
||||
style: 'popup',
|
||||
popup_seconds: 3,
|
||||
@@ -356,6 +357,7 @@ describe('StatusBarItemManager', () => {
|
||||
|
||||
const items = manager.calculateItems({
|
||||
statusConfig: {
|
||||
auto_hide: [],
|
||||
position: 'bottom',
|
||||
style: 'popup',
|
||||
popup_seconds: 3,
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { expect, it } from 'vitest';
|
||||
import { createView } from '../../../test-utils';
|
||||
import { SubstreamOffViewModifier } from '../../../../src/card-controller/view/modifiers/substream-off';
|
||||
import { hasSubstream, setSubstream } from '../../../../src/utils/substream';
|
||||
|
||||
it('should turn off substream', () => {
|
||||
const view = createView({
|
||||
view: 'live',
|
||||
camera: 'camera',
|
||||
displayMode: 'grid',
|
||||
});
|
||||
|
||||
setSubstream(view, 'substream');
|
||||
expect(hasSubstream(view)).toBe(true);
|
||||
|
||||
const modifier = new SubstreamOffViewModifier();
|
||||
modifier.modify(view);
|
||||
|
||||
expect(hasSubstream(view)).toBe(false);
|
||||
});
|
||||
@@ -1,119 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { CardController } from '../../../../src/card-controller/controller';
|
||||
import { SubstreamOnViewModifier } from '../../../../src/card-controller/view/modifiers/substream-on';
|
||||
import { RawAdvancedCameraCardConfig } from '../../../../src/config/types';
|
||||
import {
|
||||
getStreamCameraID,
|
||||
hasSubstream,
|
||||
setSubstream,
|
||||
} from '../../../../src/utils/substream';
|
||||
import {
|
||||
createCameraConfig,
|
||||
createCameraManager,
|
||||
createCapabilities,
|
||||
createCardAPI,
|
||||
createConfig,
|
||||
createStore,
|
||||
createView,
|
||||
} from '../../../test-utils';
|
||||
|
||||
const createAPIWithSubstreams = (
|
||||
config?: RawAdvancedCameraCardConfig,
|
||||
): CardController => {
|
||||
const api = createCardAPI();
|
||||
const store = createStore([
|
||||
{
|
||||
cameraID: 'camera.office',
|
||||
capabilities: createCapabilities({
|
||||
live: true,
|
||||
substream: true,
|
||||
}),
|
||||
config: createCameraConfig({
|
||||
dependencies: {
|
||||
all_cameras: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
cameraID: 'camera.kitchen',
|
||||
capabilities: createCapabilities({
|
||||
substream: true,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store));
|
||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig(config));
|
||||
return api;
|
||||
};
|
||||
|
||||
describe('should turn on substream', () => {
|
||||
it('substream available', () => {
|
||||
const view = createView({
|
||||
view: 'live',
|
||||
camera: 'camera.office',
|
||||
});
|
||||
|
||||
expect(hasSubstream(view)).toBe(false);
|
||||
|
||||
const api = createAPIWithSubstreams();
|
||||
|
||||
const modifier = new SubstreamOnViewModifier(api);
|
||||
modifier.modify(view);
|
||||
|
||||
expect(hasSubstream(view)).toBe(true);
|
||||
expect(getStreamCameraID(view)).toBe('camera.kitchen');
|
||||
|
||||
modifier.modify(view);
|
||||
|
||||
expect(hasSubstream(view)).toBe(false);
|
||||
expect(getStreamCameraID(view)).toBe('camera.office');
|
||||
});
|
||||
|
||||
it('malformed substream', () => {
|
||||
const view = createView({
|
||||
view: 'live',
|
||||
camera: 'camera.office',
|
||||
});
|
||||
|
||||
const api = createAPIWithSubstreams();
|
||||
|
||||
setSubstream(view, 'NOT_A_REAL_CAMERA');
|
||||
|
||||
const modifier = new SubstreamOnViewModifier(api);
|
||||
modifier.modify(view);
|
||||
|
||||
expect(hasSubstream(view)).toBe(false);
|
||||
expect(getStreamCameraID(view)).toBe('camera.office');
|
||||
});
|
||||
|
||||
it('substream unavailable', () => {
|
||||
const view = createView({
|
||||
view: 'live',
|
||||
camera: 'camera.office',
|
||||
});
|
||||
|
||||
expect(hasSubstream(view)).toBe(false);
|
||||
|
||||
const api = createCardAPI();
|
||||
const cameraManager = createCameraManager(createStore([]));
|
||||
vi.mocked(api.getCameraManager).mockReturnValue(cameraManager);
|
||||
|
||||
const modifier = new SubstreamOnViewModifier(api);
|
||||
modifier.modify(view);
|
||||
|
||||
expect(hasSubstream(view)).toBe(false);
|
||||
});
|
||||
|
||||
it('without camera', () => {
|
||||
const view = createView({
|
||||
camera: null,
|
||||
view: 'live',
|
||||
});
|
||||
|
||||
const api = createCardAPI();
|
||||
const modifier = new SubstreamOnViewModifier(api);
|
||||
modifier.modify(view);
|
||||
|
||||
expect(hasSubstream(view)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
import { expect, it } from 'vitest';
|
||||
import { SubstreamSelectViewModifier } from '../../../../src/card-controller/view/modifiers/substream-select';
|
||||
import { getStreamCameraID, hasSubstream } from '../../../../src/utils/substream';
|
||||
import { createView } from '../../../test-utils';
|
||||
|
||||
it('should select substream', () => {
|
||||
const view = createView({
|
||||
view: 'live',
|
||||
camera: 'camera.office',
|
||||
});
|
||||
|
||||
expect(hasSubstream(view)).toBe(false);
|
||||
|
||||
const modifier = new SubstreamSelectViewModifier('substream');
|
||||
modifier.modify(view);
|
||||
|
||||
expect(hasSubstream(view)).toBe(true);
|
||||
expect(getStreamCameraID(view)).toBe('substream');
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SubstreamViewModifier } from '../../../../src/card-controller/view/modifiers/substream';
|
||||
import { createView } from '../../../test-utils';
|
||||
|
||||
describe('SubstreamViewModifier', () => {
|
||||
it('should write the override for the selected camera', () => {
|
||||
const view = createView({ camera: 'camera' });
|
||||
|
||||
new SubstreamViewModifier('substream').modify(view);
|
||||
|
||||
expect(view.context?.live?.overrides?.get('camera')).toBe('substream');
|
||||
});
|
||||
|
||||
it('should clear the selected camera override when no substream is given', () => {
|
||||
const view = createView({
|
||||
camera: 'camera',
|
||||
context: { live: { overrides: new Map([['camera', 'substream']]) } },
|
||||
});
|
||||
|
||||
new SubstreamViewModifier().modify(view);
|
||||
|
||||
expect(view.context?.live?.overrides?.get('camera')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should write the override for an explicit camera', () => {
|
||||
const view = createView({
|
||||
camera: 'camera',
|
||||
context: { live: { overrides: new Map([['camera', 'substream']]) } },
|
||||
});
|
||||
|
||||
new SubstreamViewModifier('other-substream', 'other-camera').modify(view);
|
||||
|
||||
expect(view.context?.live?.overrides?.get('other-camera')).toBe('other-substream');
|
||||
expect(view.context?.live?.overrides?.get('camera')).toBe('substream');
|
||||
});
|
||||
|
||||
it('should clear the override for an explicit camera', () => {
|
||||
const view = createView({
|
||||
camera: 'camera',
|
||||
context: { live: { overrides: new Map([['other-camera', 'other-substream']]) } },
|
||||
});
|
||||
|
||||
new SubstreamViewModifier(undefined, 'other-camera').modify(view);
|
||||
|
||||
expect(view.context?.live?.overrides?.get('other-camera')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should no-op for a view without a camera', () => {
|
||||
const view = createView({ camera: null });
|
||||
|
||||
new SubstreamViewModifier('substream').modify(view);
|
||||
|
||||
expect(view.context).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -51,10 +51,29 @@ describe('should act correctly when view is set', () => {
|
||||
camera: 'camera',
|
||||
displayMode: 'grid',
|
||||
targetID: 'camera',
|
||||
substreamID: undefined,
|
||||
});
|
||||
expect(api.getCardElementManager().update).toBeCalled();
|
||||
});
|
||||
|
||||
it('should set the engaged substream in condition state', () => {
|
||||
const view = createView({
|
||||
view: 'live',
|
||||
camera: 'camera',
|
||||
context: { live: { overrides: new Map([['camera', 'substream']]) } },
|
||||
});
|
||||
|
||||
const factory = mock<ViewFactory>();
|
||||
factory.getViewDefault.mockReturnValue(view);
|
||||
|
||||
const api = createInitializedCardAPI();
|
||||
new ViewManager(api, { viewFactory: factory }).setViewDefault();
|
||||
|
||||
expect(api.getConditionStateManager()?.setState).toBeCalledWith(
|
||||
expect.objectContaining({ substreamID: 'substream' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should set view with minor changes without scroll', () => {
|
||||
const view_1 = createView({
|
||||
view: 'live',
|
||||
@@ -305,6 +324,48 @@ it('should set view by parameters with existing query', async () => {
|
||||
expect(manager.getView()?.camera).toBe('camera');
|
||||
});
|
||||
|
||||
it('should set view by parameters with an explicitly provided existing query', async () => {
|
||||
const viewFactory = mock<ViewFactory>();
|
||||
viewFactory.getViewByParameters.mockReturnValue(createView());
|
||||
|
||||
const viewQueryExecutor = mock<ViewQueryExecutor>();
|
||||
viewQueryExecutor.getExistingQueryModifiers.mockResolvedValue([]);
|
||||
|
||||
const manager = new ViewManager(createInitializedCardAPI(), {
|
||||
viewFactory: viewFactory,
|
||||
viewQueryExecutor: viewQueryExecutor,
|
||||
});
|
||||
|
||||
const query = new UnifiedQuery();
|
||||
await manager.setViewByParametersWithExistingQuery({ params: { query } });
|
||||
|
||||
// An explicitly-passed query is used as-is rather than the base view's.
|
||||
expect(viewFactory.getViewByParameters).toBeCalledWith(
|
||||
expect.objectContaining({ params: expect.objectContaining({ query }) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should clear the query when an explicit null query is passed', async () => {
|
||||
const viewFactory = mock<ViewFactory>();
|
||||
viewFactory.getViewByParameters.mockReturnValue(createView());
|
||||
|
||||
const viewQueryExecutor = mock<ViewQueryExecutor>();
|
||||
viewQueryExecutor.getExistingQueryModifiers.mockResolvedValue([]);
|
||||
|
||||
const manager = new ViewManager(createInitializedCardAPI(), {
|
||||
viewFactory: viewFactory,
|
||||
viewQueryExecutor: viewQueryExecutor,
|
||||
});
|
||||
|
||||
await manager.setViewByParametersWithExistingQuery({ params: { query: null } });
|
||||
|
||||
// An explicit `null` is respected (clears the query), not overridden by the
|
||||
// base view's query.
|
||||
expect(viewFactory.getViewByParameters).toBeCalledWith(
|
||||
expect.objectContaining({ params: expect.objectContaining({ query: null }) }),
|
||||
);
|
||||
});
|
||||
|
||||
describe('should handle exceptions', () => {
|
||||
it('should retry with failSafe when no existing view in sync calls', () => {
|
||||
const viewFactory = mock<ViewFactory>();
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isAutoHidden, resolveAutoHideState } from '../../src/components-lib/auto-hide';
|
||||
|
||||
describe('isAutoHidden', () => {
|
||||
it('should not hide with an empty condition list', () => {
|
||||
expect(isAutoHidden([], { call: true, casting: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('should hide when a configured condition is active', () => {
|
||||
expect(isAutoHidden(['call'], { call: true, casting: false })).toBe(true);
|
||||
});
|
||||
|
||||
it('should not hide when no configured condition is active', () => {
|
||||
expect(isAutoHidden(['call'], { call: false, casting: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('should hide when any of multiple configured conditions is active', () => {
|
||||
expect(isAutoHidden(['call', 'casting'], { call: false, casting: true })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveAutoHideState', () => {
|
||||
it('should resolve with the supplied call state', () => {
|
||||
expect(resolveAutoHideState(true)).toEqual({ call: true, casting: false });
|
||||
});
|
||||
|
||||
it('should default the call state to false', () => {
|
||||
expect(resolveAutoHideState()).toEqual({ call: false, casting: false });
|
||||
});
|
||||
});
|
||||
@@ -294,6 +294,81 @@ describe('MicrophoneActionsController', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('on call state change', () => {
|
||||
it('should unmute on call start when call is a configured unmute condition', () => {
|
||||
const microphoneManager = createMicrophoneManager();
|
||||
const controller = new MicrophoneActionsController();
|
||||
controller.setOptions({
|
||||
microphoneManager,
|
||||
autoUnmuteConditions: ['call' as const],
|
||||
});
|
||||
|
||||
controller.setCallActive(false);
|
||||
controller.setCallActive(true);
|
||||
|
||||
expect(microphoneManager.unmute).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should unmute when the call is already active on first notification', () => {
|
||||
const microphoneManager = createMicrophoneManager();
|
||||
const controller = new MicrophoneActionsController();
|
||||
controller.setOptions({
|
||||
microphoneManager,
|
||||
autoUnmuteConditions: ['call' as const],
|
||||
});
|
||||
|
||||
// `setCallActive(true)` is the first call-state signal, with no preceding
|
||||
// `false` -- as for a live view that mounts while a call is already
|
||||
// active. The initial state must not be swallowed as a baseline.
|
||||
controller.setCallActive(true);
|
||||
|
||||
expect(microphoneManager.unmute).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should mute on call end when call is a configured mute condition', () => {
|
||||
const microphoneManager = createMicrophoneManager();
|
||||
const controller = new MicrophoneActionsController();
|
||||
controller.setOptions({
|
||||
microphoneManager,
|
||||
autoMuteConditions: ['call' as const],
|
||||
});
|
||||
|
||||
controller.setCallActive(true);
|
||||
controller.setCallActive(false);
|
||||
|
||||
expect(microphoneManager.mute).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not act on the initial call state', () => {
|
||||
const microphoneManager = createMicrophoneManager();
|
||||
const controller = new MicrophoneActionsController();
|
||||
controller.setOptions({
|
||||
microphoneManager,
|
||||
autoMuteConditions: ['call' as const],
|
||||
autoUnmuteConditions: ['call' as const],
|
||||
});
|
||||
|
||||
controller.setCallActive(false);
|
||||
|
||||
expect(microphoneManager.mute).not.toBeCalled();
|
||||
expect(microphoneManager.unmute).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should not act on call start when call is not a configured condition', () => {
|
||||
const microphoneManager = createMicrophoneManager();
|
||||
const controller = new MicrophoneActionsController();
|
||||
controller.setOptions({
|
||||
microphoneManager,
|
||||
autoUnmuteConditions: [],
|
||||
});
|
||||
|
||||
controller.setCallActive(false);
|
||||
controller.setCallActive(true);
|
||||
|
||||
expect(microphoneManager.unmute).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('lifecycle', () => {
|
||||
it('should be idempotent on setRoot for the same element', () => {
|
||||
const controller = new MicrophoneActionsController();
|
||||
|
||||
@@ -631,19 +631,15 @@ describe('MediaActionsController', () => {
|
||||
controller.setOptions({
|
||||
autoUnmuteConditions: ['microphone' as const],
|
||||
playerSelector: 'video',
|
||||
microphoneState: createMicrophoneState({ muted: true }),
|
||||
});
|
||||
controller.setMicrophoneState(createMicrophoneState({ muted: true }));
|
||||
|
||||
const children = createPlayerSlideNodes();
|
||||
controller.setRoot(createParent({ children: children }));
|
||||
|
||||
await controller.setTarget(0, true);
|
||||
|
||||
controller.setOptions({
|
||||
autoUnmuteConditions: ['microphone' as const],
|
||||
playerSelector: 'video',
|
||||
microphoneState: createMicrophoneState({ muted: false }),
|
||||
});
|
||||
controller.setMicrophoneState(createMicrophoneState({ muted: false }));
|
||||
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute,
|
||||
@@ -656,19 +652,15 @@ describe('MediaActionsController', () => {
|
||||
controller.setOptions({
|
||||
autoMuteConditions: ['microphone' as const],
|
||||
playerSelector: 'video',
|
||||
microphoneState: createMicrophoneState({ muted: false }),
|
||||
});
|
||||
controller.setMicrophoneState(createMicrophoneState({ muted: false }));
|
||||
|
||||
const children = createPlayerSlideNodes();
|
||||
controller.setRoot(createParent({ children: children }));
|
||||
|
||||
await controller.setTarget(0, true);
|
||||
|
||||
controller.setOptions({
|
||||
autoMuteConditions: ['microphone' as const],
|
||||
playerSelector: 'video',
|
||||
microphoneState: createMicrophoneState({ muted: true }),
|
||||
});
|
||||
controller.setMicrophoneState(createMicrophoneState({ muted: true }));
|
||||
|
||||
vi.runOnlyPendingTimers();
|
||||
|
||||
@@ -683,19 +675,15 @@ describe('MediaActionsController', () => {
|
||||
controller.setOptions({
|
||||
autoMuteConditions: [],
|
||||
playerSelector: 'video',
|
||||
microphoneState: createMicrophoneState({ muted: false }),
|
||||
});
|
||||
controller.setMicrophoneState(createMicrophoneState({ muted: false }));
|
||||
|
||||
const children = createPlayerSlideNodes();
|
||||
controller.setRoot(createParent({ children: children }));
|
||||
|
||||
await controller.setTarget(0, true);
|
||||
|
||||
controller.setOptions({
|
||||
autoMuteConditions: ['microphone' as const],
|
||||
playerSelector: 'video',
|
||||
microphoneState: createMicrophoneState({ muted: true }),
|
||||
});
|
||||
controller.setMicrophoneState(createMicrophoneState({ muted: true }));
|
||||
|
||||
vi.runOnlyPendingTimers();
|
||||
|
||||
@@ -703,5 +691,192 @@ describe('MediaActionsController', () => {
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.mute,
|
||||
).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should not act on the initial microphone state', async () => {
|
||||
const controller = new MediaActionsController();
|
||||
|
||||
controller.setOptions({
|
||||
autoUnmuteConditions: ['microphone' as const],
|
||||
playerSelector: 'video',
|
||||
});
|
||||
|
||||
const children = createPlayerSlideNodes();
|
||||
controller.setRoot(createParent({ children: children }));
|
||||
await controller.setTarget(0, true);
|
||||
|
||||
controller.setMicrophoneState(createMicrophoneState({ muted: false }));
|
||||
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute,
|
||||
).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('should take action on call state changes', () => {
|
||||
it('should unmute the target on call start', async () => {
|
||||
const controller = new MediaActionsController();
|
||||
|
||||
controller.setOptions({
|
||||
autoUnmuteConditions: ['call' as const],
|
||||
playerSelector: 'video',
|
||||
});
|
||||
controller.setCallActive(false);
|
||||
|
||||
const children = createPlayerSlideNodes();
|
||||
controller.setRoot(createParent({ children: children }));
|
||||
|
||||
await controller.setTarget(0, true);
|
||||
|
||||
controller.setCallActive(true);
|
||||
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute,
|
||||
).toBeCalled();
|
||||
});
|
||||
|
||||
it('should mute the target on call end', async () => {
|
||||
const controller = new MediaActionsController();
|
||||
|
||||
controller.setOptions({
|
||||
autoMuteConditions: ['call' as const],
|
||||
playerSelector: 'video',
|
||||
});
|
||||
controller.setCallActive(true);
|
||||
|
||||
const children = createPlayerSlideNodes();
|
||||
controller.setRoot(createParent({ children: children }));
|
||||
|
||||
await controller.setTarget(0, true);
|
||||
|
||||
controller.setCallActive(false);
|
||||
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.mute,
|
||||
).toBeCalled();
|
||||
});
|
||||
|
||||
it('should not act on the initial call state', async () => {
|
||||
const controller = new MediaActionsController();
|
||||
|
||||
controller.setOptions({
|
||||
autoMuteConditions: ['call' as const],
|
||||
autoUnmuteConditions: ['call' as const],
|
||||
playerSelector: 'video',
|
||||
});
|
||||
|
||||
const children = createPlayerSlideNodes();
|
||||
controller.setRoot(createParent({ children: children }));
|
||||
await controller.setTarget(0, true);
|
||||
|
||||
controller.setCallActive(false);
|
||||
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.mute,
|
||||
).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should not act when call is not a configured condition', async () => {
|
||||
const controller = new MediaActionsController();
|
||||
|
||||
controller.setOptions({
|
||||
autoUnmuteConditions: [],
|
||||
playerSelector: 'video',
|
||||
});
|
||||
controller.setCallActive(false);
|
||||
|
||||
const children = createPlayerSlideNodes();
|
||||
controller.setRoot(createParent({ children: children }));
|
||||
await controller.setTarget(0, true);
|
||||
|
||||
controller.setCallActive(true);
|
||||
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute,
|
||||
).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should apply the call-start unmute when the target arrives after the call', async () => {
|
||||
const controller = new MediaActionsController();
|
||||
|
||||
controller.setOptions({
|
||||
autoUnmuteConditions: ['call' as const],
|
||||
playerSelector: 'video',
|
||||
});
|
||||
|
||||
const children = createPlayerSlideNodes();
|
||||
controller.setRoot(createParent({ children: children }));
|
||||
|
||||
// The call becomes active before any target is selected: with no
|
||||
// target, the unmute cannot be applied yet.
|
||||
controller.setCallActive(true);
|
||||
await flushPromises();
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute,
|
||||
).not.toBeCalled();
|
||||
|
||||
await controller.setTarget(0, true);
|
||||
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute,
|
||||
).toBeCalled();
|
||||
});
|
||||
|
||||
it('should unmute when the call is already active on the first call-state signal', async () => {
|
||||
const controller = new MediaActionsController();
|
||||
|
||||
controller.setOptions({
|
||||
autoUnmuteConditions: ['call' as const],
|
||||
playerSelector: 'video',
|
||||
});
|
||||
|
||||
const children = createPlayerSlideNodes();
|
||||
controller.setRoot(createParent({ children: children }));
|
||||
await controller.setTarget(0, true);
|
||||
|
||||
// `setCallActive(true)` is the first call-state signal, with no preceding
|
||||
// `false` -- as for a carousel that loads while a call is already
|
||||
// active. The initial state must not be swallowed as a baseline.
|
||||
controller.setCallActive(true);
|
||||
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute,
|
||||
).toBeCalled();
|
||||
});
|
||||
|
||||
it('should defer the call-start unmute until the media player is ready', async () => {
|
||||
const controller = new MediaActionsController();
|
||||
|
||||
controller.setOptions({
|
||||
autoUnmuteConditions: ['call' as const],
|
||||
playerSelector: 'video',
|
||||
});
|
||||
controller.setCallActive(false);
|
||||
|
||||
// A player whose media player controller is not ready on first request.
|
||||
const mediaPlayerController = mock<MediaPlayerController>();
|
||||
const player = document.createElement('video');
|
||||
player['getMediaPlayerController'] = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValue(mediaPlayerController);
|
||||
const child = createTestSlideNodes({ n: 1 })[0];
|
||||
child.appendChild(player as unknown as MediaPlayerElement);
|
||||
|
||||
controller.setRoot(createParent({ children: [child] }));
|
||||
await controller.setTarget(0, true);
|
||||
|
||||
// The call starts while the player is still not ready: no unmute yet.
|
||||
controller.setCallActive(true);
|
||||
await flushPromises();
|
||||
expect(mediaPlayerController.unmute).not.toBeCalled();
|
||||
|
||||
// Once the media loads the deferred unmute is applied -- exactly once,
|
||||
// so a later reload cannot clobber a manual mute made during the call.
|
||||
player.dispatchEvent(new Event('advanced-camera-card:media:loaded'));
|
||||
await flushPromises();
|
||||
player.dispatchEvent(new Event('advanced-camera-card:media:loaded'));
|
||||
await flushPromises();
|
||||
expect(mediaPlayerController.unmute).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,12 +4,13 @@ import { mock } from 'vitest-mock-extended';
|
||||
import { Capabilities } from '../../src/camera-manager/capabilities.js';
|
||||
import { CameraManager } from '../../src/camera-manager/manager.js';
|
||||
import { CameraManagerCameraMetadata } from '../../src/camera-manager/types.js';
|
||||
import { CallManager } from '../../src/card-controller/call/manager.js';
|
||||
import { FoldersManager } from '../../src/card-controller/folders/manager.js';
|
||||
import { FolderQuery } from '../../src/card-controller/folders/types';
|
||||
import { FullscreenManager } from '../../src/card-controller/fullscreen/fullscreen-manager.js';
|
||||
import { MediaPlayerManager } from '../../src/card-controller/media-player-manager.js';
|
||||
import { PIPManager } from '../../src/card-controller/pip-manager.js';
|
||||
import { MicrophoneManager } from '../../src/card-controller/microphone-manager.js';
|
||||
import { PIPManager } from '../../src/card-controller/pip-manager.js';
|
||||
import { ViewManager } from '../../src/card-controller/view/view-manager.js';
|
||||
import {
|
||||
MenuButtonController,
|
||||
@@ -1167,9 +1168,166 @@ describe('MenuButtonController', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('should have call button', () => {
|
||||
it('with no view', () => {
|
||||
const buttons = calculateButtons(controller, {
|
||||
cameraManager: createCameraManager(),
|
||||
view: null,
|
||||
});
|
||||
|
||||
expect(buttons).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ title: 'Start 2-way audio call' }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('with a non-live view', () => {
|
||||
const cameraManager = createCameraManager(
|
||||
createStore([
|
||||
{
|
||||
cameraID: 'camera-1',
|
||||
capabilities: createCapabilities({ '2-way-audio': true }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
const buttons = calculateButtons(controller, {
|
||||
cameraManager,
|
||||
view: createView({ camera: 'camera-1', view: 'clips' }),
|
||||
});
|
||||
|
||||
expect(buttons).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ title: 'Start 2-way audio call' }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('when no camera supports 2-way audio', () => {
|
||||
const cameraManager = createCameraManager(
|
||||
createStore([{ cameraID: 'camera-1', capabilities: createCapabilities() }]),
|
||||
);
|
||||
const buttons = calculateButtons(controller, { cameraManager });
|
||||
|
||||
expect(buttons).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ title: 'Start 2-way audio call' }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('with a single 2-way-audio target', () => {
|
||||
const cameraManager = createCameraManager(
|
||||
createStore([
|
||||
{
|
||||
cameraID: 'camera-1',
|
||||
capabilities: createCapabilities({ '2-way-audio': true }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
const buttons = calculateButtons(controller, { cameraManager });
|
||||
|
||||
expect(buttons).toContainEqual({
|
||||
alignment: 'matching',
|
||||
state_color: true,
|
||||
permanent: false,
|
||||
icon: 'mdi:phone',
|
||||
enabled: true,
|
||||
priority: 50,
|
||||
type: 'custom:advanced-camera-card-menu-icon',
|
||||
title: 'Start 2-way audio call',
|
||||
tap_action: {
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'call_start',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('with multiple 2-way-audio targets', () => {
|
||||
const cameraManager = createCameraManager(
|
||||
createStore([
|
||||
{
|
||||
cameraID: 'camera-1',
|
||||
capabilities: createCapabilities({ '2-way-audio': true }),
|
||||
config: createCameraConfig({ dependencies: { cameras: ['camera-2'] } }),
|
||||
},
|
||||
{
|
||||
cameraID: 'camera-2',
|
||||
capabilities: createCapabilities({ '2-way-audio': true }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
const buttons = calculateButtons(controller, { cameraManager });
|
||||
|
||||
expect(buttons).toContainEqual(
|
||||
expect.objectContaining({
|
||||
icon: 'mdi:phone',
|
||||
title: 'Start 2-way audio call',
|
||||
type: 'custom:advanced-camera-card-menu-submenu',
|
||||
items: [
|
||||
expect.objectContaining({
|
||||
tap_action: {
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'call_start',
|
||||
camera: 'camera-1',
|
||||
},
|
||||
}),
|
||||
expect.objectContaining({
|
||||
tap_action: {
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'call_start',
|
||||
camera: 'camera-1',
|
||||
stream: 'camera-2',
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('when a call is active', () => {
|
||||
const cameraManager = createCameraManager(
|
||||
createStore([
|
||||
{
|
||||
cameraID: 'camera-1',
|
||||
capabilities: createCapabilities({ '2-way-audio': true }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
const callManager = mock<CallManager>();
|
||||
vi.mocked(callManager.isActive).mockReturnValue(true);
|
||||
const buttons = calculateButtons(controller, {
|
||||
cameraManager,
|
||||
callManager,
|
||||
view: createView({ camera: 'camera-1' }),
|
||||
});
|
||||
|
||||
expect(buttons).toContainEqual({
|
||||
alignment: 'matching',
|
||||
state_color: true,
|
||||
permanent: false,
|
||||
icon: 'mdi:phone-hangup',
|
||||
enabled: true,
|
||||
priority: 50,
|
||||
type: 'custom:advanced-camera-card-menu-icon',
|
||||
title: 'End 2-way audio call',
|
||||
style: {
|
||||
animation: 'pulse 3s infinite',
|
||||
color: 'var(--advanced-camera-card-menu-button-critical-color)',
|
||||
},
|
||||
tap_action: {
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'call_end',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('should have microphone button', () => {
|
||||
it('when camera has 2-way-audio capability', () => {
|
||||
const microphoneManager = mock<MicrophoneManager>();
|
||||
const callManager = mock<CallManager>();
|
||||
vi.mocked(callManager.isActive).mockReturnValue(true);
|
||||
vi.mocked(microphoneManager.isForbidden).mockReturnValue(false);
|
||||
vi.mocked(microphoneManager.isMuted).mockReturnValue(false);
|
||||
vi.mocked(microphoneManager.isSupported).mockReturnValue(true);
|
||||
@@ -1185,6 +1343,7 @@ describe('MenuButtonController', () => {
|
||||
const buttons = calculateButtons(controller, {
|
||||
cameraManager,
|
||||
microphoneManager: microphoneManager,
|
||||
callManager,
|
||||
});
|
||||
|
||||
expect(buttons).toContainEqual({
|
||||
@@ -1213,6 +1372,8 @@ describe('MenuButtonController', () => {
|
||||
|
||||
it('when camera does not have 2-way-audio capability', () => {
|
||||
const microphoneManager = mock<MicrophoneManager>();
|
||||
const callManager = mock<CallManager>();
|
||||
vi.mocked(callManager.isActive).mockReturnValue(true);
|
||||
vi.mocked(microphoneManager.isForbidden).mockReturnValue(false);
|
||||
vi.mocked(microphoneManager.isMuted).mockReturnValue(false);
|
||||
vi.mocked(microphoneManager.isSupported).mockReturnValue(true);
|
||||
@@ -1223,6 +1384,34 @@ describe('MenuButtonController', () => {
|
||||
const buttons = calculateButtons(controller, {
|
||||
cameraManager,
|
||||
microphoneManager: microphoneManager,
|
||||
callManager,
|
||||
});
|
||||
|
||||
expect(buttons).not.toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ title: 'Microphone' })]),
|
||||
);
|
||||
});
|
||||
|
||||
it('is not shown without an active call', () => {
|
||||
const microphoneManager = mock<MicrophoneManager>();
|
||||
vi.mocked(microphoneManager.isForbidden).mockReturnValue(false);
|
||||
vi.mocked(microphoneManager.isMuted).mockReturnValue(false);
|
||||
vi.mocked(microphoneManager.isSupported).mockReturnValue(true);
|
||||
const callManager = mock<CallManager>();
|
||||
vi.mocked(callManager.isActive).mockReturnValue(false);
|
||||
|
||||
const cameraManager = createCameraManager(
|
||||
createStore([
|
||||
{
|
||||
cameraID: 'camera-1',
|
||||
capabilities: createCapabilities({ '2-way-audio': true }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
const buttons = calculateButtons(controller, {
|
||||
cameraManager,
|
||||
microphoneManager,
|
||||
callManager,
|
||||
});
|
||||
|
||||
expect(buttons).not.toEqual(
|
||||
@@ -1232,6 +1421,8 @@ describe('MenuButtonController', () => {
|
||||
|
||||
it('with forbidden microphone', () => {
|
||||
const microphoneManager = mock<MicrophoneManager>();
|
||||
const callManager = mock<CallManager>();
|
||||
vi.mocked(callManager.isActive).mockReturnValue(true);
|
||||
vi.mocked(microphoneManager.isForbidden).mockReturnValue(true);
|
||||
|
||||
const cameraManager = createCameraManager(
|
||||
@@ -1245,6 +1436,7 @@ describe('MenuButtonController', () => {
|
||||
const buttons = calculateButtons(controller, {
|
||||
cameraManager,
|
||||
microphoneManager: microphoneManager,
|
||||
callManager,
|
||||
});
|
||||
|
||||
expect(buttons).toContainEqual({
|
||||
@@ -1262,6 +1454,8 @@ describe('MenuButtonController', () => {
|
||||
|
||||
it('with muted microphone', () => {
|
||||
const microphoneManager = mock<MicrophoneManager>();
|
||||
const callManager = mock<CallManager>();
|
||||
vi.mocked(callManager.isActive).mockReturnValue(true);
|
||||
vi.mocked(microphoneManager.isForbidden).mockReturnValue(false);
|
||||
vi.mocked(microphoneManager.isMuted).mockReturnValue(true);
|
||||
vi.mocked(microphoneManager.isSupported).mockReturnValue(true);
|
||||
@@ -1277,6 +1471,7 @@ describe('MenuButtonController', () => {
|
||||
const buttons = calculateButtons(controller, {
|
||||
cameraManager,
|
||||
microphoneManager: microphoneManager,
|
||||
callManager,
|
||||
});
|
||||
|
||||
expect(buttons).toContainEqual({
|
||||
@@ -1302,6 +1497,8 @@ describe('MenuButtonController', () => {
|
||||
|
||||
it('with unsupported microphone', () => {
|
||||
const microphoneManager = mock<MicrophoneManager>();
|
||||
const callManager = mock<CallManager>();
|
||||
vi.mocked(callManager.isActive).mockReturnValue(true);
|
||||
vi.mocked(microphoneManager.isForbidden).mockReturnValue(false);
|
||||
vi.mocked(microphoneManager.isMuted).mockReturnValue(true);
|
||||
vi.mocked(microphoneManager.isSupported).mockReturnValue(false);
|
||||
@@ -1317,6 +1514,7 @@ describe('MenuButtonController', () => {
|
||||
const buttons = calculateButtons(controller, {
|
||||
cameraManager,
|
||||
microphoneManager: microphoneManager,
|
||||
callManager,
|
||||
});
|
||||
|
||||
expect(buttons).toContainEqual({
|
||||
@@ -1334,6 +1532,8 @@ describe('MenuButtonController', () => {
|
||||
|
||||
it('with muted toggle type microphone', () => {
|
||||
const microphoneManager = mock<MicrophoneManager>();
|
||||
const callManager = mock<CallManager>();
|
||||
vi.mocked(callManager.isActive).mockReturnValue(true);
|
||||
vi.mocked(microphoneManager.isForbidden).mockReturnValue(false);
|
||||
vi.mocked(microphoneManager.isMuted).mockReturnValue(true);
|
||||
vi.mocked(microphoneManager.isSupported).mockReturnValue(true);
|
||||
@@ -1349,6 +1549,7 @@ describe('MenuButtonController', () => {
|
||||
const buttons = calculateButtons(controller, {
|
||||
cameraManager,
|
||||
microphoneManager: microphoneManager,
|
||||
callManager,
|
||||
config: createConfig({
|
||||
menu: { buttons: { microphone: { type: 'toggle' } } },
|
||||
}),
|
||||
@@ -1373,6 +1574,8 @@ describe('MenuButtonController', () => {
|
||||
|
||||
it('with unmuted toggle type microphone', () => {
|
||||
const microphoneManager = mock<MicrophoneManager>();
|
||||
const callManager = mock<CallManager>();
|
||||
vi.mocked(callManager.isActive).mockReturnValue(true);
|
||||
vi.mocked(microphoneManager.isForbidden).mockReturnValue(false);
|
||||
vi.mocked(microphoneManager.isMuted).mockReturnValue(false);
|
||||
vi.mocked(microphoneManager.isSupported).mockReturnValue(true);
|
||||
@@ -1388,6 +1591,7 @@ describe('MenuButtonController', () => {
|
||||
const buttons = calculateButtons(controller, {
|
||||
cameraManager,
|
||||
microphoneManager: microphoneManager,
|
||||
callManager,
|
||||
config: createConfig({
|
||||
menu: { buttons: { microphone: { type: 'toggle' } } },
|
||||
}),
|
||||
|
||||
@@ -681,4 +681,37 @@ describe('MenuController', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('auto-hide', () => {
|
||||
it('should not render before a config is set', () => {
|
||||
const controller = new MenuController(createLitElement());
|
||||
expect(controller.shouldRender()).toBe(false);
|
||||
});
|
||||
|
||||
it('should not render when the style is none', () => {
|
||||
const controller = new MenuController(createLitElement());
|
||||
controller.setMenuConfig(createMenuConfig({ style: 'none' }));
|
||||
expect(controller.shouldRender()).toBe(false);
|
||||
});
|
||||
|
||||
it('should render with a config and no auto-hide state', () => {
|
||||
const controller = new MenuController(createLitElement());
|
||||
controller.setMenuConfig(createMenuConfig({ auto_hide: ['call'] }));
|
||||
expect(controller.shouldRender()).toBe(true);
|
||||
});
|
||||
|
||||
it('should render when no auto-hide condition is active', () => {
|
||||
const controller = new MenuController(createLitElement());
|
||||
controller.setMenuConfig(createMenuConfig({ auto_hide: ['call'] }));
|
||||
controller.setAutoHideState({ call: false, casting: true });
|
||||
expect(controller.shouldRender()).toBe(true);
|
||||
});
|
||||
|
||||
it('should not render when a configured auto-hide condition is active', () => {
|
||||
const controller = new MenuController(createLitElement());
|
||||
controller.setMenuConfig(createMenuConfig({ auto_hide: ['call'] }));
|
||||
controller.setAutoHideState({ call: true, casting: false });
|
||||
expect(controller.shouldRender()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -429,4 +429,35 @@ describe('StatusBarController', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('auto-hide', () => {
|
||||
const sufficientItem = {
|
||||
type: 'custom:advanced-camera-card-status-bar-string' as const,
|
||||
string: 'Item',
|
||||
sufficient: true,
|
||||
};
|
||||
|
||||
it('should render with a config and no auto-hide state', () => {
|
||||
const controller = new StatusBarController(createLitElement());
|
||||
controller.setConfig(createConfig({ auto_hide: ['call'] }));
|
||||
controller.setItems([sufficientItem]);
|
||||
expect(controller.shouldRender()).toBe(true);
|
||||
});
|
||||
|
||||
it('should render when no auto-hide condition is active', () => {
|
||||
const controller = new StatusBarController(createLitElement());
|
||||
controller.setConfig(createConfig({ auto_hide: ['call'] }));
|
||||
controller.setItems([sufficientItem]);
|
||||
controller.setAutoHideState({ call: false, casting: true });
|
||||
expect(controller.shouldRender()).toBe(true);
|
||||
});
|
||||
|
||||
it('should not render when a configured auto-hide condition is active', () => {
|
||||
const controller = new StatusBarController(createLitElement());
|
||||
controller.setConfig(createConfig({ auto_hide: ['call'] }));
|
||||
controller.setItems([sufficientItem]);
|
||||
controller.setAutoHideState({ call: true, casting: false });
|
||||
expect(controller.shouldRender()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -740,63 +740,6 @@ describe('ConditionsManager', () => {
|
||||
...state,
|
||||
};
|
||||
};
|
||||
it('empty', () => {
|
||||
const stateManager = new ConditionStateManager();
|
||||
const manager = new ConditionsManager(
|
||||
[{ condition: 'microphone' as const }],
|
||||
stateManager,
|
||||
);
|
||||
|
||||
expect(manager.getEvaluation().result).toBeTruthy();
|
||||
stateManager.setState({
|
||||
microphone: createMicrophoneState({ connected: true }),
|
||||
});
|
||||
expect(manager.getEvaluation().result).toBeTruthy();
|
||||
stateManager.setState({
|
||||
microphone: createMicrophoneState({ connected: false }),
|
||||
});
|
||||
expect(manager.getEvaluation().result).toBeTruthy();
|
||||
stateManager.setState({ microphone: createMicrophoneState({ muted: true }) });
|
||||
expect(manager.getEvaluation().result).toBeTruthy();
|
||||
stateManager.setState({ microphone: createMicrophoneState({ muted: false }) });
|
||||
expect(manager.getEvaluation().result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('connected is true', () => {
|
||||
const stateManager = new ConditionStateManager();
|
||||
const manager = new ConditionsManager(
|
||||
[{ condition: 'microphone' as const, connected: true }],
|
||||
stateManager,
|
||||
);
|
||||
|
||||
expect(manager.getEvaluation().result).toBeFalsy();
|
||||
stateManager.setState({
|
||||
microphone: createMicrophoneState({ connected: true }),
|
||||
});
|
||||
expect(manager.getEvaluation().result).toBeTruthy();
|
||||
stateManager.setState({
|
||||
microphone: createMicrophoneState({ connected: false }),
|
||||
});
|
||||
expect(manager.getEvaluation().result).toBeFalsy();
|
||||
});
|
||||
|
||||
it('connected is false', () => {
|
||||
const stateManager = new ConditionStateManager();
|
||||
const manager = new ConditionsManager(
|
||||
[{ condition: 'microphone' as const, connected: false }],
|
||||
stateManager,
|
||||
);
|
||||
|
||||
expect(manager.getEvaluation().result).toBeFalsy();
|
||||
stateManager.setState({
|
||||
microphone: createMicrophoneState({ connected: true }),
|
||||
});
|
||||
expect(manager.getEvaluation().result).toBeFalsy();
|
||||
stateManager.setState({
|
||||
microphone: createMicrophoneState({ connected: false }),
|
||||
});
|
||||
expect(manager.getEvaluation().result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('muted is true', () => {
|
||||
const stateManager = new ConditionStateManager();
|
||||
@@ -825,26 +768,50 @@ describe('ConditionsManager', () => {
|
||||
stateManager.setState({ microphone: createMicrophoneState({ muted: false }) });
|
||||
expect(manager.getEvaluation().result).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('connected and muted', () => {
|
||||
describe('with call condition', () => {
|
||||
it('bare form defaults to call:true', () => {
|
||||
const stateManager = new ConditionStateManager();
|
||||
const manager = new ConditionsManager(
|
||||
[{ condition: 'microphone' as const, muted: false, connected: true }],
|
||||
[{ condition: 'call' as const }],
|
||||
stateManager,
|
||||
);
|
||||
|
||||
expect(manager.getEvaluation().result).toBeFalsy();
|
||||
stateManager.setState({ microphone: createMicrophoneState({ muted: true }) });
|
||||
stateManager.setState({ call: true });
|
||||
expect(manager.getEvaluation().result).toBeTruthy();
|
||||
stateManager.setState({ call: false });
|
||||
expect(manager.getEvaluation().result).toBeFalsy();
|
||||
stateManager.setState({ microphone: createMicrophoneState({ muted: false }) });
|
||||
});
|
||||
|
||||
it('call is true', () => {
|
||||
const stateManager = new ConditionStateManager();
|
||||
const manager = new ConditionsManager(
|
||||
[{ condition: 'call' as const, call: true }],
|
||||
stateManager,
|
||||
);
|
||||
|
||||
expect(manager.getEvaluation().result).toBeFalsy();
|
||||
stateManager.setState({
|
||||
microphone: createMicrophoneState({ connected: false, muted: false }),
|
||||
});
|
||||
stateManager.setState({ call: true });
|
||||
expect(manager.getEvaluation().result).toBeTruthy();
|
||||
stateManager.setState({ call: false });
|
||||
expect(manager.getEvaluation().result).toBeFalsy();
|
||||
stateManager.setState({
|
||||
microphone: createMicrophoneState({ connected: true, muted: false }),
|
||||
});
|
||||
});
|
||||
|
||||
it('call is false', () => {
|
||||
const stateManager = new ConditionStateManager();
|
||||
const manager = new ConditionsManager(
|
||||
[{ condition: 'call' as const, call: false }],
|
||||
stateManager,
|
||||
);
|
||||
|
||||
// With no state.call published, the bare condition matches `false`,
|
||||
// so `call: false` is satisfied initially.
|
||||
expect(manager.getEvaluation().result).toBeTruthy();
|
||||
stateManager.setState({ call: true });
|
||||
expect(manager.getEvaluation().result).toBeFalsy();
|
||||
stateManager.setState({ call: false });
|
||||
expect(manager.getEvaluation().result).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3846,5 +3846,210 @@ describe('should handle version specific upgrades', () => {
|
||||
postUpgradeChecks(config);
|
||||
});
|
||||
});
|
||||
|
||||
describe('microphone.connected → call condition', () => {
|
||||
it('rewrites connected:true to call:true in an automation', () => {
|
||||
const config = {
|
||||
type: 'custom:advanced-camera-card',
|
||||
cameras: [{ camera_entity: 'camera.office' }],
|
||||
automations: [
|
||||
{
|
||||
conditions: [{ condition: 'microphone', connected: true }],
|
||||
actions: [
|
||||
{
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'live',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(upgradeConfig(config)).toBeTruthy();
|
||||
expect(config.automations[0].conditions).toEqual([
|
||||
{ condition: 'call', call: true },
|
||||
]);
|
||||
postUpgradeChecks(config);
|
||||
});
|
||||
|
||||
it('rewrites connected:false to call:false', () => {
|
||||
const config = {
|
||||
type: 'custom:advanced-camera-card',
|
||||
cameras: [{ camera_entity: 'camera.office' }],
|
||||
automations: [
|
||||
{
|
||||
conditions: [{ condition: 'microphone', connected: false }],
|
||||
actions: [
|
||||
{
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'live',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(upgradeConfig(config)).toBeTruthy();
|
||||
expect(config.automations[0].conditions).toEqual([
|
||||
{ condition: 'call', call: false },
|
||||
]);
|
||||
postUpgradeChecks(config);
|
||||
});
|
||||
|
||||
it('leaves a microphone.muted only condition untouched', () => {
|
||||
const config = {
|
||||
type: 'custom:advanced-camera-card',
|
||||
cameras: [{ camera_entity: 'camera.office' }],
|
||||
automations: [
|
||||
{
|
||||
conditions: [{ condition: 'microphone', muted: true }],
|
||||
actions: [
|
||||
{
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'live',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(upgradeConfig(config)).toBeFalsy();
|
||||
expect(config.automations[0].conditions).toEqual([
|
||||
{ condition: 'microphone', muted: true },
|
||||
]);
|
||||
postUpgradeChecks(config);
|
||||
});
|
||||
|
||||
it('splits a condition with both connected and muted into an AND condition', () => {
|
||||
const config = {
|
||||
type: 'custom:advanced-camera-card',
|
||||
cameras: [{ camera_entity: 'camera.office' }],
|
||||
automations: [
|
||||
{
|
||||
conditions: [{ condition: 'microphone', connected: true, muted: false }],
|
||||
actions: [
|
||||
{
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'live',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(upgradeConfig(config)).toBeTruthy();
|
||||
expect(config.automations[0].conditions).toEqual([
|
||||
{
|
||||
condition: 'and',
|
||||
conditions: [
|
||||
{ condition: 'call', call: true },
|
||||
{ condition: 'microphone', muted: false },
|
||||
],
|
||||
},
|
||||
]);
|
||||
postUpgradeChecks(config);
|
||||
});
|
||||
|
||||
it('migrates a microphone.connected nested under or/and/not', () => {
|
||||
const config = {
|
||||
type: 'custom:advanced-camera-card',
|
||||
cameras: [{ camera_entity: 'camera.office' }],
|
||||
automations: [
|
||||
{
|
||||
conditions: [
|
||||
{
|
||||
condition: 'or',
|
||||
conditions: [
|
||||
{
|
||||
condition: 'and',
|
||||
conditions: [
|
||||
{
|
||||
condition: 'not',
|
||||
conditions: [{ condition: 'microphone', connected: true }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'live',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(upgradeConfig(config)).toBeTruthy();
|
||||
expect(config.automations[0].conditions).toEqual([
|
||||
{
|
||||
condition: 'or',
|
||||
conditions: [
|
||||
{
|
||||
condition: 'and',
|
||||
conditions: [
|
||||
{
|
||||
condition: 'not',
|
||||
conditions: [{ condition: 'call', call: true }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
postUpgradeChecks(config);
|
||||
});
|
||||
|
||||
it('migrates conditions on elements and overrides', () => {
|
||||
const config = {
|
||||
type: 'custom:advanced-camera-card',
|
||||
cameras: [{ camera_entity: 'camera.office' }],
|
||||
elements: [
|
||||
{
|
||||
type: 'custom:advanced-camera-card-conditional',
|
||||
conditions: [{ condition: 'microphone', connected: true }],
|
||||
elements: [{ type: 'icon', icon: 'mdi:phone' }],
|
||||
},
|
||||
],
|
||||
overrides: [
|
||||
{
|
||||
conditions: [{ condition: 'microphone', connected: false }],
|
||||
merge: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(upgradeConfig(config)).toBeTruthy();
|
||||
expect(config.elements[0].conditions).toEqual([
|
||||
{ condition: 'call', call: true },
|
||||
]);
|
||||
expect(config.overrides[0].conditions).toEqual([
|
||||
{ condition: 'call', call: false },
|
||||
]);
|
||||
postUpgradeChecks(config);
|
||||
});
|
||||
|
||||
it('is idempotent', () => {
|
||||
const config = {
|
||||
type: 'custom:advanced-camera-card',
|
||||
cameras: [{ camera_entity: 'camera.office' }],
|
||||
automations: [
|
||||
{
|
||||
conditions: [{ condition: 'microphone', connected: true }],
|
||||
actions: [
|
||||
{
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'live',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(upgradeConfig(config)).toBeTruthy();
|
||||
|
||||
// Running upgradeConfig again should not change anything.
|
||||
expect(upgradeConfig(config)).toBeFalsy();
|
||||
expect(config.automations[0].conditions).toEqual([
|
||||
{ condition: 'call', call: true },
|
||||
]);
|
||||
postUpgradeChecks(config);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,6 @@ it('should contain expected defaults', () => {
|
||||
'menu.buttons.media_player.enabled': false,
|
||||
'menu.buttons.mute.enabled': true,
|
||||
'menu.buttons.play.enabled': true,
|
||||
'menu.style': 'none',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -85,13 +85,18 @@ describe('config defaults', () => {
|
||||
zoomable: true,
|
||||
},
|
||||
live: {
|
||||
auto_mute: ['unselected', 'hidden', 'microphone'],
|
||||
auto_mute: ['unselected', 'hidden', 'microphone', 'call'],
|
||||
auto_pause: [],
|
||||
auto_play: ['selected', 'visible'],
|
||||
auto_unmute: ['microphone'],
|
||||
auto_unmute: ['microphone', 'call'],
|
||||
controls: {
|
||||
builtin: true,
|
||||
call: {
|
||||
button_size: 40,
|
||||
lock: true,
|
||||
},
|
||||
next_previous: {
|
||||
auto_hide: ['call', 'casting'],
|
||||
size: 48,
|
||||
style: 'chevrons',
|
||||
},
|
||||
@@ -133,10 +138,9 @@ describe('config defaults', () => {
|
||||
lazy_unload: [],
|
||||
microphone: {
|
||||
always_connected: false,
|
||||
auto_mute: [],
|
||||
auto_mute: ['call'],
|
||||
auto_unmute: [],
|
||||
disconnect_seconds: 90,
|
||||
lock: true,
|
||||
mute_after_microphone_mute_seconds: 60,
|
||||
},
|
||||
preload: false,
|
||||
@@ -168,6 +172,7 @@ describe('config defaults', () => {
|
||||
controls: {
|
||||
builtin: true,
|
||||
next_previous: {
|
||||
auto_hide: ['casting'],
|
||||
size: 48,
|
||||
style: 'thumbnails',
|
||||
},
|
||||
@@ -212,8 +217,16 @@ describe('config defaults', () => {
|
||||
},
|
||||
menu: {
|
||||
alignment: 'left',
|
||||
auto_hide: ['call', 'casting'],
|
||||
button_size: 40,
|
||||
buttons: {
|
||||
call: {
|
||||
alignment: 'matching',
|
||||
enabled: true,
|
||||
permanent: false,
|
||||
priority: 50,
|
||||
state_color: true,
|
||||
},
|
||||
camera_ui: {
|
||||
alignment: 'matching',
|
||||
enabled: true,
|
||||
@@ -426,6 +439,7 @@ describe('config defaults', () => {
|
||||
},
|
||||
},
|
||||
status_bar: {
|
||||
auto_hide: ['call', 'casting'],
|
||||
height: 40,
|
||||
items: {
|
||||
engine: {
|
||||
@@ -868,6 +882,7 @@ describe('config defaults', () => {
|
||||
it('should include all conditions', () => {
|
||||
const conditions = [
|
||||
{ condition: 'and', conditions: [{ condition: 'initialized' }] },
|
||||
{ condition: 'call', call: true },
|
||||
{ condition: 'camera', cameras: ['camera.office'] },
|
||||
{ condition: 'config', paths: ['menu.style'] },
|
||||
{ condition: 'display_mode', display_mode: 'single' },
|
||||
@@ -885,7 +900,7 @@ describe('config defaults', () => {
|
||||
state: 'down',
|
||||
},
|
||||
{ condition: 'media_loaded', media_loaded: true },
|
||||
{ condition: 'microphone', connected: true, muted: true },
|
||||
{ condition: 'microphone', muted: true },
|
||||
{ condition: 'not', conditions: [{ condition: 'initialized' }] },
|
||||
{
|
||||
condition: 'numeric_state',
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from '../src/camera-manager/types';
|
||||
import { ActionsManager } from '../src/card-controller/actions/actions-manager';
|
||||
import { AutomationsManager } from '../src/card-controller/automations-manager';
|
||||
import { CallManager } from '../src/card-controller/call/manager';
|
||||
import { CameraURLManager } from '../src/card-controller/camera-url-manager';
|
||||
import {
|
||||
CardElementManager,
|
||||
@@ -688,6 +689,7 @@ export const createCardAPI = (): CardController => {
|
||||
|
||||
api.getActionsManager.mockReturnValue(mock<ActionsManager>());
|
||||
api.getAutomationsManager.mockReturnValue(mock<AutomationsManager>());
|
||||
api.getCallManager.mockReturnValue(mock<CallManager>());
|
||||
api.getDefaultManager.mockReturnValue(mock<DefaultManager>());
|
||||
api.getCameraManager.mockReturnValue(mock<CameraManager>());
|
||||
api.getCameraURLManager.mockReturnValue(mock<CameraURLManager>());
|
||||
|
||||
@@ -3,6 +3,8 @@ import { mock } from 'vitest-mock-extended';
|
||||
import { INTERNAL_CALLBACK_ACTION } from '../../src/config/schema/actions/custom/internal.js';
|
||||
import { ActionConfig } from '../../src/config/schema/actions/types.js';
|
||||
import {
|
||||
createCallEndAction,
|
||||
createCallStartAction,
|
||||
createCameraAction,
|
||||
createDisplayModeAction,
|
||||
createEffectAction,
|
||||
@@ -358,6 +360,46 @@ describe('createSetReviewAction', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCallStartAction', () => {
|
||||
it('should create call start action without a camera', () => {
|
||||
expect(createCallStartAction()).toEqual({
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'call_start',
|
||||
});
|
||||
});
|
||||
|
||||
it('should create call start action with a camera, stream and cardID', () => {
|
||||
expect(
|
||||
createCallStartAction('camera.front', 'camera.front_doorbell', {
|
||||
cardID: 'card_id',
|
||||
}),
|
||||
).toEqual({
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'call_start',
|
||||
camera: 'camera.front',
|
||||
stream: 'camera.front_doorbell',
|
||||
card_id: 'card_id',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCallEndAction', () => {
|
||||
it('should create call end action', () => {
|
||||
expect(createCallEndAction()).toEqual({
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'call_end',
|
||||
});
|
||||
});
|
||||
|
||||
it('should create call end action with a cardID', () => {
|
||||
expect(createCallEndAction({ cardID: 'card_id' })).toEqual({
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'call_end',
|
||||
card_id: 'card_id',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createNotificationAction', () => {
|
||||
it('should create notification action', () => {
|
||||
const notification = { body: { text: 'test' } };
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { hasPopOutAnimationEnded } from '../../src/utils/animation.js';
|
||||
|
||||
describe('hasPopOutAnimationEnded', () => {
|
||||
const element = mock<EventTarget>();
|
||||
|
||||
it('should return true when a pop-out animation ends on the bound element', () => {
|
||||
expect(
|
||||
hasPopOutAnimationEnded({
|
||||
target: element,
|
||||
currentTarget: element,
|
||||
animationName: 'pop-out',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when the event bubbled from a descendant', () => {
|
||||
expect(
|
||||
hasPopOutAnimationEnded({
|
||||
target: mock<EventTarget>(),
|
||||
currentTarget: element,
|
||||
animationName: 'pop-out',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for a different animation', () => {
|
||||
expect(
|
||||
hasPopOutAnimationEnded({
|
||||
target: element,
|
||||
currentTarget: element,
|
||||
animationName: 'pop-in',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,131 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getStreamCameraID,
|
||||
hasSubstream,
|
||||
removeSubstream,
|
||||
setSubstream,
|
||||
} from '../../src/utils/substream';
|
||||
import { View } from '../../src/view/view';
|
||||
import { createView } from '../test-utils';
|
||||
|
||||
describe('hasSubstream/getStreamCameraID', () => {
|
||||
it('should detect substream', () => {
|
||||
const view = createView({
|
||||
camera: 'camera',
|
||||
context: {
|
||||
live: {
|
||||
overrides: new Map([['camera', 'camera2']]),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(hasSubstream(view)).toBeTruthy();
|
||||
expect(getStreamCameraID(view)).toBe('camera2');
|
||||
});
|
||||
it('should not detect substream when absent', () => {
|
||||
const view = createView({
|
||||
camera: 'camera',
|
||||
});
|
||||
expect(hasSubstream(view)).toBeFalsy();
|
||||
expect(getStreamCameraID(view)).toBe('camera');
|
||||
});
|
||||
it('should not detect substream when main stream', () => {
|
||||
const view = createView({
|
||||
camera: 'camera',
|
||||
context: {
|
||||
live: {
|
||||
overrides: new Map([['camera', 'camera']]),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(hasSubstream(view)).toBeFalsy();
|
||||
expect(getStreamCameraID(view)).toBe('camera');
|
||||
});
|
||||
describe('should respect cameraID override', () => {
|
||||
it('should respect cameraID override when present in overrides', () => {
|
||||
const view = createView({
|
||||
camera: 'camera',
|
||||
context: {
|
||||
live: {
|
||||
overrides: new Map([
|
||||
['camera', 'camera2'],
|
||||
['camera3', 'camera4'],
|
||||
]),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(hasSubstream(view)).toBeTruthy();
|
||||
expect(getStreamCameraID(view, 'camera3')).toBe('camera4');
|
||||
});
|
||||
|
||||
it('should respect cameraID override when not present in overrides', () => {
|
||||
const view = createView();
|
||||
expect(hasSubstream(view)).toBeFalsy();
|
||||
expect(getStreamCameraID(view, 'camera3')).toBe('camera3');
|
||||
});
|
||||
});
|
||||
|
||||
it('should correctly handle null cameras', () => {
|
||||
expect(getStreamCameraID(createView({ camera: null }))).toBeNull();
|
||||
expect(hasSubstream(createView({ camera: null }))).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSubstream', () => {
|
||||
it('should set substream', () => {
|
||||
const view = createView({ camera: 'camera1' });
|
||||
setSubstream(view, 'substream1');
|
||||
expect(view.context?.live?.overrides?.get('camera1')).toBe('substream1');
|
||||
});
|
||||
|
||||
it('should return null without a camera', () => {
|
||||
const view = createView({ camera: null });
|
||||
setSubstream(view, 'foo');
|
||||
expect(view.context).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeSubstream', () => {
|
||||
it('should remove substream that exists', () => {
|
||||
const view = new View({
|
||||
view: 'live',
|
||||
camera: 'camera',
|
||||
context: {
|
||||
live: {
|
||||
overrides: new Map([['camera', 'camera2']]),
|
||||
},
|
||||
},
|
||||
});
|
||||
removeSubstream(view);
|
||||
expect(view.context).toEqual({
|
||||
live: {
|
||||
overrides: new Map(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not remove substream that does not exists', () => {
|
||||
const view = new View({
|
||||
view: 'live',
|
||||
camera: 'camera-has-no-overrides',
|
||||
context: {
|
||||
live: {
|
||||
overrides: new Map([['camera', 'camera2']]),
|
||||
},
|
||||
},
|
||||
});
|
||||
removeSubstream(view);
|
||||
expect(view.context).toEqual({
|
||||
live: {
|
||||
overrides: new Map([['camera', 'camera2']]),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not remove substream without camera', () => {
|
||||
const view = createView({
|
||||
camera: null,
|
||||
});
|
||||
removeSubstream(view);
|
||||
expect(view.context).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getStreamCameraID, hasSubstream } from '../../src/view/substream';
|
||||
import { createView } from '../test-utils';
|
||||
|
||||
describe('getStreamCameraID / hasSubstream', () => {
|
||||
it('should report a substream override', () => {
|
||||
const view = createView({
|
||||
camera: 'camera',
|
||||
context: {
|
||||
live: {
|
||||
overrides: new Map([['camera', 'camera2']]),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(hasSubstream(view)).toBeTruthy();
|
||||
expect(getStreamCameraID(view)).toBe('camera2');
|
||||
});
|
||||
|
||||
it('should not report a substream when absent', () => {
|
||||
const view = createView({ camera: 'camera' });
|
||||
expect(hasSubstream(view)).toBeFalsy();
|
||||
expect(getStreamCameraID(view)).toBe('camera');
|
||||
});
|
||||
|
||||
it('should not report a substream when the override points at the main stream', () => {
|
||||
const view = createView({
|
||||
camera: 'camera',
|
||||
context: {
|
||||
live: {
|
||||
overrides: new Map([['camera', 'camera']]),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(hasSubstream(view)).toBeFalsy();
|
||||
expect(getStreamCameraID(view)).toBe('camera');
|
||||
});
|
||||
|
||||
describe('should respect explicit cameraID argument', () => {
|
||||
it('when the cameraID has an override', () => {
|
||||
const view = createView({
|
||||
camera: 'camera',
|
||||
context: {
|
||||
live: {
|
||||
overrides: new Map([
|
||||
['camera', 'camera2'],
|
||||
['camera3', 'camera4'],
|
||||
]),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(hasSubstream(view)).toBeTruthy();
|
||||
expect(getStreamCameraID(view, 'camera3')).toBe('camera4');
|
||||
});
|
||||
|
||||
it('when the cameraID has no override', () => {
|
||||
const view = createView();
|
||||
expect(hasSubstream(view)).toBeFalsy();
|
||||
expect(getStreamCameraID(view, 'camera3')).toBe('camera3');
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle a null camera', () => {
|
||||
expect(getStreamCameraID(createView({ camera: null }))).toBeNull();
|
||||
expect(hasSubstream(createView({ camera: null }))).toBeFalsy();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user