Files
siftlode/frontend/src/components/PlexSearchBox.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

233 lines
8.7 KiB
TypeScript

// @vitest-environment jsdom
//
// Contract of the Plex header search box: the field scope-pill and the instant typeahead.
// Covers the pieces the /code-review flagged: the pill's per-scope options (Episode only in
// show/both), the suggestion list rendering, keyboard pick → jumpTo hand-off, and the regression
// that closing the panel (Escape) must NOT wedge it shut while the input keeps focus — typing again
// re-opens it. i18n is key-passthrough; the provider, api, and debounce are mocked so the test drives
// state directly. ActionMenu is floating-ui based, so we stub the observers and wrap in a FloatingTree.
import { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from "vitest";
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
// Identity debounce so a typed value is reflected immediately.
vi.mock("../lib/useDebounced", () => ({ useDebounced: (v: unknown) => v }));
// Controllable provider — PlexSearchBox reads q/scope/field and calls setQ/setField/setJumpTo.
const plexState = { scope: "both", field: "all", q: "" } as {
scope: string;
field: string;
q: string;
};
// setQ writes back to plexState.q so a typed value flows through to `dq` on the next render (the real
// provider persists it) — lets the reopen test assert a fresh query fires on retype, not just that
// the panel re-appears from cache.
const setQ = vi.fn((v: string) => {
plexState.q = v;
});
const setField = vi.fn();
const setJumpTo = vi.fn();
vi.mock("./plexContext", () => ({
usePlex: () => plexState,
usePlexActions: () => ({ setQ, setField, setJumpTo }),
}));
// api.plexLibrary stub — returns whatever the current test queued.
const plexLibrary = vi.fn();
vi.mock("../lib/api", () => ({ api: { plexLibrary: (...a: unknown[]) => plexLibrary(...a) } }));
import PlexSearchBox from "./PlexSearchBox";
const card = (id: string, title: string, type: "movie" | "show" = "movie", year = 2000) => ({
id,
type,
title,
year,
thumb: `/api/plex/image/${id}`,
});
const result = (items: ReturnType<typeof card>[]) => ({
scope: "both",
total: items.length,
offset: 0,
limit: 10,
items,
episodes: [],
});
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
// jsdom has no layout, so scrollIntoView (used to keep the active option in view) is absent.
Element.prototype.scrollIntoView ??= () => {};
});
beforeEach(() => {
plexState.scope = "both";
plexState.field = "all";
plexState.q = "";
setQ.mockClear(); // clear calls but KEEP the write-back implementation
setField.mockReset();
setJumpTo.mockReset();
plexLibrary.mockReset();
plexLibrary.mockResolvedValue(result([]));
});
afterEach(cleanup);
const renderBox = () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<FloatingTree>
<PlexSearchBox />
</FloatingTree>
</QueryClientProvider>,
);
};
const openPill = () =>
fireEvent.click(screen.getByRole("button", { name: "plex.searchField.aria" }));
describe("PlexSearchBox — scope-pill", () => {
it("offers all five field scopes when shows are in scope", () => {
plexState.scope = "both";
renderBox();
openPill();
const labels = screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent);
expect(labels).toEqual([
"plex.searchField.all",
"plex.searchField.title",
"plex.searchField.people",
"plex.searchField.summary",
"plex.searchField.episode",
]);
});
it("hides the Episode scope in a movie-only scope", () => {
plexState.scope = "movie";
renderBox();
openPill();
const labels = screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent);
expect(labels).toEqual([
"plex.searchField.all",
"plex.searchField.title",
"plex.searchField.people",
"plex.searchField.summary",
]);
expect(labels).not.toContain("plex.searchField.episode");
});
});
describe("PlexSearchBox — typeahead", () => {
it("renders the top matches on focus and picks one via keyboard → jumpTo", async () => {
plexState.q = "matrix";
plexLibrary.mockResolvedValue(
result([card("m1", "The Matrix"), card("m2", "The Matrix Reloaded")]),
);
renderBox();
fireEvent.focus(screen.getByRole("combobox"));
// Both matches show up as options.
await waitFor(() =>
expect(screen.getAllByRole("option").map((o) => o.textContent)).toEqual([
expect.stringContaining("The Matrix"),
expect.stringContaining("The Matrix Reloaded"),
]),
);
// The request carried the active field scope + the limit.
expect(plexLibrary).toHaveBeenCalledWith(
expect.objectContaining({ scope: "both", q: "matrix", field: "all", limit: 10 }),
);
// ArrowDown highlights the first, Enter opens it via the jumpTo hand-off (not a Go search).
const input = screen.getByRole("combobox");
fireEvent.keyDown(input, { key: "ArrowDown" });
fireEvent.keyDown(input, { key: "Enter" });
expect(setJumpTo).toHaveBeenCalledWith({ id: "m1", type: "movie" });
});
it("reopens after Escape when the user types again (focus/dismiss are decoupled)", async () => {
plexState.q = "matrix";
plexLibrary.mockResolvedValue(result([card("m1", "The Matrix")]));
renderBox();
const input = screen.getByRole("combobox");
fireEvent.focus(input);
await screen.findByRole("listbox");
// Escape dismisses the panel (input stays focused — Escape does not blur).
fireEvent.keyDown(input, { key: "Escape" });
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
// Typing again must re-open it without a blur+refocus round-trip AND fire a fresh suggest query
// for the new term (not just re-show the cached list).
plexLibrary.mockClear();
fireEvent.change(input, { target: { value: "matrixx" } });
await screen.findByRole("listbox");
await waitFor(() =>
expect(plexLibrary).toHaveBeenCalledWith(expect.objectContaining({ q: "matrixx" })),
);
});
// SearchBar's Escape hands the keyboard back to the page (blur). With the panel open, the
// first Escape belongs to the panel: it must close it and leave the caret in the field; only the
// next Escape leaves.
it("spends the first Escape on the panel and only the second on leaving the field", async () => {
plexState.q = "matrix";
plexLibrary.mockResolvedValue(result([card("m1", "The Matrix")]));
renderBox();
const input = screen.getByRole("combobox");
input.focus();
await screen.findByRole("listbox");
fireEvent.keyDown(input, { key: "Escape" });
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
expect(document.activeElement).toBe(input);
fireEvent.keyDown(input, { key: "Escape" });
expect(document.activeElement).not.toBe(input);
});
it("marks the input as a combobox and toggles aria-expanded with the panel", async () => {
plexState.q = "matrix";
plexLibrary.mockResolvedValue(result([card("m1", "The Matrix")]));
renderBox();
const input = screen.getByRole("combobox");
// Collapsed until focused.
expect(input.getAttribute("aria-expanded")).toBe("false");
expect(input.getAttribute("aria-controls")).toBeNull();
fireEvent.focus(input);
const list = await screen.findByRole("listbox");
// Expanded, and the input points at the listbox it controls.
expect(input.getAttribute("aria-expanded")).toBe("true");
expect(input.getAttribute("aria-controls")).toBe(list.id);
// Escape collapses it again and drops the association.
fireEvent.keyDown(input, { key: "Escape" });
await waitFor(() => expect(input.getAttribute("aria-expanded")).toBe("false"));
expect(input.getAttribute("aria-controls")).toBeNull();
});
// A listbox may own options only — "Loading…" sat inside it.
it("shows its loading line beside the listbox, not in it", async () => {
plexState.q = "matrix";
plexLibrary.mockReturnValue(new Promise(() => {}));
renderBox();
fireEvent.focus(screen.getByRole("combobox"));
const loading = await screen.findByText("plex.loading");
expect(screen.getByRole("listbox").contains(loading)).toBe(false);
});
});