Files
siftlode/frontend/src/components/plexCards.dom.test.tsx
T
peter 6df257d7df fix(plex): Restart only where an episode has a resume point; the Surprise draw is re-read, not redrawn
Show cards carry resumable (ShowState, from the same rollup) so a part-watched
show whose next episode is untouched offers Play, not a Continue and a Restart
that do the same thing; plexStarted is the one rule for cards and menus. The
Surprise rail's key sits under the discover root: whatever refreshes the rails
re-reads the draw (keep=) with its watch state now, kept past the 5-minute gc,
and only the reshuffle deals. Closing the player refreshes the Discover rails.
A show card's play acts on the last click only, and only where it was made.
2026-10-09 22:01:28 +02:00

523 lines
21 KiB
TypeScript

// @vitest-environment jsdom
//
// Plex card a11y: locks the accessible names on the icon-only card controls (an a11y sweep). These
// buttons are icon-only (WatchedIcon / ListPlus / the "…", no text), so their name comes solely from the
// aria-label — a dropped label silently reintroduces an unnamed button for screen-reader users. i18n is
// key-passthrough, so the asserted names are the i18n keys.
import { describe, it, expect, afterEach, vi } from "vitest";
import { render as rtlRender, screen, cleanup, fireEvent, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactElement } from "react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
// The key, and the episode a "next" line names after a colon (`{ name }`).
useTranslation: () => ({
i18n: { language: "en" },
t: (key: string, o?: { name?: string }) => (o?.name ? `${key}:${o.name}` : key),
}),
};
});
import { PlexPosterCard, SeasonCard, EpisodeCard } from "./plexCards";
import { iconButtonClass } from "./ui/buttonStyles";
import { api, type PlexCard, type PlexSeasonDetail } from "../lib/api";
// A show card asks the server which episode it would play once its preview is up: a query client.
const render = (ui: ReactElement) =>
rtlRender(ui, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>{children}</QueryClientProvider>
),
});
const movie = (status?: string): PlexCard => ({
id: "1",
type: "movie",
title: "M",
thumb: "",
status,
});
const episode = (status?: string): PlexCard => ({
id: "e1",
type: "episode",
title: "E1",
thumb: "",
status,
});
const season = (status = "new"): PlexSeasonDetail => ({
id: "s1",
season_number: 1,
title: "S1",
thumb: "",
episode_count: 3,
status,
episodes: [],
});
afterEach(cleanup);
describe("PlexPosterCard a11y", () => {
it("names the hover row: the watched-toggle, add to playlist and the menu's '…'", () => {
render(
<PlexPosterCard
card={movie()}
onOpen={vi.fn()}
onToggleWatched={vi.fn()}
onAdd={vi.fn()}
menuButtonProps={() => ({})}
/>,
);
expect(screen.getByRole("button", { name: "plex.markWatched" })).toBeTruthy();
expect(screen.getByRole("button", { name: "plex.playlistAdd.manage" })).toBeTruthy();
// Two: the hover row's and its touch twin beside the title (CSS shows one per device).
expect(screen.getAllByRole("button", { name: "card.menu.more" })).toHaveLength(2);
// Details moved into the menu: the card has no ⓘ of its own any more.
expect(screen.queryByRole("button", { name: "plex.info.title" })).toBeNull();
});
it("names the watched-toggle as mark-unwatched when the movie is watched", () => {
render(<PlexPosterCard card={movie("watched")} onOpen={vi.fn()} onToggleWatched={vi.fn()} />);
expect(screen.getByRole("button", { name: "plex.markUnwatched" })).toBeTruthy();
});
// The one media card — the year and the rating are the meta line under the title (pinned
// to the frame's bottom), the length a badge on the picture. The synopsis is NOT the picture's
// tooltip any more: it lives in the card's preview (below).
it("carries the year and rating in its meta line, the length on the picture", () => {
render(
<PlexPosterCard
card={{
...movie(),
year: 2023,
rating: 7.5,
duration_seconds: 6780,
summary: "A quiet synopsis.",
}}
onOpen={vi.fn()}
onToggleWatched={vi.fn()}
/>,
);
expect(screen.getByText("2023")).toBeTruthy();
expect(screen.getByText("7.5")).toBeTruthy(); // rating.toFixed(1)
expect(screen.getByText("1 hr 53 min")).toBeTruthy();
expect(screen.getByRole("button", { name: movie().title }).getAttribute("title")).toBeNull();
});
it("draws no meta line when the card has neither a year nor a rating", () => {
const { container } = render(
<PlexPosterCard card={{ ...movie(), year: null, rating: null }} onOpen={vi.fn()} />,
);
expect(container.querySelector(".mt-auto")).toBeNull();
});
});
// The owner's pick C (2026-09-27): the synopsis shows when the MOUSE rests on the card — not on a
// sweep across the grid, and not on the keyboard walk (a panel at every stop).
describe("PlexPosterCard preview", () => {
afterEach(() => vi.useRealTimers());
const card = { ...movie(), title: "A long title", year: 2020, summary: "A quiet synopsis." };
const root = () =>
screen.getByRole("button", { name: "A long title" }).closest(".cv-poster") as HTMLElement;
it("opens once the mouse has rested on the card, with the whole title and the synopsis", () => {
vi.useFakeTimers();
render(<PlexPosterCard card={card} onOpen={vi.fn()} />);
fireEvent.mouseEnter(root());
act(() => {
vi.advanceTimersByTime(600);
});
expect(screen.queryByText("A quiet synopsis.")).toBeNull(); // a sweep opens nothing
act(() => {
vi.advanceTimersByTime(400);
});
expect(screen.getByText("A quiet synopsis.")).toBeTruthy();
expect(screen.getAllByText("A long title")).toHaveLength(2); // the card's and the preview's
});
it("does not open on keyboard focus", () => {
vi.useFakeTimers();
render(<PlexPosterCard card={card} onOpen={vi.fn()} />);
fireEvent.focus(screen.getByRole("button", { name: "A long title" }));
act(() => {
vi.advanceTimersByTime(2000);
});
expect(screen.queryByText("A quiet synopsis.")).toBeNull();
});
});
// Home's shelves: a show that plays on is a card for its NEXT episode (the owner, 2026-09-27).
describe("PlexPosterCard with a next episode", () => {
const show: PlexCard = { id: "sh", type: "show", title: "Show", thumb: "", season_count: 6 };
const next: PlexCard = { ...episode(), season_number: 6, episode_number: 16 };
const playGlyph = (c: HTMLElement) => c.querySelector(".reveal-hover.inset-0 svg");
it("draws the Play, names the episode, and acts on it; the ⓘ opens the show", () => {
const onToggleWatched = vi.fn();
const onAdd = vi.fn();
const onInfo = vi.fn();
const { container } = render(
<PlexPosterCard
card={show}
next={next}
onOpen={vi.fn()}
onToggleWatched={onToggleWatched}
onAdd={onAdd}
onInfo={onInfo}
/>,
);
expect(playGlyph(container)).not.toBeNull();
expect(screen.getByText("plex.nextUp:S6·E16")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "plex.markNextWatched:S6·E16" }));
expect(onToggleWatched).toHaveBeenCalledWith(next);
// The episode's own add ("Add episode"), not the show's.
fireEvent.click(screen.getByRole("button", { name: "plex.playlist.addEpisode" }));
expect(onAdd).toHaveBeenCalledWith(next);
fireEvent.click(screen.getByRole("button", { name: "plex.openShow" }));
expect(onInfo).toHaveBeenCalled();
});
// Every show card plays (the owner, 2026-10-09): without its episode in hand the surface's onOpen
// asks which one. The toggles act on an episode, so they wait for one; the ⓘ is the show's page.
it("plays without one too, opens its page from the ⓘ, and offers no toggles", () => {
const onOpen = vi.fn();
const onPage = vi.fn();
const { container } = render(
<PlexPosterCard card={show} onOpen={onOpen} onPage={onPage} onToggleWatched={vi.fn()} />,
);
expect(playGlyph(container)).not.toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Show" }));
expect(onOpen).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: "plex.openShow" }));
expect(onPage).toHaveBeenCalledWith(show);
expect(screen.queryByRole("button", { name: "plex.markWatched" })).toBeNull();
});
it("continues a started show and starts its episode over, as a started movie does", () => {
const onOpen = vi.fn();
const onRestart = vi.fn();
render(
<PlexPosterCard
card={{ ...show, status: "in_progress", resumable: true }}
onOpen={onOpen}
onRestart={onRestart}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "card.restart" }));
expect(onRestart).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: "card.continue" }));
expect(onOpen).toHaveBeenCalledTimes(1);
});
it("plays a part-watched show with no episode to resume, with no Restart beside it", () => {
// Its next episode is untouched: Continue and Restart would both play it from its start.
render(
<PlexPosterCard
card={{ ...show, status: "in_progress", resumable: false }}
onOpen={vi.fn()}
onRestart={vi.fn()}
/>,
);
expect(screen.queryByRole("button", { name: "card.restart" })).toBeNull();
expect(screen.queryByRole("button", { name: "card.continue" })).toBeNull();
});
it("names the episode it would play once its preview is up, and not asked before", async () => {
vi.useFakeTimers();
const ask = vi
.spyOn(api, "plexShowNext")
.mockResolvedValue({ next: { ...next, title: "Pilot" } });
render(<PlexPosterCard card={show} onOpen={vi.fn()} />);
fireEvent.mouseEnter(screen.getByRole("button", { name: "Show" }).closest(".cv-poster")!);
act(() => {
vi.advanceTimersByTime(600);
});
expect(ask).not.toHaveBeenCalled(); // a sweep across a grid asks nothing
await act(async () => {
vi.advanceTimersByTime(400);
});
vi.useRealTimers();
expect(ask).toHaveBeenCalledWith("sh", "en");
expect(await screen.findByText("plex.nextUp:S6·E16 — Pilot")).toBeTruthy();
ask.mockRestore();
});
});
// C5: a started item's picture offers Continue and Restart (the video card's pair) instead of a Play;
// they sit beside the picture's own button, never inside it.
describe("a started Plex card", () => {
const started: PlexCard = {
...movie("in_progress"),
position_seconds: 600,
duration_seconds: 6000,
};
it("shows Continue + Restart, not the Play, and each does its own thing", () => {
const onOpen = vi.fn();
const onRestart = vi.fn();
const { container } = render(
<PlexPosterCard card={started} onOpen={onOpen} onRestart={onRestart} />,
);
const picture = screen.getByRole("button", { name: "M" });
expect(picture.querySelector(".reveal-hover.inset-0 svg")).toBeNull();
const cont = screen.getByRole("button", { name: "card.continue" });
const restart = screen.getByRole("button", { name: "card.restart" });
expect(picture.contains(cont) || picture.contains(restart)).toBe(false);
fireEvent.click(restart);
expect(onRestart).toHaveBeenCalledTimes(1);
expect(onOpen).not.toHaveBeenCalled();
fireEvent.click(cont);
expect(onOpen).toHaveBeenCalledTimes(1);
// Mouse only: on touch the tap continues and the "…" has the restart.
expect(cont.closest(".reveal-hover")).not.toBeNull();
expect(container.querySelector(".flex-col.reveal-hover")).not.toBeNull(); // a poster stacks
});
it("an episode's landscape picture keeps the pair side by side", () => {
const { container } = render(
<EpisodeCard
ep={{ ...episode("in_progress"), position_seconds: 60, duration_seconds: 1200 }}
onPlay={vi.fn()}
onRestart={vi.fn()}
/>,
);
expect(screen.getByRole("button", { name: "card.restart" })).toBeTruthy();
expect(container.querySelector(".flex-col.reveal-hover")).toBeNull();
});
// The card read "started" off the rounded progress %, the menu off the position —
// 20 s into a 3 h film is 0 % here and started there, so the card offered no Restart the menu had.
it("a resume point under 1 % still offers the Restart, as the card's menu does", () => {
render(
<PlexPosterCard
card={{ ...movie("in_progress"), position_seconds: 20, duration_seconds: 10800 }}
onOpen={vi.fn()}
onRestart={vi.fn()}
/>,
);
expect(screen.getByRole("button", { name: "card.restart" })).toBeTruthy();
});
it("a not-started or watched card keeps the one Play", () => {
render(<PlexPosterCard card={movie("watched")} onOpen={vi.fn()} onRestart={vi.fn()} />);
expect(screen.queryByRole("button", { name: "card.restart" })).toBeNull();
expect(
screen.getByRole("button", { name: "M" }).querySelector(".reveal-hover.inset-0 svg"),
).not.toBeNull();
});
});
describe("SeasonCard a11y", () => {
it("names the watched-toggle and the add-season button", () => {
render(<SeasonCard se={season()} onOpen={vi.fn()} onToggleWatched={vi.fn()} onAdd={vi.fn()} />);
expect(screen.getByRole("button", { name: "plex.series.markSeasonWatched" })).toBeTruthy();
expect(screen.getByRole("button", { name: "plex.playlist.addSeason" })).toBeTruthy();
});
});
describe("EpisodeCard a11y", () => {
it("names the watched-toggle and the add-episode button", () => {
render(
<EpisodeCard ep={episode()} onPlay={vi.fn()} onToggleWatched={vi.fn()} onAdd={vi.fn()} />,
);
expect(screen.getByRole("button", { name: "plex.markWatched" })).toBeTruthy();
expect(screen.getByRole("button", { name: "plex.playlist.addEpisode" })).toBeTruthy();
});
});
// In a keyboard-walkable grid or rail the WHOLE card is the item (the ring goes round the
// poster and its title) and the poster — the card's own role=button — is what Enter opens.
describe("Plex cards in a keyboard-walkable list", () => {
const cases = [
[
"PlexPosterCard",
(onOpen: () => void, navIndex?: number) => (
<PlexPosterCard
card={movie()}
onOpen={onOpen}
onToggleWatched={vi.fn()}
navIndex={navIndex}
/>
),
],
[
"SeasonCard",
(onOpen: () => void, navIndex?: number) => (
<SeasonCard se={season()} onOpen={onOpen} navIndex={navIndex} />
),
],
[
"EpisodeCard",
(onOpen: () => void, navIndex?: number) => (
<EpisodeCard ep={episode()} onPlay={onOpen} navIndex={navIndex} />
),
],
] as const;
it.each(cases)("%s: the root is the item and the poster is its open element", (_, card) => {
const onOpen = vi.fn();
const { container } = render(card(onOpen, 3));
const root = container.firstElementChild as HTMLElement;
expect(root.getAttribute("data-nav-index")).toBe("3");
const open = root.querySelector<HTMLElement>("[data-nav-open]")!;
// A real button (ui/Pressable), not a div wearing its role: Enter/Space and the touch target
// come from the primitive (the picture was once found hand-built).
expect(open.tagName).toBe("BUTTON");
expect(open.getAttribute("type")).toBe("button");
fireEvent.click(open);
expect(onOpen).toHaveBeenCalledTimes(1);
});
it.each(cases)("%s: outside a list it is no item", (_, card) => {
const { container } = render(card(vi.fn()));
expect(container.querySelector("[data-nav-index]")).toBeNull();
});
});
describe("a Plex card's title stays selectable", () => {
it("opens from its caption, but not on the click that ends selecting the title", () => {
const onOpen = vi.fn();
render(<PlexPosterCard card={movie()} onOpen={vi.fn()} onPage={onOpen} />);
const title = screen.getByText("M");
const range = document.createRange();
range.selectNodeContents(title);
window.getSelection()!.removeAllRanges();
window.getSelection()!.addRange(range);
fireEvent.click(title, { detail: 1 });
expect(onOpen).not.toHaveBeenCalled();
window.getSelection()!.removeAllRanges();
fireEvent.click(title, { detail: 1 });
expect(onOpen).toHaveBeenCalledTimes(1);
});
});
// The picture plays; the title, lit like a link, opens the page of what it names (owner).
describe("a Plex card's title is a link to its page", () => {
it("a movie's title opens its page and never plays it", () => {
const onOpen = vi.fn();
const onPage = vi.fn();
render(<PlexPosterCard card={movie()} onOpen={onOpen} onPage={onPage} />);
fireEvent.click(screen.getByText("M"), { detail: 1 });
expect(onPage).toHaveBeenCalledWith(expect.objectContaining({ id: "1", type: "movie" }));
expect(onOpen).not.toHaveBeenCalled();
});
it("an episode standing as its show opens the show", () => {
const onPage = vi.fn();
const ep: PlexCard = { ...episode(), show_id: "s9", show_title: "The Show" };
render(<PlexPosterCard card={ep} onOpen={vi.fn()} onPage={onPage} />);
fireEvent.click(screen.getByText("The Show"), { detail: 1 });
expect(onPage).toHaveBeenCalledWith(expect.objectContaining({ id: "s9", type: "show" }));
});
it("an episode card's title opens the episode's page, its picture plays", () => {
const onPlay = vi.fn();
const onPage = vi.fn();
render(<EpisodeCard ep={episode()} onPlay={onPlay} onPage={onPage} />);
fireEvent.click(screen.getByText(episode().title), { detail: 1 });
expect(onPage).toHaveBeenCalledTimes(1);
expect(onPlay).not.toHaveBeenCalled();
});
it("without a page the title is plain text: a click does nothing", () => {
const onOpen = vi.fn();
render(<PlexPosterCard card={movie()} onOpen={onOpen} />);
fireEvent.click(screen.getByText("M"), { detail: 1 });
expect(onOpen).not.toHaveBeenCalled();
expect(screen.getByText("M").className).not.toContain("cursor-pointer");
});
});
describe("a Plex card's caption and a drag", () => {
it("does not open on a press dragged past the slop", () => {
const onOpen = vi.fn();
render(<PlexPosterCard card={movie()} onOpen={vi.fn()} onPage={onOpen} />);
const title = screen.getByText("M");
fireEvent.mouseDown(title, { clientX: 10, clientY: 10 });
fireEvent.click(title, { detail: 1, clientX: 60, clientY: 10 });
expect(onOpen).not.toHaveBeenCalled();
fireEvent.mouseDown(title, { clientX: 10, clientY: 10 });
fireEvent.click(title, { detail: 1, clientX: 11, clientY: 10 });
expect(onOpen).toHaveBeenCalledTimes(1);
});
});
// On a shelf (Home, the Plex rails, a collection strip) the title is one line, so every card on the
// strip is the same height; a landscape shelf picture is short, so it takes the smaller Play, clear
// of the hover row under it (at the large size they touched on a 232px slot).
describe("PlexPosterCard on a shelf", () => {
const titleOf = () => screen.getByText("M");
const play = (c: HTMLElement) =>
c.querySelector("span[aria-hidden] > svg.lucide-play")!.parentElement!;
it("cuts the title to one line; a grid card keeps two", () => {
const { unmount } = render(<PlexPosterCard card={movie()} onOpen={vi.fn()} shelf />);
expect(titleOf().className).toContain("truncate");
unmount();
render(<PlexPosterCard card={movie()} onOpen={vi.fn()} />);
expect(titleOf().className).toContain("line-clamp-2");
});
it("only a landscape SHELF card takes the smaller Play", () => {
const large = iconButtonClass({ variant: "media", size: "lg" });
const cases: [boolean, boolean, string][] = [
[true, true, iconButtonClass({ variant: "media", size: "md" })],
[true, false, large], // a poster on a shelf is tall enough
[false, true, large], // a landscape card in a grid is wide enough
];
for (const [shelf, wide, want] of cases) {
const { container, unmount } = render(
<PlexPosterCard card={movie()} onOpen={vi.fn()} shelf={shelf} wide={wide} />,
);
expect(play(container).className, `shelf=${shelf} wide=${wide}`).toBe(want);
unmount();
}
});
});
// An episode Plex has no number for (a special) is named by its title alone, as the player's own
// header names it — the cards printed a question mark where the number was missing.
describe("an episode card with no number for the episode", () => {
const special: PlexCard = {
...episode(),
title: "The Special",
show_id: "sh",
show_title: "Show",
season_number: 0,
episode_number: null,
};
it("names a show's episode by its title alone", () => {
render(<PlexPosterCard card={special} onOpen={vi.fn()} />);
expect(screen.queryByText("plex.episodeLine")).toBeNull();
expect(screen.getByText("The Special")).toBeTruthy();
});
it("names the next episode by its title alone", () => {
const show: PlexCard = { id: "sh", type: "show", title: "Show", thumb: "", season_count: 6 };
render(
<PlexPosterCard card={show} next={special} onOpen={vi.fn()} onToggleWatched={vi.fn()} />,
);
expect(screen.getByText("plex.nextUp:The Special")).toBeTruthy();
expect(screen.getByRole("button", { name: "plex.markNextWatched:The Special" })).toBeTruthy();
});
});
// A card fetched the detail page's 400px poster for its ~144px picture. It offers the
// proxy's small cut too and lets the browser pick by its width.
describe("a Plex card's picture", () => {
it("offers the small cut beside the full one, lazily", () => {
const { container } = render(
<PlexPosterCard card={{ ...movie(), thumb: "/api/plex/image/1" }} onOpen={vi.fn()} />,
);
const img = container.querySelector("img")!;
expect(img.getAttribute("srcset")).toBe(
"/api/plex/image/1?variant=thumb_sm 240w, /api/plex/image/1 400w",
);
expect(img.getAttribute("sizes")).toBe("auto, 200px");
expect(img.getAttribute("loading")).toBe("lazy");
});
});