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.
130 lines
4.6 KiB
TypeScript
130 lines
4.6 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// Contract of the shared horizontal rail. jsdom lays out nothing (scrollWidth/clientWidth are 0), so
|
|
// the overflow-driven scroll buttons only appear once we feed the scroller fake metrics and fire a
|
|
// scroll — which is exactly the affordance under test: a button shows ONLY on the clipped edge, and
|
|
// clicking it pages the scroller. i18n is key-passthrough, so asserted names are the i18n keys.
|
|
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
|
import { act, render, screen, fireEvent, cleanup } from "@testing-library/react";
|
|
|
|
vi.mock("react-i18next", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
|
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
|
|
});
|
|
|
|
import { Carousel } from "./Carousel";
|
|
import { navItemAttrs } from "../../lib/listNav";
|
|
|
|
beforeAll(() => {
|
|
// useHScroll wires a ResizeObserver (watch-the-children); jsdom ships none.
|
|
class Stub {
|
|
observe() {}
|
|
unobserve() {}
|
|
disconnect() {}
|
|
takeRecords() {
|
|
return [];
|
|
}
|
|
}
|
|
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
|
});
|
|
|
|
afterEach(cleanup);
|
|
|
|
function setMetrics(
|
|
el: HTMLElement,
|
|
m: { scrollWidth: number; clientWidth: number; scrollLeft: number },
|
|
) {
|
|
Object.defineProperty(el, "scrollWidth", { configurable: true, value: m.scrollWidth });
|
|
Object.defineProperty(el, "clientWidth", { configurable: true, value: m.clientWidth });
|
|
Object.defineProperty(el, "scrollLeft", {
|
|
configurable: true,
|
|
writable: true,
|
|
value: m.scrollLeft,
|
|
});
|
|
}
|
|
|
|
describe("Carousel", () => {
|
|
it("renders children and an optional header with a see-all action", () => {
|
|
const onSeeAll = vi.fn();
|
|
render(
|
|
<Carousel title="Recently added" onSeeAll={onSeeAll}>
|
|
<div>card-a</div>
|
|
<div>card-b</div>
|
|
</Carousel>,
|
|
);
|
|
expect(screen.getByText("card-a")).toBeTruthy();
|
|
expect(screen.getByRole("heading", { name: "Recently added" })).toBeTruthy();
|
|
fireEvent.click(screen.getByRole("button", { name: "common.seeAll" }));
|
|
expect(onSeeAll).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("omits the header when no title is given and names the scroll region", () => {
|
|
render(
|
|
<Carousel ariaLabel="Cast">
|
|
<div>x</div>
|
|
</Carousel>,
|
|
);
|
|
expect(screen.queryByRole("heading")).toBeNull();
|
|
expect(screen.getByRole("group", { name: "Cast" })).toBeTruthy();
|
|
});
|
|
|
|
it("shows only the clipped edge's button and pages the scroller on click", () => {
|
|
render(
|
|
<Carousel ariaLabel="rail">
|
|
<div>x</div>
|
|
</Carousel>,
|
|
);
|
|
const scroller = screen.getByRole("group", { name: "rail" });
|
|
const scrollBy = vi.fn();
|
|
scroller.scrollBy = scrollBy as unknown as typeof scroller.scrollBy;
|
|
|
|
// At the left boundary with more content to the right → only the right button.
|
|
setMetrics(scroller, { scrollWidth: 1000, clientWidth: 300, scrollLeft: 0 });
|
|
fireEvent.scroll(scroller);
|
|
expect(screen.queryByRole("button", { name: "common.scrollLeft" })).toBeNull();
|
|
fireEvent.click(screen.getByRole("button", { name: "common.scrollRight" }));
|
|
expect(scrollBy).toHaveBeenCalled();
|
|
|
|
// Scrolled into the middle → both edges clipped → both buttons.
|
|
setMetrics(scroller, { scrollWidth: 1000, clientWidth: 300, scrollLeft: 350 });
|
|
fireEvent.scroll(scroller);
|
|
expect(screen.getByRole("button", { name: "common.scrollLeft" })).toBeTruthy();
|
|
expect(screen.getByRole("button", { name: "common.scrollRight" })).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
// A rail on Home, Discover or an info page is a list the keyboard walks — but only when the
|
|
// caller says its cards carry `navItemAttrs`. A carousel of plain content must not claim `j`.
|
|
describe("Carousel · keyboard", () => {
|
|
const cards = () =>
|
|
[0, 1, 2].map((i) => (
|
|
<button key={i} {...navItemAttrs(i)}>
|
|
{`card ${i}`}
|
|
</button>
|
|
));
|
|
const press = (key: string) =>
|
|
act(() => {
|
|
(document.activeElement ?? document.body).dispatchEvent(
|
|
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
|
|
);
|
|
});
|
|
|
|
it("is walked by j when keyboardNav is on", () => {
|
|
render(
|
|
<Carousel ariaLabel="Rail" keyboardNav>
|
|
{cards()}
|
|
</Carousel>,
|
|
);
|
|
press("j");
|
|
expect(document.activeElement?.textContent).toBe("card 0");
|
|
press("j");
|
|
expect(document.activeElement?.textContent).toBe("card 1");
|
|
});
|
|
|
|
it("leaves j alone without it", () => {
|
|
render(<Carousel ariaLabel="Rail">{cards()}</Carousel>);
|
|
press("j");
|
|
expect(document.activeElement).toBe(document.body);
|
|
});
|
|
});
|