diff --git a/frontend/src/components/PlexViewToggle.dom.test.tsx b/frontend/src/components/PlexViewToggle.dom.test.tsx new file mode 100644 index 00000000..41370aea --- /dev/null +++ b/frontend/src/components/PlexViewToggle.dom.test.tsx @@ -0,0 +1,65 @@ +// @vitest-environment jsdom +// +// Contract of the header Discover ⟷ Browse toggle. The floating header wrapper is pointer-events-none, +// so the toggle MUST re-enable pointer events on its root or it is click-dead (the bug this test guards +// against). Also: it only renders on the two title-browse views, marks the active one, and drives +// PlexBrowse's sub-view through the bridge. i18n is key-passthrough; the bridge is mocked. +import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; +}); + +const controls = { kind: "discover" as string, open: vi.fn() }; +vi.mock("./PlexViewBridge", () => ({ + usePlexViewControls: () => controls, +})); + +import PlexViewToggle from "./PlexViewToggle"; + +beforeEach(() => { + controls.kind = "discover"; + controls.open.mockReset(); +}); +afterEach(cleanup); + +describe("PlexViewToggle", () => { + it("re-enables pointer events on its root (the header wrapper is pointer-events-none)", () => { + const { container } = render(); + // Without this class the whole toggle is click-dead inside the pointer-events-none header. + expect(container.querySelector(".pointer-events-auto")).not.toBeNull(); + }); + + it("renders the two options with the current view pressed", () => { + render(); + const btns = [ + ...screen.getByRole("group", { name: "plex.view.label" }).querySelectorAll("button"), + ]; + expect(btns.map((b) => b.textContent)).toEqual(["plex.discover.tab", "plex.view.browse"]); + const pressed = btns + .filter((b) => b.getAttribute("aria-pressed") === "true") + .map((b) => b.textContent); + expect(pressed).toEqual(["plex.discover.tab"]); + }); + + it("opens the grid view when Browse is picked", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "plex.view.browse" })); + expect(controls.open).toHaveBeenCalledWith({ kind: "grid" }); + }); + + it("opens the discover view when Discover is picked from the grid", () => { + controls.kind = "grid"; + render(); + fireEvent.click(screen.getByRole("button", { name: "plex.discover.tab" })); + expect(controls.open).toHaveBeenCalledWith({ kind: "discover" }); + }); + + it("renders nothing on a deeper view (no Discover/Browse choice there)", () => { + controls.kind = "playlists"; + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/frontend/src/components/PlexViewToggle.tsx b/frontend/src/components/PlexViewToggle.tsx index b9b5ef33..61a60582 100644 --- a/frontend/src/components/PlexViewToggle.tsx +++ b/frontend/src/components/PlexViewToggle.tsx @@ -11,9 +11,13 @@ export default function PlexViewToggle() { // Only the two title-browse views host the Discover/Browse choice; deeper views (show/info/person/…) // and the playlists/collections browse surfaces hide it. if (!ctl || (ctl.kind !== "discover" && ctl.kind !== "grid")) return null; + // The floating header wrapper is pointer-events-none (so clicks fall through its empty area to the + // content below); every interactive child must re-enable pointer events or it is click-dead — the + // sibling PlexScopeToggle does the same. return (