The ↻ intent was spent as its fetch set out, so a watched toggle's resync cancelling that fetch re-ran it as a re-read of the old draw. It now lasts until the latest deal answers. The button spun on every re-read of the same titles; it now follows the deal. Comments that said the Surprise draw is never refetched are brought in line.
413 lines
16 KiB
TypeScript
413 lines
16 KiB
TypeScript
// @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<typeof import("react-i18next")>();
|
|
// 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<string, unknown>) =>
|
|
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(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
|
|
}
|
|
|
|
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(
|
|
<PlexDiscover
|
|
scope="both"
|
|
onOpenCard={noop}
|
|
onRestartCard={() => {}}
|
|
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(
|
|
<QueryClientProvider client={qc}>
|
|
<PlexDiscover
|
|
scope="both"
|
|
onOpenCard={noop}
|
|
onRestartCard={() => {}}
|
|
onInfoCard={noop}
|
|
onGenre={noop}
|
|
onSeeAllRecent={noop}
|
|
/>
|
|
</QueryClientProvider>,
|
|
);
|
|
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(
|
|
<PlexDiscover
|
|
scope="both"
|
|
onOpenCard={noop}
|
|
onRestartCard={() => {}}
|
|
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(
|
|
<PlexDiscover
|
|
scope="both"
|
|
onOpenCard={noop}
|
|
onRestartCard={() => {}}
|
|
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(
|
|
<PlexDiscover
|
|
scope="both"
|
|
onOpenCard={noop}
|
|
onRestartCard={() => {}}
|
|
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 = (
|
|
<QueryClientProvider client={qc}>
|
|
<PlexDiscover
|
|
scope="both"
|
|
onOpenCard={noop}
|
|
onRestartCard={() => {}}
|
|
onInfoCard={noop}
|
|
onGenre={noop}
|
|
onSeeAllRecent={noop}
|
|
/>
|
|
</QueryClientProvider>
|
|
);
|
|
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(
|
|
<QueryClientProvider client={qc}>
|
|
<PlexDiscover
|
|
scope="both"
|
|
onOpenCard={noop}
|
|
onRestartCard={() => {}}
|
|
onInfoCard={noop}
|
|
onGenre={noop}
|
|
onSeeAllRecent={noop}
|
|
/>
|
|
</QueryClientProvider>,
|
|
);
|
|
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(
|
|
<PlexDiscover
|
|
scope="both"
|
|
onOpenCard={noop}
|
|
onRestartCard={() => {}}
|
|
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(
|
|
<PlexDiscover
|
|
scope="both"
|
|
onOpenCard={noop}
|
|
onRestartCard={() => {}}
|
|
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(
|
|
<PlexDiscover
|
|
scope="both"
|
|
onOpenCard={noop}
|
|
onRestartCard={() => {}}
|
|
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
|
|
});
|
|
});
|