// @vitest-environment jsdom // // Who owns ⌫ while the Plex player is up. Three rounds got it wrong in turn: deferring to nothing // (the cheat sheet took ⌫ and the player stopped underneath it), deferring to any layer above (the // player's OWN menus push layers, so ⌫ stopped closing them), then naming just the sheet and the // palette (the app-wide ErrorDialog is neither, so ⌫ tore the player down under it). The rule that // holds: the player's own panels answer first; anything else layered above it takes the key. It had // no test (E10 review coverage debt). import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; import { render, cleanup, waitFor, act } 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"; import { pushLayer, removeLayer } from "../lib/layerStack"; 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: [], } 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; } const press = (key: string) => act(() => { window.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true })); }); describe("PlexPlayer · who owns Backspace", () => { it("goes back when nothing is layered over the player", async () => { const onClose = await mount(); press("Backspace"); expect(onClose).toHaveBeenCalledTimes(1); }); it("leaves the key to a layer above it that is not its own — an error dialog, say", async () => { const onClose = await mount(); const above = pushLayer(); press("Backspace"); expect(onClose).not.toHaveBeenCalled(); removeLayer(above); }); it("still closes its OWN panel first, even with a layer above it", async () => { const onClose = await mount(); press("d"); // the stats panel: one of the player's own const above = pushLayer(); press("Backspace"); // closes the panel, does not leave the player removeLayer(above); press("Backspace"); // nothing left of its own: now it goes back expect(onClose).toHaveBeenCalledTimes(1); }); });