From 1ed9c9dbcf97b056112d120b9f7ee06772a89379 Mon Sep 17 00:00:00 2001 From: Timo Date: Mon, 30 Jun 2025 16:01:22 +0200 Subject: [PATCH] add tests --- src/state/CallViewModel.test.ts | 145 +++++++++++++++++++++++++++++++- src/state/CallViewModel.ts | 7 +- 2 files changed, 146 insertions(+), 6 deletions(-) diff --git a/src/state/CallViewModel.test.ts b/src/state/CallViewModel.test.ts index 9d3064dd..65449bf9 100644 --- a/src/state/CallViewModel.test.ts +++ b/src/state/CallViewModel.test.ts @@ -32,7 +32,11 @@ import { } from "matrix-js-sdk/lib/matrixrtc"; import { deepCompare } from "matrix-js-sdk/lib/utils"; -import { CallViewModel, type Layout } from "./CallViewModel"; +import { + CallViewModel, + type CallViewModelOptions, + type Layout, +} from "./CallViewModel"; import { mockLivekitRoom, mockLocalParticipant, @@ -231,6 +235,10 @@ function withCallViewModel( vm: CallViewModel, subjects: { raisedHands$: BehaviorSubject> }, ) => void, + options: CallViewModelOptions = { + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + autoLeaveWhenOthersLeft: false, + }, ): void { const room = mockMatrixRoom({ client: { @@ -281,9 +289,7 @@ function withCallViewModel( rtcSession as unknown as MatrixRTCSession, liveKitRoom, mediaDevices, - { - encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, - }, + options, connectionState$, raisedHands$, new BehaviorSubject({}), @@ -1037,6 +1043,137 @@ it("should rank raised hands above video feeds and below speakers and presenters }); }); +function nooneEverThere$( + hot: (marbles: string, values: Record) => Observable, +): Observable { + return hot("a-b-c-d", { + a: [], // Start empty + b: [], // Alice joins + c: [], // Alice still there + d: [], // Alice leaves + }); +} + +function participantJoinLeave$( + hot: ( + marbles: string, + values: Record, + ) => Observable, +): Observable { + return hot("a-b-c-d", { + a: [], // Start empty + b: [aliceParticipant], // Alice joins + c: [aliceParticipant], // Alice still there + d: [], // Alice leaves + }); +} + +function rtcMemberJoinLeave$( + hot: ( + marbles: string, + values: Record, + ) => Observable, +): Observable { + return hot("a-b-c-d", { + a: [], // Start empty + b: [aliceRtcMember], // Alice joins + c: [aliceRtcMember], // Alice still there + d: [], // Alice leaves + }); +} + +test("allOthersLeft$ emits only when someone joined and then all others left", () => { + withTestScheduler(({ hot, expectObservable }) => { + // Test scenario 1: No one ever joins - should only emit initial false and never emit again + withCallViewModel( + nooneEverThere$(hot), + nooneEverThere$(hot), + of(ConnectionState.Connected), + new Map(), + mockMediaDevices({}), + (vm) => { + expectObservable(vm.allOthersLeft$).toBe("n------", { n: false }); + }, + ); + }); +}); + +test("allOthersLeft$ emits true when someone joined and then all others left", () => { + withTestScheduler(({ hot, expectObservable }) => { + withCallViewModel( + participantJoinLeave$(hot), + rtcMemberJoinLeave$(hot), + of(ConnectionState.Connected), + new Map(), + mockMediaDevices({}), + (vm) => { + expectObservable(vm.allOthersLeft$).toBe( + "n-----u", // false initially, then at frame 6: true then false emissions in same frame + { n: false, u: true }, // map(() => {}) + ); + }, + ); + }); +}); + +test("autoLeaveWhenOthersLeft$ emits only when autoLeaveWhenOthersLeft option is enabled", () => { + withTestScheduler(({ hot, expectObservable }) => { + withCallViewModel( + participantJoinLeave$(hot), + rtcMemberJoinLeave$(hot), + of(ConnectionState.Connected), + new Map(), + mockMediaDevices({}), + (vm) => { + expectObservable(vm.autoLeaveWhenOthersLeft$).toBe( + "------e", // false initially, then at frame 6: true then false emissions in same frame + { e: undefined }, + ); + }, + { + autoLeaveWhenOthersLeft: true, + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + }, + ); + }); +}); + +test("autoLeaveWhenOthersLeft$ never emits autoLeaveWhenOthersLeft option is enabled but noone is there", () => { + withTestScheduler(({ hot, expectObservable }) => { + withCallViewModel( + nooneEverThere$(hot), + nooneEverThere$(hot), + of(ConnectionState.Connected), + new Map(), + mockMediaDevices({}), + (vm) => { + expectObservable(vm.autoLeaveWhenOthersLeft$).toBe( + "-------", // false initially, then at frame 6: true then false emissions in same frame + ); + }, + { + autoLeaveWhenOthersLeft: true, + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + }, + ); + }); +}); + +test("autoLeaveWhenOthersLeft$ emits when autoLeaveWhenOthersLeft option is enabled and all others left", () => { + withTestScheduler(({ hot, expectObservable }) => { + withCallViewModel( + participantJoinLeave$(hot), + rtcMemberJoinLeave$(hot), + of(ConnectionState.Connected), + new Map(), + mockMediaDevices({}), + (vm) => { + expectObservable(vm.autoLeaveWhenOthersLeft$).toBe("-------"); + }, + ); + }); +}); + test("audio output changes when toggling earpiece mode", () => { withTestScheduler(({ schedule, expectObservable }) => { getUrlParams.mockReturnValue({ controlledAudioDevices: true }); diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index 956f055c..a3a4c31d 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -96,7 +96,7 @@ import { calculateDisplayName, shouldDisambiguate } from "../utils/displayname"; import { type MediaDevices } from "./MediaDevices"; import { type Behavior } from "./Behavior"; -interface CallViewModelOptions { +export interface CallViewModelOptions { encryptionSystem: EncryptionSystem; autoLeaveWhenOthersLeft?: boolean; } @@ -741,7 +741,10 @@ export class CallViewModel extends ViewModel { ); public readonly allOthersLeft$ = this.memberChanges$.pipe( - map(({ ids, left }) => ids.length === 0 && left.length > 0), + map( + ({ ids, left }) => + ids.length === 1 && ids.includes("local:0") && left.length > 0, + ), startWith(false), distinctUntilChanged(), );