Files
siftlode/frontend/src/components/ui/Combobox.dom.test.tsx
T
peter 4aaaaa7860 chore(comments): drop epic, sprint and review-round ids from the code
The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").

Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.

Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).

One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
2026-10-05 00:19:39 +02:00

447 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
//
// The universal combobox (chips + typed search field over the AnchoredPopover `combobox` mode). Covers
// chip removal, open-on-focus, client filter, toggling, Backspace-removes-last, the big-vocabulary cap,
// single-select, and the combobox ARIA the old hand-rolled control lacked (role=combobox, aria-expanded,
// and a virtual aria-activedescendant that keeps DOM focus on the input). i18n is key-passthrough; the
// floating-ui recipe (ActionMenu's): stub the two observers autoUpdate wires, wrap in a FloatingTree,
// and query the hidden surface with `{ hidden: true }` since jsdom never positions it.
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react";
import { useState } from "react";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
});
import Combobox from "./Combobox";
afterEach(cleanup);
const OPTIONS = [
{ value: "Drama", count: 120 },
{ value: "Action", count: 90 },
{ value: "Comedy", count: 60 },
];
function wrap(ui: React.ReactElement) {
return render(<FloatingTree>{ui}</FloatingTree>);
}
describe("Combobox", () => {
it("renders selected values as removable chips and removes on ✕", () => {
const onChange = vi.fn();
wrap(<Combobox label="Genre" options={OPTIONS} selected={["Drama"]} onChange={onChange} />);
expect(screen.getByText("Drama")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "common.removeNamed" }));
expect(onChange).toHaveBeenCalledWith([]); // Drama removed
});
it("exposes combobox ARIA: role=combobox, aria-expanded toggling, aria-controls when open", () => {
wrap(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
const input = screen.getByRole("combobox");
expect(input.getAttribute("aria-expanded")).toBe("false");
expect(input.getAttribute("aria-controls")).toBeNull();
fireEvent.focus(input);
expect(input.getAttribute("aria-expanded")).toBe("true");
// aria-controls now points at the listbox surface.
const controls = input.getAttribute("aria-controls");
expect(controls).toBeTruthy();
expect(document.getElementById(controls!)?.getAttribute("role")).toBe("listbox");
});
it("focuses the input when the field chrome is pressed (merged ref, not a dead inputRef)", () => {
// Regression: the input carried only floating-ui's ref, so `inputRef` stayed null. The field's
// onMouseDown then preventDefault()-ed every press (its `e.target !== inputRef.current` guard was
// always true) and its compensating inputRef.current?.focus() was a no-op → the box couldn't be
// typed into with the mouse. A merged ref feeds the node to both, so pressing the chrome focuses it.
wrap(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
const input = screen.getByRole("combobox");
const field = input.parentElement as HTMLElement;
expect(document.activeElement).not.toBe(input);
fireEvent.mouseDown(field); // press the field padding, not the input itself
expect(document.activeElement).toBe(input);
});
it("keeps the option list unmounted until the field is focused", () => {
wrap(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
expect(screen.queryByRole("option", { hidden: true })).toBeNull();
fireEvent.focus(screen.getByRole("combobox"));
expect(screen.getAllByRole("option", { hidden: true }).length).toBe(3);
});
it("filters the options by the typed query", () => {
wrap(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
const field = screen.getByRole("combobox");
fireEvent.focus(field);
fireEvent.change(field, { target: { value: "com" } });
const items = screen.getAllByRole("option", { hidden: true });
expect(items.length).toBe(1);
expect(items[0]?.textContent).toContain("Comedy");
});
it("marks a selected option aria-selected and toggles another on", () => {
const onChange = vi.fn();
wrap(<Combobox label="Genre" options={OPTIONS} selected={["Drama"]} onChange={onChange} />);
fireEvent.focus(screen.getByRole("combobox"));
const drama = screen
.getAllByRole("option", { hidden: true })
.find((el) => el.textContent?.includes("Drama"));
expect(drama?.getAttribute("aria-selected")).toBe("true");
fireEvent.click(screen.getByText("Action")); // clicking the option's label toggles it on
expect(onChange).toHaveBeenCalledWith(["Drama", "Action"]);
});
it("moves an aria-activedescendant on ArrowDown while DOM focus stays on the input", () => {
wrap(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
const input = screen.getByRole("combobox");
input.focus();
fireEvent.focus(input);
fireEvent.keyDown(input, { key: "ArrowDown" });
// Virtual navigation: the input keeps DOM focus, the active option is referenced by id.
expect(document.activeElement).toBe(input);
expect(input.getAttribute("aria-activedescendant")).toBeTruthy();
});
it("removes the last chip on Backspace in an empty field", () => {
const onChange = vi.fn();
wrap(
<Combobox
label="Genre"
options={OPTIONS}
selected={["Drama", "Action"]}
onChange={onChange}
/>,
);
fireEvent.keyDown(screen.getByRole("combobox"), { key: "Backspace" });
expect(onChange).toHaveBeenCalledWith(["Drama"]); // last chip dropped
});
it("caps the rendered rows for a large vocabulary and shows a refine hint", () => {
// A studio-sized list (hundreds) must not render every row — cap at 50 + a "N more" footer.
const many = Array.from({ length: 60 }, (_, i) => ({ value: `Studio ${i}`, count: 60 - i }));
wrap(<Combobox label="Studio" options={many} selected={[]} onChange={vi.fn()} />);
fireEvent.focus(screen.getByRole("combobox"));
expect(screen.getAllByRole("option", { hidden: true }).length).toBe(50);
expect(screen.getByText("common.moreRefine")).toBeTruthy(); // 10 hidden behind search
});
it("Enter commits the pointer-highlighted option, not the arrow-active one", () => {
// The visible highlight follows the pointer (hoverIndex); Enter must commit THAT row, not the bare
// keyboard activeIndex — otherwise arrow-then-hover-then-Enter toggles a different option than shown.
const onChange = vi.fn();
wrap(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={onChange} />);
const input = screen.getByRole("combobox");
input.focus();
fireEvent.focus(input);
fireEvent.keyDown(input, { key: "ArrowDown" }); // activeIndex → 0 (Drama)
const action = screen
.getAllByRole("option", { hidden: true })
.find((el) => el.textContent?.includes("Action"))!;
fireEvent.mouseMove(action); // hoverIndex → Action (the visibly highlighted row)
fireEvent.keyDown(input, { key: "Enter" });
expect(onChange).toHaveBeenCalledWith(["Action"]); // the highlighted (hovered) row, not Drama
});
it("Enter commits a hover-highlighted option even with no prior arrow key", () => {
const onChange = vi.fn();
wrap(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={onChange} />);
const input = screen.getByRole("combobox");
input.focus();
fireEvent.focus(input);
const comedy = screen
.getAllByRole("option", { hidden: true })
.find((el) => el.textContent?.includes("Comedy"))!;
fireEvent.mouseMove(comedy); // highlight via hover, activeIndex still null
fireEvent.keyDown(input, { key: "Enter" });
expect(onChange).toHaveBeenCalledWith(["Comedy"]); // no longer a silent no-op
});
it("async mode: controlled query + onQueryChange/onOpenChange, no client filter, selectedLabels chip", () => {
const onQueryChange = vi.fn();
const onOpenChange = vi.fn();
const onChange = vi.fn();
const serverOpts = [{ value: "id1", label: "Harry Potter", count: 8 }];
wrap(
<Combobox
label="Collection"
multiple={false}
clientFilter={false}
options={serverOpts}
selected={["stored-id"]}
selectedLabels={{ "stored-id": "Stored Collection" }}
query="har"
onQueryChange={onQueryChange}
onOpenChange={onOpenChange}
onChange={onChange}
/>,
);
// The selected value's option isn't in the (server-narrowed) list, so its chip uses selectedLabels.
expect(screen.getByText("Stored Collection")).toBeTruthy();
const input = screen.getByRole("combobox");
fireEvent.focus(input);
expect(onOpenChange).toHaveBeenCalledWith(true);
fireEvent.change(input, { target: { value: "harr" } });
expect(onQueryChange).toHaveBeenCalledWith("harr"); // controlled query propagates out
// clientFilter=false: the single server option renders verbatim (not re-filtered against "har").
const opts = screen.getAllByRole("option", { hidden: true });
expect(opts.length).toBe(1);
expect(opts[0]?.textContent).toContain("Harry Potter");
fireEvent.click(screen.getByText("Harry Potter"));
expect(onChange).toHaveBeenCalledWith(["id1"]); // single-select replaces
});
it("names a chip by a label that arrives with later options, in that same render", () => {
// The label cache was a ref an effect filled AFTER the render that needed it, and nothing
// re-rendered afterwards — the chip kept showing the raw id until something unrelated did.
const view = (options: { value: string; label?: string }[]) => (
<FloatingTree>
<Combobox
label="Collection"
clientFilter={false}
options={options}
selected={["id1"]}
onChange={vi.fn()}
/>
</FloatingTree>
);
const { rerender } = render(view([]));
expect(screen.getByText("id1")).toBeTruthy();
rerender(view([{ value: "id1", label: "Harry Potter" }]));
expect(screen.queryByText("id1")).toBeNull();
expect(screen.getByText("Harry Potter")).toBeTruthy();
// …and it outlives the option: a narrower list later still names the chip.
rerender(view([]));
expect(screen.getByText("Harry Potter")).toBeTruthy();
});
it("single-select: picking replaces the value and closes the list", () => {
const onChange = vi.fn();
wrap(
<Combobox
label="Collection"
multiple={false}
options={OPTIONS}
selected={["Drama"]}
onChange={onChange}
/>,
);
fireEvent.focus(screen.getByRole("combobox"));
fireEvent.click(screen.getByText("Action"));
expect(onChange).toHaveBeenCalledWith(["Action"]); // replaced, not appended
expect(screen.getByRole("combobox").getAttribute("aria-expanded")).toBe("false"); // closed
});
// The typed query is discarded when the list closes. CHARACTERIZATION, not the regression guard:
// the old effect-based clear satisfies these assertions too, because RTL wraps fireEvent in act()
// and the passive effect has flushed by the time the value is read. What the move into the close
// funnel actually fixed is measured by the next test, which the old shape fails. This one is here
// because the behaviour is worth pinning: the list is for "find and pick", and a leftover filter
// would hide everything worth seeing the next time it opens.
it("discards the typed query when the list closes, so the next open shows everything", () => {
wrap(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
const input = screen.getByRole("combobox");
fireEvent.focus(input);
fireEvent.change(input, { target: { value: "com" } });
expect(input).toHaveProperty("value", "com");
expect(screen.queryByText("Drama")).toBeNull(); // filtered away
fireEvent.keyDown(input, { key: "Escape" });
expect(input.getAttribute("aria-expanded")).toBe("false");
expect(input).toHaveProperty("value", "");
fireEvent.focus(input);
expect(screen.getByText("Drama")).toBeTruthy(); // the whole list is back
});
it("does not report a query change until something actually changes it", () => {
// A clear driven by an effect on `open` fires at MOUNT too — `open` starts false — so a
// controlled caller was told its own initial query had been cleared before the user touched
// anything. Driving it from the close event instead means no event, no callback.
const onQueryChange = vi.fn();
const onOpenChange = vi.fn();
wrap(
<Combobox
label="Collection"
multiple={false}
options={OPTIONS}
selected={[]}
onChange={vi.fn()}
query="com"
onQueryChange={onQueryChange}
onOpenChange={onOpenChange}
clientFilter={false}
/>,
);
expect(onQueryChange).not.toHaveBeenCalled();
fireEvent.focus(screen.getByRole("combobox"));
expect(onQueryChange).not.toHaveBeenCalled();
fireEvent.click(screen.getByText("Action"));
expect(onOpenChange).toHaveBeenLastCalledWith(false);
expect(onQueryChange.mock.calls).toEqual([[""]]);
});
});
// PageUp/PageDown (owner request): the sidebar's channel filter and the Plex pickers run to
// hundreds of rows, and floating-ui's list navigation has only the arrows. jsdom has no layout, so a
// page is the fixed fallback of 10 rows; the measured page is pinned in lib/listPaging.
// A failed list said "No matches" (an error dressed as an empty result), and a
// picker whose pick was still being written took another pick.
// A chip's × pressed from the keyboard unmounted with the focus on it — to <body>,
// out of the picker. The focus goes to the next chip's × (else the previous one, else the field).
describe("Combobox · removing a chip from the keyboard", () => {
function Harness({ initial }: { initial: string[] }) {
const [sel, setSel] = useState(initial);
return <Combobox label="Genre" options={OPTIONS} selected={sel} onChange={setSel} />;
}
const x = (name: string) =>
screen
.getAllByRole("button", { name: "common.removeNamed" })
.find((b) => b.closest("span")?.textContent?.includes(name))!;
it("hands the focus to the neighbour chip, then to the field", () => {
wrap(<Harness initial={["Drama", "Action"]} />);
x("Drama").focus();
fireEvent.click(x("Drama"), { detail: 0 });
expect(document.activeElement).toBe(x("Action"));
fireEvent.click(x("Action"), { detail: 0 });
expect(document.activeElement).toBe(screen.getByRole("combobox"));
});
});
describe("Combobox · honest states", () => {
it("says the list failed to load, not that nothing matched", () => {
wrap(
<Combobox label="User" options={[]} selected={[]} onChange={vi.fn()} error="Load failed" />,
);
fireEvent.focus(screen.getByRole("combobox"));
expect(screen.getByText("Load failed")).toBeTruthy();
expect(screen.queryByText("common.noResults")).toBeNull();
});
// A failed list with no way back but closing its dialog was a dead end.
it("offers a Retry on the error, by click and by Enter in the field, keeping the focus", () => {
const onRetry = vi.fn();
wrap(
<Combobox
label="User"
options={[]}
selected={[]}
onChange={vi.fn()}
error="Load failed"
onRetry={onRetry}
/>,
);
const field = screen.getByRole("combobox");
field.focus();
fireEvent.focus(field);
// The listbox holds no button for assistive tech; the alert says how (Enter) instead.
expect(screen.getByRole("alert", { hidden: true }).textContent).toContain(
"common.retryWithEnter",
);
const retry = screen.getByText("common.retry");
expect(fireEvent.mouseDown(retry)).toBe(false); // the press does not take the focus
fireEvent.click(retry);
fireEvent.keyDown(field, { key: "Enter" });
expect(onRetry).toHaveBeenCalledTimes(2);
expect(document.activeElement).toBe(field);
});
// A listbox may own options only — the alert, "Loading…", "No matches" and the
// "n more" line sat inside it (axe: aria-required-children). They sit beside it now.
it("keeps the lines about the list outside the listbox, which the field still controls", () => {
const { rerender } = wrap(
<Combobox label="User" options={[]} selected={[]} onChange={vi.fn()} error="Load failed" />,
);
const field = screen.getByRole("combobox");
field.focus();
fireEvent.focus(field);
const listbox = () => screen.getByRole("listbox", { hidden: true });
expect(field.getAttribute("aria-controls")).toBe(listbox().id);
expect(listbox().getAttribute("aria-label")).toBe("User");
expect(listbox().contains(screen.getByRole("alert", { hidden: true }))).toBe(false);
rerender(
<FloatingTree>
<Combobox label="User" options={[]} selected={[]} onChange={vi.fn()} />
</FloatingTree>,
);
expect(listbox().textContent).toBe("");
rerender(
<FloatingTree>
<Combobox label="User" options={OPTIONS} selected={[]} onChange={vi.fn()} />
</FloatingTree>,
);
const kids = [...listbox().children].map((c) => c.getAttribute("role"));
expect(kids.length).toBeGreaterThan(0);
expect(kids.every((r) => r === "option")).toBe(true);
});
it("disabled: keeps the focus, opens nothing and picks nothing", () => {
const onChange = vi.fn();
wrap(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={onChange} disabled />);
const field = screen.getByRole("combobox");
field.focus();
fireEvent.focus(field);
expect(screen.queryByRole("option", { hidden: true })).toBeNull();
fireEvent.keyDown(field, { key: "ArrowDown" });
fireEvent.keyDown(field, { key: "Enter" });
expect(onChange).not.toHaveBeenCalled();
expect(field.getAttribute("aria-disabled")).toBe("true");
expect(document.activeElement).toBe(field); // not natively disabled: the focus stays put
});
});
describe("Combobox · paging", () => {
const rows = (disabled: number[] = []) =>
Array.from({ length: 25 }, (_, i) => ({ value: `Row ${i}`, disabled: disabled.includes(i) }));
const activeValue = (input: HTMLElement) =>
document.getElementById(input.getAttribute("aria-activedescendant") ?? "")?.textContent;
it("moves the active option a page at a time, stopping at both ends", () => {
wrap(<Combobox label="Channel" options={rows()} selected={[]} onChange={vi.fn()} />);
const input = screen.getByRole("combobox");
input.focus();
fireEvent.focus(input);
expect(fireEvent.keyDown(input, { key: "PageDown" })).toBe(false); // the page does not scroll
expect(activeValue(input)).toBe("Row 10");
expect(document.activeElement).toBe(input);
fireEvent.keyDown(input, { key: "PageDown" });
fireEvent.keyDown(input, { key: "PageDown" });
expect(activeValue(input)).toBe("Row 24"); // no wrap to the top
fireEvent.keyDown(input, { key: "PageUp" });
expect(activeValue(input)).toBe("Row 14");
});
it("never lands on a disabled option, and Enter commits where it landed", () => {
const onChange = vi.fn();
wrap(<Combobox label="Channel" options={rows([10])} selected={[]} onChange={onChange} />);
const input = screen.getByRole("combobox");
input.focus();
fireEvent.focus(input);
fireEvent.keyDown(input, { key: "PageDown" });
fireEvent.keyDown(input, { key: "Enter" });
expect(onChange).toHaveBeenCalledWith(["Row 11"]);
});
it("leaves the key alone while the list is closed", () => {
wrap(<Combobox label="Channel" options={rows()} selected={[]} onChange={vi.fn()} />);
expect(fireEvent.keyDown(screen.getByRole("combobox"), { key: "PageDown" })).toBe(true);
});
});