- Closes: #2609
This commit is contained in:
1 parent
2a84f199de
commit
8f953547b1
5 files changed
+295
-86
No files matched your search
@@ -1 +1 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" contentStyleType="text/css" data-diagram-type="SEQUENCE" height="998px" preserveAspectRatio="none" style="width:951px;height:998px;background:#FFFFFF;" version="1.1" viewBox="0 0 951 998" width="951px" zoomAndPan="magnify"><?plantuml 1.2026.3?><defs/><g><rect fill="none" height="110.5313" style="stroke:#000000;stroke-width:1.5;" width="888.5503" x="34" y="98.2969"/><rect fill="none" height="110.5313" style="stroke:#000000;stroke-width:1.5;" width="878.7109" x="34" y="302.2266"/><rect fill="none" height="75.3984" style="stroke:#000000;stroke-width:1.5;" width="784.6914" x="34" y="635.8203"/><g class="participant-lifeline" data-entity-uid="part1" data-qualified-name="User" data-source-line="3" id="part1-lifeline"><g><title>User</title><rect fill="#000000" fill-opacity="0.00000" height="834.8516" width="8" x="58.9551" y="81.2969"/><line style="stroke:#181818;stroke-width:0.5;stroke-dasharray:5,5;" x1="62" x2="62" y1="81.2969" y2="916.1484"/></g></g><g class="participant-lifeline" data-entity-uid="part2" data-qualified-name="Card" data-source-line="4" id="part2-lifeline"><g><title>Call</title><rect fill="#000000" fill-opacity="0.00000" height="834.8516" width="8" x="344.8721" y="81.2969"/><line style="stroke:#181818;stroke-width:0.5;stroke-dasharray:5,5;" x1="348.8052" x2="348.8052" y1="81.2969" y2="916.1484"/></g></g><g class="participant-lifeline" data-entity-uid="part3" data-qualified-name="Mic" data-source-line="5" id="part3-lifeline"><g><title>Microphone</title><rect fill="#000000" fill-opacity="0.00000" height="834.8516" width="8" x="699.6738" y="81.2969"/><line style="stroke:#181818;stroke-width:0.5;stroke-dasharray:5,5;" x1="702.7744" x2="702.7744" y1="81.2969" y2="916.1484"/></g></g><g class="participant-lifeline" data-entity-uid="part4" data-qualified-name="Guest" data-source-line="6" id="part4-lifeline"><g><title>Guest</title><rect fill="#000000" fill-opacity="0.00000" height="834.8516" width="8" x="781.1323" y="81.2969"/><line style="stroke:#181818;stroke-width:0.5;stroke-dasharray:5,5;" x1="784.5732" x2="784.5732" y1="81.2969" y2="916.1484"/></g></g><g class="participant participant-head" data-entity-uid="part1" data-qualified-name="User" data-source-line="3" id="part1-head"><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="31.9102" x="44" y="77.9951">User</text><ellipse cx="62.9551" cy="13.5" fill="#E2E2F0" rx="8" ry="8" style="stroke:#181818;stroke-width:0.5;"/><path d="M62.9551,21.5 L62.9551,48.5 M49.9551,29.5 L75.9551,29.5 M62.9551,48.5 L49.9551,63.5 M62.9551,48.5 L75.9551,63.5" fill="none" style="stroke:#181818;stroke-width:0.5;"/></g><g class="participant participant-tail" data-entity-uid="part1" data-qualified-name="User" data-source-line="3" id="part1-tail"><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="31.9102" x="44" y="928.1436">User</text><ellipse cx="62.9551" cy="939.9453" fill="#E2E2F0" rx="8" ry="8" style="stroke:#181818;stroke-width:0.5;"/><path d="M62.9551,947.9453 L62.9551,974.9453 M49.9551,955.9453 L75.9551,955.9453 M62.9551,974.9453 L49.9551,989.9453 M62.9551,974.9453 L75.9551,989.9453" fill="none" style="stroke:#181818;stroke-width:0.5;"/></g><g class="participant participant-head" data-entity-uid="part2" data-qualified-name="Card" data-source-line="4" id="part2-head"><rect fill="#E2E2F0" height="30.2969" rx="2.5" ry="2.5" style="stroke:#181818;stroke-width:0.5;" width="40.1338" x="328.8052" y="50"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="26.1338" x="335.8052" y="69.9951">Call</text></g><g class="participant participant-tail" data-entity-uid="part2" data-qualified-name="Card" data-source-line="4" id="part2-tail"><rect fill="#E2E2F0" height="30.2969" rx="2.5" ry="2.5" style="stroke:#181818;stroke-width:0.5;" width="40.1338" x="328.8052" y="915.1484"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="26.1338" x="335.8052" y="935.1436">Call</text></g><g class="participant participant-head" data-entity-uid="part3" data-qualified-name="Mic" data-source-line="5" id="part3-head"><rect fill="#E2E2F0" height="30.2969" rx="2.5" ry="2.5" style="stroke:#181818;stroke-width:0.5;" width="95.7988" x="655.7744" y="50"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="81.7988" x="662.7744" y="69.9951">Microphone</text></g><g class="participant participant-tail" data-entity-uid="part3" data-qualified-name="Mic" data-source-line="5" id="part3-tail"><rect fill="#E2E2F0" height="30.2969" rx="2.5" ry="2.5" style="stroke:#181818;stroke-width:0.5;" width="95.7988" x="655.7744" y="915.1484"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="81.7988" x="662.7744" y="935.1436">Microphone</text></g><g class="participant participant-head" Line truncated
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" contentStyleType="text/css" data-diagram-type="SEQUENCE" height="1183px" preserveAspectRatio="none" style="width:949px;height:1183px;background:#FFFFFF;" version="1.1" viewBox="0 0 949 1183" width="949px" zoomAndPan="magnify"><?plantuml 1.2026.3?><defs/><g><rect fill="none" height="110.5313" style="stroke:#000000;stroke-width:1.5;" width="888.5503" x="32" y="98.2969"/><rect fill="none" height="235" style="stroke:#000000;stroke-width:1.5;" width="784.6914" x="32" y="222.8281"/><rect fill="none" height="110.5313" style="stroke:#000000;stroke-width:1.5;" width="878.7109" x="32" y="471.8281"/><rect fill="none" height="75.3984" style="stroke:#000000;stroke-width:1.5;" width="784.6914" x="32" y="820.5547"/><g class="participant-lifeline" data-entity-uid="part1" data-qualified-name="User" data-source-line="3" id="part1-lifeline"><g><title>User</title><rect fill="#000000" fill-opacity="0.00000" height="1019.5859" width="8" x="56.9551" y="81.2969"/><line style="stroke:#181818;stroke-width:0.5;stroke-dasharray:5,5;" x1="60" x2="60" y1="81.2969" y2="1100.8828"/></g></g><g class="participant-lifeline" data-entity-uid="part2" data-qualified-name="Card" data-source-line="4" id="part2-lifeline"><g><title>Call</title><rect fill="#000000" fill-opacity="0.00000" height="1019.5859" width="8" x="342.8721" y="81.2969"/><line style="stroke:#181818;stroke-width:0.5;stroke-dasharray:5,5;" x1="346.8052" x2="346.8052" y1="81.2969" y2="1100.8828"/></g></g><g class="participant-lifeline" data-entity-uid="part3" data-qualified-name="Mic" data-source-line="5" id="part3-lifeline"><g><title>Microphone</title><rect fill="#000000" fill-opacity="0.00000" height="1019.5859" width="8" x="697.6738" y="81.2969"/><line style="stroke:#181818;stroke-width:0.5;stroke-dasharray:5,5;" x1="700.7744" x2="700.7744" y1="81.2969" y2="1100.8828"/></g></g><g class="participant-lifeline" data-entity-uid="part4" data-qualified-name="Guest" data-source-line="6" id="part4-lifeline"><g><title>Guest</title><rect fill="#000000" fill-opacity="0.00000" height="1019.5859" width="8" x="779.1323" y="81.2969"/><line style="stroke:#181818;stroke-width:0.5;stroke-dasharray:5,5;" x1="782.5732" x2="782.5732" y1="81.2969" y2="1100.8828"/></g></g><g class="participant participant-head" data-entity-uid="part1" data-qualified-name="User" data-source-line="3" id="part1-head"><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="31.9102" x="42" y="77.9951">User</text><ellipse cx="60.9551" cy="13.5" fill="#E2E2F0" rx="8" ry="8" style="stroke:#181818;stroke-width:0.5;"/><path d="M60.9551,21.5 L60.9551,48.5 M47.9551,29.5 L73.9551,29.5 M60.9551,48.5 L47.9551,63.5 M60.9551,48.5 L73.9551,63.5" fill="none" style="stroke:#181818;stroke-width:0.5;"/></g><g class="participant participant-tail" data-entity-uid="part1" data-qualified-name="User" data-source-line="3" id="part1-tail"><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="31.9102" x="42" y="1112.8779">User</text><ellipse cx="60.9551" cy="1124.6797" fill="#E2E2F0" rx="8" ry="8" style="stroke:#181818;stroke-width:0.5;"/><path d="M60.9551,1132.6797 L60.9551,1159.6797 M47.9551,1140.6797 L73.9551,1140.6797 M60.9551,1159.6797 L47.9551,1174.6797 M60.9551,1159.6797 L73.9551,1174.6797" fill="none" style="stroke:#181818;stroke-width:0.5;"/></g><g class="participant participant-head" data-entity-uid="part2" data-qualified-name="Card" data-source-line="4" id="part2-head"><rect fill="#E2E2F0" height="30.2969" rx="2.5" ry="2.5" style="stroke:#181818;stroke-width:0.5;" width="40.1338" x="326.8052" y="50"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="26.1338" x="333.8052" y="69.9951">Call</text></g><g class="participant participant-tail" data-entity-uid="part2" data-qualified-name="Card" data-source-line="4" id="part2-tail"><rect fill="#E2E2F0" height="30.2969" rx="2.5" ry="2.5" style="stroke:#181818;stroke-width:0.5;" width="40.1338" x="326.8052" y="1099.8828"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="26.1338" x="333.8052" y="1119.8779">Call</text></g><g class="participant participant-head" data-entity-uid="part3" data-qualified-name="Mic" data-source-line="5" id="part3-head"><rect fill="#E2E2F0" height="30.2969" rx="2.5" ry="2.5" style="stroke:#181818;stroke-width:0.5;" width="95.7988" x="653.7744" y="50"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="81.7988" x="660.7744" y="69.9951">Microphone</text></g><g class="participant participant-tail" data-entity-uid="part3" data-qualified-name="Mic" data-source-line="5" id="part3-tail"><rect fill="#E2E2F0" height="30.2969" rx="2.5" ry="2.5" style="stroke:#181818;stroke-width:0.5;" width="95.7988" x="653.7744" y="1099.8828"/><text fill="#000000" font-family="sans-serif" font-size="1Line truncated
|
||||||
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 27 KiB |
@@ -12,11 +12,21 @@ opt <color:#43a047>**""live.microphone.always_connected""**</color> is enabled
|
|||||||
Mic --> User : Microphone access granted
|
Mic --> User : Microphone access granted
|
||||||
end
|
end
|
||||||
|
|
||||||
User -> Card : Start a call
|
alt Outbound call
|
||||||
note over User, Card
|
User -> Card : Start a call
|
||||||
|
note over User, Card
|
||||||
Via Call menu button or <color:#43a047>**""call_start""**</color> action.
|
Via Call menu button or <color:#43a047>**""call_start""**</color> action.
|
||||||
Needs a camera that supports 2-way audio
|
Needs a camera that supports 2-way audio
|
||||||
end note
|
end note
|
||||||
|
else Inbound call
|
||||||
|
Guest -> Card : Rings
|
||||||
|
note over Card
|
||||||
|
Via a <color:#43a047>**""view.triggers.actions.trigger: call""**</color> trigger, e.g. a doorbell.
|
||||||
|
The card rings (<color:#43a047>**""live.controls.call.ringtone""**</color>) until answered,
|
||||||
|
rejected, or <color:#43a047>**""live.controls.call.unanswered_timeout_seconds""**</color> elapses
|
||||||
|
end note
|
||||||
|
User -> Card : Answer the call
|
||||||
|
end
|
||||||
|
|
||||||
opt <color:#43a047>**""live.microphone.always_connected""**</color> not enabled
|
opt <color:#43a047>**""live.microphone.always_connected""**</color> not enabled
|
||||||
Card -> Mic : Microphone connection attempted (muted)
|
Card -> Mic : Microphone connection attempted (muted)
|
||||||
@@ -25,8 +35,9 @@ opt <color:#43a047>**""live.microphone.always_connected""**</color> not enabled
|
|||||||
end
|
end
|
||||||
|
|
||||||
note over User, Mic
|
note over User, Mic
|
||||||
The microphone must connect for the call to proceed --
|
The microphone is connected when the call is answered -- immediately
|
||||||
if access is denied or unsupported, the call does not start
|
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
|
end note
|
||||||
|
|
||||||
Card --> User : You hear the caller
|
Card --> User : You hear the caller
|
||||||
|
|||||||
@@ -6,6 +6,6 @@ export class CallAnswerAction extends AdvancedCameraCardAction<CallAnswerActionC
|
|||||||
public async execute(api: CardActionsAPI): Promise<void> {
|
public async execute(api: CardActionsAPI): Promise<void> {
|
||||||
await super.execute(api);
|
await super.execute(api);
|
||||||
|
|
||||||
api.getCallManager().answer();
|
await api.getCallManager().answer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16,13 +16,13 @@ export class CallManager {
|
|||||||
private _ringtone = new Ringtone();
|
private _ringtone = new Ringtone();
|
||||||
private _unansweredTimer = new Timer();
|
private _unansweredTimer = new Timer();
|
||||||
|
|
||||||
// Identifies the current init/uninit cycle so an in-flight `start()`
|
// Identifies the current init/uninit cycle so an in-flight `start()` or
|
||||||
// resuming from its microphone-connect await can detect that its CallManager
|
// `answer()` resuming from its microphone-connect await can detect that its
|
||||||
// was torn down -- or torn down and re-initialized -- while it was suspended
|
// CallManager was torn down -- or torn down and re-initialized -- while it
|
||||||
// and bail before installing a session or ringtone. Without this guard the
|
// was suspended, and bail before installing a session or a ringtone. Without
|
||||||
// resumed tail leaks audio onto the shared lock from an instance the user
|
// this guard the resumed tail leaks audio onto the shared lock from an
|
||||||
// can no longer see or control, and may install state into a fresh
|
// instance the user can no longer see or control, and may install state into
|
||||||
// lifecycle from a request that belongs to the previous one.
|
// a fresh lifecycle from a request that belongs to the previous one.
|
||||||
private _initGeneration = new Generation();
|
private _initGeneration = new Generation();
|
||||||
|
|
||||||
constructor(api: CardCallAPI) {
|
constructor(api: CardCallAPI) {
|
||||||
@@ -104,19 +104,12 @@ export class CallManager {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initGeneration = this._initGeneration.current();
|
// An inbound call has no use for the microphone until it is answered, and
|
||||||
const microphoneConnected = await this._connectMicrophone();
|
// it arrives without the user gesture that browsers may require before
|
||||||
// If the init/uninit lifecycle advanced while the microphone connect was
|
// granting microphone access -- so connecting here would let a refusal stop
|
||||||
// in flight, this request belongs to a previous lifecycle -- the view we
|
// the call from ever ringing. The connect is deferred to `answer()`. An
|
||||||
// captured may be stale, the triggering context is gone, and there is no
|
// outbound call is answered by construction and needs it immediately.
|
||||||
// clean teardown path for state we'd install here. Bail before touching
|
if (!inbound && !(await this._connectMicrophone())) {
|
||||||
// `_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);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,20 +201,39 @@ export class CallManager {
|
|||||||
return this._end(true);
|
return this._end(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Marks an inbound ringing call as answered: stops the ringtone, cancels
|
// Marks an inbound ringing call as answered: connects the microphone, stops
|
||||||
// the unanswered timer, and lets the normal call controls take over.
|
// the ringtone, cancels the unanswered timer, and lets the normal call
|
||||||
// No-op (returns false) if there is no call or it's already answered;
|
// controls take over. Returns true iff the call was answered. No-op (returns
|
||||||
// rejecting a ringing call uses `end()` (same teardown).
|
// false) if there is no call or it's already answered; rejecting a ringing
|
||||||
public answer(): boolean {
|
// call uses `end()` (same teardown).
|
||||||
if (!this._call || this._call.answered) {
|
public async answer(): Promise<boolean> {
|
||||||
|
const call = this._call;
|
||||||
|
if (!call || call.answered) {
|
||||||
return false;
|
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();
|
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();
|
this._unansweredTimer.stop();
|
||||||
// Replace (don't mutate) so Lit identity checks downstream pick up the
|
// 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
|
// change. The `update()` below forces card.ts to re-render and re-read
|
||||||
// `getCall()`, propagating the new session to the carousel.
|
// `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.getConditionStateManager().setState({ call: 'answered' });
|
||||||
this._api.getCardElementManager().update();
|
this._api.getCardElementManager().update();
|
||||||
return true;
|
return true;
|
||||||
@@ -377,7 +389,11 @@ export class CallManager {
|
|||||||
return false;
|
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);
|
this._notifyError('error.call_microphone_forbidden', inbound);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -385,17 +401,37 @@ export class CallManager {
|
|||||||
return true;
|
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<boolean> {
|
private async _connectMicrophone(): Promise<boolean> {
|
||||||
const microphoneManager = this._api.getMicrophoneManager();
|
const microphoneManager = this._api.getMicrophoneManager();
|
||||||
if (microphoneManager.isConnected()) {
|
if (microphoneManager.isConnected()) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const initGeneration = this._initGeneration.current();
|
||||||
|
let connected = false;
|
||||||
try {
|
try {
|
||||||
await microphoneManager.connect();
|
await microphoneManager.connect();
|
||||||
return true;
|
connected = true;
|
||||||
} catch {
|
} 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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!connected) {
|
||||||
|
this._notifyError('error.call_microphone_forbidden', false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _hasCallCapability(cameraID: string): boolean {
|
private _hasCallCapability(cameraID: string): boolean {
|
||||||
|
|||||||
@@ -544,6 +544,53 @@ describe('start', () => {
|
|||||||
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
|
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
|
||||||
expect(api.getNotificationManager().setNotification).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
|
// An inbound start request must not displace a call the user cares about
|
||||||
@@ -571,7 +618,7 @@ describe('inbound supersede policy', () => {
|
|||||||
manager.initialize();
|
manager.initialize();
|
||||||
expect(await manager.start({ inbound: true })).toBe(true);
|
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.getCall()?.answered).toBe(true);
|
||||||
|
|
||||||
expect(await manager.start({ cameraID: 'camera.garage', inbound: true })).toBe(
|
expect(await manager.start({ cameraID: 'camera.garage', inbound: true })).toBe(
|
||||||
@@ -626,7 +673,7 @@ describe('inbound supersede policy', () => {
|
|||||||
const manager = new CallManager(api);
|
const manager = new CallManager(api);
|
||||||
manager.initialize();
|
manager.initialize();
|
||||||
expect(await manager.start({ inbound: true })).toBe(true);
|
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);
|
expect(await manager.start({ cameraID: 'camera.garage' })).toBe(true);
|
||||||
|
|
||||||
@@ -838,7 +885,7 @@ describe('endIf', () => {
|
|||||||
const manager = new CallManager(api);
|
const manager = new CallManager(api);
|
||||||
manager.initialize();
|
manager.initialize();
|
||||||
expect(await manager.start({ inbound: true })).toBe(true);
|
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.getCall()?.answered).toBe(true);
|
||||||
|
|
||||||
expect(manager.endIf({ answered: false })).toBe(false);
|
expect(manager.endIf({ answered: false })).toBe(false);
|
||||||
@@ -1109,11 +1156,11 @@ describe('initialize / uninitialize', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// `inbound: true` suppresses each of the preflight/validation notifications.
|
// `inbound: true` suppresses each of the preflight/validation notifications an
|
||||||
// Every path that would call `_notifyError` is exercised under both the
|
// inbound start can still reach. The non-inbound coverage (notification
|
||||||
// non-inbound case (notification surfaced) and the inbound case (silent). The
|
// surfaced) already lives in the `start` describe above; here we assert the
|
||||||
// non-inbound coverage already lives in the `start` describe above; here we
|
// inbound paths stay silent. The forbidden-microphone check is deliberately
|
||||||
// assert the inbound paths stay silent.
|
// absent -- an inbound start no longer reaches it at all.
|
||||||
describe('inbound option', () => {
|
describe('inbound option', () => {
|
||||||
it('should suppress notification when the camera lacks live capability', async () => {
|
it('should suppress notification when the camera lacks live capability', async () => {
|
||||||
const api = createAPI({
|
const api = createAPI({
|
||||||
@@ -1142,29 +1189,6 @@ describe('inbound option', () => {
|
|||||||
expect(api.getNotificationManager().setNotification).not.toBeCalled();
|
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 () => {
|
it('should suppress notification when an explicit stream is not 2-way audio', async () => {
|
||||||
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
|
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
|
||||||
|
|
||||||
@@ -1249,11 +1273,11 @@ describe('answer', () => {
|
|||||||
expect(getRingtone().start).toBeCalled();
|
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 api = createAPI();
|
||||||
const manager = new CallManager(api);
|
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 () => {
|
it('should no-op when the call is already answered', async () => {
|
||||||
@@ -1264,11 +1288,11 @@ describe('answer', () => {
|
|||||||
const manager = new CallManager(api);
|
const manager = new CallManager(api);
|
||||||
manager.initialize();
|
manager.initialize();
|
||||||
expect(await manager.start({ inbound: true })).toBe(true);
|
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(getRingtone().stop).mockClear();
|
||||||
vi.mocked(api.getCardElementManager().update).mockClear();
|
vi.mocked(api.getCardElementManager().update).mockClear();
|
||||||
|
|
||||||
expect(manager.answer()).toBe(false);
|
expect(await manager.answer()).toBe(false);
|
||||||
|
|
||||||
expect(getRingtone().stop).not.toBeCalled();
|
expect(getRingtone().stop).not.toBeCalled();
|
||||||
expect(api.getCardElementManager().update).not.toBeCalled();
|
expect(api.getCardElementManager().update).not.toBeCalled();
|
||||||
@@ -1284,7 +1308,7 @@ describe('answer', () => {
|
|||||||
expect(await manager.start({ inbound: true })).toBe(true);
|
expect(await manager.start({ inbound: true })).toBe(true);
|
||||||
const before = manager.getCall();
|
const before = manager.getCall();
|
||||||
|
|
||||||
expect(manager.answer()).toBe(true);
|
expect(await manager.answer()).toBe(true);
|
||||||
|
|
||||||
const after = manager.getCall();
|
const after = manager.getCall();
|
||||||
expect(after?.answered).toBe(true);
|
expect(after?.answered).toBe(true);
|
||||||
@@ -1315,7 +1339,7 @@ describe('answer', () => {
|
|||||||
expect(await manager.start({ inbound: true })).toBe(true);
|
expect(await manager.start({ inbound: true })).toBe(true);
|
||||||
vi.mocked(getRingtone().stop).mockClear();
|
vi.mocked(getRingtone().stop).mockClear();
|
||||||
|
|
||||||
expect(manager.answer()).toBe(true);
|
expect(await manager.answer()).toBe(true);
|
||||||
|
|
||||||
expect(getRingtone().stop).toBeCalled();
|
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<void>((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 () => {
|
it('should force a card re-render on answer', async () => {
|
||||||
const api = createAPI({
|
const api = createAPI({
|
||||||
view: createView({ camera: 'camera.office' }),
|
view: createView({ camera: 'camera.office' }),
|
||||||
@@ -1338,7 +1424,7 @@ describe('answer', () => {
|
|||||||
expect(await manager.start({ inbound: true })).toBe(true);
|
expect(await manager.start({ inbound: true })).toBe(true);
|
||||||
vi.mocked(api.getCardElementManager().update).mockClear();
|
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
|
// The card subtree depends on `getCall().answered`, which the manager
|
||||||
// mutates outside the view-manager epoch -- so `update()` is what drives
|
// mutates outside the view-manager epoch -- so `update()` is what drives
|
||||||
@@ -1353,7 +1439,7 @@ describe('answer', () => {
|
|||||||
expect(await manager.start()).toBe(true);
|
expect(await manager.start()).toBe(true);
|
||||||
|
|
||||||
// Outbound starts answered, so `answer()` is a no-op.
|
// 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);
|
expect(manager.getCall()?.answered).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1593,7 +1679,7 @@ describe('unanswered timeout', () => {
|
|||||||
manager.initialize();
|
manager.initialize();
|
||||||
expect(await manager.start({ inbound: true })).toBe(true);
|
expect(await manager.start({ inbound: true })).toBe(true);
|
||||||
|
|
||||||
expect(manager.answer()).toBe(true);
|
expect(await manager.answer()).toBe(true);
|
||||||
vi.advanceTimersByTime(60_000);
|
vi.advanceTimersByTime(60_000);
|
||||||
|
|
||||||
expect(manager.isActive()).toBe(true);
|
expect(manager.isActive()).toBe(true);
|
||||||
@@ -1680,11 +1766,10 @@ describe('session end during setState', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('state changes during in-flight start', () => {
|
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({
|
const api = createAPI({
|
||||||
view: createView({ camera: 'camera.office' }),
|
view: createView({ camera: 'camera.office' }),
|
||||||
microphoneConnected: false,
|
microphoneConnected: false,
|
||||||
config: { live: { controls: { call: { ringtone: { type: 'chime' } } } } },
|
|
||||||
});
|
});
|
||||||
let resolveConnect: () => void = () => {};
|
let resolveConnect: () => void = () => {};
|
||||||
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
|
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
|
||||||
@@ -1695,20 +1780,19 @@ describe('state changes during in-flight start', () => {
|
|||||||
const manager = new CallManager(api);
|
const manager = new CallManager(api);
|
||||||
manager.initialize();
|
manager.initialize();
|
||||||
|
|
||||||
const startPromise = manager.start({ inbound: true });
|
const startPromise = manager.start();
|
||||||
manager.uninitialize();
|
manager.uninitialize();
|
||||||
resolveConnect();
|
resolveConnect();
|
||||||
|
|
||||||
expect(await startPromise).toBe(false);
|
expect(await startPromise).toBe(false);
|
||||||
expect(getRingtone().start).not.toBeCalled();
|
|
||||||
expect(manager.isActive()).toBe(false);
|
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({
|
const api = createAPI({
|
||||||
view: createView({ camera: 'camera.office' }),
|
view: createView({ camera: 'camera.office' }),
|
||||||
microphoneConnected: false,
|
microphoneConnected: false,
|
||||||
config: { live: { controls: { call: { ringtone: { type: 'chime' } } } } },
|
|
||||||
});
|
});
|
||||||
let resolveConnect: () => void = () => {};
|
let resolveConnect: () => void = () => {};
|
||||||
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
|
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
|
||||||
@@ -1719,14 +1803,14 @@ describe('state changes during in-flight start', () => {
|
|||||||
const manager = new CallManager(api);
|
const manager = new CallManager(api);
|
||||||
manager.initialize();
|
manager.initialize();
|
||||||
|
|
||||||
const startPromise = manager.start({ inbound: true });
|
const startPromise = manager.start();
|
||||||
manager.uninitialize();
|
manager.uninitialize();
|
||||||
manager.initialize();
|
manager.initialize();
|
||||||
resolveConnect();
|
resolveConnect();
|
||||||
|
|
||||||
expect(await startPromise).toBe(false);
|
expect(await startPromise).toBe(false);
|
||||||
expect(getRingtone().start).not.toBeCalled();
|
|
||||||
expect(manager.isActive()).toBe(false);
|
expect(manager.isActive()).toBe(false);
|
||||||
|
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should supersede a session installed by another start mid-await', async () => {
|
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<void>((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
|
// The phase the manager publishes is what automations actually react to, so
|
||||||
// these drive a real ConditionStateManager and a real CallTrigger and assert
|
// these drive a real ConditionStateManager and a real CallTrigger and assert
|
||||||
// the transitions an automation would fire on, rather than that `setState` was
|
// 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(await manager.start({ inbound: true })).toBe(true);
|
||||||
expect(answered).not.toHaveBeenCalled();
|
expect(answered).not.toHaveBeenCalled();
|
||||||
|
|
||||||
expect(manager.answer()).toBe(true);
|
expect(await manager.answer()).toBe(true);
|
||||||
|
|
||||||
expect(answered).toHaveBeenCalledTimes(1);
|
expect(answered).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
@@ -1924,7 +2086,7 @@ describe('published phase transitions in condition state', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(await manager.start({ inbound: true })).toBe(true);
|
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(manager.end()).toBe(true);
|
||||||
|
|
||||||
expect(hungUp).toHaveBeenCalledTimes(1);
|
expect(hungUp).toHaveBeenCalledTimes(1);
|
||||||
|
|||||||
Reference in new issue
Block a user