Files
siftlode/frontend/src/components/PlexMenu.dom.test.tsx
T
peter 3eb87e7108 feat(plex): every show card plays the episode it would put on
A show card's picture opened the show page everywhere but Home, whose shelves carried the
resolved `next`. Now every Plex picture plays: a show asks `GET /api/plex/show/{id}/next`
(the in-progress episode, else the first unwatched, else S1·E1 — the rule Home and the show
page's Resume follow) on the click, fresh each time. A started show shows Continue + Restart;
its title, an ⓘ and the menu's "Open show" row open its page; one with no episodes opens there.

The question is asked per show, never carried on every card: three probes a show would cost a
grid page up to 180 queries. The hover preview asks it once it opens and names the episode
("Next: S6·E16 — Title"). Related shows on a show page play the same way.
2026-10-09 01:22:00 +02:00

436 lines
18 KiB
TypeScript

// @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<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
};
});
vi.mock("../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/api")>();
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>): 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<typeof PlexMenu>[0]["extra"];
}) {
const menu = useContextMenu<PlexCard>();
const actions = usePlexCardActions(handlers, menu.open);
return (
<>
<PlexPosterCard
card={c}
onOpen={() => handlers.onOpen(c)}
onToggleWatched={handlers.onToggleWatched}
onAdd={actions.add}
menuProps={menu.triggerProps}
menuButtonProps={menu.buttonProps}
menuOpen={menu.open && menu.target === c}
/>
<PlexMenu menu={menu} actions={actions} pin={pin} extra={extra} />
</>
);
}
function setup(
c: PlexCard,
opts: Omit<Parameters<typeof Grid>[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(
<QueryClientProvider client={qc}>
<FloatingTree>
<Grid c={c} handlers={handlers} {...opts} />
</FloatingTree>
</QueryClientProvider>,
);
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 <body> 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<HTMLInputElement>("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"));
});
});