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.
226 lines
7.7 KiB
TypeScript
226 lines
7.7 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// A Plex title's ONE action list. The poster's right-click and its "…" open the same menu,
|
|
// which holds what the card no longer shows (details, copy, the pin) beside the everyday toggles;
|
|
// the card's hover "add" and the menu's "Add to playlist" open the same dialog.
|
|
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 { PlexCard } 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: [] }),
|
|
plexPlaylists: () => Promise.resolve({ playlists: [] }),
|
|
},
|
|
};
|
|
});
|
|
const copyText = vi.fn((_: string) => Promise.resolve(true));
|
|
vi.mock("../lib/clipboard", () => ({ copyText: (s: string) => copyText(s) }));
|
|
|
|
import { PlexMenu } from "./PlexMenu";
|
|
import { PlexPosterCard } from "./plexCards";
|
|
import { useContextMenu } from "./ui/useContextMenu";
|
|
import { usePlexCardActions, type PlexActionHandlers } from "./usePlexCardActions";
|
|
|
|
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): matched on text.
|
|
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 card = (extra: Partial<PlexCard>): PlexCard => ({
|
|
id: "m1",
|
|
type: "movie",
|
|
title: "A film",
|
|
thumb: "",
|
|
status: "new",
|
|
...extra,
|
|
});
|
|
|
|
function Grid({
|
|
c,
|
|
handlers,
|
|
pin,
|
|
extra,
|
|
}: {
|
|
c: PlexCard;
|
|
handlers: PlexActionHandlers;
|
|
pin?: boolean;
|
|
extra?: Parameters<typeof PlexMenu>[0]["extra"];
|
|
}) {
|
|
const menu = useContextMenu<PlexCard>();
|
|
const actions = usePlexCardActions(handlers, menu.open);
|
|
return (
|
|
<>
|
|
<PlexPosterCard
|
|
card={c}
|
|
onOpen={() => handlers.onOpen(c)}
|
|
onToggleWatched={handlers.onToggleWatched}
|
|
onAdd={actions.add}
|
|
menuProps={menu.triggerProps}
|
|
menuButtonProps={menu.buttonProps}
|
|
menuOpen={menu.open && menu.target === c}
|
|
/>
|
|
<PlexMenu menu={menu} actions={actions} pin={pin} extra={extra} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
function setup(c: PlexCard, opts: Omit<Parameters<typeof Grid>[0], "c" | "handlers"> = {}) {
|
|
const handlers = {
|
|
onOpen: vi.fn(),
|
|
onInfo: vi.fn(),
|
|
onToggleWatched: vi.fn(),
|
|
onRestart: vi.fn(),
|
|
};
|
|
render(
|
|
<QueryClientProvider client={new QueryClient()}>
|
|
<FloatingTree>
|
|
<Grid c={c} handlers={handlers} {...opts} />
|
|
</FloatingTree>
|
|
</QueryClientProvider>,
|
|
);
|
|
return { handlers };
|
|
}
|
|
// The card root holds the menu's trigger props (right-click anywhere on it).
|
|
const rightClick = () =>
|
|
fireEvent.contextMenu(screen.getByText("A film", { selector: ".line-clamp-2" }));
|
|
const moreButtons = () => screen.getAllByRole("button", { name: "card.menu.more" });
|
|
|
|
describe("a Plex title's menu", () => {
|
|
it("lists a movie's whole action list, in its groups", () => {
|
|
setup(card({}));
|
|
rightClick();
|
|
expect(labels()).toEqual([
|
|
"plex.play",
|
|
"plex.markWatched",
|
|
"plex.playlistAdd.manage",
|
|
"plex.info.title",
|
|
"common.copy.title",
|
|
"home.pin.add",
|
|
]);
|
|
});
|
|
|
|
// C5: on touch the card's hover pair is gone, so the menu is where a started title starts over.
|
|
it("a started title continues from its Play row and starts over from the next one", async () => {
|
|
const { handlers } = setup(card({ status: "in_progress", position_seconds: 600 }));
|
|
rightClick();
|
|
expect(labels().slice(0, 2)).toEqual(["card.continue", "card.restartTitle"]);
|
|
fireEvent.click(item("card.restartTitle"));
|
|
// A row acts a task after the menu closes (closeThenAct).
|
|
await vi.waitFor(() =>
|
|
expect(handlers.onRestart).toHaveBeenCalledWith(expect.objectContaining({ id: "m1" })),
|
|
);
|
|
});
|
|
|
|
it("a watched title has no restart row: it plays from the start anyway", () => {
|
|
setup(card({ status: "watched", position_seconds: 600 }));
|
|
rightClick();
|
|
expect(labels()).not.toContain("card.restartTitle");
|
|
});
|
|
|
|
it("a show opens its page and has no watched, add or details rows", () => {
|
|
setup(card({ type: "show" }));
|
|
rightClick();
|
|
expect(labels()).toEqual(["plex.openShow", "common.copy.title", "home.pin.add"]);
|
|
});
|
|
|
|
it("the card's '…' opens the same menu, and says so", () => {
|
|
setup(card({}));
|
|
const hoverMore = moreButtons()[0]!;
|
|
expect(hoverMore.getAttribute("aria-expanded")).toBe("false");
|
|
fireEvent.click(hoverMore);
|
|
expect(labels()).toContain("plex.info.title");
|
|
expect(moreButtons()[0]!.getAttribute("aria-expanded")).toBe("true");
|
|
});
|
|
|
|
it("each row fires the handler the card's own controls do", async () => {
|
|
const { handlers } = setup(card({ status: "watched" }));
|
|
rightClick();
|
|
fireEvent.click(item("plex.markUnwatched"));
|
|
// A row acts a task after the menu closes (closeThenAct), so the assertions wait for it.
|
|
await vi.waitFor(() =>
|
|
expect(handlers.onToggleWatched).toHaveBeenCalledWith(expect.objectContaining({ id: "m1" })),
|
|
);
|
|
rightClick();
|
|
fireEvent.click(item("plex.info.title"));
|
|
await vi.waitFor(() =>
|
|
expect(handlers.onInfo).toHaveBeenCalledWith(expect.objectContaining({ id: "m1" })),
|
|
);
|
|
rightClick();
|
|
fireEvent.click(item("plex.play"));
|
|
await vi.waitFor(() =>
|
|
expect(handlers.onOpen).toHaveBeenCalledWith(expect.objectContaining({ id: "m1" })),
|
|
);
|
|
});
|
|
|
|
it("copies the title inside the click", () => {
|
|
setup(card({}));
|
|
rightClick();
|
|
fireEvent.click(item("common.copy.title"));
|
|
expect(copyText).toHaveBeenCalledWith("A film");
|
|
});
|
|
|
|
it("the hover 'add' and the menu row open the same playlist dialog", async () => {
|
|
setup(card({}));
|
|
fireEvent.click(screen.getByRole("button", { name: "plex.playlistAdd.manage" }));
|
|
expect(await screen.findByText("plex.playlistAdd.title")).toBeTruthy();
|
|
cleanup();
|
|
setup(card({}));
|
|
rightClick();
|
|
fireEvent.click(item("plex.playlistAdd.manage"));
|
|
expect(await screen.findByText("plex.playlistAdd.title")).toBeTruthy();
|
|
});
|
|
|
|
it("an episode list carries no pin, and a host's own rows come last", async () => {
|
|
const remove = vi.fn();
|
|
setup(card({ type: "episode" }), {
|
|
pin: false,
|
|
extra: (c) => [{ id: "x", label: "remove", danger: true, onSelect: () => remove(c.id) }],
|
|
});
|
|
rightClick();
|
|
expect(labels()).not.toContain("home.pin.add");
|
|
// The row names what it adds, as the episode card's own hover button does.
|
|
expect(labels()).toContain("plex.playlist.addEpisode");
|
|
expect(screen.getByRole("button", { name: "plex.playlist.addEpisode" })).toBeTruthy();
|
|
expect(labels()[labels().length - 1]).toBe("remove");
|
|
fireEvent.click(item("remove"));
|
|
await vi.waitFor(() => expect(remove).toHaveBeenCalledWith("m1"));
|
|
});
|
|
});
|