fix(ui,i18n): E30 review round 1 — audit rows in the reader's language, one HU name per thing
- audit log: a job run named from its result data (nested phases too), a skipped run says so, a secret's reset reads as a reset, choices named as Configuration names them, interval / batch / counts localized; the inbox shares the same jobRunText - HU: remux is "Közvetlen közvetítés" (it shared "Közvetlen lejátszás" with direct play); backfill is behúzás, enrich is dúsítás, collection is kollekció, the allow-list is fehérlista; the last "jobnak" / "transzkódolás"; a test holds the options of one choice to distinct names - contract: every skipped / reason word a job returns is named in both languages - person page: the bio wait is timed from the open and a failing ask stops the polling; ClampedText (ui) shared with the collection page, whose short summary lost its dead More - a playlist names season 0 the Specials; the channel re-slot compares in the reader's alphabet; an invite's date is in the admin's language; the language-ordered lists are asked again once a language switch is saved (saveLanguage) - poster grids' column minimum in rem; lint refuses minmax(Npx,…)
This commit is contained in:
@@ -8,39 +8,44 @@ describe("placeByPriority", () => {
|
||||
const list = [row("a", 10), row("b", 5), row("c", 5), row("d", 0), row("e", 0)];
|
||||
|
||||
it("bubbles a raised channel up to the first row it now outranks", () => {
|
||||
expect(ids(placeByPriority([...list.slice(0, 4), { ...list[4]!, priority: 5 }], "e"))).toEqual([
|
||||
"a",
|
||||
"b",
|
||||
"c",
|
||||
"e",
|
||||
"d",
|
||||
]);
|
||||
expect(
|
||||
ids(placeByPriority([...list.slice(0, 4), { ...list[4]!, priority: 5 }], "e", "en")),
|
||||
).toEqual(["a", "b", "c", "e", "d"]);
|
||||
});
|
||||
|
||||
it("sinks a lowered channel below every row it no longer outranks", () => {
|
||||
expect(ids(placeByPriority([{ ...list[0]!, priority: 0 }, ...list.slice(1)], "a"))).toEqual([
|
||||
"b",
|
||||
"c",
|
||||
"a",
|
||||
"d",
|
||||
"e",
|
||||
]);
|
||||
expect(
|
||||
ids(placeByPriority([{ ...list[0]!, priority: 0 }, ...list.slice(1)], "a", "en")),
|
||||
).toEqual(["b", "c", "a", "d", "e"]);
|
||||
});
|
||||
|
||||
it("orders equal priorities by title, case-folded, a missing title last", () => {
|
||||
const rows = [row("x", 3, "beta"), row("y", 3, null), row("z", 3, "Alpha")];
|
||||
expect(ids(placeByPriority(rows, "z"))).toEqual(["z", "x", "y"]);
|
||||
expect(ids(placeByPriority([row("y", 3, null), row("x", 3, "beta")], "y"))).toEqual(["x", "y"]);
|
||||
expect(ids(placeByPriority(rows, "z", "en"))).toEqual(["z", "x", "y"]);
|
||||
expect(ids(placeByPriority([row("y", 3, null), row("x", 3, "beta")], "y", "en"))).toEqual([
|
||||
"x",
|
||||
"y",
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves every other row where it was, even ones out of the server's order", () => {
|
||||
const rows = [row("p", 0), row("q", 9), row("r", 2)];
|
||||
// r lands before the first row it outranks; p and q keep their (unsorted) order.
|
||||
expect(ids(placeByPriority(rows, "r"))).toEqual(["r", "p", "q"]);
|
||||
expect(ids(placeByPriority(rows, "r", "en"))).toEqual(["r", "p", "q"]);
|
||||
});
|
||||
|
||||
it("returns an unknown id's list unchanged", () => {
|
||||
expect(ids(placeByPriority(list, "nope"))).toEqual(ids(list));
|
||||
expect(ids(placeByPriority(list, "nope", "en"))).toEqual(ids(list));
|
||||
});
|
||||
|
||||
it("places a title in the reader's alphabet, as the server's collation does (E30 review)", () => {
|
||||
const rows = [row("e", 0, "Ecset"), row("f", 0, "Fa"), row("m", 5, "Életrajz")];
|
||||
const moved = [...rows.slice(0, 2), { ...rows[2]!, priority: 0 }];
|
||||
expect(ids(placeByPriority(moved, "m", "hu"))).toEqual(["e", "m", "f"]);
|
||||
const cs = [row("u", 0, "Cukor"), row("x", 0, "Dob"), row("c", 5, "Csiga")];
|
||||
expect(
|
||||
ids(placeByPriority([...cs.slice(0, 2), { ...cs[2]!, priority: 0 }], "c", "hu")),
|
||||
).toEqual(["u", "c", "x"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { compareText } from "./collate";
|
||||
|
||||
// The channel manager's list order, and the priority steps its menu offers (E28 S3e3b).
|
||||
|
||||
/** The steps a channel's menu offers (owner, 2026-09-26): fixed levels, so bubbling a channel up
|
||||
@@ -19,23 +21,27 @@ interface Ordered {
|
||||
title?: string | null;
|
||||
}
|
||||
|
||||
/** The server's order (`routes/channels.py`: priority desc, then the title case-folded, a missing
|
||||
* title last). */
|
||||
function before(a: Ordered, b: Ordered): boolean {
|
||||
/** The server's order (`routes/channels.py`: priority desc, then the title in the reader's alphabet —
|
||||
* the ICU collation of `app/collation.py`, which `compareText` shares — a missing title last). */
|
||||
function before(a: Ordered, b: Ordered, lang: string): boolean {
|
||||
if (a.priority !== b.priority) return a.priority > b.priority;
|
||||
if (a.title == null) return false;
|
||||
if (b.title == null) return true;
|
||||
return a.title.toLowerCase() < b.title.toLowerCase();
|
||||
return compareText(a.title, b.title, lang) < 0;
|
||||
}
|
||||
|
||||
/** `rows` with the channel `id` taken out and put back where its priority now places it, every
|
||||
* other row left exactly where it was — the server's order, without a refetch that would reorder
|
||||
* anything the reader did not touch. */
|
||||
export function placeByPriority<T extends Ordered>(rows: readonly T[], id: string): T[] {
|
||||
export function placeByPriority<T extends Ordered>(
|
||||
rows: readonly T[],
|
||||
id: string,
|
||||
lang: string,
|
||||
): T[] {
|
||||
const moved = rows.find((r) => r.id === id);
|
||||
if (!moved) return [...rows];
|
||||
const rest = rows.filter((r) => r !== moved);
|
||||
const at = rest.findIndex((r) => before(moved, r));
|
||||
const at = rest.findIndex((r) => before(moved, r, lang));
|
||||
rest.splice(at < 0 ? rest.length : at, 0, moved);
|
||||
return rest;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ describe("a job's result", () => {
|
||||
it("names a skip reason; an upstream error stays its own text", () => {
|
||||
const t = i18n.getFixedT("hu");
|
||||
expect(jobResultText(t, { enriched: 0, skipped: "tmdb_api_key not set" }, "hu")).toBe(
|
||||
"kiegészítve: 0 · kihagyva: nincs TMDB API-kulcs",
|
||||
"dúsítva: 0 · kihagyva: nincs TMDB API-kulcs",
|
||||
);
|
||||
expect(jobResultText(t, { error: "Plex returned 500" }, "hu")).toBe("hiba: Plex returned 500");
|
||||
});
|
||||
|
||||
@@ -37,3 +37,34 @@ export function jobResultText(t: TFunction, result: JobResult, lang: string): st
|
||||
})
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
/** What a job run said, as the inbox notice and the audit row both show it (E30): a title naming the job
|
||||
* and how the run ended, and its result in the reader's language — from the data when it rode along,
|
||||
* else read back from the English one-liner. A run skipped for a pause names why; a failed run keeps the
|
||||
* upstream's own error text (it is diagnostic). */
|
||||
export function jobRunText(
|
||||
t: TFunction,
|
||||
run: { job_id: string; status?: unknown; summary?: unknown; result?: unknown },
|
||||
lang: string,
|
||||
): { title: string; body: string | null } {
|
||||
const job = t(`scheduler.jobs.${run.job_id}`, run.job_id);
|
||||
const summary = typeof run.summary === "string" && run.summary ? run.summary : null;
|
||||
const titleKey =
|
||||
run.status === "error"
|
||||
? "inbox.jobDone.titleError"
|
||||
: run.status === "skipped"
|
||||
? "inbox.jobDone.titleSkipped"
|
||||
: "inbox.jobDone.titleOk";
|
||||
let result: JobResult | null = null;
|
||||
if (run.status !== "error") {
|
||||
result =
|
||||
run.result && typeof run.result === "object"
|
||||
? (run.result as JobResult)
|
||||
: (parseSummary(summary) ??
|
||||
(run.status === "skipped" && summary ? { skipped: summary } : null));
|
||||
}
|
||||
return {
|
||||
title: t(titleKey, { job }),
|
||||
body: result ? jobResultText(t, result, lang) : summary,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,6 +29,12 @@ describe("a px-frozen box utility", () => {
|
||||
expect(font?.messages.some((m) => m.message.startsWith("px-frozen font size"))).toBe(true);
|
||||
});
|
||||
|
||||
it("is refused as a grid column's minimum (E30 review)", async () => {
|
||||
const mm = (n: string) => `grid-cols-[repeat(auto-fill,minmax(${n},1fr))]`;
|
||||
expect(await refused(`export const g = "grid ${mm("150px")}";\n`)).toBe(1);
|
||||
expect(await refused(`export const h = "grid ${mm("9.375rem")}";\n`)).toBe(0);
|
||||
});
|
||||
|
||||
it("leaves the spacing scale, a rem arbitrary and a grid track alone", async () => {
|
||||
expect(await refused('export const c = "w-37.5 max-w-60 text-[0.75rem]";\n')).toBe(0);
|
||||
expect(await refused('export const d = "grid-cols-[1fr_240px]";\n')).toBe(0);
|
||||
|
||||
@@ -161,3 +161,17 @@ export const qk = {
|
||||
plexPerson: (name: string, lang?: string) =>
|
||||
["plex-person", name, ...(lang ? [lang] : [])] as const,
|
||||
};
|
||||
|
||||
/** The lists the SERVER puts in the reader's alphabet from the language stored in their preferences
|
||||
* (backend `lang_for_user` → `app/collation.py`), so their keys carry no language. Once a language
|
||||
* switch has been saved they are asked for again (App's `changeLanguage`): they re-sort, and a
|
||||
* title-sorted feed never reads its next page under the other alphabet's cursor (E30 review). */
|
||||
export const LANGUAGE_ORDERED = [
|
||||
qk.feed(),
|
||||
qk.channels(),
|
||||
qk.discoveredChannels(),
|
||||
qk.blockedChannels(),
|
||||
qk.shareRecipients(),
|
||||
qk.messageUsers(),
|
||||
qk.plexCollections(),
|
||||
] as const;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E30 review: the server orders some lists by the reader's STORED language (their keys carry none), so a
|
||||
// switch must re-ask for them — after the save lands, not before (the server would sort in the old one).
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { api } from "./api";
|
||||
import { LANGUAGE_ORDERED, qk } from "./queryKeys";
|
||||
import { saveLanguage } from "./saveLanguage";
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe("saveLanguage", () => {
|
||||
it("asks again for the server-ordered lists once the save has landed", async () => {
|
||||
let land!: () => void;
|
||||
vi.spyOn(api, "savePrefs").mockReturnValue(
|
||||
new Promise<never>((r) => (land = r as () => void)) as never,
|
||||
);
|
||||
const qc = new QueryClient();
|
||||
const asked = vi.spyOn(qc, "invalidateQueries");
|
||||
const done = saveLanguage(qc, "hu");
|
||||
await Promise.resolve();
|
||||
expect(asked).not.toHaveBeenCalled(); // the server still sorts in the old language
|
||||
land();
|
||||
await done;
|
||||
const keys = asked.mock.calls.map((c) => c[0]?.queryKey);
|
||||
expect(keys).toEqual([...LANGUAGE_ORDERED]);
|
||||
expect(keys).toContainEqual(qk.feed());
|
||||
expect(keys).toContainEqual(qk.channels());
|
||||
});
|
||||
|
||||
it("leaves the lists alone when the save fails", async () => {
|
||||
vi.spyOn(api, "savePrefs").mockRejectedValue(new Error("offline"));
|
||||
const qc = new QueryClient();
|
||||
const asked = vi.spyOn(qc, "invalidateQueries");
|
||||
await expect(saveLanguage(qc, "hu")).rejects.toThrow("offline");
|
||||
expect(asked).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { api } from "./api";
|
||||
import { LANGUAGE_ORDERED } from "./queryKeys";
|
||||
|
||||
/** Store the reader's language choice on the server, then ask again for the lists the server orders
|
||||
* by it (`LANGUAGE_ORDERED`): only once the save has landed does the server sort in the new alphabet.
|
||||
* Rejects with the save's error; the lists are left alone then. */
|
||||
export async function saveLanguage(qc: QueryClient, code: string): Promise<void> {
|
||||
await api.savePrefs({ language: code });
|
||||
for (const queryKey of LANGUAGE_ORDERED) void qc.invalidateQueries({ queryKey });
|
||||
}
|
||||
Reference in New Issue
Block a user