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>();
|
||||
|
||||
Reference in New Issue
Block a user