// @vitest-environment jsdom // // The Plex Discover landing. Covers the rail surface served from the mocked /discover + // /surprise calls — that each non-empty rail renders, the genre tile fires onGenre, the recently-added // "see all" fires onSeeAllRecent, the ↻ reshuffle refetches the surprise rail alone, and an all-empty // payload shows the empty message. i18n is key-passthrough; the api is mocked so queries resolve // synchronously. The rails scroll inside a Carousel (useHScroll → ResizeObserver), stubbed for jsdom. import { describe, it, expect, afterEach, beforeAll, vi } from "vitest"; import { act, render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; beforeAll(() => { class Stub { observe() {} unobserve() {} disconnect() {} takeRecords() { return []; } } globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; }); vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); // Interpolate {{title}} so the because-you-watched heading is asserted with its anchor name. return { ...actual, useTranslation: () => ({ i18n: { language: "en" }, t: (key: string, opts?: Record) => opts?.title ? `${key}:${opts.title}` : key, }), }; }); const plexDiscover = vi.fn(); const plexSurprise = vi.fn(); const plexSetState = vi.fn((..._a: unknown[]) => Promise.resolve()); vi.mock("../lib/api", () => ({ api: { plexDiscover: (...a: unknown[]) => plexDiscover(...a), plexSurprise: (...a: unknown[]) => plexSurprise(...a), plexSetState: (...a: unknown[]) => plexSetState(...a), }, // PlexDiscover now reads the active filters via usePlex (PlexProvider); its context default needs // EMPTY_PLEX_FILTERS at import time. No provider is rendered, so the default (empty) filters apply. EMPTY_PLEX_FILTERS: { genres: [], contentRatings: [], directors: [], actors: [], studios: [], keywords: [], collections: [], }, })); import PlexDiscover from "./PlexDiscover"; import { qk } from "../lib/queryKeys"; import type { PlexCard } from "../lib/api"; afterEach(() => { cleanup(); vi.clearAllMocks(); }); const card = (id: string, title: string, type: "movie" | "show" = "movie") => ({ id, type, title, year: 2000, duration_seconds: 3600, thumb: `/api/plex/image/${id}`, playable: "direct", status: "new", position_seconds: 0, }); const fullPayload = { scope: "both", continue_watching: [card("cw1", "Resume Me")], recently_watched: [card("rw1", "Watched Me")], recently_added: [card("ra1", "Just Added")], genres: [{ value: "Drama", count: 12, art: [] }], because_you_watched: { anchor: { id: "a1", title: "Anchor" }, items: [card("bw1", "Rec One")] }, }; function wrap(ui: React.ReactElement) { const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return render({ui}); } const noop = () => {}; describe("PlexDiscover", () => { it("renders each non-empty rail, the genre tiles, and the surprise sample", async () => { plexDiscover.mockResolvedValue(fullPayload); plexSurprise.mockResolvedValue({ scope: "both", items: [card("sp1", "Random Pick")] }); wrap( {}} onInfoCard={noop} onGenre={noop} onSeeAllRecent={noop} />, ); await screen.findByText("plex.discover.continueWatching"); expect(screen.getByText("plex.discover.recentlyWatched")).toBeTruthy(); expect(screen.getByText("plex.discover.recentlyAdded")).toBeTruthy(); expect(screen.getByText("plex.discover.surprise")).toBeTruthy(); expect(screen.getByText("plex.discover.byGenre")).toBeTruthy(); // The because-you-watched heading interpolates the anchor title. expect(screen.getByText("plex.discover.becauseWatched:Anchor")).toBeTruthy(); // A card from each rail is present (title rendered in the poster label). expect(screen.getAllByText("Resume Me").length).toBeGreaterThan(0); expect(screen.getAllByText("Watched Me").length).toBeGreaterThan(0); expect(screen.getAllByText("Just Added").length).toBeGreaterThan(0); expect(screen.getAllByText("Random Pick").length).toBeGreaterThan(0); expect(screen.getAllByText("Rec One").length).toBeGreaterThan(0); // Now called with the active filters too (empty default here, no provider mounted). expect(plexDiscover).toHaveBeenCalledWith( "both", "en", expect.objectContaining({ genres: [] }), ); }); it("registers the discover + surprise queries keyed by the active lang", async () => { // The real cache-separation guard (the queryKeys factory test only pins that a lang ARG survives the // spread): inspect the actual keys the component registered. A regression dropping lang from either // qk call would collide HU/EN in the cache even while the queryFn still fetches the right language. plexDiscover.mockResolvedValue(fullPayload); plexSurprise.mockResolvedValue({ scope: "both", items: [] }); const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); render( {}} onInfoCard={noop} onGenre={noop} onSeeAllRecent={noop} /> , ); await screen.findByText("plex.discover.continueWatching"); const keys = qc .getQueryCache() .getAll() .map((q) => q.queryKey); expect(keys).toContainEqual([ "plex-discover", "both", "en", expect.objectContaining({ genres: [] }), ]); expect(keys).toContainEqual([ "plex-discover", "surprise", "both", "en", expect.objectContaining({ genres: [] }), ]); }); it("fires onGenre with the tile's value", async () => { plexDiscover.mockResolvedValue(fullPayload); plexSurprise.mockResolvedValue({ scope: "both", items: [] }); const onGenre = vi.fn(); wrap( {}} onInfoCard={noop} onGenre={onGenre} onSeeAllRecent={noop} />, ); fireEvent.click(await screen.findByText("Drama")); expect(onGenre).toHaveBeenCalledWith("Drama"); }); it("fires onSeeAllRecent from the recently-added rail's see-all", async () => { plexDiscover.mockResolvedValue(fullPayload); plexSurprise.mockResolvedValue({ scope: "both", items: [] }); const onSeeAllRecent = vi.fn(); wrap( {}} onInfoCard={noop} onGenre={noop} onSeeAllRecent={onSeeAllRecent} />, ); fireEvent.click(await screen.findByRole("button", { name: "common.seeAll" })); expect(onSeeAllRecent).toHaveBeenCalledOnce(); }); it("reshuffles the surprise rail alone (refetch) without refetching the surface", async () => { plexDiscover.mockResolvedValue(fullPayload); plexSurprise.mockResolvedValue({ scope: "both", items: [card("sp1", "Random Pick")] }); wrap( {}} onInfoCard={noop} onGenre={noop} onSeeAllRecent={noop} />, ); await screen.findByText("Random Pick"); expect(plexSurprise).toHaveBeenCalledTimes(1); fireEvent.click(screen.getByRole("button", { name: "plex.discover.reshuffle" })); await waitFor(() => expect(plexSurprise).toHaveBeenCalledTimes(2)); expect(plexDiscover).toHaveBeenCalledTimes(1); // the whole surface was NOT refetched // The first draw asks for anything; the redraw hands over the one on screen to deal it last. expect(plexSurprise.mock.calls[0]?.[4]).toEqual({ exclude: [] }); expect(plexSurprise.mock.calls[1]?.[4]).toEqual({ exclude: ["sp1"] }); }); // Back from a card's player or page remounts the surface: the draw it left must still be there, with // its cards' watch state as it is now. A refetch reads the same titles again; only ↻ deals. it("keeps its draw across a remount, a refresh and a long absence; only ↻ redraws", async () => { plexDiscover.mockResolvedValue(fullPayload); let status: PlexCard["status"] = "new"; plexSurprise.mockImplementation( (_s: string, _l: unknown, _f: unknown, _lang: string, o: { keep?: string[] }) => Promise.resolve({ scope: "both", items: o.keep?.length ? [{ ...card("sp1", "Random Pick"), status }] : [card(plexSurprise.mock.calls.length > 1 ? "sp2" : "sp1", "Pick")], }), ); const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const ui = ( {}} onInfoCard={noop} onGenre={noop} onSeeAllRecent={noop} /> ); const first = render(ui); await screen.findByText("Pick"); first.unmount(); render(ui); await screen.findByText("Random Pick"); // Watched from somewhere else: whatever refreshes the rails refreshes the draw's cards too. status = "watched"; await act(() => qc.invalidateQueries({ queryKey: qk.plexDiscover() })); const [entry] = qc.getQueryCache().findAll({ queryKey: qk.plexSurprise() }); expect(entry?.state.data).toMatchObject({ items: [{ id: "sp1", status: "watched" }] }); // No draw is ever dealt behind the viewer's back: every call after the first reads it again. for (const call of plexSurprise.mock.calls.slice(1)) expect(call[4]).toEqual({ keep: ["sp1"] }); // Unobserved past the default five minutes (a film watched from the rail) it is still kept. expect(entry?.options.gcTime).toBe(Infinity); fireEvent.click(screen.getByRole("button", { name: "plex.discover.reshuffle" })); await screen.findByText("Pick"); expect(plexSurprise.mock.calls[plexSurprise.mock.calls.length - 1]?.[4]).toEqual({ exclude: ["sp1"], }); }); // A watched toggle's resync refreshes every rail, the Surprise draw included. Landing while a ↻ // deal was in flight, it cancelled that fetch and re-ran it as a re-read: the old titles came back // and the ↻ did nothing. And the ↻ spins for a deal only, never for a re-read of the same titles. it("a refresh landing mid-deal deals again; only a deal spins the ↻", async () => { plexDiscover.mockResolvedValue(fullPayload); const answers: ((v: unknown) => void)[] = []; const rereads: ((v: unknown) => void)[] = []; plexSurprise.mockImplementation( (_s: string, _l: unknown, _f: unknown, _lang: string, o: { keep?: string[] }) => plexSurprise.mock.calls.length === 1 ? Promise.resolve({ scope: "both", items: [card("sp1", "Old Pick")] }) : new Promise((r) => (o.keep ? rereads : answers).push(r)), ); const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); render( {}} onInfoCard={noop} onGenre={noop} onSeeAllRecent={noop} /> , ); await screen.findByText("Old Pick"); const button = () => screen.getByRole("button", { name: "plex.discover.reshuffle" }); act(() => void qc.invalidateQueries({ queryKey: qk.plexDiscover() })); await waitFor(() => expect(rereads).toHaveLength(1)); expect(button().getAttribute("aria-busy")).toBeNull(); await act(async () => rereads[0]?.({ scope: "both", items: [card("sp1", "Old Pick")] })); fireEvent.click(button()); expect(button().getAttribute("aria-busy")).toBe("true"); act(() => void qc.invalidateQueries({ queryKey: qk.plexDiscover() })); await waitFor(() => expect(answers).toHaveLength(2)); const last = plexSurprise.mock.calls[plexSurprise.mock.calls.length - 1]; expect(last?.[4]).toEqual({ exclude: ["sp1"] }); // The cancelled deal's late answer neither lands nor stops the spinner of the one replacing it. await act(async () => answers[0]?.({ scope: "both", items: [card("sp9", "Stale Pick")] })); expect(button().getAttribute("aria-busy")).toBe("true"); await act(async () => answers[1]?.({ scope: "both", items: [card("sp2", "New Pick")] })); await screen.findByText("New Pick"); expect(button().getAttribute("aria-busy")).toBeNull(); }); it("shows the empty message when every rail is empty", async () => { plexDiscover.mockResolvedValue({ scope: "both", continue_watching: [], recently_watched: [], recently_added: [], genres: [], because_you_watched: null, }); plexSurprise.mockResolvedValue({ scope: "both", items: [] }); wrap( {}} onInfoCard={noop} onGenre={noop} onSeeAllRecent={noop} />, ); await screen.findByText("plex.discover.empty"); }); it("puts a Surprise card back when its watched toggle fails", async () => { // The resync re-reads the Surprise draw too, but a refused toggle is put back at once rather than // left flipped until that read lands — the sibling of the collection page's fix. plexDiscover.mockResolvedValue({ ...fullPayload, continue_watching: [], recently_watched: [], recently_added: [], because_you_watched: null, }); plexSurprise.mockResolvedValue({ scope: "both", items: [card("sp1", "Random Pick")] }); plexSetState.mockRejectedValueOnce(new Error("refused")); wrap( {}} onInfoCard={noop} onGenre={noop} onSeeAllRecent={noop} />, ); fireEvent.click(await screen.findByLabelText("plex.markWatched")); await waitFor(() => expect(plexSetState).toHaveBeenCalledWith("sp1", "watched")); await waitFor(() => expect(screen.getByLabelText("plex.markWatched")).toBeTruthy()); expect(screen.queryByLabelText("plex.markUnwatched")).toBeNull(); }); }); // Every rail and the genre grid are lists the keyboard walks (lib/listNav), so `j` goes on // from one to the next in page order (the owner's call). describe("PlexDiscover · keyboard", () => { it("registers every rail and the genre grid, and j walks on from rail to rail", async () => { plexDiscover.mockResolvedValue(fullPayload); plexSurprise.mockResolvedValue({ scope: "both", items: [card("sp1", "Random Pick")] }); wrap( {}} onInfoCard={noop} onGenre={noop} onSeeAllRecent={noop} />, ); await screen.findAllByText("Random Pick"); // Five rails (continue, recently watched, recently added, because-you-watched, surprise) + genres. // Registered by a passive effect, which can land after the cards paint. await waitFor(() => expect(document.querySelectorAll("[data-nav-list]")).toHaveLength(6)); const lists = [...document.querySelectorAll("[data-nav-list]")]; // …and each holds its cards as items: the genre tiles too, not only the posters. expect(lists.every((list) => list.querySelector("[data-nav-index]"))).toBe(true); const step = () => { act(() => { (document.activeElement ?? document.body).dispatchEvent( new KeyboardEvent("keydown", { key: "j", bubbles: true, cancelable: true }), ); }); return (document.activeElement as HTMLElement).closest("[data-nav-index]")?.textContent; }; const first = step(); const second = step(); expect(first).toBeTruthy(); expect(second).toBeTruthy(); expect(second).not.toBe(first); // one card per rail: the second press crossed into the next rail }); });