A refusal code or reason with no sentence fell through to the generic "couldn't carry out that action" unnoticed: the parity test is green when EN and HU miss the same key. One did (an edit refused for a file that is not video or audio). - lib/api/refusalCodes.ts: which sentence answers each code, as data; localizeDetail says a refusal through it, unchanged for every reader. - backendIds contract: every detail code the backend sends is in exactly one record, every reason it can carry has its sentence in EN and HU, and no family names a reason the backend cannot send. - The reasons that travel in a variable are Literal types (edit, move, cookies, quota), so the type checker refuses an unknown one too. - downloads.errors.edit.not_media in EN and HU.
112 lines
4.9 KiB
TypeScript
112 lines
4.9 KiB
TypeScript
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import { REFUSAL_FAMILIES, type RefusalFamily } from "../lib/api/refusalCodes";
|
|
|
|
// Every locale key is used by the app. The parity test proves EN and HU agree; nothing proved either
|
|
// one is still READ — 75 keys had outlived the code that showed them (a whole sidebar sort vocabulary,
|
|
// the old login page, a pager the DataTable replaced), each a string a translator would keep in step
|
|
// for nobody, and each one making "is this key live?" a grep instead of a fact.
|
|
//
|
|
// A key counts as used when the source (tests excluded — a test naming a key does not show it to
|
|
// anyone) holds it as a whole string literal, or matches the shape of a DYNAMIC key — a template
|
|
// or concatenation that opens with a key head, each interpolation any segment: `plex.sort.${k}`
|
|
// covers `plex.sort.*`, `editor.${m}.hint` only `editor.*.hint`. A shape that is nothing but the
|
|
// namespace and one interpolation (`card.${status}`) would cover the whole file — it hid five dead
|
|
// keys — so it covers nothing; a key it reads that nothing else names is listed below. Plural forms
|
|
// count through their base key. A key read in a way this cannot see goes in ALLOWED with the reason,
|
|
// rather than the check being loosened for everyone.
|
|
const ALLOWED: Record<string, string> = {};
|
|
// Namespaces that are one backend enum each, and held key-for-value to it by backendIds.contract.
|
|
const BACKEND_ENUMS = ["auditActions.", "quotaActions."];
|
|
|
|
const SRC = fileURLToPath(new URL("../", import.meta.url));
|
|
|
|
function flatten(obj: Record<string, unknown>, prefix: string, out: string[]): string[] {
|
|
for (const [k, v] of Object.entries(obj)) {
|
|
const key = `${prefix}.${k}`;
|
|
if (v && typeof v === "object" && !Array.isArray(v))
|
|
flatten(v as Record<string, unknown>, key, out);
|
|
else out.push(key);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function sources(dir: string, out: string[] = []): string[] {
|
|
for (const f of readdirSync(dir)) {
|
|
const p = join(dir, f);
|
|
if (statSync(p).isDirectory()) sources(p, out);
|
|
else if (/\.(ts|tsx)$/.test(f) && !/\.test\./.test(f)) out.push(readFileSync(p, "utf-8"));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const locales = join(SRC, "i18n/locales/en");
|
|
const keys = readdirSync(locales).flatMap((f) =>
|
|
flatten(JSON.parse(readFileSync(join(locales, f), "utf-8")), f.replace(/\.json$/, ""), []),
|
|
);
|
|
const code = sources(SRC).join("\n");
|
|
const literals = new Set([...code.matchAll(/["'`]([\w.-]+)["'`]/g)].map((m) => m[1]!));
|
|
// Each dynamic key as a SHAPE, every interpolation a `*`: `plex.sort.*`, `editor.*.hint`,
|
|
// `config.choices.*.*` — from a template literal that opens with a key head, or a head concatenated.
|
|
// A refusal's sentence is built from its record (`lib/api/refusalCodes`) rather than a template the
|
|
// scan could read, so each family's prefix is a shape of its own.
|
|
const shapes = [
|
|
...[...code.matchAll(/`([\w.]+\.(?:\$\{[^}`]*\}|[\w.-])*)`/g)].map((m) => m[1]!),
|
|
...[...code.matchAll(/["'`]([\w.]+\.)["'`]\s*\+/g)].map((m) => `${m[1]!}\${}`),
|
|
...Object.values(REFUSAL_FAMILIES).map(
|
|
(f: RefusalFamily) => `${f.prefix}.\${reason}${f.suffix ?? ""}`,
|
|
),
|
|
]
|
|
.filter((s) => s.includes("${"))
|
|
.map((s) => s.replace(/\$\{[^}]*\}/g, "*"));
|
|
// A shape that is one namespace and one `*` covers nothing (see above).
|
|
const namespaceWide = (shape: string) => /^[\w-]+\.\*$/.test(shape);
|
|
const patterns = shapes
|
|
.filter((s) => !namespaceWide(s))
|
|
.map(
|
|
(s) =>
|
|
new RegExp(
|
|
`^${s
|
|
.split("*")
|
|
.map((part) => part.replace(/[.$]/g, "\\$&"))
|
|
.join("[\\w.-]+")}$`,
|
|
),
|
|
);
|
|
const base = (k: string) => k.replace(/_(zero|one|two|few|many|other)$/, "");
|
|
const used = (k: string) =>
|
|
literals.has(k) ||
|
|
literals.has(base(k)) ||
|
|
patterns.some((p) => p.test(k) || p.test(base(k))) ||
|
|
BACKEND_ENUMS.some((ns) => k.startsWith(ns));
|
|
|
|
describe("i18n keys", () => {
|
|
it("finds keys and code to check them against", () => {
|
|
expect(keys.length).toBeGreaterThan(1000);
|
|
expect(literals.has("home.hero.reroll")).toBe(true);
|
|
expect(used("plex.sort.newest")).toBe(true); // `plex.sort.${k}`
|
|
expect(shapes).toContain("editor.*.hint"); // `editor.${m}.hint`
|
|
expect(used("editor.accurate.hint")).toBe(true);
|
|
expect(used("editor.accurate.nope")).toBe(false);
|
|
// A bare namespace head covers nothing — the hole that hid five dead keys.
|
|
expect(shapes).toContain("card.*");
|
|
expect(used("card.anythingAtAll")).toBe(false);
|
|
});
|
|
|
|
it("are all read somewhere in the app", () => {
|
|
expect(keys.filter((k) => !used(k) && !(k in ALLOWED))).toEqual([]);
|
|
});
|
|
|
|
it("allow only keys that exist and the scan really cannot see", () => {
|
|
for (const k of Object.keys(ALLOWED)) {
|
|
expect({ k, exists: keys.includes(k), scanSees: used(k) }).toEqual({
|
|
k,
|
|
exists: true,
|
|
scanSees: false,
|
|
});
|
|
}
|
|
});
|
|
});
|