Files
siftlode/frontend/src/components/Home.pin.dom.test.tsx
T
peter 250e3a9f90 fix(home): Back keeps the question, and the focus finds its card by name
Opening a card left Home, and Back came back to "Doesn't matter" with a
different hero, whose card then dropped out of the Continue rail and moved
the keyboard onto its neighbour. The budget, reroll seed and seen list live
on the history entry now, and a returning list focuses the item by its
name before its place.
2026-09-19 04:51:43 +02:00

273 lines
11 KiB
TypeScript

// @vitest-environment jsdom
//
// E24 S3: 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"],
discoverCardKey: (c: { source: string; id: string }) => `${c.source}:${c.id}`,
}));
vi.mock("./navigationContext", () => ({ useNavigationActions: () => ({ setPage: vi.fn() }) }));
vi.mock("./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(() => {
// 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",
"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 instead of restoring a snapshot", async () => {
// Writing a captured `before` back was wrong twice: two overlapping moves each reverted to their
// own snapshot and the shelf settled on an order neither side held, and `before` is the WHOLE
// payload, so the rollback also discarded any rail refresh that landed mid-flight.
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("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));
});
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);
});
});