Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 43 additions & 41 deletions frontend/src/components/panels/ButtonRemapPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ButtonRemapPanel } from "./ButtonRemapPanel";

Expand All @@ -12,76 +12,78 @@ const remap = {
Actions: ["off", "left", "right", "middle", "forward", "backward", "double_click", "fire"], Firmware: "", Error: { Code: "" },
};

const multimediaRemap = {
...remap,
Actions: [...remap.Actions, "media_player", "play_pause", "stop", "previous_track", "next_track", "volume_up", "volume_down", "mute"],
};

afterEach(cleanup);

describe("ButtonRemapPanel", () => {
it("renders exactly seven closed-action selectors and preserves DPI markers", () => {
render(<ButtonRemapPanel remap={remap} ready onStage={vi.fn()} />);
expect(screen.getAllByRole("combobox")).toHaveLength(7);
expect(screen.getAllByRole("button", { name: /Button \d action/ })).toHaveLength(7);
expect(screen.getByText(/Button 6 \(DPI\+\)/)).toBeInTheDocument();
expect(screen.getByText(/Button 7 \(DPI-\)/)).toBeInTheDocument();
});

it("keeps selections in the draft and shows the complete confirmation summary", () => {
it("stages Basic choices without applying and shows the complete confirmation summary", () => {
const onStage = vi.fn();
const onApply = vi.fn();
render(<ButtonRemapPanel remap={{ ...remap, Firmware: "pending" }} ready onStage={onStage} onApply={onApply} />);
const combobox = screen.getAllByRole("combobox")[0];
fireEvent.click(combobox);
const option = screen.getByRole("option", { name: "Fire" });
fireEvent.click(option);
const selector = screen.getByRole("button", { name: "Button 1 action" });
fireEvent.click(selector);
fireEvent.click(screen.getByRole("menuitem", { name: "Basic" }));
fireEvent.click(screen.getByRole("menuitem", { name: "Fire" }));
expect(onStage).toHaveBeenCalledWith(1, "fire");
expect(onApply).not.toHaveBeenCalled();
expect(screen.getByLabelText("Remap assignment summary")).toHaveTextContent("Button 1: Left, Button 2: Right, Button 3: Middle, Button 4: Forward, Button 5: Backward, Button 6: DPI+, Button 7: DPI-");
expect(screen.getByRole("status")).toHaveTextContent("draft pending confirmation");
});

it("groups multimedia actions and disables them only for Button 1", () => {
it("keeps Multimedia visible but unavailable for Button 1", () => {
const onStage = vi.fn();
const multimediaRemap = {
...remap,
Actions: [...remap.Actions, "media_player", "play_pause", "stop", "previous_track", "next_track", "volume_up", "volume_down", "mute"],
};
render(<ButtonRemapPanel remap={multimediaRemap as never} ready onStage={onStage} />);

const selectors = screen.getAllByRole("combobox");
fireEvent.click(selectors[0]);
expect(screen.getByRole("group", { name: "Multimedia" })).toBeInTheDocument();
const disabledMediaPlayer = screen.getByRole("option", { name: "Media Player" });
fireEvent.click(screen.getByRole("button", { name: "Button 1 action" }));
fireEvent.click(screen.getByRole("menuitem", { name: "Multimedia" }));
const submenu = screen.getByRole("menu", { name: "Multimedia actions" });
const disabledMediaPlayer = within(submenu).getByRole("menuitem", { name: "Media Player" });
expect(disabledMediaPlayer).toHaveAttribute("aria-disabled", "true");
fireEvent.click(disabledMediaPlayer);
expect(onStage).not.toHaveBeenCalled();
fireEvent.keyDown(selectors[0], { key: "End" });
expect(selectors[0]).toHaveAttribute("aria-activedescendant", expect.stringMatching(/-opt-7$/));
});

fireEvent.click(selectors[1]);
const enabledMediaPlayer = Array.from(screen.getByRole("listbox", { name: "Button 2 action" }).querySelectorAll('[role="option"]')).find((option) => option.textContent === "Media Player")!;
expect(enabledMediaPlayer).toHaveAttribute("aria-disabled", "false");
fireEvent.click(enabledMediaPlayer);
expect(onStage).toHaveBeenCalledWith(2, "media_player");
it("allows Buttons 2–7 to stage Multimedia actions", () => {
const onStage = vi.fn();
render(<ButtonRemapPanel remap={multimediaRemap as never} ready onStage={onStage} />);

for (let button = 2; button <= 7; button++) {
fireEvent.click(screen.getByRole("button", { name: `Button ${button} action` }));
fireEvent.click(screen.getByRole("menuitem", { name: "Multimedia" }));
const submenu = screen.getByRole("menu", { name: "Multimedia actions" });
const mediaPlayer = within(submenu).getByRole("menuitem", { name: "Media Player" });
expect(mediaPlayer).toHaveAttribute("aria-disabled", "false");
fireEvent.click(mediaPlayer);
}
expect(onStage).toHaveBeenCalledTimes(6);
expect(onStage).toHaveBeenNthCalledWith(1, 2, "media_player");
expect(onStage).toHaveBeenNthCalledWith(6, 7, "media_player");
});

it("preserves ordered groups and disabled semantics for all seven selectors", () => {
const multimediaRemap = {
...remap,
Actions: [...remap.Actions, "media_player", "play_pause", "stop", "previous_track", "next_track", "volume_up", "volume_down", "mute"],
};
render(<ButtonRemapPanel remap={multimediaRemap as never} ready onStage={vi.fn()} />);
it("does not bubble selector keys into panel Apply or Discard shortcuts", () => {
const onApply = vi.fn();
const onDiscard = vi.fn();
render(<ButtonRemapPanel remap={multimediaRemap as never} ready onStage={vi.fn()} onApply={onApply} onDiscard={onDiscard} />);
const selector = screen.getByRole("button", { name: "Button 2 action" });

const selectors = screen.getAllByRole("combobox");
selectors.forEach((selector) => fireEvent.click(selector));
const listboxes = screen.getAllByRole("listbox");
expect(listboxes).toHaveLength(7);
listboxes.forEach((listbox, index) => {
const groups = Array.from(listbox.querySelectorAll('[role="group"]')).map((group) => group.getAttribute("aria-label"));
expect(groups).toEqual(["Basic", "Multimedia"]);
const multimedia = Array.from(listbox.querySelectorAll('[role="group"][aria-label="Multimedia"] [role="option"]'));
expect(multimedia.map((option) => option.textContent)).toEqual(["Media Player", "Play/Pause", "Stop", "Previous Track", "Next Track", "Volume Up", "Volume Down", "Mute"]);
expect(multimedia.every((option) => option.getAttribute("aria-disabled") === (index === 0 ? "true" : "false"))).toBe(true);
});
fireEvent.keyDown(selector, { key: "Enter" });
fireEvent.keyDown(selector, { key: "Escape" });
expect(onApply).not.toHaveBeenCalled();
expect(onDiscard).not.toHaveBeenCalled();
});

it("applies on Enter and discards on Escape only while the panel is focused", () => {
it("applies on Enter and discards on Escape while the panel is focused", () => {
const onApply = vi.fn();
const onDiscard = vi.fn();
render(<ButtonRemapPanel remap={remap} ready onStage={vi.fn()} onApply={onApply} onDiscard={onDiscard} />);
Expand Down
92 changes: 92 additions & 0 deletions frontend/src/components/panels/GnomeSelect.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { GnomeSelect } from "./GnomeSelect";

const options = [
{ value: "left", label: "Left", group: "Basic" },
{ value: "fire", label: "Fire", group: "Basic" },
{ value: "play_pause", label: "Play/Pause", group: "Multimedia" },
{ value: "mute", label: "Mute", group: "Multimedia", disabled: true },
];

afterEach(cleanup);

describe("GnomeSelect", () => {
it("opens with categories only and opens an adjacent submenu by pointer", () => {
render(<GnomeSelect aria-label="Button action" value="left" options={options} onChange={vi.fn()} />);

fireEvent.click(screen.getByRole("button", { name: "Button action" }));
const categoryMenu = screen.getByRole("menu", { name: "Button action categories" });
expect(categoryMenu).toBeInTheDocument();
expect(categoryMenu.parentElement).toBe(document.body);
expect(screen.getAllByRole("menuitem").map((item) => item.textContent)).toEqual(["Basic▸", "Multimedia▸"]);
expect(screen.queryByRole("menu", { name: "Basic actions" })).not.toBeInTheDocument();

fireEvent.click(screen.getByRole("menuitem", { name: "Multimedia" }));
expect(screen.getByRole("menu", { name: "Multimedia actions" })).toBeInTheDocument();
expect(screen.getAllByRole("menuitem").map((item) => item.textContent)).toEqual(["Basic▸", "Multimedia▸", "Play/Pause", "Mute"]);
});

it("anchors the portaled menu to the trigger and updates it when a scroll ancestor moves", () => {
render(<GnomeSelect aria-label="Button action" value="left" options={options} onChange={vi.fn()} />);
const trigger = screen.getByRole("button", { name: "Button action" });
let bounds = { left: 24, bottom: 140 };
Object.defineProperty(trigger, "getBoundingClientRect", {
configurable: true,
value: () => ({ ...bounds, top: bounds.bottom - 40, right: bounds.left + 160, width: 160, height: 40 }),
});

fireEvent.click(trigger);
const categoryMenu = screen.getByRole("menu", { name: "Button action categories" });
expect(categoryMenu).toHaveStyle({ top: "144px", left: "24px" });

bounds = { left: 48, bottom: 180 };
fireEvent.scroll(window);
expect(categoryMenu).toHaveStyle({ top: "184px", left: "48px" });
});

it("traverses categories and actions by keyboard, closes levels, and restores trigger focus", () => {
const onChange = vi.fn();
render(<GnomeSelect aria-label="Button action" value="left" options={options} onChange={onChange} />);
const trigger = screen.getByRole("button", { name: "Button action" });
trigger.focus();

fireEvent.keyDown(trigger, { key: "ArrowDown" });
fireEvent.keyDown(trigger, { key: "ArrowDown" });
fireEvent.keyDown(trigger, { key: "ArrowRight" });
expect(screen.getByRole("menu", { name: "Multimedia actions" })).toBeInTheDocument();

fireEvent.keyDown(trigger, { key: "ArrowDown" });
fireEvent.keyDown(trigger, { key: "Enter" });
expect(onChange).not.toHaveBeenCalled();

fireEvent.keyDown(trigger, { key: "ArrowUp" });
fireEvent.keyDown(trigger, { key: "Enter" });
expect(onChange).toHaveBeenCalledWith("play_pause");
expect(screen.queryByRole("menu", { name: "Button action categories" })).not.toBeInTheDocument();
expect(trigger).toHaveFocus();

fireEvent.keyDown(trigger, { key: "Enter" });
fireEvent.keyDown(trigger, { key: "ArrowRight" });
fireEvent.keyDown(trigger, { key: "ArrowLeft" });
expect(screen.queryByRole("menu", { name: "Basic actions" })).not.toBeInTheDocument();
fireEvent.keyDown(trigger, { key: "Escape" });
expect(screen.queryByRole("menu", { name: "Button action categories" })).not.toBeInTheDocument();
expect(trigger).toHaveFocus();
});

it("closes on outside click and does not select disabled actions", () => {
const onChange = vi.fn();
render(<><GnomeSelect aria-label="Button action" value="left" options={options} onChange={onChange} /><button>Outside</button></>);

fireEvent.click(screen.getByRole("button", { name: "Button action" }));
fireEvent.mouseEnter(screen.getByRole("menuitem", { name: "Multimedia" }));
fireEvent.click(screen.getByRole("menuitem", { name: "Mute" }));
expect(onChange).not.toHaveBeenCalled();
expect(screen.getByRole("menu", { name: "Multimedia actions" })).toBeInTheDocument();

fireEvent.mouseDown(screen.getByRole("button", { name: "Outside" }));
expect(screen.queryByRole("menu", { name: "Button action categories" })).not.toBeInTheDocument();
});
});
Loading