Add tests to make sure we always have one settings button.

This commit is contained in:
Timo K
2026-04-13 15:49:28 +02:00
parent 098a8c0c41
commit 5f0afd3edc
5 changed files with 110 additions and 34 deletions

View File

@@ -22,7 +22,7 @@ import {
} from "@vector-im/compound-design-tokens/assets/web/icons";
import styles from "./Button.module.css";
import inCallViewStyles from "../components/InCallFooter.module.css";
import inCallFooterStyles from "../components/InCallFooter.module.css";
import { platform } from "../Platform";
interface MicButtonProps extends ComponentPropsWithoutRef<"button"> {
@@ -177,9 +177,9 @@ export const SettingsButton: FC<SettingsButtonProps> = ({
<Tooltip label={t("common.settings")}>
<CpdButton
className={classNames(className, {
[inCallViewStyles.settingsOnlyShowWide]:
[inCallFooterStyles.settingsOnlyShowWide]:
showForScreenWidth === "wide",
[inCallViewStyles.settingsOnlyShowNarrow]:
[inCallFooterStyles.settingsOnlyShowNarrow]:
showForScreenWidth === "narrow",
})}
iconOnly

View File

@@ -40,6 +40,7 @@ export interface InCallFooterProps {
showControls: boolean;
hideSettingsButton: boolean;
hideLogo: boolean;
/** Pip controls buttonSize and hides: settings button, layout switcher and logo */
asPip: boolean;
gridMode: GridMode;
setGridMode: (mode: GridMode) => void;

View File

@@ -17,7 +17,7 @@ import {
import { render, type RenderResult } from "@testing-library/react";
import { type LocalParticipant } from "livekit-client";
import { BehaviorSubject, of } from "rxjs";
import { BrowserRouter } from "react-router-dom";
import { BrowserRouter, MemoryRouter } from "react-router-dom";
import { TooltipProvider } from "@vector-im/compound-web";
import { RoomContext, useLocalParticipant } from "@livekit/components-react";
import userEvent from "@testing-library/user-event";
@@ -34,13 +34,17 @@ import {
} from "../utils/test";
import { E2eeType } from "../e2ee/e2eeType";
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
import { type CallViewModelOptions } from "../state/CallViewModel/CallViewModel";
import { alice, local } from "../utils/test-fixtures";
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { type MediaDevices as ECMediaDevices } from "../state/MediaDevices";
import { constant } from "../state/Behavior";
import { AppBar } from "../AppBar";
import { initializeWidget } from "../widget";
import inCallFooterStyles from "../components/InCallFooter.module.css";
initializeWidget();
vi.hoisted(
@@ -96,6 +100,11 @@ beforeEach(() => {
});
interface CreateInCallViewArgs {
mediaDevices?: ECMediaDevices;
callViewModelOptions?: Partial<CallViewModelOptions>;
/** If set, uses a MemoryRouter with this as the initial entry instead of BrowserRouter */
initialRoute?: string;
/** If true, wraps the rendered tree in an AppBar provider */
withAppBar?: boolean;
}
function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
rtcSession: MockRTCSession;
@@ -112,47 +121,60 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
const { vm, rtcSession } = getBasicCallViewModelEnvironment(
[local, alice],
undefined,
{},
args.callViewModelOptions ?? {},
args.mediaDevices,
);
rtcSession.joined = true;
const room = rtcSession.room;
const client = room.client;
const Router = args.initialRoute
? ({ children }: { children: React.ReactNode }) => (
<MemoryRouter initialEntries={[args.initialRoute!]}>
{children}
</MemoryRouter>
)
: BrowserRouter;
const inCallView = (
<InCallView
client={client}
rtcSession={rtcSession.asMockedSession()}
muteStates={muteState}
vm={vm}
matrixInfo={{
userId: "",
displayName: "",
avatarUrl: "",
roomId: "",
roomName: "",
roomAlias: null,
roomAvatar: null,
e2eeSystem: {
kind: E2eeType.NONE,
},
}}
matrixRoom={room}
onShareClick={null}
/>
);
const content = args.withAppBar ? <AppBar>{inCallView}</AppBar> : inCallView;
const renderResult = render(
<BrowserRouter>
<Router>
<MediaDevicesContext value={args.mediaDevices ?? mockMediaDevices({})}>
<ReactionsSenderProvider
vm={vm}
rtcSession={rtcSession.asMockedSession()}
>
<TooltipProvider>
<RoomContext value={livekitRoom}>
<InCallView
client={client}
rtcSession={rtcSession.asMockedSession()}
muteStates={muteState}
vm={vm}
matrixInfo={{
userId: "",
displayName: "",
avatarUrl: "",
roomId: "",
roomName: "",
roomAlias: null,
roomAvatar: null,
e2eeSystem: {
kind: E2eeType.NONE,
},
}}
matrixRoom={room}
onShareClick={null}
/>
</RoomContext>
<RoomContext value={livekitRoom}>{content}</RoomContext>
</TooltipProvider>
</ReactionsSenderProvider>
</MediaDevicesContext>
</BrowserRouter>,
</Router>,
);
return {
...renderResult,
@@ -167,6 +189,53 @@ describe("InCallView", () => {
expect(container).toMatchSnapshot();
});
});
describe("settings button with AppBar header", () => {
it("is accessible when showHeader is false", () => {
// windowSize with height <= 600 results in "flat" windowMode,
// which means showHeader$ emits false.
const { getAllByRole } = createInCallView({
initialRoute: "/?header=app_bar",
withAppBar: true,
callViewModelOptions: {
// Set windowMode$ to "flat" (height <= 600)
windowSize$: constant({ width: 1000, height: 500 }),
},
});
// When showHeader is false, hideSettingsButton is false,
// so the settings button is visible in the footer.
const settingsBtns = getAllByRole("button", { name: "Settings" });
expect(settingsBtns.length).toBe(2);
const [btnA, btnB] = settingsBtns;
// here we check for two settings buttons because there are two buttons in the bottom bar. One for the
// the narrow layout and another one for the wide layout.
// Their visibility uses @media css queries, which cannot be tested in JSDOM,
// but we can at least check that both buttons are rendered and have the correct classes.
expect(btnA).toBeInTheDocument();
expect(btnA).toHaveClass(inCallFooterStyles.settingsOnlyShowWide);
expect(btnB).toBeInTheDocument();
expect(btnB).toHaveClass(inCallFooterStyles.settingsOnlyShowNarrow);
});
it("is accessible when showHeader is true", () => {
// windowSize with height > 600 and width > 600 results in "normal" windowMode,
// which means showHeader$ emits true.
const { getAllByRole } = createInCallView({
initialRoute: "/?header=app_bar",
withAppBar: true,
callViewModelOptions: {
// Set windowMode$ to "normal" (height >= 600)
windowSize$: constant({ width: 1000, height: 800 }),
},
});
// When showHeader is true and headerStyle is AppBar,
// hideSettingsButton is true in the footer, but the settings
// button is rendered in the AppBar via useAppBarSecondaryButton.
const settingsBtns = getAllByRole("button", { name: "Settings" });
expect(settingsBtns.length).toBe(1);
expect(settingsBtns[0]).toBeVisible();
});
});
describe("audioOutputSwitcher", () => {
it("is visible and can be clicked", async () => {
const user = userEvent.setup();

View File

@@ -360,7 +360,12 @@ export const InCallView: FC<InCallViewProps> = ({
let header: ReactNode = null;
if (showHeader) {
switch (headerStyle) {
case "none":
case HeaderStyle.AppBar: {
// dont build a header here. The AppBar will take care of it.
header = null;
break;
}
case HeaderStyle.None:
// Cosmetic header to fill out space while still affecting the bounds of
// the grid
header = (
@@ -370,7 +375,7 @@ export const InCallView: FC<InCallViewProps> = ({
/>
);
break;
case "standard":
case HeaderStyle.Standard:
header = (
<Header
className={styles.header}
@@ -565,7 +570,8 @@ export const InCallView: FC<InCallViewProps> = ({
showControls={showControls}
// Hide the logo for both embedded solutions. mobile: HeaderStyle.AppBar and desktop: HeaderStyle.None.
hideLogo={headerStyle !== HeaderStyle.Standard}
hideSettingsButton={headerStyle === HeaderStyle.AppBar}
// Only hide the settings button if we have an AppBar header and we are showing the header
hideSettingsButton={headerStyle === HeaderStyle.AppBar && showHeader}
asPip={layout.type === "pip"}
gridMode={gridMode}
setGridMode={setGridMode}

View File

@@ -169,7 +169,7 @@ exports[`InCallView > rendering > renders 1`] = `
>
<button
aria-labelledby="_r_8_"
class="_button_13vu4_8 settingForBottomLeftCorner _has-icon_13vu4_60 _icon-only_13vu4_53"
class="_button_13vu4_8 settingsOnlyShowWide _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="tertiary"
data-size="lg"
role="button"
@@ -298,7 +298,7 @@ exports[`InCallView > rendering > renders 1`] = `
>
<button
aria-labelledby="_r_d_"
class="_button_13vu4_8 settingForButtonsBar _has-icon_13vu4_60 _icon-only_13vu4_53"
class="_button_13vu4_8 settingsOnlyShowNarrow _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="secondary"
data-size="lg"
role="button"