Files
siftlode/frontend/src/components/Home.dom.test.tsx
T
peter aac79ffa53 fix(i18n): numbers, units, dates and order in the UI language, and after a switch (E30 S3)
Every locale formatter takes the language as an argument. Under the React
Compiler a formatter that read the i18n singleton was memoized on its other
arguments, so a card kept "2 hr 4 min" after a switch to Hungarian; plexLang()
had the same flaw and left Plex queries in the old language. Components now
take the language from useTranslation, and the signature makes forgetting it
a type error.

- ratings, file sizes, stream stats: the language's decimal mark ("8,3")
- view and subscriber counts compact in the language ("1,2 E")
- seconds as the language's unit ("6 mp") in the sliders and the stats
- a person's birth and death dates as dates, not ISO strings
- A-Z lists by the shown name in the language's alphabet (compareText),
  the tag catalog re-sorted by its translated names
- lint local/locale-format (was date-format) also refuses Intl.NumberFormat
  outside lib/format.ts, localeCompare, and a component reading the language
  off the i18n singleton
2026-09-29 22:39:57 +02:00

898 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")>();
// the real instance: these tests switch the language with setLanguage
const real = (await import("i18next")).default;
return {
...actual,
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
opts?.time ? `${key}:${opts.time}` : key,
i18n: real,
}),
};
});
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");
}
});
});