xtask: i18n-coverage lists missing strings; ci prints them last

The report prints, per language, the source keys its catalog lacks (one
line for a language with none). `cargo xtask ci` runs it as its last step
and never fails on it: a missing translation shows in English. A report
that can't run prints a warning.
This commit is contained in:
aiongg
2026-10-23 19:07:23 +00:00
parent cf0960b520
commit cb297eeac1
2 changed files with 56 additions and 35 deletions
+42 -33
View File
@@ -72,9 +72,33 @@ fn format_report(languages: &[Language], english_keys: &BTreeSet<Key>, translati
let coverage = 100.0 * translated as f64 / english_keys.len() as f64;
report.push_str(&format!("{:<12} {:>5}/{:<6} {:>8.2}%\n", language.code, translated, english_keys.len(), coverage));
}
report.push_str("\nMissing translations (shown in English until translated)\n");
for language in languages.iter().filter(|language| language.code != "en") {
let empty = BTreeSet::new();
let translated = translations.get(&language.code).unwrap_or(&empty);
let missing: Vec<&Key> = english_keys.difference(translated).collect();
if missing.is_empty() {
report.push_str(&format!("{}: none missing\n", language.code));
continue;
}
report.push_str(&format!("{}: {} missing\n", language.code, missing.len()));
for key in missing {
let source = escape_line(&key.source);
if key.context.is_empty() {
report.push_str(&format!(" {source}\n"));
} else {
report.push_str(&format!(" [{}] {source}\n", escape_line(&key.context)));
}
}
}
report
}
/// Keeps one key per log line: a source string with a newline or tab prints them escaped.
fn escape_line(text: &str) -> String {
text.replace('\\', "\\\\").replace('\n', "\\n").replace('\t', "\\t")
}
fn registered_languages(source: &str) -> Result<Vec<Language>, String> {
let registry = source
.split("pub static LANGUAGES")
@@ -494,15 +518,28 @@ mod tests {
let languages = registered_languages(registry);
assert!(languages.is_ok());
let languages = languages.unwrap_or_default();
let english_keys = BTreeSet::from([Key { context: String::new(), source: "Open".into() }, Key { context: String::new(), source: "Save".into() }]);
let translations = BTreeMap::from([("fr".to_string(), BTreeSet::from([Key { context: String::new(), source: "Open".into() }]))]);
let english_keys = BTreeSet::from([
Key { context: String::new(), source: "Open".into() },
Key { context: String::new(), source: "Save".into() },
Key { context: "@plural".into(), source: "{n} layer|{n} layers".into() },
]);
let translations = BTreeMap::from([
("fr".to_string(), BTreeSet::from([Key { context: String::new(), source: "Open".into() }])),
("zh".to_string(), english_keys.clone()),
]);
assert_eq!(
format_report(&languages, &english_keys, &translations),
"UI translation coverage (translated source keys / source keys)\n\
language translated coverage\n\
en 2/2 100.00%\n\
fr 1/2 50.00%\n\
zh 0/2 0.00%\n"
en 3/3 100.00%\n\
fr 1/3 33.33%\n\
zh 3/3 100.00%\n\
\n\
Missing translations (shown in English until translated)\n\
fr: 2 missing\n\
\x20 Save\n\
\x20 [@plural] {n} layer|{n} layers\n\
zh: none missing\n"
);
}
@@ -563,34 +600,6 @@ mod tests {
}
}
#[test]
fn source_key_set_is_covered_by_complete_catalogs() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap_or(Path::new("."));
let keys = source_keys(root).unwrap_or_default();
for (code, path) in [
("ja", "ja.tsv"),
("zh-hant", "zh-hant.tsv"),
("es", "es.tsv"),
("ru", "ru.tsv"),
("cs", "cs.tsv"),
("de", "de.tsv"),
("pt-br", "pt-br.tsv"),
("it", "it.tsv"),
("el", "el.tsv"),
("uk", "uk.tsv"),
("nl", "nl.tsv"),
("hu", "hu.tsv"),
("tr", "tr.tsv"),
("vi", "vi.tsv"),
("sr", "sr.tsv"),
] {
let catalog_path = root.join("crates/ui-egui/src/i18n").join(path);
let text = fs::read_to_string(&catalog_path).unwrap_or_default();
let translations = parse_catalog(&catalog_path, &text).unwrap_or_default();
let missing: Vec<_> = keys.difference(&translations).collect();
assert!(missing.is_empty(), "{code}: missing source keys: {missing:#?}");
}
}
#[test]
fn plural_forms_follow_count_expressions_and_dynamic_forms_are_not_literal_keys() {
let sources = vec![(
+14 -2
View File
@@ -26,7 +26,8 @@ commands:
wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates (one call, plus
one per optional web feature)
test workspace tests (cargo nextest when installed, else cargo test), then doctests
ci fmt --check, clippy -D warnings, test, doctest, layers, wasm (stops at first failure)
ci fmt --check, clippy -D warnings, test, doctest, layers, wasm (stops at first failure),
then the i18n-coverage report
corpus [--all | --pngsuite | --psd | --psd-tools | --pixls | --atn | --photoshop | --gimp] [--local] [--update-manifest]
show where test corpora live and their pins (xtask/src/corpus_pins.rs), or fetch
them into corpus/ (pinned commits, sha256-verified; --all = every corpus but pixls,
@@ -38,7 +39,8 @@ commands:
--local takes corpus/photoshop and corpus/gimp from the photocraft-corpus authoring clone
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
parity Photoshop menu parity; rewrites docs/parity-checklist.md
i18n-coverage report stable UI translation coverage for registered languages
i18n-coverage report UI translation coverage and the missing strings per registered language
(the last `ci` step; it never fails)
perf [--quick] [--update-baseline] [--threshold PCT] [--bench NAME]... [--skip-build] [--reuse]
run the release benches, merge them by scenario id into target/perf/results.json,
check perf/budgets.toml and perf/baseline.json (non-zero on a broken budget or regression)
@@ -249,6 +251,16 @@ fn cmd_ci() -> Result<(), String> {
("doctest", Box::new(cmd_doctest)),
("layers", Box::new(cmd_layers)),
("wasm", Box::new(cmd_wasm)),
(
"i18n",
Box::new(|| {
// Lists missing translations for the log; a missing string shows in English, so it never fails CI.
if let Err(e) = i18n_coverage::run(&root()) {
eprintln!("warning: i18n coverage report failed: {e}");
}
Ok(())
}),
),
];
let mut done = Vec::new();
for (name, f) in &steps {