Browse / github.com/storytold/craft-launcher / patch
Translations: missing strings show in English and are listed by a report, not failed by tests
Translations: missing strings show in English and are listed by a report, not failed by tests Replaces the completeness tests (every UI literal, plural, tagline and package format in each complete language) with one always-passing report, `missing_translations_report`, that prints the missing strings per language. The report scans every .rs file under crates/ui-egui/src and the tray menu at test time, including multi-line `tl!(` calls. `cargo xtask ci` runs it with `--nocapture` after the tests, so the list shows in the gate log. AGENTS.md and the i18n docs state the rule. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> <!-- coderabbit-ai-generated:v1 -->
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- aiongg (imported from pull request #928, open)
- Base
7aafad661a11594a70ae949471d481f30d8fe64a- Size
- +79 −68 in 4 files
- Review
- pending
- Shared
- 2026-10-28
- Id
98a0710bd0bfcd11396b0175
How it went
No other operator has reported using it yet.
Diff
raw · AGENTS.md crates/ui-egui/src/i18n/mod.rs crates/ui-egui/src/i18n/tests.rs xtask/src/main.rs
@@ -90,8 +90,10 @@ spinners keep moving. Every user-facing string goes through `crates/ui-egui/src/i18n` (`tl!("…")` for literals, `fmt` for `{placeholders}`, `tn` for counts). English in the code is the lookup key; adding a string-means adding its row to every catalog marked `complete_menus` (the `*.tsv` files in-`crates/ui-egui/src/i18n/`), which the i18n tests check. Translate clean-room from the meaning of+means adding English only. A string without a row in a catalog (the `*.tsv` files in+`crates/ui-egui/src/i18n/`) shows in English and never fails a test or blocks a PR; the+`missing_translations_report` test lists the missing strings per language (`cargo xtask ci` prints+it). Translators add rows to the catalogs. Translate clean-room from the meaning of the English text, never through another catalog. Product and app names (ArtCraft Launcher, Craft App Suite, PhotoCraft…) stay in English. A CJK language is drawn with the system's own CJK font (`crates/ui-egui/src/cjk_font.rs`, desktop@@ -4,15 +4,13 @@ //! # Adding a language //! 1. Add `xx.tsv` next to `ru.tsv` (copy its header; translate from the *meaning* of the //! English text, clean-room).-//! 2. Add one row to [`LANGUAGES`] (code, native name, catalog, plural rule). For a complete-//! catalog, cover every UI literal and plural key; the tests enforce that claim. A translation-//! that isn't finished yet starts with `complete_menus: false`: it still shows in-//! Preferences, strings without a row fall back to English, and the completeness tests-//! skip it until every row is done. A CJK language also sets `script` and takes its system+//! 2. Add one row to [`LANGUAGES`] (code, native name, catalog, plural rule). Strings without a+//! row fall back to English; the `missing_translations_report` test lists them per language+//! and never fails. A CJK language also sets `script` and takes its system //! font from `crate::cjk_font`. //! //! That is all: the Preferences dropdown, the system-locale match and the catalog tests-//! (parse, placeholders, plural forms, coverage of every `tl!` literal) pick it up from the+//! (parse, placeholders, plural forms) pick it up from the //! registry. //! //! # Looking strings up@@ -1,5 +1,5 @@-//! Tests for the i18n catalogs (format, duplicates, placeholders, plural forms, coverage) and the-//! lookups.+//! Tests for the i18n catalogs (format, duplicates, placeholders, plural forms) and the+//! lookups, plus the missing-translation report. use std::collections::{BTreeSet, HashSet}; @@ -118,47 +118,62 @@ fn ellipsis_preserved() { } } -/// Every string the UI looks up by literal (`tl!("…")`, `t("…")`) in the language of each catalog-/// that claims to be complete.+/// Every `.rs` file of the UI crate (except the i18n folder) and the desktop tray menu.+fn ui_sources() -> Vec<String> {+ fn walk(dir: &std::path::Path, out: &mut Vec<String>) {+ let mut entries: Vec<_> = std::fs::read_dir(dir).expect("read source dir").flatten().map(|e| e.path()).collect();+ entries.sort();+ for path in entries {+ if path.is_dir() {+ if path.file_name().is_some_and(|n| n != "i18n") {+ walk(&path, out);+ }+ } else if path.extension().is_some_and(|e| e == "rs") {+ out.push(std::fs::read_to_string(&path).expect("read source file"));+ }+ }+ }+ let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));+ let mut out = Vec::new();+ walk(&root.join("src"), &mut out);+ out.push(std::fs::read_to_string(root.join("../../apps/artcraft-launcher/src/tray.rs")).expect("read tray.rs"));+ out+}++/// The text of the string literal that starts after `source[at..]`'s leading whitespace, if any.+fn literal_after(source: &str, at: usize) -> Option<&str> {+ let rest = source[at..].trim_start();+ let body = rest.strip_prefix('"')?;+ Some(&body[..body.find('"')?])+}++/// Translation report: prints, per language, the UI strings that have no translation and so show+/// in English. It never fails on a missing translation; it fails only when the scanner finds+/// almost nothing, which means the scanner is broken. #[test]-fn complete_catalogs_cover_every_ui_literal() {- let sources = [- include_str!("../app.rs"),- include_str!("../details.rs"),- include_str!("../dialogs.rs"),- include_str!("../card_parts.rs"),- include_str!("../model.rs"),- include_str!("../notes.rs"),- include_str!("../settings.rs"),- include_str!("../settings_dialog.rs"),- include_str!("../views.rs"),- include_str!("../widgets.rs"),- // The desktop shell's tray menu and tooltip.- include_str!("../../../../apps/artcraft-launcher/src/tray.rs"),- ];+fn missing_translations_report() {+ let sources = ui_sources(); let mut keys = BTreeSet::new();- for source in sources {+ for source in &sources { // `t("…")` calls, not `fmt(`, `.get(` and the like.- for (i, _) in source.match_indices("t(\"") {+ for (i, _) in source.match_indices("t(") { if source[..i].ends_with(|c: char| c.is_alphanumeric() || c == '_' || c == '.') { continue; }- let start = i + 3;- if let Some(len) = source[start..].find('"') {- keys.insert(&source[start..start + len]);+ if let Some(key) = literal_after(source, i + 2) {+ keys.insert(key); } }- for (i, _) in source.match_indices("tl!(\"") {- let start = i + 5;- if let Some(len) = source[start..].find('"') {- keys.insert(&source[start..start + len]);+ for (i, _) in source.match_indices("tl!(") {+ if let Some(key) = literal_after(source, i + 4) {+ keys.insert(key); } } } assert!(keys.len() > 100, "found only {} UI literals", keys.len()); // Plural keys: `tn(n, "one", "other")`, `trn(lang, n, "one", "other")`. let mut plurals = BTreeSet::new();- for source in sources {+ for source in &sources { for (i, call) in source.match_indices("tn(").chain(source.match_indices("trn(")) { if source[..i].ends_with(|c: char| c.is_alphanumeric() || c == '_') || source[i + call.len()..].starts_with(')') { continue;@@ -170,19 +185,31 @@ fn complete_catalogs_cover_every_ui_literal() { } } assert!(plurals.len() >= 4, "found only {plurals:?}");- for lang in Lang::all().filter(|l| l.complete_menus()) {- let missing: Vec<_> = keys.iter().filter(|k| !super::has(lang, k)).collect();- assert!(missing.is_empty(), "{} is missing translations for {missing:#?}", lang.code());- let missing: Vec<_> = plurals.iter().filter(|k| lang.catalog().plural(k, 0).is_none()).collect();- assert!(missing.is_empty(), "{} is missing @plural rows for {missing:#?}", lang.code());- }-} -#[test]-fn complete_catalogs_translate_every_tagline() {- for lang in Lang::all().filter(|l| l.complete_menus()) {+ use artcraft_launcher_core::PackageKind;+ let kinds = [+ PackageKind::Msi,+ PackageKind::PortableZip,+ PackageKind::Dmg,+ PackageKind::Deb,+ PackageKind::Rpm,+ PackageKind::ArchPkg,+ PackageKind::Flatpak,+ PackageKind::TarGz,+ ];+ println!("\nTranslation report ({} UI literals, {} plurals): missing strings show in English", keys.len(), plurals.len());+ for lang in Lang::all().filter(|l| l.code() != "en") {+ let mut missing: Vec<String> = keys.iter().filter(|k| !super::has(lang, k)).map(|k| format!("string: {k}")).collect();+ missing.extend(plurals.iter().filter(|k| lang.catalog().plural(k, 0).is_none()).map(|k| format!("plural: {k}")));+ missing.extend(kinds.iter().filter(|k| !super::has(lang, k.label())).map(|k| format!("package format: {}", k.label()))); for app in &artcraft_launcher_core::SUITE {- assert_ne!(super::tr_ctx(lang, app.id, app.tagline), app.tagline, "{}: {}", lang.code(), app.id);+ if super::tr_ctx(lang, app.id, app.tagline) == app.tagline {+ missing.push(format!("tagline ({}): {}", app.id, app.tagline));+ }+ }+ println!("{}: {} missing", lang.code(), missing.len());+ for m in &missing {+ println!(" {m}"); } } }@@ -528,26 +555,6 @@ fn message_templates_match_real_messages() { assert_eq!(message(Lang::EN, "Something else"), "Something else"); } -#[test]-fn complete_catalogs_translate_package_formats() {- use artcraft_launcher_core::PackageKind;- let kinds = [- PackageKind::Msi,- PackageKind::PortableZip,- PackageKind::Dmg,- PackageKind::Deb,- PackageKind::Rpm,- PackageKind::ArchPkg,- PackageKind::Flatpak,- PackageKind::TarGz,- ];- for lang in Lang::all().filter(|l| l.complete_menus()) {- for kind in kinds {- assert!(super::has(lang, kind.label()), "{}: {}", lang.code(), kind.label());- }- }-}- #[test] fn picker_lists_english_first_then_names_alphabetically() { let order = Lang::for_picker();@@ -101,6 +101,10 @@ fn cmd_ci() -> Result<(), String> { ("fmt", Box::new(|| cargo_step(&["fmt", "--all", "--", "--check"]))), ("clippy", Box::new(|| cargo_step(&["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]))), ("test", Box::new(|| cargo_step(&["test", "--workspace"]))),+ (+ "i18n-report",+ Box::new(|| cargo_step(&["test", "-p", "artcraft-launcher-ui-egui", "--lib", "missing_translations_report", "--", "--nocapture"])),+ ), ("wasm", Box::new(cmd_wasm)), ]; let mut done = Vec::new();