diff --git a/src/__tests__/empty-state-ratchet.test.ts b/src/__tests__/empty-state-ratchet.test.ts new file mode 100644 index 000000000..ec19f87db --- /dev/null +++ b/src/__tests__/empty-state-ratchet.test.ts @@ -0,0 +1,107 @@ +/** + * An honest no-data state goes through `EmptyState` (or `ChartEmptyState` on + * a chart body), UI standards §6. + * + * Seventy-five surfaces said "nothing here yet" in a hand-written muted + * paragraph, in five sizes and three alignments, some centred in a padded + * box, some a bare line under the header. This is a ratchet, not a ban: the + * hand-written copies that remain are counted, the ceiling below may only go + * down, and a new copy fails the run. The ones that stay are not list or + * section empty states but a field that has no value ("not recorded" in a + * profile row), where a centred block would be the wrong shape. + * + * Matched: a `

`, `

` or `` whose class list carries + * `text-muted-foreground` and whose only child is `{t("")}` with a last + * key segment that names an empty state (`empty`, `none`, `noData`, + * `noResults`, `notRecorded`, …; a `…Hint` key is a hint, not a state). + * + * Not matched (stated, not hidden): a message assembled from several nodes, + * a key built at runtime, a state without the muted colour. The guard holds + * the idiom, not every way to say nothing. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(__dirname, ".."); + +/** + * The hand-written empty states that remain. Lower it when a site moves to + * `EmptyState`; never raise it. + */ +const CEILING = 75; + +const ELEMENT_RE = + /<(p|div|span)\b([^>]*?)>\s*\{t\(\s*"([a-zA-Z0-9_.]+)"[^)]*\)\}\s*<\/\1>/g; +const EMPTY_KEY_RE = + /(?:^|\.)[A-Za-z0-9]*?(?:empty|Empty|none|None|noData|NoData|noResults|NoResults|notRecorded|NotRecorded|noSeries|NoSeries|noReminder|NoReminder|noMedications|noSymptoms|noTimes)[A-Za-z0-9]*$/; + +function findHandWrittenEmptyStates( + files: { path: string; source: string }[], +): string[] { + const hits: string[] = []; + for (const { path, source } of files) { + for (const match of source.matchAll(ELEMENT_RE)) { + const [, , attrs, key] = match; + if (!attrs.includes("text-muted-foreground")) continue; + if (!EMPTY_KEY_RE.test(key) || key.endsWith("Hint")) continue; + hits.push(`${path}: ${key}`); + } + } + return hits; +} + +describe("empty states go through EmptyState (ratchet)", () => { + const files = walkSourceFiles(SRC, { floor: 2500 }) + .filter( + (p) => + p.endsWith(".tsx") && + (p.startsWith("components/") || p.startsWith("app/")) && + !p.startsWith("components/ui/") && + !p.includes("__tests__/"), + ) + .map((path) => ({ path, source: readFileSync(join(SRC, path), "utf8") })); + + it("walks the UI tree", () => { + expect(files.length).toBeGreaterThan(800); + }); + + it("adds no hand-written empty state", () => { + const hits = findHandWrittenEmptyStates(files); + expect( + hits.length, + `${hits.length} hand-written empty states, ceiling ${CEILING}. Render the new one through (or ChartEmptyState on a chart body).\n${hits.join("\n")}`, + ).toBeLessThanOrEqual(CEILING); + }); + + it("has its ceiling lowered when a site moves over", () => { + const hits = findHandWrittenEmptyStates(files); + expect( + hits.length, + `Only ${hits.length} hand-written empty states remain: lower CEILING to ${hits.length}.`, + ).toBe(CEILING); + }); + + it("recognises the idiom it exists to count", () => { + expect( + findHandWrittenEmptyStates([ + { + path: "components/a.tsx", + source: + '

{t("admin.aiQuality.empty")}

', + }, + { + path: "components/b.tsx", + source: + '

{t("cycle.sheet.emptyHint")}

', + }, + { + path: "components/c.tsx", + source: '', + }, + ]), + ).toEqual(["components/a.tsx: admin.aiQuality.empty"]); + }); +});