From 5f0afd3edc2e1c362f3b4b2addf91544593a2753 Mon Sep 17 00:00:00 2001 From: Timo K Date: Mon, 13 Apr 2026 15:49:28 +0200 Subject: [PATCH] Add tests to make sure we always have one settings button. --- src/button/Button.tsx | 6 +- src/components/InCallFooter.tsx | 1 + src/room/InCallView.test.tsx | 121 ++++++++++++++---- src/room/InCallView.tsx | 12 +- .../__snapshots__/InCallView.test.tsx.snap | 4 +- 5 files changed, 110 insertions(+), 34 deletions(-) diff --git a/src/button/Button.tsx b/src/button/Button.tsx index 31335370..261c013c 100644 --- a/src/button/Button.tsx +++ b/src/button/Button.tsx @@ -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 = ({ void; diff --git a/src/room/InCallView.test.tsx b/src/room/InCallView.test.tsx index e4b7d012..9d81fa67 100644 --- a/src/room/InCallView.test.tsx +++ b/src/room/InCallView.test.tsx @@ -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; + /** 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 }) => ( + + {children} + + ) + : BrowserRouter; + + const inCallView = ( + + ); + + const content = args.withAppBar ? {inCallView} : inCallView; + const renderResult = render( - + - - - + {content} - , + , ); 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(); diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index b247b8f1..87be4464 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -360,7 +360,12 @@ export const InCallView: FC = ({ 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 = ({ /> ); break; - case "standard": + case HeaderStyle.Standard: header = (
= ({ 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} diff --git a/src/room/__snapshots__/InCallView.test.tsx.snap b/src/room/__snapshots__/InCallView.test.tsx.snap index 81ef8b93..17e82bc2 100644 --- a/src/room/__snapshots__/InCallView.test.tsx.snap +++ b/src/room/__snapshots__/InCallView.test.tsx.snap @@ -169,7 +169,7 @@ exports[`InCallView > rendering > renders 1`] = ` >