Files
HealthLog/src/components/settings/__tests__/card-description-length.test.ts
T
Marc-André Bombeck 23eae2823e refactor(i18n): shorten the five descriptions the gate could not see
The gate the diet shipped with matched `<SettingsCardHeader` in three
component folders. That is not where every card description lives, so five
real ones were outside its reach — including the full backup's, which the
sweep's own shortlist had named and which stayed at 351 German characters
across three sentences while the guard reported zero violations.

Two ways in were missing. A card header can sit in a folder the walk never
entered (`labs`), and a description can be FORWARDED to the header by a shell
— `ExportCardShell`, `ImportCardShell` — where the prop is on the shell's tag,
not the header's. The gate now matches both, over seven roots, and the
"finds what it is meant to check" floor moved with it.

`SubPageShell` is deliberately not included, with the reason written at the
list: its description renders as `text-foreground text-sm`, the body tier,
where the standards allow prose. Gating it would enforce a rule §3 does not
make for that tier.

The five, all six locales:

- full backup — the twelve-item enumeration was the description. It is now
  the card's opening body paragraph, where a list of what is in the file
  belongs; the description is one sentence.
- local OCR — the how-it-works sentence stays in the slot; "less accurate,
  review every value" is safety copy and reads foreground in the body.
- CSV import, dose-history import — second sentence to the body.
- the ECG page — the "does not interpret the waveform" claim gets its own
  paragraph rather than riding the page explainer.

Proven by reverting the full backup's text: the widened gate reports it as
both two sentences and over length, at the call site, in both languages. The
old gate could not see the key at all.
2026-08-07 23:20:12 +02:00

189 lines
6.8 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
/**
* The description slot holds ONE sentence, guideline ≤ 120 characters
* (design standards §3).
*
* Scope is deliberately the strings that actually reach a card header's
* description slot, whether the prop sits on the header or on a shell that
* forwards it. That is the slot the rule is about. A hint in a card BODY is
* prose and is allowed to be prose; `scripts/measure-description-lengths.mjs`
* sweeps those for review without gating them.
*
* Both EN and DE are checked. DE runs 15-25% longer than EN, and a rule that
* only holds in English produces a slot that wraps to two lines in half the
* locales — which is the same defect, moved.
*/
const ROOT = process.cwd();
const MAX_CHARS = 120;
/**
* Strings that stay longer than the guideline, each with the reason. The
* guideline is not a hard cut: a sentence that needs 130 characters to stay
* true is fine. Two sentences never are, and that half of the rule has no
* exceptions.
*/
const LENGTH_EXCEPTIONS: Record<string, string> = {
// Empty on purpose: the sweep that introduced this guard left nothing over
// the guideline in EN or DE. An entry here needs a sentence saying what the
// extra characters buy, and `carries no stale exception` deletes it again
// the moment the string fits or stops being a description.
};
function walk(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
if (entry === "__tests__" || entry === "node_modules") continue;
walk(full, out);
} else if (entry.endsWith(".tsx")) {
out.push(full);
}
}
return out;
}
/**
* Elements whose `description` prop reaches `SettingsCardHeader`'s slot.
*
* The first cut of this guard matched `<SettingsCardHeader` only, and only
* under three component folders. That left five real card descriptions
* invisible to it — one of them the 351-character, three-sentence full-backup
* text that the audit's own shortlist had named. Two ways in were missing:
* a card header rendered from a folder the walk never entered, and a
* description FORWARDED to the header by a shell (`ExportCardShell`,
* `ImportCardShell`), where the prop is on the shell's tag rather than the
* header's.
*
* `SubPageShell` is deliberately NOT here. Its description renders as
* `<p className="text-foreground text-sm">` — the body tier, where the
* standards allow prose. Gating it would enforce a rule §3 does not make for
* that tier.
*/
const DESCRIPTION_HOSTS = [
"SettingsCardHeader",
"ExportCardShell",
"ImportCardShell",
];
/** Component roots that can host a card header, directly or via a shell. */
const ROOTS = [
join("src", "components", "settings"),
join("src", "components", "admin"),
join("src", "components", "measurement-reminders"),
join("src", "components", "labs"),
join("src", "components", "insights"),
join("src", "components", "medications"),
join("src", "components", "records"),
];
/** Every `description={t("…")}` on an element that owns the header's slot. */
function describedKeys(): Map<string, string[]> {
const keys = new Map<string, string[]>();
for (const root of ROOTS) {
for (const file of walk(join(ROOT, root))) {
const source = readFileSync(file, "utf8");
const host = new RegExp(`<(?:${DESCRIPTION_HOSTS.join("|")})\\b`, "g");
let match: RegExpExecArray | null;
while ((match = host.exec(source))) {
// The element ends at the first `/>` or `>` that closes the opening
// tag; a `description={t("x")}` after that belongs to something else.
const selfClose = source.indexOf("/>", match.index);
const childrenOpen = source.indexOf("\n >", match.index);
const end = [selfClose, childrenOpen]
.filter((i) => i !== -1)
.sort((a, b) => a - b)[0];
const slice = source.slice(match.index, end ?? undefined);
const desc = /description=\{t\(\s*"([^"]+)"/.exec(slice);
if (!desc) continue;
const list = keys.get(desc[1]) ?? [];
list.push(file.slice(ROOT.length + 1));
keys.set(desc[1], list);
}
}
}
return keys;
}
function resolve(bundle: unknown, key: string): string | undefined {
const value = key
.split(".")
.reduce<unknown>(
(node, part) =>
node && typeof node === "object"
? (node as Record<string, unknown>)[part]
: undefined,
bundle,
);
return typeof value === "string" ? value : undefined;
}
/** Sentence terminators followed by a space or the end of the string. */
function sentenceCount(text: string): number {
const trimmed = text.trim();
if (!trimmed) return 0;
const matches = trimmed.match(/[.!?](\s+\S|$)/g);
return Math.max(1, matches ? matches.length : 1);
}
describe("card header descriptions — one sentence, ≤120 characters", () => {
const keys = describedKeys();
it("finds the description slots it is meant to check", () => {
// A matcher that quietly matches nothing passes every assertion below it.
expect(keys.size).toBeGreaterThan(35);
});
for (const locale of ["en", "de"] as const) {
const bundle = JSON.parse(
readFileSync(join(ROOT, "messages", `${locale}.json`), "utf8"),
);
it(`${locale}: every description is a single sentence`, () => {
const offenders: string[] = [];
for (const [key, files] of keys) {
const value = resolve(bundle, key);
if (value === undefined) continue; // the i18n coverage guard owns this
if (sentenceCount(value) > 1) {
offenders.push(
`${key} (${sentenceCount(value)} sentences) — ${files[0]}`,
);
}
}
expect(offenders).toEqual([]);
});
it(`${locale}: every description is within the length guideline`, () => {
const offenders: string[] = [];
for (const [key, files] of keys) {
const value = resolve(bundle, key);
if (value === undefined) continue;
if (value.length > MAX_CHARS && !(key in LENGTH_EXCEPTIONS)) {
offenders.push(`${key} (${value.length} chars) — ${files[0]}`);
}
}
expect(offenders).toEqual([]);
});
}
it("carries no stale exception", () => {
const bundle = JSON.parse(
readFileSync(join(ROOT, "messages", "en.json"), "utf8"),
);
for (const [key, reason] of Object.entries(LENGTH_EXCEPTIONS)) {
expect(
keys.has(key),
`${key} is allowlisted but no longer a description`,
).toBe(true);
expect(
resolve(bundle, key)!.length,
`${key} is allowlisted but now fits the guideline`,
).toBeGreaterThan(MAX_CHARS);
expect(reason.length).toBeGreaterThan(20);
}
});
});