// @vitest-environment jsdom // // Plex card a11y: locks the accessible names on the icon-only card controls (the E4 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, screen, cleanup, fireEvent, act } from "@testing-library/react"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; }); import { PlexPosterCard, SeasonCard, EpisodeCard } from "./plexCards"; import { iconButtonClass } from "./ui/buttonStyles"; import type { PlexCard, PlexSeasonDetail } from "../lib/api"; 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 (E28 C1): 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(); }); // E28 S3h: 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("1h 53m")).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")).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: "plex.markNextWatched" })); 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(); }); it("stays a plain show card without one: no Play, no toggles, no ⓘ", () => { const { container } = render( , ); expect(playGlyph(container)).toBeNull(); expect(screen.queryByRole("button", { name: "plex.openShow" })).toBeNull(); expect(screen.queryByRole("button", { name: "plex.markWatched" })).toBeNull(); }); }); // 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(); }); 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(); }); }); // E11 S4b: in a keyboard-walkable grid or rail the WHOLE card is the item (the ring goes round the // poster and its title, D2) 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]")!; expect(open.getAttribute("role")).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); expect(onOpen).not.toHaveBeenCalled(); window.getSelection()!.removeAllRanges(); fireEvent.click(title); expect(onOpen).toHaveBeenCalledTimes(1); }); }); 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 (E28 S3h: 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(); } }); });