Fix a couple of CallViewModel tests.

This commit is contained in:
Timo K
2025-11-14 10:44:16 +01:00
parent 0115242a2b
commit fdce3ec1aa
9 changed files with 1426 additions and 1378 deletions

View File

@@ -93,6 +93,9 @@ import {
MatrixRTCTransportMissingError, MatrixRTCTransportMissingError,
} from "../../utils/errors.ts"; } from "../../utils/errors.ts";
mockConfig({
livekit: { livekit_service_url: "http://my-default-service-url.com" },
});
vi.mock("rxjs", async (importOriginal) => ({ vi.mock("rxjs", async (importOriginal) => ({
...(await importOriginal()), ...(await importOriginal()),
// Disable interval Observables for the following tests since the test // Disable interval Observables for the following tests since the test
@@ -384,6 +387,7 @@ export function withCallViewModel(
continuation(vm, rtcSession, { raisedHands$: raisedHands$ }, setSyncState); continuation(vm, rtcSession, { raisedHands$: raisedHands$ }, setSyncState);
} }
describe("CallViewModel", () => {
// TODO: Restore this test. It requires makeTransport to not be mocked, unlike // TODO: Restore this test. It requires makeTransport to not be mocked, unlike
// the rest of the tests in this file… what do we do? // the rest of the tests in this file… what do we do?
test.skip("test missing RTC config error", async () => { test.skip("test missing RTC config error", async () => {
@@ -414,6 +418,7 @@ test.skip("test missing RTC config error", async () => {
); );
mockConfig({}); mockConfig({});
vi.spyOn(AutoDiscovery, "getRawClientConfig").mockResolvedValue({}); vi.spyOn(AutoDiscovery, "getRawClientConfig").mockResolvedValue({});
const callVM = new CallViewModel( const callVM = new CallViewModel(
@@ -682,7 +687,8 @@ test("participants adjust order when space becomes constrained", () => {
(vm) => { (vm) => {
let setVisibleTiles: ((value: number) => void) | null = null; let setVisibleTiles: ((value: number) => void) | null = null;
vm.layout$.subscribe((layout) => { vm.layout$.subscribe((layout) => {
if (layout.type === "grid") setVisibleTiles = layout.setVisibleTiles; if (layout.type === "grid")
setVisibleTiles = layout.setVisibleTiles;
}); });
schedule(visibilityInputMarbles, { schedule(visibilityInputMarbles, {
a: () => setVisibleTiles!(Infinity), a: () => setVisibleTiles!(Infinity),
@@ -1571,7 +1577,10 @@ describe("waitForCallPickup$", () => {
d: () => { d: () => {
rtcSession.room.emit( rtcSession.room.emit(
MatrixRoomEvent.Timeline, MatrixRoomEvent.Timeline,
new MatrixEvent({ event_id: "$decl2", type: "m.rtc.decline" }), new MatrixEvent({
event_id: "$decl2",
type: "m.rtc.decline",
}),
rtcSession.room, rtcSession.room,
undefined, undefined,
false, false,
@@ -1652,7 +1661,9 @@ describe("waitForCallPickup$", () => {
test("audio output changes when toggling earpiece mode", () => { test("audio output changes when toggling earpiece mode", () => {
withTestScheduler(({ schedule, expectObservable }) => { withTestScheduler(({ schedule, expectObservable }) => {
getUrlParams.mockReturnValue({ controlledAudioDevices: true }); getUrlParams.mockReturnValue({ controlledAudioDevices: true });
vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue(of([])); vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue(
of([]),
);
const devices = new MediaDevices(testScope()); const devices = new MediaDevices(testScope());
@@ -1676,7 +1687,9 @@ test("audio output changes when toggling earpiece mode", () => {
yesNo, yesNo,
); );
expectObservable( expectObservable(
vm.audioOutputSwitcher$.pipe(map((switcher) => switcher?.targetOutput)), vm.audioOutputSwitcher$.pipe(
map((switcher) => switcher?.targetOutput),
),
).toBe(expectedTargetStateMarbles, { s: "speaker", e: "earpiece" }); ).toBe(expectedTargetStateMarbles, { s: "speaker", e: "earpiece" });
}); });
}); });
@@ -1756,3 +1769,4 @@ test("media tracks are paused while reconnecting to MatrixRTC", () => {
); );
}); });
}); });
});

View File

@@ -182,6 +182,21 @@ export class CallViewModel {
this.matrixRTCSession, this.matrixRTCSession,
); );
// Each hbar seperates a block of input variables required for the CallViewModel to function.
// The outputs of this block is written under the hbar.
//
// For mocking purposes it is recommended to only mock the functions creating those outputs.
// All other fields are just temp computations for the mentioned output.
// The class does not need anything except the values underneath the bar.
// The creation of the values under the bar are all tested independently and testing the callViewModel Should
// not test their cretation. Call view model only needs:
// - memberships$ via createMemberships$
// - localMembership via createLocalMembership$
// - callLifecycle via createCallNotificationLifecycle$
// - matrixMemberMetadataStore via createMatrixMemberMetadata$
// ------------------------------------------------------------------------
// memberships$
private memberships$ = createMemberships$(this.scope, this.matrixRTCSession); private memberships$ = createMemberships$(this.scope, this.matrixRTCSession);
private membershipsAndTransports = membershipsAndTransports$( private membershipsAndTransports = membershipsAndTransports$(
@@ -189,6 +204,9 @@ export class CallViewModel {
this.memberships$, this.memberships$,
); );
// ------------------------------------------------------------------------
// matrixLivekitMembers$ AND localMembership
private localTransport$ = createLocalTransport$({ private localTransport$ = createLocalTransport$({
scope: this.scope, scope: this.scope,
memberships$: this.memberships$, memberships$: this.memberships$,
@@ -199,18 +217,19 @@ export class CallViewModel {
), ),
}); });
// ------------------------------------------------------------------------
private connectionFactory = new ECConnectionFactory( private connectionFactory = new ECConnectionFactory(
this.matrixRoom.client, this.matrixRoom.client,
this.mediaDevices, this.mediaDevices,
this.trackProcessorState$, this.trackProcessorState$,
this.livekitKeyProvider, this.livekitKeyProvider,
getUrlParams().controlledAudioDevices, getUrlParams().controlledAudioDevices,
this.options.livekitRoomFactory,
); );
// Can contain duplicates. The connection manager will take care of this. private connectionManager = createConnectionManager$({
private allTransports$ = this.scope.behavior( scope: this.scope,
connectionFactory: this.connectionFactory,
inputTransports$: this.scope.behavior(
combineLatest( combineLatest(
[this.localTransport$, this.membershipsAndTransports.transports$], [this.localTransport$, this.membershipsAndTransports.transports$],
(localTransport, transports) => { (localTransport, transports) => {
@@ -221,16 +240,9 @@ export class CallViewModel {
]); ]);
}, },
), ),
); ),
private connectionManager = createConnectionManager$({
scope: this.scope,
connectionFactory: this.connectionFactory,
inputTransports$: this.allTransports$,
}); });
// ------------------------------------------------------------------------
private matrixLivekitMembers$ = createMatrixLivekitMembers$({ private matrixLivekitMembers$ = createMatrixLivekitMembers$({
scope: this.scope, scope: this.scope,
membershipsWithTransport$: membershipsWithTransport$:
@@ -273,6 +285,7 @@ export class CallViewModel {
), ),
), ),
); );
private localMatrixLivekitMemberUninitialized = { private localMatrixLivekitMemberUninitialized = {
membership$: this.localRtcMembership$, membership$: this.localRtcMembership$,
participant$: this.localMembership.participant$, participant$: this.localMembership.participant$,
@@ -294,6 +307,7 @@ export class CallViewModel {
); );
// ------------------------------------------------------------------------ // ------------------------------------------------------------------------
// callLifecycle
private callLifecycle = createCallNotificationLifecycle$({ private callLifecycle = createCallNotificationLifecycle$({
scope: this.scope, scope: this.scope,
@@ -309,6 +323,13 @@ export class CallViewModel {
public autoLeave$ = this.callLifecycle.autoLeave$; public autoLeave$ = this.callLifecycle.autoLeave$;
// ------------------------------------------------------------------------ // ------------------------------------------------------------------------
// matrixMemberMetadataStore
private matrixMemberMetadataStore = createMatrixMemberMetadata$(
this.scope,
this.scope.behavior(this.memberships$.pipe(map((mems) => mems.value))),
createRoomMembers$(this.scope, this.matrixRoom),
);
/** /**
* If there is a configuration error with the call (e.g. misconfigured E2EE). * If there is a configuration error with the call (e.g. misconfigured E2EE).
@@ -402,12 +423,6 @@ export class CallViewModel {
), ),
); );
private matrixMemberMetadataStore = createMatrixMemberMetadata$(
this.scope,
this.scope.behavior(this.memberships$.pipe(map((mems) => mems.value))),
createRoomMembers$(this.scope, this.matrixRoom),
);
/** /**
* List of user media (camera feeds) that we want tiles for. * List of user media (camera feeds) that we want tiles for.
*/ */
@@ -426,20 +441,23 @@ export class CallViewModel {
{ value: matrixLivekitMembers }, { value: matrixLivekitMembers },
duplicateTiles, duplicateTiles,
]) { ]) {
let localParticipantId = undefined;
// add local member if available // add local member if available
if (localMatrixLivekitMember) { if (localMatrixLivekitMember) {
const { const { userId, participant$, connection$, membership$ } =
localMatrixLivekitMember;
localParticipantId = `${userId}:${membership$.value.deviceId}`; // should be membership$.value.membershipID which is not optional
// const participantId = membership$.value.membershipID;
if (localParticipantId) {
for (let dup = 0; dup < 1 + duplicateTiles; dup++) {
yield {
keys: [
dup,
localParticipantId,
userId, userId,
participant$, participant$,
connection$, connection$,
// membership$, ],
} = localMatrixLivekitMember;
const participantId = participant$.value?.identity; // should be membership$.value.membershipID which is not optional
// const participantId = membership$.value.membershipID;
if (participantId) {
for (let dup = 0; dup < 1 + duplicateTiles; dup++) {
yield {
keys: [dup, participantId, userId, participant$, connection$],
data: undefined, data: undefined,
}; };
} }
@@ -450,11 +468,11 @@ export class CallViewModel {
userId, userId,
participant$, participant$,
connection$, connection$,
// membership$ membership$,
} of matrixLivekitMembers) { } of matrixLivekitMembers) {
const participantId = participant$.value?.identity; const participantId = `${userId}:${membership$.value.deviceId}`;
if (participantId === localParticipantId) continue;
// const participantId = membership$.value?.identity; // const participantId = membership$.value?.identity;
if (!participantId) continue;
for (let dup = 0; dup < 1 + duplicateTiles; dup++) { for (let dup = 0; dup < 1 + duplicateTiles; dup++) {
yield { yield {
keys: [dup, participantId, userId, participant$, connection$], keys: [dup, participantId, userId, participant$, connection$],
@@ -550,9 +568,8 @@ export class CallViewModel {
* - There can be multiple participants for one Matrix user if they join from * - There can be multiple participants for one Matrix user if they join from
* multiple devices. * multiple devices.
*/ */
// TODO KEEP THIS!! and adapt it to what our membershipManger returns
public readonly participantCount$ = this.scope.behavior( public readonly participantCount$ = this.scope.behavior(
this.memberships$.pipe(map((ms) => ms.value.length)), this.matrixLivekitMembers$.pipe(map((ms) => ms.value.length)),
); );
// only public to expose to the view. // only public to expose to the view.

View File

@@ -30,7 +30,7 @@ import {
startWith, startWith,
switchMap, switchMap,
} from "rxjs"; } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { type Behavior } from "../../Behavior"; import { type Behavior } from "../../Behavior";
import { type IConnectionManager } from "../remoteMembers/ConnectionManager"; import { type IConnectionManager } from "../remoteMembers/ConnectionManager";
@@ -52,7 +52,7 @@ import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics.ts";
import { MatrixRTCMode } from "../../../settings/settings.ts"; import { MatrixRTCMode } from "../../../settings/settings.ts";
import { Config } from "../../../config/Config.ts"; import { Config } from "../../../config/Config.ts";
import { type Connection } from "../remoteMembers/Connection.ts"; import { type Connection } from "../remoteMembers/Connection.ts";
const logger = rootLogger.getChild("[LocalMembership]");
export enum LivekitState { export enum LivekitState {
Uninitialized = "uninitialized", Uninitialized = "uninitialized",
Connecting = "connecting", Connecting = "connecting",
@@ -323,10 +323,19 @@ export const createLocalMembership$ = ({
!connectRequested || !connectRequested ||
state.matrix$.value.state !== MatrixState.Disconnected state.matrix$.value.state !== MatrixState.Disconnected
) { ) {
logger.info("Waiting for transport to enter rtc session"); logger.info(
"Not yet connecting because: ",
"transport === null:",
transport === null,
"!connectRequested:",
!connectRequested,
"state.matrix$.value.state !== MatrixState.Disconnected:",
state.matrix$.value.state !== MatrixState.Disconnected,
);
return; return;
} }
state.matrix$.next({ state: MatrixState.Connecting }); state.matrix$.next({ state: MatrixState.Connecting });
logger.info("Matrix State connecting");
enterRTCSession(matrixRTCSession, transport, options.value).catch( enterRTCSession(matrixRTCSession, transport, options.value).catch(
(error) => { (error) => {
logger.error(error); logger.error(error);
@@ -376,7 +385,9 @@ export const createLocalMembership$ = ({
for (const p of publications) { for (const p of publications) {
if (p.track?.isUpstreamPaused === true) { if (p.track?.isUpstreamPaused === true) {
const kind = p.track.kind; const kind = p.track.kind;
logger.log(`Resuming ${kind} track (MatrixRTC connection present)`); logger.info(
`Resuming ${kind} track (MatrixRTC connection present)`,
);
p.track p.track
.resumeUpstream() .resumeUpstream()
.catch((e) => .catch((e) =>
@@ -391,7 +402,7 @@ export const createLocalMembership$ = ({
for (const p of publications) { for (const p of publications) {
if (p.track?.isUpstreamPaused === false) { if (p.track?.isUpstreamPaused === false) {
const kind = p.track.kind; const kind = p.track.kind;
logger.log( logger.info(
`Pausing ${kind} track (uncertain MatrixRTC connection)`, `Pausing ${kind} track (uncertain MatrixRTC connection)`,
); );
p.track p.track

View File

@@ -117,7 +117,7 @@ export function createConnectionManager$({
connectionFactory, connectionFactory,
inputTransports$, inputTransports$,
}: Props): IConnectionManager { }: Props): IConnectionManager {
const logger = rootLogger.getChild("ConnectionManager"); const logger = rootLogger.getChild("[ConnectionManager]");
const running$ = new BehaviorSubject(true); const running$ = new BehaviorSubject(true);
scope.onEnd(() => running$.next(false)); scope.onEnd(() => running$.next(false));

View File

@@ -22,7 +22,7 @@ import { Epoch, type ObservableScope } from "../../ObservableScope";
import { type Connection } from "./Connection"; import { type Connection } from "./Connection";
import { generateItemsWithEpoch } from "../../../utils/observable"; import { generateItemsWithEpoch } from "../../../utils/observable";
const logger = rootLogger.getChild("MatrixLivekitMembers"); const logger = rootLogger.getChild("[MatrixLivekitMembers]");
/** /**
* Represents a Matrix call member and their associated LiveKit participation. * Represents a Matrix call member and their associated LiveKit participation.

View File

@@ -74,15 +74,7 @@ export class ObservableScope {
// they will no longer re-emit their current value upon subscription. We want // they will no longer re-emit their current value upon subscription. We want
// to support Observables that complete (for example `of({})`), so we have to // to support Observables that complete (for example `of({})`), so we have to
// take care to not propagate the completion event. // take care to not propagate the completion event.
setValue$ setValue$.pipe(this.bind(), distinctUntilChanged()).subscribe({
.pipe(
this.bind(),
distinctUntilChanged((a, b) => {
logger.log("distinctUntilChanged", a, b);
return a === b;
}),
)
.subscribe({
next(value) { next(value) {
subject$.next(value); subject$.next(value);
}, },

View File

@@ -76,6 +76,6 @@ export const createMemberships$ = (
MatrixRTCSessionEvent.MembershipsChanged, MatrixRTCSessionEvent.MembershipsChanged,
(_, memberships: CallMembership[]) => memberships, (_, memberships: CallMembership[]) => memberships,
).pipe(trackEpoch()), ).pipe(trackEpoch()),
new Epoch([]), new Epoch(matrixRTCSession.memberships),
); );
}; };

View File

@@ -11,9 +11,9 @@ import {
mockRemoteParticipant, mockRemoteParticipant,
} from "./test"; } from "./test";
export const localRtcMember = mockRtcMembership("@carol:example.org", "1111"); export const localRtcMember = mockRtcMembership("@local:example.org", "1111");
export const localRtcMemberDevice2 = mockRtcMembership( export const localRtcMemberDevice2 = mockRtcMembership(
"@carol:example.org", "@local:example.org",
"2222", "2222",
); );
export const local = mockMatrixRoomMember(localRtcMember); export const local = mockMatrixRoomMember(localRtcMember);

View File

@@ -6,7 +6,14 @@ Please see LICENSE in the repository root for full details.
*/ */
import { map, type Observable, of, type SchedulerLike } from "rxjs"; import { map, type Observable, of, type SchedulerLike } from "rxjs";
import { type RunHelpers, TestScheduler } from "rxjs/testing"; import { type RunHelpers, TestScheduler } from "rxjs/testing";
import { expect, type MockedObject, onTestFinished, vi, vitest } from "vitest"; import {
expect,
type MockedObject,
type MockInstance,
onTestFinished,
vi,
vitest,
} from "vitest";
import { import {
MatrixEvent, MatrixEvent,
type Room as MatrixRoom, type Room as MatrixRoom,
@@ -269,6 +276,7 @@ export function mockLivekitRoom(
}: { remoteParticipants$?: Observable<RemoteParticipant[]> } = {}, }: { remoteParticipants$?: Observable<RemoteParticipant[]> } = {},
): LivekitRoom { ): LivekitRoom {
const livekitRoom = { const livekitRoom = {
options: {},
...mockEmitter(), ...mockEmitter(),
...room, ...room,
} as Partial<LivekitRoom> as LivekitRoom; } as Partial<LivekitRoom> as LivekitRoom;
@@ -291,6 +299,7 @@ export function mockLocalParticipant(
return { return {
isLocal: true, isLocal: true,
trackPublications: new Map(), trackPublications: new Map(),
unpublishTracks: async () => Promise.resolve(),
getTrackPublication: () => getTrackPublication: () =>
({}) as Partial<LocalTrackPublication> as LocalTrackPublication, ({}) as Partial<LocalTrackPublication> as LocalTrackPublication,
...mockEmitter(), ...mockEmitter(),
@@ -331,6 +340,8 @@ export function mockRemoteParticipant(
setVolume() {}, setVolume() {},
getTrackPublication: () => getTrackPublication: () =>
({}) as Partial<RemoteTrackPublication> as RemoteTrackPublication, ({}) as Partial<RemoteTrackPublication> as RemoteTrackPublication,
// this will only get used for `getTrackPublications().length`
getTrackPublications: () => [0],
...mockEmitter(), ...mockEmitter(),
...participant, ...participant,
} as RemoteParticipant; } as RemoteParticipant;
@@ -363,13 +374,16 @@ export function createRemoteMedia(
); );
} }
export function mockConfig(config: Partial<ResolvedConfigOptions> = {}): void { export function mockConfig(
vi.spyOn(Config, "get").mockReturnValue({ config: Partial<ResolvedConfigOptions> = {},
): MockInstance<() => ResolvedConfigOptions> {
const spy = vi.spyOn(Config, "get").mockReturnValue({
...DEFAULT_CONFIG, ...DEFAULT_CONFIG,
...config, ...config,
}); });
// simulate loading the config // simulate loading the config
vi.spyOn(Config, "init").mockResolvedValue(void 0); vi.spyOn(Config, "init").mockResolvedValue(void 0);
return spy;
} }
export class MockRTCSession extends TypedEventEmitter< export class MockRTCSession extends TypedEventEmitter<