Add tests

This commit is contained in:
Timo K
2026-05-05 10:55:52 +02:00
parent 86f5b9888c
commit 63e2b4c549
5 changed files with 351 additions and 49 deletions

View File

@@ -8,14 +8,6 @@ Please see LICENSE in the repository root for full details.
import { type FC, type JSX, type Ref, useMemo } from "react";
import classNames from "classnames";
import { BehaviorSubject } from "rxjs";
import {
MicOffSolidIcon,
MicOnSolidIcon,
MicOnIcon,
VideoCallSolidIcon,
VideoCallIcon,
VideoCallOffSolidIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import LogoMark from "../icons/LogoMark.svg?react";
import LogoType from "../icons/LogoType.svg?react";
@@ -147,9 +139,7 @@ export const CallFooter: FC<FooterProps> = ({
<MediaMuteAndSwitchButton
title={"Mic Source"}
key="audio"
IconEnabled={MicOnSolidIcon}
IconDisabled={MicOffSolidIcon}
IconOptions={MicOnIcon}
iconsAndLabels="video"
enabled={audioEnabled ?? false}
onMuteClick={toggleAudio}
data-testid="incall_mute"
@@ -174,9 +164,7 @@ export const CallFooter: FC<FooterProps> = ({
<MediaMuteAndSwitchButton
title={"Camera Source"}
key="audio"
IconEnabled={VideoCallSolidIcon}
IconDisabled={VideoCallOffSolidIcon}
IconOptions={VideoCallIcon}
iconsAndLabels="audio"
enabled={videoEnabled ?? false}
onMuteClick={toggleVideo}
data-testid="incall_mute"

View File

@@ -5,15 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
MicOnSolidIcon,
MicOffSolidIcon,
VideoCallSolidIcon,
VideoCallOffSolidIcon,
AdvancedSettingsIcon,
VideoCallIcon,
MicOnIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { AdvancedSettingsIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { fn, userEvent, within, expect } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton";
@@ -28,23 +21,29 @@ type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
title: "SomeMenu",
IconEnabled: AdvancedSettingsIcon,
IconDisabled: AdvancedSettingsIcon,
iconsAndLabels: {
IconEnabled: AdvancedSettingsIcon,
IconDisabled: AdvancedSettingsIcon,
enabledLabel: "Enabled",
disabledLabel: "Disabled",
optionsButtonLabel: "Options",
},
enabled: true,
options: [
{ label: "option 1", id: "1" },
{ label: "option 2", id: "2" },
],
selectedOption: "1",
onMuteClick: fn(),
onSelect: fn(),
},
};
export const AudioMute: Story = {
args: {
...Default.args,
title: "Microphone",
IconEnabled: MicOnSolidIcon,
IconDisabled: MicOffSolidIcon,
IconOptions: MicOnIcon,
iconsAndLabels: "audio",
enabled: false,
options: [
{ label: "Microphone 1", id: "1" },
@@ -52,14 +51,20 @@ export const AudioMute: Story = {
],
selectedOption: "2",
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
// Both the mute button and the chevron trigger currently share the aria-label "Edit"
// (both are TODO placeholders in the component). The mute button is first in the DOM.
const muteButton = canvas.getByLabelText("Unmute microphone");
await userEvent.click(muteButton);
await expect(args.onMuteClick).toHaveBeenCalled();
},
};
export const AudioUnmute: Story = {
args: {
title: "Microphone",
IconEnabled: MicOnSolidIcon,
IconDisabled: MicOffSolidIcon,
IconOptions: MicOnIcon,
iconsAndLabels: "audio",
enabled: true,
options: [
{ label: "Microphone 1", id: "1" },
@@ -72,9 +77,7 @@ export const AudioUnmute: Story = {
export const VideoMute: Story = {
args: {
title: "Camera",
IconEnabled: VideoCallSolidIcon,
IconDisabled: VideoCallOffSolidIcon,
IconOptions: VideoCallIcon,
iconsAndLabels: "video",
enabled: false,
options: [
{ label: "Camera 1", id: "1" },
@@ -87,9 +90,7 @@ export const VideoMute: Story = {
export const VideoUnmute: Story = {
args: {
title: "Camera",
IconEnabled: VideoCallSolidIcon,
IconDisabled: VideoCallOffSolidIcon,
IconOptions: VideoCallIcon,
iconsAndLabels: "video",
enabled: true,
options: [
{ label: "Camera 1", id: "1" },

View File

@@ -0,0 +1,204 @@
/*
Copyright 2023, 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, test, vi } from "vitest";
import { act, render, screen, type RenderResult } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { type JSX, useState } from "react";
import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton";
describe("MediaMuteAndSwitchButton", () => {
test("renders", () => {
const { container } = render(
<MediaMuteAndSwitchButton title={"Switcher"} />,
);
expect(container).toMatchSnapshot();
});
test("renders correct audio and video labels", () => {
const renderLabels = (
type: "video" | "audio",
enabled: boolean,
): RenderResult => {
return render(
<MediaMuteAndSwitchButton
title={"Switcher"}
iconsAndLabels={type}
enabled={enabled}
/>,
);
};
const renderAudioEndabled = renderLabels("audio", true);
const renderAudioDisabled = renderLabels("audio", false);
const renderVideoEnabled = renderLabels("video", true);
const renderVideoDisabled = renderLabels("video", false);
expect(
renderAudioEndabled.getByRole("button", { name: "Mute microphone" }),
).toBeInTheDocument();
expect(
renderAudioDisabled.getByRole("button", { name: "Unmute microphone" }),
).toBeInTheDocument();
expect(
renderVideoEnabled.getByRole("button", { name: "Start video" }),
).toBeInTheDocument();
expect(
renderVideoDisabled.getByRole("button", { name: "Stop video" }),
).toBeInTheDocument();
});
test("calls mute on mute press", async () => {
const user = userEvent.setup();
const onMute = vi.fn();
const { getByRole } = render(
<MediaMuteAndSwitchButton
title={"Switcher"}
onMuteClick={onMute}
iconsAndLabels="audio"
enabled={true}
/>,
);
await user.click(getByRole("button", { name: "Mute microphone" }));
expect(onMute).toHaveBeenCalled();
});
test("calls select callback on menu click", async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
const { getByRole } = render(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
{ label: "Microphone 1", id: "mic1" },
{ label: "Microphone 2", id: "mic2" },
]}
selectedOption="mic1"
onSelect={onSelect}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
await user.click(screen.getByRole("menuitem", { name: "Microphone 2" }));
expect(onSelect).toHaveBeenCalledWith("mic2");
});
test("renders menu spinner until selection updates for the component", async () => {
const user = userEvent.setup();
const { promise, resolve } = Promise.withResolvers<void>();
const onSelectPressed = vi.fn();
const onOptionUpdated = vi.fn();
function Wrapper(): JSX.Element {
const [selectedOption, setSelectedOption] = useState("mic1");
return (
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
{ label: "Microphone 1", id: "mic1" },
{ label: "Microphone 2", id: "mic2" },
]}
selectedOption={selectedOption}
onSelect={(id) => {
onSelectPressed();
void promise.then(() => {
setSelectedOption(id);
onOptionUpdated();
});
}}
/>
);
}
const { getByRole } = render(<Wrapper />);
await user.click(getByRole("button", { name: "Microphone" }));
await user.click(screen.getByRole("menuitem", { name: "Microphone 2" }));
expect(onSelectPressed).toHaveBeenCalled();
expect(onOptionUpdated).not.toHaveBeenCalled();
// After clicking, plannedSelection="mic2" but selectedOption is still "mic1",
// so a spinner should appear on the mic2 item
const mic2Item = screen.getByRole("menuitem", { name: "Microphone 2" });
expect(mic2Item.querySelector(".rotate")).toBeTruthy();
// The currently-selected mic1 item should not have a spinner
const mic1Item = screen.getByRole("menuitem", { name: "Microphone 1" });
expect(mic1Item.querySelector(".rotate")).toBeNull();
await act(async () => {
// resolve the promise that acutally updates the select option.
resolve();
await promise;
});
expect(onOptionUpdated).toHaveBeenCalled();
// Spinner should now be gone since the selection has caught up
const mic2ItemAfter = screen.getByRole("menuitem", {
name: "Microphone 2",
});
expect(mic2ItemAfter.querySelector(".rotate")).toBeNull();
});
test("renders menu with toggle control and calls toggle callback", async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
const { getByRole } = render(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
toggles={[{ label: "Background blur", id: "bg_blur", enabled: false }]}
onSelect={onSelect}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
const toggle = screen.getByRole("menuitemcheckbox", {
name: "Background blur",
});
expect(toggle).toBeInTheDocument();
expect(toggle).toHaveAttribute("aria-checked", "false");
await user.click(toggle);
expect(onSelect).toHaveBeenCalledWith("bg_blur");
});
test("renders check icon to mark the selected menu item", async () => {
const user = userEvent.setup();
const { getByRole } = render(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
{ label: "Microphone 1", id: "mic1" },
{ label: "Microphone 2", id: "mic2" },
]}
selectedOption="mic2"
/>,
);
// open menu
await user.click(getByRole("button", { name: "Microphone" }));
// The selected item (mic2) renders both an IconOptions SVG and a CheckIcon SVG
const mic1Item = screen.getByRole("menuitem", { name: "Microphone 2" });
expect(mic1Item.querySelectorAll("svg").length).toBe(2);
// The unselected item (mic1) only renders its IconOptions SVG
const mic2Item = screen.getByRole("menuitem", { name: "Microphone 1" });
expect(mic2Item.querySelectorAll("svg").length).toBe(1);
});
});

View File

@@ -16,11 +16,19 @@ import { t } from "i18next";
import {
CheckIcon,
ChevronUpIcon,
MicOffSolidIcon,
MicOnIcon,
MicOnSolidIcon,
SpinnerIcon,
VideoCallIcon,
VideoCallOffSolidIcon,
VideoCallSolidIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import classNames from "classnames";
import { logger } from "matrix-js-sdk/lib/logger";
import styles from "./MediaMuteAndSwitchButton.module.css";
export interface MenuOptions {
label: string;
id: string;
@@ -31,6 +39,18 @@ export interface ToggleOption {
id: string;
}
export interface IconsAndLabels {
/** The Icon used if the mute button is enabled */
IconEnabled: ComponentType<React.SVGAttributes<SVGElement>>;
/** The Icon used if the mute button is disabled */
IconDisabled: ComponentType<React.SVGAttributes<SVGElement>>;
/** The icon used for the different options */
IconOptions?: ComponentType<React.SVGAttributes<SVGElement>>;
enabledLabel: string;
disabledLabel: string;
optionsButtonLabel: string;
}
export interface MediaMuteAndSwitchButtonProps {
/** The title used in the Switcher modal. */
title: string;
@@ -38,16 +58,11 @@ export interface MediaMuteAndSwitchButtonProps {
enabled?: boolean;
/** Callback if the mute button is clicked */
onMuteClick?: () => void;
/** The Icon used if the mute button is enabled */
IconEnabled: ComponentType<React.SVGAttributes<SVGElement>>;
/** The Icon used if the mute button is disabled */
IconDisabled: ComponentType<React.SVGAttributes<SVGElement>>;
iconsAndLabels?: "video" | "audio" | IconsAndLabels;
/** The options available for the media device selector modal */
options?: MenuOptions[];
/** The option that will currently be rendered as the selected option */
selectedOption?: string;
/** The icon used for the different options */
IconOptions?: ComponentType<React.SVGAttributes<SVGElement>>;
/**
* The available toggles (including there current state)
* The toggle state is not stored by this component.
@@ -65,16 +80,67 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
title,
enabled,
onMuteClick,
IconEnabled,
IconDisabled,
iconsAndLabels: iconsAndLabelsWithDefaultCases,
options,
selectedOption,
IconOptions,
toggles,
onSelect,
}) => {
const [plannedSelection, setPlannedSelection] = useState<string | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
let iconsAndLabels: IconsAndLabels | undefined;
switch (iconsAndLabelsWithDefaultCases) {
case "video":
iconsAndLabels = {
IconEnabled: VideoCallSolidIcon,
IconDisabled: VideoCallOffSolidIcon,
IconOptions: VideoCallIcon,
disabledLabel: t("stop_video_button_label"),
enabledLabel: t("start_video_button_label"),
optionsButtonLabel: t("settings.devices.microphone"),
};
break;
case "audio":
iconsAndLabels = {
IconEnabled: MicOnSolidIcon,
IconDisabled: MicOffSolidIcon,
IconOptions: MicOnIcon,
disabledLabel: t("mute_microphone_button_label"),
enabledLabel: t("unmute_microphone_button_label"),
optionsButtonLabel: t("settings.devices.microphone"),
};
break;
default:
iconsAndLabels = iconsAndLabelsWithDefaultCases;
break;
}
const {
IconEnabled,
IconDisabled,
IconOptions,
disabledLabel,
enabledLabel,
optionsButtonLabel,
} = iconsAndLabels ?? {
IconEnabled: undefined,
IconDisabled: undefined,
IconOptions: undefined,
disabledLabel: undefined,
enabledLabel: undefined,
optionsButtonLabel: undefined,
};
{
logger.info(
"RENDER WITH: selectedOption !== option.id && plannedSelection === option.id",
selectedOption,
" !==",
"option.id",
" && ",
plannedSelection,
" === ",
"option.id",
);
}
return (
<div
className={classNames({
@@ -94,7 +160,7 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
kind={enabled ? "secondary" : "primary"}
size="lg"
className={styles.button}
aria-label={t("action.edit")}
aria-label={enabled ? disabledLabel : enabledLabel}
/>
<Menu
title={title}
@@ -109,7 +175,7 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
Icon={ChevronUpIcon}
kind={"tertiary"}
size="lg"
aria-label={/*TODO*/ t("action.edit")}
aria-label={optionsButtonLabel}
/>
}
>
@@ -127,8 +193,8 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
)
}
onSelect={(e) => {
onSelect?.(option.id);
setPlannedSelection(option.id);
onSelect?.(option.id);
e.preventDefault();
}}
key={option.id}

View File

@@ -0,0 +1,43 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`MediaMuteAndSwitchButton > renders 1`] = `
<div>
<div
class="container"
>
<button
class="_button_1nw83_8 button _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
role="button"
tabindex="0"
/>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
class="_button_1nw83_8 menuButton _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="tertiary"
data-size="lg"
data-state="closed"
id="radix-_r_0_"
role="button"
tabindex="0"
type="button"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m12 10.775-3.9 3.9a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275.95.95 0 0 1-.275-.7q0-.425.275-.7l4.6-4.6q.15-.15.325-.212Q11.8 8.4 12 8.4t.375.063a.9.9 0 0 1 .325.212l4.6 4.6a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275z"
/>
</svg>
</button>
</div>
</div>
`;