|
|
|
@@ -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(
|
|
|
|
|