// @vitest-environment jsdom // // E5 S7: the compact listbox-select + chips multi-value filter. Covers the selected-chip removal, the // popover open, the search filter, and toggling an option on. i18n is key-passthrough; the popover is // floating-ui based (ActionMenu's test recipe: 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"; 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(); return { ...actual, useTranslation: () => ({ t: (k: string) => k }) }; }); import MultiSelectChips from "./MultiSelectChips"; afterEach(cleanup); const OPTIONS = [ { value: "Drama", count: 120 }, { value: "Action", count: 90 }, { value: "Comedy", count: 60 }, ]; function wrap(ui: React.ReactElement) { return render({ui}); } describe("MultiSelectChips", () => { it("renders selected values as removable chips and removes on ✕", () => { const onChange = vi.fn(); wrap( , ); expect(screen.getByText("Drama")).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: "common.remove" })); expect(onChange).toHaveBeenCalledWith([]); // Drama removed }); it("keeps the option list unmounted until the trigger is clicked", () => { wrap(); expect(screen.queryByRole("menuitemcheckbox", { hidden: true })).toBeNull(); fireEvent.click(screen.getByRole("button", { name: "plex.filter.add" })); expect(screen.getAllByRole("menuitemcheckbox", { hidden: true }).length).toBe(3); }); it("filters the options by the search query", () => { wrap(); fireEvent.click(screen.getByRole("button", { name: "plex.filter.add" })); fireEvent.change(screen.getByPlaceholderText("common.search"), { target: { value: "com" } }); const items = screen.getAllByRole("menuitemcheckbox", { hidden: true }); expect(items.length).toBe(1); expect(items[0]?.textContent).toContain("Comedy"); }); it("toggles an option on with its value added to the selection", () => { const onChange = vi.fn(); wrap( , ); fireEvent.click(screen.getByRole("button", { name: "plex.filter.add" })); fireEvent.click(screen.getByText("Action")); // clicking the option's label toggles it on expect(onChange).toHaveBeenCalledWith(["Drama", "Action"]); }); 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(); fireEvent.click(screen.getByRole("button", { name: "plex.filter.add" })); expect(screen.getAllByRole("menuitemcheckbox", { hidden: true }).length).toBe(50); expect(screen.getByText("plex.filter.moreRefine")).toBeTruthy(); // 10 hidden behind search }); });