Test CallViewModel in all MatrixRTC modes

This commit is contained in:
Robin
2025-12-08 22:42:57 -05:00
parent 2986f90a5f
commit 5a9a62039c
3 changed files with 165 additions and 140 deletions

View File

@@ -60,7 +60,8 @@ import {
import { MediaDevices } from "../MediaDevices.ts"; import { MediaDevices } from "../MediaDevices.ts";
import { getValue } from "../../utils/observable.ts"; import { getValue } from "../../utils/observable.ts";
import { type Behavior, constant } from "../Behavior.ts"; import { type Behavior, constant } from "../Behavior.ts";
import { withCallViewModel } from "./CallViewModelTestUtils.ts"; import { withCallViewModel as withCallViewModelInMode } from "./CallViewModelTestUtils.ts";
import { MatrixRTCMode } from "../../settings/settings.ts";
vi.mock("rxjs", async (importOriginal) => ({ vi.mock("rxjs", async (importOriginal) => ({
...(await importOriginal()), ...(await importOriginal()),
@@ -229,7 +230,13 @@ function mockRingEvent(
// need a value to fill in for them when emitting notifications // need a value to fill in for them when emitting notifications
const mockLegacyRingEvent = {} as { event_id: string } & ICallNotifyContent; const mockLegacyRingEvent = {} as { event_id: string } & ICallNotifyContent;
describe("CallViewModel", () => { describe.each([
[MatrixRTCMode.Legacy],
[MatrixRTCMode.Compatibil],
[MatrixRTCMode.Matrix_2_0],
])("CallViewModel (%s mode)", (mode) => {
const withCallViewModel = withCallViewModelInMode(mode);
test("participants are retained during a focus switch", () => { test("participants are retained during a focus switch", () => {
withTestScheduler(({ behavior, expectObservable }) => { withTestScheduler(({ behavior, expectObservable }) => {
// Participants disappear on frame 2 and come back on frame 3 // Participants disappear on frame 2 and come back on frame 3

View File

@@ -53,6 +53,7 @@ import {
import { type Behavior, constant } from "../Behavior"; import { type Behavior, constant } from "../Behavior";
import { type ProcessorState } from "../../livekit/TrackProcessorContext"; import { type ProcessorState } from "../../livekit/TrackProcessorContext";
import { type MediaDevices } from "../MediaDevices"; import { type MediaDevices } from "../MediaDevices";
import { type MatrixRTCMode } from "../../settings/settings";
mockConfig({ mockConfig({
livekit: { livekit_service_url: "http://my-default-service-url.com" }, livekit: { livekit_service_url: "http://my-default-service-url.com" },
@@ -80,117 +81,126 @@ export interface CallViewModelInputs {
const localParticipant = mockLocalParticipant({ identity: "" }); const localParticipant = mockLocalParticipant({ identity: "" });
export function withCallViewModel( export function withCallViewModel(mode: MatrixRTCMode) {
{ return (
remoteParticipants$ = constant([]), {
rtcMembers$ = constant([localRtcMember]), remoteParticipants$ = constant([]),
livekitConnectionState$: connectionState$ = constant( rtcMembers$ = constant([localRtcMember]),
ConnectionState.Connected, livekitConnectionState$: connectionState$ = constant(
), ConnectionState.Connected,
speaking = new Map(), ),
mediaDevices = mockMediaDevices({}), speaking = new Map(),
initialSyncState = SyncState.Syncing, mediaDevices = mockMediaDevices({}),
windowSize$ = constant({ width: 1000, height: 800 }), initialSyncState = SyncState.Syncing,
}: Partial<CallViewModelInputs> = {}, windowSize$ = constant({ width: 1000, height: 800 }),
continuation: ( }: Partial<CallViewModelInputs> = {},
vm: CallViewModel, continuation: (
rtcSession: MockRTCSession, vm: CallViewModel,
subjects: { raisedHands$: BehaviorSubject<Record<string, RaisedHandInfo>> }, rtcSession: MockRTCSession,
setSyncState: (value: SyncState) => void, subjects: {
) => void, raisedHands$: BehaviorSubject<Record<string, RaisedHandInfo>>;
options: CallViewModelOptions = { },
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, setSyncState: (value: SyncState) => void,
autoLeaveWhenOthersLeft: false, ) => void,
}, options: CallViewModelOptions = {
): void { encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
let syncState = initialSyncState; autoLeaveWhenOthersLeft: false,
const setSyncState = (value: SyncState): void => { },
const prev = syncState; ): void => {
syncState = value; let syncState = initialSyncState;
room.client.emit(ClientEvent.Sync, value, prev); const setSyncState = (value: SyncState): void => {
}; const prev = syncState;
const room = mockMatrixRoom({ syncState = value;
client: new (class extends EventEmitter { room.client.emit(ClientEvent.Sync, value, prev);
public getUserId(): string | undefined { };
return localRtcMember.userId; const room = mockMatrixRoom({
} client: new (class extends EventEmitter {
public getUserId(): string | undefined {
return localRtcMember.userId;
}
public getDeviceId(): string { public getDeviceId(): string {
return localRtcMember.deviceId; return localRtcMember.deviceId;
} }
public getDomain(): string { public getDomain(): string {
return "example.com"; return "example.com";
} }
public getSyncState(): SyncState { public getSyncState(): SyncState {
return syncState; return syncState;
} }
})() as Partial<MatrixClient> as MatrixClient, })() as Partial<MatrixClient> as MatrixClient,
getMembers: () => Array.from(roomMembers.values()), getMembers: () => Array.from(roomMembers.values()),
getMembersWithMembership: () => Array.from(roomMembers.values()), getMembersWithMembership: () => Array.from(roomMembers.values()),
}); });
const rtcSession = new MockRTCSession(room, []).withMemberships(rtcMembers$); const rtcSession = new MockRTCSession(room, []).withMemberships(
const participantsSpy = vi rtcMembers$,
.spyOn(ComponentsCore, "connectedParticipantsObserver")
.mockReturnValue(remoteParticipants$);
const mediaSpy = vi
.spyOn(ComponentsCore, "observeParticipantMedia")
.mockImplementation((p) =>
of({ participant: p } as Partial<
ComponentsCore.ParticipantMedia<LocalParticipant>
> as ComponentsCore.ParticipantMedia<LocalParticipant>),
); );
const eventsSpy = vi const participantsSpy = vi
.spyOn(ComponentsCore, "observeParticipantEvents") .spyOn(ComponentsCore, "connectedParticipantsObserver")
.mockImplementation((p, ...eventTypes) => { .mockReturnValue(remoteParticipants$);
if (eventTypes.includes(ParticipantEvent.IsSpeakingChanged)) { const mediaSpy = vi
return (speaking.get(p) ?? of(false)).pipe( .spyOn(ComponentsCore, "observeParticipantMedia")
map((s): Participant => ({ ...p, isSpeaking: s }) as Participant), .mockImplementation((p) =>
); of({ participant: p } as Partial<
} else { ComponentsCore.ParticipantMedia<LocalParticipant>
return of(p); > as ComponentsCore.ParticipantMedia<LocalParticipant>),
} );
const eventsSpy = vi
.spyOn(ComponentsCore, "observeParticipantEvents")
.mockImplementation((p, ...eventTypes) => {
if (eventTypes.includes(ParticipantEvent.IsSpeakingChanged)) {
return (speaking.get(p) ?? of(false)).pipe(
map((s): Participant => ({ ...p, isSpeaking: s }) as Participant),
);
} else {
return of(p);
}
});
const roomEventSelectorSpy = vi
.spyOn(ComponentsCore, "roomEventSelector")
.mockImplementation((_room, _eventType) => of());
const muteStates = mockMuteStates();
const raisedHands$ = new BehaviorSubject<Record<string, RaisedHandInfo>>(
{},
);
const reactions$ = new BehaviorSubject<Record<string, ReactionInfo>>({});
const vm = createCallViewModel$(
testScope(),
rtcSession.asMockedSession(),
room,
mediaDevices,
muteStates,
{
...options,
livekitRoomFactory: (): LivekitRoom =>
mockLivekitRoom({
localParticipant,
disconnect: async () => Promise.resolve(),
setE2EEEnabled: async () => Promise.resolve(),
}),
connectionState$,
windowSize$,
matrixRTCMode$: constant(mode),
},
raisedHands$,
reactions$,
new BehaviorSubject<ProcessorState>({
processor: undefined,
supported: undefined,
}),
);
onTestFinished(() => {
participantsSpy.mockRestore();
mediaSpy.mockRestore();
eventsSpy.mockRestore();
roomEventSelectorSpy.mockRestore();
}); });
const roomEventSelectorSpy = vi continuation(vm, rtcSession, { raisedHands$: raisedHands$ }, setSyncState);
.spyOn(ComponentsCore, "roomEventSelector") };
.mockImplementation((_room, _eventType) => of());
const muteStates = mockMuteStates();
const raisedHands$ = new BehaviorSubject<Record<string, RaisedHandInfo>>({});
const reactions$ = new BehaviorSubject<Record<string, ReactionInfo>>({});
const vm = createCallViewModel$(
testScope(),
rtcSession.asMockedSession(),
room,
mediaDevices,
muteStates,
{
...options,
livekitRoomFactory: (): LivekitRoom =>
mockLivekitRoom({
localParticipant,
disconnect: async () => Promise.resolve(),
setE2EEEnabled: async () => Promise.resolve(),
}),
connectionState$,
windowSize$,
},
raisedHands$,
reactions$,
new BehaviorSubject<ProcessorState>({
processor: undefined,
supported: undefined,
}),
);
onTestFinished(() => {
participantsSpy.mockRestore();
mediaSpy.mockRestore();
eventsSpy.mockRestore();
roomEventSelectorSpy.mockRestore();
});
continuation(vm, rtcSession, { raisedHands$: raisedHands$ }, setSyncState);
} }

View File

@@ -15,6 +15,7 @@ import { constant } from "./Behavior.ts";
import { aliceParticipant, localRtcMember } from "../utils/test-fixtures.ts"; import { aliceParticipant, localRtcMember } from "../utils/test-fixtures.ts";
import { ElementWidgetActions, widget } from "../widget.ts"; import { ElementWidgetActions, widget } from "../widget.ts";
import { E2eeType } from "../e2ee/e2eeType.ts"; import { E2eeType } from "../e2ee/e2eeType.ts";
import { MatrixRTCMode } from "../settings/settings.ts";
vi.mock("@livekit/components-core", { spy: true }); vi.mock("@livekit/components-core", { spy: true });
@@ -34,36 +35,43 @@ vi.mock("../widget", () => ({
}, },
})); }));
it("expect leave when ElementWidgetActions.HangupCall is called", async () => { it.each([
const pr = Promise.withResolvers<string>(); [MatrixRTCMode.Legacy],
withCallViewModel( [MatrixRTCMode.Compatibil],
{ [MatrixRTCMode.Matrix_2_0],
remoteParticipants$: constant([aliceParticipant]), ])(
rtcMembers$: constant([localRtcMember]), "expect leave when ElementWidgetActions.HangupCall is called (%s mode)",
}, async (mode) => {
(vm: CallViewModel) => { const pr = Promise.withResolvers<string>();
vm.leave$.subscribe((s: string) => { withCallViewModel(mode)(
pr.resolve(s); {
}); remoteParticipants$: constant([aliceParticipant]),
rtcMembers$: constant([localRtcMember]),
},
(vm: CallViewModel) => {
vm.leave$.subscribe((s: string) => {
pr.resolve(s);
});
widget!.lazyActions!.emit( widget!.lazyActions!.emit(
ElementWidgetActions.HangupCall, ElementWidgetActions.HangupCall,
new CustomEvent(ElementWidgetActions.HangupCall, { new CustomEvent(ElementWidgetActions.HangupCall, {
detail: { detail: {
action: "im.vector.hangup", action: "im.vector.hangup",
api: "toWidget", api: "toWidget",
data: {}, data: {},
requestId: "widgetapi-1761237395918", requestId: "widgetapi-1761237395918",
widgetId: "mrUjS9T6uKUOWHMxXvLbSv0F", widgetId: "mrUjS9T6uKUOWHMxXvLbSv0F",
}, },
}), }),
); );
}, },
{ {
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
}, },
); );
const source = await pr.promise; const source = await pr.promise;
expect(source).toBe("user"); expect(source).toBe("user");
}); },
);