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.
436 lines
18 KiB
TypeScript
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"));
|
|
});
|
|
});
|