Files
siftlode/frontend/src/features/video/VideoMenu.dom.test.tsx
T
peter 686c673fb2 feat(player): a video's related videos, on demand beside it
The player shows what YouTube lists beside the video, without what the
user has watched or hidden. Wide, a Related tab beside the description;
stacked, a button beside the info opens them in a drawer. A row plays in
the same player (Back returns) and the list stays the opened video's.
A switch picks on-topic or Hungarian-trending lists, kept on the account.

Each related row says whether the user follows its channel; the row is
the video card's narrow form with its whole menu. VideoMenu moves to
features/video, where the player can reach it.
2026-10-24 16:57:52 +02:00

202 lines
6.8 KiB
TypeScript

// @vitest-environment jsdom
//
// A video's ONE action list. The right-click and the card's "…" open the same menu, which
// holds everything the card no longer shows (download, details, copy, hide, reset, pin) beside the
// everyday toggles, and each row fires the same handler the card's own buttons do.
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 { Video } 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: [] }),
downloadIndex: () => Promise.resolve({}),
playlists: () => Promise.resolve([]),
},
};
});
const copyText = vi.fn((_: string) => Promise.resolve(true));
vi.mock("../../lib/clipboard", () => ({ copyText: (s: string) => copyText(s) }));
import { VideoMenu } from "./VideoMenu";
import { MoreButton } from "../../components/ui/MoreButton";
import { useContextMenu } from "../../components/ui/useContextMenu";
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), so an item has no
// accessible name there: matched on its text, as ContextMenu.dom.test.tsx does.
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 video = (extra: Partial<Video>): Video =>
({
id: "v1",
title: "A video",
channel_id: "c1",
channel_title: "A channel",
watch_url: "https://youtu.be/v1",
status: "new",
saved: false,
position_seconds: 0,
...extra,
}) as Video;
function Feed({
v,
handlers,
}: {
v: Video;
handlers: Parameters<typeof VideoMenu>[0]["handlers"];
}) {
const menu = useContextMenu<Video>();
return (
<>
<div {...menu.triggerProps(v)}>card</div>
<MoreButton
label="more"
expanded={menu.open && menu.target?.id === v.id}
menuProps={menu.buttonProps(v)}
/>
<VideoMenu menu={menu} handlers={handlers} />
</>
);
}
function setup(v: Video) {
const handlers = {
onState: vi.fn(),
onToggleSave: vi.fn(),
onResetState: vi.fn(),
onOpen: vi.fn(),
};
const qc = new QueryClient();
render(
<QueryClientProvider client={qc}>
<FloatingTree>
<Feed v={v} handlers={handlers} />
</FloatingTree>
</QueryClientProvider>,
);
return { handlers };
}
const rightClick = () => fireEvent.contextMenu(screen.getByText("card"));
const more = () => screen.getByRole("button", { name: "more" });
describe("a video's menu", () => {
it("lists the whole action list for a fresh video, in its groups", () => {
setup(video({}));
rightClick();
expect(labels()).toEqual([
"card.play",
"card.menu.openChannel",
"card.markWatched",
"card.saveForLater",
"playlists.addToPlaylist",
"downloads.menu.download",
"card.info",
"common.copy.title",
"common.copy.link",
"card.hide",
"home.pin.add",
]);
});
it("offers Continue and the restart on a half-watched one, the way back and the reset", async () => {
const { handlers } = setup(video({ position_seconds: 30, saved: true }));
rightClick();
expect(labels()).toContain("card.restartTitle");
expect(labels()).toContain("card.menu.unsave");
expect(labels()).toContain("card.resetState");
fireEvent.click(item("card.restartTitle"));
await vi.waitFor(() => expect(handlers.onOpen).toHaveBeenLastCalledWith(expect.anything(), 0));
});
it("each toggle fires the card's own handler", async () => {
const { handlers } = setup(video({ status: "watched" }));
rightClick();
fireEvent.click(item("card.menu.unmarkWatched"));
await vi.waitFor(() => expect(handlers.onState).toHaveBeenLastCalledWith("v1", "new"));
rightClick();
fireEvent.click(item("card.hide"));
await vi.waitFor(() => expect(handlers.onState).toHaveBeenLastCalledWith("v1", "hidden"));
});
it("copies the link INSIDE the click, before the menu closes (Safari's gesture rule)", () => {
setup(video({}));
rightClick();
fireEvent.click(item("common.copy.link"));
// Synchronously: a row that went through closeThenAct would run a task later.
expect(copyText).toHaveBeenCalledWith("https://youtu.be/v1");
expect(labels()).toEqual([]);
});
it("opens the playlists panel from the menu, anchored where the menu came from", async () => {
setup(video({}));
rightClick();
fireEvent.click(item("playlists.addToPlaylist"));
expect(await screen.findByText("playlists.noneYet")).toBeTruthy();
});
it("the '…' opens the same menu, says so, and a second press closes it", () => {
setup(video({}));
expect(more().getAttribute("aria-haspopup")).toBe("menu");
expect(more().getAttribute("aria-expanded")).toBe("false");
fireEvent.click(more());
expect(labels()).toContain("card.hide");
expect(more().getAttribute("aria-expanded")).toBe("true");
// The press lands outside the panel, which floating-ui's dismiss closes on; the click after
// it must not open it again.
fireEvent.pointerDown(more(), { pointerType: "mouse" });
fireEvent.mouseDown(more());
fireEvent.click(more(), { detail: 1 });
expect(labels()).toEqual([]);
});
it("a keyboard click on the '…' always opens, whatever an earlier press left behind", () => {
setup(video({}));
fireEvent.click(more(), { detail: 1 });
// A press on the open row's "…" that never became a click (released elsewhere)…
fireEvent.pointerDown(more(), { pointerType: "mouse" });
fireEvent.keyDown(document, { key: "Escape" });
// …then Enter on the button: a click with no pointer (detail 0).
fireEvent.click(more(), { detail: 0 });
expect(labels()).toContain("card.hide");
});
});