// @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(); 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 }) => ( {children} ), }); 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( ({})} />, ); 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(); 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( , ); 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( , ); 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(); 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(); 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( , ); 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( , ); 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( , ); 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( , ); 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(); 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( , ); 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( , ); 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( , ); expect(screen.getByRole("button", { name: "card.restart" })).toBeTruthy(); }); it("a not-started or watched card keeps the one Play", () => { render(); 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(); 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( , ); 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) => ( ), ], [ "SeasonCard", (onOpen: () => void, navIndex?: number) => ( ), ], [ "EpisodeCard", (onOpen: () => void, navIndex?: number) => ( ), ], ] 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("[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(); 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(); 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(); 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(); 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(); 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(); 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(); expect(titleOf().className).toContain("truncate"); unmount(); render(); 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( , ); 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(); 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( , ); 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( , ); 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"); }); });