gate / frontend (push) Successful in 6m5s
gate / backend (push) Successful in 7m10s
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.
349 lines
15 KiB
TypeScript
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");
|
|
});
|
|
});
|