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.
523 lines
21 KiB
TypeScript
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");
|
|
});
|
|
});
|