diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 66bf92947..0709aca18 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4259,11 +4259,34 @@ mod tests { #[test] fn command_search_respects_turkish_i_pairs_and_keeps_canonical_matches() { use egui_kittest::kittest::Queryable; + // Turkish casing follows the language code; the labels are fixtures with both I pairs. + const LABELS: &str = + "\tItalic\tİtalik\n\tHome\tGiriş\n\tFont\tYazı Tipi\n\tTable of Contents\tİçindekiler Tablosu\n\tReferences\tBaşvurular\n"; + let reg = app_with("").session.registry.clone(); + let italic = reg.get("format.italic").unwrap(); + let table = reg.get("insert.table").unwrap(); + let toc = reg.all().iter().find(|c| c.label == "Table of Contents" && c.location == "References › Table of Contents").unwrap(); + crate::i18n::set_current(crate::i18n::fixture_lang("tr", LABELS)); + for query in ["italik", "İTALİK", "YAZI TİPİ", "FORMAT.ITALIC", "ITALIC"] { + assert!(command_matches(italic, query), "tr: {query}"); + } + for query in ["içindekiler", "TABLE OF CONTENTS"] { + assert!(command_matches(toc, query), "tr: {query}"); + } + for query in ["INSERT", "INSERT.TABLE"] { + assert!(command_matches(table, query), "tr: {query}"); + } + assert_eq!(crate::i18n::lowercase("Iİıi"), "ıiıi"); + // The same labels under another language's code use Unicode's default casing. + crate::i18n::set_current(crate::i18n::fixture_lang("x-fixture", LABELS)); + assert!(!command_matches(italic, "italik"), "İ lowercases to i plus a combining dot"); + assert_eq!(crate::i18n::lowercase("Iİıi"), "ii\u{307}ıi"); + crate::i18n::set_current(crate::i18n::Lang::EN); + let mut fonts = false; let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state( move |ui, app: &mut WordApp| { let ctx = ui.ctx().clone(); - crate::i18n::set_current(crate::i18n::Lang::from_pref(&app.ui.language)); if fonts { show(app, &ctx); } else { @@ -4274,36 +4297,18 @@ mod tests { app_with("Unchanged document"), ); let before = serde_json::to_value(&h.state().session.doc).unwrap(); - for (language, query, expected) in [ - ("tr", "italik", "İtalik — Giriş › Yazı Tipi"), - ("tr", "İTALİK", "İtalik — Giriş › Yazı Tipi"), - ("tr", "içindekiler", "İçindekiler Tablosu — Başvurular › İçindekiler Tablosu"), - ("tr", "YAZI TİPİ", "İtalik — Giriş › Yazı Tipi"), - // Dozens of commands match a lone INSERT and the box shows 14, in registry order: the - // witness is an early one; Table is checked below, past the box's limit. - ("tr", "INSERT", "Bölünemez Boşluk — Ekle › Semboller"), - ("tr", "INSERT.TABLE", "Tablo — Ekle › Tablolar"), - ("tr", "FORMAT.ITALIC", "İtalik — Giriş › Yazı Tipi"), - ("tr", "TABLE OF CONTENTS", "İçindekiler Tablosu — Başvurular › İçindekiler Tablosu"), - ("en", "ITALIC", "Italic — Home › Font"), - ("en", "FORMAT.ITALIC", "Italic — Home › Font"), - ("en", "INSERT.TABLE", "Table — Insert › Tables"), - ] { - h.state_mut().ui.language = language.into(); + for (query, expected) in + [("ITALIC", "Italic — Home › Font"), ("FORMAT.ITALIC", "Italic — Home › Font"), ("INSERT.TABLE", "Table — Insert › Tables")] + { h.state_mut().dialog = Some(Dialog::Commands { query: query.into(), swallow: false }); h.run_steps(4); assert!( h.query(egui_kittest::kittest::By::new().role(egui::accesskit::Role::Button).label_contains(expected)).is_some(), - "{language}: {query}\n{:?}", + "{query}\n{:?}", h.query_all_by_role(egui::accesskit::Role::Button).collect::>() ); - assert_eq!(crate::i18n::lowercase("Iİıi"), if language == "tr" { "ıiıi" } else { "ii\u{307}ıi" }); } assert_eq!(serde_json::to_value(&h.state().session.doc).unwrap(), before, "search does not change the document"); - crate::i18n::set_current(crate::i18n::Lang::from_pref("tr")); - let reg = h.state().session.registry.clone(); - assert!(command_matches(reg.get("insert.table").unwrap(), "INSERT"), "tr: INSERT finds Table"); - crate::i18n::set_current(crate::i18n::Lang::EN); } /// #2986: a box that keeps the keyboard asks for focus only while it lacks it. `request_focus` diff --git a/crates/ui-egui/src/dialogs_layout.rs b/crates/ui-egui/src/dialogs_layout.rs index 7d91d7af3..877ad276f 100644 --- a/crates/ui-egui/src/dialogs_layout.rs +++ b/crates/ui-egui/src/dialogs_layout.rs @@ -1907,8 +1907,9 @@ mod tests { assert_eq!(t.rows.iter().map(|r| r.cells.len()).collect::>(), [3, 3, 3, 3]); } - /// The envelope sizes are in the interface language with the region's decimal separator - /// (#1128; the untranslated-text audit found `#10 4.13 × 9.5 in` drawn in English). + /// The envelope sizes go through the interface language's catalog and use the region's + /// decimal separator (#1128; the untranslated-text audit found `#10 4.13 × 9.5 in` drawn in + /// English). #[test] fn envelope_sizes_follow_the_interface_language_and_region() { let size = |name: &str| ENVELOPE_SIZES.iter().find(|s| s.0 == name).map(|(n, w, h)| envelope_label(n, *w, *h)).unwrap(); @@ -1917,10 +1918,11 @@ mod tests { let mut a = app(); a.run("file.options", json!({"region": "it-IT"})).unwrap(); crate::options::sync(&a); - crate::i18n::set_current(crate::i18n::Lang::from_code("it").unwrap()); - assert_eq!(size("Envelope #10"), "#10 4,13 × 9,5 pollici"); - assert_eq!(size("Envelope DL"), "DL 110 × 220 mm"); - crate::i18n::set_current(crate::i18n::Lang::EN); + assert_eq!(size("Envelope #10"), "#10 4,13 × 9,5 in"); + crate::i18n::pseudo::set(true); + assert_eq!(size("Envelope #10"), "⟦#10 4,13 × 9,5 in⟧"); + assert_eq!(size("Envelope DL"), "⟦DL 110 × 220 mm⟧"); + crate::i18n::pseudo::set(false); a.run("file.options", json!({"region": "en-US"})).unwrap(); crate::options::sync(&a); } diff --git a/crates/ui-egui/src/i18n/audit.rs b/crates/ui-egui/src/i18n/audit.rs index 0c5155049..62fa2b434 100644 --- a/crates/ui-egui/src/i18n/audit.rs +++ b/crates/ui-egui/src/i18n/audit.rs @@ -183,10 +183,11 @@ impl Exempt { } } -/// Lists untranslated interface text per UI state. `cargo test -p wordcraft-ui-egui -/// untranslated_interface_text -- --nocapture` prints the report. It runs with the other tests: -/// input is synthetic and timed by `TIME`, Read Aloud never starts a speech thread, and -/// process-wide names other tests change are gathered every frame. +/// Fails on interface text drawn without going through the catalog, listed per UI state, and +/// prints the missing-translation report (`report_catalog_gaps`). `cargo xtask i18n-report` runs it +/// with `--nocapture`; `cargo xtask test` and `ci` run it that way instead of in the main test run. +/// It can also run with the other tests: input is synthetic and timed by `TIME`, Read Aloud never +/// starts a speech thread, and process-wide names other tests change are gathered every frame. #[test] fn untranslated_interface_text() { pseudo::set(true); @@ -338,16 +339,31 @@ fn untranslated_interface_text() { states.dedup(); report.push_str(&format!("{:?} [{}]\n", pseudo::unmarked(text), states.join(", "))); } - assert!(found.is_empty(), "{} interface texts skip translation (wrap them in tl!):\n{report}", found.len()); report_catalog_gaps(pseudo::looked_up()); + assert!(found.is_empty(), "{} interface texts skip translation (wrap them in tl!):\n{report}", found.len()); } -/// Prints, per language, the strings the audit saw looked up (plus the equation gallery's labels, -/// shown in menus the audit doesn't open one by one) that its catalog has no entry for. A missing -/// translation falls back to English, so it never fails the test; `--nocapture` shows the list. -/// Numbered style names go through a template. +/// The missing-translation report (`cargo xtask i18n-report`, part of `cargo xtask ci`): prints, per +/// language, the interface strings its catalog has no entry for. A missing translation falls back +/// to English, so the report never fails. The strings are the ones the audit saw looked up, the +/// ribbon tabs, every command's label and ribbon location, the File › Home greetings, every reason +/// a command gives for being off (#2678) and the equation gallery's labels (shown in menus the +/// audit doesn't open one by one). Numbered style names go through a template. fn report_catalog_gaps(mut keys: std::collections::BTreeSet) { use wordcraft_engine::math_gallery::{BUILT_INS, STRUCTURES, SYMBOL_SETS}; + let tabs = crate::ribbon::TABS.iter().chain(&["Table Design", "Table Layout"]); + keys.extend(tabs.map(|t| t.to_string())); + keys.extend(["Good morning", "Good afternoon", "Good evening"].map(String::from)); + keys.extend(disabled_reasons()); + // Reasons the UI itself gives (`widgets.rs::tooltip`), which the engine's checks never reach. + keys.insert("Open a document first".to_string()); + for doc in [wordcraft_doc::Document::new(), wordcraft_engine::sample::sample_document()] { + let session = wordcraft_engine::Session::new(doc); + for spec in session.registry.all() { + keys.extend(std::iter::once(spec.label).chain(spec.location.split(" › ")).filter(|s| !s.is_empty()).map(String::from)); + keys.extend((spec.enabled)(&session).map(String::from)); + } + } keys.extend(BUILT_INS.iter().map(|(_, label, _)| label.to_string())); keys.extend(SYMBOL_SETS.iter().map(|(name, _)| name.to_string())); for g in STRUCTURES { @@ -511,28 +527,3 @@ fn disabled_reasons() -> std::collections::BTreeSet { } reasons } - -/// Ribbon tooltips show why a control is off in the interface's language: every reason has an -/// entry in every catalog (#2678). -#[test] -fn every_disabled_reason_is_translated() { - let reasons = disabled_reasons(); - // The source scan finds every reason the registry gives in a blank and the sample document. - for doc in [wordcraft_doc::Document::new(), wordcraft_engine::sample::sample_document()] { - let session = wordcraft_engine::Session::new(doc); - for spec in session.registry.all() { - if let Some(r) = (spec.enabled)(&session) { - assert!(reasons.contains(r), "{}: the source scan missed its reason {r:?}", spec.id); - } - } - } - assert!(reasons.len() > 20, "the source scan found only {reasons:#?}"); - // Reasons the UI itself gives (`widgets.rs::tooltip`), which the engine's checks never reach. - let mut reasons = reasons; - reasons.insert("Open a document first".to_string()); - let mut missing = Vec::new(); - for l in super::Lang::all().filter(|l| *l != super::Lang::EN) { - missing.extend(reasons.iter().filter(|r| !super::has(l, r)).map(|r| format!("{}: {r:?}", l.code()))); - } - assert!(missing.is_empty(), "{} disabled reasons are untranslated:\n{missing:#?}", missing.len()); -} diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 9284b9306..6a52753b2 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -488,6 +488,14 @@ pub fn fmt(template: &str, args: &[(&str, &str)]) -> String { out } +/// A test language whose catalog is `source` (catalog format), so tests of lookup code don't +/// depend on the bundled catalogs' wording. `code` selects code-specific rules such as Turkish +/// casing. +#[cfg(test)] +pub(crate) fn fixture_lang(code: &'static str, source: &'static str) -> Lang { + Lang(Box::leak(Box::new(LangInfo { code, name: "Fixture", source, prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }))) +} + #[cfg(test)] mod audit; #[cfg(test)] diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index fc5ccaa11..340b9d49a 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -72,22 +72,35 @@ fn settings_resolve_with_fallback() { #[test] fn lookups_fall_back_to_english() { - let zh = lang("zh-hans"); - assert_eq!(tr(zh, "File"), "文件"); - assert_eq!(tr(lang("zh-hant"), "File"), "檔案"); - assert_eq!(tr(lang("ja"), "File"), "ファイル"); - assert_eq!(tr(zh, "no such label"), "no such label"); + let fx = fixture_lang("x-fixture", "\tFile\tFile-FX\n"); + assert_eq!(tr(fx, "File"), "File-FX"); + assert_eq!(tr(fx, "no such label"), "no such label"); assert_eq!(tr(Lang::EN, "File"), "File"); - assert!(has(zh, "Save") && !has(Lang::EN, "Save")); + assert!(has(fx, "File") && !has(fx, "Save") && !has(Lang::EN, "File")); +} + +/// Each registered language looks strings up in its own catalog file. +#[test] +fn each_language_reads_its_own_catalog() { + for l in Lang::all() { + for e in parse_entries(l.0.source).0.iter().filter(|e| e.context.is_empty()) { + assert_eq!(tr(l, &e.source), e.translation, "{}", l.code()); + } + } } #[test] fn current_language_is_per_thread() { - set_current(lang("ja")); - assert_eq!(t("Insert"), "挿入"); - assert_eq!(location("Home › Font"), "ホーム › フォント"); - assert_eq!(prefixed("Undo", "Bold"), "元に戻す: 太字"); - assert_eq!(prefixed("Undo", ""), "元に戻す"); + set_current(fixture_lang( + "x-fixture", + "\tInsert\tInsert-FX\n\tHome\tHome-FX\n\tFont\tFont-FX\n\tUndo\tUndo-FX\n\tUndo {action}\tUndo-FX: {action}\n\tBold\tBold-FX\n", + )); + assert_eq!(t("Insert"), "Insert-FX"); + assert_eq!(location("Home › Font"), "Home-FX › Font-FX"); + assert_eq!(prefixed("Undo", "Bold"), "Undo-FX: Bold-FX"); + assert_eq!(prefixed("Undo", ""), "Undo-FX"); + // An action without a translation keeps its English inside the translated template. + assert_eq!(prefixed("Undo", "Typing"), "Undo-FX: Typing"); std::thread::spawn(|| assert_eq!(current(), Lang::EN)).join().unwrap(); set_current(Lang::EN); assert_eq!(t("Insert"), "Insert"); @@ -99,7 +112,9 @@ fn placeholders_fill_without_reinterpreting_values() { let args = [("path", "a-{pages}.docx"), ("pages", "2")]; assert_eq!(fmt("{pages}: {path}; {unknown}", &args), "2: a-{pages}.docx; {unknown}"); assert_eq!(fmt("text {unfinished", &args), "text {unfinished"); - assert_eq!(fmt(tr(lang("zh-hans"), "Page {page} of {pages}"), &[("page", "3"), ("pages", "9")]), "第 3 页,共 9 页"); + // A translation may put the placeholders in another order. + let fx = fixture_lang("x-fixture", "\tPage {page} of {pages}\t{pages}: {page}\n"); + assert_eq!(fmt(tr(fx, "Page {page} of {pages}"), &[("page", "3"), ("pages", "9")]), "9: 3"); } #[test] @@ -126,82 +141,9 @@ fn bundled_catalogs_are_well_formed() { assert_eq!(e.translation.trim(), e.translation, "{}: stray whitespace in {:?}", l.code, e.translation); assert!(!e.translation.contains("..."), "{}: use … rather than three dots: {:?}", l.code, e.translation); } - assert!(l.code == "en" || entries.len() > 700, "{} has {} entries", l.code, entries.len()); } } -/// The ribbon tabs and every registered command's label and ribbon location are translated, in -/// every language, so menus, tooltips and command search never fall back to English. -#[test] -fn every_tab_and_command_is_translated() { - let session = wordcraft_engine::Session::new(wordcraft_doc::Document::new()); - // Labels that read the same in every language. - let universal = |s: &str| !s.chars().any(char::is_alphabetic); - let mut missing = Vec::new(); - for l in Lang::all().filter(|l| *l != Lang::EN) { - let tabs = crate::ribbon::TABS.iter().chain(&["Table Design", "Table Layout"]).copied(); - let commands = session.registry.all().iter().flat_map(|spec| std::iter::once(spec.label).chain(spec.location.split(" › "))); - for s in tabs.chain(commands).filter(|s| !s.is_empty() && !universal(s)) { - if !has(l, s) { - missing.push(format!("{}: {s:?}", l.code())); - } - } - } - missing.sort(); - missing.dedup(); - assert!(missing.is_empty(), "untranslated: {missing:#?}"); -} - -/// File › Home opens with a greeting for the time of day (`backstage::home_page`); every -/// language has all three, so the page doesn't start in English. -#[test] -fn the_file_home_greeting_is_translated_in_every_language() { - let mut missing = Vec::new(); - for l in Lang::all().filter(|l| *l != Lang::EN) { - for s in ["Good morning", "Good afternoon", "Good evening"] { - if !has(l, s) { - missing.push(format!("{}: {s:?}", l.code())); - } - } - } - assert!(missing.is_empty(), "untranslated: {missing:#?}"); - assert_eq!(tr(lang("pt-br"), "Good afternoon"), "Boa tarde"); -} - -#[test] -fn korean_print_settings_and_printer_feedback_are_translated() { - let korean = lang("ko"); - for source in [ - "Printer", - "Select a printer", - "Refresh printers", - "Copies", - "Two-sided printing", - "Printer default", - "One-sided", - "Two-sided: long edge", - "Two-sided: short edge", - "Grayscale", - "Finding printers…", - "No printers found. Configure a system printer or export a PDF.", - "Printer discovery failed.", - "Submitting print job…", - "Print job submitted.", - "Print job submitted: {receipt}", - "Print failed: the printing worker stopped.", - "Print failed: {error}", - ] { - assert!(has(korean, source), "missing Korean print translation: {source}"); - } - assert_eq!(korean.catalog().plain("Printer"), Some("프린터")); - assert_eq!(korean.catalog().plain("Copies"), Some("매수")); - assert_eq!(korean.catalog().plain("Two-sided printing"), Some("양면 인쇄")); - assert_eq!( - korean.catalog().plain("No printers found. Configure a system printer or export a PDF."), - Some("프린터를 찾을 수 없습니다. 시스템 프린터를 설정하거나 PDF로 내보내세요.") - ); -} - #[test] fn the_language_setting_is_a_ui_command_and_persists() { let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); @@ -243,26 +185,6 @@ fn ukrainian_locales_and_saved_preference_work_without_changing_the_document() { assert_eq!(uk.name(), "Українська"); } -#[test] -fn ukrainian_covers_the_entire_existing_interface_catalog() { - use std::collections::HashSet; - let keys = |source| parse_entries(source).0.into_iter().map(|e| e.source).collect::>(); - assert_eq!(keys(lang("uk").0.source), keys(lang("zh-hans").0.source)); - let uk = lang("uk"); - assert_eq!(tr(uk, "Home"), "Основне"); - assert_eq!(tr(uk, "Font"), "Шрифт"); - assert_eq!(tr(uk, "Review"), "Рецензування"); - assert_eq!(tr(uk, "Save"), "Зберегти"); - assert_eq!(tr(uk, "unknown future label"), "unknown future label"); - assert_eq!(fmt(tr(uk, "Exported {path}"), &[("path", "draft-{words}.docx")]), "Експортовано draft-{words}.docx"); - // Count-neutral wording is grammatical for every Ukrainian integer category, including teens. - for count in [0, 1, 2, 5, 11, 12, 21, 22, 25, 111, 121] { - let words = count.to_string(); - assert_eq!(fmt(tr(uk, "{words} words"), &[("words", &words)]), format!("Слів: {count}")); - assert_eq!(fmt(tr(uk, "{selected} of {words} words"), &[("selected", "1"), ("words", &words)]), format!("Слів: {count}; виділено: 1")); - } -} - #[test] fn bundled_interface_fonts_cover_ukrainian_without_system_fallbacks() { let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new()); @@ -279,20 +201,6 @@ fn bundled_interface_fonts_cover_ukrainian_without_system_fallbacks() { } } -#[test] -fn serbian_covers_the_entire_existing_interface_catalog() { - use std::collections::HashSet; - let keys = |source| parse_entries(source).0.into_iter().map(|e| e.source).collect::>(); - let reference = keys(lang("zh-hans").0.source); - assert_eq!(keys(lang("sr").0.source), reference); - assert_eq!(keys(lang("sr-latn").0.source), reference); - let (cyr, lat) = (lang("sr"), lang("sr-latn")); - assert_eq!(tr(cyr, "Home"), "Почетак"); - assert_eq!(tr(lat, "Home"), "Početak"); - assert_eq!(tr(cyr, "unknown future label"), "unknown future label"); - assert_eq!(fmt(tr(lat, "Exported {path}"), &[("path", "draft-1.docx")]), "Izvezeno: draft-1.docx"); -} - #[test] fn bundled_interface_fonts_cover_serbian_cyrillic_without_system_fallbacks() { let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new()); @@ -338,8 +246,6 @@ fn serbian_locales_resolve_by_script() { assert_eq!(lang_from_tag(tag), Some(lat), "{tag}"); } assert_eq!(first_supported([UNSUPPORTED, "sr-Latn-RS", "en-US"]), Some(lat)); - assert_eq!(tr(cyr, "File"), "Фајл"); - assert_eq!(tr(lat, "File"), "Fajl"); } #[test] @@ -364,26 +270,6 @@ fn brazilian_portuguese_locales_and_saved_preference_work_without_changing_the_d assert_eq!(pt.name(), "Português (Brasil)"); } -#[test] -fn brazilian_portuguese_covers_the_entire_existing_interface_catalog() { - use std::collections::HashSet; - let keys = |source| parse_entries(source).0.into_iter().map(|e| e.source).collect::>(); - assert_eq!(keys(lang("pt-br").0.source), keys(lang("zh-hans").0.source)); - let pt = lang("pt-br"); - assert_eq!(tr(pt, "Home"), "Início"); - assert_eq!(tr(pt, "Font"), "Fonte"); - assert_eq!(tr(pt, "Review"), "Revisão"); - assert_eq!(tr(pt, "Save"), "Salvar"); - assert_eq!(tr(pt, "unknown future label"), "unknown future label"); - assert_eq!(fmt(tr(pt, "Exported {path}"), &[("path", "draft-{words}.docx")]), "Exportado draft-{words}.docx"); - // Count-neutral wording is grammatical for every count: `Palavras: 21`, `Palavras: 21; seleção: 1`. - for count in [0, 1, 2, 5, 21, 100, 1000] { - let words = count.to_string(); - assert_eq!(fmt(tr(pt, "{words} words"), &[("words", &words)]), format!("Palavras: {count}")); - assert_eq!(fmt(tr(pt, "{selected} of {words} words"), &[("selected", "1"), ("words", &words)]), format!("Palavras: {count}; seleção: 1")); - } -} - #[test] fn bundled_interface_fonts_cover_brazilian_portuguese_without_system_fallbacks() { let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new()); @@ -425,37 +311,6 @@ fn czech_locales_and_saved_preference_work_without_changing_the_document() { assert_eq!(cs.name(), "Čeština"); } -#[test] -fn czech_covers_the_entire_existing_interface_catalog() { - use std::collections::HashSet; - let keys = |source| parse_entries(source).0.into_iter().map(|e| e.source).collect::>(); - // Keep the original Chinese coverage and include the complete styles, equation gallery, - // paper sizes and watermarks from the Russian and Spanish catalogs. - assert!(keys(lang("cs").0.source).is_superset(&keys(lang("zh-hans").0.source))); - assert!(keys(lang("cs").0.source).is_superset(&keys(lang("ru").0.source))); - assert!(keys(lang("cs").0.source).is_superset(&keys(lang("es").0.source))); - let cs = lang("cs"); - assert_eq!(tr(cs, "Home"), "Domů"); - assert_eq!(tr(cs, "Font"), "Písmo"); - assert_eq!(tr(cs, "Review"), "Revize"); - assert_eq!(tr(cs, "Save"), "Uložit"); - assert_eq!(tr(cs, "unknown future label"), "unknown future label"); - assert_eq!(fmt(tr(cs, "Exported {path}"), &[("path", "draft-{words}.docx")]), "Exportováno: draft-{words}.docx"); - // Count-neutral wording is grammatical across all Czech plural categories (1, 2–4, 0/5+). - for count in [0, 1, 2, 3, 4, 5, 11, 21, 100, 1000] { - let words = count.to_string(); - assert_eq!(fmt(tr(cs, "{words} words"), &[("words", &words)]), format!("Počet slov: {count}")); - assert_eq!(fmt(tr(cs, "{selected} of {words} words"), &[("selected", "1"), ("words", &words)]), format!("Počet slov: {words}; vybráno: 1")); - } - set_current(cs); - // Built-in names and numbered style families use Czech; user-defined names stay as written. - for (style, expected) in [("Normal", "Normální"), ("No Spacing", "Bez mezer"), ("Heading 2", "Nadpis 2"), ("TOC 3", "Obsah 3")] { - assert_eq!(builtin_style_name(style), expected); - } - assert_eq!(builtin_style_name("My Own Style"), "My Own Style"); - set_current(Lang::EN); -} - #[test] fn bundled_interface_fonts_cover_czech_without_system_fallbacks() { let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new()); @@ -566,35 +421,6 @@ fn russian_locales_and_saved_preference_work_without_changing_the_document() { assert_eq!(ru.name(), "Русский"); } -#[test] -fn russian_covers_the_entire_existing_interface_catalog() { - use std::collections::HashSet; - let keys = |source| parse_entries(source).0.into_iter().map(|e| e.source).collect::>(); - // Everything the other catalogs translate, plus the preview samples below. - assert!(keys(lang("ru").0.source).is_superset(&keys(lang("zh-hans").0.source))); - assert!(parse_entries(lang("ru").0.source).1.is_empty()); - let ru = lang("ru"); - // Style gallery and Design tab previews show Cyrillic sample text. - assert_eq!(tr(ru, "AaBbCcDd"), "АаБбВвГг"); - assert_eq!(tr(ru, "Title"), "Заголовок"); - assert_eq!(tr(ru, "Heading 1"), "Заголовок 1"); - assert!(tr(ru, "Body text in a short paragraph to show spacing.").starts_with("Основной текст")); - assert_eq!(tr(ru, "Home"), "Главная"); - assert_eq!(tr(ru, "Font"), "Шрифт"); - assert_eq!(tr(ru, "Review"), "Рецензирование"); - assert_eq!(tr(ru, "Save"), "Сохранить"); - assert_eq!(tr(ru, "unknown future label"), "unknown future label"); - // The search hint's example must find the command it names. - assert_eq!(tr(ru, "Insert Table"), "Вставить таблицу"); - assert!(tr(ru, "Type a command, e.g. \"insert table\"").contains("вставить таблицу")); - // Count-neutral wording is grammatical for every count: `Слов: 21`, `Слов: 1 из 21`. - for count in [0, 1, 2, 5, 21, 100, 1000] { - let words = count.to_string(); - assert_eq!(fmt(tr(ru, "{words} words"), &[("words", &words)]), format!("Слов: {count}")); - assert_eq!(fmt(tr(ru, "{selected} of {words} words"), &[("selected", "1"), ("words", &words)]), format!("Слов: 1 из {count}")); - } -} - #[test] fn bundled_interface_fonts_cover_russian_without_system_fallbacks() { let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new()); @@ -621,68 +447,29 @@ fn bundled_interface_fonts_cover_russian_without_system_fallbacks() { fn built_in_style_names_are_translated_for_display_and_user_styles_are_not() { let sheet = wordcraft_doc::StyleSheet::builtin(); let name = |id: &str| style_name(sheet.get(id).unwrap()); - set_current(lang("ru")); - assert_eq!(name("Normal"), "Обычный"); - assert_eq!(name("Title"), "Заголовок"); - assert_eq!(name("Heading1"), "Заголовок 1"); + set_current(fixture_lang( + "x-fixture", + "\tNormal\tNormal-FX\n\tHeading 1\tHeading-one-FX\n\tHeading {n}\tHeading-FX {n}\n\tTOC {n}\tTOC-FX {n}\n\tIndex {n}\tIndex-FX {n}\n", + )); + assert_eq!(name("Normal"), "Normal-FX"); + // An explicit entry wins over its family's template. + assert_eq!(name("Heading1"), "Heading-one-FX"); // Numbered families beyond the catalog's explicit entries go through one template. - assert_eq!(name("Heading7"), "Заголовок 7"); - assert_eq!(name("TOC9"), "Оглавление 9"); + assert_eq!(name("Heading7"), "Heading-FX 7"); + assert_eq!(name("TOC9"), "TOC-FX 9"); + assert_eq!(builtin_style_name("Index 2"), "Index-FX 2"); // A style the author made keeps its name, even when it matches a built-in one. let mine = wordcraft_doc::Style { name: "Normal".into(), builtin: false, ..Default::default() }; assert_eq!(style_name(&mine), "Normal"); // A built-in name without a translation stays English. assert_eq!(builtin_style_name("Some Future Style"), "Some Future Style"); assert_eq!(builtin_style_name("Heading x"), "Heading x"); - // Every built-in paragraph and character style shows in Russian. Table styles (`Grid Table 4 – - // Blue`) appear only as tooltips under Table Design and stay English for now. - let untranslated: Vec<&str> = sheet - .styles - .iter() - .filter(|s| s.kind != wordcraft_doc::StyleKind::Table && style_name(s) == s.name && s.name.chars().any(char::is_alphabetic)) - .map(|s| s.name.as_str()) - .collect(); - assert!(untranslated.is_empty(), "built-in styles without a Russian name: {untranslated:?}"); set_current(Lang::EN); assert_eq!(name("Heading7"), "Heading 7"); // The document itself never changes. assert_eq!(sheet.get("Normal").unwrap().name, "Normal"); } -#[test] -fn spanish_built_in_style_names_are_translated() { - set_current(lang("es")); - for (source, expected) in [ - ("Book Title", "Título de libro"), - ("Caption", "Leyenda"), - ("Emphasis", "Énfasis"), - ("Endnote Reference", "Referencia de nota al final"), - ("Endnote Text", "Texto de nota al final"), - ("Footnote Reference", "Referencia de nota al pie"), - ("Footnote Text", "Texto de nota al pie"), - ("Hyperlink", "Hipervínculo"), - ("Intense Emphasis", "Énfasis intenso"), - ("Intense Quote", "Cita intensa"), - ("Intense Reference", "Referencia intensa"), - ("List Paragraph", "Párrafo de lista"), - ("Quote", "Cita"), - ("Strong", "Fuerte"), - ("Subtitle", "Subtítulo"), - ("Subtle Emphasis", "Énfasis sutil"), - ("Subtle Reference", "Referencia sutil"), - ("TOC Heading", "Título de tabla de contenido"), - ] { - assert_eq!(builtin_style_name(source), expected, "{source}"); - } - for level in 4..=9 { - assert_eq!(builtin_style_name(&format!("Heading {level}")), format!("Título {level}")); - } - for level in 1..=9 { - assert_eq!(builtin_style_name(&format!("TOC {level}")), format!("Tabla de contenido {level}")); - } - set_current(Lang::EN); -} - #[test] fn context_entries_override_only_their_context_and_unknown_contexts_are_rejected() { let (c, errors) = Catalog::parse("\tTitle\tЗаголовок\npreview\tTitle\tТитул\n\tFont\tШрифт\nnope\tFont\tX\n"); @@ -693,11 +480,12 @@ fn context_entries_override_only_their_context_and_unknown_contexts_are_rejected // Without a context entry, a context lookup uses the plain one. assert_eq!(c.in_context("preview", "Font"), Some("Шрифт")); assert_eq!(c.in_context("preview", "Missing"), None); - let ru = lang("ru"); - assert_eq!(tr(ru, "Title"), "Заголовок"); - assert_eq!(trc(ru, "preview", "Title"), "Пример"); + let fx = fixture_lang("x-fixture", "\tTitle\tTitle-FX\npreview\tTitle\tSample-FX\n"); + assert_eq!(tr(fx, "Title"), "Title-FX"); + assert_eq!(trc(fx, "preview", "Title"), "Sample-FX"); + assert_eq!(trc(fx, "template", "Title"), "Title-FX"); assert_eq!(trc(Lang::EN, "preview", "Title"), "Title"); - assert_eq!(trc(lang("ja"), "preview", "Unknown"), "Unknown"); + assert_eq!(trc(fx, "preview", "Unknown"), "Unknown"); } #[test] @@ -720,28 +508,6 @@ fn estonian_locales_and_saved_preference_keep_document_content() { assert_eq!(Lang::from_pref(&restored.language), et); } -#[test] -fn estonian_covers_the_interface_catalog_and_keeps_count_labels_neutral() { - use std::collections::HashSet; - let keys = |source| parse_entries(source).0.into_iter().map(|e| e.source).collect::>(); - let et = lang("et"); - // Estonian carries the interface catalog; Spanish and Turkish add the equation gallery and - // built-in style names on top. - assert!(keys(et.0.source).is_subset(&keys(lang("es").0.source))); - assert!(keys(et.0.source).is_subset(&keys(lang("tr").0.source))); - assert!(keys(lang("zh-hans").0.source).is_subset(&keys(et.0.source))); - assert_eq!(tr(et, "Home"), "Avaleht"); - assert_eq!(tr(et, "Save"), "Salvesta"); - assert_eq!(tr(et, "Spelling & Grammar"), "Õigekiri ja grammatika"); - assert_eq!(tr(et, "unknown future label"), "unknown future label"); - assert_eq!(fmt(tr(et, "Exported {path}"), &[("path", "draft-{words}.docx")]), "Eksporditud: draft-{words}.docx"); - for count in [0, 1, 2, 11, 21, 101] { - let words = count.to_string(); - assert_eq!(fmt(tr(et, "{words} words"), &[("words", &words)]), format!("Sõnu: {count}")); - assert_eq!(fmt(tr(et, "{selected} of {words} words"), &[("selected", "1"), ("words", &words)]), format!("Sõnu: {count}; valitud: 1")); - } -} - #[test] fn bundled_interface_fonts_cover_estonian_letters() { let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new()); @@ -779,51 +545,6 @@ fn turkish_locales_and_saved_preference_work_without_changing_the_document() { assert_eq!(tr.name(), "Türkçe"); } -#[test] -fn turkish_covers_the_entire_existing_interface_catalog_and_preserves_templates() { - use std::collections::HashSet; - let keys = |source| parse_entries(source).0.into_iter().map(|e| e.source).collect::>(); - // Full parity with the Russian catalog: covers all UI strings, styles, math gallery, and presets. - assert_eq!(keys(lang("tr").0.source), keys(lang("ru").0.source)); - assert!(keys(lang("es").0.source).is_subset(&keys(lang("tr").0.source))); - assert!(keys(lang("zh-hans").0.source).is_subset(&keys(lang("tr").0.source))); - let tr = lang("tr"); - for (source, expected) in [ - ("Home", "Giriş"), - ("Font", "Yazı Tipi"), - ("Review", "İncele"), - ("Italic", "İtalik"), - ("Table of Contents", "İçindekiler Tablosu"), - ("Track Changes", "Değişiklikleri İzle"), - ("Save", "Kaydet"), - ("UPPERCASE", "BÜYÜK HARF"), - ("lowercase", "küçük harf"), - ("Square Root", "Karekök"), - ("Euler's identity", "Euler Özdeşliği"), - ("Default Paragraph Font", "Varsayılan Paragraf Yazı Tipi"), - ("CONFIDENTIAL", "GİZLİ"), - ("WordCraft", "WordCraft"), - ("unknown future label", "unknown future label"), - ] { - assert_eq!(super::tr(tr, source), expected); - } - assert_eq!(fmt(super::tr(tr, "Exported {path}"), &[("path", "taslak-{words}.docx")]), "Dışa aktarıldı: taslak-{words}.docx"); - assert_eq!(fmt(super::tr(tr, "Page {page} of {pages}"), &[("page", "3"), ("pages", "9")]), "Sayfa 3 / 9"); - // The Font dialog uppercases its preview sample for All Caps, so the sample has no lowercase i. - assert_eq!(super::tr(tr, "AaBbYyZz").to_uppercase(), "AABBYYZZ ĞĞİIŞŞ"); - // Turkish count nouns remain singular for both one and other, including zero and large counts. - for count in [0, 1, 2, 5, 11, 21, 100, 1000] { - let words = count.to_string(); - assert_eq!(fmt(super::tr(tr, "{words} words"), &[("words", &words)]), format!("{count} sözcük")); - assert_eq!(fmt(super::tr(tr, "{selected} of {words} words"), &[("selected", "1"), ("words", &words)]), format!("{count} sözcükten 1 seçili")); - } - set_current(tr); - assert_eq!(location("Home › Font"), "Giriş › Yazı Tipi"); - assert_eq!(prefixed("Undo", "Italic"), "Geri Al: İtalik"); - set_current(Lang::EN); - assert_eq!(super::tr(Lang::EN, "Italic"), "Italic"); -} - #[test] fn bundled_interface_fonts_cover_turkish_without_system_fallbacks() { let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new()); @@ -863,48 +584,6 @@ fn hungarian_locales_and_saved_preference_work_without_changing_the_document() { assert_eq!(app.ui.language, AUTO); } -#[test] -fn hungarian_covers_the_entire_existing_interface_catalog() { - use std::collections::HashSet; - let keys = |source| parse_entries(source).0.into_iter().map(|e| e.source).collect::>(); - // Every key of the most complete catalog: built-in style names, equation gallery labels, paper - // sizes and watermarks included. - assert!(keys(lang("hu").0.source).is_superset(&keys(lang("ru").0.source))); - assert!(keys(lang("hu").0.source).is_superset(&keys(lang("es").0.source))); - let hu = lang("hu"); - for (source, expected) in [ - ("Home", "Kezdőlap"), - ("Font", "Betűtípus"), - ("Review", "Véleményezés"), - ("Header", "Élőfej"), - ("Track Changes", "Változáskövetés"), - ("Save", "Mentés"), - ("Redo", "Újra"), - ("UPPERCASE", "NAGYBETŰS"), - ("WordCraft", "WordCraft"), - ("unknown future label", "unknown future label"), - ] { - assert_eq!(super::tr(hu, source), expected); - } - assert_eq!(fmt(super::tr(hu, "Page {page} of {pages}"), &[("page", "3"), ("pages", "9")]), "Oldal: 3/9"); - // A noun after a number stays singular in Hungarian, whatever the count. - for count in [0, 1, 2, 21, 1000] { - let words = count.to_string(); - assert_eq!(fmt(super::tr(hu, "{words} words"), &[("words", &words)]), format!("{count} szó")); - } - // The Font dialog uppercases its preview sample for All Caps. - assert_eq!(super::tr(hu, "AaBbYyZz").to_uppercase(), "AABBYYZZ ŐŐŰŰ"); - set_current(hu); - // Built-in styles show translated names; numbered families go through their template. - for (style, expected) in [("Normal", "Normál"), ("No Spacing", "Nincs térköz"), ("Heading 2", "Címsor 2"), ("TOC 3", "Tartalomjegyzék 3")] { - assert_eq!(builtin_style_name(style), expected); - } - assert_eq!(builtin_style_name("My Own Style"), "My Own Style"); - assert_eq!(location("Home › Font"), "Kezdőlap › Betűtípus"); - assert_eq!(prefixed("Undo", "Bold"), "Visszavonás: Félkövér"); - set_current(Lang::EN); -} - #[test] fn bundled_interface_fonts_cover_hungarian_without_system_fallbacks() { let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new()); @@ -932,10 +611,6 @@ fn french_locales_and_saved_preference_work_without_changing_the_document() { assert_eq!(first_supported([UNSUPPORTED, "fr-CA", "en-US"]), Some(fr)); assert_eq!(normalize_pref("FR"), Some("fr")); assert_eq!(fr.name(), "Français"); - assert_eq!(tr(fr, "Home"), "Accueil"); - assert_eq!(tr(fr, "Save"), "Enregistrer"); - assert_eq!(fmt(tr(fr, "Page {page} of {pages}"), &[("page", "3"), ("pages", "9")]), "Page 3 sur 9"); - assert_eq!(tr(fr, "unknown future label"), "unknown future label"); let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_engine::sample::sample_document()), Default::default()); let before = serde_json::to_value(&app.session.doc).unwrap(); let result = app.run("ui.language", serde_json::json!({"value": "FR"})).unwrap(); @@ -998,51 +673,6 @@ fn polish_locales_and_saved_preference_work_without_changing_the_document() { assert_eq!(pl.name(), "Polski"); } -#[test] -fn polish_covers_every_key_of_every_other_catalog() { - use std::collections::HashSet; - let keys = |source| parse_entries(source).0.into_iter().map(|e| e.source).collect::>(); - let polish = keys(lang("pl").0.source); - // Italian and Hungarian still carry "Style: APA", which the interface no longer shows; - // Slovenian carries the union of every catalog's keys. - // Hungarian and Bulgarian also name the citation styles, and Bulgarian has a few one-line - // pieces of gallery labels. Polish matches every other catalog, including the built-in style - // names, citation styles and equation gallery. French carries newer strings (paper sizes, - // bookmarks, printing) the Polish catalog hasn't caught up with yet. - for other in Lang::all().filter(|l| *l != Lang::EN && !matches!(l.code(), "pl" | "it" | "hu" | "bg" | "vi" | "sv" | "hr" | "fr" | "ro" | "sl")) { - let mut missing: Vec = keys(other.0.source).difference(&polish).cloned().collect(); - missing.sort(); - assert!(missing.is_empty(), "pl lacks {}'s {missing:?}", other.code()); - } - for code in ["it", "hu"] { - let missing: Vec = keys(lang(code).0.source).difference(&polish).cloned().collect(); - assert_eq!(missing, ["Style: APA"], "pl vs {code}"); - } - let pl = lang("pl"); - assert_eq!(tr(pl, "File"), "Plik"); - assert_eq!(tr(pl, "Home"), "Start"); - assert_eq!(tr(pl, "Insert"), "Wstaw"); - assert_eq!(tr(pl, "Review"), "Recenzja"); - assert_eq!(tr(pl, "Save"), "Zapisz"); - assert_eq!(tr(pl, "Spelling & Grammar"), "Pisownia i gramatyka"); - assert_eq!(tr(pl, "Polish"), "Polski"); - assert_eq!(tr(pl, "GOST (name sort)"), "GOST (sortowanie według nazwiska)"); - assert_eq!(tr(pl, "ISO 690 (author-date)"), "ISO 690 (autor–data)"); - assert_eq!(tr(pl, "unknown future label"), "unknown future label"); - assert_eq!(fmt(tr(pl, "Exported {path}"), &[("path", "draft-{words}.docx")]), "Wyeksportowano: draft-{words}.docx"); - assert_eq!(fmt(tr(pl, "Page {page} of {pages}"), &[("page", "3"), ("pages", "9")]), "Strona 3 z 9"); - // Count-neutral wording reads grammatically for every Polish number form (1, 2–4, 5+, 12–14, 22). - for count in [0, 1, 2, 4, 5, 12, 14, 22, 25, 101, 1000] { - let words = count.to_string(); - assert_eq!(fmt(tr(pl, "{words} words"), &[("words", &words)]), format!("Słowa: {count}")); - assert_eq!(fmt(tr(pl, "{selected} of {words} words"), &[("selected", "1"), ("words", &words)]), format!("Słowa: 1 z {count}")); - } - set_current(pl); - assert_eq!(location("Review › Proofing"), "Recenzja › Sprawdzanie"); - assert_eq!(prefixed("Undo", "Bold"), "Cofnij: Pogrubienie"); - set_current(Lang::EN); -} - #[test] fn bundled_interface_fonts_cover_polish_without_system_fallbacks() { let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new()); @@ -1076,13 +706,15 @@ fn a_polish_interface_writes_new_documents_in_polish() { assert!(!app.session.dirty); app.run("file.new", serde_json::json!({})).unwrap(); assert_eq!(app.session.doc.styles.default_chr.lang.as_deref(), Some("pl-PL")); - // Typing Polish then shows no spelling marks; the status bar names the language. + // Typing Polish then shows no spelling marks; the status bar names the language, translated. app.session.run("text.insert", &serde_json::json!({"text": "Zażółć gęślą jaźń"})).unwrap(); assert_eq!(app.session.run("review.issues", &serde_json::json!({})).unwrap(), serde_json::json!([])); - set_current(lang("pl")); - assert_eq!(crate::ribbon::caret_language(&app), "Polski"); + // The app's frames set the interface language; name it in English first. set_current(Lang::EN); assert_eq!(crate::ribbon::caret_language(&app), "Polish"); + super::pseudo::set(true); + assert_eq!(crate::ribbon::caret_language(&app), "⟦Polish⟧", "the language name goes through the catalog"); + super::pseudo::set(false); // Back to English: documents already written keep their language. app.run("ui.language", serde_json::json!({"value": "en"})).unwrap(); frame(&mut app); @@ -1102,10 +734,12 @@ fn a_czech_interface_writes_new_documents_and_plain_text_in_czech() { assert!(!app.session.dirty); app.run("file.new", serde_json::json!({})).unwrap(); assert_eq!(app.session.doc.styles.default_chr.lang.as_deref(), Some("cs-CZ")); - set_current(lang("cs")); - assert_eq!(crate::ribbon::caret_language(&app), "Čeština"); + // The app's frames set the interface language; name it in English first. set_current(Lang::EN); assert_eq!(crate::ribbon::caret_language(&app), "Czech"); + super::pseudo::set(true); + assert_eq!(crate::ribbon::caret_language(&app), "⟦Czech⟧", "the language name goes through the catalog"); + super::pseudo::set(false); for path in ["poznamka.txt", "poznamka.md"] { // Base64 of our synthetic text, "Město"; file.open takes bytes without a dialog. app.session.run("file.open", &serde_json::json!({"path": path, "data": "TcSbc3Rv"})).unwrap(); @@ -1127,14 +761,16 @@ fn an_italian_interface_writes_new_documents_in_italian() { frame(&mut app); assert_eq!(app.session.editing_lang.as_deref(), Some("it-IT")); assert_eq!(app.session.doc.styles.default_chr.lang.as_deref(), Some("it-IT"), "the untouched first document follows"); - // Typing Italian then shows no English misspellings; the status bar names the language. + // Typing Italian then shows no English misspellings; the status bar names the language, translated. app.session.run("text.insert", &serde_json::json!({"text": "Città vecchie e caffè"})).unwrap(); let issues = app.session.run("review.issues", &serde_json::json!({})).unwrap(); assert!(issues.as_array().unwrap().iter().all(|i| i["lang"] == "it"), "{issues}"); - set_current(lang("it")); - assert_eq!(crate::ribbon::caret_language(&app), "Italiano"); + // The app's frames set the interface language; name it in English first. set_current(Lang::EN); assert_eq!(crate::ribbon::caret_language(&app), "Italian"); + super::pseudo::set(true); + assert_eq!(crate::ribbon::caret_language(&app), "⟦Italian⟧", "the language name goes through the catalog"); + super::pseudo::set(false); } #[test] @@ -1163,25 +799,6 @@ fn german_locales_and_saved_preference_keep_document_content() { assert_eq!(Lang::from_pref(&restored.language), de); } -#[test] -fn german_covers_the_interface_catalog_and_keeps_count_labels_neutral() { - use std::collections::HashSet; - let keys = |source| parse_entries(source).0.into_iter().map(|e| e.source).collect::>(); - let de = lang("de"); - assert_eq!(keys(de.0.source), keys(lang("zh-hans").0.source)); - assert_eq!(tr(de, "Home"), "Start"); - assert_eq!(tr(de, "Save"), "Speichern"); - assert_eq!(tr(de, "Spelling & Grammar"), "Rechtschreibung und Grammatik"); - assert_eq!(tr(de, "unknown future label"), "unknown future label"); - assert_eq!(fmt(tr(de, "Exported {path}"), &[("path", "draft-{words}.docx")]), "Exportiert: draft-{words}.docx"); - // Count-neutral wording is grammatical for every count: `Wörter: 21`, `Wörter: 21; ausgewählt: 1`. - for count in [0, 1, 2, 11, 21, 101] { - let words = count.to_string(); - assert_eq!(fmt(tr(de, "{words} words"), &[("words", &words)]), format!("Wörter: {count}")); - assert_eq!(fmt(tr(de, "{selected} of {words} words"), &[("selected", "1"), ("words", &words)]), format!("Wörter: {count}; ausgewählt: 1")); - } -} - #[test] fn bundled_interface_fonts_cover_german_letters() { let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new()); @@ -1214,31 +831,6 @@ fn italian_locales_and_saved_preference_keep_document_content() { assert_eq!(Lang::from_pref(&restored.language), it); } -#[test] -fn italian_covers_the_entire_existing_interface_catalog_and_keeps_count_labels_neutral() { - use std::collections::HashSet; - let keys = |source| parse_entries(source).0.into_iter().map(|e| e.source).collect::>(); - let it = lang("it"); - for other in ["es", "tr", "et", "zh-hans"] { - assert!(keys(lang(other).0.source).is_subset(&keys(it.0.source)), "{other} has keys Italian lacks"); - } - assert_eq!(tr(it, "Good morning"), "Buongiorno"); - assert_eq!(tr(it, "Save"), "Salva"); - assert_eq!(tr(it, "Insert"), "Inserisci"); - assert_eq!(tr(it, "Spelling & Grammar"), "Ortografia e grammatica"); - assert_eq!(tr(it, "unknown future label"), "unknown future label"); - // The Review tab's group and its toggle keep different names. - assert_ne!(tr(it, "Changes"), tr(it, "Track Changes")); - // Italian nouns agree with the number, so count labels put the noun first and read right at 1. - for count in [0, 1, 2, 11, 21, 101] { - let n = count.to_string(); - assert_eq!(fmt(tr(it, "{words} words"), &[("words", &n)]), format!("Parole: {count}")); - assert_eq!(fmt(tr(it, "{selected} of {words} words"), &[("selected", "1"), ("words", &n)]), format!("Parole: {count}; selezionate: 1")); - assert_eq!(fmt(tr(it, "{n} lines"), &[("n", &n)]), format!("Righe: {count}")); - assert_eq!(fmt(tr(it, "{count} items highlighted"), &[("count", &n)]), format!("Elementi evidenziati: {count}")); - } -} - #[test] fn bundled_interface_fonts_cover_italian_letters() { let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new()); @@ -1271,41 +863,6 @@ fn bulgarian_locales_and_saved_preference_work_without_changing_the_document() { assert_eq!(bg.name(), "Български"); } -#[test] -fn bulgarian_covers_the_entire_existing_interface_catalog() { - use std::collections::HashSet; - let keys = |source| parse_entries(source).0.into_iter().map(|e| e.source).collect::>(); - // Every key the interface looks up (Spanish carries them all), and no catalog errors. - assert!(keys(lang("bg").0.source).is_superset(&keys(lang("es").0.source))); - assert!(parse_entries(lang("bg").0.source).1.is_empty()); - let bg = lang("bg"); - for (source, expected) in [ - ("Home", "Начало"), - ("Insert", "Вмъкване"), - ("Font", "Шрифт"), - ("Paragraph", "Абзац"), - ("Review", "Преглед"), - ("Save", "Запазване"), - ("Heading 1", "Заглавие 1"), - ("Hyphenation", "Сричкопренасяне"), - ("Track Changes", "Проследяване на промените"), - ("WordCraft", "WordCraft"), - ("unknown future label", "unknown future label"), - ] { - assert_eq!(tr(bg, source), expected, "{source}"); - } - // The search hint's example must find the command it names. - assert_eq!(tr(bg, "Insert Table"), "Вмъкване на таблица"); - assert!(tr(bg, "Type a command, e.g. \"insert table\"").contains("вмъкване на таблица")); - assert_eq!(fmt(tr(bg, "Page {page} of {pages}"), &[("page", "3"), ("pages", "9")]), "Страница 3 от 9"); - // Count-neutral wording is grammatical for every count: `Думи: 21`, `Думи: 1 от 21`. - for count in [0, 1, 2, 5, 21, 100, 1000] { - let words = count.to_string(); - assert_eq!(fmt(tr(bg, "{words} words"), &[("words", &words)]), format!("Думи: {count}")); - assert_eq!(fmt(tr(bg, "{selected} of {words} words"), &[("selected", "1"), ("words", &words)]), format!("Думи: 1 от {count}")); - } -} - #[test] fn bundled_interface_fonts_cover_bulgarian_without_system_fallbacks() { let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new()); @@ -1348,49 +905,6 @@ fn croatian_locales_and_saved_preference_work_without_changing_the_document() { assert_eq!(hr.name(), "Hrvatski"); } -#[test] -fn croatian_covers_the_entire_existing_interface_catalog_and_keeps_count_labels_neutral() { - use std::collections::HashSet; - let keys = |source| parse_entries(source).0.into_iter().map(|e| e.source).collect::>(); - let hr = lang("hr"); - // Every key of every other catalog, and no catalog errors. - for other in Lang::all().filter(|l| *l != Lang::EN && l.code() != "hr") { - let mut missing: Vec = keys(other.0.source).difference(&keys(hr.0.source)).cloned().collect(); - missing.sort(); - // Italian and Hungarian still carry "Style: APA", which the interface no longer shows. - missing.retain(|k| k != "Style: APA" || !matches!(other.code(), "it" | "hu")); - assert!(missing.is_empty(), "hr lacks {}'s {missing:?}", other.code()); - } - assert!(parse_entries(hr.0.source).1.is_empty()); - for (source, expected) in [ - ("File", "Datoteka"), - ("Home", "Početna"), - ("Insert", "Umetni"), - ("Review", "Pregled"), - ("Save", "Spremi"), - ("Heading 1", "Naslov 1"), - ("Spelling & Grammar", "Pravopis i gramatika"), - ("Hyphenation", "Rastavljanje riječi"), - ("WordCraft", "WordCraft"), - ("unknown future label", "unknown future label"), - ] { - assert_eq!(tr(hr, source), expected, "{source}"); - } - assert_eq!(fmt(tr(hr, "Page {page} of {pages}"), &[("page", "3"), ("pages", "9")]), "Stranica 3 od 9"); - // The letter template is "Pismo"; the paper size of the same English name stays "Letter". - assert_eq!(trc(hr, "template", "Letter"), "Pismo"); - assert_eq!(tr(hr, "Letter"), "Letter"); - // Croatian nouns take three forms after numerals (1, 2–4, 5+), so counts read `Riječi: 21`. - for count in [0, 1, 2, 4, 5, 12, 14, 21, 22, 25, 101, 1000] { - let words = count.to_string(); - assert_eq!(fmt(tr(hr, "{words} words"), &[("words", &words)]), format!("Riječi: {count}")); - assert_eq!(fmt(tr(hr, "{selected} of {words} words"), &[("selected", "1"), ("words", &words)]), format!("Riječi: 1 od {count}")); - } - set_current(hr); - assert_eq!(location("Review › Proofing"), "Pregled › Jezična provjera"); - set_current(Lang::EN); -} - #[test] fn bundled_interface_fonts_cover_croatian_without_system_fallbacks() { let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new()); @@ -1433,42 +947,6 @@ fn swedish_locales_and_saved_preference_work_without_changing_the_document() { assert_eq!(sv.name(), "Svenska"); } -#[test] -fn swedish_covers_the_entire_existing_interface_catalog() { - use std::collections::HashSet; - let keys = |source| parse_entries(source).0.into_iter().map(|e| e.source).collect::>(); - // Every key the interface looks up (Spanish carries them all), and no catalog errors. - assert!(keys(lang("sv").0.source).is_superset(&keys(lang("es").0.source))); - assert!(parse_entries(lang("sv").0.source).1.is_empty()); - let sv = lang("sv"); - for (source, expected) in [ - ("File", "Arkiv"), - ("Home", "Start"), - ("Insert", "Infoga"), - ("Font", "Typsnitt"), - ("Paragraph", "Stycke"), - ("Review", "Granska"), - ("Save", "Spara"), - ("Heading 1", "Rubrik 1"), - ("Hyphenation", "Avstavning"), - ("Track Changes", "Spåra ändringar"), - ("WordCraft", "WordCraft"), - ("unknown future label", "unknown future label"), - ] { - assert_eq!(tr(sv, source), expected, "{source}"); - } - // The search hint's example must find the command it names. - assert_eq!(tr(sv, "Insert Table"), "Infoga tabell"); - assert!(tr(sv, "Type a command, e.g. \"insert table\"").contains(&tr(sv, "Insert Table").to_lowercase())); - assert_eq!(fmt(tr(sv, "Page {page} of {pages}"), &[("page", "3"), ("pages", "9")]), "Sida 3 av 9"); - // `ord` is the same in the singular and the plural, so the counts read right for every number. - for count in [0, 1, 2, 5, 21, 100, 1000] { - let words = count.to_string(); - assert_eq!(fmt(tr(sv, "{words} words"), &[("words", &words)]), format!("{count} ord")); - assert_eq!(fmt(tr(sv, "{selected} of {words} words"), &[("selected", "1"), ("words", &words)]), format!("1 av {count} ord")); - } -} - #[test] fn bundled_interface_fonts_cover_swedish_letters() { let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new()); @@ -1512,46 +990,6 @@ fn vietnamese_locales_and_saved_preference_work_without_changing_the_document() assert_eq!(vi.name(), "Tiếng Việt"); } -#[test] -fn vietnamese_covers_existing_catalog_keys_with_an_explicit_preview_fallback() { - use std::collections::HashSet; - let vi = lang("vi"); - let (entries, errors) = parse_entries(vi.0.source); - assert!(errors.is_empty(), "{errors:?}"); - let vi_keys: HashSet<_> = entries.into_iter().map(|e| (e.context, e.source)).collect(); - // The short plain title fits the preview too; this is the only deliberate context fallback. - let preview_title = ("preview".to_string(), "Title".to_string()); - assert!(vi_keys.contains(&(String::new(), "Title".to_string()))); - assert_eq!(trc(vi, "preview", "Title"), "Tiêu đề"); - assert_eq!(trc(vi, "preview", "Title"), tr(vi, "Title")); - // Compare complete (context, source) keys so a contextual entry cannot hide a missing plain - // translation, or silently satisfy another context's key. - for other in Lang::all().filter(|l| *l != Lang::EN && l.code() != "vi") { - let (entries, errors) = parse_entries(other.0.source); - assert!(errors.is_empty(), "{}: {errors:?}", other.code()); - let keys: HashSet<_> = entries.into_iter().map(|e| (e.context, e.source)).collect(); - let mut missing: Vec<_> = keys.difference(&vi_keys).filter(|key| **key != preview_title).cloned().collect(); - missing.sort(); - assert!(missing.is_empty(), "vi lacks {}'s {missing:?}", other.code()); - } -} - -#[test] -fn vietnamese_translations_preserve_templates_and_count_labels() { - let vi = lang("vi"); - assert_eq!(tr(vi, "Home"), "Trang chủ"); - assert_eq!(tr(vi, "Save"), "Lưu"); - assert_eq!(tr(vi, "Spelling & Grammar"), "Chính tả & Ngữ pháp"); - assert_eq!(tr(vi, "unknown future label"), "unknown future label"); - assert_eq!(fmt(tr(vi, "Exported {path}"), &[("path", "draft-{words}.docx")]), "Đã xuất draft-{words}.docx"); - // Count-neutral wording (`Từ: 21`): a Vietnamese noun stays unchanged after a number. - for count in [0, 1, 2, 11, 21, 101] { - let words = count.to_string(); - assert_eq!(fmt(tr(vi, "{words} words"), &[("words", &words)]), format!("Từ: {count}")); - assert_eq!(fmt(tr(vi, "{selected} of {words} words"), &[("selected", "1"), ("words", &words)]), format!("Từ: 1 trên {count}")); - } -} - #[test] fn bundled_interface_fonts_cover_vietnamese_letters() { let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new()); @@ -1581,9 +1019,14 @@ fn bundled_interface_fonts_cover_vietnamese_letters() { /// #3317: the spooler's English receipt is not spliced into a translated sentence. #[test] fn spooled_print_receipt_is_translated() { - let german = lang("de"); - let msg = crate::printing::feedback(&crate::printing::Feedback::Submitted("spooled 2 sheet(s)".into()), |s| tr(german, s).to_owned()); - assert_eq!(msg, "Druckauftrag gesendet: 2 Blatt an den Spooler übergeben"); + use crate::printing::{Feedback, feedback}; + let (c, errors) = Catalog::parse( + "\tPrint job submitted: spooled {count} sheet(s)\tSpooled-FX: {count}\n\tPrint job submitted: {receipt}\tSubmitted-FX: {receipt}\n", + ); + assert!(errors.is_empty(), "{errors:?}"); + let receipt = || Feedback::Submitted("spooled 2 sheet(s)".into()); + assert_eq!(feedback(&receipt(), |s| c.plain(s).unwrap_or(s).to_owned()), "Spooled-FX: 2"); + assert_eq!(feedback(&receipt(), str::to_owned), "Print job submitted: spooled 2 sheet(s)"); } #[test] @@ -1607,28 +1050,6 @@ fn romanian_locales_and_saved_preference_work_without_changing_the_document() { assert!(!ro.uses_cjk()); } -#[test] -fn romanian_covers_every_key_of_every_other_catalog_and_keeps_placeholders() { - use std::collections::HashSet; - let ro = lang("ro"); - let (entries, errors) = parse_entries(ro.0.source); - assert!(errors.is_empty(), "{errors:?}"); - let ro_keys: HashSet<_> = entries.iter().map(|e| (e.context.clone(), e.source.clone())).collect(); - for other in Lang::all().filter(|l| *l != Lang::EN && l.code() != "ro") { - let (other_entries, other_errors) = parse_entries(other.0.source); - assert!(other_errors.is_empty(), "{}: {other_errors:?}", other.code()); - let mut missing: Vec<_> = other_entries.into_iter().map(|e| (e.context, e.source)).filter(|k| !ro_keys.contains(k)).collect(); - missing.sort(); - // Italian and Hungarian still carry "Style: APA", which the interface no longer shows. - missing.retain(|(_, k)| k != "Style: APA" || !matches!(other.code(), "it" | "hu")); - assert!(missing.is_empty(), "ro lacks {}'s {missing:?}", other.code()); - } - assert_eq!(tr(ro, "Home"), "Pornire"); - assert_eq!(tr(ro, "Save"), "Salvare"); - assert_eq!(tr(ro, "unknown future label"), "unknown future label"); - assert_eq!(fmt(tr(ro, "Exported {path}"), &[("path", "draft.docx")]), "Exportat: draft.docx"); -} - #[test] fn bundled_interface_fonts_cover_romanian_without_system_fallbacks() { let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new()); @@ -1666,51 +1087,6 @@ fn slovenian_locales_and_saved_preference_work_without_changing_the_document() { assert_eq!(Lang::from_pref(&restored.language), sl); } -#[test] -fn slovenian_covers_every_key_of_every_other_catalog() { - use std::collections::HashSet; - let keys = |source| parse_entries(source).0.into_iter().map(|e| e.source).collect::>(); - let slovenian = keys(lang("sl").0.source); - assert!(parse_entries(lang("sl").0.source).1.is_empty()); - for other in Lang::all().filter(|l| *l != Lang::EN && l.code() != "sl") { - let mut missing: Vec = keys(other.0.source).difference(&slovenian).cloned().collect(); - missing.sort(); - // Italian and Hungarian still carry "Style: APA", which the interface no longer shows. - missing.retain(|k| k != "Style: APA" || !matches!(other.code(), "it" | "hu")); - assert!(missing.is_empty(), "sl lacks {}'s {missing:?}", other.code()); - } - let sl = lang("sl"); - for (source, expected) in [ - ("File", "Datoteka"), - ("Home", "Osnovno"), - ("Insert", "Vstavljanje"), - ("Layout", "Postavitev"), - ("References", "Sklici"), - ("Review", "Pregled"), - ("View", "Pogled"), - ("Save", "Shrani"), - ("Bold", "Krepko"), - ("Italic", "Ležeče"), - ("Underline", "Podčrtano"), - ("Track Changes", "Sledenje spremembam"), - ("Insert Citation", "Vstavi citat"), - ("Citations", "Citati"), - ("unknown future label", "unknown future label"), - ] { - assert_eq!(tr(sl, source), expected, "{source}"); - } - // The search hint's example must find the command it names. - assert_eq!(tr(sl, "Insert Table"), "Vstavi tabelo"); - assert!(tr(sl, "Type a command, e.g. \"insert table\"").contains("vstavi tabelo")); - assert_eq!(fmt(tr(sl, "Page {page} of {pages}"), &[("page", "3"), ("pages", "9")]), "Stran 3 od 9"); - // Count-neutral wording is grammatical for every count (Slovenian has singular, dual and plural). - for count in [0, 1, 2, 3, 5, 101, 102] { - let words = count.to_string(); - assert_eq!(fmt(tr(sl, "{words} words"), &[("words", &words)]), format!("Besede: {count}")); - assert_eq!(fmt(tr(sl, "{selected} of {words} words"), &[("selected", "1"), ("words", &words)]), format!("Besede: 1 od {count}")); - } -} - #[test] fn bundled_interface_fonts_cover_slovenian_letters() { let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new()); @@ -1723,17 +1099,3 @@ fn bundled_interface_fonts_cover_slovenian_letters() { } assert!(!lang("sl").uses_cjk()); } - -#[test] -fn slovenian_has_no_keys_outside_the_other_catalogs() { - use std::collections::HashSet; - let keys = |source| parse_entries(source).0.into_iter().map(|e| e.source).collect::>(); - let slovenian = keys(lang("sl").0.source); - let mut union = HashSet::new(); - for other in Lang::all().filter(|l| *l != Lang::EN && l.code() != "sl") { - union.extend(keys(other.0.source)); - } - let mut extra: Vec = slovenian.difference(&union).cloned().collect(); - extra.sort(); - assert!(extra.is_empty(), "sl has {} keys no other catalog has: {extra:?}", extra.len()); -} diff --git a/crates/ui-egui/src/options.rs b/crates/ui-egui/src/options.rs index 4e50a2cef..5b303f1be 100644 --- a/crates/ui-egui/src/options.rs +++ b/crates/ui-egui/src/options.rs @@ -912,10 +912,6 @@ mod tests { .expect("navigation label is painted"); let bounds = text.visual_bounding_rect(); assert!(row.contains_rect(bounds.intersect(clip)), "{language} {percent}% {selected}: {label} paints outside {row:?}"); - if language == "ru" && id == "accessibility" { - assert_eq!(label, "Доступность"); - assert!(clip.contains_rect(bounds), "{language} {percent}% {selected}: {label} must fit its row"); - } if language == "en" && ["general", "display", "save"].contains(&id) { assert!(clip.contains_rect(bounds), "short labels must remain fully visible"); } diff --git a/docs/localization-hu.md b/docs/localization-hu.md index 2b6f2f920..cee5fa13a 100644 --- a/docs/localization-hu.md +++ b/docs/localization-hu.md @@ -1,6 +1,6 @@ # Hungarian interface -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** trivial (the interface audit lists missing Hungarian entries instead of failing; previously minor (Hungarian interface catalog and regression checks)) · **Target:** WordCraft 0.5.0 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** trivial (interface-language tests: a missing translation never fails a test, `cargo xtask i18n-report` in `cargo xtask ci` prints the strings each language lacks, and lookup and template tests use inline fixture catalogs or the pseudo-language; previously trivial: the interface audit lists missing Hungarian entries instead of failing; previously minor: Hungarian interface catalog and regression checks) · **Target:** WordCraft 0.5.0 Choose **File › Options › Interface language › Magyar**, or follow a Hungarian system display language (`hu-HU`, `hu_HU.UTF-8`). The catalog code is `hu`; `ui.language` with `{"value":"hu"}` @@ -59,12 +59,9 @@ acute capitals. ## Verification and remaining scope `cargo test -p wordcraft-ui-egui --lib i18n::tests` covers Hungarian locale tags and preference -order, saving and restoring `hu`, unchanged document content, every key of the Russian catalog, -built-in style names, placeholders, count wording, the font sample's capitals, English fallback, -and the glyphs `ÁáÉéÍíÓóÖöŐőÚúÜüŰű` in the bundled Inter (Regular, Medium, SemiBold) and JetBrains Mono. The shared catalog checks -(validation, every tab and command translated) include Hungarian automatically, and the on-demand -interface audit (`untranslated_interface_text`) lists Hungarian strings it looked up that the -catalog lacks. +order, saving and restoring `hu`, unchanged document content and the glyphs `ÁáÉéÍíÓóÖöŐőÚúÜüŰű` in +the bundled Inter (Regular, Medium, SemiBold) and JetBrains Mono. The shared checks (catalog validation and the missing-translation report, `cargo xtask i18n-report`) +include Hungarian automatically. Spelling, grammar and hyphenation stay English-only: this is an interface translation, not Hungarian proofing. A native-speaker review of the catalog is welcome. @@ -73,5 +70,7 @@ Hungarian proofing. A native-speaker review of the catalog is welcome. | Date | Change | Summary | |---|---|---| +| 2026-10-23 | trivial | Lookup, context, template, style-name and command-search tests use inline fixture catalogs or the pseudo-language; tests that asserted one language's catalog wording removed | +| 2026-10-23 | trivial | Interface-language tests: a missing translation falls back to English and never fails a test; `cargo xtask i18n-report` (part of `cargo xtask ci`) prints the strings each language lacks; single-language coverage tests removed | | 2026-10-23 | trivial | The interface audit lists Hungarian strings the catalog lacks; a missing entry doesn't fail the audit | | 2026-10-11 | minor | Hungarian catalog (2,419 entries: built-in style names, equation gallery, citation styles), conventions, terminology, scope and regression checks | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index c40174ca0..a080e9725 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (Spanish proofing: es-ES, es-MX, es-419 and es-UY from the installed Hunspell dictionaries, Uruguayan `vos` forms, opening `¿`/`¡` checks, #3360, #3553; previously trivial: interface audit: missing catalog entries are reported per language and don't fail the audit; previously minor: disabled-control reasons in ribbon hints translated in every catalog, #2678; previously trivial: Slovenian interface catalog: every key of every other catalog, 2,614 entries; previously minor: Romanian interface catalog; previously trivial: the File › Home greeting is translated in every catalog; previously trivial: Japanese row: ruby (Phonetic Guide) is supported and HTML export/import keeps it, #1681; previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously minor: English dialects: en-GB, en-CA, en-AU and the other British-spelling Englishes proof their own spellings and flag the other dialect's, #1281, #1045; previously minor: how to install the Hunspell dictionaries on Windows, macOS and Linux: `docs/spelling-dictionaries.md`; previously minor: Read Aloud speaks each sentence in its own language, #1843; previously minor: Indonesian proofing: id-ID spelling from the installed Hunspell dictionary, dictionary-driven suggestions, generic checks with Indonesian messages, #498; previously trivial: print-area strings translated in every catalog; previously trivial: Italian: the interface audit checks the Italian catalog, `Privacy Settings` wording; previously trivial: Italian grammar: `po'`, `qual è`, `È`, `un`/`un'` rules; more elisions and truncations in spelling; previously trivial: Czech interface catalog completed, 2,447 entries; previously trivial: interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (Spanish proofing: es-ES, es-MX, es-419 and es-UY from the installed Hunspell dictionaries, Uruguayan `vos` forms, opening `¿`/`¡` checks, #3360, #3553; previously trivial: interface-language tests: a missing translation never fails a test, `cargo xtask i18n-report` in `cargo xtask ci` prints the strings each language lacks, and lookup and template tests use inline fixture catalogs or the pseudo-language; previously trivial: interface audit: missing catalog entries are reported per language and don't fail the audit; previously minor: disabled-control reasons in ribbon hints translated in every catalog, #2678; previously trivial: Slovenian interface catalog: every key of every other catalog, 2,614 entries; previously minor: Romanian interface catalog; previously trivial: the File › Home greeting is translated in every catalog; previously trivial: Japanese row: ruby (Phonetic Guide) is supported and HTML export/import keeps it, #1681; previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously minor: English dialects: en-GB, en-CA, en-AU and the other British-spelling Englishes proof their own spellings and flag the other dialect's, #1281, #1045; previously minor: how to install the Hunspell dictionaries on Windows, macOS and Linux: `docs/spelling-dictionaries.md`; previously minor: Read Aloud speaks each sentence in its own language, #1843; previously minor: Indonesian proofing: id-ID spelling from the installed Hunspell dictionary, dictionary-driven suggestions, generic checks with Indonesian messages, #498; previously trivial: print-area strings translated in every catalog; previously trivial: Italian: the interface audit checks the Italian catalog, `Privacy Settings` wording; previously trivial: Italian grammar: `po'`, `qual è`, `È`, `un`/`un'` rules; more elisions and truncations in spelling; previously trivial: Czech interface catalog completed, 2,447 entries; previously trivial: interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Interface languages, script support and proofing per language. How translation works (catalog format, adding a language, clean-room rule): `crates/ui-egui/src/i18n/mod.rs`. @@ -21,8 +21,15 @@ native-speaker review (human). - **27 interface languages**: English plus `zh-hans`, `zh-hant`, `ja`, Korean `ko` (#1216), `uk`, `es`, `pt-br`, Serbian `sr`/`sr-latn` (#250), Estonian `et`, Turkish `tr`, Russian `ru`, Italian `it` (#594), German `de` (#574), Polish `pl`, Czech `cs`, Hungarian `hu`, French `fr`, Bulgarian `bg`, Vietnamese `vi` (#1434), Swedish `sv` (#1219), Croatian `hr` (#1271), Romanian `ro`, Slovenian `sl`, Dutch `nl` and Danish `da` (#2722). Catalogs have 2,061–2,674 entries - each; Polish has every key of every other catalog, including the built-in style names and the equation gallery, except the citation style names only Hungarian, Bulgarian, Swedish, Romanian and Slovenian translate so far (a test checks it). A test (`every_tab_and_command_is_translated`) enforces that every ribbon tab, command - label and ribbon location is translated in every language. + each; Polish has every key of every other catalog, including the built-in style names and the equation gallery, except the citation style names only Hungarian, Bulgarian, Swedish, Romanian and Slovenian translate so far. A missing translation falls back to English and never fails a test or + build. `cargo xtask i18n-report`, part of `cargo xtask ci`, prints per language the interface + strings its catalog lacks: everything the interface audit saw looked up, the ribbon tabs, command + labels and ribbon locations, the File › Home greetings, disabled-control reasons and the equation + gallery. The catalog checks apply to every language alike: format (the file parses, no duplicate + keys, placeholders match the English, whitespace, ellipses). Font tests check that the bundled faces have a glyph for each catalog's letters. + Tests of the lookup code (English fallback, contexts, placeholder templates, built-in style names, + the per-thread language, command search) use small inline catalogs or the pseudo-language, so + rewording a translation never fails a test. `pl`, `pl-PL` and `pl_PL.UTF-8` system locales pick Polish automatically. - Of the 675 English strings a scan extracts from `tl!(…)` calls and command labels/locations, each catalog covers **661 (98%)**. Some newer dialog and pane strings (Equation tab tooltips, Zotero, @@ -76,8 +83,7 @@ entries, the same keys as the Russian catalog. Command search folds the Turkish finds Yazı Tipi) while English labels and ASCII command ids keep their usual matching. Turkish proofing is not included; terminology and checks: [`localization-tr.md`](localization-tr.md). Bulgarian (`bg-BG` system locales, `{"value":"bg"}`) has 2,419 entries: every key of the reference -catalog, the equation gallery's labels and everything the interface audit finds (the audit checks -Bulgarian as it does Russian). Counts use count-neutral wording (`Думи: 21`). Bulgarian text is +catalog, the equation gallery's labels and everything the interface audit finds. Counts use count-neutral wording (`Думи: 21`). Bulgarian text is hyphenated by the Bulgarian syllable rules (#826); Bulgarian spelling and grammar are not included. Slovenian (`sl-SI` system locales, `{"value":"sl"}`) has 2,614 entries: every key of every other catalog. Counts use count-neutral wording (`Besede: 21`); terminology: [`localization-sl.md`](localization-sl.md). @@ -88,9 +94,8 @@ the Russian catalog, built-in style names shown translated, plus the citation st Czech follows `cs-CZ` system locales and `{"value":"cs"}`, with 2,447 entries: every key of the Russian and Spanish catalogs, including built-in style names and numbered families, the equation -gallery, watermarks, paper sizes and preview tiles. The Czech coverage test -enforces both reference catalogs and retains the Chinese catalog coverage; counts keep the existing -count-neutral wording (`Počet slov: 21`). Czech proofing is not included. +gallery, watermarks, paper sizes and preview tiles. Counts use count-neutral wording +(`Počet slov: 21`). Czech proofing is not included. When no saved proofing-language default exists, Polish, Portuguese and Czech interfaces write new documents and opened TXT/Markdown text in their proofing languages (`pl-PL`, `pt-BR`, `cs-CZ`). @@ -98,18 +103,16 @@ A Spanish interface writes them in the region's Spanish: `es-` plus the system r region's language is Spanish (`es-UY` on a Uruguayan system, `es-MX` on a Mexican one), else Spain's `es-ES` (#3360). -**Interface audit (runs in CI).** `untranslated_interface_text` lays the app out in a pseudo-language -(every tab, File page, dialog, pane, the right-click menu, and a hover and a click on every ribbon -control) and fails on visible text that skipped the catalog. It then prints, per language, the -strings it looked up that the catalog lacks (`--nocapture`); a missing entry falls back to English -and doesn't fail the audit. Proper names (citation styles such as APA or +**Interface audit (runs in CI).** `cargo xtask i18n-report` runs `untranslated_interface_text`, +which lays the app out in a pseudo-language (every tab, File page, dialog, pane, the right-click +menu, and a hover and a click on every ribbon control) and fails on visible text that skipped the +catalog. It then prints the missing-translation report described above, which never fails. Proper names (citation styles such as APA or GOST, LaTeX, Unicode) and `bad parameters:` errors, which name a command's JSON parameters for scripts and MCP, stay English. Size labels (Envelopes, Labels, Layout › Size) are translated patterns (`{name} {height} × {width} in`) with the region's decimal separator; paper names other than envelopes (Letter, Legal, A4, B5 (JIS)) stay as they are (#1128). -`every_disabled_reason_is_translated` reads every command's `enabled` check from the engine's -command sources (the reason a ribbon hint gives for a greyed-out control, #1086) and fails if any -catalog lacks one (#2678). +The report's disabled-control reasons (the reason a ribbon hint gives for a greyed-out control, +#1086, #2678) are read from every command's `enabled` check in the engine's command sources. Set Proofing Language › Set As Default keeps the chosen language in user preferences across restarts (#2882), taking precedence over the interface language for blank documents and plain-text/Markdown files. Existing DOCX and built-in English templates keep their own language. Ordinary selection-language changes do not change this preference; older settings keep following the interface language. A rejected command, including a final protected-region check, retains the previous saved default. This does not install dictionaries or add proofing support. @@ -147,8 +150,6 @@ Italian (`it`, #594) follows `it`, `it-IT` and `it-CH` system locales. Its catal the others plus 51 strings that are wrapped in `tl!` but were in no catalog yet (File › Info properties, the File › Home greeting, list levels, Equation tab gallery and tooltips). Ribbon tabs, groups and command names use the names Italian Word users know (feature names, as `AGENTS.md` -allows); all other text is written from the meaning of the English. The interface audit also -checks the Italian catalog (2026-10-14). Italian proofing is in the proofing table above. allows); all other text is written from the meaning of the English. Italian proofing: see the table above. A native speaker reviewed the catalog on 2026-10-14 (wording fixes, consistent terms). @@ -218,6 +219,8 @@ WordCraft keeps 2,54 cm). | Date | Change | Summary | |---|---|---| +| 2026-10-23 | trivial | Lookup, context, template, style-name and command-search tests use inline fixture catalogs or the pseudo-language; tests that asserted one language's catalog wording removed | +| 2026-10-23 | trivial | Interface-language tests: a missing translation falls back to English and never fails a test; `cargo xtask i18n-report` (part of `cargo xtask ci`) prints the strings each language lacks; single-language coverage tests removed | | 2026-10-23 | trivial | Interface audit: strings a catalog lacks are printed per language, for every language, instead of failing for Russian, Hungarian, Turkish and Bulgarian | | 2026-10-22 | minor | Spanish proofing (#3360, #3553): es-ES, es-MX, es-419 and es-UY each read their installed Hunspell dictionary (RLA-ES, not bundled), Czech's dictionary-driven suggestions, Uruguayan `vos` forms (imperatives included), repeated-word, spacing and opening-mark checks with Spanish messages; Review › Language, the status bar, Set Proofing Language and File › Options › Proofing list the four varieties, and a Spanish interface writes new documents in the region's Spanish; estimates unchanged | | 2026-10-21 | trivial | Japanese row: ruby (Phonetic Guide) is supported over its base text and HTML export/import keeps it (#1681); the row had listed ruby as missing | diff --git a/docs/localization-sl.md b/docs/localization-sl.md index 6cf6466b6..83ba4f265 100644 --- a/docs/localization-sl.md +++ b/docs/localization-sl.md @@ -1,6 +1,6 @@ # Slovenian interface -> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** trivial (catalog completed to every key of every other catalog, 2,614 entries) · **Target:** WordCraft 0.7.0 +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-23 · **Change:** trivial (interface-language tests: a missing translation never fails a test, `cargo xtask i18n-report` in `cargo xtask ci` prints the strings each language lacks, and lookup and template tests use inline fixture catalogs or the pseudo-language; previously trivial: catalog completed to every key of every other catalog, 2,614 entries) · **Target:** WordCraft 0.7.0 Choose **File › Options › Interface language › Slovenščina**, or follow a Slovenian system display language (`sl-SI`, `sl_SI.UTF-8`). The catalog code is `sl`; `ui.language` with `{"value":"sl"}` @@ -57,7 +57,15 @@ resources were consulted. ## Verification and remaining scope -Tests in `crates/ui-egui/src/i18n/tests.rs` check locale selection, the saved preference, that the -catalog covers every key of every other catalog and has no keys outside them, key terms, the search -hint example and the bundled fonts' coverage of č, š, ž. Slovenian spelling, grammar and hyphenation -are not included. Native speaker review is welcome. +Tests in `crates/ui-egui/src/i18n/tests.rs` check locale selection, the saved preference and the +bundled fonts' coverage of č, š, ž. The shared checks (catalog validation and the missing-translation report, `cargo xtask i18n-report`) +include Slovenian automatically. Slovenian spelling, grammar and hyphenation are not included. Native speaker review +is welcome. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-23 | trivial | Lookup, context, template, style-name and command-search tests use inline fixture catalogs or the pseudo-language; tests that asserted one language's catalog wording removed | +| 2026-10-23 | trivial | Interface-language tests: a missing translation falls back to English and never fails a test; `cargo xtask i18n-report` (part of `cargo xtask ci`) prints the strings each language lacks; single-language coverage tests removed | +| 2026-10-15 | trivial | Catalog completed to every key of every other catalog (2,614 entries) | diff --git a/docs/localization-sv.md b/docs/localization-sv.md index 4c93d58ad..5c7628c72 100644 --- a/docs/localization-sv.md +++ b/docs/localization-sv.md @@ -1,6 +1,6 @@ # Swedish interface -> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-23 · **Change:** trivial (the interface audit lists missing Swedish entries instead of failing; previously minor (Swedish interface catalog and regression checks)) · **Target:** WordCraft 0.6.1 +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-23 · **Change:** trivial (interface-language tests: a missing translation never fails a test, `cargo xtask i18n-report` in `cargo xtask ci` prints the strings each language lacks, and lookup and template tests use inline fixture catalogs or the pseudo-language; previously trivial: the interface audit lists missing Swedish entries instead of failing; previously minor: Swedish interface catalog and regression checks) · **Target:** WordCraft 0.6.1 Choose **File › Options › Interface language › Svenska**, or follow a Swedish system display language (`sv-SE`, `sv_SE.UTF-8`, and `sv-FI` for Swedish in Finland). The catalog code is `sv`; @@ -79,13 +79,10 @@ quotes); they are translated as written and still act on English text. ## Verification and remaining scope `cargo test -p wordcraft-ui-egui --lib i18n::tests` covers Swedish locale tags (`sv`, `sv-SE`, -`sv_SE.UTF-8`, `SV-se`, `sv-FI`) and preference order, saving and restoring `sv`, unchanged -document content, every key of the reference catalog, no catalog errors, the search hint's example -(`infoga tabell`), page and count wording, English fallback, and the glyphs `ÅÄÖåäöÉé` plus every -other accented letter the catalog uses in the bundled Inter (Regular, Medium, SemiBold) and -JetBrains Mono. The shared catalog checks (validation, every tab and command translated) include -Swedish automatically, and the interface audit (`untranslated_interface_text`) lists Swedish -strings it looked up that the catalog lacks. +`sv_SE.UTF-8`, `SV-se`, `sv-FI`) and preference order, saving and restoring `sv`, unchanged document +content and the glyphs `ÅÄÖåäöÉé` plus every other accented letter the catalog uses in the bundled +Inter (Regular, Medium, SemiBold) and JetBrains Mono. The shared checks (catalog validation and the missing-translation report, `cargo xtask i18n-report`) +include Swedish automatically. Spelling, grammar and hyphenation stay English-only: this is an interface translation, not Swedish proofing. @@ -94,5 +91,7 @@ Swedish proofing. | Date | Change | Summary | |---|---|---| +| 2026-10-23 | trivial | Lookup, context, template, style-name and command-search tests use inline fixture catalogs or the pseudo-language; tests that asserted one language's catalog wording removed | +| 2026-10-23 | trivial | Interface-language tests: a missing translation falls back to English and never fails a test; `cargo xtask i18n-report` (part of `cargo xtask ci`) prints the strings each language lacks; single-language coverage tests removed | | 2026-10-23 | trivial | The interface audit lists Swedish strings the catalog lacks; a missing entry doesn't fail the audit | | 2026-10-13 | minor | Swedish catalog (2,504 entries), conventions, terminology, known compromises and regression checks | diff --git a/docs/localization-tr.md b/docs/localization-tr.md index 664b45545..a72a23688 100644 --- a/docs/localization-tr.md +++ b/docs/localization-tr.md @@ -1,6 +1,6 @@ # Turkish interface -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (Turkish interface catalog completed, 2,325 entries, #774; previously minor: Turkish interface catalog and regression checks) · **Target:** WordCraft 0.5.0 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** trivial (interface-language tests: a missing translation never fails a test, `cargo xtask i18n-report` in `cargo xtask ci` prints the strings each language lacks, and lookup and template tests use inline fixture catalogs or the pseudo-language; previously minor: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Turkish interface catalog and regression checks) · **Target:** WordCraft 0.5.0 Choose **File › Options › Interface language › Türkçe**, or follow a Turkish system display language (`tr-TR`, `tr-CY`, `tr_TR.UTF-8`). The catalog code is `tr`; `ui.language` with `{"value":"tr"}` @@ -46,13 +46,11 @@ still work. Other interface languages and document search are unchanged. ## Verification and remaining scope `cargo test -p wordcraft-ui-egui --lib i18n::tests` covers Turkish locale tags and preference order, -saving and restoring `tr`, unknown preferences, unchanged document content, full key parity with -the Russian catalog (and superset of Spanish and Chinese), placeholders, count wording, the font -sample's capitals, English fallback, and the glyphs `ÇçĞğİıÖöŞşÜü` in the bundled Inter (Regular, -Medium, SemiBold) and JetBrains Mono. A dialog test renders real command-search results in Turkish -and English, including dotted and dotless I and ASCII command ids. The shared catalog checks -(validation, every tab and command translated, and interface audit strings) include Turkish -automatically. +saving and restoring `tr`, unknown preferences, unchanged document content and the glyphs +`ÇçĞğİıÖöŞşÜü` in the bundled Inter (Regular, Medium, SemiBold) and JetBrains Mono. A command-search +test checks dotted and dotless I under Turkish casing with fixture labels, and that English labels +and ASCII command ids still match. The shared checks (catalog validation and the missing-translation report, `cargo xtask i18n-report`) +include Turkish automatically. Turkish proofing (`crates/proof/src/tr`) provides spelling against installed Hunspell `tr_TR` dictionaries, spelling suggestions tuned for Turkish diacritics and detached particles, rule-based grammar @@ -62,6 +60,8 @@ checks (reduplications, conjunction commas, capitalization), and syllable hyphen | Date | Change | Summary | |---|---|---| +| 2026-10-23 | trivial | Lookup, context, template, style-name and command-search tests use inline fixture catalogs or the pseudo-language; tests that asserted one language's catalog wording removed | +| 2026-10-23 | trivial | Interface-language tests: a missing translation falls back to English and never fails a test; `cargo xtask i18n-report` (part of `cargo xtask ci`) prints the strings each language lacks; single-language coverage tests removed | | 2026-10-13 | minor | Turkish proofing added: spelling, suggestions, grammar rules and TDK syllable hyphenation | | 2026-10-11 | minor | Turkish catalog completed (2,325 entries, #774), covering all math structures, built-in styles, presets and preview tiles | | 2026-10-11 | minor | Turkish catalog (1,307 entries), terminology, scope and regression checks | diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 79f886c66..647e07260 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -21,8 +21,11 @@ commands: assets check that every icon/image/font/asset is attributed in ATTRIBUTION.md layers enforce the crate dependency layering (plan/architecture.md §3) wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates (one call) - test workspace tests (cargo nextest when installed, else cargo test), then doctests - ci fmt --check, clippy -D warnings, test, doctest, assets, layers, wasm + test workspace tests (cargo nextest when installed, else cargo test), the interface + audit with its missing-translation report, then doctests + i18n-report the interface audit: fails on text that skips the catalog, and prints the + strings each language has no translation for (never a failure) + ci fmt --check, clippy -D warnings, test, i18n-report, doctest, assets, layers, wasm (stops at first failure) corpus [--download] show where test corpora live; --download fetches PngSuite into corpus/pngsuite @@ -42,7 +45,8 @@ fn main() -> ExitCode { Some("assets") => assets::run(&root()), Some("layers") => cmd_layers(), Some("wasm") => cmd_wasm(), - Some("test") => cmd_test().and_then(|()| cmd_doctest()), + Some("test") => cmd_test().and_then(|()| cmd_i18n_report()).and_then(|()| cmd_doctest()), + Some("i18n-report") => cmd_i18n_report(), Some("ci") => cmd_ci(), Some("corpus") => cmd_corpus(rest.contains(&"--download")), Some("stats") => stats::run(&root(), rest.contains(&"--exact")), @@ -165,18 +169,31 @@ fn nextest_available() -> bool { cargo().args(["nextest", "--version"]).stdout(Stdio::null()).stderr(Stdio::null()).status().is_ok_and(|s| s.success()) } +/// The interface audit, which `cmd_i18n_report` runs on its own so its report is printed. +const I18N_AUDIT: &str = "i18n::audit::untranslated_interface_text"; +const I18N_AUDIT_FILTER: &str = "package(wordcraft-ui-egui) & test(=i18n::audit::untranslated_interface_text)"; + fn cmd_test() -> Result<(), String> { + test_run(&["-E", &format!("not ({I18N_AUDIT_FILTER})")], &["--skip", I18N_AUDIT]) +} + +/// Missing translations never fail the build: the report lists them in the log. +fn cmd_i18n_report() -> Result<(), String> { + test_run(&["-E", I18N_AUDIT_FILTER, "--no-capture"], &["--exact", I18N_AUDIT, "--nocapture"]) +} + +/// A test run over `TEST_TARGETS`, with nextest arguments or, without nextest, libtest arguments. +/// Every run builds the same targets, so a second run reuses the first one's build. +fn test_run(nextest: &[&str], libtest: &[&str]) -> Result<(), String> { let mut c = cargo(); - let tool = if nextest_available() { - c.args(["nextest", "run"]); - "nextest run" + if nextest_available() { + c.args(["nextest", "run"]).args(TEST_TARGETS).args(nextest); + run(c, &format!("cargo nextest run {} {}", TEST_TARGETS.join(" "), nextest.join(" "))) } else { eprintln!("note: cargo-nextest is not installed, running cargo test (cargo install cargo-nextest --locked)"); - c.arg("test"); - "test" - }; - c.args(TEST_TARGETS); - run(c, &format!("cargo {tool} {}", TEST_TARGETS.join(" "))) + c.arg("test").args(TEST_TARGETS).arg("--").args(libtest); + run(c, &format!("cargo test {} -- {}", TEST_TARGETS.join(" "), libtest.join(" "))) + } } /// nextest does not run doctests. @@ -206,6 +223,7 @@ fn cmd_ci() -> Result<(), String> { }), ), ("test", Box::new(cmd_test)), + ("i18n-report", Box::new(cmd_i18n_report)), ("doctest", Box::new(cmd_doctest)), ("assets", Box::new(|| assets::run(&root()))), ("layers", Box::new(cmd_layers)),