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.
324 lines
13 KiB
TypeScript
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();
|
|
});
|
|
});
|