mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-22 22:29:30 +00:00
- 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
560 lines
18 KiB
TypeScript
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");
|
|
});
|
|
});
|