Files
siftlode/frontend/src/components/ui/DataTable.keyboard.dom.test.tsx
T
peter 4aaaaa7860 chore(comments): drop epic, sprint and review-round ids from the code
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.
2026-10-05 00:19:39 +02:00

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");
});
});