test/scripts/i18n-coverage.test.ts
Rama main · réplica de lectura del repositorio original.
import { expect } from "@std/expect";
import { describe, it as test } from "@std/testing/bdd";
import {
EXTRA_SCAN_FILES,
flashLiterals,
isTsModule,
LEFTOVER_ALLOWLIST,
leftoverLiterals,
relFromSrc,
T_CALL,
TEMPLATES_DIR,
} from "#scripts/check-i18n/rules.ts";
import { allEnglishMessages } from "#test-utils/i18n.ts";
import { walkSourceFiles as walk } from "#test-utils/walk-src.ts";
/**
* Codebase-level i18n coverage, verified in both directions:
* forward — every t("key") reference in the source resolves to a real
* locale key (no typos / dangling references);
* backward — no user-facing string is left hard-coded in a scanned source
* file (everything goes through t()), except a budget of strings
* still pending wiring, recorded per file in LEFTOVER_ALLOWLIST.
*
* The per-string detection lives in scripts/check-i18n/rules.ts, which the
* grader's i18n_catalog check shares. This file owns the tree-wide scans:
* the forward key resolution, the reviewed prose that
* must stay catalog-backed, the backward budget, and the allowlist ratchet.
*
* The backward scan covers JSX templates (.tsx) AND the .ts modules that hold
* field/copy definitions (e.g. fields/*.ts, email/defaults.ts) plus the shared
* form framework — places where hard-coded labels used to slip through because
* the scan only looked at .tsx text/attributes.
*
* LEFTOVER_ALLOWLIST is a ratchet, not a free pass: it records the EXACT number
* of hard-coded strings each unfinished file still has. The backward test fails
* if a file grows past its number (so a migrated file can never gain new
* hard-coded copy), and the stale test fails if a file drops below its number
* (lower it to lock in the progress) or reaches zero (remove the entry). The
* debt can therefore only shrink, and every change to it shows up as a diff
* here for review.
*/
const messages = await allEnglishMessages();
const SRC_DIR = "src";
/** The files the backward scan covers: every .ts/.tsx under templates plus the
* explicit extra copy-bearing modules. */
const scanTargets = (): string[] => [
...walk(TEMPLATES_DIR, [".ts", ".tsx"]),
...EXTRA_SCAN_FILES,
];
const missingMessageReferences = (file: string): string[] => {
const missing: string[] = [];
const src = Deno.readTextFileSync(file);
for (const match of src.matchAll(T_CALL)) {
const key = match[2]!;
if (key.includes("${") || key.includes("{")) continue;
if (!(key in messages)) missing.push(`${file}: t("${key}")`);
}
return missing;
};
describe("i18n coverage", () => {
test('forward: every t("key") in the source resolves to a locale key', () => {
const missing = walk(SRC_DIR, [".ts", ".tsx"]).flatMap(
missingMessageReferences,
);
expect(missing).toEqual([]);
});
test("reviewed template prose stays catalog-backed", () => {
const missing: string[] = [];
const REQUIRED_TEMPLATE_KEYS = new Map<string, readonly string[]>([
[
"src/ui/templates/setup.tsx",
[
"setup.agreement.controller_text",
"setup.agreement.processor_text",
"setup.agreement.encrypted_text",
"setup.agreement.responsibilities_text",
"setup.agreement.breach_text",
"setup.agreement.deletion_text",
"setup.agreement.password_warning",
],
],
["src/ui/templates/admin/guide.tsx", ["guide.search_hint"]],
]);
for (const [file, requiredKeys] of REQUIRED_TEMPLATE_KEYS) {
const referencedKeys = new Set(
Array.from(
Deno.readTextFileSync(file).matchAll(T_CALL),
(match) => match[2],
),
);
for (const key of requiredKeys) {
if (!referencedKeys.has(key)) missing.push(`${file}: t("${key}")`);
}
}
expect(missing).toEqual([]);
});
test("backward: no hard-coded user-facing strings beyond each file's budget", () => {
const offenders: string[] = [];
for (const file of scanTargets()) {
const rel = relFromSrc(file);
const allowed = LEFTOVER_ALLOWLIST.get(rel) ?? 0;
const hits = leftoverLiterals(
Deno.readTextFileSync(file),
isTsModule(file),
);
if (hits.length > allowed) {
offenders.push(
`${rel}: ${hits.length} hard-coded (budget ${allowed}) — wire with ` +
"t(), or bump its allowlist count if still mid-migration: " +
hits.slice(0, 3).join("; "),
);
}
}
expect(offenders).toEqual([]);
});
test("the leftover allowlist ratchets down (no stale or inflated entries)", () => {
const stale: string[] = [];
for (const [rel, allowed] of LEFTOVER_ALLOWLIST) {
const path = `${SRC_DIR}/${rel}`;
const src = (() => {
try {
return Deno.readTextFileSync(path);
} catch {
return null;
}
})();
if (src === null) {
stale.push(`${rel} (missing — remove from allowlist)`);
continue;
}
const count = leftoverLiterals(src, isTsModule(path)).length;
if (count === 0) stale.push(`${rel} (now clean — remove from allowlist)`);
else if (count < allowed) {
stale.push(`${rel} (down to ${count} — lower its allowlist count)`);
}
}
expect(stale).toEqual([]);
});
});
describe("flash messages", () => {
test("backward: no hard-coded flash messages under src/features/", () => {
const offenders = walk("src/features", [".ts", ".tsx"]).flatMap((file) =>
flashLiterals(Deno.readTextFileSync(file)),
);
expect(offenders).toEqual([]);
});
});