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, /^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(`