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.
202 lines
6.8 KiB
TypeScript
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");
|
|
});
|
|
});
|