refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/
A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/ (ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows). Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the contract tests that scan sources now scan the whole app source, not only components/ — each glob was counted before and after so none went green over fewer files. The locale-format rule matched `/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton check; it now covers every folder that holds components, with a test per folder.
This commit is contained in:
@@ -0,0 +1,910 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// 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("../../features/navigation/navigationContext", () => ({
|
||||
useNavigationActions: () => ({ setPage: (p: string) => setPage(p) }),
|
||||
}));
|
||||
vi.mock("../plex/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.
|
||||
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. 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 () => {
|
||||
// A later 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. That race was measured 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.
|
||||
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();
|
||||
});
|
||||
|
||||
// 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.findByRole("button", { name: "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.findByRole("button", { name: "A Show" }));
|
||||
expect(setJumpTo).toHaveBeenCalledWith(expect.objectContaining({ replace: true }));
|
||||
expect(setPage).toHaveBeenCalledWith("plex");
|
||||
});
|
||||
|
||||
it("a show tile's title opens the show page; its picture plays on", async () => {
|
||||
discover.mockResolvedValue({
|
||||
budget: "any",
|
||||
hero: null,
|
||||
continue_watching: [showCard()],
|
||||
comfort: [],
|
||||
});
|
||||
mount();
|
||||
fireEvent.click(await screen.findByText("A Show"), { detail: 1 });
|
||||
expect(setJumpTo).toHaveBeenCalledWith(expect.objectContaining({ type: "show" }));
|
||||
expect(setJumpTo).not.toHaveBeenCalledWith(expect.objectContaining({ type: "play" }));
|
||||
});
|
||||
|
||||
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();
|
||||
// The tile's ⓘ is now 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",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// The owner's decision: 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]);
|
||||
});
|
||||
});
|
||||
|
||||
// The owner's call (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", () => {
|
||||
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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,377 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The comfort shelf's editor — the right-click menu on the Home rails. Its own file, not an
|
||||
// addition to Home.dom.test.tsx, because the shelf query is module-level react-query state that a
|
||||
// sibling test's mocks would leak into.
|
||||
//
|
||||
// What is asserted here is what tsc cannot see: that the menu the user gets over a SHELF tile is a
|
||||
// different menu from the one over a resume tile, that a move row is disabled exactly when the move
|
||||
// would go nowhere, and that a move writes the shelf's WHOLE new order rather than the pair it
|
||||
// swapped.
|
||||
//
|
||||
// Same floating-ui recipe as ContextMenu.dom.test.tsx: stub the observers, wrap in a FloatingTree,
|
||||
// and match menu items by text with `{ hidden: true }` (floating-ui never positions in jsdom, so the
|
||||
// surface stays visibility:hidden and its contents have no accessible name).
|
||||
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";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
|
||||
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) => key,
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const discover = vi.fn();
|
||||
const comfortPins = vi.fn();
|
||||
const addComfortPin = vi.fn();
|
||||
const removeComfortPin = vi.fn();
|
||||
const reorderComfortPins = vi.fn();
|
||||
vi.mock("../../lib/api", () => ({
|
||||
api: {
|
||||
discover: (...a: unknown[]) => discover(...a),
|
||||
discoverPick: () => new Promise(() => {}),
|
||||
setState: () => Promise.resolve(),
|
||||
watchLaterAdd: () => Promise.resolve(),
|
||||
watchLaterRemove: () => Promise.resolve(),
|
||||
downloadIndex: () => Promise.resolve({}), // the cards' download buttons read it
|
||||
comfortPins: (...a: unknown[]) => comfortPins(...a),
|
||||
addComfortPin: (...a: unknown[]) => addComfortPin(...a),
|
||||
removeComfortPin: (...a: unknown[]) => removeComfortPin(...a),
|
||||
reorderComfortPins: (...a: unknown[]) => reorderComfortPins(...a),
|
||||
},
|
||||
HttpError: class extends Error {},
|
||||
DISCOVER_BUDGETS: ["short", "medium", "long", "any"],
|
||||
HERO_TIERS: ["resume", "comfort", "surprise"],
|
||||
discoverCardKey: (c: { source: string; id: string }) => `${c.source}:${c.id}`,
|
||||
}));
|
||||
|
||||
const openChannel = vi.fn();
|
||||
vi.mock("../../features/navigation/navigationContext", () => ({
|
||||
useNavigationActions: () => ({
|
||||
setPage: vi.fn(),
|
||||
openChannel: (...a: unknown[]) => openChannel(...a),
|
||||
}),
|
||||
}));
|
||||
vi.mock("../plex/plexContext", () => ({ usePlexActions: () => ({ setJumpTo: vi.fn() }) }));
|
||||
|
||||
import Home from "./Home";
|
||||
|
||||
const video = (id: string, title: string) => ({
|
||||
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: 0,
|
||||
saved: false,
|
||||
watch_url: `https://www.youtube.com/watch?v=${id}`,
|
||||
});
|
||||
|
||||
const base = (id: string, title: string) => ({
|
||||
source: "youtube",
|
||||
kind: "video",
|
||||
id,
|
||||
title,
|
||||
subtitle: null,
|
||||
thumb: null,
|
||||
aspect: "wide",
|
||||
duration_seconds: 600,
|
||||
position_seconds: 0,
|
||||
remaining_seconds: 600,
|
||||
status: "new",
|
||||
plex: null,
|
||||
video: video(id, title),
|
||||
});
|
||||
|
||||
/** A pinned CHANNEL — the shelf tile with no native card, and the one that is easiest to address by
|
||||
* its title in a test (the video tiles render a whole VideoCard). */
|
||||
const channelPin = (id: string, title: string, pinId: number) => ({
|
||||
...base(id, title),
|
||||
kind: "channel",
|
||||
video: null,
|
||||
unwatched_count: 3,
|
||||
pin_id: pinId,
|
||||
next: base(`${id}-next`, `${title} next`),
|
||||
});
|
||||
|
||||
const shelf = [
|
||||
channelPin("UCa", "Comfort A", 3),
|
||||
channelPin("UCb", "Comfort B", 7),
|
||||
channelPin("UCc", "Comfort C", 9),
|
||||
];
|
||||
|
||||
const payload = () => ({
|
||||
budget: "any",
|
||||
hero: null,
|
||||
continue_watching: [base("v2", "Resume thing")],
|
||||
comfort: shelf,
|
||||
});
|
||||
|
||||
function mount() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<FloatingTree>
|
||||
<QueryClientProvider client={qc}>
|
||||
<Home />
|
||||
</QueryClientProvider>
|
||||
</FloatingTree>,
|
||||
);
|
||||
}
|
||||
|
||||
const labels = () => screen.queryAllByRole("menuitem", { hidden: true }).map((b) => b.textContent);
|
||||
const item = (label: string) => {
|
||||
const found = screen
|
||||
.getAllByRole("menuitem", { hidden: true })
|
||||
.find((b) => b.textContent === label);
|
||||
if (!found) throw new Error(`no menuitem "${label}" in [${labels().join(", ")}]`);
|
||||
return found as HTMLButtonElement;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
openChannel.mockReset();
|
||||
// Home keeps its question on the history entry (Back restores it): each test starts a fresh one.
|
||||
window.history.replaceState(null, "");
|
||||
discover.mockReset().mockResolvedValue(payload());
|
||||
comfortPins.mockReset().mockResolvedValue({ items: shelf });
|
||||
addComfortPin.mockReset().mockResolvedValue({ ok: true, id: 1, created: true });
|
||||
removeComfortPin.mockReset().mockResolvedValue({ deleted: 1 });
|
||||
reorderComfortPins.mockReset().mockResolvedValue({ ok: true });
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("Home — the comfort shelf's context menu", () => {
|
||||
it("offers the four moves and the unpin over a SHELF tile", async () => {
|
||||
mount();
|
||||
fireEvent.contextMenu(await screen.findByText("Comfort B"));
|
||||
expect(labels()).toEqual([
|
||||
"home.hero.play",
|
||||
// A channel tile opens its channel (the owner, 2026-09-27: it had nothing a channel offers).
|
||||
"card.menu.openChannel",
|
||||
"home.shelf.moveFirst",
|
||||
"home.shelf.moveLeft",
|
||||
"home.shelf.moveRight",
|
||||
"home.shelf.moveLast",
|
||||
"home.pin.remove",
|
||||
]);
|
||||
});
|
||||
|
||||
it("disables exactly the moves that would go nowhere", async () => {
|
||||
mount();
|
||||
// The first tile can go right, but not left or to the front it is already at.
|
||||
fireEvent.contextMenu(await screen.findByText("Comfort A"));
|
||||
expect(item("home.shelf.moveFirst").disabled).toBe(true);
|
||||
expect(item("home.shelf.moveLeft").disabled).toBe(true);
|
||||
expect(item("home.shelf.moveRight").disabled).toBe(false);
|
||||
expect(item("home.shelf.moveLast").disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("writes the WHOLE new order when a tile moves", async () => {
|
||||
mount();
|
||||
fireEvent.contextMenu(await screen.findByText("Comfort A"));
|
||||
fireEvent.click(item("home.shelf.moveRight"));
|
||||
// Not the swapped pair: the endpoint takes the shelf's complete order, and a short list would be
|
||||
// the server reordering half the shelf.
|
||||
await waitFor(() => expect(reorderComfortPins).toHaveBeenCalledWith([7, 3, 9]));
|
||||
});
|
||||
|
||||
it("moves the tile under the pointer, not only in the request", async () => {
|
||||
mount();
|
||||
fireEvent.contextMenu(await screen.findByText("Comfort A"));
|
||||
// Hold the refetch that follows the write: it would answer with this mock's ORIGINAL order,
|
||||
// which is not what is under test. The claim here is that the tile moves under the pointer,
|
||||
// before any round trip — `keepPreviousData` keeps the patched cache on screen meanwhile.
|
||||
discover.mockReturnValue(new Promise(() => {}));
|
||||
fireEvent.click(item("home.shelf.moveRight"));
|
||||
await waitFor(() => expect(reorderComfortPins).toHaveBeenCalled());
|
||||
// The RENDERED order, not just the payload sent: drop the optimistic cache write and the request
|
||||
// is still perfectly right while the shelf visibly does not move, so the user clicks again.
|
||||
expect(screen.getAllByText(/^Comfort [ABC]$/).map((e) => e.textContent)).toEqual([
|
||||
"Comfort B",
|
||||
"Comfort A",
|
||||
"Comfort C",
|
||||
]);
|
||||
});
|
||||
|
||||
it("re-asks the server when a move fails", async () => {
|
||||
reorderComfortPins.mockRejectedValue(new Error("nope"));
|
||||
mount();
|
||||
await screen.findByText("Comfort A");
|
||||
expect(discover).toHaveBeenCalledTimes(1);
|
||||
fireEvent.contextMenu(screen.getByText("Comfort A"));
|
||||
fireEvent.click(item("home.shelf.moveRight"));
|
||||
await waitFor(() => expect(discover).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("puts the shown order back when a move is refused, even if the re-read fails too", async () => {
|
||||
// Only a re-read used to undo a refused move — and when that read failed for the same reason the
|
||||
// write did, react-query kept the last data: the shelf showed the order the user had just been
|
||||
// told was not saved. With one write at a time, the order shown before it IS the server's.
|
||||
reorderComfortPins.mockRejectedValue(new Error("nope"));
|
||||
mount();
|
||||
await screen.findByText("Comfort A");
|
||||
discover.mockRejectedValue(new Error("still down"));
|
||||
fireEvent.contextMenu(screen.getByText("Comfort A"));
|
||||
fireEvent.click(item("home.shelf.moveRight"));
|
||||
await waitFor(() => expect(discover).toHaveBeenCalledTimes(2));
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByText(/^Comfort [ABC]$/).map((e) => e.textContent)).toEqual([
|
||||
"Comfort A",
|
||||
"Comfort B",
|
||||
"Comfort C",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("writes one move at a time: the rows wait, and say so, while one is being saved", async () => {
|
||||
reorderComfortPins.mockReturnValue(new Promise(() => {}));
|
||||
discover.mockResolvedValueOnce(payload()).mockReturnValue(new Promise(() => {}));
|
||||
mount();
|
||||
fireEvent.contextMenu(await screen.findByText("Comfort A"));
|
||||
fireEvent.click(item("home.shelf.moveRight"));
|
||||
await waitFor(() => expect(reorderComfortPins).toHaveBeenCalledTimes(1));
|
||||
fireEvent.contextMenu(screen.getByText("Comfort C"));
|
||||
// The first row carries the note, so its text is its label followed by the note.
|
||||
expect(item("home.shelf.moveFirst" + "home.shelf.moveSaving").disabled).toBe(true);
|
||||
expect(item("home.shelf.moveLeft").disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("moves what the rail SHOWS while a new budget is still loading", async () => {
|
||||
// Under keepPreviousData the rail keeps the previous budget's payload on screen while the new
|
||||
// key holds nothing yet; the move read that empty cache, found no tile and did nothing.
|
||||
mount();
|
||||
await screen.findByText("Comfort A");
|
||||
discover.mockReturnValue(new Promise(() => {})); // the new budget never arrives
|
||||
fireEvent.click(screen.getByText("home.budget.short"));
|
||||
fireEvent.contextMenu(screen.getByText("Comfort A"));
|
||||
fireEvent.click(item("home.shelf.moveRight"));
|
||||
await waitFor(() => expect(reorderComfortPins).toHaveBeenCalledWith([7, 3, 9]));
|
||||
});
|
||||
|
||||
it("unpins with the pin id the shelf card carries", async () => {
|
||||
mount();
|
||||
fireEvent.contextMenu(await screen.findByText("Comfort C"));
|
||||
fireEvent.click(item("home.pin.remove"));
|
||||
await waitFor(() => expect(removeComfortPin).toHaveBeenCalledWith(9));
|
||||
});
|
||||
|
||||
it("offers the plain PIN over a resume tile, and never a move", async () => {
|
||||
// The rail below the shelf is not orderable, so its menu must not grow rows that would reorder
|
||||
// something the tile is not part of.
|
||||
mount();
|
||||
await screen.findByText("Comfort A");
|
||||
fireEvent.contextMenu(screen.getByText("Resume thing"));
|
||||
await waitFor(() => expect(labels()).toContain("home.pin.add"));
|
||||
expect(labels().some((l) => l?.startsWith("home.shelf.move"))).toBe(false);
|
||||
fireEvent.click(item("home.pin.add"));
|
||||
await waitFor(() => expect(addComfortPin).toHaveBeenCalledWith("video", "v2"));
|
||||
});
|
||||
|
||||
it("still opens the menu over a shelf tile that has nothing left to play", async () => {
|
||||
// The tile with nothing left to play is the one you most want to take OFF the shelf, so its
|
||||
// menu still has to open and its Play row still has to be honest about being dead.
|
||||
// NOTE what this does NOT prove: jsdom delivers `contextmenu` to a real `<button disabled>`
|
||||
// too, so swapping the tile's `aria-disabled` back to `disabled` keeps this test green. That
|
||||
// choice was settled in a real browser instead (see the tile's own comment) — the difference
|
||||
// is focus, which jsdom's event dispatch never exercises.
|
||||
discover.mockResolvedValue({
|
||||
...payload(),
|
||||
comfort: [{ ...channelPin("UCd", "Nothing left", 11), next: null }],
|
||||
});
|
||||
mount();
|
||||
fireEvent.contextMenu(await screen.findByText("Nothing left"));
|
||||
expect(item("home.hero.play").disabled).toBe(true);
|
||||
fireEvent.click(item("home.pin.remove"));
|
||||
await waitFor(() => expect(removeComfortPin).toHaveBeenCalledWith(11));
|
||||
});
|
||||
|
||||
// A comfort hero resolved from a pinned SHOW is that show's next episode, which is not itself
|
||||
// pinned: the menu looked the episode up, missed, and offered "Pin" under "From your shelf" —
|
||||
// taking it created a second pin. The card now names the pin it came from, and the row acts on
|
||||
// THAT (owner's call, 2026-09-19: "Unpin").
|
||||
it("unpins the SHOW a comfort hero's episode was resolved from", async () => {
|
||||
const show = {
|
||||
...base("ps1", "Pinned show"),
|
||||
source: "plex",
|
||||
kind: "show",
|
||||
video: null,
|
||||
pin_id: 21,
|
||||
};
|
||||
const episode = {
|
||||
...base("ps1e2", "Next episode"),
|
||||
source: "plex",
|
||||
kind: "episode",
|
||||
video: null,
|
||||
pinned_from: { source: "plex", kind: "show", id: "ps1", title: "Pinned show" },
|
||||
};
|
||||
discover.mockResolvedValue({ ...payload(), hero: { tier: "comfort", card: episode } });
|
||||
comfortPins.mockResolvedValue({ items: [...shelf, show] });
|
||||
mount();
|
||||
fireEvent.contextMenu(await screen.findByText("Next episode"));
|
||||
fireEvent.click(await waitFor(() => item("home.pin.remove")));
|
||||
await waitFor(() => expect(removeComfortPin).toHaveBeenCalledWith(21));
|
||||
expect(addComfortPin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("says so rather than guessing while the shelf is still loading", async () => {
|
||||
// "Not pinned" and "not known yet" must not render as the same row: the pin label is only
|
||||
// trustworthy once the shelf has answered.
|
||||
comfortPins.mockReturnValue(new Promise(() => {}));
|
||||
mount();
|
||||
await screen.findByText("Comfort A");
|
||||
fireEvent.contextMenu(screen.getByText("Resume thing"));
|
||||
expect(item("home.pin.add").disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// The owner, 2026-09-27: every Home card offers what the same card offers elsewhere.
|
||||
describe("Home — a card's own actions", () => {
|
||||
it("opens a pinned channel from its tile's ↗", async () => {
|
||||
mount();
|
||||
await screen.findByText("Comfort B");
|
||||
fireEvent.click(
|
||||
screen.getAllByRole("button", { name: "card.menu.openChannel" })[1] as HTMLElement,
|
||||
);
|
||||
expect(openChannel).toHaveBeenCalledWith("UCb", "Comfort B");
|
||||
});
|
||||
|
||||
it("gives the hero the card's whole menu, not the pin row alone", async () => {
|
||||
discover.mockResolvedValue({
|
||||
...payload(),
|
||||
hero: { tier: "surprise", card: base("h1", "Hero video") },
|
||||
});
|
||||
mount();
|
||||
fireEvent.contextMenu(await screen.findByRole("heading", { name: "Hero video" }));
|
||||
const rows = labels();
|
||||
expect(rows[0]).toBe("home.hero.play");
|
||||
expect(rows).toContain("card.markWatched");
|
||||
expect(rows).toContain("card.menu.openChannel");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user