From 63e2b4c5498d18a8e5574456455900a72f1e0891 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 5 May 2026 10:55:52 +0200 Subject: [PATCH] Add tests --- src/components/CallFooter.tsx | 16 +- .../MediaMuteAndSwitchButton.stories.tsx | 47 ++-- .../MediaMuteAndSwitchButton.test.tsx | 204 ++++++++++++++++++ src/components/MediaMuteAndSwitchButton.tsx | 90 ++++++-- .../MediaMuteAndSwitchButton.test.tsx.snap | 43 ++++ 5 files changed, 351 insertions(+), 49 deletions(-) create mode 100644 src/components/MediaMuteAndSwitchButton.test.tsx create mode 100644 src/components/__snapshots__/MediaMuteAndSwitchButton.test.tsx.snap diff --git a/src/components/CallFooter.tsx b/src/components/CallFooter.tsx index c0ced063..4c556f44 100644 --- a/src/components/CallFooter.tsx +++ b/src/components/CallFooter.tsx @@ -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 = ({ = ({ ; 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" }, diff --git a/src/components/MediaMuteAndSwitchButton.test.tsx b/src/components/MediaMuteAndSwitchButton.test.tsx new file mode 100644 index 00000000..a3a9cf2c --- /dev/null +++ b/src/components/MediaMuteAndSwitchButton.test.tsx @@ -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( + , + ); + expect(container).toMatchSnapshot(); + }); + + test("renders correct audio and video labels", () => { + const renderLabels = ( + type: "video" | "audio", + enabled: boolean, + ): RenderResult => { + return render( + , + ); + }; + 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( + , + ); + + 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( + , + ); + + 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(); + const onSelectPressed = vi.fn(); + const onOptionUpdated = vi.fn(); + function Wrapper(): JSX.Element { + const [selectedOption, setSelectedOption] = useState("mic1"); + return ( + { + onSelectPressed(); + void promise.then(() => { + setSelectedOption(id); + onOptionUpdated(); + }); + }} + /> + ); + } + + const { getByRole } = render(); + + 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( + , + ); + + 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( + , + ); + + // 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); + }); +}); diff --git a/src/components/MediaMuteAndSwitchButton.tsx b/src/components/MediaMuteAndSwitchButton.tsx index 55e99766..6325a8e8 100644 --- a/src/components/MediaMuteAndSwitchButton.tsx +++ b/src/components/MediaMuteAndSwitchButton.tsx @@ -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>; + /** The Icon used if the mute button is disabled */ + IconDisabled: ComponentType>; + /** The icon used for the different options */ + IconOptions?: ComponentType>; + 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>; - /** The Icon used if the mute button is disabled */ - IconDisabled: ComponentType>; + 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>; /** * The available toggles (including there current state) * The toggle state is not stored by this component. @@ -65,16 +80,67 @@ export const MediaMuteAndSwitchButton: FC = ({ title, enabled, onMuteClick, - IconEnabled, - IconDisabled, + iconsAndLabels: iconsAndLabelsWithDefaultCases, options, selectedOption, - IconOptions, toggles, onSelect, }) => { const [plannedSelection, setPlannedSelection] = useState(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 (
= ({ kind={enabled ? "secondary" : "primary"} size="lg" className={styles.button} - aria-label={t("action.edit")} + aria-label={enabled ? disabledLabel : enabledLabel} /> = ({ Icon={ChevronUpIcon} kind={"tertiary"} size="lg" - aria-label={/*TODO*/ t("action.edit")} + aria-label={optionsButtonLabel} /> } > @@ -127,8 +193,8 @@ export const MediaMuteAndSwitchButton: FC = ({ ) } onSelect={(e) => { - onSelect?.(option.id); setPlannedSelection(option.id); + onSelect?.(option.id); e.preventDefault(); }} key={option.id} diff --git a/src/components/__snapshots__/MediaMuteAndSwitchButton.test.tsx.snap b/src/components/__snapshots__/MediaMuteAndSwitchButton.test.tsx.snap new file mode 100644 index 00000000..13f34f45 --- /dev/null +++ b/src/components/__snapshots__/MediaMuteAndSwitchButton.test.tsx.snap @@ -0,0 +1,43 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`MediaMuteAndSwitchButton > renders 1`] = ` +
+
+ +
+
+`;