Files
siftlode/frontend/src/components/PlexPlaylistView.menu.dom.test.tsx
T
peter 0fce732497 fix(plex): a playlist row's menu adds its title to a collection too
The row menu called the title's action list without the anchor the panel opens beside, and the optional parameter dropped the row silently. The anchor is now required, so every host menu decides.
2026-10-08 21:18:48 +02:00

383 lines
16 KiB
TypeScript

// @vitest-environment jsdom
//
// The Plex playlist page: a title, a show group and a season each open ONE menu (the "…"
// beside them, a right-click, a long-press, Shift+F10) instead of a red X at rest; removing needs no
// confirm, since the toast's Undo puts the rows back; `j`/`k` walk the visible rows and Delete takes
// out the one the keyboard is on; the playlist's rename and delete sit in the header's "…".
import { setLanguage } from "../i18n";
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, cleanup, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => {
const real = (await import("i18next")).default;
return {
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({
t: (key: string, o?: { title?: string }) => (o?.title ? `${key}:${o.title}` : key),
i18n: real,
}),
};
});
const h = vi.hoisted(() => ({ notify: vi.fn(), confirm: vi.fn(async () => true) }));
vi.mock("../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/notifications")>()),
notify: (...a: unknown[]) => h.notify(...a),
}));
vi.mock("./confirmContext", () => ({ useConfirm: () => h.confirm }));
import PlexPlaylistView from "./PlexPlaylistView";
import { api, type PlexCard } from "../lib/api";
import { qk } from "../lib/queryKeys";
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.restoreAllMocks();
h.notify.mockReset();
h.confirm.mockReset();
h.confirm.mockImplementation(async () => true);
});
const movie = (id: string) =>
({ id, type: "movie", title: `Movie ${id}`, year: 2020, thumb: "" }) as unknown as PlexCard;
const ep = (id: string, season: number, n: number) =>
({
id,
type: "episode",
title: `Ep ${id}`,
show_id: "S",
show_title: "The Show",
season_number: season,
episode_number: n,
thumb: "",
}) as unknown as PlexCard;
function mount(items: PlexCard[], onInfoCard?: (c: PlexCard) => void, { admin = false } = {}) {
vi.spyOn(api, "plexPlaylist").mockResolvedValue({ id: 5, title: "Films", items } as never);
vi.spyOn(api, "comfortPins").mockResolvedValue([] as never);
const onPlay = vi.fn();
const onBack = vi.fn();
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
if (admin) client.setQueryData(qk.me(), { role: "admin" });
render(
<QueryClientProvider client={client}>
<PlexPlaylistView playlistId={5} onBack={onBack} onPlay={onPlay} onInfoCard={onInfoCard} />
</QueryClientProvider>,
);
return { onPlay, onBack };
}
const rowOf = (text: string) => screen.getByText(text).closest<HTMLElement>("[data-nav-index]")!;
const menuRows = () =>
screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent ?? "");
async function openMenu(open: () => void) {
open();
await waitFor(() =>
expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0),
);
}
async function pick(open: () => void, label: string) {
await openMenu(open);
fireEvent.click(
screen.getAllByRole("menuitem", { hidden: true }).find((b) => b.textContent === label)!,
);
}
const moreOf = (text: string) =>
rowOf(text).querySelector<HTMLElement>('button[aria-label="plex.playlist.rowMenu"]')!;
const toast = () =>
h.notify.mock.calls
.map((c) => c[0] as { message: string; action?: { label: string; onClick: () => void } })
.filter((n) => n.message?.startsWith("plex.playlist.removedToast"))
.pop();
// The list's keys are live once its effect has registered it (`data-nav-list`), a passive effect that
// can run AFTER the rows are on screen: a key pressed on a slow machine in between did nothing (CI flake).
const keysLiveOn = (el: Element) =>
waitFor(() => expect(el.closest("[data-nav-list]")).not.toBeNull());
const pressKey = (key: string) =>
act(() => {
(document.activeElement ?? document.body).dispatchEvent(
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
);
});
describe("PlexPlaylistView · a row's menu", () => {
it("offers an admin a movie's 'Add to collection…', as its card's menu does", async () => {
mount([movie("1")], undefined, { admin: true });
await screen.findByText("Movie 1");
await openMenu(() => fireEvent.click(moreOf("Movie 1")));
expect(menuRows()).toContain("plex.collectionAdd.row");
});
it("has no X at rest: each title ends in its '…', which hides until hover or focus", async () => {
mount([movie("1"), ep("e1", 1, 1)]);
await screen.findByText("Movie 1");
expect(screen.queryAllByRole("button", { name: /plex\.playlist\.remove/ })).toHaveLength(0);
const more = moreOf("Movie 1");
expect(more.classList.contains("reveal")).toBe(true);
expect(rowOf("Movie 1").classList.contains("group")).toBe(true);
expect(moreOf("The Show")).toBeTruthy();
});
it("lists the title's own actions, its moves, then the removal", async () => {
const info = vi.fn();
mount([movie("1")], info);
await screen.findByText("Movie 1");
await openMenu(() => fireEvent.click(moreOf("Movie 1")));
expect(menuRows()).toEqual([
"plex.play",
"plex.markWatched",
"plex.playlistAdd.manage",
"plex.info.title",
"common.copy.title",
"home.pin.add",
"common.order.top",
"common.order.up",
"common.order.down",
"common.order.bottom",
"plex.playlist.removeFromPlaylist",
]);
expect(moreOf("Movie 1").getAttribute("aria-expanded")).toBe("true");
});
it("closes on a second press of the same '…', though its row is rebuilt each render", async () => {
mount([movie("1")]);
await screen.findByText("Movie 1");
await openMenu(() => fireEvent.click(moreOf("Movie 1")));
act(() => {
fireEvent.pointerDown(moreOf("Movie 1"), { pointerType: "mouse" });
fireEvent.mouseDown(moreOf("Movie 1"));
fireEvent.click(moreOf("Movie 1"), { detail: 1 });
});
await waitFor(() =>
expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0),
);
});
it("removes a title with no confirm, and the toast's Undo puts it back at its index", async () => {
mount([movie("1"), movie("2"), movie("3")]);
await screen.findByText("Movie 2");
vi.spyOn(api, "plexPlaylistRemoveItem").mockImplementation(async () => {
// The server's side of it: the Undo reads the list fresh, and the row is gone from it.
vi.mocked(api.plexPlaylist).mockResolvedValue({
id: 5,
title: "Films",
items: [movie("1"), movie("3")],
} as never);
return {} as never;
});
const add = vi.spyOn(api, "plexPlaylistAddBulk").mockResolvedValue({} as never);
await pick(() => fireEvent.contextMenu(rowOf("Movie 2")), "plex.playlist.removeFromPlaylist");
await waitFor(() => expect(api.plexPlaylistRemoveItem).toHaveBeenCalledWith(5, "2"));
expect(h.confirm).not.toHaveBeenCalled();
await waitFor(() => expect(toast()?.message).toBe("plex.playlist.removedToast:Movie 2"));
act(() => toast()!.action!.onClick());
await waitFor(() => expect(add).toHaveBeenCalledWith(5, ["2"], [1]));
});
it("plays and removes a whole show from its group's menu", async () => {
const { onPlay } = mount([movie("1"), ep("e1", 1, 1), ep("e2", 2, 1)]);
await screen.findByText("The Show");
await openMenu(() => fireEvent.click(moreOf("The Show")));
expect(menuRows()).toEqual([
"plex.playlist.playShow",
"common.copy.title",
"common.order.top",
"common.order.up",
"common.order.down",
"common.order.bottom",
"plex.playlist.removeShow",
]);
fireEvent.click(
screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "plex.playlist.playShow")!,
);
await waitFor(() => expect(onPlay).toHaveBeenCalledWith("e1", ["1", "e1", "e2"]));
const bulk = vi.spyOn(api, "plexPlaylistRemoveBulk").mockResolvedValue({} as never);
await pick(() => fireEvent.click(moreOf("The Show")), "plex.playlist.removeShow");
await waitFor(() => expect(bulk).toHaveBeenCalledWith(5, ["e1", "e2"]));
await waitFor(() => expect(toast()?.message).toBe("plex.playlist.removedToast:The Show"));
});
it("removes one season from its label's menu", async () => {
mount([ep("e1", 1, 1), ep("e2", 2, 1), ep("e3", 2, 2)]);
fireEvent.click(await screen.findByText("The Show")); // open the group
const seasonLabel = await screen.findByText(/plex\.seasonTitle · 2/);
const more = seasonLabel.parentElement!.querySelector<HTMLElement>(
'button[aria-label="plex.playlist.rowMenu"]',
)!;
const bulk = vi.spyOn(api, "plexPlaylistRemoveBulk").mockResolvedValue({} as never);
await openMenu(() => fireEvent.click(more));
expect(menuRows()).toEqual(["plex.playlist.playSeason", "plex.playlist.removeSeason"]);
fireEvent.click(
screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "plex.playlist.removeSeason")!,
);
await waitFor(() => expect(bulk).toHaveBeenCalledWith(5, ["e2", "e3"]));
});
});
const moveRow = (label: string) =>
screen.getAllByRole("menuitem", { hidden: true }).find((b) => b.textContent === label)!;
describe("PlexPlaylistView · reordering without a drag (WCAG 2.5.7)", () => {
it("moves a title among the blocks from its menu, the moves that go nowhere held off", async () => {
mount([movie("1"), ep("e1", 1, 1), movie("2")]);
await screen.findByText("Movie 1");
const reorder = vi.spyOn(api, "plexReorderPlaylist").mockResolvedValue({} as never);
await openMenu(() => fireEvent.click(moreOf("Movie 1")));
expect((moveRow("common.order.top") as HTMLButtonElement).disabled).toBe(true);
expect((moveRow("common.order.up") as HTMLButtonElement).disabled).toBe(true);
fireEvent.click(moveRow("common.order.bottom"));
// The show moves as one block: Movie 1 lands after Movie 2, the episode stays with its show.
await waitFor(() => expect(reorder).toHaveBeenCalledWith(5, ["e1", "2", "1"]));
});
it("moves a whole show from its group's menu", async () => {
mount([movie("1"), ep("e1", 1, 1), ep("e2", 1, 2)]);
await screen.findByText("The Show");
const reorder = vi.spyOn(api, "plexReorderPlaylist").mockResolvedValue({} as never);
await pick(() => fireEvent.click(moreOf("The Show")), "common.order.up");
await waitFor(() => expect(reorder).toHaveBeenCalledWith(5, ["e1", "e2", "1"]));
});
it("moves an episode only within its own show", async () => {
mount([movie("1"), ep("e1", 1, 1), ep("e2", 1, 2), ep("e3", 1, 3)]);
fireEvent.click(await screen.findByText("The Show")); // open the group
await screen.findByText("Ep e3");
const reorder = vi.spyOn(api, "plexReorderPlaylist").mockResolvedValue({} as never);
await openMenu(() => fireEvent.click(moreOf("Ep e3")));
// Last of three in its show: down is off, though a movie sits above the show in the list.
expect((moveRow("common.order.down") as HTMLButtonElement).disabled).toBe(true);
fireEvent.click(moveRow("common.order.top"));
await waitFor(() => expect(reorder).toHaveBeenCalledWith(5, ["1", "e3", "e1", "e2"]));
});
});
describe("PlexPlaylistView · the keyboard", () => {
it("walks the rows with j and takes out the walked one on Delete", async () => {
mount([movie("1"), movie("2"), movie("3")]);
await keysLiveOn(await screen.findByText("Movie 3"));
vi.spyOn(api, "plexPlaylistRemoveItem").mockResolvedValue({} as never);
pressKey("j");
pressKey("j");
expect(document.activeElement).toBe(rowOf("Movie 2"));
pressKey("Delete");
await waitFor(() => expect(api.plexPlaylistRemoveItem).toHaveBeenCalledWith(5, "2"));
await waitFor(() => expect(screen.queryByText("Movie 2")).toBeNull());
await waitFor(() => expect(document.activeElement).toBe(rowOf("Movie 3")));
});
it("walks an open show's episodes after its header, and Delete on the header takes the show", async () => {
mount([ep("e1", 1, 1), ep("e2", 1, 2), movie("9")]);
fireEvent.click(await screen.findByText("The Show"));
await keysLiveOn(await screen.findByText("Ep e2"));
pressKey("j");
expect(document.activeElement).toBe(rowOf("The Show"));
pressKey("j");
expect(document.activeElement).toBe(rowOf("Ep e1"));
// Both sit flush in the show's clipping card: an outside ring was cut away (index.css).
expect(rowOf("The Show").hasAttribute("data-nav-inset")).toBe(true);
expect(rowOf("Ep e1").hasAttribute("data-nav-inset")).toBe(true);
pressKey("k");
const bulk = vi.spyOn(api, "plexPlaylistRemoveBulk").mockResolvedValue({} as never);
pressKey("Delete");
await waitFor(() => expect(bulk).toHaveBeenCalledWith(5, ["e1", "e2"]));
});
});
describe("PlexPlaylistView · the header's '…'", () => {
it("holds rename and delete; the pencil and the trash are gone", async () => {
const { onBack } = mount([movie("1")]);
await screen.findByText("Movie 1");
expect(screen.queryByRole("button", { name: "plex.playlist.rename" })).toBeNull();
expect(screen.queryByRole("button", { name: "plex.playlist.delete" })).toBeNull();
const del = vi.spyOn(api, "plexDeletePlaylist").mockResolvedValue({ deleted: 5 });
const more = screen.getByRole("button", { name: "plex.playlist.menu" });
await openMenu(() => fireEvent.click(more));
expect(menuRows()).toEqual(["plex.playlist.rename", "plex.playlist.delete"]);
fireEvent.click(
screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "plex.playlist.delete")!,
);
// Deleting the playlist itself keeps its confirm: it has no undo.
await waitFor(() =>
expect(h.confirm).toHaveBeenCalledWith(expect.objectContaining({ danger: true })),
);
await waitFor(() => expect(del).toHaveBeenCalledWith(5));
await waitFor(() => expect(onBack).toHaveBeenCalled());
});
it("hands the focus back to the '…' when a rename opened from it is left", async () => {
mount([movie("1")]);
await screen.findByText("Movie 1");
const more = screen.getByRole("button", { name: "plex.playlist.menu" });
act(() => more.focus());
await pick(() => fireEvent.click(more), "plex.playlist.rename");
const field = await screen.findByRole("textbox", { name: "plex.playlist.rename" });
fireEvent.keyDown(field, { key: "Escape" });
await waitFor(() =>
expect(document.activeElement).toBe(
screen.getByRole("button", { name: "plex.playlist.menu" }),
),
);
});
});
// The watched toggle flips the view's LOCAL copy of the rows. On a failure the
// resync refetches an identical list, react-query keeps the same `data`, and the copy was never
// re-seeded — the row stayed "watched" on screen while Plex still had it unwatched.
describe("PlexPlaylistView · a failed watched toggle", () => {
it("puts the row's watched state back", async () => {
mount([movie("1"), movie("2")]);
await screen.findByText("Movie 2");
vi.spyOn(api, "plexSetState").mockRejectedValue(new Error("offline"));
await pick(() => fireEvent.contextMenu(rowOf("Movie 2")), "plex.markWatched");
await waitFor(() =>
expect(h.notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "error", title: "plex.watchedFailed" }),
),
);
await act(async () => {});
fireEvent.keyDown(document.activeElement ?? document.body, { key: "Escape" });
await openMenu(() => fireEvent.contextMenu(rowOf("Movie 2")));
expect(menuRows()).toContain("plex.markWatched");
});
});
describe("PlexPlaylistView · the reader's language", () => {
it("asks for the rows in Hungarian", async () => {
setLanguage("hu");
try {
mount([movie("1")]);
await screen.findByText("Movie 1");
expect(api.plexPlaylist).toHaveBeenCalledWith(5, "hu");
} finally {
setLanguage("en");
}
});
});
describe("PlexPlaylistView · a season's name", () => {
it("names season 0 the Specials, as the show page does", async () => {
mount([ep("e0", 0, 1), ep("e1", 1, 1)]);
fireEvent.click(await screen.findByText("The Show")); // open the show's seasons
expect(await screen.findByText(/^plex\.specials · 1$/)).toBeTruthy();
expect(screen.getByText(/^plex\.seasonTitle · 1$/)).toBeTruthy();
});
});