Test CallViewModel

This adds tests for a couple of the less trivial bits of code in CallViewModel. Testing them helped me uncover why focus switches still weren't being smooth! (It was because I was using RxJS's sample operator when I really wanted withLatestFrom.)
This commit is contained in:
Robin
2024-09-11 01:03:23 -04:00
parent d12a01b1c4
commit 016ba676dd
3 changed files with 391 additions and 64 deletions

View File

@@ -0,0 +1,276 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { test, vi, onTestFinished } from "vitest";
import { map, Observable } from "rxjs";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import {
ConnectionState,
LocalParticipant,
RemoteParticipant,
} from "livekit-client";
import * as ComponentsCore from "@livekit/components-core";
import { CallViewModel, Layout } from "./CallViewModel";
import {
mockLivekitRoom,
mockLocalParticipant,
mockMatrixRoom,
mockMember,
mockRemoteParticipant,
OurRunHelpers,
withTestScheduler,
} from "../utils/test";
import {
ECAddonConnectionState,
ECConnectionState,
} from "../livekit/useECConnectionState";
vi.mock("@livekit/components-core");
const aliceId = "@alice:example.org:AAAA";
const bobId = "@bob:example.org:BBBB";
const alice = mockMember({ userId: "@alice:example.org" });
const bob = mockMember({ userId: "@bob:example.org" });
const carol = mockMember({ userId: "@carol:example.org" });
const localParticipant = mockLocalParticipant({ identity: "" });
const aliceParticipant = mockRemoteParticipant({ identity: aliceId });
const aliceSharingScreen = mockRemoteParticipant({
identity: aliceId,
isScreenShareEnabled: true,
});
const bobParticipant = mockRemoteParticipant({ identity: bobId });
const bobSharingScreen = mockRemoteParticipant({
identity: bobId,
isScreenShareEnabled: true,
});
const members = new Map([
[alice.userId, alice],
[bob.userId, bob],
[carol.userId, carol],
]);
export interface GridLayoutSummary {
type: "grid";
spotlight?: string[];
grid: string[];
}
export interface SpotlightLandscapeLayoutSummary {
type: "spotlight-landscape";
spotlight: string[];
grid: string[];
}
export interface SpotlightPortraitLayoutSummary {
type: "spotlight-portrait";
spotlight: string[];
grid: string[];
}
export interface SpotlightExpandedLayoutSummary {
type: "spotlight-expanded";
spotlight: string[];
pip?: string;
}
export interface OneOnOneLayoutSummary {
type: "one-on-one";
local: string;
remote: string;
}
export interface PipLayoutSummary {
type: "pip";
spotlight: string[];
}
export type LayoutSummary =
| GridLayoutSummary
| SpotlightLandscapeLayoutSummary
| SpotlightPortraitLayoutSummary
| SpotlightExpandedLayoutSummary
| OneOnOneLayoutSummary
| PipLayoutSummary;
function summarizeLayout(l: Layout): LayoutSummary {
switch (l.type) {
case "grid":
return {
type: l.type,
spotlight: l.spotlight?.map((vm) => vm.id),
grid: l.grid.map((vm) => vm.id),
};
case "spotlight-landscape":
case "spotlight-portrait":
return {
type: l.type,
spotlight: l.spotlight.map((vm) => vm.id),
grid: l.grid.map((vm) => vm.id),
};
case "spotlight-expanded":
return {
type: l.type,
spotlight: l.spotlight.map((vm) => vm.id),
pip: l.pip?.id,
};
case "one-on-one":
return { type: l.type, local: l.local.id, remote: l.remote.id };
case "pip":
return { type: l.type, spotlight: l.spotlight.map((vm) => vm.id) };
}
}
function withCallViewModel(
{ cold }: OurRunHelpers,
remoteParticipants: Observable<RemoteParticipant[]>,
connectionState: Observable<ECConnectionState>,
continuation: (vm: CallViewModel) => void,
): void {
const participantsSpy = vi
.spyOn(ComponentsCore, "connectedParticipantsObserver")
.mockReturnValue(remoteParticipants);
const mediaSpy = vi
.spyOn(ComponentsCore, "observeParticipantMedia")
.mockImplementation((p) =>
cold("a", {
a: { participant: p } as Partial<
ComponentsCore.ParticipantMedia<LocalParticipant>
> as ComponentsCore.ParticipantMedia<LocalParticipant>,
}),
);
const eventsSpy = vi
.spyOn(ComponentsCore, "observeParticipantEvents")
.mockImplementation((p) => cold("a", { a: p }));
const vm = new CallViewModel(
mockMatrixRoom({
client: {
getUserId: () => "@carol:example.org",
} as Partial<MatrixClient> as MatrixClient,
getMember: (userId) => members.get(userId) ?? null,
}),
mockLivekitRoom({ localParticipant }),
true,
connectionState,
);
onTestFinished(() => {
vm!.destroy();
participantsSpy!.mockRestore();
mediaSpy!.mockRestore();
eventsSpy!.mockRestore();
});
continuation(vm);
}
test("participants are retained during a focus switch", () => {
withTestScheduler((helpers) => {
const { hot, expectObservable } = helpers;
// Participants disappear on frame 2 and come back on frame 3
const partMarbles = "a-ba";
// Start switching focus on frame 1 and reconnect on frame 3
const connMarbles = "ab-a";
// The visible participants should remain the same throughout the switch
const laytMarbles = "aaaa 2997ms a 56998ms a";
withCallViewModel(
helpers,
hot(partMarbles, {
a: [aliceParticipant, bobParticipant],
b: [],
}),
hot(connMarbles, {
a: ConnectionState.Connected,
b: ECAddonConnectionState.ECSwitchingFocus,
}),
(vm) => {
expectObservable(vm.layout.pipe(map(summarizeLayout))).toBe(
laytMarbles,
{
a: {
type: "grid",
spotlight: undefined,
grid: [":0", `${aliceId}:0`, `${bobId}:0`],
},
},
);
},
);
});
});
test("screen sharing activates spotlight layout", () => {
withTestScheduler((helpers) => {
const { hot, schedule, expectObservable } = helpers;
// Start with no screen shares, then have Alice and Bob share their screens,
// then return to no screen shares, then have just Alice share for a bit
const partMarbles = "abc---d---a-b---a";
// While there are no screen shares, switch to spotlight manually, and then
// switch back to grid at the end
const modeMarbles = "-----------a--------b";
// We should automatically enter spotlight for the first round of screen
// sharing, then return to grid, then manually go into spotlight, and
// remain in spotlight until we manually go back to grid
const laytMarbles = "ab(cc)(dd)ae(bb)(ee)a 59979ms a";
withCallViewModel(
helpers,
hot(partMarbles, {
a: [aliceParticipant, bobParticipant],
b: [aliceSharingScreen, bobParticipant],
c: [aliceSharingScreen, bobSharingScreen],
d: [aliceParticipant, bobSharingScreen],
}),
hot("a", { a: ConnectionState.Connected }),
(vm) => {
schedule(modeMarbles, {
a: () => vm.setGridMode("spotlight"),
b: () => vm.setGridMode("grid"),
});
expectObservable(vm.layout.pipe(map(summarizeLayout))).toBe(
laytMarbles,
{
a: {
type: "grid",
spotlight: undefined,
grid: [":0", `${aliceId}:0`, `${bobId}:0`],
},
b: {
type: "spotlight-landscape",
spotlight: [`${aliceId}:0:screen-share`],
grid: [":0", `${aliceId}:0`, `${bobId}:0`],
},
c: {
type: "spotlight-landscape",
spotlight: [
`${aliceId}:0:screen-share`,
`${bobId}:0:screen-share`,
],
grid: [":0", `${aliceId}:0`, `${bobId}:0`],
},
d: {
type: "spotlight-landscape",
spotlight: [`${bobId}:0:screen-share`],
grid: [":0", `${aliceId}:0`, `${bobId}:0`],
},
e: {
type: "spotlight-landscape",
spotlight: [`${aliceId}:0`],
grid: [":0", `${aliceId}:0`, `${bobId}:0`],
},
},
);
},
);
});
});

View File

@@ -26,13 +26,13 @@ import {
concat, concat,
distinctUntilChanged, distinctUntilChanged,
filter, filter,
forkJoin,
fromEvent, fromEvent,
map, map,
merge, merge,
mergeAll, mergeMap,
of, of,
race, race,
sample,
scan, scan,
skip, skip,
startWith, startWith,
@@ -42,7 +42,7 @@ import {
take, take,
throttleTime, throttleTime,
timer, timer,
zip, withLatestFrom,
} from "rxjs"; } from "rxjs";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
@@ -165,10 +165,19 @@ class UserMedia {
participant: LocalParticipant | RemoteParticipant, participant: LocalParticipant | RemoteParticipant,
callEncrypted: boolean, callEncrypted: boolean,
) { ) {
this.vm = this.vm = participant.isLocal
participant instanceof LocalParticipant ? new LocalUserMediaViewModel(
? new LocalUserMediaViewModel(id, member, participant, callEncrypted) id,
: new RemoteUserMediaViewModel(id, member, participant, callEncrypted); member,
participant as LocalParticipant,
callEncrypted,
)
: new RemoteUserMediaViewModel(
id,
member,
participant as RemoteParticipant,
callEncrypted,
);
this.speaker = this.vm.speaking.pipe( this.speaker = this.vm.speaking.pipe(
// Require 1 s of continuous speaking to become a speaker, and 60 s of // Require 1 s of continuous speaking to become a speaker, and 60 s of
@@ -182,6 +191,7 @@ class UserMedia {
), ),
), ),
startWith(false), startWith(false),
distinctUntilChanged(),
// Make this Observable hot so that the timers don't reset when you // Make this Observable hot so that the timers don't reset when you
// resubscribe // resubscribe
this.scope.state(), this.scope.state(),
@@ -252,10 +262,9 @@ export class CallViewModel extends ViewModel {
// Lists of participants to "hold" on display, even if LiveKit claims that // Lists of participants to "hold" on display, even if LiveKit claims that
// they've left // they've left
private readonly remoteParticipantHolds: Observable<RemoteParticipant[][]> = private readonly remoteParticipantHolds: Observable<RemoteParticipant[][]> =
zip( this.connectionState.pipe(
this.connectionState, withLatestFrom(this.rawRemoteParticipants),
this.rawRemoteParticipants.pipe(sample(this.connectionState)), mergeMap(([s, ps]) => {
(s, ps) => {
// Whenever we switch focuses, we should retain all the previous // Whenever we switch focuses, we should retain all the previous
// participants for at least POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS ms to // participants for at least POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS ms to
// give their clients time to switch over and avoid jarring layout shifts // give their clients time to switch over and avoid jarring layout shifts
@@ -264,29 +273,19 @@ export class CallViewModel extends ViewModel {
// Hold these participants // Hold these participants
of({ hold: ps }), of({ hold: ps }),
// Wait for time to pass and the connection state to have changed // Wait for time to pass and the connection state to have changed
Promise.all([ forkJoin([
new Promise<void>((resolve) => timer(POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS),
setTimeout(resolve, POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS), this.connectionState.pipe(
filter((s) => s !== ECAddonConnectionState.ECSwitchingFocus),
take(1),
), ),
new Promise<void>((resolve) => {
const subscription = this.connectionState
.pipe(this.scope.bind())
.subscribe((s) => {
if (s !== ECAddonConnectionState.ECSwitchingFocus) {
resolve();
subscription.unsubscribe();
}
});
}),
// Then unhold them // Then unhold them
]).then(() => ({ unhold: ps })), ]).pipe(map(() => ({ unhold: ps }))),
); );
} else { } else {
return EMPTY; return EMPTY;
} }
}, }),
).pipe(
mergeAll(),
// Accumulate the hold instructions into a single list showing which // Accumulate the hold instructions into a single list showing which
// participants are being held // participants are being held
accumulate([] as RemoteParticipant[][], (holds, instruction) => accumulate([] as RemoteParticipant[][], (holds, instruction) =>

View File

@@ -7,12 +7,13 @@ Please see LICENSE in the repository root for full details.
import { map } from "rxjs"; import { map } from "rxjs";
import { RunHelpers, TestScheduler } from "rxjs/testing"; import { RunHelpers, TestScheduler } from "rxjs/testing";
import { expect, vi } from "vitest"; import { expect, vi } from "vitest";
import { RoomMember } from "matrix-js-sdk/src/matrix"; import { RoomMember, Room as MatrixRoom } from "matrix-js-sdk/src/matrix";
import { import {
LocalParticipant, LocalParticipant,
LocalTrackPublication, LocalTrackPublication,
RemoteParticipant, RemoteParticipant,
RemoteTrackPublication, RemoteTrackPublication,
Room as LivekitRoom,
} from "livekit-client"; } from "livekit-client";
import { import {
@@ -62,7 +63,7 @@ export function withTestScheduler(
); );
} }
function mockMember(member: Partial<RoomMember>): RoomMember { export function mockMember(member: Partial<RoomMember>): RoomMember {
return { return {
on() { on() {
return this; return this;
@@ -80,14 +81,47 @@ function mockMember(member: Partial<RoomMember>): RoomMember {
} as RoomMember; } as RoomMember;
} }
export async function withLocalMedia( export function mockMatrixRoom(room: Partial<MatrixRoom>): MatrixRoom {
member: Partial<RoomMember>, return {
continuation: (vm: LocalUserMediaViewModel) => void | Promise<void>, on() {
): Promise<void> { return this as MatrixRoom;
const vm = new LocalUserMediaViewModel( },
"local", off() {
mockMember(member), return this as MatrixRoom;
{ },
addEventListener() {
return this as MatrixRoom;
},
removeEventListener() {
return this as MatrixRoom;
},
...room,
} as Partial<MatrixRoom> as MatrixRoom;
}
export function mockLivekitRoom(room: Partial<LivekitRoom>): LivekitRoom {
return {
on() {
return this as LivekitRoom;
},
off() {
return this as LivekitRoom;
},
addEventListener() {
return this as LivekitRoom;
},
removeEventListener() {
return this as LivekitRoom;
},
...room,
} as Partial<LivekitRoom> as LivekitRoom;
}
export function mockLocalParticipant(
participant: Partial<LocalParticipant>,
): LocalParticipant {
return {
isLocal: true,
getTrackPublication: () => getTrackPublication: () =>
({}) as Partial<LocalTrackPublication> as LocalTrackPublication, ({}) as Partial<LocalTrackPublication> as LocalTrackPublication,
on() { on() {
@@ -102,7 +136,18 @@ export async function withLocalMedia(
removeListener() { removeListener() {
return this as LocalParticipant; return this as LocalParticipant;
}, },
} as Partial<LocalParticipant> as LocalParticipant, ...participant,
} as Partial<LocalParticipant> as LocalParticipant;
}
export async function withLocalMedia(
member: Partial<RoomMember>,
continuation: (vm: LocalUserMediaViewModel) => void | Promise<void>,
): Promise<void> {
const vm = new LocalUserMediaViewModel(
"local",
mockMember(member),
mockLocalParticipant({}),
true, true,
); );
try { try {
@@ -112,15 +157,11 @@ export async function withLocalMedia(
} }
} }
export async function withRemoteMedia( export function mockRemoteParticipant(
member: Partial<RoomMember>,
participant: Partial<RemoteParticipant>, participant: Partial<RemoteParticipant>,
continuation: (vm: RemoteUserMediaViewModel) => void | Promise<void>, ): RemoteParticipant {
): Promise<void> { return {
const vm = new RemoteUserMediaViewModel( isLocal: false,
"remote",
mockMember(member),
{
setVolume() {}, setVolume() {},
getTrackPublication: () => getTrackPublication: () =>
({}) as Partial<RemoteTrackPublication> as RemoteTrackPublication, ({}) as Partial<RemoteTrackPublication> as RemoteTrackPublication,
@@ -137,7 +178,18 @@ export async function withRemoteMedia(
return this; return this;
}, },
...participant, ...participant,
} as RemoteParticipant, } as RemoteParticipant;
}
export async function withRemoteMedia(
member: Partial<RoomMember>,
participant: Partial<RemoteParticipant>,
continuation: (vm: RemoteUserMediaViewModel) => void | Promise<void>,
): Promise<void> {
const vm = new RemoteUserMediaViewModel(
"remote",
mockMember(member),
mockRemoteParticipant(participant),
true, true,
); );
try { try {