From 16b3594f31d37bbbbba2d4d2a092e32eda0ebe11 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:52 +0000 Subject: [PATCH] refactor(ui-egui): move the tests module of i18n.rs to i18n/tests.rs --- crates/ui-egui/src/i18n.rs | 360 +------------------------------ crates/ui-egui/src/i18n/tests.rs | 357 ++++++++++++++++++++++++++++++ 2 files changed, 358 insertions(+), 359 deletions(-) create mode 100644 crates/ui-egui/src/i18n/tests.rs diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 02e66b3de..1207915ed 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -4894,365 +4894,7 @@ pub fn workspace_name<'a>(lang: &str, name: &'a str) -> &'a str { } #[cfg(test)] -mod tests { - use super::*; - use std::collections::HashSet; - - /// Every per-language table `tr` looks up, by interface language. - const LANGUAGE_TABLES: &[(&str, &[(&str, &str)])] = &[ - ("ar", ar::TABLE), - ("cs", cs::TABLE), - ("de", de::TABLE), - ("fr", fr::TABLE), - ("it", it::TABLE), - ("ja", ja::TABLE), - ("ka", ka::TABLE), - ("pl", pl::TABLE), - ("pt-br", pt_br::TABLE), - ("ru", ru::TABLE), - ("uk", uk::TABLE), - ]; - - /// Every per-language table `tr_context` looks up. - const CONTEXT_TABLES: &[(&str, &[(&str, &str, &str)])] = - &[("ar", ar::CONTEXT), ("cs", cs::CONTEXT), ("ja", ja::CONTEXT), ("pl", pl::CONTEXT), ("ru", ru::CONTEXT), ("uk", uk::CONTEXT)]; - - /// The languages of the shared tables' columns, in column order. - const SHARED_COLUMNS: [&str; 5] = ["de", "fr", "es", "ja", "zh"]; - - /// The German and Japanese tables translate the whole interface and win over the shared columns. - const WHOLE_INTERFACE: [&str; 2] = ["de", "ja"]; - - /// What `tr` shows for a row: an empty one is still untranslated. - fn or_english<'a>(text: &'a str, english: &'a str) -> &'a str { - if text.trim().is_empty() { english } else { text } - } - - #[test] - fn cached_lookup_preserves_every_translation_across_language_switches() { - let own = |lang: &str, key: &str| { - LANGUAGE_TABLES.iter().find(|(code, _)| *code == lang).and_then(|(_, table)| table.iter().find(|(k, _)| *k == key)).map(|(_, text)| *text) - }; - for (key, translations) in TABLE { - for (lang, shared) in SHARED_COLUMNS.into_iter().zip(translations) { - // A language's own table is looked up before the shared column. - let expected = own(lang, key).filter(|text| !text.trim().is_empty()).unwrap_or(*shared); - assert_eq!(tr(lang, key), or_english(expected, key), "{lang}: {key}"); - } - assert_eq!(tr("", key), *key); - } - for (lang, table) in LANGUAGE_TABLES { - for (key, expected) in *table { - if !expected.trim().is_empty() { - assert_eq!(tr(lang, key), *expected, "{lang}: {key}"); - } - assert_eq!(tr("", key), *key); - } - } - // Strings no catalog has stay as they are, in every language. - let unknown = String::from("A user-defined untranslated label"); - for lang in LANGUAGES.iter().map(|(code, _)| *code).chain(["en", "unknown"]) { - assert!(std::ptr::eq(tr(lang, &unknown), unknown.as_str()), "{lang}"); - // Fixed standard names stay identical. - assert_eq!(tr(lang, "PDF/X-4"), "PDF/X-4", "{lang}"); - } - } - - /// The Data Merge arrangement combo takes its accessible name from `tr(lang, "Arrangement")`. - /// A row in only the shared table leaves the languages whose lookup returns directly with the - /// English name. - #[test] - fn arrangement_accessible_name_is_localized_in_every_interface_language() { - for (code, name) in LANGUAGES { - let label = tr(code, "Arrangement"); - if code.is_empty() { - assert_eq!(label, "Arrangement", "{name}"); - } else { - assert_ne!(label, "Arrangement", "{name} still announces the English accessible name"); - assert!(!label.trim().is_empty(), "{name}"); - } - } - } - - #[test] - fn context_captions_come_from_their_context_or_the_plain_translation() { - for (lang, table) in CONTEXT_TABLES { - for (key, scope, text) in *table { - if !text.trim().is_empty() { - assert_eq!(tr_context(lang, key, scope), *text, "{lang}: {key} ({scope})"); - } - } - } - for (key, scope, row) in CONTEXT { - for (lang, shared) in SHARED_COLUMNS.into_iter().zip(row) { - // A language's own context table is looked up before the shared one. - let own = CONTEXT_TABLES - .iter() - .filter(|(code, _)| *code == lang) - .flat_map(|(_, table)| table.iter()) - .find(|(k, s, _)| k == key && s == scope) - .map(|(_, _, text)| *text) - .filter(|text| !text.trim().is_empty()); - let expected = own.or(Some(*shared).filter(|text| !text.trim().is_empty())).unwrap_or_else(|| tr(lang, key)); - assert_eq!(tr_context(lang, key, scope), expected, "{lang}: {key} ({scope})"); - } - } - // An unknown context, or a language without one, keeps the plain translation. - let keys = CONTEXT.iter().map(|(key, _, _)| *key).chain(CONTEXT_TABLES.iter().flat_map(|(_, table)| table.iter().map(|(key, _, _)| *key))); - for key in keys { - for lang in LANGUAGES.iter().map(|(code, _)| *code).chain(["en"]) { - assert_eq!(tr_context(lang, key, "no such context"), tr(lang, key), "{lang}: {key}"); - } - assert_eq!(tr_context("", key, "count"), key); - } - } - - /// The tables `tr` reads, as (language, key, translation), context captions included. - fn entries() -> Vec<(&'static str, &'static str, &'static str)> { - let mut entries: Vec<_> = LANGUAGE_TABLES.iter().flat_map(|(lang, table)| table.iter().map(|(key, text)| (*lang, *key, *text))).collect(); - for (lang, i) in SHARED_COLUMNS.into_iter().zip(0..) { - entries.extend(TABLE.iter().filter_map(|(key, row)| row.get(i).map(|text| (lang, *key, *text)))); - entries.extend(CONTEXT.iter().filter_map(|(key, _, row)| row.get(i).map(|text| (lang, *key, *text)))); - } - entries.extend(CONTEXT_TABLES.iter().flat_map(|(lang, table)| table.iter().map(|(key, _, text)| (*lang, *key, *text)))); - entries - } - - /// Every catalog has one row per key and keeps the English placeholders. A string a catalog - /// doesn't translate shows in English, so a missing or empty row fails nothing here; the - /// `missing_translations_report` lists them. - #[test] - fn every_catalog_has_unique_keys_and_keeps_placeholders() { - for (lang, i) in SHARED_COLUMNS.iter().zip(0..) { - assert_eq!(column(lang), Some(i), "{lang}"); - } - for (lang, table) in LANGUAGE_TABLES { - let keys: HashSet<_> = table.iter().map(|(key, _)| *key).collect(); - assert_eq!(keys.len(), table.len(), "{lang}: duplicate keys"); - } - let shared: HashSet<_> = TABLE.iter().map(|(key, _)| *key).collect(); - assert_eq!(shared.len(), TABLE.len(), "duplicate keys in the shared table"); - let shared_contexts: HashSet<_> = CONTEXT.iter().map(|(key, scope, _)| (*key, *scope)).collect(); - assert_eq!(shared_contexts.len(), CONTEXT.len(), "duplicate keys in the shared context table"); - for (lang, table) in CONTEXT_TABLES { - let keys: HashSet<_> = table.iter().map(|(key, scope, _)| (*key, *scope)).collect(); - assert_eq!(keys.len(), table.len(), "{lang}: duplicate context keys"); - } - // A per-language table is looked up before the shared table, so a key in both hides the - // shared column, except in the tables that translate the whole interface. - for (lang, table) in LANGUAGE_TABLES.iter().filter(|(lang, _)| column(lang).is_some() && !WHOLE_INTERFACE.contains(lang)) { - let both: Vec<_> = table.iter().map(|(key, _)| *key).filter(|key| shared.contains(key)).collect(); - assert!(both.is_empty(), "{lang} keys also in the shared table: {both:?}"); - } - - let placeholders = |s: &'static str| { - let mut names: Vec<_> = s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name)).collect(); - names.sort_unstable(); - names - }; - for (lang, key, text) in entries().into_iter().filter(|(_, _, text)| !text.trim().is_empty()) { - assert_eq!(placeholders(key), placeholders(text), "{lang}: placeholders of {key:?}"); - } - } - - /// The strings each interface language shows in English, for the `cargo xtask ci` log. It - /// always passes: `cargo xtask i18n-report` runs it. - #[test] - #[ignore = "a report, run by `cargo xtask i18n-report`"] - fn missing_translations_report() { - let interface = interface_strings(); - let catalogs: Vec<(&str, HashSet<&str>)> = LANGUAGE_TABLES - .iter() - .map(|(lang, table)| (*lang, table.iter().map(|(key, _)| *key).collect())) - .chain([("shared", TABLE.iter().map(|(key, _)| *key).collect())]) - .collect(); - let mut keys: Vec<&str> = interface.iter().copied().chain(catalogs.iter().flat_map(|(_, keys)| keys.iter().copied())).collect(); - keys.sort_unstable(); - keys.dedup(); - // `tr` hands back the string it was given when it has no translation. - let missing = |lang: &str| -> Vec<&str> { - keys.iter() - .copied() - .filter(|key| { - let english = (*key).to_owned(); - std::ptr::eq(tr(lang, &english), english.as_str()) - }) - .collect() - }; - let report: Vec<(&str, Vec<&str>)> = LANGUAGES.iter().filter(|(code, _)| !code.is_empty()).map(|(code, _)| (*code, missing(code))).collect(); - println!("i18n report: {} interface strings; a missing translation shows in English.", keys.len()); - for (lang, missing) in &report { - println!(" {lang:<6} {:>5} missing", missing.len()); - } - for (lang, missing) in report.iter().filter(|(_, missing)| !missing.is_empty()) { - println!("{lang} ({}): {}", missing.len(), missing.iter().map(|key| format!("{key:?}")).collect::>().join(", ")); - } - // A catalog key neither the interface list nor another catalog knows is stale, or a label - // `interface_strings` doesn't list yet. - for (name, own) in &catalogs { - let mut unknown: Vec<_> = own - .iter() - .filter(|key| !interface.contains(*key) && !catalogs.iter().any(|(other, keys)| other != name && keys.contains(*key))) - .collect(); - unknown.sort_unstable(); - if !unknown.is_empty() { - println!("{name}: {} rows for keys the interface list doesn't have: {unknown:?}", unknown.len()); - } - } - } - - #[test] - fn only_arabic_lays_out_right_to_left() { - for (code, _) in LANGUAGES { - assert_eq!(is_rtl(code), *code == "ar", "{code}"); - } - } - - /// Built-in style and workspace names follow the interface language; names people give their - /// own styles and workspaces are document data and stay as typed. - #[test] - fn only_built_in_style_and_workspace_names_are_translated() { - for (code, _) in LANGUAGES { - for name in [designcraft_doc::NO_PARA_STYLE, designcraft_doc::BASIC_PARAGRAPH, designcraft_doc::NO_CHAR_STYLE] { - assert_eq!(style_name(code, name), tr(code, name), "{code}"); - } - assert_eq!(workspace_name(code, "Essentials"), tr(code, "Essentials"), "{code}"); - for custom in ["Group", "Columns", "My layout style"] { - assert_eq!(style_name(code, custom), custom, "{code}"); - assert_eq!(workspace_name(code, custom), custom, "{code}"); - } - } - } - - /// Languages with several plural forms put the category first, so every number reads the same. - #[test] - fn category_first_counts_put_the_category_before_every_number() { - assert!(!category_first("de") && !category_first("")); - for lang in ["ka", "uk", "ru", "pl"] { - assert!(category_first(lang), "{lang}"); - for noun in ["pages", "objects", "errors"] { - for n in [0, 1, 2, 4, 5, 11, 12, 21, 22, 25, 101, 111] { - assert_eq!(count_label(lang, noun, n), format!("{}: {n}", tr(lang, noun)), "{lang}: {noun}"); - } - } - } - assert_eq!(count_label("", "objects", 2), "2 objects"); - } - - /// Every English string the interface passes to `tr`, as far as the command and tool tables and - /// the list of UI-only labels know them. - fn interface_strings() -> HashSet<&'static str> { - let mut keys = HashSet::new(); - // Command labels and menu names reach `tr` from the engine and the UI command table. - for spec in designcraft_engine::command_specs() { - keys.insert(spec.label); - keys.extend(spec.menu.iter().copied()); - } - keys.extend(crate::menus::UI_COMMANDS.iter().map(|(_, label, _, _)| *label)); - // Tool names reach `tr` from the Tools panel. - keys.extend(designcraft_tools::TOOL_GROUPS.iter().flat_map(|group| group.iter()).map(|tool| tool.label)); - // So do the About window's tab names, app descriptions and the credits' column and sort labels. - keys.extend(crate::about::ABOUT_TABS); - keys.extend(crate::about::SIBLINGS.iter().map(|(_, _, what)| *what)); - keys.extend(crate::credits::NameMode::ALL.iter().map(|mode| mode.label())); - keys.extend(crate::credits::SortKey::ALL.iter().flat_map(|key| [key.label().0, key.label().1])); - // Docking chrome labels reach `tr` through craft-ui's localizer. - keys.extend(crate::panel_docking::DOCK_LABELS.iter().map(|(_, english)| *english)); - // UI labels passed to `tr` that no other catalog has yet. - keys.extend([ - "(any)", - "(unchanged)", - "[No Language]", - "Cell Number:", - "Differs from this document", - "Digits:", - "Change fill to:", - "Chinese: Simplified", - "Chinese: Traditional", - "Don't Save", - "Email Address:", - "Email:", - "Find fill:", - "H:", - "Message:", - "Organization:", - "Phone:", - "W:", - "Subject:", - "Text:", - "Unsaved Changes", - "“{}” has changes that are not saved. Save them before closing?", - "% of all commits", - "{count} conflict(s): {names}", - "{count} match(es)", - "{count} replacement(s) made", - "{contributors} contributors · {commits} commits", - "{label}: {name} (double-click: Color Picker)", - "All", - "Align To", - "Arrangement", - "Auto Rename", - "Character Style", - "Company", - "DesignCraft page", - "Equal fixed heights for the selected rows; cell text may become overset.", - "Found", - "Grab bag", - "Key Object", - "Load Styles", - "layer", - "layers", - "Lines +/−", - "New Cell Style", - "New Table Style", - "No contributor data was built into this copy.", - "No matches", - "No model credits were built into this copy.", - "Object Style", - "Paragraph Style", - "PDF/X-1a:2003", - "Press keys…", - "Range:", - "Redefine Document Style", - "Report an issue", - "Reverse the order", - "Sort Entries in Alphabetical Order", - "Story {story} ← story {parent}", - "Story Editor — {count} words", - "Text Anchor", - "Transparency Flattener", - "Use Document Style Definition", - // Status-bar messages. - "Alternate layout", - "Couldn't write recovery data: {error}", - "Export a PDF, IDML or package to share — no cloud account needed.", - "Import cancelled: the originating document has closed", - "Invalid panel layout; restored the default dock", - "PDF import cancelled: the originating document has closed", - "Packaged {count} files into {dir}", - "Relinked {count}", - "Sent {count} page(s) to {printer}", - "the default printer", - "{name}: import cancelled while a dialog is open; choose the file again", - "{name}: {count} step(s) done", - ]); - // The Keyboard Shortcuts dialog's strings (craft-keymap) reach `tr` too. - keys.extend(crate::shortcuts_dialog::strings()); - keys - } - - #[test] - fn czech_counts_use_the_plural_form_for_the_number() { - for (noun, [one, few, other]) in cs::PLURALS { - for (n, form) in [(0, other), (1, one), (2, few), (4, few), (5, other), (11, other), (22, other)] { - let expected = if form.trim().is_empty() { format!("{n} {}", tr("cs", noun)) } else { format!("{n} {form}") }; - assert_eq!(count_label("cs", noun, n), expected, "{noun}"); - } - } - // A noun without plural forms keeps the plain translation. - assert_eq!(count_label("cs", "a new noun", 3), "3 a new noun"); - } -} +mod tests; /// `name` lowercased with the Latin accents that occur in language names folded away, so a /// byte-wise sort reads alphabetically. Other scripts are left as they are. diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs new file mode 100644 index 000000000..db13138cf --- /dev/null +++ b/crates/ui-egui/src/i18n/tests.rs @@ -0,0 +1,357 @@ +use super::*; +use std::collections::HashSet; + +/// Every per-language table `tr` looks up, by interface language. +const LANGUAGE_TABLES: &[(&str, &[(&str, &str)])] = &[ + ("ar", ar::TABLE), + ("cs", cs::TABLE), + ("de", de::TABLE), + ("fr", fr::TABLE), + ("it", it::TABLE), + ("ja", ja::TABLE), + ("ka", ka::TABLE), + ("pl", pl::TABLE), + ("pt-br", pt_br::TABLE), + ("ru", ru::TABLE), + ("uk", uk::TABLE), +]; + +/// Every per-language table `tr_context` looks up. +const CONTEXT_TABLES: &[(&str, &[(&str, &str, &str)])] = + &[("ar", ar::CONTEXT), ("cs", cs::CONTEXT), ("ja", ja::CONTEXT), ("pl", pl::CONTEXT), ("ru", ru::CONTEXT), ("uk", uk::CONTEXT)]; + +/// The languages of the shared tables' columns, in column order. +const SHARED_COLUMNS: [&str; 5] = ["de", "fr", "es", "ja", "zh"]; + +/// The German and Japanese tables translate the whole interface and win over the shared columns. +const WHOLE_INTERFACE: [&str; 2] = ["de", "ja"]; + +/// What `tr` shows for a row: an empty one is still untranslated. +fn or_english<'a>(text: &'a str, english: &'a str) -> &'a str { + if text.trim().is_empty() { english } else { text } +} + +#[test] +fn cached_lookup_preserves_every_translation_across_language_switches() { + let own = |lang: &str, key: &str| { + LANGUAGE_TABLES.iter().find(|(code, _)| *code == lang).and_then(|(_, table)| table.iter().find(|(k, _)| *k == key)).map(|(_, text)| *text) + }; + for (key, translations) in TABLE { + for (lang, shared) in SHARED_COLUMNS.into_iter().zip(translations) { + // A language's own table is looked up before the shared column. + let expected = own(lang, key).filter(|text| !text.trim().is_empty()).unwrap_or(*shared); + assert_eq!(tr(lang, key), or_english(expected, key), "{lang}: {key}"); + } + assert_eq!(tr("", key), *key); + } + for (lang, table) in LANGUAGE_TABLES { + for (key, expected) in *table { + if !expected.trim().is_empty() { + assert_eq!(tr(lang, key), *expected, "{lang}: {key}"); + } + assert_eq!(tr("", key), *key); + } + } + // Strings no catalog has stay as they are, in every language. + let unknown = String::from("A user-defined untranslated label"); + for lang in LANGUAGES.iter().map(|(code, _)| *code).chain(["en", "unknown"]) { + assert!(std::ptr::eq(tr(lang, &unknown), unknown.as_str()), "{lang}"); + // Fixed standard names stay identical. + assert_eq!(tr(lang, "PDF/X-4"), "PDF/X-4", "{lang}"); + } +} + +/// The Data Merge arrangement combo takes its accessible name from `tr(lang, "Arrangement")`. +/// A row in only the shared table leaves the languages whose lookup returns directly with the +/// English name. +#[test] +fn arrangement_accessible_name_is_localized_in_every_interface_language() { + for (code, name) in LANGUAGES { + let label = tr(code, "Arrangement"); + if code.is_empty() { + assert_eq!(label, "Arrangement", "{name}"); + } else { + assert_ne!(label, "Arrangement", "{name} still announces the English accessible name"); + assert!(!label.trim().is_empty(), "{name}"); + } + } +} + +#[test] +fn context_captions_come_from_their_context_or_the_plain_translation() { + for (lang, table) in CONTEXT_TABLES { + for (key, scope, text) in *table { + if !text.trim().is_empty() { + assert_eq!(tr_context(lang, key, scope), *text, "{lang}: {key} ({scope})"); + } + } + } + for (key, scope, row) in CONTEXT { + for (lang, shared) in SHARED_COLUMNS.into_iter().zip(row) { + // A language's own context table is looked up before the shared one. + let own = CONTEXT_TABLES + .iter() + .filter(|(code, _)| *code == lang) + .flat_map(|(_, table)| table.iter()) + .find(|(k, s, _)| k == key && s == scope) + .map(|(_, _, text)| *text) + .filter(|text| !text.trim().is_empty()); + let expected = own.or(Some(*shared).filter(|text| !text.trim().is_empty())).unwrap_or_else(|| tr(lang, key)); + assert_eq!(tr_context(lang, key, scope), expected, "{lang}: {key} ({scope})"); + } + } + // An unknown context, or a language without one, keeps the plain translation. + let keys = CONTEXT.iter().map(|(key, _, _)| *key).chain(CONTEXT_TABLES.iter().flat_map(|(_, table)| table.iter().map(|(key, _, _)| *key))); + for key in keys { + for lang in LANGUAGES.iter().map(|(code, _)| *code).chain(["en"]) { + assert_eq!(tr_context(lang, key, "no such context"), tr(lang, key), "{lang}: {key}"); + } + assert_eq!(tr_context("", key, "count"), key); + } +} + +/// The tables `tr` reads, as (language, key, translation), context captions included. +fn entries() -> Vec<(&'static str, &'static str, &'static str)> { + let mut entries: Vec<_> = LANGUAGE_TABLES.iter().flat_map(|(lang, table)| table.iter().map(|(key, text)| (*lang, *key, *text))).collect(); + for (lang, i) in SHARED_COLUMNS.into_iter().zip(0..) { + entries.extend(TABLE.iter().filter_map(|(key, row)| row.get(i).map(|text| (lang, *key, *text)))); + entries.extend(CONTEXT.iter().filter_map(|(key, _, row)| row.get(i).map(|text| (lang, *key, *text)))); + } + entries.extend(CONTEXT_TABLES.iter().flat_map(|(lang, table)| table.iter().map(|(key, _, text)| (*lang, *key, *text)))); + entries +} + +/// Every catalog has one row per key and keeps the English placeholders. A string a catalog +/// doesn't translate shows in English, so a missing or empty row fails nothing here; the +/// `missing_translations_report` lists them. +#[test] +fn every_catalog_has_unique_keys_and_keeps_placeholders() { + for (lang, i) in SHARED_COLUMNS.iter().zip(0..) { + assert_eq!(column(lang), Some(i), "{lang}"); + } + for (lang, table) in LANGUAGE_TABLES { + let keys: HashSet<_> = table.iter().map(|(key, _)| *key).collect(); + assert_eq!(keys.len(), table.len(), "{lang}: duplicate keys"); + } + let shared: HashSet<_> = TABLE.iter().map(|(key, _)| *key).collect(); + assert_eq!(shared.len(), TABLE.len(), "duplicate keys in the shared table"); + let shared_contexts: HashSet<_> = CONTEXT.iter().map(|(key, scope, _)| (*key, *scope)).collect(); + assert_eq!(shared_contexts.len(), CONTEXT.len(), "duplicate keys in the shared context table"); + for (lang, table) in CONTEXT_TABLES { + let keys: HashSet<_> = table.iter().map(|(key, scope, _)| (*key, *scope)).collect(); + assert_eq!(keys.len(), table.len(), "{lang}: duplicate context keys"); + } + // A per-language table is looked up before the shared table, so a key in both hides the + // shared column, except in the tables that translate the whole interface. + for (lang, table) in LANGUAGE_TABLES.iter().filter(|(lang, _)| column(lang).is_some() && !WHOLE_INTERFACE.contains(lang)) { + let both: Vec<_> = table.iter().map(|(key, _)| *key).filter(|key| shared.contains(key)).collect(); + assert!(both.is_empty(), "{lang} keys also in the shared table: {both:?}"); + } + + let placeholders = |s: &'static str| { + let mut names: Vec<_> = s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name)).collect(); + names.sort_unstable(); + names + }; + for (lang, key, text) in entries().into_iter().filter(|(_, _, text)| !text.trim().is_empty()) { + assert_eq!(placeholders(key), placeholders(text), "{lang}: placeholders of {key:?}"); + } +} + +/// The strings each interface language shows in English, for the `cargo xtask ci` log. It +/// always passes: `cargo xtask i18n-report` runs it. +#[test] +#[ignore = "a report, run by `cargo xtask i18n-report`"] +fn missing_translations_report() { + let interface = interface_strings(); + let catalogs: Vec<(&str, HashSet<&str>)> = LANGUAGE_TABLES + .iter() + .map(|(lang, table)| (*lang, table.iter().map(|(key, _)| *key).collect())) + .chain([("shared", TABLE.iter().map(|(key, _)| *key).collect())]) + .collect(); + let mut keys: Vec<&str> = interface.iter().copied().chain(catalogs.iter().flat_map(|(_, keys)| keys.iter().copied())).collect(); + keys.sort_unstable(); + keys.dedup(); + // `tr` hands back the string it was given when it has no translation. + let missing = |lang: &str| -> Vec<&str> { + keys.iter() + .copied() + .filter(|key| { + let english = (*key).to_owned(); + std::ptr::eq(tr(lang, &english), english.as_str()) + }) + .collect() + }; + let report: Vec<(&str, Vec<&str>)> = LANGUAGES.iter().filter(|(code, _)| !code.is_empty()).map(|(code, _)| (*code, missing(code))).collect(); + println!("i18n report: {} interface strings; a missing translation shows in English.", keys.len()); + for (lang, missing) in &report { + println!(" {lang:<6} {:>5} missing", missing.len()); + } + for (lang, missing) in report.iter().filter(|(_, missing)| !missing.is_empty()) { + println!("{lang} ({}): {}", missing.len(), missing.iter().map(|key| format!("{key:?}")).collect::>().join(", ")); + } + // A catalog key neither the interface list nor another catalog knows is stale, or a label + // `interface_strings` doesn't list yet. + for (name, own) in &catalogs { + let mut unknown: Vec<_> = own + .iter() + .filter(|key| !interface.contains(*key) && !catalogs.iter().any(|(other, keys)| other != name && keys.contains(*key))) + .collect(); + unknown.sort_unstable(); + if !unknown.is_empty() { + println!("{name}: {} rows for keys the interface list doesn't have: {unknown:?}", unknown.len()); + } + } +} + +#[test] +fn only_arabic_lays_out_right_to_left() { + for (code, _) in LANGUAGES { + assert_eq!(is_rtl(code), *code == "ar", "{code}"); + } +} + +/// Built-in style and workspace names follow the interface language; names people give their +/// own styles and workspaces are document data and stay as typed. +#[test] +fn only_built_in_style_and_workspace_names_are_translated() { + for (code, _) in LANGUAGES { + for name in [designcraft_doc::NO_PARA_STYLE, designcraft_doc::BASIC_PARAGRAPH, designcraft_doc::NO_CHAR_STYLE] { + assert_eq!(style_name(code, name), tr(code, name), "{code}"); + } + assert_eq!(workspace_name(code, "Essentials"), tr(code, "Essentials"), "{code}"); + for custom in ["Group", "Columns", "My layout style"] { + assert_eq!(style_name(code, custom), custom, "{code}"); + assert_eq!(workspace_name(code, custom), custom, "{code}"); + } + } +} + +/// Languages with several plural forms put the category first, so every number reads the same. +#[test] +fn category_first_counts_put_the_category_before_every_number() { + assert!(!category_first("de") && !category_first("")); + for lang in ["ka", "uk", "ru", "pl"] { + assert!(category_first(lang), "{lang}"); + for noun in ["pages", "objects", "errors"] { + for n in [0, 1, 2, 4, 5, 11, 12, 21, 22, 25, 101, 111] { + assert_eq!(count_label(lang, noun, n), format!("{}: {n}", tr(lang, noun)), "{lang}: {noun}"); + } + } + } + assert_eq!(count_label("", "objects", 2), "2 objects"); +} + +/// Every English string the interface passes to `tr`, as far as the command and tool tables and +/// the list of UI-only labels know them. +fn interface_strings() -> HashSet<&'static str> { + let mut keys = HashSet::new(); + // Command labels and menu names reach `tr` from the engine and the UI command table. + for spec in designcraft_engine::command_specs() { + keys.insert(spec.label); + keys.extend(spec.menu.iter().copied()); + } + keys.extend(crate::menus::UI_COMMANDS.iter().map(|(_, label, _, _)| *label)); + // Tool names reach `tr` from the Tools panel. + keys.extend(designcraft_tools::TOOL_GROUPS.iter().flat_map(|group| group.iter()).map(|tool| tool.label)); + // So do the About window's tab names, app descriptions and the credits' column and sort labels. + keys.extend(crate::about::ABOUT_TABS); + keys.extend(crate::about::SIBLINGS.iter().map(|(_, _, what)| *what)); + keys.extend(crate::credits::NameMode::ALL.iter().map(|mode| mode.label())); + keys.extend(crate::credits::SortKey::ALL.iter().flat_map(|key| [key.label().0, key.label().1])); + // Docking chrome labels reach `tr` through craft-ui's localizer. + keys.extend(crate::panel_docking::DOCK_LABELS.iter().map(|(_, english)| *english)); + // UI labels passed to `tr` that no other catalog has yet. + keys.extend([ + "(any)", + "(unchanged)", + "[No Language]", + "Cell Number:", + "Differs from this document", + "Digits:", + "Change fill to:", + "Chinese: Simplified", + "Chinese: Traditional", + "Don't Save", + "Email Address:", + "Email:", + "Find fill:", + "H:", + "Message:", + "Organization:", + "Phone:", + "W:", + "Subject:", + "Text:", + "Unsaved Changes", + "“{}” has changes that are not saved. Save them before closing?", + "% of all commits", + "{count} conflict(s): {names}", + "{count} match(es)", + "{count} replacement(s) made", + "{contributors} contributors · {commits} commits", + "{label}: {name} (double-click: Color Picker)", + "All", + "Align To", + "Arrangement", + "Auto Rename", + "Character Style", + "Company", + "DesignCraft page", + "Equal fixed heights for the selected rows; cell text may become overset.", + "Found", + "Grab bag", + "Key Object", + "Load Styles", + "layer", + "layers", + "Lines +/−", + "New Cell Style", + "New Table Style", + "No contributor data was built into this copy.", + "No matches", + "No model credits were built into this copy.", + "Object Style", + "Paragraph Style", + "PDF/X-1a:2003", + "Press keys…", + "Range:", + "Redefine Document Style", + "Report an issue", + "Reverse the order", + "Sort Entries in Alphabetical Order", + "Story {story} ← story {parent}", + "Story Editor — {count} words", + "Text Anchor", + "Transparency Flattener", + "Use Document Style Definition", + // Status-bar messages. + "Alternate layout", + "Couldn't write recovery data: {error}", + "Export a PDF, IDML or package to share — no cloud account needed.", + "Import cancelled: the originating document has closed", + "Invalid panel layout; restored the default dock", + "PDF import cancelled: the originating document has closed", + "Packaged {count} files into {dir}", + "Relinked {count}", + "Sent {count} page(s) to {printer}", + "the default printer", + "{name}: import cancelled while a dialog is open; choose the file again", + "{name}: {count} step(s) done", + ]); + // The Keyboard Shortcuts dialog's strings (craft-keymap) reach `tr` too. + keys.extend(crate::shortcuts_dialog::strings()); + keys +} + +#[test] +fn czech_counts_use_the_plural_form_for_the_number() { + for (noun, [one, few, other]) in cs::PLURALS { + for (n, form) in [(0, other), (1, one), (2, few), (4, few), (5, other), (11, other), (22, other)] { + let expected = if form.trim().is_empty() { format!("{n} {}", tr("cs", noun)) } else { format!("{n} {form}") }; + assert_eq!(count_label("cs", noun, n), expected, "{noun}"); + } + } + // A noun without plural forms keeps the plain translation. + assert_eq!(count_label("cs", "a new noun", 3), "3 a new noun"); +}