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

View File

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

View File

@@ -17,7 +17,7 @@ import {
import { render, type RenderResult } from "@testing-library/react"; import { render, type RenderResult } from "@testing-library/react";
import { type LocalParticipant } from "livekit-client"; import { type LocalParticipant } from "livekit-client";
import { BehaviorSubject, of } from "rxjs"; 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 { 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 userEvent from "@testing-library/user-event";
@@ -34,13 +34,17 @@ import {
} from "../utils/test"; } from "../utils/test";
import { E2eeType } from "../e2ee/e2eeType"; import { E2eeType } from "../e2ee/e2eeType";
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel"; import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
import { type CallViewModelOptions } from "../state/CallViewModel/CallViewModel";
import { alice, local } from "../utils/test-fixtures"; import { alice, local } from "../utils/test-fixtures";
import { ReactionsSenderProvider } from "../reactions/useReactionsSender"; import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement"; import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer"; import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer";
import { MediaDevicesContext } from "../MediaDevicesContext"; import { MediaDevicesContext } from "../MediaDevicesContext";
import { type MediaDevices as ECMediaDevices } from "../state/MediaDevices"; import { type MediaDevices as ECMediaDevices } from "../state/MediaDevices";
import { constant } from "../state/Behavior";
import { AppBar } from "../AppBar";
import { initializeWidget } from "../widget"; import { initializeWidget } from "../widget";
import inCallFooterStyles from "../components/InCallFooter.module.css";
initializeWidget(); initializeWidget();
vi.hoisted( vi.hoisted(
@@ -96,6 +100,11 @@ beforeEach(() => {
}); });
interface CreateInCallViewArgs { interface CreateInCallViewArgs {
mediaDevices?: ECMediaDevices; 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 & { function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
rtcSession: MockRTCSession; rtcSession: MockRTCSession;
@@ -112,47 +121,60 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
const { vm, rtcSession } = getBasicCallViewModelEnvironment( const { vm, rtcSession } = getBasicCallViewModelEnvironment(
[local, alice], [local, alice],
undefined, undefined,
{}, args.callViewModelOptions ?? {},
args.mediaDevices, args.mediaDevices,
); );
rtcSession.joined = true; rtcSession.joined = true;
const room = rtcSession.room; const room = rtcSession.room;
const client = room.client; 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( const renderResult = render(
<BrowserRouter> <Router>
<MediaDevicesContext value={args.mediaDevices ?? mockMediaDevices({})}> <MediaDevicesContext value={args.mediaDevices ?? mockMediaDevices({})}>
<ReactionsSenderProvider <ReactionsSenderProvider
vm={vm} vm={vm}
rtcSession={rtcSession.asMockedSession()} rtcSession={rtcSession.asMockedSession()}
> >
<TooltipProvider> <TooltipProvider>
<RoomContext value={livekitRoom}> <RoomContext value={livekitRoom}>{content}</RoomContext>
<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>
</TooltipProvider> </TooltipProvider>
</ReactionsSenderProvider> </ReactionsSenderProvider>
</MediaDevicesContext> </MediaDevicesContext>
</BrowserRouter>, </Router>,
); );
return { return {
...renderResult, ...renderResult,
@@ -167,6 +189,53 @@ describe("InCallView", () => {
expect(container).toMatchSnapshot(); 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", () => { describe("audioOutputSwitcher", () => {
it("is visible and can be clicked", async () => { it("is visible and can be clicked", async () => {
const user = userEvent.setup(); const user = userEvent.setup();

View File

@@ -360,7 +360,12 @@ export const InCallView: FC<InCallViewProps> = ({
let header: ReactNode = null; let header: ReactNode = null;
if (showHeader) { if (showHeader) {
switch (headerStyle) { 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 // Cosmetic header to fill out space while still affecting the bounds of
// the grid // the grid
header = ( header = (
@@ -370,7 +375,7 @@ export const InCallView: FC<InCallViewProps> = ({
/> />
); );
break; break;
case "standard": case HeaderStyle.Standard:
header = ( header = (
<Header <Header
className={styles.header} className={styles.header}
@@ -565,7 +570,8 @@ export const InCallView: FC<InCallViewProps> = ({
showControls={showControls} showControls={showControls}
// Hide the logo for both embedded solutions. mobile: HeaderStyle.AppBar and desktop: HeaderStyle.None. // Hide the logo for both embedded solutions. mobile: HeaderStyle.AppBar and desktop: HeaderStyle.None.
hideLogo={headerStyle !== HeaderStyle.Standard} 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"} asPip={layout.type === "pip"}
gridMode={gridMode} gridMode={gridMode}
setGridMode={setGridMode} setGridMode={setGridMode}

View File

@@ -169,7 +169,7 @@ exports[`InCallView > rendering > renders 1`] = `
> >
<button <button
aria-labelledby="_r_8_" 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-kind="tertiary"
data-size="lg" data-size="lg"
role="button" role="button"
@@ -298,7 +298,7 @@ exports[`InCallView > rendering > renders 1`] = `
> >
<button <button
aria-labelledby="_r_d_" 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-kind="secondary"
data-size="lg" data-size="lg"
role="button" role="button"