import { describe, expect, it } from "vitest"; import { readSources } from "../lib/rawSources.testkit"; // Every rail and every card of a card list is walkable by the keyboard (lib/listNav). Two // opt-in halves, both checked here, because either one missing silently leaves the keyboard out — // and a page where `j` walks two rails and skips the third reads as broken, not as unfinished: // - a `Carousel`, a `VirtualGrid` and a `DataTable` say `keyboardNav`, and a card says `navIndex`; // - a card that says `navIndex` sits in a list that registers with listNav — a `NavGrid`, a // `Carousel`, or a `VirtualGrid` (its `renderItem`). In a plain `
` the card // is marked as an item of a list nobody walks. // Each exception is listed with its reason. // // The assertion pins the SET of offending tags, never a count, and checks the glob matched at all: a // guard whose glob stops matching is green over nothing. const WALKABLE = ["Carousel", "VirtualGrid", "DataTable"]; const CARDS = [ "VideoCard", "PlexPosterCard", "SeasonCard", "EpisodeCard", "PosterTile", "ShelfTile", "DownloadRow", ]; const LISTS = ["NavGrid", "Carousel", "VirtualGrid"]; /** `path#tag#n` → why it is not a keyboard list. An entry that no longer matches a tag fails the test. */ const EXEMPT: Record = { "components/AuditLog.tsx#DataTable#1": "an admin log read in place: its rows open nothing, so stepping onto them would only move a ring", }; interface Tag { start: number; end: number; text: string; } const stripComments = (source: string) => source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:"'`])\/\/.*$/gm, "$1"); /** Each opening `` tag, found by brace depth — a JSX attribute holds its own `>` * (`title={…}`, an arrow `=>`), so the first `>` is not the end of the tag. Takes * comment-free code, so the positions line up with `inList`. */ export function openingTags(code: string, name: string): Tag[] { const tags: Tag[] = []; const start = new RegExp(`<${name}(?=[\\s/>])`, "g"); for (let m = start.exec(code); m; m = start.exec(code)) { let depth = 0; let quote: string | null = null; let i = m.index + name.length + 1; for (; i < code.length; i++) { const c = code[i]; if (quote) { if (c === quote) quote = null; } else if (depth === 0 && (c === '"' || c === "'")) quote = c; else if (c === "{") depth++; else if (c === "}") depth--; else if (c === ">" && depth === 0) break; } tags.push({ start: m.index, end: i + 1, text: code.slice(m.index, i + 1) }); } return tags; } /** Whether `pos` lies inside an open list element: between `` and ``, or inside a * list's own tag (a `renderItem={…}` attribute). */ export function inList(code: string, pos: number): boolean { return LISTS.some((name) => { let open = 0; for (const tag of openingTags(code, name)) { if (tag.start > pos) break; if (tag.end > pos) return true; if (!tag.text.endsWith("/>")) open++; } return open > code.slice(0, pos).split(``).length - 1; }); } const has = (tag: Tag, prop: string) => new RegExp(`\\s${prop}\\b`).test(tag.text); function offenders(): { hits: string[]; tags: number; exempted: Set } { const files = readSources(__dirname, "./**/*.tsx"); const hits: string[] = []; const exempted = new Set(); let tags = 0; for (const [rel, source] of Object.entries(files)) { const path = rel.replace(/^\.\//, "components/"); if (/\.test\.tsx$/.test(path)) continue; const code = stripComments(String(source)); const report = (id: string, why: string) => { if (id in EXEMPT) exempted.add(id); else hits.push(`${id} ${why}`); }; for (const name of WALKABLE) { openingTags(code, name).forEach((tag, n) => { tags++; if (!has(tag, "keyboardNav")) report(`${path}#${name}#${n + 1}`, "no keyboardNav"); }); } for (const name of CARDS) { openingTags(code, name).forEach((tag, n) => { tags++; const id = `${path}#${name}#${n + 1}`; if (!has(tag, "navIndex")) report(id, "no navIndex"); else if (!inList(code, tag.start)) report(id, "not inside a NavGrid/Carousel/VirtualGrid"); }); } } return { hits: hits.sort(), tags, exempted }; } describe("the keyboard-list wiring contract", () => { it("has no rail or card list the keyboard cannot walk", () => { const { hits, tags, exempted } = offenders(); expect(tags).toBeGreaterThan(15); expect(hits).toEqual([]); expect(Object.keys(EXEMPT).filter((id) => !exempted.has(id))).toEqual([]); }); it("reads a tag to its real end, past the > inside its attributes", () => { const code = stripComments(` {title}} onSeeAll={() => go(1 > 0)} keyboardNav > // in a comment is no tag `); const tags = openingTags(code, "Carousel"); expect(tags).toHaveLength(2); expect(has(tags[0]!, "keyboardNav")).toBe(true); expect(has(tags[1]!, "keyboardNav")).toBe(false); }); it("tells a card inside a list from one in a plain grid", () => { const code = ` {a.map((c, i) => )}
{a.map((c, i) => )}
} /> `; const at = (n: number) => openingTags(code, "PosterTile")[n]!.start; expect(inList(code, at(0))).toBe(true); expect(inList(code, at(1))).toBe(false); expect(inList(code, openingTags(code, "VideoCard")[0]!.start)).toBe(true); }); });