Files
siftlode/frontend/src/components/PlexMenu.dom.test.tsx
T
peter 89e65ca331 feat(design): players on touch and the C5 control set, restart on the card (E28 S3f2)
Plex player: a finger's tap on the picture only shows or hides the controls,
the hide timer starts with playback, a double-tap on a side third skips 10 s,
the seek bar is a 2.75rem touch-none hit area with its head shown on touch.
The row is play/pause, Back by position, Next only with a next item, mute,
time, tracks, a fixed ⋯ (settings, media info, stats, shortcuts, download)
and fullscreen; Stop, Restart and the ±10 s buttons are gone.

A started card shows the video card's Continue + Restart pair (one shared
MediaResumeButtons, beside the picture's button), and its menu lists the
restart; the player opens that item at 0 (fromStart). YouTube modal: the
description opens from an ⓘ, the queue arrows sit inside the screen.
2026-09-27 02:12:54 +02:00

226 lines
7.7 KiB
TypeScript

// @vitest-environment jsdom
//
// E28 C1: 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 type { PlexCard } from "../lib/api";
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"> = {}) {
const handlers = {
onOpen: vi.fn(),
onInfo: vi.fn(),
onToggleWatched: vi.fn(),
onRestart: vi.fn(),
};
render(
<QueryClientProvider client={new QueryClient()}>
<FloatingTree>
<Grid c={c} handlers={handlers} {...opts} />
</FloatingTree>
</QueryClientProvider>,
);
return { handlers };
}
// 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");
});
it("a show opens its page and has no watched, add or details rows", () => {
setup(card({ type: "show" }));
rightClick();
expect(labels()).toEqual(["plex.openShow", "common.copy.title", "home.pin.add"]);
});
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"));
});
});