Files
bookorbit/scripts/crowdin-stats/generate.test.mjs
T
neon 7dcc509a54 feat(client): add nine interface languages and a searchable language picker
Traditional Chinese cannot reuse the `%two_letters_code%` filename because it would collide with
Simplified under `zh.json`, so `crowdin.yml` maps `zh-TW` to a `zh-Hant` catalog and locale
matching resolves by script subtag before falling back to the base language. Every other new
locale keeps its bare two-letter ID.

Twenty-five entries outgrew the old menus. The mobile submenu inherited `overflow-hidden` with no
max-height, so the last nine languages were unreachable on a phone, and the list was ordered by
ISO code while displaying endonyms, which reads as unsorted. Both header menus now render one
LanguagePicker that searches the native name, the English name and the locale code, groups the
browser's own languages as suggestions, and orders the rest with a collator on the string it
actually shows. Mobile opens a sheet instead of a nested flyout, since a flyout is the wrong
shape for touch regardless of height. Settings gains the Appearance language section its copy
has anticipated since the keys were added.

Tighten the Crowdin progress chart, which the longer language list had stretched to 810px tall.
2026-08-11 07:43:38 +00:00

222 lines
9.8 KiB
JavaScript

import assert from "node:assert/strict";
import { test } from "node:test";
import { computeLayout, extractLanguages, fetchCrowdinApiProgress, fetchStats, generateSvg, parseArguments } from "./generate.mjs";
function language(id, name, locale, translationProgress, translated = 0, total = 0) {
return { data: { languageId: id, language: { name, locale }, translationProgress, words: { translated, total } } };
}
const sampleStats = {
progress: [
language("de", "German", "de-DE", "96%", 18675, 19358),
language("en", "English", "en-US", "95%", 18465, 19358),
language("nl", "Dutch", "nl-NL", "95%", 18535, 19358),
language("pt-BR", "Portuguese, Brazilian", "pt-BR", "97%", 18868, 19358),
language("sl", "Slovenian", "sl-SI", "95%", 18554, 19358),
],
};
test("parseArguments parses flags", () => {
const options = parseArguments(["--out", "custom.svg", "--url", "https://example.com/stats.json"]);
assert.equal(options.outputPath, "custom.svg");
assert.equal(options.statsUrl, "https://example.com/stats.json");
});
test("parseArguments rejects unknown flags and missing values", () => {
assert.throws(() => parseArguments(["--nope", "x"]), /Unknown argument/);
assert.throws(() => parseArguments(["--out"]), /Missing value for --out/);
});
test("parseArguments accepts a project override", () => {
assert.equal(parseArguments([]).projectId, "912891");
assert.equal(parseArguments(["--project", "42"]).projectId, "42");
});
test("fetchStats reads live progress from the Crowdin API when a token is available", async () => {
const calls = [];
const fetchImpl = async (url, init) => {
calls.push({ url, authorization: init.headers.Authorization });
return new Response(JSON.stringify({ data: [language("de", "German", "de-DE", 93, 4921, 5322)] }));
};
const { stats, source } = await fetchStats({ token: "secret-token", fetchImpl });
assert.equal(source, "Crowdin API");
assert.match(calls[0].url, /\/projects\/912891\/languages\/progress/);
assert.equal(calls[0].authorization, "Bearer secret-token");
// The API payload is remapped into the badge feed shape the generator expects.
assert.equal(stats.progress[0].data.languageId, "de");
assert.equal(extractLanguages(stats)[0].percent, 93);
});
test("fetchStats falls back to the cached badge feed when no token is set", async () => {
let requested;
const fetchImpl = async (url) => {
requested = url;
return new Response(JSON.stringify(sampleStats));
};
const { stats, source } = await fetchStats({ fetchImpl });
assert.equal(source, "cached badge feed");
assert.match(requested, /badges\.awesome-crowdin\.com/);
assert.equal(stats.progress.length, sampleStats.progress.length);
});
test("fetchCrowdinApiProgress rejects failed requests and malformed payloads", async () => {
await assert.rejects(() => fetchCrowdinApiProgress("token", { fetchImpl: async () => new Response("nope", { status: 500 }) }), /HTTP 500/);
await assert.rejects(
() => fetchCrowdinApiProgress("token", { fetchImpl: async () => new Response(JSON.stringify({ unexpected: true })) }),
/Invalid Crowdin progress response format/,
);
});
test("extractLanguages pins the source language at 100% and sorts targets by progress", () => {
const languages = extractLanguages(sampleStats);
assert.deepEqual(
languages.map((item) => item.id),
["en", "pt-BR", "de", "nl", "sl"],
);
assert.deepEqual(languages[0].percent, 100);
assert.equal(languages[0].isSource, true);
assert.equal(languages[1].isSource, false);
});
test("extractLanguages derives flags from the locale and defaults missing progress to zero", () => {
const [german] = extractLanguages({ progress: [language("de", "German", "de-DE", "96%")] });
assert.equal(german.flag, "🇩🇪");
const [brazilian] = extractLanguages({ progress: [language("pt-BR", "Portuguese, Brazilian", "pt-BR", "97%")] });
assert.equal(brazilian.name, "Portuguese");
assert.equal(brazilian.flag, "🇧🇷");
const [unflagged] = extractLanguages({ progress: [language("pt-BR", "Portuguese, Brazilian", "pt", "97%")] });
assert.equal(unflagged.name, "Portuguese, Brazilian");
const [unknown] = extractLanguages({ progress: [{ data: { languageId: "eo" } }] });
assert.equal(unknown.name, "eo");
// Crowdin's own Chinese names are long enough to widen the label column for every row.
const [simplified] = extractLanguages({ progress: [language("zh-CN", "Chinese Simplified", "zh-CN", "99%")] });
assert.equal(simplified.name, "Chinese (S)");
const [traditional] = extractLanguages({ progress: [language("zh-TW", "Chinese Traditional", "zh-TW", "0%")] });
assert.equal(traditional.name, "Chinese (T)");
assert.equal(unknown.flag, "");
assert.equal(unknown.percent, 0);
});
test("generateSvg renders every target language with its percentage", () => {
const svg = generateSvg(sampleStats);
assert.match(svg, /^<svg /);
assert.match(svg, /🇩🇪 {2}German/);
assert.match(svg, /🇳🇱 {2}Dutch/);
assert.match(svg, /🇧🇷 {2}Portuguese</);
assert.match(svg, /🇸🇮 {2}Slovenian/);
assert.match(svg, />97%</);
assert.match(svg, /4 languages · 96% average/);
assert.match(svg, /English/);
});
test("generateSvg renders the source language like any other completed row but keeps it out of the average", () => {
const svg = generateSvg(sampleStats);
const [source] = extractLanguages(sampleStats);
const layout = computeLayout(extractLanguages(sampleStats));
assert.equal(source.isSource, true);
assert.equal(source.percent, 100);
assert.doesNotMatch(svg, /bar-source/);
// English sits at 100%, so it takes the completed tier and fills the whole bar.
assert.match(svg, new RegExp(`width="${layout.barWidth}" height="6" rx="3" class="bar-done"`));
assert.match(svg, /96% average/);
});
test("generateSvg renders at its natural size so label type is never scaled down", () => {
const { height } = computeLayout(extractLanguages(sampleStats));
assert.match(generateSvg(sampleStats), new RegExp(`<svg [^>]*width="460" height="${height}" viewBox="0 0 460 ${height}"`));
});
test("generateSvg grows the canvas instead of overflowing when languages are added", () => {
const withExtra = generateSvg({ progress: [...sampleStats.progress, language("es", "Spanish", "es-ES", "40%", 4000, 19358)] });
assert.match(withExtra, /viewBox="0 0 460 263"/);
assert.match(withExtra, /🇪🇸 {2}Spanish/);
assert.match(generateSvg(sampleStats), /viewBox="0 0 460 238"/);
});
test("computeLayout keeps horizontal rows at any language count", () => {
const rows = (count) => Array.from({ length: count }, (_, index) => ({ name: `Language ${index}`, flag: "", percent: 50 }));
const nine = computeLayout(rows(9));
const twenty = computeLayout(rows(20));
assert.equal(nine.count, 9);
assert.equal(twenty.height - nine.height, 11 * 25, "each extra language adds exactly one row");
});
test("generateSvg renders many languages as rows without rotating labels", () => {
const many = {
progress: [
language("en", "English", "en-US", "95%", 1, 2),
...Array.from({ length: 12 }, (_, index) => language(`l${index}`, `Language ${index}`, "xx-XX", "50%", 1, 2)),
],
};
const layout = computeLayout(extractLanguages(many));
const svg = generateSvg(many);
assert.equal(layout.count, 13);
assert.match(svg, new RegExp(`viewBox="0 0 460 ${layout.height}"`));
assert.equal(svg.match(/class="bar-bg"/g).length, 13);
assert.doesNotMatch(svg, /rotate\(/);
});
test("computeLayout sizes the label column to the widest name", () => {
const short = computeLayout([{ name: "Thai", flag: "", percent: 50 }]);
const long = computeLayout([
{ name: "Thai", flag: "", percent: 50 },
{ name: "Portuguese, Brazilian", flag: "", percent: 50 },
]);
assert.ok(long.barX > short.barX, "a longer name must push the bar right");
assert.equal(short.barX + short.barWidth, long.barX + long.barWidth, "bars stay right-aligned against the percentage column");
});
test("computeLayout caps the label column so one long name cannot squeeze out the bar", () => {
const layout = computeLayout([{ name: "A".repeat(200), flag: "🇦🇽", percent: 50 }]);
assert.ok(layout.barWidth > 200, `bar collapsed to ${layout.barWidth}px`);
assert.match(generateSvg({ progress: [language("xx", "A".repeat(200), "ax-AX", "50%")] }), /…/);
});
test("generateSvg keeps bars inside the card", () => {
const svg = generateSvg(sampleStats);
assert.equal(svg.match(/class="bar-bg"/g).length, 5, "one track per language row");
for (const [, x, width] of svg.matchAll(/<rect x="(\d+)" y="\d+" width="(\d+)"[^>]*class="bar-(?:bg|done|progress|low)"/g)) {
assert.ok(Number(x) >= 20, "bar must not overflow the left padding");
assert.ok(Number(x) + Number(width) <= 460 - 20, "bar must not overflow the right padding");
}
});
test("generateSvg colours bars by progress and always explains the scale", () => {
const svg = generateSvg({
progress: [language("de", "German", "de-DE", "100%"), language("nl", "Dutch", "nl-NL", "70%"), language("sl", "Slovenian", "sl-SI", "12%")],
});
assert.match(svg, /class="bar-done"/);
assert.match(svg, /class="bar-progress"/);
assert.match(svg, /class="bar-low"/);
// Every tier stays in the legend even when no language currently falls in it.
const complete = generateSvg({ progress: [language("de", "German", "de-DE", "100%")] });
for (const label of ["Complete", "In progress", "Needs work"]) {
assert.match(complete, new RegExp(`>${label}<`));
}
});
test("generateSvg escapes language names and handles empty data", () => {
const svg = generateSvg({ progress: [language("xx", "A & B <Test>", "xx-XX", "10%")] });
assert.match(svg, /A &amp; B &lt;Test&gt;/);
const empty = generateSvg({ progress: [] });
assert.match(empty, /no translation data/);
assert.match(empty, /viewBox="0 0 460 96"/);
assert.doesNotMatch(empty, /NaN|undefined/);
});