- The saved views, the view switcher, the year filter and the table's single-choice filter mark the chosen row with MenuCheck, as DESIGN asks, not a trailing tick, a dot or a filled row; a contract test holds every checkable menu row to it. keepSelectedPaint had no caller left and is gone. - A combobox's panel carries its listbox role on an inner PopoverList (listInside), so the failed load, Loading, No matches and the 'n more' line sit beside the options, not inside the listbox. The Plex search box's Loading line likewise. - The Plex sidebar's playlist picker offers Create only once the lists have loaded: over a failed load it pushed the error and its Retry out at the first keystroke.
233 lines
8.7 KiB
TypeScript
233 lines
8.7 KiB
TypeScript
// @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<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" })),
|
|
);
|
|
});
|
|
|
|
// 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();
|
|
});
|
|
|
|
// Round 2 (F4 sibling): 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);
|
|
});
|
|
});
|