Add test for the media switch button:

- is it available in the bottom bar
 - does pressing it trigger the expected callback
This commit is contained in:
Timo K
2026-04-09 13:23:29 +02:00
parent 4356e8cdb2
commit 9d20ec00f1
3 changed files with 59 additions and 40 deletions

View File

@@ -15,20 +15,17 @@ import {
vi, vi,
} from "vitest"; } from "vitest";
import { render, type RenderResult } from "@testing-library/react"; import { render, type RenderResult } from "@testing-library/react";
import { type MatrixClient, JoinRule, type RoomState } from "matrix-js-sdk";
import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container";
import { type LocalParticipant } from "livekit-client"; import { type LocalParticipant } from "livekit-client";
import { of } from "rxjs"; import { BehaviorSubject, of } from "rxjs";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import { TooltipProvider } from "@vector-im/compound-web"; import { TooltipProvider } from "@vector-im/compound-web";
import { RoomContext, useLocalParticipant } from "@livekit/components-react"; import { RoomContext, useLocalParticipant } from "@livekit/components-react";
import userEvent from "@testing-library/user-event";
import { InCallView } from "./InCallView"; import { InCallView } from "./InCallView";
import { import {
mockLivekitRoom, mockLivekitRoom,
mockLocalParticipant, mockLocalParticipant,
mockMatrixRoom,
mockMatrixRoomMember,
mockMediaDevices, mockMediaDevices,
mockMuteStates, mockMuteStates,
mockRemoteParticipant, mockRemoteParticipant,
@@ -43,7 +40,9 @@ import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer"; import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer";
import { MediaDevicesContext } from "../MediaDevicesContext"; import { MediaDevicesContext } from "../MediaDevicesContext";
import { HeaderStyle } from "../UrlParams"; import { HeaderStyle } from "../UrlParams";
import { type MediaDevices as ECMediaDevices } from "../state/MediaDevices";
import { initializeWidget } from "../widget"; import { initializeWidget } from "../widget";
initializeWidget(); initializeWidget();
vi.hoisted( vi.hoisted(
() => () =>
@@ -71,10 +70,7 @@ const localParticipant = mockLocalParticipant({
const remoteParticipant = mockRemoteParticipant({ const remoteParticipant = mockRemoteParticipant({
identity: "@alice:example.org:AAAAAA", identity: "@alice:example.org:AAAAAA",
}); });
const carol = mockMatrixRoomMember(localRtcMember);
const roomMembers = new Map([carol].map((p) => [p.userId, p]));
const roomId = "!foo:bar";
let useRoomEncryptionSystemMock: MockedFunction<typeof useRoomEncryptionSystem>; let useRoomEncryptionSystemMock: MockedFunction<typeof useRoomEncryptionSystem>;
beforeEach(() => { beforeEach(() => {
@@ -99,36 +95,12 @@ beforeEach(() => {
useRoomEncryptionSystem as typeof useRoomEncryptionSystemMock; useRoomEncryptionSystem as typeof useRoomEncryptionSystemMock;
useRoomEncryptionSystemMock.mockReturnValue({ kind: E2eeType.NONE }); useRoomEncryptionSystemMock.mockReturnValue({ kind: E2eeType.NONE });
}); });
interface CreateInCallViewArgs {
function createInCallView(): RenderResult & { mediaDevices?: ECMediaDevices;
}
function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
rtcSession: MockRTCSession; rtcSession: MockRTCSession;
} { } {
const client = {
getUser: () => null,
getUserId: () => localRtcMember.userId,
getDeviceId: () => localRtcMember.deviceId,
getRoom: (rId) => (rId === roomId ? room : null),
getDomain: () => "example.com",
} as Partial<MatrixClient> as MatrixClient;
const room = mockMatrixRoom({
relations: {
getChildEventsForEvent: () =>
vi.mocked({
getRelations: () => [],
}),
} as unknown as RelationsContainer,
client,
roomId,
// getMember: (userId) => roomMembers.get(userId) ?? null,
getMembers: () => Array.from(roomMembers.values()),
getMxcAvatarUrl: () => null,
hasEncryptionStateEvent: vi.fn().mockReturnValue(true),
getCanonicalAlias: () => null,
currentState: {
getJoinRule: () => JoinRule.Invite,
} as Partial<RoomState> as RoomState,
});
const muteState = mockMuteStates(); const muteState = mockMuteStates();
const livekitRoom = mockLivekitRoom( const livekitRoom = mockLivekitRoom(
{ {
@@ -138,12 +110,19 @@ function createInCallView(): RenderResult & {
remoteParticipants$: of([remoteParticipant]), remoteParticipants$: of([remoteParticipant]),
}, },
); );
const { vm, rtcSession } = getBasicCallViewModelEnvironment([local, alice]); const { vm, rtcSession } = getBasicCallViewModelEnvironment(
[local, alice],
undefined,
{},
args.mediaDevices,
);
rtcSession.joined = true; rtcSession.joined = true;
const room = rtcSession.room;
const client = room.client;
const renderResult = render( const renderResult = render(
<BrowserRouter> <BrowserRouter>
<MediaDevicesContext value={mockMediaDevices({})}> <MediaDevicesContext value={args.mediaDevices ?? mockMediaDevices({})}>
<ReactionsSenderProvider <ReactionsSenderProvider
vm={vm} vm={vm}
rtcSession={rtcSession.asMockedSession()} rtcSession={rtcSession.asMockedSession()}
@@ -190,4 +169,41 @@ describe("InCallView", () => {
expect(container).toMatchSnapshot(); expect(container).toMatchSnapshot();
}); });
}); });
describe("audioOutputSwitcher", () => {
it("is visible and can be clicked", async () => {
const user = userEvent.setup();
const switchFn = vi.fn();
// Create mediaDevices with a speaker and an earpiece available,
// with the speaker currently selected.
// This is needed so that the audio switcher button is visible
const available$ = new BehaviorSubject(
new Map<string, { type: "speaker" } | { type: "earpiece" }>([
["speaker-id", { type: "speaker" }],
["earpiece-id", { type: "earpiece" }],
]),
);
const selected$ = new BehaviorSubject<
{ id: string; virtualEarpiece: boolean } | undefined
>({ id: "speaker-id", virtualEarpiece: false });
const mediaDevices = mockMediaDevices({
audioOutput: {
available$,
selected$,
select: switchFn,
},
});
const { getByRole } = createInCallView({ mediaDevices });
// The button should be visible. When current output is "speaker",
// the switcher targets "earpiece", so the tooltip label is "Handset".
const audioOutputBtn = getByRole("button", { name: "Handset" });
expect(audioOutputBtn).toBeVisible();
await user.click(audioOutputBtn);
// Clicking the button should call select -> switchFn with the earpiece device id
expect(switchFn).toHaveBeenCalledWith("earpiece-id");
});
});
}); });

View File

@@ -632,8 +632,8 @@ export const InCallView: FC<InCallViewProps> = ({
return ( return (
<Tooltip label={label}> <Tooltip label={label}>
<IconButton <IconButton
key="audio_output_switcher"
onClick={(e) => { onClick={(e) => {
e.preventDefault();
audioOutputSwitcher.switch(); audioOutputSwitcher.switch();
}} }}
> >

View File

@@ -34,6 +34,7 @@ import {
MockRTCSession, MockRTCSession,
testScope, testScope,
} from "./test"; } from "./test";
import { MediaDevices } from "../state/MediaDevices";
import { aliceRtcMember, localRtcMember } from "./test-fixtures"; import { aliceRtcMember, localRtcMember } from "./test-fixtures";
import { type RaisedHandInfo, type ReactionInfo } from "../reactions"; import { type RaisedHandInfo, type ReactionInfo } from "../reactions";
import { constant } from "../state/Behavior"; import { constant } from "../state/Behavior";
@@ -63,6 +64,7 @@ export function getBasicRTCSession(
getDeviceId: () => localRtcMember.deviceId, getDeviceId: () => localRtcMember.deviceId,
getSyncState: () => SyncState.Syncing, getSyncState: () => SyncState.Syncing,
getDomain: () => null, getDomain: () => null,
getAccessToken: () => "fake-token",
sendEvent: vitest.fn().mockResolvedValue({ event_id: "$fake:event" }), sendEvent: vitest.fn().mockResolvedValue({ event_id: "$fake:event" }),
redactEvent: vitest.fn().mockResolvedValue({ event_id: "$fake:event" }), redactEvent: vitest.fn().mockResolvedValue({ event_id: "$fake:event" }),
decryptEventIfNeeded: vitest.fn().mockResolvedValue(undefined), decryptEventIfNeeded: vitest.fn().mockResolvedValue(undefined),
@@ -131,6 +133,7 @@ export function getBasicCallViewModelEnvironment(
members: RoomMember[], members: RoomMember[],
initialRtcMemberships: CallMembership[] = [localRtcMember, aliceRtcMember], initialRtcMemberships: CallMembership[] = [localRtcMember, aliceRtcMember],
callViewModelOptions: Partial<CallViewModelOptions> = {}, callViewModelOptions: Partial<CallViewModelOptions> = {},
mediaDevicesOverride?: MediaDevices,
): { ): {
vm: CallViewModel; vm: CallViewModel;
rtcMemberships$: BehaviorSubject<CallMembership[]>; rtcMemberships$: BehaviorSubject<CallMembership[]>;
@@ -151,7 +154,7 @@ export function getBasicCallViewModelEnvironment(
testScope(), testScope(),
rtcSession.asMockedSession(), rtcSession.asMockedSession(),
matrixRoom, matrixRoom,
mockMediaDevices({}), mediaDevicesOverride ?? mockMediaDevices({}),
mockMuteStates(), mockMuteStates(),
{ {
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },