From 15a705e23457d502f053e2b7b9fac1876830d403 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:39:59 -0400 Subject: [PATCH] i18n: missing translations are reported, never a failure (#2383) A string a catalog lacks shows in English. Coverage, key-parity and per-language count tests and tests of one language's exact wording are removed; UI wiring tests take their expected text from the catalog. cargo xtask i18n-report lists the missing strings per language and always passes; cargo xtask ci runs it first. --- crates/ui-egui/src/backstage.rs | 5 +- crates/ui-egui/src/formula_locale.rs | 7 - crates/ui-egui/src/i18n.rs | 210 ++--------------- crates/ui-egui/src/i18n/ja.rs | 132 +---------- crates/ui-egui/tests/it/file_menu.rs | 24 +- crates/ui-egui/tests/it/formula_locale.rs | 5 - crates/ui-egui/tests/it/interaction.rs | 50 ++-- .../tests/it/translated_form_fields.rs | 17 +- docs/localization-parity.md | 8 +- xtask/Cargo.toml | 2 +- xtask/src/i18n_report.rs | 218 ++++++++++++++++++ xtask/src/main.rs | 11 +- 12 files changed, 297 insertions(+), 392 deletions(-) create mode 100644 xtask/src/i18n_report.rs diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index c2acf025b..f5bdc20ab 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -1084,11 +1084,8 @@ mod tests { } assert!(age(Language::En, f64::MAX).ends_with(" years ago")); // Other languages put the count where their own words want it. - assert_eq!(age(Language::Ja, 3.0 * DAY), "3 日前"); - assert_eq!(age(Language::De, 3.0 * DAY), "vor 3 Tagen"); - assert_eq!(age(Language::Fr, 90.0), "il y a 1 min"); - assert_eq!(age(Language::It, 3.0 * DAY), "3 giorni fa"); for lang in Language::ALL { + assert!(age(lang, 3.0 * DAY).contains('3'), "{lang:?}: {}", age(lang, 3.0 * DAY)); for seconds in [10.0, 600.0, 7200.0, 1.5 * DAY, 5.0 * DAY, 30.0 * DAY, 200.0 * DAY, 2000.0 * DAY] { let words = age(lang, seconds); assert!(!words.is_empty() && !words.contains('{'), "{lang:?} {seconds}: {words}"); diff --git a/crates/ui-egui/src/formula_locale.rs b/crates/ui-egui/src/formula_locale.rs index 699167934..d53c5fa34 100644 --- a/crates/ui-egui/src/formula_locale.rs +++ b/crates/ui-egui/src/formula_locale.rs @@ -105,13 +105,6 @@ fn split_signature(signature: &str) -> Option<(&str, Vec<&str>)> { Some((head, if inner.is_empty() { Vec::new() } else { inner.split(", ").collect() })) } -/// The catalog names of a signature's arguments (`number` for `[number2]`), without `...`; the -/// function help tables are checked against them. -#[cfg(test)] -pub(crate) fn argument_names(signature: &str) -> Vec<&str> { - split_signature(signature).map(|(_, args)| args.into_iter().map(argument_name).filter(|name| *name != "...").collect()).unwrap_or_default() -} - /// `[number2]` -> `number`. A bracket can open or close on its own (SWITCH's /// `[default_or_value2, result2]`, LAMBDA's `[parameter1, ...]`). fn argument_name(arg: &str) -> &str { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 4156c2cd3..345037b52 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -321,21 +321,14 @@ fn name_sort_key(name: &str) -> String { mod tests { use super::*; - /// Help tables are sorted, unique, and keyed by text the function catalog still has. + /// Help tables are sorted and unique: lookups binary-search them. #[test] - fn function_help_is_sorted_and_keyed_by_current_catalog_text() { - // Lookups binary-search the tables. A key that no longer matches the catalog (an English - // description reworded, an argument renamed) would quietly show English again. - let catalog = gridcraft_engine::cmd::formulas::function_list(); - let descriptions: std::collections::HashSet<&str> = catalog.iter().filter_map(|f| f["description"].as_str()).collect(); - let arguments: std::collections::HashSet<&str> = - catalog.iter().filter_map(|f| f["signature"].as_str()).flat_map(crate::formula_locale::argument_names).collect(); + fn function_help_tables_are_sorted_and_unique() { for language in Language::ALL { let Some((description_table, argument_table)) = language.function_help() else { continue }; - for (table, catalog_text) in [(description_table, &descriptions), (argument_table, &arguments)] { + for table in [description_table, argument_table] { assert!(table.windows(2).all(|pair| pair[0].0 < pair[1].0), "{language:?} function help is not sorted and unique"); for (english, translated) in table { - assert!(catalog_text.contains(english), "{language:?} translates {english:?}, which the function catalog doesn't use"); assert!(!translated.is_empty(), "{language:?}: empty translation of {english:?}"); } } @@ -343,105 +336,27 @@ mod tests { } #[test] - fn translations_are_unique_and_preserve_unknown_text() { - for (i, (en, ja)) in ja::CORE.iter().enumerate() { - assert!(!ja.is_empty()); - assert!(ja::CORE.iter().take(i).all(|(other, _)| en != other)); - assert_eq!(Language::En.tr(en), en.rsplit_once('|').map_or(*en, |(_, label)| label)); - } + fn untranslated_text_falls_back_to_english() { + let label = |key: &'static str| key.rsplit_once('|').map_or(key, |(_, label)| label); for (english, _) in ja::CORE { - assert!(zh::translate(english).is_some_and(|text| !text.is_empty()), "missing Simplified Chinese: {english}"); - assert!(ko::translate(english).is_some_and(|text| !text.is_empty()), "missing Korean: {english}"); - assert!(ru::translate(english).is_some_and(|text| !text.is_empty()), "missing Russian: {english}"); - assert!(pt::translate(english).is_some_and(|text| !text.is_empty()), "missing Portuguese: {english}"); - assert!(es::translate(english).is_some_and(|text| !text.is_empty()), "missing Spanish: {english}"); - assert!(el::translate(english).is_some_and(|text| !text.is_empty()), "missing Greek: {english}"); - assert!(de::translate(english).is_some_and(|text| !text.is_empty()), "missing German: {english}"); - assert!(cs::translate(english).is_some_and(|text| !text.is_empty()), "missing Czech: {english}"); - assert!(fr::translate(english).is_some_and(|text| !text.is_empty()), "missing French: {english}"); - assert!(sr::translate(english).is_some_and(|text| !text.is_empty()), "missing Serbian: {english}"); - assert!(tr::translate(english).is_some_and(|text| !text.is_empty()), "missing Turkish: {english}"); - assert!(pl::translate(english).is_some_and(|text| !text.is_empty()), "missing Polish: {english}"); - assert!(it::translate(english).is_some_and(|text| !text.is_empty()), "missing Italian: {english}"); - assert!(uk::translate(english).is_some_and(|text| !text.is_empty()), "missing Ukrainian: {english}"); - assert!(vi::translate(english).is_some_and(|text| !text.is_empty()), "missing Vietnamese: {english}"); - assert!(nl::translate(english).is_some_and(|text| !text.is_empty()), "missing Dutch: {english}"); - assert!(ro::translate(english).is_some_and(|text| !text.is_empty()), "missing Romanian: {english}"); + assert_eq!(Language::En.tr(english), label(english)); } - assert_eq!(Language::Ja.tr("Data"), "データ"); - assert_eq!(Language::Zh.tr("Data"), "数据"); - assert_eq!(Language::Ko.tr("Data"), "데이터"); - assert_eq!(Language::Ru.tr("Data"), "Данные"); - assert_eq!(Language::PtBr.tr("Data"), "Dados"); - assert_eq!(Language::PtBr.tr_error("Select a chart first."), "Selecione um gráfico primeiro."); - assert_eq!(Language::PtBr.tr_error("Sheet|Unknown failure"), "Sheet|Unknown failure"); - assert_eq!(Language::PtBr.tr_error("command `chart`: Select a chart first."), "Selecione um gráfico primeiro."); - assert_eq!(Language::PtBr.tr_error("Printer Office: permission denied"), "Printer Office: permission denied"); - assert_eq!(Language::Es.tr("Data"), "Datos"); - assert_eq!(Language::El.tr("Data"), "Δεδομένα"); - assert_eq!(Language::El.tr("Page Layout|Orientation"), "Προσανατολισμός σελίδας"); - assert_eq!(Language::El.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::El.tr("Page {page} of {pages}"), "Σελίδα {page} από {pages}"); - assert_eq!(Language::Es.tr("Freeze Panes"), "Inmovilizar paneles"); - assert_eq!(Language::Es.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::De.tr("Data"), "Daten"); - assert_eq!(Language::De.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::Fr.tr("Data"), "Données"); - assert_eq!(Language::Fr.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::Tr.tr("Data"), "Veri"); - assert_eq!(Language::Tr.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::PtBr.tr("Date"), "Data"); - assert_eq!(Language::PtBr.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::Ja.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::Zh.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::Ko.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::Ru.tr("Sheet1!A1"), "Sheet1!A1"); - // Czech "Data" is spelled as in English, so probe it with a label that differs. - assert_eq!(Language::Cs.tr("Data"), "Data"); - assert_eq!(Language::Cs.tr("Formulas"), "Vzorce"); - assert_eq!(Language::Cs.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::Tr.tr("Formulas"), "Formüller"); - // A `context|label` key shows only its label in English and splits the translation. + for language in Language::ALL { + assert_eq!(language.tr("Sheet1!A1"), "Sheet1!A1"); + assert_eq!(language.tr("Not in any catalog|Untranslated"), "Untranslated"); + } + // A `context|label` key shows only its label in English. assert_eq!(Language::En.tr("Page Layout|Orientation"), "Orientation"); - assert_eq!(Language::Ja.tr("Page Layout|Orientation"), "印刷の向き"); - assert_eq!(Language::Ja.tr("Orientation"), "方向"); - assert_eq!(Language::Cs.tr("Page Layout|Orientation"), "Orientace"); - assert_eq!(Language::Cs.tr("Orientation"), "Orientace textu"); - assert_eq!(Language::Sr.tr("Data"), "Podaci"); - assert_eq!(Language::Sr.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::Sr.tr("Page Layout|Orientation"), "Orijentacija"); - assert_eq!(Language::Sr.tr("Orientation"), "Orijentacija"); - assert_eq!(Language::Tr.tr("Page Layout|Orientation"), "Sayfa Yönü"); - assert_eq!(Language::Tr.tr("Orientation"), "Yönlendirme"); - assert_eq!(Language::Pl.tr("Data"), "Dane"); - assert_eq!(Language::Pl.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::Pl.tr("Page Layout|Orientation"), "Orientacja"); - assert_eq!(Language::It.tr("Data"), "Dati"); - assert_eq!(Language::It.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::It.tr("Freeze Panes"), "Blocca riquadri"); - assert_eq!(Language::It.tr("Page Layout|Orientation"), "Orientamento"); - assert_eq!(Language::It.tr("Orientation"), "Orientamento"); - assert_eq!(Language::Uk.tr("Data"), "Дані"); - assert_eq!(Language::Uk.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::Vi.tr("Data"), "Dữ liệu"); - assert_eq!(Language::Vi.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::Vi.tr("Page Layout|Orientation"), "Hướng giấy"); - assert_eq!(Language::Vi.tr("Orientation"), "Hướng chữ"); - assert_eq!(Language::Nl.tr("Data"), "Gegevens"); - assert_eq!(Language::Nl.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::Nl.tr("Page Layout|Orientation"), "Paginastand"); - assert_eq!(Language::Nl.tr("Orientation"), "Richting"); - assert_eq!(Language::Ro.tr("Data"), "Date"); - assert_eq!(Language::Ro.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::Ro.tr("Page Layout|Orientation"), "Orientare"); - assert_eq!(Language::Ro.tr("Orientation"), "Orientare text"); + // Errors drop the `command `id`:` prefix before translating; an untranslated one stays whole. + assert_eq!(Language::PtBr.tr_error("command `chart`: Select a chart first."), Language::PtBr.tr("Select a chart first.")); + assert_eq!(Language::PtBr.tr_error("Sheet|Unknown failure"), "Sheet|Unknown failure"); + assert_eq!(Language::PtBr.tr_error("Printer Office: permission denied"), "Printer Office: permission denied"); assert_eq!(Language::parse("xx"), None); } /// A catalog whose translations slip onto their neighbours' keys still covers every key, so /// check the shape of each translation against its English: placeholders survive, and a - /// one-line label (a menu item, a tooltip) doesn't turn into a two-line ribbon label. French - /// once shipped with over a hundred translations shifted this way ("Close" read "Société"). + /// one-line label (a menu item, a tooltip) doesn't turn into a two-line ribbon label. #[test] fn translations_keep_the_shape_of_their_english() { let placeholders = @@ -457,106 +372,19 @@ mod tests { } } } - for (english, french) in [ - ("Close", "Fermer"), - ("File", "Fichier"), - ("Save", "Enregistrer"), - ("Animations", "Animations"), - ("Company", "Société"), - ("Show Detail", "Afficher le détail"), - ("Solver", "Solveur"), - ] { - assert_eq!(Language::Fr.tr(english), french); - } - } - - #[test] - fn german_cell_and_header_context_menus_are_fully_translated() { - for label in [ - "Cut", - "Copy", - "Paste", - "Paste Special…", - "Insert…", - "Delete…", - "Clear Contents", - "Filter by Selected Cell's Value", - "Sort A to Z", - "Sort Z to A", - "New Comment", - "New Note", - "Format Cells…", - "Pick From Drop-down List…", - "Define Name…", - "Insert Copied Cells", - "Insert Copied Cells…", - "Insert Cut Cells", - "Insert", - "Delete", - "Row Height…", - "AutoFit Row Height", - "Column Width…", - "AutoFit Column Width", - "Hide", - "Unhide", - "Group", - ] { - assert_ne!(Language::De.tr(label), label, "German context menu label missing: {label}"); - } - } - - #[test] - fn sheet_tab_menu_and_autosave_labels_are_translated() { - let labels = [ - "Insert Sheet", - "Delete", - "Rename", - "Move or Copy…", - "Duplicate", - "Tab Color", - "No Color", - "Hide", - "Unhide…", - "Protect Sheet…", - "Unprotect Sheet…", - "Select All Sheets", - "AutoSave", - "AutoSave: save after every change (workbooks saved to a file)", - "Previous sheet", - "Next sheet", - "All sheets", - "New sheet (⇧F11)", - ]; - for language in [Language::Es, Language::De, Language::Fr, Language::Ko, Language::Ru, Language::Zh, Language::Sr] { - for label in labels { - assert_ne!(language.tr(label), label, "{} lacks a translation of {label:?}", language.code()); - } - } - assert_eq!(Language::Es.tr("AutoSave"), "Autoguardado"); } #[test] fn translations_never_show_the_context_prefix() { // `tr` returns a found translation as it is, so a catalog that keeps `context|` in its // value shows it on screen (File › Options read "Options|Général" in French). - for language in Language::ALL { - for (english, _) in ja::CORE { + let keys = ja::CORE.iter().map(|(english, _)| *english).chain(["Options|General", "Options|Save", "Sheet Options|View"]); + for english in keys { + for language in Language::ALL { let shown = language.tr(english); assert!(!shown.contains('|'), "{} shows a context prefix for {english}: {shown}", language.code()); } } - assert_eq!(Language::Fr.tr("Options|General"), "Général"); - assert_eq!(Language::Fr.tr("Options|Save"), "Enregistrer"); - assert_eq!(Language::Fr.tr("Sheet Options|View"), "Affichage"); - assert_eq!(Language::Sr.tr("Options|General"), "Opšte"); - assert_eq!(Language::Sr.tr("Options|Save"), "Sačuvaj"); - assert_eq!(Language::Sr.tr("Sheet Options|View"), "Prikaz"); - assert_eq!(Language::Nl.tr("Options|General"), "Algemeen"); - assert_eq!(Language::Nl.tr("Options|Save"), "Opslaan"); - assert_eq!(Language::Nl.tr("Sheet Options|View"), "Weergeven"); - assert_eq!(Language::Ro.tr("Options|General"), "General"); - assert_eq!(Language::Ro.tr("Options|Save"), "Salvare"); - assert_eq!(Language::Ro.tr("Sheet Options|View"), "Vizualizare"); } #[test] diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 482852d8d..2df4cfc11 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1,12 +1,12 @@ //! Japanese translations for the interface strings in `i18n.rs`; labels follow Excel for Mac's //! Japanese wording, and anything absent falls back to English. //! -//! [`CORE`] is the shared reference catalog every language translates: the ribbon, sheet tabs, -//! widgets and the most common dialog labels. [`DIALOGS`] adds the dialog titles and labels, -//! message boxes, Backstage/File-view items, template names and native-menu strings the Brazilian -//! Portuguese catalog (`pt.rs`) covers on top of it. The two lists never overlap. +//! [`CORE`] is the shared reference set: the ribbon, sheet tabs, widgets and the most common dialog +//! labels. [`DIALOGS`] adds the dialog titles and labels, message boxes, Backstage/File-view items, +//! template names and native-menu strings. The two lists never overlap. A string a language doesn't +//! translate shows in English; `cargo xtask i18n-report` lists them per language. -/// English label → Japanese, the shared reference set every catalog covers. +/// English label → Japanese, the shared reference set. pub(super) const CORE: &[(&str, &str)] = &[ ("Paste", "貼り付け"), ("Paste Values", "値の貼り付け"), @@ -1150,99 +1150,17 @@ mod tests { DIALOGS.iter().chain(CORE).map(|(english, _)| *english) } - /// The decoded value of the Rust string literal starting at byte `start` (its opening `"`), and - /// the byte index just past its closing quote. Escapes (`\n`, `\"`, `\\`, `\t`, `\u{…}`) are - /// decoded, so the keys compare equal to the ones in the tables above. - fn string_literal(source: &str, start: usize) -> Option<(String, usize)> { - if !source[start..].starts_with('"') { - return None; - } - let mut value = String::new(); - let mut chars = source[start + 1..].char_indices(); - while let Some((offset, c)) = chars.next() { - match c { - '"' => return Some((value, start + 1 + offset + 1)), - '\\' => match chars.next().map(|(_, c)| c) { - Some('n') => value.push('\n'), - Some('t') => value.push('\t'), - Some('r') => value.push('\r'), - Some('0') => value.push('\0'), - Some('\\') => value.push('\\'), - Some('"') => value.push('"'), - Some('\'') => value.push('\''), - Some('u') => { - let mut hex = String::new(); - for (_, c) in chars.by_ref() { - if c == '}' { - break; - } - if c != '{' { - hex.push(c); - } - } - if let Some(c) = u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) { - value.push(c); - } - } - // A line continuation (backslash, newline, indentation) adds nothing. - _ => {} - }, - _ => value.push(c), - } - } - None - } - - /// Every key `pt.rs` translates, read out of its source with a small scanner: a match arm is a - /// string literal whose `=>` is followed by another string literal, which covers the block form - /// (`"key" => { "value" }`) as well. Parsing the real source keeps the two catalogs from - /// drifting apart again instead of checking a remembered count. - fn portuguese_keys() -> Vec { - let source = include_str!("pt.rs"); - let mut literals: Vec<(String, usize, usize)> = Vec::new(); - let mut i = 0; - while i < source.len() { - let rest = &source[i..]; - if rest.starts_with("//") { - i += rest.find('\n').unwrap_or(rest.len()); - continue; - } - if rest.starts_with('"') { - let (value, end) = string_literal(source, i).unwrap(); - literals.push((value, i, end)); - i = end; - continue; - } - i += rest.chars().next().unwrap().len_utf8(); - } - let mut keys = Vec::new(); - for pair in literals.windows(2) { - let (key, _, end) = &pair[0]; - let start = pair[1].1; - let gap = source[*end..start].trim(); - if gap == "=>" || gap == "=> {" { - keys.push(key.clone()); - } - } - keys - } - #[test] - fn keys_are_unique_and_every_value_is_translated() { + fn keys_are_unique_and_no_value_is_blank() { let mut seen = HashSet::new(); for (english, japanese) in DIALOGS.iter().chain(CORE) { - assert!(!japanese.is_empty(), "empty translation for {english:?}"); assert!(japanese.chars().any(|c| !c.is_whitespace()), "blank translation for {english:?}"); assert!(seen.insert(*english), "duplicate key across CORE and DIALOGS: {english:?}"); } - assert_eq!(seen.len(), catalog_keys().count()); - assert_eq!(CORE.len(), 392, "the shared reference set every catalog covers"); - assert_eq!(DIALOGS.len(), 562, "the dialog, message and menu strings beyond it"); - assert_eq!(catalog_keys().count(), 954); } #[test] - fn extra_labels_are_translated_and_do_not_overlap_the_catalog() { + fn extra_labels_do_not_overlap_the_catalog() { let catalog: HashSet<&str> = catalog_keys().collect(); let mut seen = HashSet::new(); for (english, japanese) in EXTRA { @@ -1252,40 +1170,4 @@ mod tests { assert_eq!(translate(english), Some(*japanese)); } } - - #[test] - fn covers_every_key_the_portuguese_catalog_translates() { - let portuguese = portuguese_keys(); - assert_eq!(portuguese.len(), 954, "pt.rs's arms changed: add the new keys here and update the count"); - let japanese: HashSet<&str> = catalog_keys().collect(); - for key in &portuguese { - assert!(japanese.contains(key.as_str()), "the Portuguese catalog translates {key:?}, the Japanese one does not"); - } - assert_eq!(japanese.len(), portuguese.len(), "the two catalogs hold the same key set"); - } - - #[test] - fn high_value_strings_use_excel_japanese_wording() { - for (english, japanese) in [ - ("OK", "OK"), - ("Cancel", "キャンセル"), - ("Don't Save", "保存しない"), - ("Text Box", "テキスト ボックス"), - ("Row Height", "行の高さ"), - ("Column Width", "列の幅"), - ("Sort", "並べ替え"), - ("Filter", "フィルター"), - ("Conditional Formatting", "条件付き書式"), - ("Do you want to save the changes you made to {title}?", "{title} への変更を保存しますか?"), - ("Save a copy as .{format}", ".{format} としてコピーを保存"), - ("Format Cells|Fill", "塗りつぶし"), - ("Insert\nSlicer", "スライサーの\n挿入"), - ] { - assert_eq!(translate(english), Some(japanese), "{english:?}"); - assert_eq!(crate::i18n::Language::Ja.tr(english), japanese, "{english:?}"); - } - // A `context|label` key shows only its label in English; an unknown label keeps the text. - assert_eq!(crate::i18n::Language::En.tr("Format Cells|Fill"), "Fill"); - assert_eq!(crate::i18n::Language::Ja.tr("Not in any catalog"), "Not in any catalog"); - } } diff --git a/crates/ui-egui/tests/it/file_menu.rs b/crates/ui-egui/tests/it/file_menu.rs index 047efe1b1..092876b8d 100644 --- a/crates/ui-egui/tests/it/file_menu.rs +++ b/crates/ui-egui/tests/it/file_menu.rs @@ -4,6 +4,7 @@ use std::sync::{Arc, Mutex}; use egui_kittest::kittest::Queryable as _; use gridcraft_engine::{Session, core::CellRef, core::Value}; +use gridcraft_ui_egui::i18n::Language; use gridcraft_ui_egui::{Services, SheetApp}; fn harness(services: Services) -> egui_kittest::Harness<'static, SheetApp> { @@ -134,30 +135,25 @@ fn file_options_opens_the_options_page_and_pages_are_reachable_by_command() { #[test] fn translated_options_headings_show_no_context_prefix() { - for (code, headings, prefixed, (view, view_prefixed)) in [ - ("fr", ["Général", "Enregistrer"], ["Options|Général", "Options|Enregistrer"], ("Affichage", "Options de la feuille|Affichage")), - ("sr", ["Opšte", "Sačuvaj"], ["Opcije|Opšte", "Opcije|Sačuvaj"], ("Prikaz", "Opcije lista|Prikaz")), - ("nl", ["Algemeen", "Opslaan"], ["Opties|Algemeen", "Opties|Opslaan"], ("Weergeven", "Bladopties|Weergeven")), - ("ro", ["General", "Salvare"], ["Opțiuni|General", "Opțiuni|Salvare"], ("Vizualizare", "Opțiuni foaie|Vizualizare")), - ] { + for language in [Language::Fr, Language::Sr, Language::Nl, Language::Ro] { let mut h = harness(Services::default()); - h.state_mut().run("app.language.set", serde_json::json!({"language": code})).unwrap(); + h.state_mut().run("app.language.set", serde_json::json!({"language": language.code()})).unwrap(); h.state_mut().run("file.options", serde_json::json!({})).unwrap(); h.run_steps(3); assert_eq!(page(&h), Some("options")); - for heading in headings { - assert!(h.query_all_by_label(heading).next().is_some(), "{code}: no {heading} heading"); - } - for label in prefixed { - assert!(h.query_by_label(label).is_none(), "{code}: {label} shows its context"); + for key in ["Options|General", "Options|Save"] { + let heading = language.tr(key); + assert!(!heading.contains('|'), "{language:?}: {key} shows its context: {heading}"); + assert!(h.query_all_by_label(heading).next().is_some(), "{language:?}: no {heading} heading"); } // Page Layout › Sheet Options: the Gridlines and Headings "View" checkboxes had the same // prefix. The View tab may share the word, so count at least the two checkboxes. h.state_mut().run("ui.backstage", serde_json::json!({"page": "close"})).unwrap(); h.state_mut().ui.ribbon_tab = "Page Layout".into(); h.run_steps(3); - assert!(h.query_by_label(view_prefixed).is_none(), "{code}: {view_prefixed} shows its context"); - assert!(h.query_all_by_label(view).count() >= 2, "{code}: the Sheet Options checkboxes don't read {view}"); + let view = language.tr("Sheet Options|View"); + assert!(!view.contains('|'), "{language:?}: Sheet Options|View shows its context: {view}"); + assert!(h.query_all_by_label(view).count() >= 2, "{language:?}: the Sheet Options checkboxes don't read {view}"); } } diff --git a/crates/ui-egui/tests/it/formula_locale.rs b/crates/ui-egui/tests/it/formula_locale.rs index 16f4dac15..98ab70d57 100644 --- a/crates/ui-egui/tests/it/formula_locale.rs +++ b/crates/ui-egui/tests/it/formula_locale.rs @@ -176,14 +176,12 @@ fn portuguese_interface_retains_its_canonical_formula_fallback() { let mut app = app(); app.run("app.language.set", json!({"code":"pt-BR"})).unwrap(); assert_eq!(app.ui.language, Language::PtBr); - assert_eq!(app.ui.language.tr("Data"), "Dados"); assert!(enter(&mut app, "=SUM(1.5,2.25)")); app.begin_edit(None, true); assert_eq!(app.editor.as_ref().unwrap().text, "=SUM(1.5,2.25)"); assert!(!enter(&mut app, "=SUMA(1;2)")); let restored: UiState = serde_json::from_str(&serde_json::to_string(&app.ui).unwrap()).unwrap(); assert_eq!(restored.language, Language::PtBr); - assert_eq!(Language::Es.tr("Page Layout|Orientation"), "Orientación"); } #[test] @@ -193,7 +191,6 @@ fn czech_interface_retains_its_canonical_formula_fallback() { let mut app = app(); app.run("app.language.set", json!({"code":"cs-CZ"})).unwrap(); assert_eq!(app.ui.language, Language::Cs); - assert_eq!(app.ui.language.tr("Formulas"), "Vzorce"); assert!(enter(&mut app, "=SUM(1.5,2.25)")); app.begin_edit(None, true); assert_eq!(app.editor.as_ref().unwrap().text, "=SUM(1.5,2.25)"); @@ -225,8 +222,6 @@ fn turkish_interface_retains_its_canonical_formula_fallback() { let mut app = app(); app.run("app.language.set", json!({"code":"tr-TR"})).unwrap(); assert_eq!(app.ui.language, Language::Tr); - assert_eq!(app.ui.language.tr("Data"), "Veri"); - assert_eq!(app.ui.language.tr("Formulas"), "Formüller"); assert!(enter(&mut app, "=SUM(1.5,2.25)")); app.begin_edit(None, true); assert_eq!(app.editor.as_ref().unwrap().text, "=SUM(1.5,2.25)"); diff --git a/crates/ui-egui/tests/it/interaction.rs b/crates/ui-egui/tests/it/interaction.rs index 46e981d0a..94fefe6d3 100644 --- a/crates/ui-egui/tests/it/interaction.rs +++ b/crates/ui-egui/tests/it/interaction.rs @@ -4,6 +4,7 @@ use egui::{Event, Key, Modifiers, PointerButton}; use egui_kittest::kittest::Queryable as _; use gridcraft_engine::Session; use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_ui_egui::i18n::Language; use gridcraft_ui_egui::{SheetApp, grid::Geo}; use serde_json::json; @@ -400,8 +401,7 @@ fn renders_every_ribbon_tab_without_panicking() { } #[test] -fn language_switch_translates_the_ribbon_and_persists() { - use gridcraft_ui_egui::i18n::Language; +fn language_switch_renders_the_ribbon_and_persists() { let mut h = harness(blank()); // English by default whatever the host's locale: only the desktop app reads the system language. assert_eq!(h.state().ui.language, Language::En); @@ -415,31 +415,13 @@ fn language_switch_translates_the_ribbon_and_persists() { for id in ["app.language.set", "app.language.english", "app.language.japanese"] { assert!(ids.contains(&id), "engine.commands lists {id}"); } - for (language, expected) in [ - (Language::Zh, "数据"), - (Language::Ja, "データ"), - (Language::Ko, "데이터"), - (Language::Ru, "Данные"), - (Language::PtBr, "Dados"), - (Language::De, "Daten"), - (Language::Fr, "Données"), - (Language::Tr, "Veri"), - (Language::It, "Dati"), - ] { + for language in Language::ALL { h.state_mut().run("app.language.set", json!({"language": language.code()})).unwrap(); h.run_steps(2); // every locale renders the ribbon without panicking assert_eq!(h.state().ui.language, language); - assert_eq!(language.tr("Data"), expected); let restored: gridcraft_ui_egui::UiState = serde_json::from_str(&serde_json::to_string(&h.state().ui).unwrap()).unwrap(); assert_eq!(restored.language, language); } - // Czech spells "Data" as English does, so the Czech table is checked on a label that differs. - h.state_mut().run("app.language.set", json!({"language": "cs"})).unwrap(); - h.run_steps(2); - assert_eq!(h.state().ui.language, Language::Cs); - assert_eq!(Language::Cs.tr("Formulas"), "Vzorce"); - let restored: gridcraft_ui_egui::UiState = serde_json::from_str(&serde_json::to_string(&h.state().ui).unwrap()).unwrap(); - assert_eq!(restored.language, Language::Cs); h.state_mut().run("app.language.set", json!({"language": "en"})).unwrap(); assert_eq!(h.state().ui.language, Language::En); } @@ -452,16 +434,21 @@ fn pivot_fields_pane_follows_the_interface_language() { h.state_mut().run("insert.pivotTable", json!({"source": "Sheet1!$A$1:$B$3", "rows": ["Region"]})).unwrap(); h.state_mut().run("app.language.set", json!({"language": "es"})).unwrap(); h.run_steps(4); - for label in ["Campos de tabla dinámica", "Filas", "Valores", "Actualizar", "Campo calculado…"] { - assert!(h.query_by_label(label).is_some(), "the pane shows `{label}`"); + let es = |english| Language::Es.tr(english); + for english in ["PivotTable Fields", "Rows", "Values", "Refresh", "Calculated Field…"] { + assert!(h.query_by_label(es(english)).is_some(), "the pane shows `{}`", es(english)); + } + if es("PivotTable Fields") != "PivotTable Fields" { + assert!(h.query_by_label("PivotTable Fields").is_none()); } - assert!(h.query_by_label("PivotTable Fields").is_none()); h.get_by_label("Region ▾").click(); h.run_steps(2); - for label in ["Mover a rótulos de columna", "Agrupar por años", "Quitar campo"] { - assert!(h.query_by_label(label).is_some(), "the field menu shows `{label}`"); + for english in ["Move to Column Labels", "Group by Years", "Remove Field"] { + assert!(h.query_by_label(es(english)).is_some(), "the field menu shows `{}`", es(english)); + } + if es("Remove Field") != "Remove Field" { + assert!(h.query_by_label("Remove Field").is_none()); } - assert!(h.query_by_label("Remove Field").is_none()); } #[test] @@ -492,9 +479,12 @@ fn format_cells_draws_its_labels_in_the_interface_language() { h.state_mut().run("app.language.set", json!({"language": "de"})).unwrap(); h.state_mut().open_dialog("formatCells", json!({"tab": "Number"})); h.run_steps(2); - assert!(h.query_by_label("Wissenschaftlich").is_some(), "the Number tab's categories are translated"); - assert!(h.query_by_label("Scientific").is_none(), "no English category is left"); - h.get_by_label("Wissenschaftlich").click(); + let scientific = Language::De.tr("Scientific"); + assert!(h.query_by_label(scientific).is_some(), "the Number tab's categories are translated"); + if scientific != "Scientific" { + assert!(h.query_by_label("Scientific").is_none(), "no English category is left"); + } + h.get_by_label(scientific).click(); h.run_steps(2); let style = &h.state().dialog.as_ref().unwrap().values["style"]; assert_eq!(style["num_fmt"], json!("0.00E+00"), "a translated category still applies its code"); diff --git a/crates/ui-egui/tests/it/translated_form_fields.rs b/crates/ui-egui/tests/it/translated_form_fields.rs index 35847a425..b284bf786 100644 --- a/crates/ui-egui/tests/it/translated_form_fields.rs +++ b/crates/ui-egui/tests/it/translated_form_fields.rs @@ -4,6 +4,7 @@ use egui_kittest::kittest::Queryable as _; use gridcraft_engine::Session; use gridcraft_ui_egui::SheetApp; +use gridcraft_ui_egui::i18n::Language; use serde_json::json; fn harness() -> egui_kittest::Harness<'static, SheetApp> { @@ -23,19 +24,17 @@ fn harness() -> egui_kittest::Harness<'static, SheetApp> { #[test] fn form_fields_are_translated() { - for (dialog, translated, english) in [ - ("pageSetup", ["Margem esquerda (pol.):", "Rodapé (pol.):"], ["Left margin (in):", "Footer (in):"]), - ("insertPaste", ["Deslocar células para a direita", "Deslocar células para baixo"], ["Shift cells right", "Shift cells down"]), - ] { + for (dialog, labels) in [("pageSetup", ["Left margin (in):", "Footer (in):"]), ("insertPaste", ["Shift cells right", "Shift cells down"])] { let mut h = harness(); h.state_mut().run("app.language.set", json!({"language": "pt"})).unwrap(); h.state_mut().open_dialog(dialog, json!({})); h.run_steps(3); - for label in translated { - assert!(h.query_by_label_contains(label).is_some(), "{dialog}: no {label}"); - } - for label in english { - assert!(h.query_by_label_contains(label).is_none(), "{dialog}: {label} is untranslated"); + for english in labels { + let translated = Language::PtBr.tr(english); + assert!(h.query_by_label_contains(translated).is_some(), "{dialog}: no {translated}"); + if translated != english { + assert!(h.query_by_label_contains(english).is_none(), "{dialog}: {english} is untranslated"); + } } } } diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 979ac1b81..39a9b3f91 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-21 · **Change:** minor (the Japanese catalog covers the dialogs, messages and menus; Dutch interface language; Romanian interface catalog) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (missing translations are listed by `cargo xtask i18n-report` and never fail a check) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Per-language status against Excel, which ships its UI in dozens of languages (Windows language packs cover 100+), with localized function names, argument separators, number and date formats, @@ -96,8 +96,9 @@ language (none so far) and of localized function names. ## Infrastructure (do first) 1. Extend the catalog from the ribbon to every user-visible string (dialogs, menus, task panes, - status and error messages, command labels, function descriptions), with a missing-key check - in `cargo xtask ci`. Language switching and the system-language default already exist (#31). + status and error messages, command labels, function descriptions). A string a catalog lacks + shows in English and never fails a check: `cargo xtask i18n-report` lists the missing strings + per language, and `cargo xtask ci` runs it first. Language switching and the system-language default already exist (#31). 2. Locale-aware formulas: localized function-name tables and separators at the formula-bar boundary only (files keep en-US invariant names), locale number and date formats, list separators in CSV. @@ -109,6 +110,7 @@ language (none so far) and of localized function names. | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | Missing translations are reported, never a failure: `cargo xtask i18n-report` prints, per language, the strings other catalogs translate and it lacks (plus Spanish function help rows keyed by English the function catalog no longer has) and always passes; `cargo xtask ci` runs it first. Coverage and per-language parity tests and tests of one language's exact wording are gone; UI tests take the expected text from the catalog. Duplicate-key, placeholder, context-prefix and line-shape checks stay | | 2026-10-21 | minor | Romanian interface (`ro`, Română): 970 labels covering every key of the other catalogs (`crates/ui-egui/src/i18n/ro.rs`), `ro-*` system locales, the View-tab switcher and `app.language.set`; context keys show no prefix (File › Options test). English formulas and formats; AI-assisted draft pending native-speaker review | | 2026-10-20 | minor | Japanese: furigana (`rPh` phonetic runs and `` in `xl/sharedStrings.xml`) is no longer deleted by an open + save; it is kept on the workbook and written back (a `` without runs is kept too, while duplicate texts with different readings keep only the first, reported on open). Display of the readings is still missing (`crates/xlsx/src/tests/phonetic_tests.rs`) | | 2026-10-20 | minor | Japanese catalog (`i18n/ja.rs`) covers every key the Brazilian Portuguese catalog translates: the 398-label shared core moved verbatim plus 545 dialog, message, Backstage and menu strings — 943 keys, ~12% → ~43%. A test parses `pt.rs`'s source, so the two catalogs can't drift apart again. Portuguese re-measured at 943 (was 923) | diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index cc3da3bb2..c40c97697 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -4,7 +4,7 @@ version.workspace = true edition.workspace = true license.workspace = true publish = false -description = "GridCraft workspace tooling: cargo xtask ci | layers | assets | wasm | parity | version | stats | ico" +description = "GridCraft workspace tooling: cargo xtask ci | layers | assets | wasm | parity | i18n-report | version | stats | ico" [lints] workspace = true diff --git a/xtask/src/i18n_report.rs b/xtask/src/i18n_report.rs new file mode 100644 index 000000000..d72a887fa --- /dev/null +++ b/xtask/src/i18n_report.rs @@ -0,0 +1,218 @@ +//! `cargo xtask i18n-report`: the interface strings each language doesn't translate yet, read from +//! the catalogs in `crates/ui-egui/src/i18n/`. A string a catalog lacks shows in English, so the +//! report always succeeds; `cargo xtask ci` runs it first so the list shows in the gate log. +//! +//! The reference set is every key any catalog translates. A catalog is a source file with a +//! `fn translate`; its keys are the string literals that start a match arm (`"key" => …`) or open a +//! table row (`("key", …)`). With the `engine` feature it also lists function help rows keyed by +//! English the function catalog no longer has: those show the English help. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt::Write as _; +use std::path::Path; + +/// Missing keys listed per language; the rest are counted. +const LIST_CAP: usize = 50; + +pub fn run(root: &Path) -> Result<(), String> { + let dir = root.join("crates/ui-egui/src/i18n"); + let mut catalogs = BTreeMap::new(); + let mut help = BTreeMap::new(); + let entries = match std::fs::read_dir(&dir) { + Ok(entries) => entries, + Err(e) => { + println!("i18n-report: can't read {}: {e}", dir.display()); + return Ok(()); + } + }; + for entry in entries.flatten() { + let path = entry.path(); + let Some(stem) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else { continue }; + if path.extension().and_then(|e| e.to_str()) != Some("rs") { + continue; + } + let Ok(source) = std::fs::read_to_string(&path) else { continue }; + if let Some(language) = stem.strip_suffix("_functions") { + help.insert(language.to_owned(), catalog_keys(&source)); + } else if source.contains("fn translate(") { + catalogs.insert(stem, catalog_keys(&source)); + } + } + print!("{}", render(&catalogs)); + print!("{}", function_help(&help)); + Ok(()) +} + +/// Per language: how many reference keys it lacks, then the first [`LIST_CAP`] of them. +pub fn render(catalogs: &BTreeMap>) -> String { + let reference: BTreeSet<&String> = catalogs.values().flatten().collect(); + let mut out = + format!("i18n-report: {} interface strings across {} catalogs; a string a catalog lacks shows in English\n", reference.len(), catalogs.len()); + for (language, keys) in catalogs { + let missing: Vec<&&String> = reference.iter().filter(|key| !keys.contains(key.as_str())).collect(); + let _ = writeln!(out, " {language}: {} missing", missing.len()); + list(&mut out, missing.iter().map(|key| key.as_str()), missing.len()); + } + out +} + +fn list<'a>(out: &mut String, keys: impl Iterator, count: usize) { + for key in keys.take(LIST_CAP) { + let _ = writeln!(out, " {key:?}"); + } + if count > LIST_CAP { + let _ = writeln!(out, " … and {} more", count - LIST_CAP); + } +} + +/// Function help rows whose English the function catalog no longer has (an English description +/// reworded, an argument renamed). +#[cfg(feature = "engine")] +fn function_help(help: &BTreeMap>) -> String { + let catalog = gridcraft_engine::cmd::formulas::function_list(); + let mut known: BTreeSet<&str> = catalog.iter().filter_map(|f| f["description"].as_str()).collect(); + known.extend(catalog.iter().filter_map(|f| f["signature"].as_str()).flat_map(argument_names)); + let mut out = String::new(); + for (language, keys) in help { + let stale: Vec<&str> = keys.iter().map(String::as_str).filter(|key| !known.contains(key)).collect(); + let _ = writeln!(out, " {language} function help: {} rows, {} keyed by English the function catalog no longer has", keys.len(), stale.len()); + list(&mut out, stale.iter().copied(), stale.len()); + } + out +} + +#[cfg(not(feature = "engine"))] +fn function_help(_: &BTreeMap>) -> String { + String::new() +} + +/// `SUM(number1, [number2], ...)` -> `number`, `number`: brackets, index and `...` dropped. +#[cfg(feature = "engine")] +fn argument_names(signature: &str) -> Vec<&str> { + let Some(inner) = signature.split_once('(').and_then(|(_, rest)| rest.strip_suffix(')')) else { return Vec::new() }; + inner + .split(", ") + .map(|arg| { + let arg = arg.strip_prefix('[').unwrap_or(arg); + let arg = arg.strip_suffix(']').unwrap_or(arg); + arg.trim_end_matches(|c: char| c.is_ascii_digit()) + }) + .filter(|name| !name.is_empty() && *name != "...") + .collect() +} + +/// The keys of one catalog source: string literals that start a match arm or open a table row. +/// Comments and the test module are skipped. +pub fn catalog_keys(source: &str) -> BTreeSet { + let source = source.split("#[cfg(test)]").next().unwrap_or(source); + let mut keys = BTreeSet::new(); + for (value, start, end) in literals(source) { + let before = source.get(..start).unwrap_or("").trim_end(); + let after = source.get(end..).unwrap_or("").trim_start(); + if after.starts_with("=>") || (before.ends_with('(') && after.starts_with(',')) { + keys.insert(value); + } + } + keys +} + +/// Every string literal outside `//` comments: its decoded value, and the byte range from its +/// opening quote to just past its closing one. +fn literals(source: &str) -> Vec<(String, usize, usize)> { + let mut out = Vec::new(); + let mut chars = source.char_indices().peekable(); + while let Some((i, c)) = chars.next() { + if c == '/' && source.get(i..).is_some_and(|rest| rest.starts_with("//")) { + for (_, c) in chars.by_ref() { + if c == '\n' { + break; + } + } + continue; + } + if c != '"' { + continue; + } + let mut value = String::new(); + let mut end = source.len(); + while let Some((j, c)) = chars.next() { + match c { + '"' => { + end = j + 1; + break; + } + '\\' => match chars.next().map(|(_, c)| c) { + Some('n') => value.push('\n'), + Some('t') => value.push('\t'), + Some('r') => value.push('\r'), + Some('0') => value.push('\0'), + Some(c @ ('\\' | '"' | '\'')) => value.push(c), + Some('u') => { + let mut hex = String::new(); + for (_, c) in chars.by_ref() { + match c { + '}' => break, + '{' => {} + c => hex.push(c), + } + } + if let Some(c) = u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) { + value.push(c); + } + } + // A line continuation skips the newline and the next line's indentation. + Some('\n') => { + while chars.peek().is_some_and(|(_, c)| c.is_whitespace()) { + chars.next(); + } + } + _ => {} + }, + c => value.push(c), + } + } + out.push((value, i, end)); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_match_arms_and_table_rows_and_lists_what_each_catalog_lacks() { + let matched = r#" +pub(super) fn translate(text: &str) -> Option<&'static str> { + Some(match text { + "Paste" => "Einfügen", // "Commented" => "x", + "Line\nbreak \"quoted\"" => { + "Zeile" + } + _ => return None, + }) +} +#[cfg(test)] +mod tests { const T: &[(&str, &str)] = &[("Test only", "x")]; } +"#; + let table = r#" +pub(super) const CORE: &[(&str, &str)] = &[ + ("Paste", "貼り付け"), + ( + "Long \u{2026}", + "長い", + ), +]; +pub(super) fn translate(text: &str) -> Option<&'static str> { None } +"#; + let de = catalog_keys(matched); + assert_eq!(de.iter().map(String::as_str).collect::>(), ["Line\nbreak \"quoted\"", "Paste"]); + let ja = catalog_keys(table); + assert_eq!(ja.iter().map(String::as_str).collect::>(), ["Long …", "Paste"]); + + let report = render(&BTreeMap::from([("de".to_owned(), de), ("ja".to_owned(), ja)])); + assert!(report.starts_with("i18n-report: 3 interface strings across 2 catalogs"), "{report}"); + assert!(report.contains(" de: 1 missing\n \"Long …\"\n"), "{report}"); + assert!(report.contains(" ja: 1 missing\n \"Line\\nbreak \\\"quoted\\\"\"\n"), "{report}"); + } +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 0c9f5547d..fe54b0f4e 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -6,6 +6,7 @@ #![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic))] mod assets; +mod i18n_report; mod ico; mod keytips; mod layers; @@ -21,11 +22,12 @@ const USAGE: &str = "\ usage: cargo xtask commands: - ci fmt --check, clippy -D warnings, test, doctest, assets, layers, wasm, parity --check, - keytips --check (stops at the first failure; the parity check runs only if - docs/parity-checklist.md exists) + ci i18n-report, fmt --check, clippy -D warnings, test, doctest, assets, layers, wasm, + parity --check, keytips --check (stops at the first failure; the parity check runs + only if docs/parity-checklist.md exists) test workspace tests (cargo nextest when installed, else cargo test), then doctests assets check that every icon/image/font/sample file is attributed in ATTRIBUTION.md + i18n-report list the interface strings each language doesn't translate yet (always passes) layers enforce the crate dependency layering (AGENTS.md \"Layering\") wasm cargo check --target wasm32-unknown-unknown for the L0-L5 crates (+ gridcraft-web), one call parity [--check] [--out PATH] @@ -44,6 +46,7 @@ fn main() -> ExitCode { let rest: Vec<&str> = args.iter().skip(1).map(String::as_str).collect(); let result = match args.first().map(String::as_str) { Some("assets") => assets::run(&root()), + Some("i18n-report") => i18n_report::run(&root()), Some("layers") => cmd_layers(), Some("wasm") => cmd_wasm(), Some("test") => cmd_test().and_then(|()| cmd_doctest()), @@ -194,6 +197,8 @@ fn cmd_doctest() -> Result<(), String> { fn cmd_ci() -> Result<(), String> { type Step = (&'static str, Box Result<(), String>>); let steps: Vec = vec![ + // Missing translations never fail the gate; the list goes first so every log shows it. + ("i18n-report", Box::new(|| i18n_report::run(&root()))), ( "fmt", Box::new(|| {