feat(plex): every show card plays the episode it would put on
A show card's picture opened the show page everywhere but Home, whose shelves carried the
resolved `next`. Now every Plex picture plays: a show asks `GET /api/plex/show/{id}/next`
(the in-progress episode, else the first unwatched, else S1·E1 — the rule Home and the show
page's Resume follow) on the click, fresh each time. A started show shows Continue + Restart;
its title, an ⓘ and the menu's "Open show" row open its page; one with no episodes opens there.
The question is asked per show, never carried on every card: three probes a show would cost a
grid page up to 180 queries. The hover preview asks it once it opens and names the episode
("Next: S6·E16 — Title"). Related shows on a show page play the same way.
This commit is contained in:
@@ -5,7 +5,9 @@
|
||||
// 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";
|
||||
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")>();
|
||||
@@ -21,7 +23,15 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
|
||||
import { PlexPosterCard, SeasonCard, EpisodeCard } from "./plexCards";
|
||||
import { iconButtonClass } from "./ui/buttonStyles";
|
||||
import type { PlexCard, PlexSeasonDetail } from "../lib/api";
|
||||
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",
|
||||
@@ -169,14 +179,57 @@ describe("PlexPosterCard with a next episode", () => {
|
||||
expect(onInfo).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stays a plain show card without one: no Play, no toggles, no ⓘ", () => {
|
||||
// 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={vi.fn()} onToggleWatched={vi.fn()} onInfo={vi.fn()} />,
|
||||
<PlexPosterCard card={show} onOpen={onOpen} onPage={onPage} onToggleWatched={vi.fn()} />,
|
||||
);
|
||||
expect(playGlyph(container)).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "plex.openShow" })).toBeNull();
|
||||
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" }}
|
||||
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("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;
|
||||
|
||||
Reference in New Issue
Block a user