From 70f702ca170f1c44e439a5965e8f57090e39dd3f Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:43:21 -0400 Subject: [PATCH] i18n: a missing translation fails no check; cargo xtask i18n-report lists them (#1228) --- crates/ui-egui/src/i18n_tests.rs | 108 +++++++++++++------------------ docs/localization-parity.md | 13 ++-- docs/translating.md | 20 +++--- xtask/src/main.rs | 14 +++- 4 files changed, 72 insertions(+), 83 deletions(-) diff --git a/crates/ui-egui/src/i18n_tests.rs b/crates/ui-egui/src/i18n_tests.rs index 046212c1..3921cb1b 100644 --- a/crates/ui-egui/src/i18n_tests.rs +++ b/crates/ui-egui/src/i18n_tests.rs @@ -1,13 +1,11 @@ //! The catalog checks: every key the code looks up is in the English catalog, no catalog has keys -//! the code doesn't use, placeholders match English, and complete languages translate everything. +//! the code doesn't use, and placeholders match English. A missing translation reads English and +//! fails nothing: [`missing_translations_report`] lists them (`cargo xtask i18n-report`). use std::collections::BTreeSet; use super::*; -/// Languages that must translate every UI string and command label. -const COMPLETE: &[&str] = &["es"]; - /// Keys looked up from data rather than written as `tr("…")` in the code: the main menu's /// submenu names (the engine's menu catalog) and labels of engine and document enums. fn dynamic_keys() -> BTreeSet { @@ -274,14 +272,19 @@ fn english_text_is_its_key_except_plurals() { assert!(english().commands.is_empty() && english().menu.is_empty(), "English command labels come from the engine"); } -#[test] -fn catalogs_have_known_keys_and_matching_placeholders() { - let en = english(); - let ids: BTreeSet<&str> = figcraft_engine::all_commands() +/// The ids `[commands]` entries translate: every command and every main-menu item. +fn command_ids() -> BTreeSet<&'static str> { + figcraft_engine::all_commands() .iter() .map(|c| c.id) .chain(figcraft_engine::catalog::MENU.iter().map(|m| m.id).filter(|id| !id.is_empty())) - .collect(); + .collect() +} + +#[test] +fn catalogs_have_known_keys_and_matching_placeholders() { + let en = english(); + let ids = command_ids(); for lang in LANGS { assert!(!lang.name.is_empty(), "{}: [meta] name is missing", lang.code); for (k, t) in lang.ui { @@ -302,13 +305,29 @@ fn catalogs_have_known_keys_and_matching_placeholders() { for (id, _) in lang.menu { assert!(menu_worded_differently().contains(id), "{}: [menu] {id:?} reads like the command; [commands] covers it", lang.code); } - if COMPLETE.contains(&lang.code) { - let missing: Vec<_> = en.ui.iter().filter(|(k, _)| find(lang.ui, k).is_none()).map(|(k, _)| k).collect(); - assert!(missing.is_empty(), "{}: untranslated: {missing:?}", lang.code); - let missing: Vec<_> = ids.iter().filter(|id| find(lang.commands, id).is_none()).collect(); - assert!(missing.is_empty(), "{}: untranslated commands: {missing:?}", lang.code); - let missing: Vec<_> = menu_worded_differently().into_iter().filter(|id| find(lang.menu, id).is_none()).collect(); - assert!(missing.is_empty(), "{}: menu items worded unlike their command, missing from [menu]: {missing:?}", lang.code); + } +} + +/// What each language lacks: UI strings, command labels and menu wordings that read as in English +/// because neither its catalog nor the one it overlays has them. It prints them and always passes. +#[test] +fn missing_translations_report() { + let ids = command_ids(); + let worded = menu_worded_differently(); + for (i, lang) in LANGS.iter().enumerate().filter(|(_, l)| l.code != "en") { + let own = || chain(i).filter(|l| l.code != "en"); + let ui: Vec<_> = english().ui.iter().map(|(k, _)| *k).filter(|k| own().all(|l| find(l.ui, k).is_none())).collect(); + let commands: Vec<_> = ids.iter().filter(|id| own().all(|l| find(l.commands, id).is_none())).collect(); + let menu: Vec<_> = worded.iter().filter(|id| own().all(|l| find(l.menu, id).is_none())).collect(); + println!("{}: {} UI strings, {} command labels, {} menu wordings missing", lang.code, ui.len(), commands.len(), menu.len()); + for k in ui { + println!(" {k:?}"); + } + for id in commands { + println!(" [commands] {id}"); + } + for id in menu { + println!(" [menu] {id}"); } } } @@ -344,8 +363,6 @@ fn system_languages_pick_the_first_one_with_a_catalog() { assert_eq!(code("system", Some("cs_CZ.UTF-8")), "cs"); assert_eq!(code("system", Some("cs-CZ,en-US")), "cs"); assert_eq!(code("cs", None), "cs"); - assert_eq!(LANGS[resolve("cs", None)].name, "Čeština"); - assert!(LANGS[resolve("cs", None)].draft); assert_eq!(code("system", Some("pt-BR")), "pt-BR"); assert_eq!(code("system", Some("tr_TR.UTF-8")), "tr"); assert_eq!(code("system", Some("id_ID.UTF-8")), "id"); @@ -368,8 +385,6 @@ fn swedish_is_picked_for_swedish_locales() { assert_eq!(code("system", Some(tag)), "sv", "{tag}"); } let sv = LANGS.iter().position(|l| l.code == "sv").unwrap(); - assert_eq!(LANGS[sv].name, "Svenska"); - assert!(!LANGS[sv].draft); assert!(!plural_one(sv, 0) && plural_one(sv, 1) && !plural_one(sv, 2)); } @@ -387,10 +402,11 @@ fn latin_american_spanish_overlays_spanish() { assert_eq!(LANGS[la].parent, es); assert!(es < la, "the picker lists Español first"); // Its own wording, else Spanish's, else English's. - assert!(matches!(lookup(la, "Add fill"), Some(Text::Plain("Agregar relleno")))); - assert!(matches!(lookup(la, "Fill"), Some(Text::Plain("Relleno")))); + for (k, _) in LANGS[es].ui.iter().filter(|(k, _)| find(LANGS[la].ui, k).is_none()) { + assert!(lookup(la, k).is_some_and(|t| std::ptr::eq(t, find(LANGS[es].ui, k).unwrap())), "{k:?}"); + } let undo = |i: usize| chain(i).find_map(|l| find(l.commands, "edit.undo")).map(forms); - assert!(undo(es).is_some() && undo(la) == undo(es), "command labels fall back too"); + assert_eq!(undo(la), undo(es), "command labels fall back too"); // An overlay lists only what differs from the catalog under it. for lang in LANGS.iter().filter(|l| l.parent != 0) { let under = &LANGS[lang.parent]; @@ -431,19 +447,11 @@ fn japanese_korean_and_chinese_have_no_plural_forms() { } #[test] -fn georgian_is_a_draft_with_count_neutral_plurals() { +fn georgian_is_picked_for_georgian_locales() { let code = |pref: &str, system: Option<&str>| LANGS[resolve(pref, system)].code; for tag in ["ka", "ka-GE", "ka_GE.UTF-8"] { assert_eq!(code("system", Some(tag)), "ka", "{tag}"); } - let ka = LANGS.iter().position(|l| l.code == "ka").unwrap(); - assert!(LANGS[ka].draft && LANGS[ka].name == "ქართული"); - // A noun stays singular after a numeral: both forms read the same where English counts in both. - for (k, t) in LANGS[ka].ui { - if let (Text::Plural { one: Some(one), other, .. }, Some(Text::Plural { one: Some(en_one), .. })) = (t, find(english().ui, k)) { - assert!(!en_one.contains("{n}") || one == other, "ka: {k:?}"); - } - } } /// Release builds embed craft-fonts' Noto Sans Georgian (`theme::install_fonts`' built-in faces), @@ -477,7 +485,6 @@ fn chinese_reads_simplified_and_traditional_has_no_catalog() { assert_eq!(code(tag, None), "en", "{tag}"); } assert_eq!(code("system", Some("zh-TW,ja-JP")), "ja"); - assert_eq!(LANGS[resolve("zh-Hans", None)].name, "简体中文"); } #[test] @@ -491,28 +498,18 @@ fn the_key_scanner_reads_rust_literals() { #[test] fn czech_integer_plurals() { - let cs = LANGS.iter().position(|l| l.code == "cs").unwrap(); assert_eq!(plural("cs", 1), Plural::One); - assert_eq!(fill(counted(cs, "{n} layers", 1), &[("n", &1)]), "1 vrstva"); for n in [2, 3, 4] { assert_eq!(plural("cs", n), Plural::Few, "{n}"); - assert_eq!(fill(counted(cs, "{n} layers", n), &[("n", &n)]), format!("{n} vrstvy")); } for n in [0, 5, 11, 12, 21, 22, 100, 1_000_000] { assert_eq!(plural("cs", n), Plural::Other, "{n}"); - assert_eq!(fill(counted(cs, "{n} layers", n), &[("n", &n)]), format!("{n} vrstev")); - } - for (key, text) in LANGS[cs].ui { - if let Text::Plural { one, few, .. } = text { - assert!(one.is_some() && few.is_some(), "{key}: missing Czech plural forms"); - } } } #[test] fn russian_language_tags_and_integer_plurals() { let ru = LANGS.iter().position(|l| l.code == "ru").unwrap(); - assert_eq!(LANGS[ru].name, "Русский"); for tag in ["ru", "ru-RU", "ru_RU.UTF-8", "RU", "ru-BY", "ru-Cyrl-RU"] { assert_eq!(resolve(tag, None), ru, "{tag}"); assert_eq!(resolve("system", Some(tag)), ru, "{tag}"); @@ -520,49 +517,34 @@ fn russian_language_tags_and_integer_plurals() { assert_eq!(resolve("system", Some("hi,ru-RU,en-US")), ru); for n in [1, 21, 31, 101, 1_001] { assert_eq!(plural("ru", n), Plural::One, "{n}"); - assert_eq!(fill(counted(ru, "{n} layers", n), &[("n", &n)]), format!("{n} слой")); } for n in [2, 3, 4, 22, 23, 24, 102, 104, 1_002] { assert_eq!(plural("ru", n), Plural::Few, "{n}"); - assert_eq!(fill(counted(ru, "{n} layers", n), &[("n", &n)]), format!("{n} слоя")); } for n in [0, 5, 10, 11, 12, 13, 14, 19, 20, 25, 100, 111, 112, 114, 1_000_000] { assert_eq!(plural("ru", n), Plural::Many, "{n}"); - assert_eq!(fill(counted(ru, "{n} layers", n), &[("n", &n)]), format!("{n} слоёв")); - } - for (key, text) in LANGS[ru].ui { - if let Text::Plural { one, few, many, .. } = text { - assert!(one.is_some() && few.is_some() && many.is_some(), "{key}: missing Russian plural forms"); - } } } #[test] fn polish_integer_plurals() { - let pl = LANGS.iter().position(|l| l.code == "pl").unwrap(); - assert_eq!(LANGS[pl].name, "Polski"); assert_eq!(plural("pl", 1), Plural::One); - assert_eq!(fill(counted(pl, "{n} layers", 1), &[("n", &1)]), "1 warstwa"); for n in [2, 3, 4, 22, 23, 24, 102, 104, 1_002] { assert_eq!(plural("pl", n), Plural::Few, "{n}"); - assert_eq!(fill(counted(pl, "{n} layers", n), &[("n", &n)]), format!("{n} warstwy")); } for n in [0, 5, 10, 11, 12, 13, 14, 19, 20, 21, 25, 31, 100, 101, 111, 112, 114, 1_000_000] { assert_eq!(plural("pl", n), Plural::Many, "{n}"); - assert_eq!(fill(counted(pl, "{n} layers", n), &[("n", &n)]), format!("{n} warstw")); - } - for (key, text) in LANGS[pl].ui { - if let Text::Plural { one, few, many, .. } = text { - assert!(one.is_some() && few.is_some() && many.is_some(), "{key}: missing Polish plural forms"); - } } } #[test] fn plural_fallback_uses_the_language_of_its_catalog() { + // A plural es-419 doesn't overlay reads Spanish's entry under Spanish's rule. + let es = LANGS.iter().position(|l| l.code == "es").unwrap(); let la = LANGS.iter().position(|l| l.code == "es-419").unwrap(); - assert_eq!(counted(la, "{n} layers", 1), "{n} capa"); - assert_eq!(counted(la, "{n} layers", 21), "{n} capas"); + for n in [1, 21] { + assert_eq!(counted(la, "{n} layers", n), counted(es, "{n} layers", n), "{n}"); + } // Unsupported optional categories use other; existing one/other languages are unchanged. for code in ["en", "es", "de"] { for n in [0, 2, 11, 21, 1_000_000] { diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 44e35f39..aa4cbb52 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (a missing translation fails no check: `cargo xtask i18n-report` lists them; Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7) FigCraft's interface has a localization layer with an English catalog, a Russian catalog reviewed by a native speaker, and Spanish (with a Latin American overlay), French, German, Brazilian @@ -46,11 +46,11 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v (`es-419.toml` over `es.toml`); its UI strings, command labels and menu wordings read the overlay, then the base language, then English. - **Check:** `cargo test -p figcraft-ui-egui i18n` fails on keys the code uses that `en.toml` - lacks, keys no code uses, unknown command ids, placeholders that differ from English, overlay - entries that read as in their base language and, for complete languages (Spanish), anything - untranslated. The other catalogs are not held to it: a string added later shows English there - until someone translates it. Every draft translated every string on 2026-10-12; by 2026-10-17 - the strings added since left them at 95–99% (table below). + lacks, keys no code uses, unknown command ids, placeholders that differ from English and overlay + entries that read as in their base language. No language is held to full coverage: a string + added later shows English until someone translates it, and `cargo xtask i18n-report` (part of + `cargo xtask ci`) lists what each language lacks. Every draft translated every string on + 2026-10-12; by 2026-10-17 the strings added since left them at 95–99% (table below). - **Not yet localized:** messages the engine writes (errors, a few toasts, Dev Mode property values such as "inside"), number formatting (decimal points stay `.`), and decimal plural rules (`trn` takes integers: Czech uses one/few/other and Russian one/few/many; French and Brazilian @@ -122,6 +122,7 @@ Hindi, Vietnamese and Arabic. Traditional Chinese is not planned (no catalog; it | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | A missing translation fails no check: Spanish is no longer held to full coverage, and the tests of one language's wording, names and plural forms are gone (the plural rules and language-tag tests stay). `cargo xtask i18n-report`, run by `cargo xtask ci`, lists each language's missing UI strings, command labels and menu wordings. Grade unchanged | | 2026-10-15 | minor | Community catalogs (#257, #263, #469, #477, #288): Russian (`ru`, Русский, reviewed and approved by VanTan) and Turkish (`tr`, Türkçe, reviewed by a native speaker), Czech (`cs`, Čeština) and Indonesian (`id`, Bahasa Indonesia) drafts, and the Japanese draft caught up; Preferences ▸ Language lists them and System maps their regional tags. Catalogs may give few and many plural forms; Russian picks one, few or many by its integer rule (11–14 take many), the catalog supplying the text choosing the rule. Only Spanish is held to completeness. Counts re-measured: 1,587 UI keys (38 plural), 559 command labels and 31 menu wordings, 2,177 entries; Spanish 100%, Japanese 2,176, Czech and Indonesian 2,166, Russian and Turkish 2,165, the five older drafts 2,088–2,089 (95%). Localization 39% and 29–51 h unchanged at this rounding | | 2026-10-17 | minor | Chinese showed as boxes on macOS (#406): the font scan also walks the system's downloaded font assets (`/System/Library/AssetsV2` and the older `/System/Library/Assets`, only their `com_apple_MobileAsset_Font*` folders), where current versions keep PingFang SC, and Hiragino Sans GB and Heiti SC join the Chinese UI faces and the text fallbacks. Tested with a folder laid out the same way; not yet run on a Mac | | 2026-10-17 | minor | String counts re-measured from the catalogs: 1,680 UI keys, 561 command labels and 31 menu wordings (2,272 entries); Spanish complete, the other drafts at 95.3–99.2% (strings added since 2026-10-12 show English), es-419 101 entries. Indonesian has a draft (98.8%; it was listed as none); Russian (98.8%, reviewed), Swedish (99.6%, command labels and menu wordings from #599), Turkish (98.7%) and Czech (98.8%) listed with counts; the language picker's list updated; a paragraph repeated by a merge removed. Grade and hours unchanged (Indonesian's draft is offset by the drafts' new gaps) | diff --git a/docs/translating.md b/docs/translating.md index eaa59755..9dfca60c 100644 --- a/docs/translating.md +++ b/docs/translating.md @@ -91,13 +91,9 @@ picker leaves Georgian out. A new CJK or other non-Latin language also needs its three forms. Czech's CLDR `many` applies only to decimals; `trn` takes integer counts. Polish uses `one` for 1 only, `few` like Russian (2–4, 22–24… except 12–14) and `many` for the other integers, including 0 and 21 (e.g. `{n} warstwa`, `{n} warstwy`, `{n} warstw`). -4. To require full coverage, add the code to `COMPLETE` in - [`crates/ui-egui/src/i18n_tests.rs`](../crates/ui-egui/src/i18n_tests.rs). Every later PR that - adds a string then has to translate it into each complete language, so only Spanish is there: - French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech, Indonesian and - Georgian are registered as drafts, Russian and Turkish are reviewed but not required, and a string added - later shows English in them until it is translated. To list what a draft lacks, - add its code to `COMPLETE` locally and run the check. +4. A string added later shows English in every language until someone translates it; a missing + translation never fails a check or a build. `cargo xtask i18n-report` lists what each language + lacks (UI strings, command labels, menu wordings), and `cargo xtask ci` prints the same list. 5. Run the check and look at the panels in the app (longer words must fit the right panel): cargo test -p figcraft-ui-egui i18n @@ -106,9 +102,9 @@ picker leaves Georgian out. A new CJK or other non-Latin language also needs its with "…" and show it in full on hover; elsewhere, look for clipped or wrapped text. The check fails on keys the code uses that `en.toml` lacks (it prints the lines to paste), keys no -code uses, unknown command ids, placeholders that don't match English, and, for complete languages, -anything untranslated. Machine-drafted catalogs stay marked as drafts until a native speaker has -reviewed them; a reviewed catalog stays a draft until a maintainer approves it (its `status` says which). +code uses, unknown command ids and placeholders that don't match English. Machine-drafted catalogs +stay marked as drafts until a native speaker has reviewed them; a reviewed catalog stays a draft +until a maintainer approves it (its `status` says which). ## Adding strings in code @@ -116,11 +112,11 @@ Wrap every string a user sees or a screen reader hears: `tr` for plain text, `tr placeholders, `trn` for counts, `tr_noop` for literals in `const` tables that are looked up later with `tr(label)`. The key must be a string literal as the first argument (the check reads the source). Don't wrap ids, command ids, JSON, log messages, file extensions or document content. Then -add the key to `en.toml` and to each complete language (Spanish and Russian); the drafts may follow later. +add the key to `en.toml`; translations may follow later. Labels from the engine and document crates (tool names, blend modes, easing curves…) are looked up at display with `tr(x.label())`; they are listed in `dynamic_labels` in `i18n_tests.rs`. The importers' fixed notes (the string literals passed to `warn` in `crates/format/src/fig` and `crates/format/src/rest`) are keys too: the import report shows them in the UI language, so a new -note goes into `en.toml` and each complete language. Notes built with `format!` show in English. +note goes into `en.toml`. Notes built with `format!` show in English. diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 81d48471..6c09c921 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -1,7 +1,9 @@ //! Project tasks: `cargo xtask `. //! -//! - `ci` fmt check, clippy -D warnings, tests, layers, assets, wasm check +//! - `ci` fmt check, clippy -D warnings, tests, the translation report, layers, assets, wasm check //! - `test` the workspace tests (nextest when installed) and doctests +//! - `i18n-report` the UI strings, command labels and menu wordings each language lacks (never fails +//! on them: a missing translation reads English) //! - `layers` crate dependency layering check //! - `assets` every file under assets/ and docs/ has a row in ATTRIBUTION.md //! - `parity` menu-catalog coverage → docs/parity-checklist.md @@ -38,6 +40,7 @@ fn main() -> ExitCode { let ok = match task { "ci" => ci(), "test" => test(), + "i18n-report" => i18n_report(), "layers" => layers::check(&root()), "assets" => assets(), "parity" => parity(), @@ -47,7 +50,7 @@ fn main() -> ExitCode { "plugins" => plugins(), "bench" => bench(args.get(1).map_or("1000,10000,50000", String::as_str)), _ => { - eprintln!("tasks: ci, test, layers, assets, parity, version [set X.Y.Z], icon, wasm, plugins, bench [SIZES]"); + eprintln!("tasks: ci, test, i18n-report, layers, assets, parity, version [set X.Y.Z], icon, wasm, plugins, bench [SIZES]"); task == "help" } }; @@ -58,6 +61,7 @@ fn ci() -> bool { run(cargo().args(["fmt", "--all", "--check"])) && run(cargo().args(["clippy", "--workspace", "--all-targets", "--keep-going", "--", "-D", "warnings"])) && test() + && i18n_report() && layers::check(&root()) && assets() && wasm() @@ -77,6 +81,12 @@ fn test() -> bool { units && run(cargo().args(["test", "--workspace", "--doc"])) } +/// Prints what each language's catalog lacks, from the test that lists it. `--workspace --lib` +/// reuses the binaries `test` built. +fn i18n_report() -> bool { + run(cargo().args(["test", "-q", "--workspace", "--lib", "--", "--exact", "i18n::tests::missing_translations_report", "--nocapture"])) +} + fn wasm() -> bool { run(cargo().args([ "check",