// @vitest-environment jsdom // // What may be open over the Plex player at once, and what Back closes. The ⋯ menu, the tracks // menu, the tuning panel, the info overlay and the up-next card are ONE value: opening one replaces // the other, so two can never draw on top of each other. The stats readout is not one of them — it // stays up beside a panel — and ⌫ / Escape peel the panel first, then the stats, and only then // leave the player; the mouse/browser Back peels them one per press too, the one opened last first. import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; import { render, cleanup, waitFor, act, screen, fireEvent } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; vi.mock("react-i18next", async (importOriginal) => ({ ...(await importOriginal()), useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), })); import PlexPlayer from "./PlexPlayer"; import { api, type PlexItemDetail } from "../lib/api"; beforeAll(() => { class Stub { observe() {} unobserve() {} disconnect() {} takeRecords() { return []; } } globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver; for (const [name, value] of Object.entries({ play: () => Promise.resolve(), pause: () => {}, load: () => {}, })) Object.defineProperty(HTMLMediaElement.prototype, name, { configurable: true, value }); Object.defineProperty(HTMLMediaElement.prototype, "seekable", { configurable: true, get: () => ({ length: 0, end: () => 0 }), }); Object.defineProperty(HTMLMediaElement.prototype, "textTracks", { configurable: true, get: () => Object.assign([], { addEventListener() {}, removeEventListener() {} }), }); }); afterEach(() => { cleanup(); vi.restoreAllMocks(); }); const detail = { id: "1", title: "Title 1", type: "movie", duration_seconds: 3000, status: "new", position_seconds: 0, audio_streams: [], subtitle_streams: [], markers: [], cast: [], genres: [], } as unknown as PlexItemDetail; async function mount() { vi.spyOn(api, "plexItem").mockResolvedValue(detail); vi.spyOn(api, "plexSession").mockResolvedValue({ mode: "direct", url: "/media/1", start_s: 0, } as Awaited>); vi.spyOn(api, "plexProgress").mockResolvedValue(undefined as never); const onClose = vi.fn(); render( , ); await waitFor(() => expect(document.querySelector("video")?.getAttribute("src")).toBe("/media/1"), ); return onClose; } /** The browser's Back, waited out to its popstate. */ const browserBack = () => act(async () => { const landed = new Promise((resolve) => window.addEventListener("popstate", resolve, { once: true }), ); window.history.back(); await landed; }); const press = (key: string) => act(() => { window.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true })); }); // `hidden`: the faded-out control bands are aria-hidden while the video plays. const settings = () => screen.queryByRole("dialog", { name: "plex.player.settings", hidden: true }); const stats = () => screen.queryByRole("region", { name: "plex.player.stats.title", hidden: true }); const moreMenu = () => screen.queryByRole("menu", { hidden: true }); // The info overlay is the full-screen scrim the media info sits in. const info = () => document.querySelector(".place-items-center.z-menu"); async function openSettings() { fireEvent.click(screen.getByLabelText("plex.player.more")); const row = await waitFor(() => { const r = screen .queryAllByRole("menuitem", { hidden: true }) .find((b) => b.textContent === "plex.player.settings"); expect(r).toBeDefined(); return r!; }); fireEvent.click(row); await waitFor(() => expect(settings()).not.toBeNull()); } describe("PlexPlayer · one panel at a time", () => { it("the browser Back with the ⋯ menu open closes the menu, not the player", async () => { const onClose = await mount(); fireEvent.click(screen.getByLabelText("plex.player.more")); await waitFor(() => expect(moreMenu()).not.toBeNull()); // The menu claims a history entry, so the Back below is spent on it. First in the file: the // history module's entry count is per module, and an earlier test's entries would be in it. await waitFor(() => expect(window.history.state?._ov).toBeDefined()); await browserBack(); await waitFor(() => expect(moreMenu()).toBeNull()); expect(onClose).not.toHaveBeenCalled(); }); it("the browser Back peels each layer that is up, one press at a time", async () => { // One shared history entry for every layer: the first Back spent it on one layer, and the // second left the player with the stats readout still up. Second in the file (see above): the // first test leaves no entry behind. window.history.pushState({ page: "player" }, ""); // the entry the player was opened on const onClose = await mount(); press("d"); press("i"); await waitFor(() => expect(info()).not.toBeNull()); await browserBack(); await waitFor(() => expect(info()).toBeNull()); expect(stats()).not.toBeNull(); await browserBack(); await waitFor(() => expect(stats()).toBeNull()); expect(onClose).not.toHaveBeenCalled(); }); it("the info overlay replaces the tuning panel rather than drawing over it", async () => { await mount(); await openSettings(); press("i"); await waitFor(() => expect(info()).not.toBeNull()); expect(settings()).toBeNull(); }); it("the stats readout stays up beside a panel; Escape peels the panel, then the stats, then leaves", async () => { const onClose = await mount(); press("d"); press("i"); await waitFor(() => expect(info()).not.toBeNull()); expect(stats()).not.toBeNull(); press("Escape"); expect(info()).toBeNull(); expect(stats()).not.toBeNull(); expect(onClose).not.toHaveBeenCalled(); press("Escape"); expect(stats()).toBeNull(); expect(onClose).not.toHaveBeenCalled(); press("Escape"); expect(onClose).toHaveBeenCalledTimes(1); }); });