From 9d5145a7a6b04c6b1b49b10809bb69337b71ea09 Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 30 Aug 2024 19:09:42 -0400 Subject: [PATCH] Test MediaViewModel This was the result of me playing around with RxJS marble testing to understand how to get things done with its TestScheduler. I discovered that it lacks a clear way to fire arbitrary actions during the test, so I built a small helper function called schedule which does this for us. --- src/state/MediaViewModel.test.ts | 132 +++++++++++++++++++++++++++++++ src/utils/test.ts | 39 ++++++++- 2 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 src/state/MediaViewModel.test.ts diff --git a/src/state/MediaViewModel.test.ts b/src/state/MediaViewModel.test.ts new file mode 100644 index 00000000..f65b7775 --- /dev/null +++ b/src/state/MediaViewModel.test.ts @@ -0,0 +1,132 @@ +/* +Copyright 2024 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { RoomMember } from "matrix-js-sdk/src/matrix"; +import { expect, test, vi } from "vitest"; +import { LocalParticipant, RemoteParticipant } from "livekit-client"; + +import { + LocalUserMediaViewModel, + RemoteUserMediaViewModel, +} from "./MediaViewModel"; +import { withTestScheduler } from "../utils/test"; + +function withLocal(continuation: (vm: LocalUserMediaViewModel) => void): void { + const member = {} as unknown as RoomMember; + const vm = new LocalUserMediaViewModel( + "a", + member, + {} as unknown as LocalParticipant, + true, + ); + try { + continuation(vm); + } finally { + vm.destroy(); + } +} + +function withRemote( + participant: Partial, + continuation: (vm: RemoteUserMediaViewModel) => void, +): void { + const member = {} as unknown as RoomMember; + const vm = new RemoteUserMediaViewModel( + "a", + member, + { setVolume() {}, ...participant } as RemoteParticipant, + true, + ); + try { + continuation(vm); + } finally { + vm.destroy(); + } +} + +test("set a participant's volume", () => { + const setVolumeSpy = vi.fn(); + withRemote({ setVolume: setVolumeSpy }, (vm) => + withTestScheduler(({ expectObservable, schedule }) => { + schedule("-a|", { + a() { + vm.setLocalVolume(0.8); + expect(setVolumeSpy).toHaveBeenLastCalledWith(0.8); + }, + }); + expectObservable(vm.localVolume).toBe("ab", { a: 1, b: 0.8 }); + }), + ); +}); + +test("mute and unmute a participant", () => { + const setVolumeSpy = vi.fn(); + withRemote({ setVolume: setVolumeSpy }, (vm) => + withTestScheduler(({ expectObservable, schedule }) => { + schedule("-abc|", { + a() { + vm.toggleLocallyMuted(); + expect(setVolumeSpy).toHaveBeenLastCalledWith(0); + }, + b() { + vm.setLocalVolume(0.8); + expect(setVolumeSpy).toHaveBeenLastCalledWith(0); + }, + c() { + vm.toggleLocallyMuted(); + expect(setVolumeSpy).toHaveBeenLastCalledWith(0.8); + }, + }); + expectObservable(vm.locallyMuted).toBe("ab-c", { + a: false, + b: true, + c: false, + }); + }), + ); +}); + +test("toggle fit/contain for a participant's video", () => { + withRemote({}, (vm) => + withTestScheduler(({ expectObservable, schedule }) => { + schedule("-ab|", { + a: () => vm.toggleFitContain(), + b: () => vm.toggleFitContain(), + }); + expectObservable(vm.cropVideo).toBe("abc", { + a: true, + b: false, + c: true, + }); + }), + ); +}); + +test("local media remembers whether it should always be shown", () => { + withLocal((vm) => + withTestScheduler(({ expectObservable, schedule }) => { + schedule("-a|", { a: () => vm.setAlwaysShow(false) }); + expectObservable(vm.alwaysShow).toBe("ab", { a: true, b: false }); + }), + ); + // Next local media should start out *not* always shown + withLocal((vm) => + withTestScheduler(({ expectObservable, schedule }) => { + schedule("-a|", { a: () => vm.setAlwaysShow(true) }); + expectObservable(vm.alwaysShow).toBe("ab", { a: false, b: true }); + }), + ); +}); diff --git a/src/utils/test.ts b/src/utils/test.ts index f3d7874a..43329be0 100644 --- a/src/utils/test.ts +++ b/src/utils/test.ts @@ -1,5 +1,5 @@ /* -Copyright 2023 New Vector Ltd +Copyright 2023-2024 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -13,7 +13,9 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -import { vi } from "vitest"; +import { map } from "rxjs"; +import { RunHelpers, TestScheduler } from "rxjs/testing"; +import { expect, vi } from "vitest"; export function withFakeTimers(continuation: () => void): void { vi.useFakeTimers(); @@ -23,3 +25,36 @@ export function withFakeTimers(continuation: () => void): void { vi.useRealTimers(); } } + +export interface OurRunHelpers extends RunHelpers { + /** + * Schedules a sequence of actions to happen, as described by a marble + * diagram. + */ + schedule: (marbles: string, actions: Record void>) => void; +} + +/** + * Run Observables with a scheduler that virtualizes time, for testing purposes. + */ +export function withTestScheduler( + continuation: (helpers: OurRunHelpers) => void, +): void { + new TestScheduler((actual, expected) => { + expect(actual).deep.equals(expected); + }).run((helpers) => + continuation({ + ...helpers, + schedule(marbles, actions) { + const actionsObservable = helpers + .cold(marbles) + .pipe(map((value) => actions[value]())); + const results = Object.fromEntries( + Object.keys(actions).map((value) => [value, undefined] as const), + ); + // Run the actions and verify that none of them error + helpers.expectObservable(actionsObservable).toBe(marbles, results); + }, + }), + ); +}