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
898 lines
38 KiB
TypeScript
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");
|
|
}
|
|
});
|
|
});
|