Files
siftlode/frontend/src/components/Home.dom.test.tsx
T
peter 632e39d458 feat(plex): every card rail in the reader's language; an episode in a poster rail stands as its show (E30 S1b)
The grid served Hungarian titles, but every other card surface (Discover rails, the
show page's episodes, search, collections, the person page, playlists, Home's resume
rail, hero and comfort shelf) served English. The card serializers now take a
required keyword-only lang (a forgotten call site fails instead of going English), and
each of those reads takes ?lang=hu, with the language in its query key. A Hungarian
search finds an episode by its Hungarian title too.

An episode on a poster rail showed its 16:9 still cropped into a poster, titled only
with the episode name; it now shows the show's poster and name, with S·E · title under
it (option A). Plex's generic season names (Season 1, Specials) are rebuilt from the
number in the reader's language; a season with a name of its own keeps it.

Home's comfort-shelf move wrote its optimistic order to the key without the language,
which nothing read any more; it writes the query's own key.
2026-09-29 02:12:32 +02:00

896 lines
38 KiB
TypeScript

// @vitest-environment jsdom
//
// E24 S2: the Home surface. Covers the four behaviours the browser round actually caught, each of
// which is invisible to tsc and to a render-only check:
// • the hero's kicker comes from the SERVER's tier, never from the card's shape;
// • the hero is not printed twice (once as the hero, once as the first resume tile);
// • a budget change asks for the new budget and keeps the old answer on screen meanwhile;
// • the reroll sends the already-shown card as an exclusion — without it the resume tier hands the
// identical card straight back and the button looks dead.
// i18n is key-passthrough; the api is mocked so queries resolve synchronously. The rails live inside
// a Carousel (useHScroll → ResizeObserver), stubbed for jsdom.
import { setLanguage } from "../i18n";
import { StrictMode } from "react";
import { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from "vitest";
import { act, render, screen, fireEvent, cleanup, waitFor, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
opts?.time ? `${key}:${opts.time}` : key,
i18n: { language: "en" },
}),
};
});
const discover = vi.fn();
const discoverPick = vi.fn();
vi.mock("../lib/api", () => ({
api: {
discover: (...a: unknown[]) => discover(...a),
discoverPick: (...a: unknown[]) => discoverPick(...a),
setState: () => Promise.resolve(),
watchLaterAdd: () => Promise.resolve(),
watchLaterRemove: () => Promise.resolve(),
downloadIndex: () => Promise.resolve({}), // the cards' download buttons read it
},
DISCOVER_BUDGETS: ["short", "medium", "long", "any"],
HERO_TIERS: ["resume", "comfort", "surprise"],
discoverCardKey: (c: { source: string; id: string }) => `${c.source}:${c.id}`,
}));
// The page hands Plex playback off to the Plex module. The provider itself is not under test, but
// WHAT is handed over is: the jump target decides both what plays and where Back goes.
const setPage = vi.fn();
const setJumpTo = vi.fn();
vi.mock("./navigationContext", () => ({
useNavigationActions: () => ({ setPage: (p: string) => setPage(p) }),
}));
vi.mock("./plexContext", () => ({
usePlexActions: () => ({ setJumpTo: (t: unknown) => setJumpTo(t) }),
}));
import Home from "./Home";
import { requestSurprise, takeSurprise } from "../lib/homeIntent";
function ytCard(id: string, title: string, extra: Record<string, unknown> = {}) {
return {
source: "youtube",
kind: "video",
id,
title,
subtitle: "A channel",
thumb: null,
aspect: "wide",
duration_seconds: 600,
position_seconds: 120,
remaining_seconds: 480,
status: "new",
plex: null,
video: {
id,
title,
channel_id: "UC1",
channel_title: "A channel",
channel_thumbnail: null,
channel_url: "https://youtube.com/channel/UC1",
published_at: "2026-01-01T00:00:00Z",
thumbnail_url: null,
duration_seconds: 600,
view_count: 10,
is_short: false,
live_status: "none",
status: "new",
position_seconds: 120,
saved: false,
watch_url: `https://www.youtube.com/watch?v=${id}`,
},
...extra,
};
}
/** A Plex SHOW row of the resume rail, carrying the episode that would actually start — what the
* backend now attaches (`rails.continue_watching`). */
const showCard = () => ({
source: "plex",
kind: "show",
id: "show-1",
title: "A Show",
subtitle: null,
thumb: null,
aspect: "poster",
duration_seconds: null,
position_seconds: 0,
remaining_seconds: null,
status: "in_progress",
video: null,
plex: { id: "show-1", type: "show", title: "A Show", thumb: "" },
next: {
source: "plex",
kind: "episode",
id: "ep-7",
title: "Ep 7",
subtitle: "A Show",
thumb: null,
aspect: "wide",
duration_seconds: 1500,
position_seconds: 0,
remaining_seconds: 1500,
status: "new",
video: null,
plex: { id: "ep-7", type: "episode", title: "Ep 7", thumb: "" },
},
});
const payload = (heroId: string, tier = "resume") => ({
budget: "any",
hero: { tier, card: ytCard(heroId, `Hero ${heroId}`) },
continue_watching: [ytCard(heroId, `Hero ${heroId}`), ytCard("v2", "Second thing")],
comfort: [],
});
function mount() {
return mountWithClient().result;
}
/** Same mount, handing back the QueryClient — the tests that need to drive an invalidation (i.e. a
* background refetch, the only way to reach the has-data-and-errored state) cannot reach it
* otherwise. */
function mountWithClient() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const result = render(
<QueryClientProvider client={qc}>
<Home />
</QueryClientProvider>,
);
return { qc, result };
}
beforeEach(() => {
// Home keeps its question on the history entry (Back restores it): each test starts a fresh one.
window.history.replaceState(null, "");
discover.mockReset();
discoverPick.mockReset();
setPage.mockReset();
setJumpTo.mockReset();
discover.mockResolvedValue(payload("v1"));
// Module-level state: a token left behind by one test would reroll the next one's first render.
takeSurprise();
});
afterEach(cleanup);
describe("Home", () => {
it("labels the hero from the server's tier, not from the card", async () => {
// The same card shape is served under a `surprise` tier: a client that inferred "where you left
// off" from the resume position would get this wrong, which is the dishonest state we forbid.
discover.mockResolvedValue(payload("v1", "surprise"));
mount();
expect(await screen.findByText("home.hero.surprise")).toBeTruthy();
expect(screen.queryByText(/home\.hero\.resume/)).toBeNull();
});
it("interpolates the remaining time into the resume kicker", async () => {
mount();
// 480s left → 8:00, passed through the key-passthrough t() as `<key>:<time>`.
expect(await screen.findByText("home.hero.resumeLeft:8:00")).toBeTruthy();
});
it("renders an error, never the empty state, when the payload cannot be loaded", async () => {
// The one thing this surface must not do: report a backend outage as "nothing to suggest yet"
// over a library full of things to suggest.
discover.mockRejectedValue(new Error("boom"));
mount();
expect(await screen.findByText("home.error")).toBeTruthy();
expect(screen.queryByText("home.hero.emptyAny")).toBeNull();
});
it("keeps the page on screen when a BACKGROUND refetch fails", async () => {
// react-query flips `status` to error on a failed refetch while keeping the cached payload, and
// every mutation here invalidates `qk.discover()` — so an unguarded `isError` blanked a fully
// populated landing page over one blip.
const { qc } = mountWithClient();
await screen.findByText("Hero v1");
discover.mockRejectedValue(new Error("blip"));
await act(async () => {
await qc.invalidateQueries({ queryKey: qk.discover() });
});
// The refetch must actually have been attempted, or this asserts nothing at all.
await waitFor(() => expect(discover).toHaveBeenCalledTimes(2));
expect(screen.queryByText("home.error")).toBeNull();
expect(screen.getByText("Hero v1")).toBeTruthy();
});
it("does not print the hero again as the first resume tile", async () => {
mount();
await screen.findByText("home.hero.resumeLeft:8:00");
// "Hero v1" is the hero's own heading; the rail below must not repeat it.
expect(screen.getAllByText("Hero v1")).toHaveLength(1);
expect(screen.getByText("Second thing")).toBeTruthy();
});
it("does not print a Plex series twice when the hero is the episode it resolves to", async () => {
// The commonest Plex resume case, and the one a single-key match misses: the rail row is the
// SHOW while the hero is the episode that show resolves to, so their `source:id` keys differ.
// Renamed on `plex` too, not only on the unified card: the rail tile is a `PlexPosterCard`, which
// takes its heading from the NATIVE payload — renaming just the outer title made this vacuous.
const s = showCard();
const show = { ...s, title: "The Series Tile", plex: { ...s.plex, title: "The Series Tile" } };
discover.mockResolvedValue({
budget: "any",
hero: { tier: "resume", card: show.next },
continue_watching: [show, ytCard("v2", "Second thing")],
comfort: [],
});
mount();
await screen.findByText("Second thing");
expect(screen.queryByText("The Series Tile")).toBeNull();
});
it("hides the resume rail rather than contradicting the hero that emptied it", async () => {
// One in-progress item: it becomes the hero, the dedupe filter takes it out of the rail, and the
// rail then announced "nothing half-finished right now" under a hero captioned "where you left
// off". The shelf's own empty state is untouched — it still has something to teach.
discover.mockResolvedValue({
budget: "any",
hero: { tier: "resume", card: ytCard("v1", "Hero v1") },
continue_watching: [ytCard("v1", "Hero v1")],
comfort: [],
});
mount();
await screen.findByText("Hero v1");
expect(screen.queryByText("home.continue.empty")).toBeNull();
expect(screen.getByText("home.comfort.empty")).toBeTruthy();
});
it("holds the hero it last found when a reroll fails", async () => {
// A failed pick has no data of its own (every press is a new key, and `keepPreviousData` is
// pending-only), so the hero used to rewind to the card the page LOADED with — several presses
// back — while `core.req`'s own dialog explained the failure.
// No resume rail: "Hero v1" then appears exactly where the hero is, so its reappearance is
// unambiguous evidence of a rewind rather than a rail tile that stopped being deduplicated.
discover.mockResolvedValue({
budget: "any",
hero: { tier: "resume", card: ytCard("v1", "Hero v1") },
continue_watching: [],
comfort: [],
});
discoverPick.mockResolvedValueOnce({
budget: "any",
hero: { tier: "surprise", card: ytCard("v9", "Rerolled") },
});
mount();
await screen.findByText("Hero v1");
fireEvent.click(screen.getByText("home.hero.reroll"));
await screen.findByText("Rerolled");
// Rejected on OUR schedule: asserting straight after the call goes out catches the query still
// pending, where `keepPreviousData` is legitimately holding the old hero — which is not the
// state under test and made this pass with or without the fix.
let failPick!: (e: Error) => void;
discoverPick.mockReturnValueOnce(
new Promise((_resolve, reject) => {
failPick = reject;
}),
);
fireEvent.click(screen.getByText("home.hero.reroll"));
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
await act(async () => {
failPick(new Error("boom"));
await new Promise((r) => setTimeout(r, 0));
});
expect(screen.getByText("Rerolled")).toBeTruthy();
expect(screen.queryByText("Hero v1")).toBeNull();
});
it("never answers a new budget with the previous budget's pick", async () => {
// Both halves of the hold outlive the question they answered: the chip row resets seed/seen/press,
// but it cannot reset an answer that already RESOLVED, and `keepPreviousData` chains straight
// through the disabled seed-0 query the chip click leaves behind. So the first reroll under a new
// budget offered the old budget's card — while it loaded, and permanently once it failed, since
// `retry: false` makes a failed pick terminal. Under a chip that promises ten minutes, that makes
// the page's one control meaningless (round 2).
discover.mockResolvedValue({
budget: "any",
hero: { tier: "resume", card: ytCard("v1", "Hero v1") },
continue_watching: [],
comfort: [],
});
discoverPick.mockResolvedValueOnce({
budget: "any",
hero: { tier: "surprise", card: ytCard("v9", "Long film") },
});
mount();
await screen.findByText("Hero v1");
fireEvent.click(screen.getByText("home.hero.reroll"));
await screen.findByText("Long film");
// A different question — whose own answer is already loaded.
discover.mockResolvedValue({
budget: "short",
hero: { tier: "resume", card: ytCard("v2", "Short thing") },
continue_watching: [],
comfort: [],
});
fireEvent.click(screen.getByText("home.budget.short"));
await screen.findByText("Short thing");
// Rejected on OUR schedule, as the sibling test above does, so the assertion cannot land while
// the query is still pending — that is a different state.
let failPick!: (e: Error) => void;
discoverPick.mockReturnValueOnce(
new Promise((_resolve, reject) => {
failPick = reject;
}),
);
fireEvent.click(screen.getByText("home.hero.reroll"));
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
// In flight: the placeholder must not reach across the budget change.
expect(screen.queryByText("Long film")).toBeNull();
await act(async () => {
failPick(new Error("boom"));
await new Promise((r) => setTimeout(r, 0));
});
// Failed: this budget's own hero, never the film picked for "any".
expect(screen.getByText("Short thing")).toBeTruthy();
expect(screen.queryByText("Long film")).toBeNull();
});
it("a mutation's invalidation reaches the rerolled hero too", async () => {
// The reroll has its own query key so pressing the button does not refetch the rails — but under
// the SAME root, or the prefix every mutation invalidates misses it and a rerolled hero outlives
// the page: play it, finish it, and it was still the answer, with a zeroed progress bar.
discoverPick.mockResolvedValue({
budget: "any",
hero: { tier: "surprise", card: ytCard("v9", "Rerolled") },
});
const { qc } = mountWithClient();
await screen.findByText("Hero v1");
fireEvent.click(screen.getByText("home.hero.reroll"));
await screen.findByText("Rerolled");
expect(discoverPick).toHaveBeenCalledTimes(1);
await act(async () => {
await qc.invalidateQueries({ queryKey: qk.discover() });
});
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
});
it("an unrelated mutation does not put the reroll button into a spinner", async () => {
// The other edge of the test above. Because the pick key sits under the `["discover"]` prefix,
// every mark-watched, bookmark, pin and shelf move on this page refetches it in the BACKGROUND —
// so binding the button's `loading` to raw `isFetching` made it spin and, since a `loading`
// `ui/Button` swallows its clicks, ignore presses after actions the user never associated with a
// reroll (round 2). A press is outstanding only while THIS key has no answer of its own.
const rerollBtn = () =>
screen.getByText("home.hero.reroll").closest("button") as HTMLButtonElement;
let resolvePick!: (v: unknown) => void;
discoverPick.mockReturnValueOnce(
new Promise((resolve) => {
resolvePick = resolve;
}),
);
const { qc } = mountWithClient();
await screen.findByText("Hero v1");
fireEvent.click(screen.getByText("home.hero.reroll"));
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(1));
// The positive control, and it is the half most easily broken while fixing the other: this is the
// FIRST press, so there is no previous pick for the placeholder to hold, and a predicate resting
// on `isPlaceholderData` alone would drop the spinner from exactly the press that needs it.
expect(rerollBtn().getAttribute("aria-busy")).toBe("true");
await act(async () => {
resolvePick({ budget: "any", hero: { tier: "surprise", card: ytCard("v9", "Rerolled") } });
await new Promise((r) => setTimeout(r, 0));
});
await screen.findByText("Rerolled");
// Never settles, so the assertion lands squarely inside the refetch window rather than after it —
// and therefore the invalidation must NOT be awaited: its promise resolves only once every
// refetch it started has, which this one never will.
discoverPick.mockReturnValue(new Promise(() => {}));
act(() => {
void qc.invalidateQueries({ queryKey: qk.discover() });
});
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
expect(rerollBtn().getAttribute("aria-busy")).toBeNull();
});
it("a press that FAILED does not leave the button hostage to the next mutation", async () => {
// Round 3's correction to the case above. A key whose request failed never gets data of its own
// (`retry: false` makes it terminal), so a predicate reading `data === undefined` stayed true
// forever and every later background refetch re-disabled the button — the same defect, one
// branch over. `isFetched` remembers that the key settled, an error counting as settled.
const rerollBtn = () =>
screen.getByText("home.hero.reroll").closest("button") as HTMLButtonElement;
let failPick!: (e: Error) => void;
discoverPick.mockReturnValueOnce(
new Promise((_resolve, reject) => {
failPick = reject;
}),
);
const { qc } = mountWithClient();
await screen.findByText("Hero v1");
fireEvent.click(screen.getByText("home.hero.reroll"));
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(1));
// `waitFor`, not a fixed tick: react-query hands the rejection to the observer through
// `notifyManager`'s own `setTimeout(0)`, registered AFTER any timer this test schedules, so
// "await one macrotask then assert" races it. Round 4 measured that race as an intermittent red.
failPick(new Error("boom"));
await waitFor(() => expect(rerollBtn().getAttribute("aria-busy")).toBeNull()); // the failure releases the button
// Now an unrelated action refetches that errored key in the background.
discoverPick.mockReturnValue(new Promise(() => {}));
act(() => {
void qc.invalidateQueries({ queryKey: qk.discover() });
});
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
expect(rerollBtn().getAttribute("aria-busy")).toBeNull();
});
it("keeps the honest empty answer when a press fails after an empty one", async () => {
// The tagged slot is authoritative for its own budget, including when what it holds is "no hero
// at all". Under a `??` chain that read as "nothing remembered" and fell through to the loaded
// payload, so a failed press resurrected the card the page had already excluded as seen and just
// said it had nothing beyond (round 3).
discover.mockResolvedValue({
budget: "short",
hero: { tier: "resume", card: ytCard("v1", "Hero v1") },
continue_watching: [],
comfort: [],
});
mount();
await screen.findByText("Hero v1");
fireEvent.click(screen.getByText("home.budget.short")); // the empty-state branch needs a band
await waitFor(() => expect(discover).toHaveBeenLastCalledWith("short", 0, "en"));
// A press that RESOLVES with nothing left: the honest empty state.
discoverPick.mockResolvedValueOnce({ budget: "short", hero: null });
fireEvent.click(screen.getByText("home.hero.reroll"));
await screen.findByText("home.hero.emptyBudget");
expect(screen.queryByText("Hero v1")).toBeNull();
// …and a press from there that fails must not undo it.
let failPick!: (e: Error) => void;
discoverPick.mockReturnValueOnce(
new Promise((_resolve, reject) => {
failPick = reject;
}),
);
const rerollBtn = () =>
screen.getByText("home.hero.reroll").closest("button") as HTMLButtonElement;
fireEvent.click(screen.getByText("home.hero.reroll"));
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
expect(rerollBtn().getAttribute("aria-busy")).toBe("true"); // the press is outstanding
failPick(new Error("boom"));
// Wait for a signal that the REJECTION has landed, not for a fixed tick: react-query notifies
// the observer through its own `setTimeout(0)`, and the empty state is already on screen while
// the press is in flight — so asserting it straight away would pass with or without the fix.
// The button releasing is that signal (`isFetched` flips once the key settles, error included).
await waitFor(() => expect(rerollBtn().getAttribute("aria-busy")).toBeNull());
expect(screen.getByText("home.hero.emptyBudget")).toBeTruthy();
expect(screen.queryByText("Hero v1")).toBeNull();
});
it("refreshes the payload the bookmark is read from", async () => {
mount();
await screen.findByText("Second thing");
expect(discover).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getAllByTitle("card.saveForLater")[0]!);
// `VideoCard` derives the filled/empty star straight from `video.saved` in this payload and
// keeps no optimistic state of its own, so without re-asking, the icon never changed and the
// next click re-sent the same call — the control looked dead.
await waitFor(() => expect(discover).toHaveBeenCalledTimes(2));
});
it("asks for the chosen budget and keeps the previous answer on screen meanwhile", async () => {
mount();
await screen.findByText("Hero v1");
// A never-resolving refetch: the point is what the page shows WHILE it waits.
discover.mockReturnValue(new Promise(() => {}));
fireEvent.click(screen.getByText("home.budget.short"));
await waitFor(() => expect(discover).toHaveBeenLastCalledWith("short", 0, "en"));
// Still the old answer — not a spinner that would take the chips away with it.
expect(screen.getByText("Hero v1")).toBeTruthy();
});
it("rerolls with the shown card excluded, and holds the hero while it loads", async () => {
discoverPick.mockResolvedValue({
budget: "any",
hero: { tier: "surprise", card: ytCard("v9", "Rerolled") },
});
mount();
await screen.findByText("Hero v1");
fireEvent.click(screen.getByText("home.hero.reroll"));
await waitFor(() =>
expect(discoverPick).toHaveBeenCalledWith("any", expect.any(Number), ["youtube:v1"], 1, "en"),
);
expect(await screen.findByText("Rerolled")).toBeTruthy();
});
it("never shows the empty state while a reroll is in flight", async () => {
// The regression the browser round caught: a new query key has no data for a beat, so the hero
// fell to null and the page rendered "nothing to suggest" over a perfectly good library.
discoverPick.mockReturnValue(new Promise(() => {}));
mount();
await screen.findByText("Hero v1");
fireEvent.click(screen.getByText("home.hero.reroll"));
await waitFor(() => expect(discoverPick).toHaveBeenCalled());
expect(screen.queryByText("home.hero.emptyAny")).toBeNull();
expect(screen.getByText("Hero v1")).toBeTruthy();
});
// E24 S4. The palette's "Meglepetés" is pressed on ANOTHER page, so Home's lazy chunk is not
// mounted yet — the request has to WAIT as a token rather than fire into an empty room. And it
// must be spent: a token that survived would make a later visit reroll on its own, which reads as
// the page overruling the answer you came back for.
it("acts on a pending surprise request once, and never replays it", async () => {
discoverPick.mockResolvedValue({
budget: "any",
hero: { tier: "surprise", card: ytCard("v9", "Rerolled") },
});
requestSurprise();
const first = mount();
expect(await screen.findByText("Rerolled")).toBeTruthy();
expect(discoverPick).toHaveBeenCalledTimes(1);
first.unmount();
discoverPick.mockClear();
// A later visit is a new history entry (setPage pushes a fresh one); the same entry would bring
// the rerolled question back on purpose (see the Back test below).
window.history.pushState(null, "");
mount();
await screen.findByText("Hero v1");
expect(discoverPick).not.toHaveBeenCalled();
});
// Prod UAT 2026-09-19: a card opened from Home leaves the page, and Back came back to "Doesn't
// matter" and a different hero. The question is kept on the entry, so Back asks it again.
it("Back to the same entry brings back the budget and the rerolled hero", async () => {
discoverPick.mockResolvedValue({
budget: "short",
hero: { tier: "surprise", card: ytCard("v9", "Rerolled") },
});
const first = mount();
await screen.findByText("Hero v1");
fireEvent.click(screen.getByText("home.budget.short"));
fireEvent.click(screen.getByText("home.hero.reroll"));
expect(await screen.findByText("Rerolled")).toBeTruthy();
const asked = discoverPick.mock.lastCall;
first.unmount();
discover.mockClear();
discoverPick.mockClear();
mount();
expect(await screen.findByText("Rerolled")).toBeTruthy();
expect(discover).toHaveBeenLastCalledWith("short", 0, "en");
// The very same question — seed, the cards already seen and the press count all came back, not
// just the budget: the server answers each of them differently.
await waitFor(() => expect(discoverPick.mock.lastCall).toEqual(asked));
});
// Measured in the browser (2026-09-07), before the fix: the palette's "Meglepetés" arrived at Home
// as `start=2` — two picks deep before the page had painted once — because StrictMode invokes a
// mount effect twice with the same render snapshot, and a reroll is not idempotent. The token is
// taken with a compare-and-clear against the LIVE store, which is what makes the second pass moot.
it("does not reroll twice under StrictMode's double-invoked mount effect", async () => {
discoverPick.mockResolvedValue({
budget: "any",
hero: { tier: "surprise", card: ytCard("v9", "Rerolled") },
});
requestSurprise();
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<StrictMode>
<QueryClientProvider client={qc}>
<Home />
</QueryClientProvider>
</StrictMode>,
);
expect(await screen.findByText("Rerolled")).toBeTruthy();
expect(discoverPick).toHaveBeenCalledTimes(1);
// …and on the press count the server reads as "which tier answers first": 2 leads with the
// SURPRISE tier, which is what a command labelled "Meglepetés" promised. A plain next press (1)
// led with the shelf, so anyone with one got "A polcodról" instead (owner's call, 2026-09-19).
expect(discoverPick).toHaveBeenCalledWith("any", expect.any(Number), [], 2, "en");
});
// The rotation carries on from the requested tier: the page's own button after a palette surprise
// is the NEXT tier (resume), not a second surprise and not a jump back to press 1.
it("lets the button's next press carry the rotation on from the requested tier", async () => {
discoverPick.mockResolvedValue({
budget: "any",
hero: { tier: "surprise", card: ytCard("v9", "Rerolled") },
});
requestSurprise();
mount();
expect(await screen.findByText("Rerolled")).toBeTruthy();
fireEvent.click(screen.getByText("home.hero.reroll"));
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
expect(discoverPick.mock.lastCall?.[3]).toBe(3);
});
// A token minted for a navigation that never mounted Home (a failed lazy chunk, a Back before
// the module arrived) must not replay on the NEXT visit: it names the history entry it was
// requested for, and Home mounting on any other entry drops it.
it("drops a surprise request that was made for another history entry", async () => {
requestSurprise();
window.history.pushState(null, "");
mount();
await screen.findByText("Hero v1");
expect(discoverPick).not.toHaveBeenCalled();
// …and it is spent, not merely skipped: the entry it named does not revive it either.
expect(takeSurprise()).toBe(false);
});
it("says the shelf is empty rather than hiding it", async () => {
mount();
expect(await screen.findByText("home.comfort.empty")).toBeTruthy();
});
it("plays a half-watched SHOW's next episode instead of opening the show page", async () => {
// Reported 2026-09-06. A show tile that opens the show page hands back the very decision this
// surface exists to remove — six seasons to choose between. The rail row carries `next`, and the
// click follows it.
discover.mockResolvedValue({
budget: "any",
hero: null,
continue_watching: [showCard()],
comfort: [],
});
mount();
fireEvent.click(await screen.findByText("A Show"));
expect(setJumpTo).toHaveBeenCalledWith(expect.objectContaining({ id: "ep-7", type: "play" }));
});
it("hands off with `replace` so Back returns HERE, not to the Plex module's root", async () => {
// The hand-off is a continuation of the navigation that just pushed the Plex page entry, not a
// second step on top of it — without this, Back landed on the Plex Discover page the user never
// visited (and, under StrictMode, did nothing at all the first press).
discover.mockResolvedValue({
budget: "any",
hero: null,
continue_watching: [showCard()],
comfort: [],
});
mount();
fireEvent.click(await screen.findByText("A Show"));
expect(setJumpTo).toHaveBeenCalledWith(expect.objectContaining({ replace: true }));
expect(setPage).toHaveBeenCalledWith("plex");
});
it("sends the ⓘ affordance to the item's page, not to the player", async () => {
// `onInfo` and `onPlay` differ by one ternary arm, and confusing them is the defect the play
// test above was written for — but only `onPlay` was pinned, so swapping ⓘ's "movie" for "play"
// typechecked (both are members of `PlexJumpTarget["type"]`) and shipped green. ⓘ is the one
// action on this surface a user presses precisely because they do NOT want to start watching.
const movie = {
...ytCard("m1", "A Film"),
source: "plex",
kind: "movie",
aspect: "poster",
video: null,
plex: { id: "m1", type: "movie", title: "A Film", thumb: "" },
};
discover.mockResolvedValue({
budget: "any",
hero: null,
continue_watching: [movie],
comfort: [],
});
mount();
// Since E28 C1 the tile's ⓘ is its menu's "Media info" row, opened from the tile's "…".
fireEvent.click((await screen.findAllByRole("button", { name: "card.menu.more" }))[0]!);
const info = screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "plex.info.title");
fireEvent.click(info!);
await waitFor(() =>
expect(setJumpTo).toHaveBeenCalledWith({ id: "m1", type: "movie", replace: true }),
);
expect(setPage).toHaveBeenCalledWith("plex");
});
// C5: what a tile's Play would continue, its menu can start over — the one way to on touch.
it("a started film's menu starts it over: the player opens from the start", async () => {
const movie = {
...ytCard("m1", "A Film"),
source: "plex",
kind: "movie",
aspect: "poster",
position_seconds: 600,
video: null,
plex: { id: "m1", type: "movie", title: "A Film", thumb: "", position_seconds: 600 },
};
discover.mockResolvedValue({
budget: "any",
hero: null,
continue_watching: [movie],
comfort: [],
});
mount();
fireEvent.click((await screen.findAllByRole("button", { name: "card.menu.more" }))[0]!);
const restart = screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "card.restartTitle");
fireEvent.click(restart!);
await waitFor(() =>
expect(setJumpTo).toHaveBeenCalledWith(
expect.objectContaining({ id: "m1", type: "play", fromStart: true }),
),
);
});
it("rotates the tier on each press so the button offers a different KIND of answer", async () => {
// Measured 2026-09-06: exclusion alone left it walking the resume rail and cycling nine cards,
// never once reaching the shelf. The press count travels to the server as the rotation.
discoverPick.mockResolvedValue({
budget: "any",
hero: { tier: "comfort", card: ytCard("v9", "Rerolled") },
});
mount();
await screen.findByText("Hero v1");
fireEvent.click(screen.getByText("home.hero.reroll"));
await waitFor(() =>
expect(discoverPick).toHaveBeenLastCalledWith(
"any",
expect.any(Number),
["youtube:v1"],
1,
"en",
),
);
await screen.findByText("Rerolled");
fireEvent.click(screen.getByText("home.hero.reroll"));
await waitFor(() =>
expect(discoverPick).toHaveBeenLastCalledWith(
"any",
expect.any(Number),
// The exact list, not `expect.any(Array)`: the window has to ACCUMULATE. A one-element list
// is identical whether it accumulates or replaces, so the first press cannot tell them
// apart — and with the loose matcher both "replace, never grow" and SEEN_WINDOW = 1 (which
// reinstate the measured nine-card cycle) left the whole suite green.
["youtube:v1", "youtube:v9"],
2,
"en",
),
);
});
});
// E11 S4b, owner decision D4: the rails are one keyboard page — `j` walks a rail's cards and goes on
// into the next rail, whichever order the page stacks them in.
describe("Home · keyboard", () => {
it("walks the rails with j, from one rail on into the next", async () => {
discover.mockResolvedValue({
...payload("v1"),
continue_watching: [
ytCard("v1", "Hero v1"),
ytCard("v2", "Second thing"),
ytCard("v3", "Third thing"),
],
// A pinned channel has no card of its own: the shelf draws it as a ShelfTile.
comfort: [
ytCard("v4", "Comfort thing"),
{
...ytCard("UC9", "Pinned channel"),
kind: "channel",
video: null,
next: null,
unwatched_count: 3,
},
],
});
mount();
// The rail's keys are live once its effect registers it, which can land after the cards paint.
const first = await screen.findByText("Comfort thing");
await waitFor(() => expect(first.closest("[data-nav-list]")).not.toBeNull());
const titles = ["Comfort thing", "Pinned channel", "Second thing", "Third thing"];
const seen: (string | undefined)[] = [];
for (let i = 0; i < titles.length; i++) {
act(() => {
(document.activeElement ?? document.body).dispatchEvent(
new KeyboardEvent("keydown", { key: "j", bubbles: true, cancelable: true }),
);
});
const item = (document.activeElement as HTMLElement).closest("[data-nav-index]");
seen.push(titles.find((title) => item?.textContent?.includes(title)));
}
// Every card once, across both rails: the shelf tile, and on from one rail into the next.
expect(new Set(seen)).toEqual(new Set(titles));
// Every card of every rail is an item of it — the shelf tile too, not only the native cards.
const rails = [...document.querySelectorAll("[data-nav-list]")];
expect(rails.map((rail) => rail.querySelectorAll("[data-nav-index]").length)).toEqual([2, 2]);
});
});
// E28 S3h (the owner, 2026-09-26/27): the hero stands in three quarters of a wide page with its text
// centred beside the picture; one fact line, no repeated year, and prose only where there is some,
// at a readable measure.
describe("Home's hero text", () => {
const film = {
source: "plex",
kind: "movie",
id: "m1",
title: "A Film",
subtitle: "2003",
thumb: null,
aspect: "poster",
duration_seconds: 9240,
position_seconds: 0,
remaining_seconds: null,
status: "new",
video: null,
plex: {
id: "m1",
type: "movie",
title: "A Film",
thumb: "",
year: 2003,
rating: 7.8,
summary: "What the film is about.",
},
};
it("a film: the year once, in the fact line with the length and score; its summary at the measure", async () => {
discover.mockResolvedValue({ ...payload("v1"), hero: { tier: "surprise", card: film } });
mount();
const title = await screen.findByRole("heading", { name: "A Film" });
const text = title.parentElement!;
expect(within(text).getByText(/^2003 · \S/)).toBeTruthy();
expect(within(text).getByText("7.8")).toBeTruthy();
// the subtitle was the year alone: it is not printed a second time
expect(within(text).queryByText("2003")).toBeNull();
expect(within(text).getByText("What the film is about.").className).toContain("measure");
expect(title.closest("section")!.className).toContain("lg:w-3/4");
});
it("a video: the channel as the subtitle, views and age as the facts, and no summary line", async () => {
discover.mockResolvedValue({
...payload("v1"),
hero: { tier: "surprise", card: ytCard("v9", "A video") },
continue_watching: [],
});
mount();
const text = (await screen.findByRole("heading", { name: "A video" })).parentElement!;
expect(within(text).getByText("A channel")).toBeTruthy();
expect(within(text).getByText(/^10 card\.views · /)).toBeTruthy();
expect(text.querySelector("p.measure")).toBeNull();
});
});
describe("Home · the reader's language (E30)", () => {
it("asks for the page in Hungarian, so a Plex card comes back with its Hungarian title", async () => {
setLanguage("hu");
try {
mount();
await waitFor(() => expect(discover).toHaveBeenCalled());
expect(discover.mock.calls[0]?.[2]).toBe("hu");
} finally {
setLanguage("en");
}
});
});