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