Files
siftlode/frontend/src/components/PlexPlayer.panels.dom.test.tsx
T
peter 2aa3ac6e85 fix(player): the browser Back peels each layer over the Plex video, one press at a time
All the layers shared one history entry, so the first Back spent it on one layer and the second
left the player with the stats readout (or the countdown) still up. Each layer now holds its own
entry; Back closes the one opened last.
2026-10-07 23:16:38 +02:00

184 lines
6.4 KiB
TypeScript

// @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<typeof import("react-i18next")>()),
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<ReturnType<typeof api.plexSession>>);
vi.spyOn(api, "plexProgress").mockResolvedValue(undefined as never);
const onClose = vi.fn();
render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<PlexPlayer itemId="1" onClose={onClose} />
</QueryClientProvider>,
);
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);
});
});