feat(i18n): every server refusal is said by a record the contract holds

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.
This commit is contained in:
2026-10-30 23:36:25 +01:00
parent b3be568e36
commit 4379f76747
14 changed files with 381 additions and 123 deletions
+109 -7
View File
@@ -2,6 +2,12 @@ import { readdirSync, readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { RUN_STATUSES } from "../lib/activityUrlState";
import {
REFUSAL_BY_REASON,
REFUSAL_FAMILIES,
REFUSAL_FIXED,
REFUSAL_UNSHOWN,
} from "../lib/api/refusalCodes";
import { vocabKey } from "../lib/vocab";
// Every id the BACKEND owns and the UI names must have its name in both languages. The UI looks these
@@ -87,13 +93,9 @@ 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/refusalText.ts", import.meta.url), "utf-8").match(
/ADMIN_REFUSALS = new Set\(\[([^\]]*)\]/,
)?.[1] ?? ""
).matchAll(/"(\w+)"/g),
].map((m) => m[1]!);
const adminCodes = Object.entries(REFUSAL_FAMILIES)
.filter(([, f]) => f.prefix === "errors.admin")
.map(([code]) => code);
const adminRefusals = [
...backend("routes/admin.py").matchAll(/\{\s*"code":\s*"(\w+)",\s*"reason":\s*"(\w+)"/g),
].filter((m) => adminCodes.includes(m[1]!));
@@ -214,6 +216,54 @@ const clipReasons = [
),
];
// Every refusal code the backend answers: each `"code": "<x>"` of a detail anywhere in the app. A
// subtitle's status payload (`{"state": …, "code": …}`) is the player's own vocabulary, not a refusal.
const backendCodes = [
...new Set(
appSources().flatMap((src) =>
src
.split("\n")
.filter((line) => !line.includes('"state":'))
.flatMap((line) => [...line.matchAll(/"code":\s*"(\w+)"/g)].map((m) => m[1]!)),
),
),
];
const literalOf = (file: string, name: string) =>
[
...(
backend(file).match(new RegExp(`^${name} = Literal\\[([^\\]]*)\\]`, "m"))?.[1] ?? ""
).matchAll(/"(\w+)"/g),
].map((m) => m[1]!);
const literalCalls = (file: string, re: RegExp) => [
...new Set([...backend(file).matchAll(re)].map((m) => m[1]!)),
];
const detailReasons = (file: string, code: string) =>
literalCalls(file, new RegExp(`"code":\\s*"${code}",\\s*"reason":\\s*"(\\w+)"`, "g"));
// What each refusal code can carry as its reason, read where it is decided: a Literal when the reason
// travels in a variable (`e.reason`), the call's own literal otherwise.
const reasonsOf: Record<string, string[]> = {
refused: refusedReasons,
youtube: youtubeReasons,
not_found: goneWhats,
clip: clipReasons,
...Object.fromEntries(adminCodes.map((c) => [c, detailReasons("routes/admin.py", c)])),
playlist: literalCalls("routes/playlists.py", /_refused\(\s*\d+,\s*"(\w+)"/g),
playlist_move: literalOf("reader/service.py", "MoveReason"),
edit: literalOf("downloads/service.py", "EditReason"),
cookies: literalOf("downloads/credentials.py", "CookieReason"),
url_blocked: literalCalls("downloads/urlguard.py", /^REASON_\w+ = "(\w+)"/gm),
login: literalCalls("routes/auth.py", /_login_refused\(\s*\d+,\s*"(\w+)"/g),
quota: literalOf("downloads/quota.py", "QuotaReason"),
reset: detailReasons("routes/auth.py", "reset"),
collection: detailReasons("routes/plex/collections.py", "collection"),
};
const recordedCodes = [
...Object.keys(REFUSAL_FAMILIES),
...Object.keys(REFUSAL_BY_REASON),
...Object.keys(REFUSAL_FIXED),
...Object.keys(REFUSAL_UNSHOWN),
];
// The closed English vocabularies the backend WRITES into data, named through `vocab.<group>`
// (lib/vocab) by their letters-and-digits key. A word the backend adds shows in English until named —
// so each list is read from its source here: the TMDB crew jobs kept, every auto-tag the tagger can
@@ -301,6 +351,25 @@ describe("backend ids have their names in both languages", () => {
expect(adminCodes.filter((c) => !adminRefusals.some((m) => m[1] === c))).toEqual([]);
});
it("every refusal code the backend answers is said by exactly one record, and no record names another", () => {
expect(backendCodes).toEqual(expect.arrayContaining(["refused", "playlist", "too_large"]));
expect(backendCodes.filter((c) => recordedCodes.filter((r) => r === c).length !== 1)).toEqual(
[],
);
expect(recordedCodes.filter((c) => !backendCodes.includes(c))).toEqual([]);
});
it("read every refusal's reasons (a scan that matches nothing is green over nothing)", () => {
const coded = [...Object.keys(REFUSAL_FAMILIES), ...Object.keys(REFUSAL_BY_REASON)];
expect(Object.keys(reasonsOf).sort()).toEqual(coded.sort());
expect(Object.entries(reasonsOf).filter(([, rs]) => rs.length === 0)).toEqual([]);
});
it("a code whose reasons share sentences names every reason it is sent with, and no other", () => {
for (const [code, { reasons }] of Object.entries(REFUSAL_BY_REASON))
expect(Object.keys(reasons).sort(), code).toEqual([...reasonsOf[code]!].sort());
});
it("the run list's status chips are the server's run statuses", () => {
expect([...RUN_STATUSES].sort()).toEqual([...runStatuses].sort());
});
@@ -456,6 +525,39 @@ describe("backend ids have their names in both languages", () => {
}
});
it(`${lang}: every refusal the backend can answer has its sentence`, () => {
const wanted = [
...Object.entries(REFUSAL_FAMILIES).flatMap(([code, f]) => [
...reasonsOf[code]!.map((r) => `${f.prefix}.${r}${"suffix" in f ? f.suffix : ""}`),
f.fallback,
]),
...Object.values(REFUSAL_BY_REASON).flatMap((f) => [
...Object.values(f.reasons),
f.fallback,
]),
...Object.values(REFUSAL_FIXED),
"errors.generic",
];
expect(missing([...new Set(wanted)])).toEqual([]);
});
it(`${lang}: no sentence for a refusal reason the backend cannot send`, () => {
// A family whose prefix is its own (the shared `errors.admin`, and the sign-in screens, which
// name more than the one-line refusal, are checked elsewhere or not at all).
for (const [code, f] of Object.entries(REFUSAL_FAMILIES)) {
if (f.prefix === "errors.admin" || "suffix" in f) continue;
const fallbackId = f.fallback.startsWith(`${f.prefix}.`)
? f.fallback.slice(f.prefix.length + 1)
: null;
expect(
[...idsUnder(keys, f.prefix)].filter(
(r) => r !== fallbackId && !reasonsOf[code]!.includes(r),
),
code,
).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(
@@ -219,6 +219,7 @@
"edit": {
"empty_edit": "Choose a trim range or crop area first",
"source_not_ready": "The source download isn't ready to edit",
"not_media": "Only video and audio can be edited",
"generic": "Couldn't create that edit"
},
"upload": {
@@ -219,6 +219,7 @@
"edit": {
"empty_edit": "Előbb válassz vágási tartományt vagy körbevágási területet",
"source_not_ready": "A forrásletöltés még nem szerkeszthető",
"not_media": "Csak videó és hang szerkeszthető",
"generic": "Nem sikerült létrehozni a vágást"
},
"upload": {
@@ -3,6 +3,8 @@ 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
@@ -49,9 +51,14 @@ 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, "*"));