Files
siftlode/frontend/src/components/PlexDiscover.dom.test.tsx
T
peter 2768ab5bdd fix(gate): the plan-code guard catches decision codes, and the purge takes them
A plan's decision ids (`D1`, `D4`, `D6-A`) cited in 11 comments and test titles named a document
no reader of this repo has, and the guard had no pattern for them. It has now (mirrored in
audit_metrics), with cases for each form the citations took and for the look-alikes it must leave
alone (a JPEG marker, "3D"); the 11 lines say their why without the id. Case test seen red without
the pattern, the scan red on a planted `D9:`.
2026-10-05 03:16:13 +02:00

313 lines
11 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";
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-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
});
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 refetches the rails but not the Surprise draw (that would reshuffle it), so a
// refused toggle stayed flipped there — 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
});
});