diff --git a/src/state/CallViewModel.test.ts b/src/state/CallViewModel.test.ts index d45a5c03..1e9bcc89 100644 --- a/src/state/CallViewModel.test.ts +++ b/src/state/CallViewModel.test.ts @@ -233,12 +233,22 @@ function summarizeLayout$(l$: Observable): Observable { ); } +interface CallViewModelInputs { + remoteParticipants$: Behavior; + rtcMembers$: Behavior[]>; + connectionState$: Observable; + speaking: Map>; + mediaDevices: MediaDevices; +} + function withCallViewModel( - remoteParticipants$: Behavior, - rtcMembers$: Behavior[]>, - connectionState$: Observable, - speaking: Map>, - mediaDevices: MediaDevices, + { + remoteParticipants$ = constant([]), + rtcMembers$ = constant([localRtcMember]), + connectionState$ = of(ConnectionState.Connected), + speaking = new Map(), + mediaDevices = mockMediaDevices({}), + }: Partial, continuation: ( vm: CallViewModel, rtcSession: MockRTCSession, @@ -324,17 +334,17 @@ test("participants are retained during a focus switch", () => { const expectedLayoutMarbles = " a"; withCallViewModel( - behavior(participantInputMarbles, { - a: [aliceParticipant, bobParticipant], - b: [], - }), - constant([localRtcMember, aliceRtcMember, bobRtcMember]), - behavior(connectionInputMarbles, { - c: ConnectionState.Connected, - s: ECAddonConnectionState.ECSwitchingFocus, - }), - new Map(), - mockMediaDevices({}), + { + remoteParticipants$: behavior(participantInputMarbles, { + a: [aliceParticipant, bobParticipant], + b: [], + }), + rtcMembers$: constant([localRtcMember, aliceRtcMember, bobRtcMember]), + connectionState$: behavior(connectionInputMarbles, { + c: ConnectionState.Connected, + s: ECAddonConnectionState.ECSwitchingFocus, + }), + }, (vm) => { expectObservable(summarizeLayout$(vm.layout$)).toBe( expectedLayoutMarbles, @@ -365,16 +375,15 @@ test("screen sharing activates spotlight layout", () => { const expectedLayoutMarbles = " abcdaefeg"; const expectedShowSpeakingMarbles = "y----nyny"; withCallViewModel( - behavior(participantInputMarbles, { - a: [aliceParticipant, bobParticipant], - b: [aliceSharingScreen, bobParticipant], - c: [aliceSharingScreen, bobSharingScreen], - d: [aliceParticipant, bobSharingScreen], - }), - constant([localRtcMember, aliceRtcMember, bobRtcMember]), - of(ConnectionState.Connected), - new Map(), - mockMediaDevices({}), + { + remoteParticipants$: behavior(participantInputMarbles, { + a: [aliceParticipant, bobParticipant], + b: [aliceSharingScreen, bobParticipant], + c: [aliceSharingScreen, bobSharingScreen], + d: [aliceParticipant, bobSharingScreen], + }), + rtcMembers$: constant([localRtcMember, aliceRtcMember, bobRtcMember]), + }, (vm) => { schedule(modeInputMarbles, { s: () => vm.setGridMode("spotlight"), @@ -447,15 +456,24 @@ test("participants stay in the same order unless to appear/disappear", () => { const expectedLayoutMarbles = " a 1999ms b 1999ms a 57999ms c 1999ms a"; withCallViewModel( - constant([aliceParticipant, bobParticipant, daveParticipant]), - constant([localRtcMember, aliceRtcMember, bobRtcMember, daveRtcMember]), - of(ConnectionState.Connected), - new Map([ - [aliceParticipant, behavior(aSpeakingInputMarbles, yesNo)], - [bobParticipant, behavior(bSpeakingInputMarbles, yesNo)], - [daveParticipant, behavior(dSpeakingInputMarbles, yesNo)], - ]), - mockMediaDevices({}), + { + remoteParticipants$: constant([ + aliceParticipant, + bobParticipant, + daveParticipant, + ]), + rtcMembers$: constant([ + localRtcMember, + aliceRtcMember, + bobRtcMember, + daveRtcMember, + ]), + speaking: new Map([ + [aliceParticipant, behavior(aSpeakingInputMarbles, yesNo)], + [bobParticipant, behavior(bSpeakingInputMarbles, yesNo)], + [daveParticipant, behavior(dSpeakingInputMarbles, yesNo)], + ]), + }, (vm) => { schedule(visibilityInputMarbles, { a: () => { @@ -505,14 +523,23 @@ test("participants adjust order when space becomes constrained", () => { const expectedLayoutMarbles = " a-b"; withCallViewModel( - constant([aliceParticipant, bobParticipant, daveParticipant]), - constant([localRtcMember, aliceRtcMember, bobRtcMember, daveRtcMember]), - of(ConnectionState.Connected), - new Map([ - [bobParticipant, behavior(bSpeakingInputMarbles, yesNo)], - [daveParticipant, behavior(dSpeakingInputMarbles, yesNo)], - ]), - mockMediaDevices({}), + { + remoteParticipants$: constant([ + aliceParticipant, + bobParticipant, + daveParticipant, + ]), + rtcMembers$: constant([ + localRtcMember, + aliceRtcMember, + bobRtcMember, + daveRtcMember, + ]), + speaking: new Map([ + [bobParticipant, behavior(bSpeakingInputMarbles, yesNo)], + [daveParticipant, behavior(dSpeakingInputMarbles, yesNo)], + ]), + }, (vm) => { let setVisibleTiles: ((value: number) => void) | null = null; vm.layout$.subscribe((layout) => { @@ -558,15 +585,24 @@ test("spotlight speakers swap places", () => { const expectedLayoutMarbles = "abcd"; withCallViewModel( - constant([aliceParticipant, bobParticipant, daveParticipant]), - constant([localRtcMember, aliceRtcMember, bobRtcMember, daveRtcMember]), - of(ConnectionState.Connected), - new Map([ - [aliceParticipant, behavior(aSpeakingInputMarbles, yesNo)], - [bobParticipant, behavior(bSpeakingInputMarbles, yesNo)], - [daveParticipant, behavior(dSpeakingInputMarbles, yesNo)], - ]), - mockMediaDevices({}), + { + remoteParticipants$: constant([ + aliceParticipant, + bobParticipant, + daveParticipant, + ]), + rtcMembers$: constant([ + localRtcMember, + aliceRtcMember, + bobRtcMember, + daveRtcMember, + ]), + speaking: new Map([ + [aliceParticipant, behavior(aSpeakingInputMarbles, yesNo)], + [bobParticipant, behavior(bSpeakingInputMarbles, yesNo)], + [daveParticipant, behavior(dSpeakingInputMarbles, yesNo)], + ]), + }, (vm) => { schedule(modeInputMarbles, { s: () => vm.setGridMode("spotlight") }); @@ -608,11 +644,10 @@ test("layout enters picture-in-picture mode when requested", () => { const expectedLayoutMarbles = " aba"; withCallViewModel( - constant([aliceParticipant, bobParticipant]), - constant([localRtcMember, aliceRtcMember, bobRtcMember]), - of(ConnectionState.Connected), - new Map(), - mockMediaDevices({}), + { + remoteParticipants$: constant([aliceParticipant, bobParticipant]), + rtcMembers$: constant([localRtcMember, aliceRtcMember, bobRtcMember]), + }, (vm) => { schedule(pipControlInputMarbles, { e: () => window.controls.enablePip(), @@ -650,11 +685,10 @@ test("spotlight remembers whether it's expanded", () => { const expectedLayoutMarbles = "abcbada"; withCallViewModel( - constant([aliceParticipant, bobParticipant]), - constant([localRtcMember, aliceRtcMember, bobRtcMember]), - of(ConnectionState.Connected), - new Map(), - mockMediaDevices({}), + { + remoteParticipants$: constant([aliceParticipant, bobParticipant]), + rtcMembers$: constant([localRtcMember, aliceRtcMember, bobRtcMember]), + }, (vm) => { schedule(modeInputMarbles, { s: () => vm.setGridMode("spotlight"), @@ -707,23 +741,22 @@ test("participants must have a MatrixRTCSession to be visible", () => { const expectedLayoutMarbles = "a-bc-b"; withCallViewModel( - behavior(scenarioInputMarbles, { - a: [], - b: [bobParticipant], - c: [aliceParticipant, bobParticipant], - d: [aliceParticipant, daveParticipant, bobParticipant], - e: [aliceParticipant, daveParticipant, bobSharingScreen], - }), - behavior(scenarioInputMarbles, { - a: [localRtcMember], - b: [localRtcMember], - c: [localRtcMember, aliceRtcMember], - d: [localRtcMember, aliceRtcMember, daveRtcMember], - e: [localRtcMember, aliceRtcMember, daveRtcMember], - }), - of(ConnectionState.Connected), - new Map(), - mockMediaDevices({}), + { + remoteParticipants$: behavior(scenarioInputMarbles, { + a: [], + b: [bobParticipant], + c: [aliceParticipant, bobParticipant], + d: [aliceParticipant, daveParticipant, bobParticipant], + e: [aliceParticipant, daveParticipant, bobSharingScreen], + }), + rtcMembers$: behavior(scenarioInputMarbles, { + a: [localRtcMember], + b: [localRtcMember], + c: [localRtcMember, aliceRtcMember], + d: [localRtcMember, aliceRtcMember, daveRtcMember], + e: [localRtcMember, aliceRtcMember, daveRtcMember], + }), + }, (vm) => { vm.setGridMode("grid"); expectObservable(summarizeLayout$(vm.layout$)).toBe( @@ -760,15 +793,14 @@ test("shows participants without MatrixRTCSession when enabled in settings", () const expectedLayoutMarbles = "abc"; withCallViewModel( - behavior(scenarioInputMarbles, { - a: [], - b: [aliceParticipant], - c: [aliceParticipant, bobParticipant], - }), - constant([localRtcMember]), // No one else joins the MatrixRTC session - of(ConnectionState.Connected), - new Map(), - mockMediaDevices({}), + { + remoteParticipants$: behavior(scenarioInputMarbles, { + a: [], + b: [aliceParticipant], + c: [aliceParticipant, bobParticipant], + }), + rtcMembers$: constant([localRtcMember]), // No one else joins the MatrixRTC session + }, (vm) => { vm.setGridMode("grid"); expectObservable(summarizeLayout$(vm.layout$)).toBe( @@ -807,16 +839,14 @@ it("should show at least one tile per MatrixRTCSession", () => { const expectedLayoutMarbles = "abcd"; withCallViewModel( - constant([]), - behavior(scenarioInputMarbles, { - a: [localRtcMember], - b: [localRtcMember, aliceRtcMember], - c: [localRtcMember, aliceRtcMember, daveRtcMember], - d: [localRtcMember, daveRtcMember], - }), - of(ConnectionState.Connected), - new Map(), - mockMediaDevices({}), + { + rtcMembers$: behavior(scenarioInputMarbles, { + a: [localRtcMember], + b: [localRtcMember, aliceRtcMember], + c: [localRtcMember, aliceRtcMember, daveRtcMember], + d: [localRtcMember, daveRtcMember], + }), + }, (vm) => { vm.setGridMode("grid"); expectObservable(summarizeLayout$(vm.layout$)).toBe( @@ -855,22 +885,20 @@ test("should disambiguate users with the same displayname", () => { const expectedLayoutMarbles = "abcde"; withCallViewModel( - constant([]), - behavior(scenarioInputMarbles, { - a: [localRtcMember], - b: [localRtcMember, aliceRtcMember], - c: [localRtcMember, aliceRtcMember, aliceDoppelgangerRtcMember], - d: [ - localRtcMember, - aliceRtcMember, - aliceDoppelgangerRtcMember, - bobRtcMember, - ], - e: [localRtcMember, aliceDoppelgangerRtcMember, bobRtcMember], - }), - of(ConnectionState.Connected), - new Map(), - mockMediaDevices({}), + { + rtcMembers$: behavior(scenarioInputMarbles, { + a: [localRtcMember], + b: [localRtcMember, aliceRtcMember], + c: [localRtcMember, aliceRtcMember, aliceDoppelgangerRtcMember], + d: [ + localRtcMember, + aliceRtcMember, + aliceDoppelgangerRtcMember, + bobRtcMember, + ], + e: [localRtcMember, aliceDoppelgangerRtcMember, bobRtcMember], + }), + }, (vm) => { expectObservable(vm.memberDisplaynames$).toBe(expectedLayoutMarbles, { // Carol has no displayname - So userId is used. @@ -910,14 +938,12 @@ test("should disambiguate users with invisible characters", () => { const expectedLayoutMarbles = "ab"; withCallViewModel( - constant([]), - behavior(scenarioInputMarbles, { - a: [localRtcMember], - b: [localRtcMember, bobRtcMember, bobZeroWidthSpaceRtcMember], - }), - of(ConnectionState.Connected), - new Map(), - mockMediaDevices({}), + { + rtcMembers$: behavior(scenarioInputMarbles, { + a: [localRtcMember], + b: [localRtcMember, bobRtcMember, bobZeroWidthSpaceRtcMember], + }), + }, (vm) => { expectObservable(vm.memberDisplaynames$).toBe(expectedLayoutMarbles, { // Carol has no displayname - So userId is used. @@ -943,14 +969,12 @@ test("should strip RTL characters from displayname", () => { const expectedLayoutMarbles = "ab"; withCallViewModel( - constant([]), - behavior(scenarioInputMarbles, { - a: [localRtcMember], - b: [localRtcMember, daveRtcMember, daveRTLRtcMember], - }), - of(ConnectionState.Connected), - new Map(), - mockMediaDevices({}), + { + rtcMembers$: behavior(scenarioInputMarbles, { + a: [localRtcMember], + b: [localRtcMember, daveRtcMember, daveRTLRtcMember], + }), + }, (vm) => { expectObservable(vm.memberDisplaynames$).toBe(expectedLayoutMarbles, { // Carol has no displayname - So userId is used. @@ -970,16 +994,15 @@ test("should strip RTL characters from displayname", () => { }); it("should rank raised hands above video feeds and below speakers and presenters", () => { - withTestScheduler(({ schedule, expectObservable, behavior }) => { + withTestScheduler(({ schedule, expectObservable }) => { // There should always be one tile for each MatrixRTCSession const expectedLayoutMarbles = "ab"; withCallViewModel( - constant([aliceParticipant, bobParticipant]), - constant([localRtcMember, aliceRtcMember, bobRtcMember]), - of(ConnectionState.Connected), - new Map(), - mockMediaDevices({}), + { + remoteParticipants$: constant([aliceParticipant, bobParticipant]), + rtcMembers$: constant([localRtcMember, aliceRtcMember, bobRtcMember]), + }, (vm, _rtcSession, { raisedHands$ }) => { schedule("ab", { a: () => { @@ -1072,11 +1095,7 @@ test("allOthersLeft$ emits only when someone joined and then all others left", ( withTestScheduler(({ hot, expectObservable, scope }) => { // Test scenario 1: No one ever joins - should only emit initial false and never emit again withCallViewModel( - scope.behavior(nooneEverThere$(hot), []), - constant([localRtcMember]), - of(ConnectionState.Connected), - new Map(), - mockMediaDevices({}), + { remoteParticipants$: scope.behavior(nooneEverThere$(hot), []) }, (vm) => { expectObservable(vm.allOthersLeft$).toBe("n------", { n: false }); }, @@ -1087,11 +1106,10 @@ test("allOthersLeft$ emits only when someone joined and then all others left", ( test("allOthersLeft$ emits true when someone joined and then all others left", () => { withTestScheduler(({ hot, expectObservable, scope }) => { withCallViewModel( - scope.behavior(participantJoinLeave$(hot), []), - scope.behavior(rtcMemberJoinLeave$(hot), []), - of(ConnectionState.Connected), - new Map(), - mockMediaDevices({}), + { + remoteParticipants$: scope.behavior(participantJoinLeave$(hot), []), + rtcMembers$: scope.behavior(rtcMemberJoinLeave$(hot), []), + }, (vm) => { expectObservable(vm.allOthersLeft$).toBe( "n-----u", // false initially, then at frame 6: true then false emissions in same frame @@ -1105,11 +1123,10 @@ test("allOthersLeft$ emits true when someone joined and then all others left", ( test("autoLeaveWhenOthersLeft$ emits only when autoLeaveWhenOthersLeft option is enabled", () => { withTestScheduler(({ hot, expectObservable, scope }) => { withCallViewModel( - scope.behavior(participantJoinLeave$(hot), []), - scope.behavior(rtcMemberJoinLeave$(hot), []), - of(ConnectionState.Connected), - new Map(), - mockMediaDevices({}), + { + remoteParticipants$: scope.behavior(participantJoinLeave$(hot), []), + rtcMembers$: scope.behavior(rtcMemberJoinLeave$(hot), []), + }, (vm) => { expectObservable(vm.autoLeaveWhenOthersLeft$).toBe( "------e", // false initially, then at frame 6: true then false emissions in same frame @@ -1127,11 +1144,10 @@ test("autoLeaveWhenOthersLeft$ emits only when autoLeaveWhenOthersLeft option is test("autoLeaveWhenOthersLeft$ never emits autoLeaveWhenOthersLeft option is enabled but no-one is there", () => { withTestScheduler(({ hot, expectObservable, scope }) => { withCallViewModel( - scope.behavior(nooneEverThere$(hot), []), - scope.behavior(nooneEverThere$(hot), []), - of(ConnectionState.Connected), - new Map(), - mockMediaDevices({}), + { + remoteParticipants$: scope.behavior(nooneEverThere$(hot), []), + rtcMembers$: scope.behavior(nooneEverThere$(hot), []), + }, (vm) => { expectObservable(vm.autoLeaveWhenOthersLeft$).toBe("-------"); }, @@ -1146,11 +1162,10 @@ test("autoLeaveWhenOthersLeft$ never emits autoLeaveWhenOthersLeft option is ena test("autoLeaveWhenOthersLeft$ doesn't emit when autoLeaveWhenOthersLeft option is disabled and all others left", () => { withTestScheduler(({ hot, expectObservable, scope }) => { withCallViewModel( - scope.behavior(participantJoinLeave$(hot), []), - scope.behavior(rtcMemberJoinLeave$(hot), []), - of(ConnectionState.Connected), - new Map(), - mockMediaDevices({}), + { + remoteParticipants$: scope.behavior(participantJoinLeave$(hot), []), + rtcMembers$: scope.behavior(rtcMemberJoinLeave$(hot), []), + }, (vm) => { expectObservable(vm.autoLeaveWhenOthersLeft$).toBe("-------"); }, @@ -1165,27 +1180,26 @@ test("autoLeaveWhenOthersLeft$ doesn't emit when autoLeaveWhenOthersLeft option test("autoLeaveWhenOthersLeft$ doesn't emits when autoLeaveWhenOthersLeft option is enabled and all others left", () => { withTestScheduler(({ hot, expectObservable, scope }) => { withCallViewModel( - scope.behavior( - hot("a-b-c-d", { - a: [], // Alone - b: [aliceParticipant], // Alice joins - c: [aliceParticipant], - d: [], // Local joins with a second device - }), - [], //Alice leaves - ), - scope.behavior( - hot("a-b-c-d", { - a: [localRtcMember], // Start empty - b: [localRtcMember, aliceRtcMember], // Alice joins - c: [localRtcMember, aliceRtcMember, localRtcMemberDevice2], // Alice still there - d: [localRtcMember, localRtcMemberDevice2], // The second Alice leaves - }), - [], - ), - of(ConnectionState.Connected), - new Map(), - mockMediaDevices({}), + { + remoteParticipants$: scope.behavior( + hot("a-b-c-d", { + a: [], // Alone + b: [aliceParticipant], // Alice joins + c: [aliceParticipant], + d: [], // Local joins with a second device + }), + [], //Alice leaves + ), + rtcMembers$: scope.behavior( + hot("a-b-c-d", { + a: [localRtcMember], // Start empty + b: [localRtcMember, aliceRtcMember], // Alice joins + c: [localRtcMember, aliceRtcMember, localRtcMemberDevice2], // Alice still there + d: [localRtcMember, localRtcMemberDevice2], // The second Alice leaves + }), + [], + ), + }, (vm) => { expectObservable(vm.autoLeaveWhenOthersLeft$).toBe("------e", { e: undefined, @@ -1219,27 +1233,18 @@ test("audio output changes when toggling earpiece mode", () => { const expectedEarpieceModeMarbles = "n-yn"; const expectedTargetStateMarbles = " sese"; - withCallViewModel( - constant([]), - constant([localRtcMember]), - of(ConnectionState.Connected), - new Map(), - devices, - (vm) => { - schedule(toggleInputMarbles, { - a: () => getValue(vm.audioOutputSwitcher$)?.switch(), - }); - expectObservable(vm.earpieceMode$).toBe( - expectedEarpieceModeMarbles, - yesNo, - ); - expectObservable( - vm.audioOutputSwitcher$.pipe( - map((switcher) => switcher?.targetOutput), - ), - ).toBe(expectedTargetStateMarbles, { s: "speaker", e: "earpiece" }); - }, - ); + withCallViewModel({ mediaDevices: devices }, (vm) => { + schedule(toggleInputMarbles, { + a: () => getValue(vm.audioOutputSwitcher$)?.switch(), + }); + expectObservable(vm.earpieceMode$).toBe( + expectedEarpieceModeMarbles, + yesNo, + ); + expectObservable( + vm.audioOutputSwitcher$.pipe(map((switcher) => switcher?.targetOutput)), + ).toBe(expectedTargetStateMarbles, { s: "speaker", e: "earpiece" }); + }); }); }); @@ -1276,30 +1281,20 @@ test("media tracks are paused while reconnecting to MatrixRTC", () => { const expectedReconnectingMarbles = "nyn"; const expectedTrackRunningMarbles = "yny"; - withCallViewModel( - constant([]), - constant([localRtcMember]), - of(ConnectionState.Connected), - new Map(), - mockMediaDevices({}), - (vm, rtcSession) => { - schedule(connectedMarbles, { - y: () => { - rtcSession.probablyLeft = false; - }, - n: () => { - rtcSession.probablyLeft = true; - }, - }); - expectObservable(vm.reconnecting$).toBe( - expectedReconnectingMarbles, - yesNo, - ); - expectObservable(trackRunning$).toBe( - expectedTrackRunningMarbles, - yesNo, - ); - }, - ); + withCallViewModel({}, (vm, rtcSession) => { + schedule(connectedMarbles, { + y: () => { + rtcSession.probablyLeft = false; + }, + n: () => { + rtcSession.probablyLeft = true; + }, + }); + expectObservable(vm.reconnecting$).toBe( + expectedReconnectingMarbles, + yesNo, + ); + expectObservable(trackRunning$).toBe(expectedTrackRunningMarbles, yesNo); + }); }); });