simplifications docs and tests

This commit is contained in:
Timo K
2026-05-12 12:46:01 +02:00
parent b042f2594d
commit 246db5a820
7 changed files with 94 additions and 86 deletions

View File

@@ -13,7 +13,7 @@ import { Link } from "@vector-im/compound-web";
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import { CallFooter, type FooterSnapshot } from "./CallFooter"; import { CallFooter, type FooterSnapshot } from "./CallFooter";
import inCallViewStyles from "../room/InCallView.module.css"; import inCallViewStyles from "../room/InCallView.module.css";
import { createMockedViewModel } from "../state/ViewModel"; import { createStaticViewModel } from "../state/ViewModel";
import { ReactionsSenderContext } from "../reactions/useReactionsSender"; import { ReactionsSenderContext } from "../reactions/useReactionsSender";
import { type ReactionOption } from "../reactions"; import { type ReactionOption } from "../reactions";
import { type GridMode } from "../state/CallViewModel/CallViewModel"; import { type GridMode } from "../state/CallViewModel/CallViewModel";
@@ -38,7 +38,7 @@ function CallFooterStoryWrapper({
}: FooterSnapshot & { }: FooterSnapshot & {
children?: false | JSX.Element | JSX.Element[] | undefined; children?: false | JSX.Element | JSX.Element[] | undefined;
}): ReactNode { }): ReactNode {
const vm = createMockedViewModel(vmSnapshot); const vm = createStaticViewModel(vmSnapshot);
return ( return (
<div className={inCallViewStyles.inRoom}> <div className={inCallViewStyles.inRoom}>
<ReactionsSenderContext <ReactionsSenderContext
@@ -70,6 +70,7 @@ const fnArgType = {
export const Default: Story = { export const Default: Story = {
args: { args: {
showLogo: false, showLogo: false,
showSettingsButton: true,
layoutMode: "grid", layoutMode: "grid",
audioEnabled: true, audioEnabled: true,
videoEnabled: true, videoEnabled: true,

View File

@@ -59,7 +59,6 @@ export interface FooterSnapshot {
buttonSize: "md" | "lg"; buttonSize: "md" | "lg";
showSettingsButton?: boolean; showSettingsButton?: boolean;
showLayoutSwitcher?: boolean; showLayoutSwitcher?: boolean;
showLogoDebugContainer?: boolean;
showLogo?: boolean; showLogo?: boolean;
layoutMode?: GridMode; layoutMode?: GridMode;
@@ -127,7 +126,6 @@ export const CallFooter: FC<FooterProps> = ({ ref, children, vm }) => {
videoToggles, videoToggles,
buttonSize, buttonSize,
showSettingsButton, showSettingsButton,
showLogoDebugContainer,
showLogo, showLogo,
} = useViewModel(vm); } = useViewModel(vm);
@@ -285,10 +283,10 @@ export const CallFooter: FC<FooterProps> = ({ ref, children, vm }) => {
/> />
)} )}
{children} {children}
{showLogoDebugContainer && logoDebugContainer} {(showLogo || debugTileLayout) && logoDebugContainer}
</div> </div>
{!hideControls && <div className={styles.buttons}>{buttons}</div>} {!hideControls && <div className={styles.buttons}>{buttons}</div>}
{setLayoutMode && layoutMode && ( {!hideControls && setLayoutMode && layoutMode && (
<Switch<"spotlight", "grid"> <Switch<"spotlight", "grid">
name="layoutMode" name="layoutMode"
aria-label={t("layout_switch_label")} aria-label={t("layout_switch_label")}

View File

@@ -104,30 +104,30 @@ describe("createCallFooterViewModel", () => {
} }
it("are empty when both the platform is iOS", () => { it("are empty when both the platform is iOS", () => {
checkEmptyFor("ios", gridLayout); 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", () => { it("are populated when the platform is desktop and the layout is not PiP", () => {
platformMock.mockReturnValue("desktop"); platformMock.mockReturnValue("desktop");
const vm = createCallFooterViewModel( const vm = createCallFooterViewModel(
testScope(), testScope(),
buildMinimalCallViewModel(gridLayout), buildMinimalCallViewModel(gridLayout),
mockMuteStates(), mockMuteStates(),
twoMicsAndOneCamMediaDevices, twoMicsAndOneCamMediaDevices,
/* openSettings */ undefined, /* openSettings */ undefined,
/* reactionIdentifier */ undefined, /* reactionIdentifier */ undefined,
); );
expect(vm.audioOptions$?.value).toEqual([ expect(vm.audioOptions$?.value).toEqual([
{ id: "mic1", label: "Microphone 1" }, { id: "mic1", label: "Microphone 1" },
{ id: "mic2", label: "Microphone 2" }, { id: "mic2", label: "Microphone 2" },
]); ]);
expect(vm.videoOptions$?.value).toEqual([ expect(vm.videoOptions$?.value).toEqual([
{ id: "cam1", label: "Camera 1" }, { id: "cam1", label: "Camera 1" },
]); ]);
});
}); });
}); });
}); });

View File

@@ -22,7 +22,7 @@ import {
import { type Behavior, constant } from "../state/Behavior"; import { type Behavior, constant } from "../state/Behavior";
import type { ObservableScope } from "../state/ObservableScope"; import type { ObservableScope } from "../state/ObservableScope";
import { type MuteStates } from "../state/MuteStates"; 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 { getUrlParams, HeaderStyle } from "../UrlParams";
import { platform } from "../Platform"; import { platform } from "../Platform";
import { type FooterSnapshot } from "./CallFooter"; import { type FooterSnapshot } from "./CallFooter";
@@ -173,8 +173,8 @@ export function createCallFooterViewModel(
reactionIdentifier: string | undefined, reactionIdentifier: string | undefined,
): ViewModel<FooterSnapshot> { ): ViewModel<FooterSnapshot> {
const { showControls, header: headerStyle } = getUrlParams(); const { showControls, header: headerStyle } = getUrlParams();
const showLogo = headerStyle === HeaderStyle.Standard;
const hideLogo = headerStyle !== HeaderStyle.Standard;
const isPip$ = scope.behavior( const isPip$ = scope.behavior(
callModel.layout$.pipe(map((l) => l.type === "pip")), callModel.layout$.pipe(map((l) => l.type === "pip")),
); );
@@ -206,12 +206,7 @@ export function createCallFooterViewModel(
showLayoutSwitcher$: scope.behavior( showLayoutSwitcher$: scope.behavior(
isPip$.pipe(map((l) => !isPip$ && showControls)), isPip$.pipe(map((l) => !isPip$ && showControls)),
), ),
showLogoDebugContainer$: scope.behavior( showLogo$: scope.behavior(isPip$.pipe(map((isPip) => showLogo && !isPip))),
combineLatest([isPip$, debugTileLayoutSetting.value$]).pipe(
map(([isPip, debugTile]) => !isPip || (!hideLogo && !debugTile)),
),
),
showLogo$: scope.behavior(isPip$.pipe(map((l) => !hideLogo && !isPip$))),
layoutMode$: callModel.gridMode$, layoutMode$: callModel.gridMode$,
setLayoutMode$: constant(callModel.setGridMode), setLayoutMode$: constant(callModel.setGridMode),
@@ -272,31 +267,22 @@ export function createLobbyFooterViewModel(
showLogo: boolean, showLogo: boolean,
): ViewModel<FooterSnapshot> { ): ViewModel<FooterSnapshot> {
return { 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), ...buildMuteBehaviors(scope, muteStates),
...buildDeviceBehaviors(scope, mediaDevices, constant(false)), ...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),
}; };
} }

View File

@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
*/ */
import { import {
afterEach,
beforeEach, beforeEach,
describe, describe,
expect, expect,
@@ -14,10 +15,10 @@ import {
type MockedFunction, type MockedFunction,
vi, vi,
} from "vitest"; } 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 { type LocalParticipant } from "livekit-client";
import { BehaviorSubject, of } from "rxjs"; 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 { 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,7 +35,10 @@ 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 {
type CallViewModel,
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";
@@ -100,13 +104,12 @@ beforeEach(() => {
interface CreateInCallViewArgs { interface CreateInCallViewArgs {
mediaDevices?: ECMediaDevices; mediaDevices?: ECMediaDevices;
callViewModelOptions?: Partial<CallViewModelOptions>; 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 */ /** If true, wraps the rendered tree in an AppBar provider */
withAppBar?: boolean; withAppBar?: boolean;
} }
function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & { function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
rtcSession: MockRTCSession; rtcSession: MockRTCSession;
vm: CallViewModel;
} { } {
const mediaDevices = args.mediaDevices ?? mockMediaDevices({}); const mediaDevices = args.mediaDevices ?? mockMediaDevices({});
const muteState = mockMuteStates(); const muteState = mockMuteStates();
@@ -129,14 +132,6 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
const room = rtcSession.room; const room = rtcSession.room;
const client = room.client; const client = room.client;
const Router = args.initialRoute
? ({ children }: { children: React.ReactNode }): React.ReactNode => (
<MemoryRouter initialEntries={[args.initialRoute!]}>
{children}
</MemoryRouter>
)
: BrowserRouter;
const inCallView = ( const inCallView = (
<InCallView <InCallView
client={client} client={client}
@@ -163,7 +158,7 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
const content = args.withAppBar ? <AppBar>{inCallView}</AppBar> : inCallView; const content = args.withAppBar ? <AppBar>{inCallView}</AppBar> : inCallView;
const renderResult = render( const renderResult = render(
<Router> <BrowserRouter>
<MediaDevicesContext value={mediaDevices}> <MediaDevicesContext value={mediaDevices}>
<ReactionsSenderProvider <ReactionsSenderProvider
vm={vm} vm={vm}
@@ -174,11 +169,12 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
</TooltipProvider> </TooltipProvider>
</ReactionsSenderProvider> </ReactionsSenderProvider>
</MediaDevicesContext> </MediaDevicesContext>
</Router>, </BrowserRouter>,
); );
return { return {
...renderResult, ...renderResult,
rtcSession, rtcSession,
vm,
}; };
} }
@@ -190,17 +186,37 @@ describe("InCallView", () => {
}); });
}); });
describe("settings button with AppBar header", () => { 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", () => { it("mobile landscape, is accessible when showHeader is false", () => {
// windowSize with height <= 600 results in "flat" windowMode, // windowSize with height <= 600 results in "flat" windowMode,
// which means showHeader$ emits false. // which means showHeader$ emits false.
const { getAllByRole } = createInCallView({ const { getAllByRole, queryAllByRole, vm } = createInCallView({
initialRoute: "/?header=app_bar",
withAppBar: true, withAppBar: true,
callViewModelOptions: { callViewModelOptions: {
// Set windowMode$ to "flat" (height <= 600) // Set windowMode$ to "flat" (height <= 600)
windowSize$: constant({ width: 1000, height: 500 }), 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, // When showHeader is false, hideSettingsButton is false,
// so the settings button is visible in the footer. // so the settings button is visible in the footer.
const settingsBtn = getAllByRole("button", { name: "Settings" }); const settingsBtn = getAllByRole("button", { name: "Settings" });
@@ -220,7 +236,6 @@ describe("InCallView", () => {
// windowSize with height > 600 and width > 600 results in "normal" windowMode, // windowSize with height > 600 and width > 600 results in "normal" windowMode,
// which means showHeader$ emits true. // which means showHeader$ emits true.
const { getAllByRole } = createInCallView({ const { getAllByRole } = createInCallView({
initialRoute: "/?header=app_bar",
withAppBar: true, withAppBar: true,
callViewModelOptions: { callViewModelOptions: {
// Set windowMode$ to "normal" (height >= 600) // Set windowMode$ to "normal" (height >= 600)
@@ -228,7 +243,7 @@ describe("InCallView", () => {
}, },
}); });
// When showHeader is true and headerStyle is AppBar, // 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. // button is rendered in the AppBar via useAppBarSecondaryButton.
const settingsBtns = getAllByRole("button", { name: "Settings" }); const settingsBtns = getAllByRole("button", { name: "Settings" });

View File

@@ -169,7 +169,7 @@ exports[`InCallView > rendering > renders 1`] = `
class="settingsLogoContainer" class="settingsLogoContainer"
> >
<button <button
aria-labelledby="_r_8_" aria-labelledby="_r_f_"
class="_icon-button_1215g_8 settingsOnlyShowWide" class="_icon-button_1215g_8 settingsOnlyShowWide"
data-kind="secondary" data-kind="secondary"
data-testid="settings-bottom-left" data-testid="settings-bottom-left"
@@ -304,7 +304,7 @@ exports[`InCallView > rendering > renders 1`] = `
class="buttons" class="buttons"
> >
<button <button
aria-labelledby="_r_d_" aria-labelledby="_r_k_"
class="_button_1nw83_8 settingsOnlyShowNarrow _has-icon_1nw83_60 _icon-only_1nw83_53" class="_button_1nw83_8 settingsOnlyShowNarrow _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="secondary" data-kind="secondary"
data-size="lg" data-size="lg"
@@ -328,7 +328,7 @@ exports[`InCallView > rendering > renders 1`] = `
<button <button
aria-checked="false" aria-checked="false"
aria-disabled="true" aria-disabled="true"
aria-labelledby="_r_i_" aria-labelledby="_r_p_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53" class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary" data-kind="primary"
data-size="lg" data-size="lg"
@@ -352,7 +352,7 @@ exports[`InCallView > rendering > renders 1`] = `
<button <button
aria-checked="false" aria-checked="false"
aria-disabled="true" aria-disabled="true"
aria-labelledby="_r_n_" aria-labelledby="_r_u_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53" class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary" data-kind="primary"
data-size="lg" data-size="lg"
@@ -374,7 +374,7 @@ exports[`InCallView > rendering > renders 1`] = `
</svg> </svg>
</button> </button>
<button <button
aria-labelledby="_r_s_" aria-labelledby="_r_13_"
class="_button_1nw83_8 endCall _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110" class="_button_1nw83_8 endCall _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
data-kind="primary" data-kind="primary"
data-size="lg" data-size="lg"
@@ -402,7 +402,7 @@ exports[`InCallView > rendering > renders 1`] = `
data-size="lg" data-size="lg"
> >
<input <input
aria-labelledby="_r_11_" aria-labelledby="_r_18_"
name="layoutMode" name="layoutMode"
type="radio" type="radio"
value="spotlight" value="spotlight"
@@ -420,7 +420,7 @@ exports[`InCallView > rendering > renders 1`] = `
/> />
</svg> </svg>
<input <input
aria-labelledby="_r_16_" aria-labelledby="_r_1d_"
checked="" checked=""
name="layoutMode" name="layoutMode"
type="radio" type="radio"

View File

@@ -26,7 +26,15 @@ export function useViewModel<Snapshot>(vm: ViewModel<Snapshot>): Snapshot {
return snapshot; return snapshot;
} }
export function createMockedViewModel<Snapshot>( /**
* This allows to build a view model (or Partial view model)
* with BehaviorSubjects.
* It can be used in tests and for simplifying view model creation for non reactive snapshot parameters.
*
* @param snapshot The snapshot values this view model with start with. ({a: number, b: string})
* @returns A view model: ({a$: BehaviroSubject<number>, b$: BehaviroSubject<string>}) (note the automatic addition of $ at the end of the keys)
*/
export function createStaticViewModel<Snapshot>(
snapshot: Snapshot, snapshot: Snapshot,
): ViewModel<Snapshot> { ): ViewModel<Snapshot> {
const vm = {} as ViewModel<Snapshot>; const vm = {} as ViewModel<Snapshot>;