// @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()), 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()), 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( , ); return { onPlay, onBack }; } const rowOf = (text: string) => screen.getByText(text).closest("[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('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( '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"); }); });