Files
element-call-Github/src/components/MediaMuteAndSwitchButton.test.tsx
T
fkwp 7f3b98ed6a Size the device menu against the call, and cover its criteria
- Bound the scrolling device list by the measured height of the call area.
  It was capped with a viewport unit and a Radix variable, both of which
  measure the window: as a component in a corner of a host's page, the menu
  would have been sized against the whole page. The menu is portalled outside
  the root, so no container query reaches it and it has to be measured.
- Leave a single divider between the two lists, as the design has. Compound
  underlines every menu heading, so the headings drop theirs, and the line
  closing the microphone section goes: the section wrapper already bounds the
  meter's stickiness without one.
- Cover device persistence, hot-plug, fallback when the device in use is
  removed, fallback to a default when a remembered device is gone, numbered
  labels before permission, "Default" listed as its own entry, one device kind
  not disturbing another, and camera parity. Their acceptance criteria named
  check commands for tests that did not exist, which makes the criteria
  unenforceable and blocks the next drift check.

Spec: FEATURES_SPEC/2026-09_Quick_Audio_Menu.md — AC3, AC4, AC5, AC8, AC9,
AC10, AC11, AC19, AC20
2026-09-16 16:23:02 +02:00

560 lines
18 KiB
TypeScript

/*
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, type ReactNode } from "react";
import { TooltipProvider } from "@vector-im/compound-web";
import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { type MediaDevices } from "../state/MediaDevices";
interface RenderOptions {
requestDeviceNames: () => void;
}
function renderComponent(
component: ReactNode,
{ requestDeviceNames = (): void => {} }: Partial<RenderOptions> = {},
): RenderResult {
return render(
<TooltipProvider>
<MediaDevicesContext
value={{ requestDeviceNames } as unknown as MediaDevices}
>
{component}
</MediaDevicesContext>
</TooltipProvider>,
);
}
describe("MediaMuteAndSwitchButton", () => {
test("renders", () => {
const { container } = renderComponent(
<TooltipProvider>
<MediaMuteAndSwitchButton title={"Switcher"} iconsAndLabels={"audio"} />
</TooltipProvider>,
);
expect(container).toMatchSnapshot();
});
test("renders correct audio and video labels", () => {
const renderLabels = (
type: "video" | "audio",
enabled: boolean,
): RenderResult => {
return renderComponent(
<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("switch", { name: "Mute microphone" }),
).toBeInTheDocument();
expect(
renderAudioDisabled.getByRole("switch", { name: "Unmute microphone" }),
).toBeInTheDocument();
expect(
renderVideoEnabled.getByRole("switch", { name: "Start video" }),
).toBeInTheDocument();
expect(
renderVideoDisabled.getByRole("switch", { name: "Stop video" }),
).toBeInTheDocument();
});
test("calls mute on mute press", async () => {
const user = userEvent.setup();
const onMute = vi.fn();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
title={"Switcher"}
onMuteClick={onMute}
iconsAndLabels="audio"
enabled={true}
/>,
);
await user.click(getByRole("switch", { name: "Mute microphone" }));
expect(onMute).toHaveBeenCalled();
});
test("disables mute button while busy", async () => {
const user = userEvent.setup();
const onMute = vi.fn();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
title={"Switcher"}
onMuteClick={onMute}
iconsAndLabels="audio"
enabled={true}
busy={true}
/>,
);
const muteButton = getByRole("switch", { name: "Mute microphone" });
expect(muteButton).toHaveAttribute("aria-disabled", "true");
expect(muteButton).toHaveAttribute("aria-busy", "true");
await user.click(muteButton);
expect(onMute).not.toHaveBeenCalled();
});
test("disables video button while busy", async () => {
const user = userEvent.setup();
const onMute = vi.fn();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
title={"Switcher"}
onMuteClick={onMute}
iconsAndLabels="video"
enabled={true}
busy={true}
/>,
);
const videoButton = getByRole("switch", { name: "Stop video" });
expect(videoButton).toHaveAttribute("aria-disabled", "true");
expect(videoButton).toHaveAttribute("aria-busy", "true");
await user.click(videoButton);
expect(onMute).not.toHaveBeenCalled();
});
test("requests device names when opened", async () => {
const user = userEvent.setup();
const requestDeviceNames = vi.fn();
renderComponent(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled
/>,
{ requestDeviceNames },
);
expect(requestDeviceNames).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Microphone" }));
expect(requestDeviceNames).toHaveBeenCalled();
});
test("shows numbered devices correctly", async () => {
const user = userEvent.setup();
renderComponent(
<>
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled
options={[
{ label: { type: "number", number: 1 }, id: "mic1" },
{ label: { type: "number", number: 2 }, id: "mic2" },
]}
selectedOption="mic1"
/>
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="video"
enabled
options={[
{ label: { type: "number", number: 1 }, id: "cam1" },
{ label: { type: "number", number: 2 }, id: "cam2" },
]}
selectedOption="cam1"
/>
</>,
);
await user.click(screen.getByRole("button", { name: "Microphone" }));
screen.getByRole("menuitemradio", { name: "Microphone 1" });
screen.getByRole("menuitemradio", { name: "Microphone 2" });
await user.keyboard("[Escape]");
await user.click(screen.getByRole("button", { name: "Camera" }));
screen.getByRole("menuitemradio", { name: "Camera 1" });
screen.getByRole("menuitemradio", { name: "Camera 2" });
});
test("calls select callback on menu click", async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption="mic1"
onSelect={onSelect}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
await user.click(
screen.getByRole("menuitemradio", { name: "Microphone 2" }),
);
expect(onSelect).toHaveBeenCalledWith("mic2");
});
test("does not call select callback on already selected menu click", async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption="mic1"
onSelect={onSelect}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
await user.click(
screen.getByRole("menuitemradio", { name: "Microphone 1" }),
);
expect(onSelect).not.toHaveBeenCalled();
});
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: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption={selectedOption}
onSelect={(id) => {
onSelectPressed();
void promise.then(() => {
setSelectedOption(id);
onOptionUpdated();
});
}}
/>
);
}
const { getByRole } = renderComponent(<Wrapper />);
await user.click(getByRole("button", { name: "Microphone" }));
await user.click(
screen.getByRole("menuitemradio", { name: "Microphone 2" }),
);
expect(onSelectPressed).toHaveBeenCalled();
expect(onOptionUpdated).not.toHaveBeenCalled();
// After clicking, plannedSelection="mic2" but selectedOption is still "mic1",
// so mic2 should be in an activating state
screen.getByRole("menuitemradio", {
name: "Microphone 2 Activating…",
checked: false,
});
// The currently-selected mic1 item should not be activating
screen.getByRole("menuitemradio", {
name: "Microphone 1",
checked: true,
});
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("menuitemradio", {
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 onVideoBlurToggle = vi.fn();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="video"
enabled={true}
videoBlurToggleClick={onVideoBlurToggle}
onSelect={onSelect}
/>,
);
await user.click(getByRole("button", { name: "Camera" }));
const toggle = screen.getByRole("menuitemcheckbox", {
name: "Blur background",
});
expect(toggle).toBeInTheDocument();
expect(toggle).toHaveAttribute("aria-checked", "false");
await user.click(toggle);
expect(onVideoBlurToggle).toHaveBeenCalled();
});
test("marks the selected menu item as checked", async () => {
const user = userEvent.setup();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption="mic2"
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
screen.getByRole("menuitemradio", { name: "Microphone 2", checked: true });
screen.getByRole("menuitemradio", { name: "Microphone 1", checked: false });
});
test("disables every device while a selection is settling", async () => {
const user = userEvent.setup();
const { promise, resolve } = Promise.withResolvers<void>();
function Wrapper(): JSX.Element {
const [selectedOption, setSelectedOption] = useState("mic1");
return (
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption={selectedOption}
onSelect={(id) => {
void promise.then(() => setSelectedOption(id));
}}
outputOptions={[
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
{ label: { type: "name", name: "Headset" }, id: "spk2" },
]}
selectedOutputOption="spk1"
onSelectOutput={vi.fn()}
/>
);
}
const { getByRole } = renderComponent(<Wrapper />);
await user.click(getByRole("button", { name: "Microphone" }));
await user.click(
screen.getByRole("menuitemradio", { name: "Microphone 2" }),
);
// In flight: nothing else can be picked, in either section, so a second
// request cannot overtake the first.
for (const name of ["Microphone 1", "Speakers", "Headset"]) {
expect(screen.getByRole("menuitemradio", { name })).toHaveAttribute(
"aria-disabled",
"true",
);
}
await act(async () => {
resolve();
await promise;
});
// Settled: choosable again.
expect(
screen.getByRole("menuitemradio", { name: "Microphone 1" }),
).not.toHaveAttribute("aria-disabled", "true");
expect(
screen.getByRole("menuitemradio", { name: "Headset" }),
).not.toHaveAttribute("aria-disabled", "true");
});
test("camera menu uses the same selection pattern and keeps the blur toggle", async () => {
const user = userEvent.setup();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="video"
enabled={true}
options={[
{ label: { type: "name", name: "Camera 1" }, id: "cam1" },
{ label: { type: "name", name: "Camera 2" }, id: "cam2" },
]}
selectedOption="cam1"
onSelect={vi.fn()}
videoBlurToggleClick={vi.fn()}
/>,
);
await user.click(getByRole("button", { name: "Camera" }));
// Same selection pattern as the microphone menu.
screen.getByRole("menuitemradio", { name: "Camera 1", checked: true });
screen.getByRole("menuitemradio", { name: "Camera 2", checked: false });
// And background blur is still reachable from here.
expect(
screen.getByRole("menuitemcheckbox", { name: "Blur background" }),
).toBeInTheDocument();
});
test("lists speaker and microphone sections", async () => {
const user = userEvent.setup();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption="mic1"
onSelect={vi.fn()}
outputOptions={[
{
label: { type: "default", name: "Built-in Output" },
id: "default",
},
{ label: { type: "name", name: "Headset" }, id: "spk2" },
]}
selectedOutputOption="default"
onSelectOutput={vi.fn()}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
screen.getByRole("menuitemradio", {
name: "Default (Built-in Output)",
checked: true,
});
screen.getByRole("menuitemradio", { name: "Headset", checked: false });
screen.getByRole("menuitemradio", { name: "Microphone 1", checked: true });
screen.getByRole("menuitemradio", { name: "Microphone 2", checked: false });
});
test("calls the output select callback on speaker click", async () => {
const user = userEvent.setup();
const onSelectOutput = vi.fn();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption="mic1"
outputOptions={[
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
{ label: { type: "name", name: "Headset" }, id: "spk2" },
]}
selectedOutputOption="spk1"
onSelectOutput={onSelectOutput}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
await user.click(screen.getByRole("menuitemradio", { name: "Headset" }));
expect(onSelectOutput).toHaveBeenCalledWith("spk2");
});
test("shows a single device entry disabled", async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
]}
selectedOption="mic1"
onSelect={onSelect}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
// Shown rather than hidden, so the menu keeps its shape, but not choosable.
const only = screen.getByRole("menuitemradio", { name: "Microphone 1" });
expect(only).toHaveAttribute("aria-disabled", "true");
});
test("shows the speaker section disabled when output selection is unsupported", async () => {
const user = userEvent.setup();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption="mic1"
onSelect={vi.fn()}
outputOptions={[
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
{ label: { type: "name", name: "Headset" }, id: "spk2" },
]}
selectedOutputOption="spk1"
// No callback: nothing can be picked here.
onSelectOutput={undefined}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
expect(
screen.getByRole("menuitemradio", { name: "Speakers" }),
).toHaveAttribute("aria-disabled", "true");
expect(
screen.getByRole("menuitemradio", { name: "Headset" }),
).toHaveAttribute("aria-disabled", "true");
// The microphone section is unaffected.
expect(
screen.getByRole("menuitemradio", { name: "Microphone 2" }),
).not.toHaveAttribute("aria-disabled", "true");
});
});