Files
siftlode/frontend/src/components/ui/ContextMenu.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

324 lines
13 KiB
TypeScript

// @vitest-environment jsdom
//
// Contract of the right-click / long-press context menu. Same floating-ui recipe as
// ActionMenu.dom.test.tsx: stub the two observers autoUpdate wires and wrap in a FloatingTree, and
// query with `{ hidden: true }` because floating-ui never positions in jsdom, so the surface stays
// `visibility: hidden` and the a11y tree treats its contents as hidden.
//
// What is worth asserting here is not "a menu opens" but the two things that make it a CONTEXT menu:
// the left button keeps the row's own action, and the gesture that opens it doesn't also fire that
// action underneath.
import { describe, it, expect, vi, afterEach, beforeAll, onTestFinished } from "vitest";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react";
import { type ActionMenuItem } from "./ActionMenu";
import { ContextMenu } from "./ContextMenu";
import { useContextMenu } from "./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;
// jsdom ships no PointerEvent, so fireEvent.pointerDown would deliver pointerType/clientX/clientY
// as null — and the long-press path is ENTIRELY about those three. Without this polyfill the touch
// tests would pass for the wrong reason (every press looks like an untyped press at 0,0) and the
// "a mouse never starts the timer" guard could rot unnoticed. MouseEvent already carries the
// coordinates; only pointerType has to be added.
class PointerEventStub extends MouseEvent {
pointerType: string;
constructor(type: string, init: MouseEventInit & { pointerType?: string } = {}) {
super(type, init);
this.pointerType = init.pointerType ?? "";
}
}
globalThis.PointerEvent ??= PointerEventStub as unknown as typeof PointerEvent;
});
afterEach(cleanup);
/** A two-row list wired exactly the way a real consumer wires it: one menu, `triggerProps` per row. */
function List({
build,
onRowClick = () => {},
}: {
build: (row: string) => ActionMenuItem[];
onRowClick?: (row: string) => void;
}) {
const menu = useContextMenu<string>();
return (
<>
{["alpha", "beta"].map((row) => (
<button key={row} onClick={() => onRowClick(row)} {...menu.triggerProps(row)}>
{row}
</button>
))}
<ContextMenu
menu={menu}
ariaLabel="Row actions"
items={menu.target ? build(menu.target) : []}
/>
</>
);
}
const renderList = (build: (row: string) => ActionMenuItem[], onRowClick = vi.fn()) => {
render(
<FloatingTree>
<List build={build} onRowClick={onRowClick} />
</FloatingTree>,
);
return { onRowClick };
};
const row = (name: string) => screen.getByRole("button", { name });
const menuitems = () =>
screen.queryAllByRole("menuitem", { hidden: true }).map((b) => b.textContent);
// The surface is visibility:hidden in jsdom (floating-ui never positions), and an accessible NAME is
// empty inside a hidden subtree — so items are matched on their text, as in ActionMenu.dom.test.tsx.
const item = (label: string) => {
const found = screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === label);
if (!found) throw new Error(`no menuitem "${label}" in [${menuitems().join(", ")}]`);
return found as HTMLButtonElement;
};
describe("useContextMenu / ContextMenu", () => {
it("stays closed until a right-click, and builds the menu from the row that got it", () => {
renderList((r) => [{ id: "a", label: `Play ${r}`, onSelect: vi.fn() }]);
expect(menuitems()).toEqual([]);
fireEvent.contextMenu(row("beta"), { clientX: 40, clientY: 80 });
expect(menuitems()).toEqual(["Play beta"]);
});
it("leaves the LEFT button to the row's own action", () => {
// The whole point of the sprint: right-click is a new input path, not a replacement for the
// row's default action.
const { onRowClick } = renderList((r) => [{ id: "a", label: r, onSelect: vi.fn() }]);
fireEvent.click(row("alpha"));
expect(onRowClick).toHaveBeenCalledWith("alpha");
expect(menuitems()).toEqual([]);
});
describe("on selected words the browser's own menu opens (DESIGN: Text stays selectable)", () => {
const selectText = (el: Element) => {
const range = document.createRange();
range.selectNodeContents(el);
window.getSelection()!.removeAllRanges();
window.getSelection()!.addRange(range);
};
/** Right-click `el` at (x, y); true when the app left the event to the browser (not prevented). */
const rightClick = (el: Element, x = 10, y = 10) =>
fireEvent.contextMenu(el, { clientX: x, clientY: y });
afterEach(() => {
window.getSelection()!.removeAllRanges();
});
it("lets the browser have a right-click on the selection, and keeps the app menu elsewhere", () => {
renderList((r) => [{ id: "a", label: `Play ${r}`, onSelect: vi.fn() }]);
selectText(row("beta"));
expect(rightClick(row("beta"))).toBe(true); // not prevented: the native menu opens
expect(menuitems()).toEqual([]);
expect(rightClick(row("alpha"))).toBe(false);
expect(menuitems()).toEqual(["Play alpha"]);
});
it("where ranges are laid out, only a point ON the selected words counts", () => {
renderList((r) => [{ id: "a", label: `Play ${r}`, onSelect: vi.fn() }]);
// A real engine: the selected words' line box.
// jsdom lays nothing out and has no Range.getClientRects at all; lend it one for this test.
const box = { left: 0, top: 0, right: 50, bottom: 20 } as DOMRect;
Object.defineProperty(Range.prototype, "getClientRects", {
configurable: true,
value: () => [box],
});
onTestFinished(() => {
delete (Range.prototype as { getClientRects?: unknown }).getClientRects;
});
selectText(row("beta"));
expect(rightClick(row("beta"), 10, 10)).toBe(true);
expect(menuitems()).toEqual([]);
// The same row, beside its selected words (its picture, its padding): the object's menu.
expect(rightClick(row("beta"), 200, 10)).toBe(false);
expect(menuitems()).toEqual(["Play beta"]);
});
});
it("selecting an item closes the menu, then fires it (see ActionMenu's order test)", async () => {
const onSelect = vi.fn();
renderList(() => [{ id: "a", label: "Remove", onSelect }]);
fireEvent.contextMenu(row("alpha"));
fireEvent.click(item("Remove"));
expect(menuitems()).toEqual([]);
await vi.waitFor(() => expect(onSelect).toHaveBeenCalledTimes(1));
});
it("Shift+F10 opens it from the keyboard", () => {
renderList((r) => [{ id: "a", label: `Play ${r}`, onSelect: vi.fn() }]);
fireEvent.keyDown(row("alpha"), { key: "F10", shiftKey: true });
expect(menuitems()).toEqual(["Play alpha"]);
});
it("Escape closes it", () => {
renderList(() => [{ id: "a", label: "Remove", onSelect: vi.fn() }]);
fireEvent.contextMenu(row("alpha"));
fireEvent.keyDown(document, { key: "Escape" });
expect(menuitems()).toEqual([]);
});
// A card the keyboard is on is focused as a whole, through a tabindex=-1 root (lib/listNav), and
// holds tabbable controls. floating-ui handed the focus back to the first of them instead of the
// card — measured in Chrome: j, Shift+F10, Escape left it on the thumbnail.
// Closed here by picking the row (the other way out; jsdom delivers no Escape to a keyboard-opened
// menu — Escape is measured in the browser).
it("gives the focus back to a card focused as a whole, not to a control inside it", async () => {
function Card() {
const menu = useContextMenu<string>();
return (
<>
<div tabIndex={-1} data-testid="card" {...menu.triggerProps("card")}>
<a href="#thumb">thumbnail</a>
</div>
<ContextMenu
menu={menu}
ariaLabel="Card actions"
items={menu.target ? [{ id: "a", label: "Pin", onSelect: vi.fn() }] : []}
/>
</>
);
}
render(
<FloatingTree>
<Card />
</FloatingTree>,
);
const card = screen.getByTestId("card");
act(() => card.focus());
fireEvent.keyDown(card, { key: "F10", shiftKey: true });
expect(menuitems()).toEqual(["Pin"]);
await vi.waitFor(() => expect(card.contains(document.activeElement)).toBe(false));
fireEvent.click(item("Pin"));
expect(menuitems()).toEqual([]);
await vi.waitFor(() => expect(document.activeElement).toBe(card));
});
describe("long press (touch)", () => {
const press = (el: HTMLElement, x = 10, y = 10) =>
fireEvent.pointerDown(el, { pointerType: "touch", clientX: x, clientY: y });
it("opens after the hold and SWALLOWS the click that follows the release", () => {
vi.useFakeTimers();
try {
const { onRowClick } = renderList((r) => [
{ id: "a", label: `Play ${r}`, onSelect: vi.fn() },
]);
press(row("alpha"));
act(() => void vi.advanceTimersByTime(600));
expect(menuitems()).toEqual(["Play alpha"]);
// Without the click guard the row would ALSO play the video under the open menu.
fireEvent.click(row("alpha"));
expect(onRowClick).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it("a scroll (pointer moved past the slop) cancels it", () => {
vi.useFakeTimers();
try {
const { onRowClick } = renderList((r) => [{ id: "a", label: r, onSelect: vi.fn() }]);
press(row("alpha"), 10, 10);
fireEvent.pointerMove(row("alpha"), { pointerType: "touch", clientX: 10, clientY: 60 });
act(() => void vi.advanceTimersByTime(600));
expect(menuitems()).toEqual([]);
// …and a cancelled long press leaves the row's own click intact.
fireEvent.click(row("alpha"));
expect(onRowClick).toHaveBeenCalledWith("alpha");
} finally {
vi.useRealTimers();
}
});
it("a MOUSE press never starts the timer (it has a right button)", () => {
vi.useFakeTimers();
try {
renderList((r) => [{ id: "a", label: r, onSelect: vi.fn() }]);
fireEvent.pointerDown(row("alpha"), { pointerType: "mouse", clientX: 10, clientY: 10 });
act(() => void vi.advanceTimersByTime(600));
expect(menuitems()).toEqual([]);
} finally {
vi.useRealTimers();
}
});
});
describe("submenus", () => {
const withSub = (): ActionMenuItem[] => [
{ id: "play", label: "Play", onSelect: vi.fn() },
{
id: "move",
label: "Move to",
items: [
{ id: "t1", label: "Watch later", onSelect: vi.fn() },
{ id: "t2", label: "Homelab", onSelect: vi.fn() },
],
},
];
it("marks the submenu row as such and keeps its items unmounted until opened", () => {
renderList(withSub);
fireEvent.contextMenu(row("alpha"));
const trigger = item("Move to");
expect(trigger.getAttribute("aria-haspopup")).toBe("menu");
expect(trigger.getAttribute("aria-expanded")).toBe("false");
expect(menuitems()).toEqual(["Play", "Move to"]);
});
it("opens on click and on ArrowRight, and ArrowLeft steps back out of it", () => {
renderList(withSub);
fireEvent.contextMenu(row("alpha"));
fireEvent.click(item("Move to"));
expect(menuitems()).toEqual(["Play", "Move to", "Watch later", "Homelab"]);
// ArrowLeft is pressed INSIDE the submenu (that is where focus goes when it opens) and closes
// only that layer — Escape would close the whole stack, which is not what "back" means.
fireEvent.keyDown(item("Watch later"), { key: "ArrowLeft" });
expect(menuitems()).toEqual(["Play", "Move to"]);
fireEvent.keyDown(item("Move to"), { key: "ArrowRight" });
expect(menuitems()).toEqual(["Play", "Move to", "Watch later", "Homelab"]);
});
it("choosing inside a submenu closes the WHOLE stack, not just the submenu", async () => {
const chosen = vi.fn();
renderList(() => [
{ id: "play", label: "Play", onSelect: vi.fn() },
{ id: "move", label: "Move to", items: [{ id: "t1", label: "Homelab", onSelect: chosen }] },
]);
fireEvent.contextMenu(row("alpha"));
fireEvent.click(item("Move to"));
fireEvent.click(item("Homelab"));
expect(menuitems()).toEqual([]);
await vi.waitFor(() => expect(chosen).toHaveBeenCalledTimes(1));
});
});
it("a disabled item is inert and never fires", () => {
const onSelect = vi.fn();
renderList(() => [{ id: "a", label: "Move to", disabled: true, onSelect }]);
fireEvent.contextMenu(row("alpha"));
const disabled = item("Move to");
expect(disabled.disabled).toBe(true);
fireEvent.click(disabled);
expect(onSelect).not.toHaveBeenCalled();
});
});