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.
166 lines
5.9 KiB
TypeScript
166 lines
5.9 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// DataTable's `keyboardNav`: the rows are a keyboard list — `j`/`k` walk them and Enter
|
|
// clicks the element a column marks `data-nav-open`. It used to render its rows twice (a table and,
|
|
// below `md`, a card list, one hidden by CSS), so `j` past the last row could carry on into the hidden
|
|
// copy. Now it mounts exactly one layout, chosen by the `md` media query.
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { act, cleanup, render } from "@testing-library/react";
|
|
import DataTable, { type Column } from "./DataTable";
|
|
|
|
vi.mock("react-i18next", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
|
return {
|
|
...actual,
|
|
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
|
};
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
type Row = { id: string };
|
|
const rows: Row[] = [{ id: "a" }, { id: "b" }];
|
|
|
|
function table(onOpen: (id: string) => void, keyboardNav = true) {
|
|
const columns: Column<Row>[] = [
|
|
{
|
|
key: "name",
|
|
header: "Name",
|
|
cardPrimary: true,
|
|
render: (r) => (
|
|
<button data-nav-open="" onClick={() => onOpen(r.id)}>
|
|
{r.id}
|
|
</button>
|
|
),
|
|
},
|
|
{ key: "act", header: "", render: (r) => <button>act {r.id}</button> },
|
|
];
|
|
return render(
|
|
<DataTable rows={rows} columns={columns} rowKey={(r) => r.id} keyboardNav={keyboardNav} />,
|
|
);
|
|
}
|
|
|
|
function press(key: string) {
|
|
act(() => {
|
|
(document.activeElement ?? document.body).dispatchEvent(
|
|
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
|
|
);
|
|
});
|
|
}
|
|
|
|
/** The viewport `md` question, answered as asked (jsdom has no matchMedia of its own). */
|
|
function viewport(wide: boolean) {
|
|
vi.stubGlobal("matchMedia", (query: string) => ({
|
|
matches: query === "(min-width: 48rem)" ? wide : false,
|
|
media: query,
|
|
addEventListener: () => {},
|
|
removeEventListener: () => {},
|
|
}));
|
|
}
|
|
|
|
/** The focused row: which copy (TR / DIV) and which row. */
|
|
const focused = () => {
|
|
const el = document.activeElement as HTMLElement;
|
|
return `${el.tagName} ${el.querySelector("[data-nav-open]")?.textContent ?? ""}`;
|
|
};
|
|
|
|
describe("DataTable · keyboardNav", () => {
|
|
it("walks the table's rows, stops at the last one, and opens a row on Enter", () => {
|
|
const opened: string[] = [];
|
|
viewport(true);
|
|
table((id) => opened.push(id));
|
|
press("j");
|
|
expect(focused()).toBe("TR a");
|
|
press("j");
|
|
expect(focused()).toBe("TR b");
|
|
// The last row is the last stop — there is no second copy after it to walk into.
|
|
press("j");
|
|
expect(focused()).toBe("TR b");
|
|
press("Enter");
|
|
expect(opened).toEqual(["b"]);
|
|
});
|
|
|
|
it("walks the card list when the viewport is narrow", () => {
|
|
viewport(false);
|
|
table(() => {});
|
|
press("j");
|
|
expect(focused()).toBe("DIV a");
|
|
});
|
|
|
|
it("mounts one layout, never both", () => {
|
|
viewport(true);
|
|
const wide = table(() => {});
|
|
expect(wide.container.querySelectorAll("[data-nav-index]")).toHaveLength(2);
|
|
expect(wide.container.querySelector("table")).not.toBeNull();
|
|
cleanup();
|
|
viewport(false);
|
|
const narrow = table(() => {});
|
|
expect(narrow.container.querySelectorAll("[data-nav-index]")).toHaveLength(2);
|
|
expect(narrow.container.querySelector("table")).toBeNull();
|
|
});
|
|
|
|
// `useListNav` reads its container when its `enabled` changes. With the flag left on across a
|
|
// breakpoint, the layout mounted by a resize was never registered — `j` did nothing — while the
|
|
// unmounted one stayed registered (deep review, debt burn).
|
|
it("keeps the keys working on whichever layout a resize mounts", () => {
|
|
let wide = true;
|
|
const listeners = new Set<() => void>();
|
|
vi.stubGlobal("matchMedia", (query: string) => ({
|
|
get matches() {
|
|
return query === "(min-width: 48rem)" ? wide : false;
|
|
},
|
|
media: query,
|
|
addEventListener: (_: string, l: () => void) => listeners.add(l),
|
|
removeEventListener: (_: string, l: () => void) => listeners.delete(l),
|
|
}));
|
|
const resize = (next: boolean) => {
|
|
wide = next;
|
|
act(() => listeners.forEach((l) => l()));
|
|
};
|
|
table(() => {});
|
|
resize(false);
|
|
expect(document.querySelectorAll("[data-nav-list]")).toHaveLength(1);
|
|
press("j");
|
|
expect(focused()).toBe("DIV a");
|
|
resize(true);
|
|
expect(document.querySelectorAll("[data-nav-list]")).toHaveLength(1);
|
|
(document.activeElement as HTMLElement).blur();
|
|
press("j");
|
|
expect(focused()).toBe("TR a");
|
|
});
|
|
|
|
it("leaves the rows out of the keyboard without keyboardNav", () => {
|
|
const { container } = table(() => {}, false);
|
|
expect(container.querySelector("[data-nav-index]")).toBeNull();
|
|
press("j");
|
|
expect(document.activeElement).toBe(document.body);
|
|
});
|
|
});
|
|
|
|
describe("DataTable · reveal", () => {
|
|
const many: Row[] = Array.from({ length: 25 }, (_, i) => ({ id: `r${i}` }));
|
|
const cols: Column<Row>[] = [
|
|
{ key: "name", header: "Name", cardPrimary: true, render: (r) => r.id },
|
|
];
|
|
const shown = () => [...document.querySelectorAll("tbody tr")].map((tr) => tr.textContent);
|
|
|
|
it("turns to the page that now holds a moved row on each new seq", () => {
|
|
viewport(true);
|
|
const view = (list: Row[], reveal: { key: string; seq: number } | null) => (
|
|
<DataTable rows={list} columns={cols} rowKey={(r) => r.id} reveal={reveal} />
|
|
);
|
|
const { rerender } = render(view(many, { key: "r22", seq: 0 }));
|
|
// The mount's seq is not a request: the first page stays.
|
|
expect(shown()[0]).toBe("r0");
|
|
rerender(view(many, { key: "r22", seq: 1 }));
|
|
expect(shown()).toContain("r22");
|
|
expect(shown()[0]).toBe("r20");
|
|
// r22 bubbled up to the top: the next seq follows it back to page one.
|
|
rerender(view([many[22]!, ...many.filter((r) => r.id !== "r22")], { key: "r22", seq: 2 }));
|
|
expect(shown()[0]).toBe("r22");
|
|
});
|
|
});
|