Fix remaining tests

This commit is contained in:
Robin
2025-07-11 23:53:31 -04:00
parent 434712ba17
commit 586a923be3
4 changed files with 185 additions and 124 deletions

View File

@@ -16,7 +16,6 @@ import {
import { render, waitFor, screen } from "@testing-library/react"; import { render, waitFor, screen } from "@testing-library/react";
import { type MatrixClient, JoinRule, type RoomState } from "matrix-js-sdk"; import { type MatrixClient, JoinRule, type RoomState } from "matrix-js-sdk";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { of } from "rxjs";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container"; import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container";
@@ -43,6 +42,7 @@ import { MatrixRTCFocusMissingError } from "../utils/errors";
import { ProcessorProvider } from "../livekit/TrackProcessorContext"; import { ProcessorProvider } from "../livekit/TrackProcessorContext";
import { MediaDevicesContext } from "../MediaDevicesContext"; import { MediaDevicesContext } from "../MediaDevicesContext";
import { HeaderStyle } from "../UrlParams"; import { HeaderStyle } from "../UrlParams";
import { constant } from "../state/Behavior";
vi.mock("../soundUtils"); vi.mock("../soundUtils");
vi.mock("../useAudioContext"); vi.mock("../useAudioContext");
@@ -141,7 +141,7 @@ function createGroupCallView(
room, room,
localRtcMember, localRtcMember,
[], [],
).withMemberships(of([])); ).withMemberships(constant([]));
rtcSession.joined = joined; rtcSession.joined = joined;
const muteState = { const muteState = {
audio: { enabled: false }, audio: { enabled: false },

View File

@@ -44,8 +44,19 @@ Observable.prototype.behavior = function <T>(
scope: ObservableScope, scope: ObservableScope,
): Behavior<T> { ): Behavior<T> {
const subject$ = new BehaviorSubject<T | typeof nothing>(nothing); const subject$ = new BehaviorSubject<T | typeof nothing>(nothing);
// Push values from the Observable into the BehaviorSubject // Push values from the Observable into the BehaviorSubject.
this.pipe(scope.bind(), distinctUntilChanged()).subscribe(subject$); // BehaviorSubjects have an undesirable feature where if you call 'complete',
// 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
// take care to not propagate the completion event.
this.pipe(scope.bind(), distinctUntilChanged()).subscribe({
next(value) {
subject$.next(value);
},
error(err) {
subject$.error(err);
},
});
if (subject$.value === nothing) if (subject$.value === nothing)
throw new Error("Behavior failed to synchronously emit an initial value"); throw new Error("Behavior failed to synchronously emit an initial value");
return subject$ as Behavior<T>; return subject$ as Behavior<T>;

View File

@@ -12,9 +12,9 @@ import {
debounceTime, debounceTime,
distinctUntilChanged, distinctUntilChanged,
map, map,
NEVER,
type Observable, type Observable,
of, of,
skip,
switchMap, switchMap,
} from "rxjs"; } from "rxjs";
import { type MatrixClient } from "matrix-js-sdk"; import { type MatrixClient } from "matrix-js-sdk";
@@ -75,11 +75,18 @@ import {
import { ObservableScope } from "./ObservableScope"; import { ObservableScope } from "./ObservableScope";
import { MediaDevices } from "./MediaDevices"; import { MediaDevices } from "./MediaDevices";
import { getValue } from "../utils/observable"; import { getValue } from "../utils/observable";
import { constant } from "./Behavior"; import { type Behavior, constant } from "./Behavior";
const getUrlParams = vi.hoisted(() => vi.fn(() => ({}))); const getUrlParams = vi.hoisted(() => vi.fn(() => ({})));
vi.mock("../UrlParams", () => ({ getUrlParams })); vi.mock("../UrlParams", () => ({ getUrlParams }));
vi.mock("rxjs", async (importOriginal) => ({
...(await importOriginal()),
// Disable interval Observables for the following tests since the test
// scheduler will loop on them forever and never call the test 'done'
interval: (): Observable<number> => NEVER,
}));
vi.mock("@livekit/components-core"); vi.mock("@livekit/components-core");
const daveRtcMember = mockRtcMembership("@dave:example.org", "DDDD"); const daveRtcMember = mockRtcMembership("@dave:example.org", "DDDD");
@@ -215,8 +222,8 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
} }
function withCallViewModel( function withCallViewModel(
remoteParticipants$: Observable<RemoteParticipant[]>, remoteParticipants$: Behavior<RemoteParticipant[]>,
rtcMembers$: Observable<Partial<CallMembership>[]>, rtcMembers$: Behavior<Partial<CallMembership>[]>,
connectionState$: Observable<ECConnectionState>, connectionState$: Observable<ECConnectionState>,
speaking: Map<Participant, Observable<boolean>>, speaking: Map<Participant, Observable<boolean>>,
mediaDevices: MediaDevices, mediaDevices: MediaDevices,
@@ -294,7 +301,7 @@ function withCallViewModel(
} }
test("participants are retained during a focus switch", () => { test("participants are retained during a focus switch", () => {
withTestScheduler(({ hot, 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
const participantInputMarbles = "a-ba"; const participantInputMarbles = "a-ba";
// Start switching focus on frame 1 and reconnect on frame 3 // Start switching focus on frame 1 and reconnect on frame 3
@@ -303,12 +310,12 @@ test("participants are retained during a focus switch", () => {
const expectedLayoutMarbles = " a"; const expectedLayoutMarbles = " a";
withCallViewModel( withCallViewModel(
hot(participantInputMarbles, { behavior(participantInputMarbles, {
a: [aliceParticipant, bobParticipant], a: [aliceParticipant, bobParticipant],
b: [], b: [],
}), }),
of([aliceRtcMember, bobRtcMember]), constant([aliceRtcMember, bobRtcMember]),
hot(connectionInputMarbles, { behavior(connectionInputMarbles, {
c: ConnectionState.Connected, c: ConnectionState.Connected,
s: ECAddonConnectionState.ECSwitchingFocus, s: ECAddonConnectionState.ECSwitchingFocus,
}), }),
@@ -331,7 +338,7 @@ test("participants are retained during a focus switch", () => {
}); });
test("screen sharing activates spotlight layout", () => { test("screen sharing activates spotlight layout", () => {
withTestScheduler(({ hot, schedule, expectObservable }) => { withTestScheduler(({ behavior, schedule, expectObservable }) => {
// Start with no screen shares, then have Alice and Bob share their screens, // 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 // then return to no screen shares, then have just Alice share for a bit
const participantInputMarbles = " abcda-ba"; const participantInputMarbles = " abcda-ba";
@@ -344,13 +351,13 @@ test("screen sharing activates spotlight layout", () => {
const expectedLayoutMarbles = " abcdaefeg"; const expectedLayoutMarbles = " abcdaefeg";
const expectedShowSpeakingMarbles = "y----nyny"; const expectedShowSpeakingMarbles = "y----nyny";
withCallViewModel( withCallViewModel(
hot(participantInputMarbles, { behavior(participantInputMarbles, {
a: [aliceParticipant, bobParticipant], a: [aliceParticipant, bobParticipant],
b: [aliceSharingScreen, bobParticipant], b: [aliceSharingScreen, bobParticipant],
c: [aliceSharingScreen, bobSharingScreen], c: [aliceSharingScreen, bobSharingScreen],
d: [aliceParticipant, bobSharingScreen], d: [aliceParticipant, bobSharingScreen],
}), }),
of([aliceRtcMember, bobRtcMember]), constant([aliceRtcMember, bobRtcMember]),
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map(), new Map(),
mockMediaDevices({}), mockMediaDevices({}),
@@ -416,7 +423,7 @@ test("screen sharing activates spotlight layout", () => {
}); });
test("participants stay in the same order unless to appear/disappear", () => { test("participants stay in the same order unless to appear/disappear", () => {
withTestScheduler(({ hot, schedule, expectObservable }) => { withTestScheduler(({ behavior, schedule, expectObservable }) => {
const visibilityInputMarbles = "a"; const visibilityInputMarbles = "a";
// First Bob speaks, then Dave, then Alice // First Bob speaks, then Dave, then Alice
const aSpeakingInputMarbles = " n- 1998ms - 1999ms y"; const aSpeakingInputMarbles = " n- 1998ms - 1999ms y";
@@ -429,13 +436,22 @@ test("participants stay in the same order unless to appear/disappear", () => {
const expectedLayoutMarbles = " a 1999ms b 1999ms a 57999ms c 1999ms a"; const expectedLayoutMarbles = " a 1999ms b 1999ms a 57999ms c 1999ms a";
withCallViewModel( withCallViewModel(
of([aliceParticipant, bobParticipant, daveParticipant]), constant([aliceParticipant, bobParticipant, daveParticipant]),
of([aliceRtcMember, bobRtcMember, daveRtcMember]), constant([aliceRtcMember, bobRtcMember, daveRtcMember]),
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map([ new Map([
[aliceParticipant, hot(aSpeakingInputMarbles, { y: true, n: false })], [
[bobParticipant, hot(bSpeakingInputMarbles, { y: true, n: false })], aliceParticipant,
[daveParticipant, hot(dSpeakingInputMarbles, { y: true, n: false })], behavior(aSpeakingInputMarbles, { y: true, n: false }),
],
[
bobParticipant,
behavior(bSpeakingInputMarbles, { y: true, n: false }),
],
[
daveParticipant,
behavior(dSpeakingInputMarbles, { y: true, n: false }),
],
]), ]),
mockMediaDevices({}), mockMediaDevices({}),
(vm) => { (vm) => {
@@ -475,7 +491,7 @@ test("participants stay in the same order unless to appear/disappear", () => {
}); });
test("participants adjust order when space becomes constrained", () => { test("participants adjust order when space becomes constrained", () => {
withTestScheduler(({ hot, schedule, expectObservable }) => { withTestScheduler(({ behavior, schedule, expectObservable }) => {
// Start with all tiles on screen then shrink to 3 // Start with all tiles on screen then shrink to 3
const visibilityInputMarbles = "a-b"; const visibilityInputMarbles = "a-b";
// Bob and Dave speak // Bob and Dave speak
@@ -487,12 +503,18 @@ test("participants adjust order when space becomes constrained", () => {
const expectedLayoutMarbles = " a-b"; const expectedLayoutMarbles = " a-b";
withCallViewModel( withCallViewModel(
of([aliceParticipant, bobParticipant, daveParticipant]), constant([aliceParticipant, bobParticipant, daveParticipant]),
of([aliceRtcMember, bobRtcMember, daveRtcMember]), constant([aliceRtcMember, bobRtcMember, daveRtcMember]),
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map([ new Map([
[bobParticipant, hot(bSpeakingInputMarbles, { y: true, n: false })], [
[daveParticipant, hot(dSpeakingInputMarbles, { y: true, n: false })], bobParticipant,
behavior(bSpeakingInputMarbles, { y: true, n: false }),
],
[
daveParticipant,
behavior(dSpeakingInputMarbles, { y: true, n: false }),
],
]), ]),
mockMediaDevices({}), mockMediaDevices({}),
(vm) => { (vm) => {
@@ -526,7 +548,7 @@ test("participants adjust order when space becomes constrained", () => {
}); });
test("spotlight speakers swap places", () => { test("spotlight speakers swap places", () => {
withTestScheduler(({ hot, schedule, expectObservable }) => { withTestScheduler(({ behavior, schedule, expectObservable }) => {
// Go immediately into spotlight mode for the test // Go immediately into spotlight mode for the test
const modeInputMarbles = " s"; const modeInputMarbles = " s";
// First Bob speaks, then Dave, then Alice // First Bob speaks, then Dave, then Alice
@@ -540,13 +562,22 @@ test("spotlight speakers swap places", () => {
const expectedLayoutMarbles = "abcd"; const expectedLayoutMarbles = "abcd";
withCallViewModel( withCallViewModel(
of([aliceParticipant, bobParticipant, daveParticipant]), constant([aliceParticipant, bobParticipant, daveParticipant]),
of([aliceRtcMember, bobRtcMember, daveRtcMember]), constant([aliceRtcMember, bobRtcMember, daveRtcMember]),
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map([ new Map([
[aliceParticipant, hot(aSpeakingInputMarbles, { y: true, n: false })], [
[bobParticipant, hot(bSpeakingInputMarbles, { y: true, n: false })], aliceParticipant,
[daveParticipant, hot(dSpeakingInputMarbles, { y: true, n: false })], behavior(aSpeakingInputMarbles, { y: true, n: false }),
],
[
bobParticipant,
behavior(bSpeakingInputMarbles, { y: true, n: false }),
],
[
daveParticipant,
behavior(dSpeakingInputMarbles, { y: true, n: false }),
],
]), ]),
mockMediaDevices({}), mockMediaDevices({}),
(vm) => { (vm) => {
@@ -590,8 +621,8 @@ test("layout enters picture-in-picture mode when requested", () => {
const expectedLayoutMarbles = " aba"; const expectedLayoutMarbles = " aba";
withCallViewModel( withCallViewModel(
of([aliceParticipant, bobParticipant]), constant([aliceParticipant, bobParticipant]),
of([aliceRtcMember, bobRtcMember]), constant([aliceRtcMember, bobRtcMember]),
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map(), new Map(),
mockMediaDevices({}), mockMediaDevices({}),
@@ -632,8 +663,8 @@ test("spotlight remembers whether it's expanded", () => {
const expectedLayoutMarbles = "abcbada"; const expectedLayoutMarbles = "abcbada";
withCallViewModel( withCallViewModel(
of([aliceParticipant, bobParticipant]), constant([aliceParticipant, bobParticipant]),
of([aliceRtcMember, bobRtcMember]), constant([aliceRtcMember, bobRtcMember]),
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map(), new Map(),
mockMediaDevices({}), mockMediaDevices({}),
@@ -681,7 +712,7 @@ test("spotlight remembers whether it's expanded", () => {
}); });
test("participants must have a MatrixRTCSession to be visible", () => { test("participants must have a MatrixRTCSession to be visible", () => {
withTestScheduler(({ hot, expectObservable }) => { withTestScheduler(({ behavior, expectObservable }) => {
// iterate through a number of combinations of participants and MatrixRTC memberships // iterate through a number of combinations of participants and MatrixRTC memberships
// Bob never has an MatrixRTC membership // Bob never has an MatrixRTC membership
const scenarioInputMarbles = " abcdec"; const scenarioInputMarbles = " abcdec";
@@ -689,14 +720,14 @@ test("participants must have a MatrixRTCSession to be visible", () => {
const expectedLayoutMarbles = "a-bc-b"; const expectedLayoutMarbles = "a-bc-b";
withCallViewModel( withCallViewModel(
hot(scenarioInputMarbles, { behavior(scenarioInputMarbles, {
a: [], a: [],
b: [bobParticipant], b: [bobParticipant],
c: [aliceParticipant, bobParticipant], c: [aliceParticipant, bobParticipant],
d: [aliceParticipant, daveParticipant, bobParticipant], d: [aliceParticipant, daveParticipant, bobParticipant],
e: [aliceParticipant, daveParticipant, bobSharingScreen], e: [aliceParticipant, daveParticipant, bobSharingScreen],
}), }),
hot(scenarioInputMarbles, { behavior(scenarioInputMarbles, {
a: [], a: [],
b: [], b: [],
c: [aliceRtcMember], c: [aliceRtcMember],
@@ -737,17 +768,17 @@ test("shows participants without MatrixRTCSession when enabled in settings", ()
try { try {
// enable the setting: // enable the setting:
showNonMemberTiles.setValue(true); showNonMemberTiles.setValue(true);
withTestScheduler(({ hot, expectObservable }) => { withTestScheduler(({ behavior, expectObservable }) => {
const scenarioInputMarbles = " abc"; const scenarioInputMarbles = " abc";
const expectedLayoutMarbles = "abc"; const expectedLayoutMarbles = "abc";
withCallViewModel( withCallViewModel(
hot(scenarioInputMarbles, { behavior(scenarioInputMarbles, {
a: [], a: [],
b: [aliceParticipant], b: [aliceParticipant],
c: [aliceParticipant, bobParticipant], c: [aliceParticipant, bobParticipant],
}), }),
of([]), // No one joins the MatrixRTC session constant([]), // No one joins the MatrixRTC session
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map(), new Map(),
mockMediaDevices({}), mockMediaDevices({}),
@@ -782,15 +813,15 @@ test("shows participants without MatrixRTCSession when enabled in settings", ()
}); });
it("should show at least one tile per MatrixRTCSession", () => { it("should show at least one tile per MatrixRTCSession", () => {
withTestScheduler(({ hot, expectObservable }) => { withTestScheduler(({ behavior, expectObservable }) => {
// iterate through some combinations of MatrixRTC memberships // iterate through some combinations of MatrixRTC memberships
const scenarioInputMarbles = " abcd"; const scenarioInputMarbles = " abcd";
// There should always be one tile for each MatrixRTCSession // There should always be one tile for each MatrixRTCSession
const expectedLayoutMarbles = "abcd"; const expectedLayoutMarbles = "abcd";
withCallViewModel( withCallViewModel(
of([]), constant([]),
hot(scenarioInputMarbles, { behavior(scenarioInputMarbles, {
a: [], a: [],
b: [aliceRtcMember], b: [aliceRtcMember],
c: [aliceRtcMember, daveRtcMember], c: [aliceRtcMember, daveRtcMember],
@@ -832,13 +863,13 @@ it("should show at least one tile per MatrixRTCSession", () => {
}); });
test("should disambiguate users with the same displayname", () => { test("should disambiguate users with the same displayname", () => {
withTestScheduler(({ hot, expectObservable }) => { withTestScheduler(({ behavior, expectObservable }) => {
const scenarioInputMarbles = "abcde"; const scenarioInputMarbles = "abcde";
const expectedLayoutMarbles = "abcde"; const expectedLayoutMarbles = "abcde";
withCallViewModel( withCallViewModel(
of([]), constant([]),
hot(scenarioInputMarbles, { behavior(scenarioInputMarbles, {
a: [], a: [],
b: [aliceRtcMember], b: [aliceRtcMember],
c: [aliceRtcMember, aliceDoppelgangerRtcMember], c: [aliceRtcMember, aliceDoppelgangerRtcMember],
@@ -849,50 +880,46 @@ test("should disambiguate users with the same displayname", () => {
new Map(), new Map(),
mockMediaDevices({}), mockMediaDevices({}),
(vm) => { (vm) => {
// Skip the null state. expectObservable(vm.memberDisplaynames$).toBe(expectedLayoutMarbles, {
expectObservable(vm.memberDisplaynames$.pipe(skip(1))).toBe( // Carol has no displayname - So userId is used.
expectedLayoutMarbles, a: new Map([[carolId, carol.userId]]),
{ b: new Map([
// Carol has no displayname - So userId is used. [carolId, carol.userId],
a: new Map([[carolId, carol.userId]]), [aliceId, alice.rawDisplayName],
b: new Map([ ]),
[carolId, carol.userId], // The second alice joins.
[aliceId, alice.rawDisplayName], c: new Map([
]), [carolId, carol.userId],
// The second alice joins. [aliceId, "Alice (@alice:example.org)"],
c: new Map([ [aliceDoppelgangerId, "Alice (@alice2:example.org)"],
[carolId, carol.userId], ]),
[aliceId, "Alice (@alice:example.org)"], // Bob also joins
[aliceDoppelgangerId, "Alice (@alice2:example.org)"], d: new Map([
]), [carolId, carol.userId],
// Bob also joins [aliceId, "Alice (@alice:example.org)"],
d: new Map([ [aliceDoppelgangerId, "Alice (@alice2:example.org)"],
[carolId, carol.userId], [bobId, bob.rawDisplayName],
[aliceId, "Alice (@alice:example.org)"], ]),
[aliceDoppelgangerId, "Alice (@alice2:example.org)"], // Alice leaves, and the displayname should reset.
[bobId, bob.rawDisplayName], e: new Map([
]), [carolId, carol.userId],
// Alice leaves, and the displayname should reset. [aliceDoppelgangerId, "Alice"],
e: new Map([ [bobId, bob.rawDisplayName],
[carolId, carol.userId], ]),
[aliceDoppelgangerId, "Alice"], });
[bobId, bob.rawDisplayName],
]),
},
);
}, },
); );
}); });
}); });
test("should disambiguate users with invisible characters", () => { test("should disambiguate users with invisible characters", () => {
withTestScheduler(({ hot, expectObservable }) => { withTestScheduler(({ behavior, expectObservable }) => {
const scenarioInputMarbles = "ab"; const scenarioInputMarbles = "ab";
const expectedLayoutMarbles = "ab"; const expectedLayoutMarbles = "ab";
withCallViewModel( withCallViewModel(
of([]), constant([]),
hot(scenarioInputMarbles, { behavior(scenarioInputMarbles, {
a: [], a: [],
b: [bobRtcMember, bobZeroWidthSpaceRtcMember], b: [bobRtcMember, bobZeroWidthSpaceRtcMember],
}), }),
@@ -900,36 +927,32 @@ test("should disambiguate users with invisible characters", () => {
new Map(), new Map(),
mockMediaDevices({}), mockMediaDevices({}),
(vm) => { (vm) => {
// Skip the null state. expectObservable(vm.memberDisplaynames$).toBe(expectedLayoutMarbles, {
expectObservable(vm.memberDisplaynames$.pipe(skip(1))).toBe( // Carol has no displayname - So userId is used.
expectedLayoutMarbles, a: new Map([[carolId, carol.userId]]),
{ // Both Bobs join, and should handle zero width hacks.
// Carol has no displayname - So userId is used. b: new Map([
a: new Map([[carolId, carol.userId]]), [carolId, carol.userId],
// Both Bobs join, and should handle zero width hacks. [bobId, `Bob (${bob.userId})`],
b: new Map([ [
[carolId, carol.userId], bobZeroWidthSpaceId,
[bobId, `Bob (${bob.userId})`], `${bobZeroWidthSpace.rawDisplayName} (${bobZeroWidthSpace.userId})`,
[ ],
bobZeroWidthSpaceId, ]),
`${bobZeroWidthSpace.rawDisplayName} (${bobZeroWidthSpace.userId})`, });
],
]),
},
);
}, },
); );
}); });
}); });
test("should strip RTL characters from displayname", () => { test("should strip RTL characters from displayname", () => {
withTestScheduler(({ hot, expectObservable }) => { withTestScheduler(({ behavior, expectObservable }) => {
const scenarioInputMarbles = "ab"; const scenarioInputMarbles = "ab";
const expectedLayoutMarbles = "ab"; const expectedLayoutMarbles = "ab";
withCallViewModel( withCallViewModel(
of([]), constant([]),
hot(scenarioInputMarbles, { behavior(scenarioInputMarbles, {
a: [], a: [],
b: [daveRtcMember, daveRTLRtcMember], b: [daveRtcMember, daveRTLRtcMember],
}), }),
@@ -937,22 +960,18 @@ test("should strip RTL characters from displayname", () => {
new Map(), new Map(),
mockMediaDevices({}), mockMediaDevices({}),
(vm) => { (vm) => {
// Skip the null state. expectObservable(vm.memberDisplaynames$).toBe(expectedLayoutMarbles, {
expectObservable(vm.memberDisplaynames$.pipe(skip(1))).toBe( // Carol has no displayname - So userId is used.
expectedLayoutMarbles, a: new Map([[carolId, carol.userId]]),
{ // Both Dave's join. Since after stripping
// Carol has no displayname - So userId is used. b: new Map([
a: new Map([[carolId, carol.userId]]), [carolId, carol.userId],
// Both Dave's join. Since after stripping // Not disambiguated
b: new Map([ [daveId, "Dave"],
[carolId, carol.userId], // This one is, since it's using RTL.
// Not disambiguated [daveRTLId, `evaD (${daveRTL.userId})`],
[daveId, "Dave"], ]),
// This one is, since it's using RTL. });
[daveRTLId, `evaD (${daveRTL.userId})`],
]),
},
);
}, },
); );
}); });
@@ -964,8 +983,8 @@ it("should rank raised hands above video feeds and below speakers and presenters
const expectedLayoutMarbles = "ab"; const expectedLayoutMarbles = "ab";
withCallViewModel( withCallViewModel(
of([aliceParticipant, bobParticipant]), constant([aliceParticipant, bobParticipant]),
of([aliceRtcMember, bobRtcMember]), constant([aliceRtcMember, bobRtcMember]),
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map(), new Map(),
mockMediaDevices({}), mockMediaDevices({}),
@@ -1039,8 +1058,8 @@ test("audio output changes when toggling earpiece mode", () => {
const expectedTargetStateMarbles = " sese"; const expectedTargetStateMarbles = " sese";
withCallViewModel( withCallViewModel(
of([]), constant([]),
of([]), constant([]),
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map(), new Map(),
devices, devices,

View File

@@ -4,7 +4,7 @@ Copyright 2023, 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. 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, startWith } from "rxjs";
import { type RunHelpers, TestScheduler } from "rxjs/testing"; import { type RunHelpers, TestScheduler } from "rxjs/testing";
import { expect, vi, vitest } from "vitest"; import { expect, vi, vitest } from "vitest";
import { import {
@@ -47,7 +47,8 @@ import {
} from "../config/ConfigOptions"; } from "../config/ConfigOptions";
import { Config } from "../config/Config"; import { Config } from "../config/Config";
import { type MediaDevices } from "../state/MediaDevices"; import { type MediaDevices } from "../state/MediaDevices";
import { constant } from "../state/Behavior"; import { type Behavior, constant } from "../state/Behavior";
import { ObservableScope } from "../state/ObservableScope";
export function withFakeTimers(continuation: () => void): void { export function withFakeTimers(continuation: () => void): void {
vi.useFakeTimers(); vi.useFakeTimers();
@@ -68,6 +69,11 @@ export interface OurRunHelpers extends RunHelpers {
* diagram. * diagram.
*/ */
schedule: (marbles: string, actions: Record<string, () => void>) => void; schedule: (marbles: string, actions: Record<string, () => void>) => void;
behavior<T = string>(
marbles: string,
values?: { [marble: string]: T },
error?: unknown,
): Behavior<T>;
} }
interface TestRunnerGlobal { interface TestRunnerGlobal {
@@ -83,6 +89,7 @@ export function withTestScheduler(
const scheduler = new TestScheduler((actual, expected) => { const scheduler = new TestScheduler((actual, expected) => {
expect(actual).deep.equals(expected); expect(actual).deep.equals(expected);
}); });
const scope = new ObservableScope();
// we set the test scheduler as a global so that you can watch it in a debugger // we set the test scheduler as a global so that you can watch it in a debugger
// and get the frame number. e.g. `rxjsTestScheduler?.now()` // and get the frame number. e.g. `rxjsTestScheduler?.now()`
(global as unknown as TestRunnerGlobal).rxjsTestScheduler = scheduler; (global as unknown as TestRunnerGlobal).rxjsTestScheduler = scheduler;
@@ -99,8 +106,32 @@ export function withTestScheduler(
// Run the actions and verify that none of them error // Run the actions and verify that none of them error
helpers.expectObservable(actionsObservable$).toBe(marbles, results); helpers.expectObservable(actionsObservable$).toBe(marbles, results);
}, },
behavior<T>(
marbles: string,
values?: { [marble: string]: T },
error?: unknown,
) {
// Generate a hot Observable with helpers.hot and use it as a Behavior.
// To do this, we need to ensure that the initial value emits
// synchronously upon subscription. The issue is that helpers.hot emits
// frame 0 of the marble diagram *asynchronously*, only once we return
// from the continuation, so we need to splice out the initial marble
// and turn it into a proper initial value.
const initialMarbleIndex = marbles.search(/[^ ]/);
if (initialMarbleIndex === -1)
throw new Error("Behavior must have an initial value");
const initialMarble = marbles[initialMarbleIndex];
const initialValue =
values === undefined ? (initialMarble as T) : values[initialMarble];
// The remainder of the marble diagram should start on frame 1
return helpers
.hot(`-${marbles.slice(initialMarbleIndex + 1)}`, values, error)
.pipe(startWith(initialValue))
.behavior(scope);
},
}), }),
); );
scope.end();
} }
interface EmitterMock<T> { interface EmitterMock<T> {