Round 4 reviewed round 3's commit. Nothing in production behaviour was wrong; what was wrong was the round's own test and its comments, which is the failure class this loop keeps finding. - The new NavigationProvider case named after the `&& !channelViewRef.current` clause never reached it. With storage cleared and a bare URL the provider lands on "home" (E24's own default), not "feed", so asking for "feed" was an ordinary page change and the guard's first conjunct short-circuited. Deleting the clause left all 828 tests green — a dead nav control (the rail's own module stops closing an open channel page) with no red anywhere. The test now asserts the precondition and calls `setPage` with the page that IS current. - Both new Home cases rejected a promise and then asserted after one fixed macrotask. React-query notifies through `notifyManager`'s own `setTimeout(0)`, registered after any timer the test schedules, so the assertion raced it; two reviewers measured intermittent reds. They poll for a signal that the rejection LANDED instead — the reroll button releasing, which is also the honest one for the empty-answer case, where the empty state is already on screen while the press is in flight and asserting it early would pass either way. - `buryUnder`'s comment still opened with "one rule for both entry points" ten lines above the paragraph saying the rule is complete only for one of them, listed the non-reporting palette rows without `openChannel` — which has its own `replaceState` no-push branch and is reached from PlayerModal too — and pointed at a ROADMAP.md this repository does not contain. - `history.dom.test.ts`'s header claimed its layers close by genuine Backs only, which two of its own tests contradict; and `CommandPalette.ordering.dom.test.tsx` still claimed to be "the only assertion in the suite" doing what the file corrected in round 3 also does. Round 3's message said that pair was handled and only one side had been. The provider test was re-mutation-checked (dropping the clause now goes red), and both hardened Home tests still go red against their own reverts. Seven consecutive full runs green. Gate: ruff, ruff format, 3x tsc, eslint (0 errors), prettier, knip, vitest 828, pytest 1005.
677 lines
29 KiB
TypeScript
677 lines
29 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 { StrictMode } from "react";
|
|
import { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from "vitest";
|
|
import { act, render, screen, fireEvent, cleanup, waitFor } 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(),
|
|
},
|
|
DISCOVER_BUDGETS: ["short", "medium", "long", "any"],
|
|
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("./NavigationProvider", () => ({
|
|
useNavigationActions: () => ({ setPage: (p: string) => setPage(p) }),
|
|
}));
|
|
vi.mock("./PlexProvider", () => ({
|
|
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(() => {
|
|
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 `ui/Button` turns
|
|
// `loading` into `disabled`, swallow clicks 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().disabled).toBe(true);
|
|
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().disabled).toBe(false);
|
|
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().disabled).toBe(false)); // 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().disabled).toBe(false);
|
|
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));
|
|
|
|
// 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().disabled).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().disabled).toBe(false));
|
|
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));
|
|
// 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),
|
|
);
|
|
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();
|
|
mount();
|
|
await screen.findByText("Hero v1");
|
|
expect(discoverPick).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// 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".
|
|
expect(discoverPick).toHaveBeenCalledWith("any", expect.any(Number), [], 1);
|
|
});
|
|
|
|
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();
|
|
fireEvent.click(await screen.findByLabelText("plex.info.title"));
|
|
expect(setJumpTo).toHaveBeenCalledWith({ id: "m1", type: "movie", replace: true });
|
|
expect(setPage).toHaveBeenCalledWith("plex");
|
|
});
|
|
|
|
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),
|
|
);
|
|
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,
|
|
),
|
|
);
|
|
});
|
|
});
|