collective

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

diff --git a/AGENTS.md b/AGENTS.md--- a/AGENTS.md+++ b/AGENTS.md@@ -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`, desktopdiff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs--- a/crates/ui-egui/src/i18n/mod.rs+++ b/crates/ui-egui/src/i18n/mod.rs@@ -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 updiff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs--- a/crates/ui-egui/src/i18n/tests.rs+++ b/crates/ui-egui/src/i18n/tests.rs@@ -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();diff --git a/xtask/src/main.rs b/xtask/src/main.rs--- a/xtask/src/main.rs+++ b/xtask/src/main.rs@@ -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();