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