// @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(); 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("./navigationContext", () => ({ useNavigationActions: () => ({ setPage: vi.fn(), openChannel: (...a: unknown[]) => openChannel(...a), }), })); 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( , ); } 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 `