Files
siftlode/frontend/src/components/VideoMenu.dom.test.tsx
T
peter 4aaaaa7860 chore(comments): drop epic, sprint and review-round ids from the code
The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").

Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.

Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).

One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
2026-10-05 00:19:39 +02:00

202 lines
6.7 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 "./ui/MoreButton";
import { useContextMenu } from "./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");
});
});