// @vitest-environment jsdom // // A Plex title's ONE action list. The poster's right-click and its "…" open the same menu, // which holds what the card no longer shows (details, copy, the pin) beside the everyday toggles; // the card's hover "add" and the menu's "Add to playlist" open the same dialog. import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { FloatingTree } from "@floating-ui/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { api, HttpError, type PlexCard, type PlexCollection } from "../lib/api"; import { qk } from "../lib/queryKeys"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), }; }); vi.mock("../lib/api", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, api: { ...actual.api, comfortPins: () => Promise.resolve({ items: [] }), plexPlaylists: () => Promise.resolve({ playlists: [] }), }, }; }); const copyText = vi.fn((_: string) => Promise.resolve(true)); vi.mock("../lib/clipboard", () => ({ copyText: (s: string) => copyText(s) })); import { PlexMenu } from "./PlexMenu"; import { PlexPosterCard } from "./plexCards"; import { useContextMenu } from "./ui/useContextMenu"; import { usePlexCardActions, type PlexActionHandlers } from "./usePlexCardActions"; beforeAll(() => { class Stub { observe() {} unobserve() {} disconnect() {} takeRecords() { return []; } } globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver; }); afterEach(() => { cleanup(); vi.clearAllMocks(); }); // The surface is visibility:hidden in jsdom (floating-ui never positions): matched on text. const labels = () => screen.queryAllByRole("menuitem", { hidden: true }).map((b) => b.textContent); const item = (label: string) => { const found = screen .queryAllByRole("menuitem", { hidden: true }) .find((b) => b.textContent === label); if (!found) throw new Error(`no menuitem "${label}" in [${labels().join(", ")}]`); return found; }; const card = (extra: Partial): PlexCard => ({ id: "m1", type: "movie", title: "A film", thumb: "", status: "new", ...extra, }); function Grid({ c, handlers, pin, extra, }: { c: PlexCard; handlers: PlexActionHandlers; pin?: boolean; extra?: Parameters[0]["extra"]; }) { const menu = useContextMenu(); const actions = usePlexCardActions(handlers, menu.open); return ( <> handlers.onOpen(c)} onToggleWatched={handlers.onToggleWatched} onAdd={actions.add} menuProps={menu.triggerProps} menuButtonProps={menu.buttonProps} menuOpen={menu.open && menu.target === c} /> ); } function setup( c: PlexCard, opts: Omit[0], "c" | "handlers"> = {}, { admin = false }: { admin?: boolean } = {}, ) { const handlers = { onOpen: vi.fn(), onInfo: vi.fn(), onToggleWatched: vi.fn(), onRestart: vi.fn(), }; const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); if (admin) qc.setQueryData(qk.me(), { role: "admin" }); render( , ); return { handlers, qc }; } // The card root holds the menu's trigger props (right-click anywhere on it). const rightClick = () => fireEvent.contextMenu(screen.getByText("A film", { selector: ".line-clamp-2" })); const moreButtons = () => screen.getAllByRole("button", { name: "card.menu.more" }); describe("a Plex title's menu", () => { it("lists a movie's whole action list, in its groups", () => { setup(card({})); rightClick(); expect(labels()).toEqual([ "plex.play", "plex.markWatched", "plex.playlistAdd.manage", "plex.info.title", "common.copy.title", "home.pin.add", ]); }); // C5: on touch the card's hover pair is gone, so the menu is where a started title starts over. it("a started title continues from its Play row and starts over from the next one", async () => { const { handlers } = setup(card({ status: "in_progress", position_seconds: 600 })); rightClick(); expect(labels().slice(0, 2)).toEqual(["card.continue", "card.restartTitle"]); fireEvent.click(item("card.restartTitle")); // A row acts a task after the menu closes (closeThenAct). await vi.waitFor(() => expect(handlers.onRestart).toHaveBeenCalledWith(expect.objectContaining({ id: "m1" })), ); }); it("a watched title has no restart row: it plays from the start anyway", () => { setup(card({ status: "watched", position_seconds: 600 })); rightClick(); expect(labels()).not.toContain("card.restartTitle"); }); // A show plays like any title (the surface resolves the episode); its page is a row of its own. // The watched toggle and the add belong to an episode, so a show has none. it("a show plays, opens its page from a row of its own, and has no watched or add rows", async () => { const { handlers } = setup(card({ type: "show" })); rightClick(); expect(labels()).toEqual(["plex.play", "plex.openShow", "common.copy.title", "home.pin.add"]); fireEvent.click(item("plex.play")); await vi.waitFor(() => expect(handlers.onOpen).toHaveBeenCalledWith(expect.objectContaining({ type: "show" })), ); rightClick(); fireEvent.click(item("plex.openShow")); await vi.waitFor(() => expect(handlers.onInfo).toHaveBeenCalledWith(expect.objectContaining({ type: "show" })), ); }); it("a started show continues, and starts its episode over from the next row", () => { setup(card({ type: "show", status: "in_progress" })); rightClick(); expect(labels().slice(0, 2)).toEqual(["card.continue", "card.restartTitle"]); }); it("the card's '…' opens the same menu, and says so", () => { setup(card({})); const hoverMore = moreButtons()[0]!; expect(hoverMore.getAttribute("aria-expanded")).toBe("false"); fireEvent.click(hoverMore); expect(labels()).toContain("plex.info.title"); expect(moreButtons()[0]!.getAttribute("aria-expanded")).toBe("true"); }); it("each row fires the handler the card's own controls do", async () => { const { handlers } = setup(card({ status: "watched" })); rightClick(); fireEvent.click(item("plex.markUnwatched")); // A row acts a task after the menu closes (closeThenAct), so the assertions wait for it. await vi.waitFor(() => expect(handlers.onToggleWatched).toHaveBeenCalledWith(expect.objectContaining({ id: "m1" })), ); rightClick(); fireEvent.click(item("plex.info.title")); await vi.waitFor(() => expect(handlers.onInfo).toHaveBeenCalledWith(expect.objectContaining({ id: "m1" })), ); rightClick(); fireEvent.click(item("plex.play")); await vi.waitFor(() => expect(handlers.onOpen).toHaveBeenCalledWith(expect.objectContaining({ id: "m1" })), ); }); it("copies the title inside the click", () => { setup(card({})); rightClick(); fireEvent.click(item("common.copy.title")); expect(copyText).toHaveBeenCalledWith("A film"); }); it("the hover 'add' and the menu row open the same playlist dialog", async () => { setup(card({})); fireEvent.click(screen.getByRole("button", { name: "plex.playlistAdd.manage" })); expect(await screen.findByText("plex.playlistAdd.title")).toBeTruthy(); cleanup(); setup(card({})); rightClick(); fireEvent.click(item("plex.playlistAdd.manage")); expect(await screen.findByText("plex.playlistAdd.title")).toBeTruthy(); }); it("an episode list carries no pin, and a host's own rows come last", async () => { const remove = vi.fn(); setup(card({ type: "episode" }), { pin: false, extra: (c) => [{ id: "x", label: "remove", danger: true, onSelect: () => remove(c.id) }], }); rightClick(); expect(labels()).not.toContain("home.pin.add"); // The row names what it adds, as the episode card's own hover button does. expect(labels()).toContain("plex.playlist.addEpisode"); expect(screen.getByRole("button", { name: "plex.playlist.addEpisode" })).toBeTruthy(); expect(labels()[labels().length - 1]).toBe("remove"); fireEvent.click(item("remove")); await vi.waitFor(() => expect(remove).toHaveBeenCalledWith("m1")); }); }); // Adding a title to a collection was a trip to the Collections page, a new collection, its name and a // search for the title. From the card's menu it is a tick, or a name and Enter. describe("a Plex title's menu — Add to collection", () => { const coll = (id: string, title: string, contains: boolean) => ({ id, title, contains }) as PlexCollection & { contains: boolean }; it("an admin gets the row on a movie and a show; an episode and a viewer do not", () => { setup(card({}), {}, { admin: true }); rightClick(); expect(labels()).toContain("plex.collectionAdd.row"); cleanup(); setup(card({ type: "show" }), {}, { admin: true }); rightClick(); expect(labels()).toContain("plex.collectionAdd.row"); cleanup(); setup(card({ type: "episode" }), {}, { admin: true }); rightClick(); expect(labels()).not.toContain("plex.collectionAdd.row"); cleanup(); setup(card({})); rightClick(); expect(labels()).not.toContain("plex.collectionAdd.row"); }); it("ticks the title into a collection, out of one, and names a new one with it inside", async () => { vi.spyOn(api, "plexCollectionsFor").mockResolvedValue({ collections: [coll("cA", "Alpha", true), coll("cB", "Beta", false)], }); const add = vi.spyOn(api, "plexCollectionAddItem").mockResolvedValue({} as PlexCollection); const remove = vi .spyOn(api, "plexCollectionRemoveItem") .mockResolvedValue({} as PlexCollection); const create = vi.spyOn(api, "plexCreateCollection").mockResolvedValue({} as PlexCollection); setup(card({}), {}, { admin: true }); rightClick(); fireEvent.click(item("plex.collectionAdd.row")); const row = async (name: string) => vi.waitFor(() => { const found = screen .queryAllByRole("menuitemcheckbox", { hidden: true }) .find((b) => b.textContent === name); expect(found).toBeDefined(); return found!; }); fireEvent.click(await row("Beta")); await vi.waitFor(() => expect(add).toHaveBeenCalledWith("cB", "m1")); // One write at a time: the next tick waits for this one to settle. await vi.waitFor(() => expect(document.querySelector("[aria-busy]")?.getAttribute("aria-busy")).toBe("false"), ); fireEvent.click(await row("Alpha")); await vi.waitFor(() => expect(remove).toHaveBeenCalledWith("cA", "m1")); const name = screen.getByLabelText("plex.collection.namePlaceholder"); fireEvent.change(name, { target: { value: " Rainy days " } }); fireEvent.keyDown(name, { key: "Enter" }); await vi.waitFor(() => expect(create).toHaveBeenCalledWith("", "Rainy days", "m1")); }); const checkRow = async (name: string) => vi.waitFor(() => { const found = screen .queryAllByRole("menuitemcheckbox", { hidden: true }) .find((b) => b.textContent === name); expect(found).toBeDefined(); return found!; }); const idle = () => vi.waitFor(() => expect(document.querySelector("[aria-busy]")?.getAttribute("aria-busy")).toBe("false"), ); it("flips the tick as soon as a write lands, so the next tap undoes it before the list refetches", async () => { const list = vi.spyOn(api, "plexCollectionsFor"); list.mockResolvedValueOnce({ collections: [coll("cB", "Beta", false)] }); // The refetch after the write never answers: the tick must not wait for it. list.mockImplementation(() => new Promise(() => {})); const add = vi.spyOn(api, "plexCollectionAddItem").mockResolvedValue({} as PlexCollection); const remove = vi .spyOn(api, "plexCollectionRemoveItem") .mockResolvedValue({} as PlexCollection); setup(card({}), {}, { admin: true }); rightClick(); fireEvent.click(item("plex.collectionAdd.row")); fireEvent.click(await checkRow("Beta")); await vi.waitFor(() => expect(add).toHaveBeenCalledWith("cB", "m1")); await idle(); expect((await checkRow("Beta")).getAttribute("aria-checked")).toBe("true"); fireEvent.click(await checkRow("Beta")); await vi.waitFor(() => expect(remove).toHaveBeenCalledWith("cB", "m1")); expect(add).toHaveBeenCalledTimes(1); }); it("refreshes a show's own page too, whose collections come with the show", async () => { vi.spyOn(api, "plexCollectionsFor").mockResolvedValue({ collections: [coll("cB", "Beta", false)], }); vi.spyOn(api, "plexCollectionAddItem").mockResolvedValue({} as PlexCollection); const { qc } = setup(card({ type: "show" }), {}, { admin: true }); const invalidate = vi.spyOn(qc, "invalidateQueries"); rightClick(); fireEvent.click(item("plex.collectionAdd.row")); fireEvent.click(await checkRow("Beta")); await idle(); expect(invalidate).toHaveBeenCalledWith({ queryKey: qk.plexShow("m1") }); }); it("is a named menu that says it is loading, not that there are no collections", async () => { vi.spyOn(api, "plexCollectionsFor").mockImplementation(() => new Promise(() => {})); setup(card({}), {}, { admin: true }); rightClick(); fireEvent.click(item("plex.collectionAdd.row")); // jsdom computes no name for the unpositioned (visibility: hidden) surface: read what names it. const menu = await vi.waitFor(() => { const found = document.querySelector('[role="menu"][aria-label="plex.collectionAdd.title"]'); expect(found).not.toBeNull(); return found!; }); expect(menu.hasAttribute("aria-labelledby")).toBe(false); expect(screen.getByText("common.loading")).toBeTruthy(); expect(screen.queryByText("plex.collectionAdd.none")).toBeNull(); }); it("says a failed load as one, with a retry — not as no collections", async () => { const list = vi.spyOn(api, "plexCollectionsFor").mockRejectedValue(new Error("down")); setup(card({}), {}, { admin: true }); rightClick(); fireEvent.click(item("plex.collectionAdd.row")); const alert = await screen.findByRole("alert", { hidden: true }); expect(alert.textContent).toContain("plex.collectionAdd.loadFailed"); expect(screen.queryByText("plex.collectionAdd.none")).toBeNull(); // A menu owns its items only: the line about the list sits beside it, not in it. expect(document.querySelector("[role=menu]")).not.toBeNull(); expect(alert.closest("[role=menu]")).toBeNull(); list.mockResolvedValue({ collections: [coll("cB", "Beta", false), coll("cC", "Gamma", false)], }); // Retry from the keyboard: the focused button goes as the list loads, and focus must not be left // on with the menu open — it lands on the first row that came back. const retry = (await screen.findByText("common.retry")).closest("button")!; retry.focus(); fireEvent.click(retry); const beta = await checkRow("Beta"); await vi.waitFor(() => expect(document.activeElement).toBe(beta)); // …and the menu's own arrows carry on from there. fireEvent.keyDown(beta, { key: "ArrowDown" }); expect(document.activeElement).toBe(await checkRow("Gamma")); }); it("clears the name when Plex made the collection and only the read after it failed", async () => { vi.spyOn(api, "plexCollectionsFor").mockResolvedValue({ collections: [] }); const create = vi .spyOn(api, "plexCreateCollection") .mockRejectedValue( new HttpError(422, "pending", { code: "refused", reason: "collection_pending_sync" }), ); setup(card({}), {}, { admin: true }); rightClick(); fireEvent.click(item("plex.collectionAdd.row")); const name = await screen.findByLabelText("plex.collection.namePlaceholder"); fireEvent.change(name, { target: { value: "Noir" } }); fireEvent.keyDown(name, { key: "Enter" }); await vi.waitFor(() => expect(create).toHaveBeenCalledTimes(1)); // It exists now: a second Enter on the kept name could only be refused as taken. await vi.waitFor(() => expect(name.value).toBe("")); }); it("steps from the name field into the rows with the arrows — rows that arrived after it opened", async () => { let answer: (v: { collections: (PlexCollection & { contains: boolean })[] }) => void = () => {}; vi.spyOn(api, "plexCollectionsFor").mockImplementation( () => new Promise((resolve) => (answer = resolve)), ); setup(card({}), {}, { admin: true }); rightClick(); fireEvent.click(item("plex.collectionAdd.row")); const name = await screen.findByLabelText("plex.collection.namePlaceholder"); name.focus(); answer({ collections: [coll("cA", "Alpha", true), coll("cB", "Beta", false)] }); const alpha = await checkRow("Alpha"); fireEvent.keyDown(name, { key: "ArrowDown" }); expect(document.activeElement).toBe(alpha); name.focus(); fireEvent.keyDown(name, { key: "ArrowUp" }); expect(document.activeElement).toBe(await checkRow("Beta")); }); });