From 246db5a820ea33fbfe9b678668d427165c6f0ee9 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 12 May 2026 12:46:01 +0200 Subject: [PATCH] simplifications docs and tests --- src/components/CallFooter.stories.tsx | 5 +- src/components/CallFooter.tsx | 6 +-- src/components/CallFooterViewModel.test.ts | 42 +++++++-------- src/components/CallFooterViewModel.tsx | 50 +++++++---------- src/room/InCallView.test.tsx | 53 ++++++++++++------- .../__snapshots__/InCallView.test.tsx.snap | 14 ++--- src/state/ViewModel.ts | 10 +++- 7 files changed, 94 insertions(+), 86 deletions(-) diff --git a/src/components/CallFooter.stories.tsx b/src/components/CallFooter.stories.tsx index 28b8be9e..04533891 100644 --- a/src/components/CallFooter.stories.tsx +++ b/src/components/CallFooter.stories.tsx @@ -13,7 +13,7 @@ import { Link } from "@vector-im/compound-web"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { CallFooter, type FooterSnapshot } from "./CallFooter"; import inCallViewStyles from "../room/InCallView.module.css"; -import { createMockedViewModel } from "../state/ViewModel"; +import { createStaticViewModel } from "../state/ViewModel"; import { ReactionsSenderContext } from "../reactions/useReactionsSender"; import { type ReactionOption } from "../reactions"; import { type GridMode } from "../state/CallViewModel/CallViewModel"; @@ -38,7 +38,7 @@ function CallFooterStoryWrapper({ }: FooterSnapshot & { children?: false | JSX.Element | JSX.Element[] | undefined; }): ReactNode { - const vm = createMockedViewModel(vmSnapshot); + const vm = createStaticViewModel(vmSnapshot); return (
= ({ ref, children, vm }) => { videoToggles, buttonSize, showSettingsButton, - showLogoDebugContainer, showLogo, } = useViewModel(vm); @@ -285,10 +283,10 @@ export const CallFooter: FC = ({ ref, children, vm }) => { /> )} {children} - {showLogoDebugContainer && logoDebugContainer} + {(showLogo || debugTileLayout) && logoDebugContainer}
{!hideControls &&
{buttons}
} - {setLayoutMode && layoutMode && ( + {!hideControls && setLayoutMode && layoutMode && ( name="layoutMode" aria-label={t("layout_switch_label")} diff --git a/src/components/CallFooterViewModel.test.ts b/src/components/CallFooterViewModel.test.ts index efb3d4e4..c6ce6594 100644 --- a/src/components/CallFooterViewModel.test.ts +++ b/src/components/CallFooterViewModel.test.ts @@ -104,30 +104,30 @@ describe("createCallFooterViewModel", () => { } it("are empty when both the platform is iOS", () => { checkEmptyFor("ios", gridLayout); - it("are empty when both the layout is pip", () => { - checkEmptyFor("desktop", pipLayout); - }); + }); + it("are empty when both the layout is pip", () => { + checkEmptyFor("desktop", pipLayout); + }); - it("are populated when the platform is desktop and the layout is not PiP", () => { - platformMock.mockReturnValue("desktop"); + it("are populated when the platform is desktop and the layout is not PiP", () => { + platformMock.mockReturnValue("desktop"); - const vm = createCallFooterViewModel( - testScope(), - buildMinimalCallViewModel(gridLayout), - mockMuteStates(), - twoMicsAndOneCamMediaDevices, - /* openSettings */ undefined, - /* reactionIdentifier */ undefined, - ); + const vm = createCallFooterViewModel( + testScope(), + buildMinimalCallViewModel(gridLayout), + mockMuteStates(), + twoMicsAndOneCamMediaDevices, + /* openSettings */ undefined, + /* reactionIdentifier */ undefined, + ); - expect(vm.audioOptions$?.value).toEqual([ - { id: "mic1", label: "Microphone 1" }, - { id: "mic2", label: "Microphone 2" }, - ]); - expect(vm.videoOptions$?.value).toEqual([ - { id: "cam1", label: "Camera 1" }, - ]); - }); + expect(vm.audioOptions$?.value).toEqual([ + { id: "mic1", label: "Microphone 1" }, + { id: "mic2", label: "Microphone 2" }, + ]); + expect(vm.videoOptions$?.value).toEqual([ + { id: "cam1", label: "Camera 1" }, + ]); }); }); }); diff --git a/src/components/CallFooterViewModel.tsx b/src/components/CallFooterViewModel.tsx index c1ca11fa..a8c5b83e 100644 --- a/src/components/CallFooterViewModel.tsx +++ b/src/components/CallFooterViewModel.tsx @@ -22,7 +22,7 @@ import { import { type Behavior, constant } from "../state/Behavior"; import type { ObservableScope } from "../state/ObservableScope"; import { type MuteStates } from "../state/MuteStates"; -import { type ViewModel } from "../state/ViewModel"; +import { createStaticViewModel, type ViewModel } from "../state/ViewModel"; import { getUrlParams, HeaderStyle } from "../UrlParams"; import { platform } from "../Platform"; import { type FooterSnapshot } from "./CallFooter"; @@ -173,8 +173,8 @@ export function createCallFooterViewModel( reactionIdentifier: string | undefined, ): ViewModel { const { showControls, header: headerStyle } = getUrlParams(); + const showLogo = headerStyle === HeaderStyle.Standard; - const hideLogo = headerStyle !== HeaderStyle.Standard; const isPip$ = scope.behavior( callModel.layout$.pipe(map((l) => l.type === "pip")), ); @@ -206,12 +206,7 @@ export function createCallFooterViewModel( showLayoutSwitcher$: scope.behavior( isPip$.pipe(map((l) => !isPip$ && showControls)), ), - showLogoDebugContainer$: scope.behavior( - combineLatest([isPip$, debugTileLayoutSetting.value$]).pipe( - map(([isPip, debugTile]) => !isPip || (!hideLogo && !debugTile)), - ), - ), - showLogo$: scope.behavior(isPip$.pipe(map((l) => !hideLogo && !isPip$))), + showLogo$: scope.behavior(isPip$.pipe(map((isPip) => showLogo && !isPip))), layoutMode$: callModel.gridMode$, setLayoutMode$: constant(callModel.setGridMode), @@ -272,31 +267,22 @@ export function createLobbyFooterViewModel( showLogo: boolean, ): ViewModel { return { + ...createStaticViewModel({ + // we can safly skip any props that we do not need. + // The view model will then have less keys. + // But as soon as we call `useViewModel` and convert back to a snapshot the missing props will + // be correcty matching the snapshot type. + showLogo, + hideControls: false, + asOverlay: false, + buttonSize: "lg", + showLayoutSwitcher: false, + openSettings, + hangup, + debugTileLayout: false, + showSettingsButton: openSettings !== undefined, + }), ...buildMuteBehaviors(scope, muteStates), ...buildDeviceBehaviors(scope, mediaDevices, constant(false)), - hideControls$: constant(false), - asOverlay$: constant(false), - buttonSize$: constant("lg"), - showSettingsButton$: constant(openSettings !== undefined), - showLayoutSwitcher$: constant(false), - showLogoDebugContainer$: constant(showLogo), - showLogo$: constant(showLogo), - - layoutMode$: constant(undefined), - setLayoutMode$: constant(undefined), - - sharingScreen$: constant(undefined), - toggleScreenSharing$: constant(undefined), - - audioOutputSwitcher$: constant(undefined), - - openSettings$: constant(openSettings), - hangup$: constant(hangup), - - reactionIdentifier$: constant(undefined), - reactionData$: constant(undefined), - - debugTileLayout$: constant(false), - tileStoreGeneration$: constant(0), }; } diff --git a/src/room/InCallView.test.tsx b/src/room/InCallView.test.tsx index c23a9dcb..cf4c2d92 100644 --- a/src/room/InCallView.test.tsx +++ b/src/room/InCallView.test.tsx @@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details. */ import { + afterEach, beforeEach, describe, expect, @@ -14,10 +15,10 @@ import { type MockedFunction, vi, } from "vitest"; -import { render, type RenderResult } from "@testing-library/react"; +import { act, render, type RenderResult } from "@testing-library/react"; import { type LocalParticipant } from "livekit-client"; import { BehaviorSubject, of } from "rxjs"; -import { BrowserRouter, MemoryRouter } from "react-router-dom"; +import { BrowserRouter } 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,7 +35,10 @@ import { } from "../utils/test"; import { E2eeType } from "../e2ee/e2eeType"; import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel"; -import { type CallViewModelOptions } from "../state/CallViewModel/CallViewModel"; +import { + type CallViewModel, + type CallViewModelOptions, +} from "../state/CallViewModel/CallViewModel"; import { alice, local } from "../utils/test-fixtures"; import { ReactionsSenderProvider } from "../reactions/useReactionsSender"; import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement"; @@ -100,13 +104,12 @@ 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; + vm: CallViewModel; } { const mediaDevices = args.mediaDevices ?? mockMediaDevices({}); const muteState = mockMuteStates(); @@ -129,14 +132,6 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & { const room = rtcSession.room; const client = room.client; - const Router = args.initialRoute - ? ({ children }: { children: React.ReactNode }): React.ReactNode => ( - - {children} - - ) - : BrowserRouter; - const inCallView = ( {inCallView} : inCallView; const renderResult = render( - + - , + , ); return { ...renderResult, rtcSession, + vm, }; } @@ -190,17 +186,37 @@ describe("InCallView", () => { }); }); describe("settings button with AppBar header", () => { + beforeEach(() => { + // getUrlParams() reads window.location directly rather than from the + // React Router context, so MemoryRouter alone is not enough to make + // it see "header=app_bar". Push the real URL so both paths agree. + window.history.pushState({}, "", "?header=app_bar"); + }); + + afterEach(() => { + window.history.pushState({}, "", "/"); + }); + it("mobile landscape, 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", + const { getAllByRole, queryAllByRole, vm } = createInCallView({ withAppBar: true, callViewModelOptions: { // Set windowMode$ to "flat" (height <= 600) windowSize$: constant({ width: 1000, height: 500 }), }, }); + + // In flat (landscape) mode the footer starts hidden until the user + // taps the screen, so no settings button should be accessible yet. + expect(queryAllByRole("button", { name: "Settings" })).toHaveLength(0); + + // Simulate a touch tap on the call view to reveal the footer. + // (PointerEvent is not available in JSDOM, so we call tapScreen() directly, + // which is exactly what the onClick handler does for touch events.) + act(() => vm.tapScreen()); + // When showHeader is false, hideSettingsButton is false, // so the settings button is visible in the footer. const settingsBtn = getAllByRole("button", { name: "Settings" }); @@ -220,7 +236,6 @@ describe("InCallView", () => { // 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) @@ -228,7 +243,7 @@ describe("InCallView", () => { }, }); // When showHeader is true and headerStyle is AppBar, - // hideSettingsButton is true in the footer, but the settings + // showSettingsButton is false in the footer, but the settings // button is rendered in the AppBar via useAppBarSecondaryButton. const settingsBtns = getAllByRole("button", { name: "Settings" }); diff --git a/src/room/__snapshots__/InCallView.test.tsx.snap b/src/room/__snapshots__/InCallView.test.tsx.snap index d9f768e7..831bf307 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`] = ` class="settingsLogoContainer" >