Files
siftlode/frontend/src/components/PlexPlaylistView.menu.dom.test.tsx
T
peter 0f9ed7cdbf
gate / frontend (push) Successful in 6m5s
gate / backend (push) Successful in 7m10s
test: a list key waits for the list's registration, not only its rows
The server gate's frontend job went red on 0.96.0: PlexPlaylistView's j/Delete
test pressed j after the rows painted but before useListNav's passive effect
registered the list, so the key did nothing on the slower runner. Every test
that presses a list key after an async load now waits for [data-nav-list] on
its list first (PlexPlaylistView, Playlists, Home, PlexDiscover). Checked
against a 30 ms delayed registration: the old tests fail, the new ones pass.
2026-09-27 23:01:57 +02:00

349 lines
15 KiB
TypeScript

// @vitest-environment jsdom
//
// E28 C1 on 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 { 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) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({
t: (key: string, o?: { title?: string }) => (o?.title ? `${key}:${o.title}` : key),
i18n: { language: "en" },
}),
}));
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";
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) {
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();
render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<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("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\.playlist\.season · 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" }),
),
);
});
});
// E28 review E2: 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");
});
});