// @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 / Info / ListPlus, 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 } 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 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 watched-toggle (mark-watched when unwatched) and the info button", () => { render( , ); expect(screen.getByRole("button", { name: "plex.markWatched" })).toBeTruthy(); expect(screen.getByRole("button", { name: "plex.info.title" })).toBeTruthy(); }); it("names the watched-toggle as mark-unwatched when the movie is watched", () => { render( , ); expect(screen.getByRole("button", { name: "plex.markUnwatched" })).toBeTruthy(); }); it("reveals the synopsis + rating on the hover scrim when the card carries them", () => { render( , ); expect(screen.getByText("A quiet synopsis.")).toBeTruthy(); expect(screen.getByText("7.5")).toBeTruthy(); // rating.toFixed(1) }); it("omits the scrim when the card has no synopsis or rating", () => { render( , ); expect(screen.queryByText(/synopsis/i)).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(); }); });