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.
248 lines
11 KiB
TypeScript
248 lines
11 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// Contract of the reusable ⋯ overflow menu. Labels/handlers are plain props (no i18n), so we assert
|
|
// the open/select/close + disabled-item behaviour. The menu content only mounts while open, and the
|
|
// AnchoredPopover platform is floating-ui based, so we stub the two observers autoUpdate wires and
|
|
// wrap in a FloatingTree (its real-app parent) so nested-layer dispatch has a node to register.
|
|
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
|
|
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
|
import { FloatingTree } from "@floating-ui/react";
|
|
import ActionMenu, { type ActionMenuItem } from "./ActionMenu";
|
|
|
|
beforeAll(() => {
|
|
// floating-ui's autoUpdate observes with ResizeObserver + IntersectionObserver (layoutShift); jsdom
|
|
// ships neither, and the floating element mounts (so they run) the moment the menu opens.
|
|
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);
|
|
|
|
const renderMenu = (items: ActionMenuItem[]) =>
|
|
render(
|
|
<FloatingTree>
|
|
<ActionMenu ariaLabel="Catalog actions" items={items} />
|
|
</FloatingTree>,
|
|
);
|
|
|
|
describe("ActionMenu", () => {
|
|
it("renders a labelled trigger and keeps items unmounted until opened", () => {
|
|
renderMenu([{ id: "sync", label: "Sync", onSelect: vi.fn() }]);
|
|
expect(screen.getByRole("button", { name: "Catalog actions" })).toBeTruthy();
|
|
expect(screen.queryByRole("menuitem")).toBeNull();
|
|
});
|
|
|
|
// floating-ui never computes a position in jsdom, so the surface stays visibility:hidden; the items
|
|
// are in the DOM but the a11y tree treats them as hidden — hence `{ hidden: true }` on role queries.
|
|
it("says under its label why a disabled row is off, and what an enabled one opens stands at", () => {
|
|
// A `title` on a disabled row reached nobody: a disabled button fires no pointer events and
|
|
// roving focus skips it. The reason is visible text now, undimmed so it can be read. An enabled
|
|
// row's state ("Downloaded" under "Download…") is the same line: a `title` shows to a resting
|
|
// mouse only, never to a key or a finger.
|
|
renderMenu([
|
|
{ id: "move", label: "Move to", onSelect: vi.fn(), disabled: true, note: "No other list" },
|
|
{ id: "dl", label: "Download…", onSelect: vi.fn(), note: "Downloaded" },
|
|
{ id: "sync", label: "Sync", onSelect: vi.fn() },
|
|
]);
|
|
fireEvent.click(screen.getByRole("button", { name: "Catalog actions" }));
|
|
const [move, dl, sync] = screen.getAllByRole("menuitem", { hidden: true });
|
|
expect(move!.textContent).toBe("Move toNo other list");
|
|
expect(dl!.textContent).toBe("Download…Downloaded");
|
|
expect(sync!.textContent).toBe("Sync");
|
|
const note = screen.getByText("No other list");
|
|
expect(note.closest(".opacity-50")).toBeNull();
|
|
expect(screen.getByText("Move to").className).toContain("opacity-50");
|
|
});
|
|
|
|
it("opens on click and shows one menuitem per item", () => {
|
|
renderMenu([
|
|
{ id: "sync", label: "Sync", onSelect: vi.fn() },
|
|
{ id: "backfill", label: "Backfill", onSelect: vi.fn() },
|
|
]);
|
|
fireEvent.click(screen.getByRole("button", { name: "Catalog actions" }));
|
|
expect(screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent)).toEqual([
|
|
"Sync",
|
|
"Backfill",
|
|
]);
|
|
});
|
|
|
|
// Close first, act on a later task: floating-ui hands the focus back to the trigger from a microtask
|
|
// as the menu unmounts, and an action run in that same batch (a confirm dialog) had its focus taken
|
|
// back to the trigger behind the dialog's scrim — measured in Chrome. jsdom does not run
|
|
// that focus race, so the ORDER is what is pinned: the menu is gone before the action runs.
|
|
it("selecting an item closes the menu first, then fires its onSelect once", async () => {
|
|
const onSelect = vi.fn();
|
|
renderMenu([{ id: "sync", label: "Sync", onSelect }]);
|
|
fireEvent.click(screen.getByRole("button", { name: "Catalog actions" }));
|
|
fireEvent.click(screen.getByText("Sync"));
|
|
expect(screen.queryByRole("menuitem", { hidden: true })).toBeNull();
|
|
expect(onSelect).not.toHaveBeenCalled();
|
|
await vi.waitFor(() => expect(onSelect).toHaveBeenCalledTimes(1));
|
|
});
|
|
|
|
it("a 'many' check row toggles in place: the menu stays open and the focus stays on it", async () => {
|
|
const onSelect = vi.fn();
|
|
renderMenu([
|
|
{ id: "rust", label: "rust", check: { kind: "many", checked: true }, onSelect },
|
|
{ id: "go", label: "go", check: { kind: "many", checked: false }, onSelect: vi.fn() },
|
|
]);
|
|
fireEvent.click(screen.getByRole("button", { name: "Catalog actions" }));
|
|
const rows = screen.getAllByRole("menuitemcheckbox", { hidden: true });
|
|
expect(rows.map((r) => r.getAttribute("aria-checked"))).toEqual(["true", "false"]);
|
|
fireEvent.click(rows[0]!);
|
|
// Synchronously — no close, no deferred act — and nothing closed around it.
|
|
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
expect(screen.getAllByRole("menuitemcheckbox", { hidden: true })).toHaveLength(2);
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
expect(screen.getAllByRole("menuitemcheckbox", { hidden: true })).toHaveLength(2);
|
|
});
|
|
|
|
it("a 'one' check row is a radio that picks and closes like any row", async () => {
|
|
const onSelect = vi.fn();
|
|
renderMenu([
|
|
{ id: "10", label: "10", check: { kind: "one", checked: false }, onSelect },
|
|
{ id: "5", label: "5", check: { kind: "one", checked: true }, onSelect: vi.fn() },
|
|
]);
|
|
fireEvent.click(screen.getByRole("button", { name: "Catalog actions" }));
|
|
const rows = screen.getAllByRole("menuitemradio", { hidden: true });
|
|
expect(rows.map((r) => r.getAttribute("aria-checked"))).toEqual(["false", "true"]);
|
|
fireEvent.click(rows[0]!);
|
|
expect(screen.queryByRole("menuitemradio", { hidden: true })).toBeNull();
|
|
await vi.waitFor(() => expect(onSelect).toHaveBeenCalledTimes(1));
|
|
});
|
|
|
|
it("a disabled item is inert — rendered disabled and never fires onSelect", () => {
|
|
const onSelect = vi.fn();
|
|
renderMenu([{ id: "sync", label: "Sync", onSelect, disabled: true }]);
|
|
fireEvent.click(screen.getByRole("button", { name: "Catalog actions" }));
|
|
const item = screen.getByText("Sync").closest("button")!;
|
|
expect((item as HTMLButtonElement).disabled).toBe(true);
|
|
fireEvent.click(item);
|
|
expect(onSelect).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("skips a disabled item in roving keyboard nav", () => {
|
|
// items[0] disabled, items[1] + items[2] enabled. Roving keyboard focus (the `tabindex=0` item,
|
|
// which tracks useListNavigation's activeIndex) must NEVER land on the disabled index-0 — arrowing
|
|
// skips straight over it. The skip comes for free: PopoverItem renders a native `disabled` button
|
|
// and useListNavigation reads that DOM state (no explicit disabledIndices needed). Without it,
|
|
// ArrowUp/Down would strand roving focus on the inert index-0 button. (floating-ui wires this on
|
|
// real key nav, not on open, in jsdom — so we drive it with arrow keys.)
|
|
renderMenu([
|
|
{ id: "sync", label: "Sync", onSelect: vi.fn(), disabled: true },
|
|
{ id: "backfill", label: "Backfill", onSelect: vi.fn() },
|
|
{ id: "reset", label: "Reset", onSelect: vi.fn() },
|
|
]);
|
|
fireEvent.click(screen.getByRole("button", { name: "Catalog actions" }));
|
|
const [disabled, backfill, reset] = screen.getAllByRole("menuitem", { hidden: true }) as [
|
|
HTMLElement,
|
|
HTMLElement,
|
|
HTMLElement,
|
|
];
|
|
const menu = screen.getByRole("menu", { hidden: true });
|
|
const rover = () =>
|
|
[disabled, backfill, reset].find((el) => el.getAttribute("tabindex") === "0")?.textContent;
|
|
|
|
// The disabled item never becomes the roving target, whichever way we arrow.
|
|
fireEvent.keyDown(menu, { key: "ArrowDown" }); // skips disabled[0] -> first enabled
|
|
expect(rover()).toBe("Backfill");
|
|
expect(disabled.getAttribute("tabindex")).toBe("-1");
|
|
|
|
fireEvent.keyDown(menu, { key: "ArrowDown" });
|
|
expect(rover()).toBe("Reset");
|
|
|
|
fireEvent.keyDown(menu, { key: "ArrowUp" }); // back to Backfill
|
|
expect(rover()).toBe("Backfill");
|
|
fireEvent.keyDown(menu, { key: "ArrowUp" }); // wraps over disabled[0] to the last item
|
|
expect(rover()).toBe("Reset");
|
|
expect(disabled.getAttribute("tabindex")).toBe("-1"); // still never the disabled item
|
|
});
|
|
|
|
it("honours a custom trigger node", () => {
|
|
renderMenu([{ id: "sync", label: "Sync", onSelect: vi.fn() }]);
|
|
cleanup();
|
|
render(
|
|
<FloatingTree>
|
|
<ActionMenu
|
|
ariaLabel="More"
|
|
items={[{ id: "x", label: "X", onSelect: vi.fn() }]}
|
|
trigger={<span data-testid="custom-trigger">•••</span>}
|
|
/>
|
|
</FloatingTree>,
|
|
);
|
|
expect(screen.getByTestId("custom-trigger")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
// PageUp/PageDown in a roving menu (owner request) — the same page as the palette's and the
|
|
// combobox's (lib/listPaging); here the keyboard's row holds real DOM focus (the `tabindex=0` rover).
|
|
describe("ActionMenu · paging", () => {
|
|
it("moves the roving focus a page at a time, over disabled rows, stopping at the ends", () => {
|
|
renderMenu(
|
|
Array.from({ length: 25 }, (_, i) => ({
|
|
id: `r${i}`,
|
|
label: `Row ${i}`,
|
|
onSelect: vi.fn(),
|
|
disabled: i === 20,
|
|
})),
|
|
);
|
|
fireEvent.click(screen.getByRole("button", { name: "Catalog actions" }));
|
|
const menu = screen.getByRole("menu", { hidden: true });
|
|
const rover = () =>
|
|
screen
|
|
.getAllByRole("menuitem", { hidden: true })
|
|
.find((el) => el.getAttribute("tabindex") === "0")?.textContent;
|
|
|
|
expect(fireEvent.keyDown(menu, { key: "PageDown" })).toBe(false);
|
|
expect(rover()).toBe("Row 10");
|
|
fireEvent.keyDown(menu, { key: "PageDown" });
|
|
expect(rover()).toBe("Row 21"); // Row 20 is disabled
|
|
fireEvent.keyDown(menu, { key: "PageDown" });
|
|
expect(rover()).toBe("Row 24");
|
|
fireEvent.keyDown(menu, { key: "PageUp" });
|
|
expect(rover()).toBe("Row 14");
|
|
});
|
|
|
|
it("pages only the submenu the key was pressed in, not the menu around it", () => {
|
|
// A submenu's surface sits inside its parent's in the React tree, so the key bubbles through the
|
|
// parent's handler too; paging both would drag the focus back out to the parent's row.
|
|
renderMenu([
|
|
{
|
|
id: "move",
|
|
label: "Move to",
|
|
items: Array.from({ length: 25 }, (_, i) => ({
|
|
id: `s${i}`,
|
|
label: `Sub ${i}`,
|
|
onSelect: vi.fn(),
|
|
})),
|
|
},
|
|
...Array.from({ length: 12 }, (_, i) => ({
|
|
id: `p${i}`,
|
|
label: `Top ${i}`,
|
|
onSelect: vi.fn(),
|
|
})),
|
|
]);
|
|
fireEvent.click(screen.getByRole("button", { name: "Catalog actions" }));
|
|
fireEvent.click(screen.getByText("Move to"));
|
|
const rovers = () =>
|
|
screen
|
|
.getAllByRole("menuitem", { hidden: true })
|
|
.filter((el) => el.getAttribute("tabindex") === "0")
|
|
.map((el) => el.textContent ?? "");
|
|
const outer = () => rovers().filter((label) => !label.startsWith("Sub"));
|
|
const before = outer();
|
|
|
|
fireEvent.keyDown(screen.getByText("Sub 0").closest("button")!, { key: "PageDown" });
|
|
expect(rovers()).toContain("Sub 10");
|
|
expect(outer()).toEqual(before);
|
|
});
|
|
});
|