// @vitest-environment jsdom // // Contract of the Plex header search box (S2): 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(); 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("./PlexProvider", () => ({ 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[]) => ({ 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( , ); }; 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" })), ); }); // E11 S4: 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(); }); });