feat(e24): the Home surface — one question, one answer, both sources

E24 S2. A new `home` module in layout C+ (the mockup the owner picked): the
time-budget chips on top, one large hero that answers them, then the comfort
shelf and a cross-source resume rail. Home is the landing — deliberately OVER
the remembered last module, since landing on whatever you happened to close the
app on is the habit this epic exists to replace; `LS.startOnHome = "0"` opts an
account back out (Settings toggle lands in S4).

No third card component: each unified card carries the native feed / Plex
payload, so the rails render through the existing VideoCard (`cardsSmall`) and
PlexPosterCard, and `aspect` decides the tile shape without branching on source.
Plex playback hands off to the Plex module via a new `jumpTo` type — the player
lives there with its lazy hls.js and its provider tree, and mounting a second one
on the landing page to save one hand-off would be the expensive way round.

Four defects found by driving it in a real browser against the live account, each
now pinned by a mutation-checked test:

- **The reroll gave the same card back.** A new seed only re-draws the SURPRISE
  tier; the resume tier deterministically returns the most recently progressed
  thing that fits, so anyone with something half-finished pressed a dead button.
  `/pick` now takes an `exclude` list of `<source>:<id>` keys and the client keeps
  a rolling window of 8. Excluding a SHOW also excludes the episode it resolves
  to — otherwise the same suggestion comes back wearing a hat.
- **"Nothing to suggest yet" flashed over a full library** whenever a reroll was
  in flight: the new query key has no data for a beat. An empty state standing in
  for a loading one is the dishonesty the house rules name outright. The hero now
  falls back to the loaded payload while a pick is pending, and only trusts a null
  once the pick has RESOLVED.
- **A budget change replaced the page — chips included — with a spinner**, so the
  control you just clicked vanished under your cursor. `keepPreviousData`, the
  same reason the feed uses it.
- **The hero printed twice**, once as itself and once as the first resume tile.

Two smaller UI calls made from the same session: the budget caption sits NEXT to
its chips rather than in SegmentedToggle's justify-between mode, which on an 80rem
page throws the answers to the far edge; and the feed takes the YouTube mark its
own toolbar already uses, since Home now owns the house icon.

Gate green: ruff, ruff format, 3x tsc, eslint, prettier, knip, vitest 780,
pytest 989. Verified end to end on localdev against the real account: budget
chips re-answer (matched on REMAINING time — a 51-minute video with 2:46 left is
a ten-minute answer), the reroll walks three distinct heroes, and a Plex show
tile opens its drill-down in the Plex module.
This commit is contained in:
2026-09-06 04:14:14 +02:00
parent 53b50231ff
commit f3c0b7be23
21 changed files with 964 additions and 14 deletions
+184
View File
@@ -0,0 +1,184 @@
// @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 { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from "vitest";
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
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(),
},
discoverCardKey: (c: { source: string; id: string }) => `${c.source}:${c.id}`,
}));
// The page hands Plex playback off to the Plex module; neither provider is under test here.
vi.mock("./NavigationProvider", () => ({ useNavigationActions: () => ({ setPage: vi.fn() }) }));
vi.mock("./PlexProvider", () => ({ usePlexActions: () => ({ setJumpTo: vi.fn() }) }));
import Home from "./Home";
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,
};
}
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() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<Home />
</QueryClientProvider>,
);
}
beforeEach(() => {
discover.mockReset();
discoverPick.mockReset();
discover.mockResolvedValue(payload("v1"));
});
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("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("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"]),
);
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();
});
it("says the shelf is empty rather than hiding it", async () => {
mount();
expect(await screen.findByText("home.comfort.empty")).toBeTruthy();
});
});