feat: Explicit answer/reject for inbound calls (#2504)
This commit is contained in:
committed by
dermotduffy
parent
b9f09b7c4e
commit
f397596ed1
@@ -0,0 +1,18 @@
|
||||
import { expect, it } from 'vitest';
|
||||
import { CallAnswerAction } from '../../../../src/card-controller/actions/actions/call-answer';
|
||||
import { createCardAPI } from '../../../test-utils';
|
||||
|
||||
it('should handle call_answer action', async () => {
|
||||
const api = createCardAPI();
|
||||
const action = new CallAnswerAction(
|
||||
{},
|
||||
{
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'call_answer',
|
||||
},
|
||||
);
|
||||
|
||||
await action.execute(api);
|
||||
|
||||
expect(api.getCallManager().answer).toBeCalled();
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { CallAnswerAction } from '../../../src/card-controller/actions/actions/call-answer';
|
||||
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';
|
||||
@@ -88,6 +89,7 @@ describe('ActionFactory', () => {
|
||||
|
||||
describe('custom actions', () => {
|
||||
it.each([
|
||||
[{ advanced_camera_card_action: 'call_answer' as const }, CallAnswerAction],
|
||||
[{ 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],
|
||||
|
||||
@@ -104,12 +104,12 @@ describe('isActive', () => {
|
||||
|
||||
expect(manager.isActive()).toBe(true);
|
||||
// The call runs on the parent camera's own stream, so callCameraID is
|
||||
// absent.
|
||||
// absent. Outbound calls are answered by construction.
|
||||
expect(manager.getCall()).toEqual({
|
||||
cameraID: 'camera.office',
|
||||
previousView: expect.any(View),
|
||||
inbound: false,
|
||||
answered: false,
|
||||
answered: true,
|
||||
});
|
||||
expect(manager.getCall()?.previousView?.view).toBe('live');
|
||||
});
|
||||
@@ -569,20 +569,7 @@ describe('inbound supersede policy', () => {
|
||||
manager.initialize();
|
||||
expect(await manager.start({ inbound: true })).toBe(true);
|
||||
|
||||
// Answer the call.
|
||||
getConditionStateListener(api)({
|
||||
old: {
|
||||
camera: 'camera.office',
|
||||
view: 'live',
|
||||
microphone: { connected: true, muted: true, forbidden: false },
|
||||
},
|
||||
change: { microphone: { connected: true, muted: false, forbidden: false } },
|
||||
new: {
|
||||
camera: 'camera.office',
|
||||
view: 'live',
|
||||
microphone: { connected: true, muted: false, forbidden: false },
|
||||
},
|
||||
});
|
||||
expect(manager.answer()).toBe(true);
|
||||
expect(manager.getCall()?.answered).toBe(true);
|
||||
|
||||
expect(await manager.start({ cameraID: 'camera.garage', inbound: true })).toBe(
|
||||
@@ -637,19 +624,7 @@ describe('inbound supersede policy', () => {
|
||||
const manager = new CallManager(api);
|
||||
manager.initialize();
|
||||
expect(await manager.start({ inbound: true })).toBe(true);
|
||||
getConditionStateListener(api)({
|
||||
old: {
|
||||
camera: 'camera.office',
|
||||
view: 'live',
|
||||
microphone: { connected: true, muted: true, forbidden: false },
|
||||
},
|
||||
change: { microphone: { connected: true, muted: false, forbidden: false } },
|
||||
new: {
|
||||
camera: 'camera.office',
|
||||
view: 'live',
|
||||
microphone: { connected: true, muted: false, forbidden: false },
|
||||
},
|
||||
});
|
||||
expect(manager.answer()).toBe(true);
|
||||
|
||||
expect(await manager.start({ cameraID: 'camera.garage' })).toBe(true);
|
||||
|
||||
@@ -862,19 +837,7 @@ describe('endIf', () => {
|
||||
const manager = new CallManager(api);
|
||||
manager.initialize();
|
||||
expect(await manager.start({ inbound: true })).toBe(true);
|
||||
getConditionStateListener(api)({
|
||||
old: {
|
||||
camera: 'camera.office',
|
||||
view: 'live',
|
||||
microphone: { connected: true, muted: true, forbidden: false },
|
||||
},
|
||||
change: { microphone: { connected: true, muted: false, forbidden: false } },
|
||||
new: {
|
||||
camera: 'camera.office',
|
||||
view: 'live',
|
||||
microphone: { connected: true, muted: false, forbidden: false },
|
||||
},
|
||||
});
|
||||
expect(manager.answer()).toBe(true);
|
||||
expect(manager.getCall()?.answered).toBe(true);
|
||||
|
||||
expect(manager.endIf({ answered: false })).toBe(false);
|
||||
@@ -1242,15 +1205,12 @@ describe('inbound option', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Answered tracking: the first muted->unmuted microphone transition during an
|
||||
// inbound call flips `answered` to true (once; later mute/unmute cycles do not
|
||||
// flip it back) and stops the ringtone / cancels the unanswered timer.
|
||||
describe('answered tracking', () => {
|
||||
describe('answer', () => {
|
||||
const inboundConfig = {
|
||||
live: { controls: { call: { ringtone: { type: 'chime' as const } } } },
|
||||
};
|
||||
|
||||
it('should not be answered immediately after an inbound start', async () => {
|
||||
it('should default to unanswered for an inbound start', async () => {
|
||||
const api = createAPI({
|
||||
view: createView({ camera: 'camera.office' }),
|
||||
config: inboundConfig,
|
||||
@@ -1263,7 +1223,40 @@ describe('answered tracking', () => {
|
||||
expect(manager.getCall()?.answered).toBe(false);
|
||||
});
|
||||
|
||||
it('should mark answered on a muted->unmuted microphone transition', async () => {
|
||||
it('should default to answered for an outbound start', async () => {
|
||||
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
|
||||
const manager = new CallManager(api);
|
||||
manager.initialize();
|
||||
|
||||
expect(await manager.start()).toBe(true);
|
||||
|
||||
// Outbound calls are answered by construction -- the user initiated them.
|
||||
expect(manager.getCall()?.answered).toBe(true);
|
||||
});
|
||||
|
||||
it('should default to unanswered for inbound even if the mic is already un-muted', async () => {
|
||||
const api = createAPI({
|
||||
view: createView({ camera: 'camera.office' }),
|
||||
microphoneMuted: false,
|
||||
config: inboundConfig,
|
||||
});
|
||||
const manager = new CallManager(api);
|
||||
manager.initialize();
|
||||
|
||||
expect(await manager.start({ inbound: true })).toBe(true);
|
||||
|
||||
expect(manager.getCall()?.answered).toBe(false);
|
||||
expect(getRingtone().start).toBeCalled();
|
||||
});
|
||||
|
||||
it('should no-op when no call is active', () => {
|
||||
const api = createAPI();
|
||||
const manager = new CallManager(api);
|
||||
|
||||
expect(manager.answer()).toBe(false);
|
||||
});
|
||||
|
||||
it('should no-op when the call is already answered', async () => {
|
||||
const api = createAPI({
|
||||
view: createView({ camera: 'camera.office' }),
|
||||
config: inboundConfig,
|
||||
@@ -1271,25 +1264,100 @@ describe('answered tracking', () => {
|
||||
const manager = new CallManager(api);
|
||||
manager.initialize();
|
||||
expect(await manager.start({ inbound: true })).toBe(true);
|
||||
expect(manager.answer()).toBe(true);
|
||||
vi.mocked(getRingtone().stop).mockClear();
|
||||
vi.mocked(api.getCardElementManager().update).mockClear();
|
||||
|
||||
getConditionStateListener(api)({
|
||||
old: {
|
||||
camera: 'camera.office',
|
||||
view: 'live',
|
||||
microphone: { connected: true, muted: true, forbidden: false },
|
||||
},
|
||||
change: { microphone: { connected: true, muted: false, forbidden: false } },
|
||||
new: {
|
||||
camera: 'camera.office',
|
||||
view: 'live',
|
||||
microphone: { connected: true, muted: false, forbidden: false },
|
||||
},
|
||||
expect(manager.answer()).toBe(false);
|
||||
|
||||
expect(getRingtone().stop).not.toBeCalled();
|
||||
expect(api.getCardElementManager().update).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should mark answered and replace the session immutably', async () => {
|
||||
const api = createAPI({
|
||||
view: createView({ camera: 'camera.office' }),
|
||||
config: inboundConfig,
|
||||
});
|
||||
const manager = new CallManager(api);
|
||||
manager.initialize();
|
||||
expect(await manager.start({ inbound: true })).toBe(true);
|
||||
const before = manager.getCall();
|
||||
|
||||
expect(manager.answer()).toBe(true);
|
||||
|
||||
const after = manager.getCall();
|
||||
expect(after?.answered).toBe(true);
|
||||
// New object identity so Lit consumers re-render on the prop change.
|
||||
expect(after).not.toBe(before);
|
||||
expect(after?.cameraID).toBe(before?.cameraID);
|
||||
expect(after?.inbound).toBe(before?.inbound);
|
||||
});
|
||||
|
||||
it('should stop the ringtone and unanswered timer on answer', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const api = createAPI({
|
||||
view: createView({ camera: 'camera.office' }),
|
||||
config: {
|
||||
live: {
|
||||
controls: {
|
||||
call: {
|
||||
ringtone: { type: 'chime' as const },
|
||||
unanswered_timeout_seconds: 60,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const manager = new CallManager(api);
|
||||
manager.initialize();
|
||||
expect(await manager.start({ inbound: true })).toBe(true);
|
||||
vi.mocked(getRingtone().stop).mockClear();
|
||||
|
||||
expect(manager.answer()).toBe(true);
|
||||
|
||||
expect(getRingtone().stop).toBeCalled();
|
||||
|
||||
// Timer was armed and should now be cancelled: advancing past the
|
||||
// timeout must not end the (now-answered) call.
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(manager.isActive()).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('should force a card re-render on answer', async () => {
|
||||
const api = createAPI({
|
||||
view: createView({ camera: 'camera.office' }),
|
||||
config: inboundConfig,
|
||||
});
|
||||
const manager = new CallManager(api);
|
||||
manager.initialize();
|
||||
expect(await manager.start({ inbound: true })).toBe(true);
|
||||
vi.mocked(api.getCardElementManager().update).mockClear();
|
||||
|
||||
expect(manager.answer()).toBe(true);
|
||||
|
||||
// The card subtree depends on `getCall().answered`, which the manager
|
||||
// mutates outside the view-manager epoch -- so `update()` is what drives
|
||||
// the re-render through to the call-controls overlay.
|
||||
expect(api.getCardElementManager().update).toBeCalled();
|
||||
});
|
||||
|
||||
it('should not mark non-inbound (outbound) calls via answer (already answered)', async () => {
|
||||
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
|
||||
const manager = new CallManager(api);
|
||||
manager.initialize();
|
||||
expect(await manager.start()).toBe(true);
|
||||
|
||||
// Outbound starts answered, so `answer()` is a no-op.
|
||||
expect(manager.answer()).toBe(false);
|
||||
expect(manager.getCall()?.answered).toBe(true);
|
||||
});
|
||||
|
||||
it('should not flip answered back when the user re-mutes after answering', async () => {
|
||||
it('should not flip answered on mic mute/unmute transitions', async () => {
|
||||
const api = createAPI({
|
||||
view: createView({ camera: 'camera.office' }),
|
||||
config: inboundConfig,
|
||||
@@ -1299,6 +1367,7 @@ describe('answered tracking', () => {
|
||||
expect(await manager.start({ inbound: true })).toBe(true);
|
||||
const listener = getConditionStateListener(api);
|
||||
|
||||
// Mic mute -> unmute during the pre-answer ring: must NOT auto-answer.
|
||||
listener({
|
||||
old: {
|
||||
camera: 'camera.office',
|
||||
@@ -1312,124 +1381,7 @@ describe('answered tracking', () => {
|
||||
microphone: { connected: true, muted: false, forbidden: false },
|
||||
},
|
||||
});
|
||||
expect(manager.getCall()?.answered).toBe(true);
|
||||
|
||||
listener({
|
||||
old: {
|
||||
camera: 'camera.office',
|
||||
view: 'live',
|
||||
microphone: { connected: true, muted: false, forbidden: false },
|
||||
},
|
||||
change: { microphone: { connected: true, muted: true, forbidden: false } },
|
||||
new: {
|
||||
camera: 'camera.office',
|
||||
view: 'live',
|
||||
microphone: { connected: true, muted: true, forbidden: false },
|
||||
},
|
||||
});
|
||||
|
||||
expect(manager.getCall()?.answered).toBe(true);
|
||||
});
|
||||
|
||||
it('should not mark non-inbound calls answered on un-mute', async () => {
|
||||
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
|
||||
const manager = new CallManager(api);
|
||||
manager.initialize();
|
||||
expect(await manager.start()).toBe(true);
|
||||
|
||||
getConditionStateListener(api)({
|
||||
old: {
|
||||
camera: 'camera.office',
|
||||
view: 'live',
|
||||
microphone: { connected: true, muted: true, forbidden: false },
|
||||
},
|
||||
change: { microphone: { connected: true, muted: false, forbidden: false } },
|
||||
new: {
|
||||
camera: 'camera.office',
|
||||
view: 'live',
|
||||
microphone: { connected: true, muted: false, forbidden: false },
|
||||
},
|
||||
});
|
||||
|
||||
expect(manager.getCall()?.answered).toBe(false);
|
||||
});
|
||||
|
||||
it('should stop the ringtone on answer', async () => {
|
||||
const api = createAPI({
|
||||
view: createView({ camera: 'camera.office' }),
|
||||
config: inboundConfig,
|
||||
});
|
||||
const manager = new CallManager(api);
|
||||
manager.initialize();
|
||||
expect(await manager.start({ inbound: true })).toBe(true);
|
||||
vi.mocked(getRingtone().stop).mockClear();
|
||||
|
||||
getConditionStateListener(api)({
|
||||
old: {
|
||||
camera: 'camera.office',
|
||||
view: 'live',
|
||||
microphone: { connected: true, muted: true, forbidden: false },
|
||||
},
|
||||
change: { microphone: { connected: true, muted: false, forbidden: false } },
|
||||
new: {
|
||||
camera: 'camera.office',
|
||||
view: 'live',
|
||||
microphone: { connected: true, muted: false, forbidden: false },
|
||||
},
|
||||
});
|
||||
|
||||
expect(getRingtone().stop).toBeCalled();
|
||||
});
|
||||
|
||||
it('should treat an inbound call as already-answered when the mic is already un-muted', async () => {
|
||||
const api = createAPI({
|
||||
view: createView({ camera: 'camera.office' }),
|
||||
microphoneMuted: false,
|
||||
config: inboundConfig,
|
||||
});
|
||||
const manager = new CallManager(api);
|
||||
manager.initialize();
|
||||
|
||||
expect(await manager.start({ inbound: true })).toBe(true);
|
||||
|
||||
expect(manager.getCall()?.answered).toBe(true);
|
||||
expect(getRingtone().start).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should not arm the unanswered timer when the mic is already un-muted', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const api = createAPI({
|
||||
view: createView({ camera: 'camera.office' }),
|
||||
microphoneMuted: false,
|
||||
config: {
|
||||
live: { controls: { call: { unanswered_timeout_seconds: 60 } } },
|
||||
},
|
||||
});
|
||||
const manager = new CallManager(api);
|
||||
manager.initialize();
|
||||
expect(await manager.start({ inbound: true })).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(60_000);
|
||||
|
||||
expect(manager.isActive()).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('should not mark a manual (non-inbound) call as answered even if the mic is un-muted', async () => {
|
||||
const api = createAPI({
|
||||
view: createView({ camera: 'camera.office' }),
|
||||
microphoneMuted: false,
|
||||
});
|
||||
const manager = new CallManager(api);
|
||||
manager.initialize();
|
||||
|
||||
expect(await manager.start()).toBe(true);
|
||||
|
||||
// `answered` only carries meaning alongside `inbound`, so for manual
|
||||
// calls it stays at its default to make the intent explicit.
|
||||
expect(manager.getCall()?.answered).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1572,19 +1524,7 @@ describe('unanswered timeout', () => {
|
||||
manager.initialize();
|
||||
expect(await manager.start({ inbound: true })).toBe(true);
|
||||
|
||||
getConditionStateListener(api)({
|
||||
old: {
|
||||
camera: 'camera.office',
|
||||
view: 'live',
|
||||
microphone: { connected: true, muted: true, forbidden: false },
|
||||
},
|
||||
change: { microphone: { connected: true, muted: false, forbidden: false } },
|
||||
new: {
|
||||
camera: 'camera.office',
|
||||
view: 'live',
|
||||
microphone: { connected: true, muted: false, forbidden: false },
|
||||
},
|
||||
});
|
||||
expect(manager.answer()).toBe(true);
|
||||
vi.advanceTimersByTime(60_000);
|
||||
|
||||
expect(manager.isActive()).toBe(true);
|
||||
|
||||
@@ -294,8 +294,8 @@ describe('MicrophoneActionsController', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('on call state change', () => {
|
||||
it('should unmute on call start when call is a configured unmute condition', () => {
|
||||
describe('on call answered state change', () => {
|
||||
it('should unmute on call answer when call is a configured unmute condition', () => {
|
||||
const microphoneManager = createMicrophoneManager();
|
||||
const controller = new MicrophoneActionsController();
|
||||
controller.setOptions({
|
||||
@@ -303,13 +303,13 @@ describe('MicrophoneActionsController', () => {
|
||||
autoUnmuteConditions: ['call' as const],
|
||||
});
|
||||
|
||||
controller.setCallActive(false);
|
||||
controller.setCallActive(true);
|
||||
controller.setCallAnswered(false);
|
||||
controller.setCallAnswered(true);
|
||||
|
||||
expect(microphoneManager.unmute).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should unmute when the call is already active on first notification', () => {
|
||||
it('should unmute when the call is already answered on first notification', () => {
|
||||
const microphoneManager = createMicrophoneManager();
|
||||
const controller = new MicrophoneActionsController();
|
||||
controller.setOptions({
|
||||
@@ -317,10 +317,10 @@ describe('MicrophoneActionsController', () => {
|
||||
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);
|
||||
// `setCallAnswered(true)` is the first call-state signal, with no
|
||||
// preceding `false` -- as for a live view that mounts while a call is
|
||||
// already answered. The initial state must not be swallowed as a baseline.
|
||||
controller.setCallAnswered(true);
|
||||
|
||||
expect(microphoneManager.unmute).toBeCalledTimes(1);
|
||||
});
|
||||
@@ -333,8 +333,8 @@ describe('MicrophoneActionsController', () => {
|
||||
autoMuteConditions: ['call' as const],
|
||||
});
|
||||
|
||||
controller.setCallActive(true);
|
||||
controller.setCallActive(false);
|
||||
controller.setCallAnswered(true);
|
||||
controller.setCallAnswered(false);
|
||||
|
||||
expect(microphoneManager.mute).toBeCalledTimes(1);
|
||||
});
|
||||
@@ -348,13 +348,13 @@ describe('MicrophoneActionsController', () => {
|
||||
autoUnmuteConditions: ['call' as const],
|
||||
});
|
||||
|
||||
controller.setCallActive(false);
|
||||
controller.setCallAnswered(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', () => {
|
||||
it('should not act on call answer when call is not a configured condition', () => {
|
||||
const microphoneManager = createMicrophoneManager();
|
||||
const controller = new MicrophoneActionsController();
|
||||
controller.setOptions({
|
||||
@@ -362,8 +362,8 @@ describe('MicrophoneActionsController', () => {
|
||||
autoUnmuteConditions: [],
|
||||
});
|
||||
|
||||
controller.setCallActive(false);
|
||||
controller.setCallActive(true);
|
||||
controller.setCallAnswered(false);
|
||||
controller.setCallAnswered(true);
|
||||
|
||||
expect(microphoneManager.unmute).not.toBeCalled();
|
||||
});
|
||||
|
||||
@@ -712,22 +712,22 @@ describe('MediaActionsController', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('should take action on call state changes', () => {
|
||||
it('should unmute the target on call start', async () => {
|
||||
describe('should take action on call answered state changes', () => {
|
||||
it('should unmute the target on call answer', async () => {
|
||||
const controller = new MediaActionsController();
|
||||
|
||||
controller.setOptions({
|
||||
autoUnmuteConditions: ['call' as const],
|
||||
playerSelector: 'video',
|
||||
});
|
||||
controller.setCallActive(false);
|
||||
controller.setCallAnswered(false);
|
||||
|
||||
const children = createPlayerSlideNodes();
|
||||
controller.setRoot(createParent({ children: children }));
|
||||
|
||||
await controller.setTarget(0, true);
|
||||
|
||||
controller.setCallActive(true);
|
||||
controller.setCallAnswered(true);
|
||||
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute,
|
||||
@@ -741,14 +741,14 @@ describe('MediaActionsController', () => {
|
||||
autoMuteConditions: ['call' as const],
|
||||
playerSelector: 'video',
|
||||
});
|
||||
controller.setCallActive(true);
|
||||
controller.setCallAnswered(true);
|
||||
|
||||
const children = createPlayerSlideNodes();
|
||||
controller.setRoot(createParent({ children: children }));
|
||||
|
||||
await controller.setTarget(0, true);
|
||||
|
||||
controller.setCallActive(false);
|
||||
controller.setCallAnswered(false);
|
||||
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.mute,
|
||||
@@ -768,7 +768,7 @@ describe('MediaActionsController', () => {
|
||||
controller.setRoot(createParent({ children: children }));
|
||||
await controller.setTarget(0, true);
|
||||
|
||||
controller.setCallActive(false);
|
||||
controller.setCallAnswered(false);
|
||||
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.mute,
|
||||
@@ -782,20 +782,20 @@ describe('MediaActionsController', () => {
|
||||
autoUnmuteConditions: [],
|
||||
playerSelector: 'video',
|
||||
});
|
||||
controller.setCallActive(false);
|
||||
controller.setCallAnswered(false);
|
||||
|
||||
const children = createPlayerSlideNodes();
|
||||
controller.setRoot(createParent({ children: children }));
|
||||
await controller.setTarget(0, true);
|
||||
|
||||
controller.setCallActive(true);
|
||||
controller.setCallAnswered(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 () => {
|
||||
it('should apply the call-answer unmute when the target arrives after the call', async () => {
|
||||
const controller = new MediaActionsController();
|
||||
|
||||
controller.setOptions({
|
||||
@@ -806,9 +806,9 @@ describe('MediaActionsController', () => {
|
||||
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);
|
||||
// The call is answered before any target is selected: with no target,
|
||||
// the unmute cannot be applied yet.
|
||||
controller.setCallAnswered(true);
|
||||
await flushPromises();
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute,
|
||||
@@ -821,7 +821,7 @@ describe('MediaActionsController', () => {
|
||||
).toBeCalled();
|
||||
});
|
||||
|
||||
it('should unmute when the call is already active on the first call-state signal', async () => {
|
||||
it('should unmute when the call is already answered on the first call-state signal', async () => {
|
||||
const controller = new MediaActionsController();
|
||||
|
||||
controller.setOptions({
|
||||
@@ -833,24 +833,25 @@ describe('MediaActionsController', () => {
|
||||
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);
|
||||
// `setCallAnswered(true)` is the first call-state signal, with no
|
||||
// preceding `false` -- as for a carousel that loads while an answered
|
||||
// call is already active. The initial state must not be swallowed as
|
||||
// a baseline.
|
||||
controller.setCallAnswered(true);
|
||||
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute,
|
||||
).toBeCalled();
|
||||
});
|
||||
|
||||
it('should defer the call-start unmute until the media player is ready', async () => {
|
||||
it('should defer the call-answer unmute until the media player is ready', async () => {
|
||||
const controller = new MediaActionsController();
|
||||
|
||||
controller.setOptions({
|
||||
autoUnmuteConditions: ['call' as const],
|
||||
playerSelector: 'video',
|
||||
});
|
||||
controller.setCallActive(false);
|
||||
controller.setCallAnswered(false);
|
||||
|
||||
// A player whose media player controller is not ready on first request.
|
||||
const mediaPlayerController = mock<MediaPlayerController>();
|
||||
@@ -865,8 +866,8 @@ describe('MediaActionsController', () => {
|
||||
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);
|
||||
// The call is answered while the player is still not ready: no unmute yet.
|
||||
controller.setCallAnswered(true);
|
||||
await flushPromises();
|
||||
expect(mediaPlayerController.unmute).not.toBeCalled();
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 {
|
||||
createCallAnswerAction,
|
||||
createCallEndAction,
|
||||
createCallStartAction,
|
||||
createCameraAction,
|
||||
@@ -430,6 +431,23 @@ describe('createSubstreamOffAction', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCallAnswerAction', () => {
|
||||
it('should create call answer action', () => {
|
||||
expect(createCallAnswerAction()).toEqual({
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'call_answer',
|
||||
});
|
||||
});
|
||||
|
||||
it('should create call answer action with a cardID', () => {
|
||||
expect(createCallAnswerAction({ cardID: 'card_id' })).toEqual({
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'call_answer',
|
||||
card_id: 'card_id',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCallEndAction', () => {
|
||||
it('should create call end action', () => {
|
||||
expect(createCallEndAction()).toEqual({
|
||||
|
||||
Reference in New Issue
Block a user