diff --git a/docs/images/call-sequence.svg b/docs/images/call-sequence.svg index 43d20788..92fd8ef2 100644 --- a/docs/images/call-sequence.svg +++ b/docs/images/call-sequence.svg @@ -1 +1 @@ -UserCallMicrophoneGuestUserUserCallCallMicrophoneMicrophoneGuestGuestopt[live.microphone.always_connectedis enabled]Microphone connection attempted (muted)Connected at card load, before (and independent of) any callMicrophone access grantedStart a callVia Call menu button orcall_startaction.Needs a camera that supports 2-way audioopt[live.microphone.always_connectednot enabled]Microphone connection attempted (muted)Stays muted by default (live.microphone.auto_unmute)Microphone access grantedThe microphone must connect for the call to proceed —if access is denied or unsupported, the call does not startYou hear the callerInbound audio unmutes if configured (live.auto_unmute)Menu, status bar & next/previous hide (auto_hide)Camera navigation is locked (live.controls.call.lock)Unmute to talkList 'call' inlive.microphone.auto_unmuteto unmute automaticallyloop[Until the call ends]TalkingRespondingEnd the callVia Hang-up button,call_endaction, or — whenlive.controls.call.lockis disabled — navigating awayInbound audio mutes (live.auto_mute)Microphone mutes (live.microphone.auto_mute)Disconnects afterlive.microphone.disconnect_secondsunlesslive.microphone.always_connected \ No newline at end of file +UserCallMicrophoneGuestUserUserCallCallMicrophoneMicrophoneGuestGuestopt[live.microphone.always_connectedis enabled]Microphone connection attempted (muted)Connected at card load, before (and independent of) any callMicrophone access grantedalt[Outbound call]Start a callVia Call menu button orcall_startaction.Needs a camera that supports 2-way audio[Inbound call]RingsVia aview.triggers.actions.trigger: calltrigger, e.g. a doorbell.The card rings (live.controls.call.ringtone) until answered,rejected, orlive.controls.call.unanswered_timeout_secondselapsesAnswer the callopt[live.microphone.always_connectednot enabled]Microphone connection attempted (muted)Stays muted by default (live.microphone.auto_unmute)Microphone access grantedThe microphone is connected when the call is answered -- immediatelyfor an outbound call. An inbound call rings without it, so a doorbellcan ring even where microphone access needs a tap to be grantedYou hear the callerInbound audio unmutes if configured (live.auto_unmute)Menu, status bar & next/previous hide (auto_hide)Camera navigation is locked (live.controls.call.lock)Unmute to talkList 'call' inlive.microphone.auto_unmuteto unmute automaticallyloop[Until the call ends]TalkingRespondingEnd the callVia Hang-up button,call_endaction, or -- whenlive.controls.call.lockis disabled -- navigating awayInbound audio mutes (live.auto_mute)Microphone mutes (live.microphone.auto_mute)Disconnects afterlive.microphone.disconnect_secondsunlesslive.microphone.always_connected \ No newline at end of file diff --git a/docs/uml/call-sequence.puml b/docs/uml/call-sequence.puml index 804cf9d7..477b1175 100644 --- a/docs/uml/call-sequence.puml +++ b/docs/uml/call-sequence.puml @@ -12,11 +12,21 @@ opt **""live.microphone.always_connected""** is enabled Mic --> User : Microphone access granted end -User -> Card : Start a call -note over User, Card - Via Call menu button or **""call_start""** action. - Needs a camera that supports 2-way audio -end note +alt Outbound call + User -> Card : Start a call + note over User, Card + Via Call menu button or **""call_start""** action. + Needs a camera that supports 2-way audio + end note +else Inbound call + Guest -> Card : Rings + note over Card + Via a **""view.triggers.actions.trigger: call""** trigger, e.g. a doorbell. + The card rings (**""live.controls.call.ringtone""**) until answered, + rejected, or **""live.controls.call.unanswered_timeout_seconds""** elapses + end note + User -> Card : Answer the call +end opt **""live.microphone.always_connected""** not enabled Card -> Mic : Microphone connection attempted (muted) @@ -25,8 +35,9 @@ opt **""live.microphone.always_connected""** not enabled end note over User, Mic - The microphone must connect for the call to proceed -- - if access is denied or unsupported, the call does not start + The microphone is connected when the call is answered -- immediately + for an outbound call. An inbound call rings without it, so a doorbell + can ring even where microphone access needs a tap to be granted end note Card --> User : You hear the caller diff --git a/src/card-controller/actions/actions/call-answer.ts b/src/card-controller/actions/actions/call-answer.ts index 572b584a..69d18b46 100644 --- a/src/card-controller/actions/actions/call-answer.ts +++ b/src/card-controller/actions/actions/call-answer.ts @@ -6,6 +6,6 @@ export class CallAnswerAction extends AdvancedCameraCardAction { await super.execute(api); - api.getCallManager().answer(); + await api.getCallManager().answer(); } } diff --git a/src/card-controller/call/manager.ts b/src/card-controller/call/manager.ts index aeca9198..220a5b18 100644 --- a/src/card-controller/call/manager.ts +++ b/src/card-controller/call/manager.ts @@ -16,13 +16,13 @@ export class CallManager { private _ringtone = new Ringtone(); private _unansweredTimer = new Timer(); - // Identifies the current init/uninit cycle so an in-flight `start()` - // resuming from its microphone-connect await can detect that its CallManager - // was torn down -- or torn down and re-initialized -- while it was suspended - // and bail before installing a session or ringtone. Without this guard the - // resumed tail leaks audio onto the shared lock from an instance the user - // can no longer see or control, and may install state into a fresh - // lifecycle from a request that belongs to the previous one. + // Identifies the current init/uninit cycle so an in-flight `start()` or + // `answer()` resuming from its microphone-connect await can detect that its + // CallManager was torn down -- or torn down and re-initialized -- while it + // was suspended, and bail before installing a session or a ringtone. Without + // this guard the resumed tail leaks audio onto the shared lock from an + // instance the user can no longer see or control, and may install state into + // a fresh lifecycle from a request that belongs to the previous one. private _initGeneration = new Generation(); constructor(api: CardCallAPI) { @@ -104,19 +104,12 @@ export class CallManager { return false; } - const initGeneration = this._initGeneration.current(); - const microphoneConnected = await this._connectMicrophone(); - // If the init/uninit lifecycle advanced while the microphone connect was - // in flight, this request belongs to a previous lifecycle -- the view we - // captured may be stale, the triggering context is gone, and there is no - // clean teardown path for state we'd install here. Bail before touching - // `_call`, the ringtone lock, or surfacing a notification onto a torn-down - // NotificationManager. - if (!this._initGeneration.isCurrent(initGeneration)) { - return false; - } - if (!microphoneConnected) { - this._notifyError('error.call_microphone_forbidden', inbound); + // An inbound call has no use for the microphone until it is answered, and + // it arrives without the user gesture that browsers may require before + // granting microphone access -- so connecting here would let a refusal stop + // the call from ever ringing. The connect is deferred to `answer()`. An + // outbound call is answered by construction and needs it immediately. + if (!inbound && !(await this._connectMicrophone())) { return false; } @@ -208,20 +201,39 @@ export class CallManager { return this._end(true); } - // Marks an inbound ringing call as answered: stops the ringtone, cancels - // the unanswered timer, and lets the normal call controls take over. - // No-op (returns false) if there is no call or it's already answered; - // rejecting a ringing call uses `end()` (same teardown). - public answer(): boolean { - if (!this._call || this._call.answered) { + // Marks an inbound ringing call as answered: connects the microphone, stops + // the ringtone, cancels the unanswered timer, and lets the normal call + // controls take over. Returns true iff the call was answered. No-op (returns + // false) if there is no call or it's already answered; rejecting a ringing + // call uses `end()` (same teardown). + public async answer(): Promise { + const call = this._call; + if (!call || call.answered) { return false; } + + // The user has acknowledged the ring, so silence it before the microphone + // connect, which may put a browser permission prompt on screen. this._ringtone.stop(); + + // An inbound call rings without the microphone, so this is where it is + // connected -- under the user gesture that answering provides. The call is + // left ringing on failure so it can be answered again. + if (!(await this._connectMicrophone())) { + return false; + } + + // The call may have ended, or been superseded by another, while the + // microphone connect was in flight -- there is then nothing left to answer. + if (this._call !== call) { + return false; + } + this._unansweredTimer.stop(); // Replace (don't mutate) so Lit identity checks downstream pick up the // change. The `update()` below forces card.ts to re-render and re-read // `getCall()`, propagating the new session to the carousel. - this._call = { ...this._call, answered: true }; + this._call = { ...call, answered: true }; this._api.getConditionStateManager().setState({ call: 'answered' }); this._api.getCardElementManager().update(); return true; @@ -377,7 +389,11 @@ export class CallManager { return false; } - if (microphoneManager.isForbidden()) { + // An earlier microphone denial does not stop an inbound call: the ring + // needs no microphone, and `answer()` retries the connect -- succeeding + // there clears the denial, and failing there reports it. An outbound call + // needs the microphone immediately, so a known denial ends it here. + if (!inbound && microphoneManager.isForbidden()) { this._notifyError('error.call_microphone_forbidden', inbound); return false; } @@ -385,17 +401,37 @@ export class CallManager { return true; } + // Connects the microphone for a call. Returns true iff it is connected and + // this request still belongs to the current init/uninit lifecycle. A connect + // failure is surfaced as a notification. private async _connectMicrophone(): Promise { const microphoneManager = this._api.getMicrophoneManager(); if (microphoneManager.isConnected()) { return true; } + + const initGeneration = this._initGeneration.current(); + let connected = false; try { await microphoneManager.connect(); - return true; + connected = true; } catch { + // Reported below, once this request is known to still be the current one. + } + + // If the init/uninit lifecycle advanced while the connect was in flight, + // this request belongs to a previous lifecycle: the state it would act on + // is gone, and a notification would be surfaced onto a torn-down + // NotificationManager. + if (!this._initGeneration.isCurrent(initGeneration)) { return false; } + + if (!connected) { + this._notifyError('error.call_microphone_forbidden', false); + return false; + } + return true; } private _hasCallCapability(cameraID: string): boolean { diff --git a/tests/card-controller/call/manager.test.ts b/tests/card-controller/call/manager.test.ts index d672625c..d5505947 100644 --- a/tests/card-controller/call/manager.test.ts +++ b/tests/card-controller/call/manager.test.ts @@ -544,6 +544,53 @@ describe('start', () => { expect(api.getViewManager().setViewByParameters).not.toBeCalled(); expect(api.getNotificationManager().setNotification).toBeCalled(); }); + + it('should ring an inbound call without connecting the microphone', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + microphoneConnected: false, + config: { live: { controls: { call: { ringtone: { type: 'chime' } } } } }, + }); + vi.mocked(api.getMicrophoneManager().connect).mockRejectedValue(new Error()); + const manager = new CallManager(api); + + // The microphone is only needed once the call is answered, so a connect + // that would fail must not stop the call from ringing. + expect(await manager.start({ inbound: true })).toBe(true); + + expect(api.getMicrophoneManager().connect).not.toBeCalled(); + expect(getRingtone().start).toBeCalled(); + }); + + it('should ring an inbound call when the microphone is forbidden', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + microphoneForbidden: true, + config: { live: { controls: { call: { ringtone: { type: 'chime' } } } } }, + }); + const manager = new CallManager(api); + + // An earlier denial must not silence a doorbell: the ring needs no + // microphone, and answering retries the connect. + expect(await manager.start({ inbound: true })).toBe(true); + + expect(getRingtone().start).toBeCalled(); + }); + + it('should abort an inbound call when the microphone is unsupported', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + microphoneSupported: false, + config: { live: { controls: { call: { ringtone: { type: 'chime' } } } } }, + }); + const manager = new CallManager(api); + + // Unlike a denial, a browser without microphone support cannot start + // supporting it while the page is loaded, so the call can never be taken. + expect(await manager.start({ inbound: true })).toBe(false); + + expect(getRingtone().start).not.toBeCalled(); + }); }); // An inbound start request must not displace a call the user cares about @@ -571,7 +618,7 @@ describe('inbound supersede policy', () => { manager.initialize(); expect(await manager.start({ inbound: true })).toBe(true); - expect(manager.answer()).toBe(true); + expect(await manager.answer()).toBe(true); expect(manager.getCall()?.answered).toBe(true); expect(await manager.start({ cameraID: 'camera.garage', inbound: true })).toBe( @@ -626,7 +673,7 @@ describe('inbound supersede policy', () => { const manager = new CallManager(api); manager.initialize(); expect(await manager.start({ inbound: true })).toBe(true); - expect(manager.answer()).toBe(true); + expect(await manager.answer()).toBe(true); expect(await manager.start({ cameraID: 'camera.garage' })).toBe(true); @@ -838,7 +885,7 @@ describe('endIf', () => { const manager = new CallManager(api); manager.initialize(); expect(await manager.start({ inbound: true })).toBe(true); - expect(manager.answer()).toBe(true); + expect(await manager.answer()).toBe(true); expect(manager.getCall()?.answered).toBe(true); expect(manager.endIf({ answered: false })).toBe(false); @@ -1109,11 +1156,11 @@ describe('initialize / uninitialize', () => { }); }); -// `inbound: true` suppresses each of the preflight/validation notifications. -// Every path that would call `_notifyError` is exercised under both the -// non-inbound case (notification surfaced) and the inbound case (silent). The -// non-inbound coverage already lives in the `start` describe above; here we -// assert the inbound paths stay silent. +// `inbound: true` suppresses each of the preflight/validation notifications an +// inbound start can still reach. The non-inbound coverage (notification +// surfaced) already lives in the `start` describe above; here we assert the +// inbound paths stay silent. The forbidden-microphone check is deliberately +// absent -- an inbound start no longer reaches it at all. describe('inbound option', () => { it('should suppress notification when the camera lacks live capability', async () => { const api = createAPI({ @@ -1142,29 +1189,6 @@ describe('inbound option', () => { expect(api.getNotificationManager().setNotification).not.toBeCalled(); }); - it('should suppress notification when the microphone is forbidden', async () => { - const api = createAPI({ - view: createView({ camera: 'camera.office' }), - microphoneForbidden: true, - }); - - expect(await new CallManager(api).start({ inbound: true })).toBe(false); - - expect(api.getNotificationManager().setNotification).not.toBeCalled(); - }); - - it('should suppress notification when microphone connect rejects', async () => { - const api = createAPI({ - view: createView({ camera: 'camera.office' }), - microphoneConnected: false, - }); - vi.mocked(api.getMicrophoneManager().connect).mockRejectedValue(new Error('denied')); - - expect(await new CallManager(api).start({ inbound: true })).toBe(false); - - expect(api.getNotificationManager().setNotification).not.toBeCalled(); - }); - it('should suppress notification when an explicit stream is not 2-way audio', async () => { const api = createAPI({ view: createView({ camera: 'camera.office' }) }); @@ -1249,11 +1273,11 @@ describe('answer', () => { expect(getRingtone().start).toBeCalled(); }); - it('should no-op when no call is active', () => { + it('should no-op when no call is active', async () => { const api = createAPI(); const manager = new CallManager(api); - expect(manager.answer()).toBe(false); + expect(await manager.answer()).toBe(false); }); it('should no-op when the call is already answered', async () => { @@ -1264,11 +1288,11 @@ describe('answer', () => { const manager = new CallManager(api); manager.initialize(); expect(await manager.start({ inbound: true })).toBe(true); - expect(manager.answer()).toBe(true); + expect(await manager.answer()).toBe(true); vi.mocked(getRingtone().stop).mockClear(); vi.mocked(api.getCardElementManager().update).mockClear(); - expect(manager.answer()).toBe(false); + expect(await manager.answer()).toBe(false); expect(getRingtone().stop).not.toBeCalled(); expect(api.getCardElementManager().update).not.toBeCalled(); @@ -1284,7 +1308,7 @@ describe('answer', () => { expect(await manager.start({ inbound: true })).toBe(true); const before = manager.getCall(); - expect(manager.answer()).toBe(true); + expect(await manager.answer()).toBe(true); const after = manager.getCall(); expect(after?.answered).toBe(true); @@ -1315,7 +1339,7 @@ describe('answer', () => { expect(await manager.start({ inbound: true })).toBe(true); vi.mocked(getRingtone().stop).mockClear(); - expect(manager.answer()).toBe(true); + expect(await manager.answer()).toBe(true); expect(getRingtone().stop).toBeCalled(); @@ -1328,6 +1352,68 @@ describe('answer', () => { } }); + it('should connect the microphone on answer', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + microphoneConnected: false, + config: inboundConfig, + }); + vi.mocked(api.getMicrophoneManager().connect).mockResolvedValue(); + const manager = new CallManager(api); + manager.initialize(); + expect(await manager.start({ inbound: true })).toBe(true); + + expect(await manager.answer()).toBe(true); + + expect(api.getMicrophoneManager().connect).toBeCalled(); + }); + + it('should silence the ringtone before the microphone connect', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + microphoneConnected: false, + config: inboundConfig, + }); + let resolveConnect: () => void = () => {}; + vi.mocked(api.getMicrophoneManager().connect).mockReturnValue( + new Promise((resolve) => { + resolveConnect = resolve; + }), + ); + const manager = new CallManager(api); + manager.initialize(); + expect(await manager.start({ inbound: true })).toBe(true); + vi.mocked(getRingtone().stop).mockClear(); + + const answerPromise = manager.answer(); + + // The user has acknowledged the ring, so it must stop without waiting for a + // microphone permission prompt to be dealt with. + expect(getRingtone().stop).toBeCalled(); + + resolveConnect(); + expect(await answerPromise).toBe(true); + }); + + it('should leave the call ringing when the microphone connect fails', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + microphoneConnected: false, + config: inboundConfig, + }); + vi.mocked(api.getMicrophoneManager().connect).mockRejectedValue(new Error('denied')); + const manager = new CallManager(api); + manager.initialize(); + expect(await manager.start({ inbound: true })).toBe(true); + + expect(await manager.answer()).toBe(false); + + // Answering is an explicit user gesture, so the failure is surfaced and the + // call remains answerable. + expect(api.getNotificationManager().setNotification).toBeCalled(); + expect(manager.getCall()?.answered).toBe(false); + }); + it('should force a card re-render on answer', async () => { const api = createAPI({ view: createView({ camera: 'camera.office' }), @@ -1338,7 +1424,7 @@ describe('answer', () => { expect(await manager.start({ inbound: true })).toBe(true); vi.mocked(api.getCardElementManager().update).mockClear(); - expect(manager.answer()).toBe(true); + expect(await 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 @@ -1353,7 +1439,7 @@ describe('answer', () => { expect(await manager.start()).toBe(true); // Outbound starts answered, so `answer()` is a no-op. - expect(manager.answer()).toBe(false); + expect(await manager.answer()).toBe(false); expect(manager.getCall()?.answered).toBe(true); }); @@ -1593,7 +1679,7 @@ describe('unanswered timeout', () => { manager.initialize(); expect(await manager.start({ inbound: true })).toBe(true); - expect(manager.answer()).toBe(true); + expect(await manager.answer()).toBe(true); vi.advanceTimersByTime(60_000); expect(manager.isActive()).toBe(true); @@ -1680,11 +1766,10 @@ describe('session end during setState', () => { }); describe('state changes during in-flight start', () => { - it('should not install a session or ring when uninitialized mid-await', async () => { + it('should not install a session when uninitialized mid-await', async () => { const api = createAPI({ view: createView({ camera: 'camera.office' }), microphoneConnected: false, - config: { live: { controls: { call: { ringtone: { type: 'chime' } } } } }, }); let resolveConnect: () => void = () => {}; vi.mocked(api.getMicrophoneManager().connect).mockReturnValue( @@ -1695,20 +1780,19 @@ describe('state changes during in-flight start', () => { const manager = new CallManager(api); manager.initialize(); - const startPromise = manager.start({ inbound: true }); + const startPromise = manager.start(); manager.uninitialize(); resolveConnect(); expect(await startPromise).toBe(false); - expect(getRingtone().start).not.toBeCalled(); expect(manager.isActive()).toBe(false); + expect(api.getViewManager().setViewByParameters).not.toBeCalled(); }); - it('should not install a session or ring when uninitialized and re-initialized mid-await', async () => { + it('should not install a session when uninitialized and re-initialized mid-await', async () => { const api = createAPI({ view: createView({ camera: 'camera.office' }), microphoneConnected: false, - config: { live: { controls: { call: { ringtone: { type: 'chime' } } } } }, }); let resolveConnect: () => void = () => {}; vi.mocked(api.getMicrophoneManager().connect).mockReturnValue( @@ -1719,14 +1803,14 @@ describe('state changes during in-flight start', () => { const manager = new CallManager(api); manager.initialize(); - const startPromise = manager.start({ inbound: true }); + const startPromise = manager.start(); manager.uninitialize(); manager.initialize(); resolveConnect(); expect(await startPromise).toBe(false); - expect(getRingtone().start).not.toBeCalled(); expect(manager.isActive()).toBe(false); + expect(api.getViewManager().setViewByParameters).not.toBeCalled(); }); it('should supersede a session installed by another start mid-await', async () => { @@ -1792,6 +1876,84 @@ describe('state changes during in-flight start', () => { }); }); +// Answering an inbound call is where its microphone is connected, so the same +// mid-await state changes that `start()` guards against apply here too. +describe('state changes during in-flight answer', () => { + const inboundConfig = { + live: { controls: { call: { ringtone: { type: 'chime' as const } } } }, + }; + + // Starts a ringing inbound call whose subsequent `answer()` will suspend on + // the microphone connect until the returned settler is invoked. + const createRingingCall = async ( + api: CardController, + ): Promise<{ + manager: CallManager; + resolveConnect: () => void; + rejectConnect: (reason: unknown) => void; + }> => { + const manager = new CallManager(api); + manager.initialize(); + expect(await manager.start({ inbound: true })).toBe(true); + + vi.mocked(api.getMicrophoneManager().isConnected).mockReturnValue(false); + let resolveConnect: () => void = () => {}; + let rejectConnect: (reason: unknown) => void = () => {}; + vi.mocked(api.getMicrophoneManager().connect).mockReturnValue( + new Promise((resolve, reject) => { + resolveConnect = resolve; + rejectConnect = reject; + }), + ); + return { manager, resolveConnect, rejectConnect }; + }; + + it('should not answer when uninitialized mid-await', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + config: inboundConfig, + }); + const { manager, resolveConnect } = await createRingingCall(api); + + const answerPromise = manager.answer(); + manager.uninitialize(); + resolveConnect(); + + expect(await answerPromise).toBe(false); + expect(manager.isActive()).toBe(false); + }); + + it('should suppress the microphone-failure notification when uninitialized mid-await', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + config: inboundConfig, + }); + const { manager, rejectConnect } = await createRingingCall(api); + + const answerPromise = manager.answer(); + manager.uninitialize(); + rejectConnect(new Error('denied')); + + expect(await answerPromise).toBe(false); + expect(api.getNotificationManager().setNotification).not.toBeCalled(); + }); + + it('should not answer a session that ended mid-await', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + config: inboundConfig, + }); + const { manager, resolveConnect } = await createRingingCall(api); + + const answerPromise = manager.answer(); + expect(manager.end()).toBe(true); + resolveConnect(); + + expect(await answerPromise).toBe(false); + expect(manager.isActive()).toBe(false); + }); +}); + // The phase the manager publishes is what automations actually react to, so // these drive a real ConditionStateManager and a real CallTrigger and assert // the transitions an automation would fire on, rather than that `setState` was @@ -1877,7 +2039,7 @@ describe('published phase transitions in condition state', () => { expect(await manager.start({ inbound: true })).toBe(true); expect(answered).not.toHaveBeenCalled(); - expect(manager.answer()).toBe(true); + expect(await manager.answer()).toBe(true); expect(answered).toHaveBeenCalledTimes(1); }); @@ -1924,7 +2086,7 @@ describe('published phase transitions in condition state', () => { }); expect(await manager.start({ inbound: true })).toBe(true); - expect(manager.answer()).toBe(true); + expect(await manager.answer()).toBe(true); expect(manager.end()).toBe(true); expect(hungUp).toHaveBeenCalledTimes(1);