test(ui): sharpen the page-title and empty-state guards

The H1 guard matched the raw text, so seven files sat on its allowlist
only for mentioning an H1 in a comment; it strips comments first now and
the entries are gone. The empty-state ratchet counted against a ceiling,
which let a new copy in wherever an old one left, and missed the no...Yet
keys: it freezes the remaining sites by file and key, recognises no...Yet
and nothing... keys, and the two it missed (the insight status card and
the screener card) render EmptyState.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 16:45:11 +02:00
1 parent 7ed953a029
commit c8649faddf
4 files changed
+68 -44

No files matched your search

+41 -23
View File
@@ -5,15 +5,17 @@
* 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.
* hand-written copies that remain are listed by file and key, a new one
* fails the run, and a site that moves over leaves the list. 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. A count would let a new copy in wherever an old one left.
*
* Matched: a `<p>`, `<div>` or `<span>` whose class list carries
* `text-muted-foreground` and whose only child is `{t("<key>")}` with a last
* key segment that names an empty state (`empty`, `none`, `noData`,
* `noResults`, `notRecorded`, …; a `…Hint` key is a hint, not a state).
* `noResults`, `notRecorded`, `no…Yet`, `nothing…`; 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
@@ -28,15 +30,31 @@ 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.
* The hand-written empty states that remain, each a field-level line rather
* than a list or section state. Remove an entry when its site moves to
* `EmptyState`; never add one.
*/
const CEILING = 13;
const REMAINING = [
"components/cycle/cycle-phase-crosstab.tsx: cycle.insights.headline.empty",
"components/documents/episode-documents-card.tsx: documents.episodeCard.emptyLine",
"components/illness/episode-visits-card.tsx: encounters.episodeCard.emptyLine",
"components/insights/workout-detail/day-context.tsx: insights.workouts.detail.dayMoodNone",
"components/insights/workout-detail/day-context.tsx: insights.workouts.detail.daySleepNone",
"components/labs/biomarker-aliases.tsx: labs.aliases.empty",
"components/medications/dose-history-ledger.tsx: medications.detail.verlauf.summaryEmpty",
"components/medications/scheduling/dose-window-editor.tsx: medications.detail.zeitplan.window.noTimes",
"components/medications/sections/composition-section.tsx: medications.composition.editorEmpty",
"components/records/screening-hints-card.tsx: records.life.screening.noReminder",
"components/settings/ai/fallback-chain-card.tsx: settings.ai.providerChain.addNoneAvailable",
"components/settings/integrations/apple-health-card.tsx: settings.appleHealth.delivery.noSyncYet",
"components/settings/report-selection/scope-summary.tsx: reportSelection.scopeNone",
"components/timeline/selection-bar.tsx: timeline.selection.empty",
];
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]*$/;
/(?:^|\.)[A-Za-z0-9]*?(?:empty|Empty|none|None|noData|NoData|noResults|NoResults|notRecorded|NotRecorded|noSeries|NoSeries|noReminder|NoReminder|noMedications|noSymptoms|noTimes|nothing|Nothing)[A-Za-z0-9]*$|(?:^|\.)no[A-Z][A-Za-z0-9]*Yet$/;
function findHandWrittenEmptyStates(
files: { path: string; source: string }[],
@@ -68,20 +86,12 @@ describe("empty states go through EmptyState (ratchet)", () => {
expect(files.length).toBeGreaterThan(800);
});
it("adds no hand-written empty state", () => {
const hits = findHandWrittenEmptyStates(files);
it("adds no hand-written empty state, and lists none that moved over", () => {
const hits = findHandWrittenEmptyStates(files).sort();
expect(
hits.length,
`${hits.length} hand-written empty states, ceiling ${CEILING}. Render the new one through <EmptyState variant="plain" size="compact"> (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);
hits,
'A new hand-written empty state: render it through <EmptyState variant="plain" size="compact"> (or ChartEmptyState on a chart body). A site that moved over: remove its entry.',
).toEqual([...REMAINING].sort());
});
it("recognises the idiom it exists to count", () => {
@@ -101,7 +111,15 @@ describe("empty states go through EmptyState (ratchet)", () => {
path: "components/c.tsx",
source: '<EmptyState title={t("labs.detail.emptyTitle")} />',
},
{
path: "components/d.tsx",
source:
'<p className="text-muted-foreground text-sm">{t("insights.noAnalysisYet")}</p>',
},
]),
).toEqual(["components/a.tsx: admin.aiQuality.empty"]);
).toEqual([
"components/a.tsx: admin.aiQuality.empty",
"components/d.tsx: insights.noAnalysisYet",
]);
});
});
+14 -15
View File
@@ -10,15 +10,15 @@
* keeps a new `<h1>` literal from appearing outside the places where a
* different heading is the point.
*
* Matched: an `<h1` tag in JSX under `src/components` and `src/app`. A
* mention in a comment counts too, which is why the files that only talk
* about an H1 are listed with that reason.
* Matched: an `<h1` tag in JSX under `src/components` and `src/app`, with
* comments stripped first, so a file that only talks about an H1 needs no
* entry and every entry below is a heading that is actually rendered.
*/
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { walkSourceFiles } from "./helpers/source-files";
import { stripComments, walkSourceFiles } from "./helpers/source-files";
const SRC = join(__dirname, "..");
@@ -48,14 +48,6 @@ const ALLOWED: Record<string, string> = {
// Prose pages (the prose H1 tier, §5).
"app/about/page.tsx": "prose page",
"app/privacy/page.tsx": "prose page",
// Mention an H1 in a comment only.
"app/insights/ecg/page.tsx": "comment",
"components/admin/reminders-section.tsx": "comment",
"components/charts/health-chart.tsx": "comment",
"components/insights/coach-panel/coach-top-bar-trail.tsx": "comment",
"components/insights/ecg-section.tsx": "comment",
"components/measurement-reminders/vorsorge-section.tsx": "comment",
"components/settings/settings-shell.tsx": "comment",
};
describe("page titles go through PageHeader", () => {
@@ -65,9 +57,8 @@ describe("page titles go through PageHeader", () => {
(p.startsWith("components/") || p.startsWith("app/")) &&
!p.includes("__tests__/"),
);
const withH1 = files.filter((p) =>
readFileSync(join(SRC, p), "utf8").includes("<h1"),
);
const hasH1 = (source: string) => /<h1[\s>]/.test(stripComments(source));
const withH1 = files.filter((p) => hasH1(readFileSync(join(SRC, p), "utf8")));
it("finds the H1 sites at all", () => {
expect(withH1.length).toBeGreaterThan(10);
@@ -77,6 +68,14 @@ describe("page titles go through PageHeader", () => {
expect(withH1.filter((p) => !(p in ALLOWED))).toEqual([]);
});
it("counter-check: an H1 in code counts, one in a comment does not", () => {
expect(hasH1(`return <h1 className="text-2xl">Title</h1>;`)).toBe(true);
expect(hasH1(`// the page's <h1> lives in PageHeader\nreturn null;`)).toBe(
false,
);
expect(hasH1(`/* an <h1> above */ return <h2>x</h2>;`)).toBe(false);
});
it("keeps every allowlist entry live", () => {
const set = new Set(withH1);
for (const key of Object.keys(ALLOWED))
@@ -2,6 +2,7 @@
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { EmptyState } from "@/components/ui/empty-state";
import { TileHeader } from "@/components/insights/tile-header";
import {
useTranslations,
@@ -228,9 +229,11 @@ export function InsightStatusCard({
<TileHeader icon={nodeIcon(icon)} title={title} />
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-sm">
{t("insights.noAnalysisYet")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("insights.noAnalysisYet")}
/>
</CardContent>
</Card>
);
@@ -30,6 +30,7 @@
* sibling of the detail-open button (never nested inside it) so the two
* actions never collide.
*/
import { EmptyState } from "@/components/ui/empty-state";
import { Info } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -170,9 +171,12 @@ export function InstrumentCard({
</div>
</div>
) : (
<div className="text-muted-foreground flex min-h-[2.75rem] items-center px-4 text-sm md:px-6">
{t("mentalHealth.noResultYet")}
</div>
<EmptyState
variant="plain"
size="compact"
className="px-4 md:px-6"
title={t("mentalHealth.noResultYet")}
/>
)}
{/* Bottom-pinned single primary action — the med-/Vorsorge card slot.