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.
156 lines
5.9 KiB
TypeScript
156 lines
5.9 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// The card shows its state and the everyday toggles (watched, save, add to playlist) and a
|
|
// "…" into the list's ONE menu; download, details, hide and reset live only in that menu. Every view
|
|
// carries the same set, and a view that shows the "…" twice (the card: in the hover row on the
|
|
// picture, and beside the title for touch — only one is displayed per device) points both at the
|
|
// same menu.
|
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { cleanup, render, screen } from "@testing-library/react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
|
|
vi.mock("react-i18next", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("react-i18next")>()),
|
|
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
|
}));
|
|
vi.mock("./navigationContext", () => ({
|
|
useNavigationActions: () => ({ openChannel: vi.fn() }),
|
|
}));
|
|
|
|
import VideoCard from "./VideoCard";
|
|
import type { Video } from "../lib/api";
|
|
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
|
|
|
|
beforeAll(() => {
|
|
class Stub {
|
|
observe() {}
|
|
unobserve() {}
|
|
disconnect() {}
|
|
takeRecords() {
|
|
return [];
|
|
}
|
|
}
|
|
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
|
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
|
});
|
|
beforeEach(cleanup);
|
|
|
|
const video = {
|
|
id: "abc123",
|
|
title: "Cicada 3301",
|
|
channel_id: "c1",
|
|
channel_title: "LEMMiNO",
|
|
status: "new",
|
|
saved: false,
|
|
position_seconds: 40,
|
|
duration_seconds: 1200,
|
|
published_at: "2026-01-01T00:00:00Z",
|
|
watch_url: "https://www.youtube.com/watch?v=abc123",
|
|
thumbnail_url: "",
|
|
} as unknown as Video;
|
|
|
|
function renderCard(view: FeedView, menuOpen = false) {
|
|
const buttonProps = vi.fn(() => ({ "aria-haspopup": "menu" }));
|
|
render(
|
|
<QueryClientProvider client={new QueryClient()}>
|
|
<VideoCard
|
|
video={video}
|
|
view={view}
|
|
width={0}
|
|
onState={() => {}}
|
|
onToggleSave={() => {}}
|
|
onOpen={() => {}}
|
|
menuButtonProps={buttonProps}
|
|
menuOpen={menuOpen}
|
|
/>
|
|
</QueryClientProvider>,
|
|
);
|
|
return { buttonProps };
|
|
}
|
|
|
|
const names = () => screen.getAllByRole("button").map((b) => b.getAttribute("aria-label"));
|
|
|
|
describe.each(Object.keys(FEED_VIEW_SPEC) as FeedView[])("the %s view's actions", (view) => {
|
|
it("are the toggles and the '…' — nothing that belongs to the menu", () => {
|
|
renderCard(view);
|
|
const all = names();
|
|
for (const want of ["card.markWatched", "card.saveForLater", "playlists.addToPlaylist"])
|
|
expect(all).toContain(want);
|
|
expect(all).toContain("card.menu.more");
|
|
for (const gone of ["downloads.button.label", "card.hide", "card.resetState", "card.info"])
|
|
expect(all).not.toContain(gone);
|
|
});
|
|
|
|
it("points every '…' at the menu, and says when it is open", () => {
|
|
const { buttonProps } = renderCard(view, true);
|
|
const mores = screen.getAllByRole("button", { name: "card.menu.more" });
|
|
expect(buttonProps).toHaveBeenCalledWith(video);
|
|
for (const m of mores) {
|
|
expect(m.getAttribute("aria-haspopup")).toBe("menu");
|
|
expect(m.getAttribute("aria-expanded")).toBe("true");
|
|
}
|
|
});
|
|
});
|
|
|
|
// The owner's pick (2026-09-26): the compact row had a leading action column that sat
|
|
// empty at rest. Now the title leads, the "…" is always at the row's end, and on hover the quick
|
|
// toggles float in beside it over the date, which steps aside.
|
|
describe("the compact row (rowsCompact)", () => {
|
|
it("leads with the title and ends with the '…'; the toggles reveal beside it over a yielding date", () => {
|
|
renderCard("rowsCompact");
|
|
const row = screen.getByTestId("video-card");
|
|
expect(row.firstElementChild!.textContent).toBe("Cicada 3301");
|
|
const more = screen.getByRole("button", { name: "card.menu.more" });
|
|
// the layer that floats left of the "…" (right-full) and shows on hover only
|
|
const toggles = screen
|
|
.getByRole("button", { name: "card.markWatched" })
|
|
.closest<HTMLElement>(".right-full");
|
|
expect(toggles!.className).toContain("reveal-hover");
|
|
// the toggles and the "…" share the row's last slot (the resume bar is drawn after it)
|
|
const slot = more.parentElement!;
|
|
expect(slot.contains(toggles)).toBe(true);
|
|
const inFlow = [...row.children].filter((c) => !c.className.includes("absolute"));
|
|
expect(inFlow[inFlow.length - 1]).toBe(slot);
|
|
expect(row.querySelector(".w-48")!.className).toContain("group-hover:opacity-0");
|
|
});
|
|
});
|
|
|
|
// On a shelf (Home's strips) the card is one title line over the channel alone, so every card on
|
|
// the strip is the same height (the owner's shelf density).
|
|
describe("the card on a shelf", () => {
|
|
function renderSmall(shelf: boolean) {
|
|
render(
|
|
<QueryClientProvider client={new QueryClient()}>
|
|
<VideoCard
|
|
video={video}
|
|
view="cardsSmall"
|
|
width={0}
|
|
shelf={shelf}
|
|
onState={() => {}}
|
|
onToggleSave={() => {}}
|
|
/>
|
|
</QueryClientProvider>,
|
|
);
|
|
return screen.getByTestId("video-card");
|
|
}
|
|
const titleOf = (card: HTMLElement) =>
|
|
[...card.querySelectorAll("*")].find(
|
|
(e) => e.childElementCount === 0 && e.textContent === "Cicada 3301",
|
|
)!;
|
|
|
|
it("cuts the title to one line and drops the views · age line", () => {
|
|
const card = renderSmall(true);
|
|
const title = titleOf(card).closest(".font-medium")!;
|
|
expect(title.className).toContain("truncate");
|
|
expect(title.className).not.toContain("line-clamp-2");
|
|
expect(card.querySelector("time[title]")).toBeNull();
|
|
expect(screen.getByRole("link", { name: "LEMMiNO" })).toBeTruthy();
|
|
});
|
|
|
|
it("off a shelf keeps two title lines and the age", () => {
|
|
const card = renderSmall(false);
|
|
expect(titleOf(card).closest(".font-medium")!.className).toContain("line-clamp-2");
|
|
expect(card.querySelector("time[title]")).not.toBeNull();
|
|
});
|
|
});
|