Files
siftlode/frontend/src/i18n/backendIds.contract.test.ts
T
peter cf0c55ba80 fix(a11y): names that carry what they show; the unused-key check stops covering whole files
- The palette launcher's name replaced its visible "Jump to…" (and the key cap's its "Ctrl K"):
  a voice-control "click Jump to" matched nothing (WCAG 2.5.3). Each name contains its text now.
- SegmentedToggle's `title` was only a tooltip on a non-focusable box — the live search's "how
  many per batch" hint reached no keyboard or screen-reader user. It is the group's accessible
  description, standalone and captioned alike.
- The unused-key contract let a template like `card.${status}` cover its whole namespace, which hid
  five dead keys (deleted). Dynamic keys are matched by shape (`editor.*.hint`); a bare namespace
  shape covers nothing, and the audit/quota namespaces are now held both ways by backendIds.
- CLAUDE.md's "FE control" row matches the lint rule it cites; each no-restricted-syntax entry has
  its reason directly above it.
2026-09-19 19:19:43 +02:00

254 lines
11 KiB
TypeScript

import { readdirSync, readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
// Every id the BACKEND owns and the UI names must have its name in both languages. The UI looks these
// up with the raw id as the fallback — `t(\`config.fields.${key}.label\`, key)` — so a missing key never
// fails loudly: the admin Configuration page showed `maintenance`, `tmdb`, `tmdb_api_key`,
// `plex_overlay_map`… as bare identifiers, for months, and the i18n parity test stayed green because EN
// and HU were missing the SAME keys (owner report, E11 S5). The ids live in Python, so the check reads
// the registries straight from the backend sources:
// - `sysconfig.SPECS` → `config.groups.<group>`, `config.fields.<key>.label` + `.hint`, and for a
// "choice" key `config.choices.<key>.<option>` — the options read from its Literal in config.py
// - `scheduler.JOB_INTERVALS` → `scheduler.jobs.<id>` + `scheduler.jobDesc.<id>`
// - the `audit` action enum → `auditActions.<id>`; the `quota` action enum → `quotaActions.<id>`
// and the other way round: a name left behind for an id the backend dropped is dead weight that reads
// as a live setting to the next person (`plex_sync_interval_min` moved to the scheduler long ago).
// Progress phases are not covered: jobs pass most of them as variables, not literals a scan can see.
const backend = (file: string) =>
readFileSync(new URL(`../../../backend/app/${file}`, import.meta.url), "utf-8");
const mods = import.meta.glob("./locales/*/*.json", { eager: true }) as Record<
string,
{ default: Record<string, unknown> }
>;
function flatten(obj: Record<string, unknown>, prefix: string, out: Set<string>): Set<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.add(key);
}
return out;
}
const keysOf = (lang: string): Set<string> => {
const out = new Set<string>();
for (const [path, mod] of Object.entries(mods)) {
const m = path.match(/\/locales\/([^/]+)\/([^/]+)\.json$/);
if (m?.[1] === lang) flatten(mod.default, m[2]!, out);
}
return out;
};
const specs = [
...backend("sysconfig.py").matchAll(/ConfigSpec\(\s*"(\w+)",\s*"(\w+)",\s*"(\w+)",\s*"(\w+)"/g),
].map((m) => ({ key: m[1]!, type: m[2]!, group: m[3]!, attr: m[4]! }));
// A choice key's options, as config.py spells them: `<attr>: <Alias> = …` → `<Alias> = Literal[…]`.
const configPy = backend("config.py");
const choicesOf = (attr: string): string[] => {
const alias = configPy.match(new RegExp(`^\\s+${attr}: (\\w+) =`, "m"))?.[1];
const body = alias && configPy.match(new RegExp(`^${alias} = Literal\\[([^\\]]*)\\]`, "m"))?.[1];
return [...(body ?? "").matchAll(/"(\w+)"/g)].map((m) => m[1]!);
};
const choiceIds = specs
.filter((s) => s.type === "choice")
.flatMap((s) => choicesOf(s.attr).map((c) => `config.choices.${s.key}.${c}`));
const jobs = [
...(backend("scheduler.py").split("JOB_INTERVALS")[1] ?? "")
.split("}")[0]!
.matchAll(/"(\w+)": settings\./g),
].map((m) => m[1]!);
const enumValues = (file: string) =>
[...backend(file).matchAll(/^\s+[A-Z_]+ = "(\w+)"$/gm)].map((m) => m[1]!);
const auditActions = enumValues("audit.py");
const quotaActions = enumValues("quota.py");
// The admin actions' refusal reasons → `errors.admin.<reason>`. The codes are read from
// `localizeDetail`'s own ADMIN_REFUSALS, so a code added there is scanned here too; a detail dict
// ruff wraps over several lines still matches.
const adminCodes = [
...(
readFileSync(new URL("../lib/api/core.ts", import.meta.url), "utf-8").match(
/ADMIN_REFUSALS = new Set\(\[([^\]]*)\]/,
)?.[1] ?? ""
).matchAll(/"(\w+)"/g),
].map((m) => m[1]!);
const adminRefusals = [
...backend("routes/admin.py").matchAll(/\{\s*"code":\s*"(\w+)",\s*"reason":\s*"(\w+)"/g),
].filter((m) => adminCodes.includes(m[1]!));
const adminReasons = [...new Set(adminRefusals.map((m) => m[2]!))];
// The download failure catalog's host-specific entries → `downloads.errors.fail.catalog.<detail>`.
// A code holds a dot (`facebook.login_wall`), so the reverse check strips the prefix and the part
// rather than splitting on dots.
const failDetails = [
...new Set([...backend("downloads/errors.py").matchAll(/detail="([\w.]+)"/g)].map((m) => m[1]!)),
];
const DETAIL = "downloads.errors.fail.catalog.";
// What a structured 404 says is gone → `errors.notFound.<what>`: every `not_found("x")` and every
// `owned_or_404(..., what="x")` / `get_or_404(..., what="x")` under routes/, read from the sources.
const routeSources = (dir = "routes"): string[] =>
readdirSync(new URL(`../../../backend/app/${dir}/`, import.meta.url), {
withFileTypes: true,
}).flatMap((e) =>
e.isDirectory()
? routeSources(`${dir}/${e.name}`)
: e.name.endsWith(".py")
? [backend(`${dir}/${e.name}`)]
: [],
);
const goneWhats = [
...new Set(
routeSources().flatMap((src) =>
[...src.matchAll(/not_found\("(\w+)"\)|what="(\w+)"/g)].map((m) => (m[1] ?? m[2])!),
),
),
];
// Every generic refusal → `errors.refused.<reason>`: each `refused(<status>, "<reason>")` anywhere in
// the backend (not `_refused`, a router's own family), plus the values of the settings map, which
// `setting_refused` picks from at runtime.
const appSources = (dir = ""): string[] =>
readdirSync(new URL(`../../../backend/app/${dir}`, import.meta.url), {
withFileTypes: true,
}).flatMap((e) =>
e.isDirectory()
? appSources(`${dir}${e.name}/`)
: e.name.endsWith(".py")
? [backend(`${dir}${e.name}`)]
: [],
);
const refusedReasons = [
...new Set([
...appSources().flatMap((src) =>
[...src.matchAll(/(?<![\w.])refused\(\s*\d+,\s*"(\w+)"/g)].map((m) => m[1]!),
),
...[
...(backend("refusal.py").split("_SETTING_REASONS = {")[1]?.split("}")[0] ?? "").matchAll(
/:\s*"(\w+)"/g,
),
].map((m) => m[1]!),
]),
];
// A refused YouTube action → `errors.youtube.<reason>`, the reasons read from their Literal.
const youtubeReasons = [
...(
backend("youtube/client.py").match(/^YouTubeRefusal = Literal\[([^\]]*)\]/m)?.[1] ?? ""
).matchAll(/"(\w+)"/g),
].map((m) => m[1]!);
/** The ids under `prefix` a locale names, `prefix.<id>` or `prefix.<id>.<anything>`. */
const idsUnder = (keys: Set<string>, prefix: string) =>
new Set(
[...keys]
.filter((k) => k.startsWith(`${prefix}.`))
.map((k) => k.split(".")[prefix.split(".").length]!),
);
describe("backend ids have their names in both languages", () => {
it("read the registries (a scan that matches nothing is green over nothing)", () => {
expect(specs.map((s) => s.key)).toEqual(expect.arrayContaining(["smtp_host", "tmdb_language"]));
// Every choice key resolved its options (a Literal the scan can't read would check nothing).
for (const s of specs.filter((x) => x.type === "choice"))
expect(choicesOf(s.attr).length, s.key).toBeGreaterThan(1);
expect(choiceIds).toContain("config.choices.log_level.TRACE");
expect(jobs).toEqual(expect.arrayContaining(["rss_poll", "audit_gc"]));
expect(auditActions).toEqual(expect.arrayContaining(["config_set", "audit_clear"]));
expect(quotaActions.length).toBeGreaterThan(0);
expect(adminCodes).toEqual(expect.arrayContaining(["activate", "setpw"]));
expect(failDetails).toEqual(expect.arrayContaining(["facebook.login_wall"]));
expect(goneWhats).toEqual(expect.arrayContaining(["playlist", "download", "job"]));
expect(youtubeReasons).toEqual(["quota", "reconnect", "unavailable"]);
expect(refusedReasons).toEqual(
expect.arrayContaining(["weak_password", "setting_too_low", "plex_write_failed"]),
);
// Every code the frontend localizes is raised somewhere the scan can see.
expect(adminCodes.filter((c) => !adminRefusals.some((m) => m[1] === c))).toEqual([]);
});
for (const lang of ["en", "hu"]) {
const keys = keysOf(lang);
const missing = (wanted: string[]) => wanted.filter((k) => !keys.has(k));
it(`${lang}: every config key and group`, () => {
expect(
missing([
...new Set(specs.map((s) => `config.groups.${s.group}`)),
...specs.flatMap((s) => [`config.fields.${s.key}.label`, `config.fields.${s.key}.hint`]),
]),
).toEqual([]);
const known = new Set(specs.map((s) => s.key));
expect([...idsUnder(keys, "config.fields")].filter((k) => !known.has(k))).toEqual([]);
const groups = new Set(specs.map((s) => s.group));
expect([...idsUnder(keys, "config.groups")].filter((g) => !groups.has(g))).toEqual([]);
});
it(`${lang}: every option of every choice key, and none for an option that is gone`, () => {
expect(missing(choiceIds)).toEqual([]);
expect(
[...keys].filter((k) => k.startsWith("config.choices.") && !choiceIds.includes(k)),
).toEqual([]);
});
it(`${lang}: every scheduler job`, () => {
expect(
missing(jobs.flatMap((j) => [`scheduler.jobs.${j}`, `scheduler.jobDesc.${j}`])),
).toEqual([]);
for (const prefix of ["scheduler.jobs", "scheduler.jobDesc"])
expect([...idsUnder(keys, prefix)].filter((j) => !jobs.includes(j))).toEqual([]);
});
it(`${lang}: every audit and quota action`, () => {
expect(auditActions.filter((a) => !idsUnder(keys, "auditActions").has(a))).toEqual([]);
expect(quotaActions.filter((a) => !idsUnder(keys, "quotaActions").has(a))).toEqual([]);
// …and no name for an action the backend no longer has (the unused-key contract leaves
// these two namespaces to this check).
expect([...idsUnder(keys, "auditActions")].filter((a) => !auditActions.includes(a))).toEqual(
[],
);
expect([...idsUnder(keys, "quotaActions")].filter((a) => !quotaActions.includes(a))).toEqual(
[],
);
});
it(`${lang}: every download failure-catalog entry`, () => {
expect(
missing(failDetails.flatMap((d) => [`${DETAIL}${d}.short`, `${DETAIL}${d}.body`])),
).toEqual([]);
const named = new Set(
[...keys]
.filter((k) => k.startsWith(DETAIL))
.map((k) => k.slice(DETAIL.length).replace(/\.(short|body)$/, "")),
);
expect([...named].filter((d) => !failDetails.includes(d))).toEqual([]);
});
it(`${lang}: every "that is gone" a 404 names, and every YouTube refusal`, () => {
expect(missing(goneWhats.map((w) => `errors.notFound.${w}`))).toEqual([]);
expect(
[...idsUnder(keys, "errors.notFound")].filter(
(w) => w !== "generic" && !goneWhats.includes(w),
),
).toEqual([]);
expect(missing(youtubeReasons.map((r) => `errors.youtube.${r}`))).toEqual([]);
expect(
[...idsUnder(keys, "errors.youtube")].filter((r) => !youtubeReasons.includes(r)),
).toEqual([]);
});
it(`${lang}: every generic refusal the backend can answer, and none it cannot`, () => {
expect(missing(refusedReasons.map((r) => `errors.refused.${r}`))).toEqual([]);
expect(
[...idsUnder(keys, "errors.refused")].filter((r) => !refusedReasons.includes(r)),
).toEqual([]);
});
it(`${lang}: every admin refusal reason`, () => {
expect(missing(adminReasons.map((r) => `errors.admin.${r}`))).toEqual([]);
expect([...idsUnder(keys, "errors.admin")].filter((r) => !adminReasons.includes(r))).toEqual(
[],
);
});
}
});