pub use rust_i18n::t; /// Available UI locale codes. Separate from dictionary language support. pub const SUPPORTED_UI_LOCALES: &[&str] = &[ "en", "de", "es", "fr", "it", "pt", "nl", "sv", "da", "nb", "fi", "pl", "cs", "ro", "hr", "hu", "lt", "lv", "sl", "et", "tr", ]; pub fn set_ui_locale(locale: &str) { let effective = if SUPPORTED_UI_LOCALES.contains(&locale) { locale } else { "en" }; rust_i18n::set_locale(effective); } /// Retrieve the set of all translation keys for a given locale. /// Used by the catalog parity test to verify every key exists in every locale. #[cfg(test)] fn collect_yaml_keys(value: &serde_yaml::Value, prefix: &str, keys: &mut std::collections::BTreeSet) { match value { serde_yaml::Value::Mapping(map) => { for (k, v) in map { let key_str = k.as_str().unwrap_or(""); let full = if prefix.is_empty() { key_str.to_string() } else { format!("{prefix}.{key_str}") }; collect_yaml_keys(v, &full, keys); } } _ => { keys.insert(prefix.to_string()); } } } /// Translate a LanguageLayoutValidationError for display in the UI. pub fn localized_language_layout_error( err: &crate::l10n::language_pack::LanguageLayoutValidationError, ) -> String { use crate::l10n::language_pack::LanguageLayoutValidationError::*; match err { UnknownLanguage(key) => t!("errors.unknown_language", key = key).to_string(), UnknownLayout(key) => t!("errors.unknown_layout", key = key).to_string(), UnsupportedLanguageLayoutPair { language_key, layout_key, } => t!( "errors.unsupported_pair", language = language_key, layout = layout_key ) .to_string(), LanguageBlockedBySupportLevel(key) => { t!("errors.language_blocked", key = key).to_string() } } } #[cfg(test)] mod tests { use super::*; use std::collections::BTreeSet; fn locale_keys(locale: &str) -> BTreeSet { let path = format!("locales/{locale}.yml"); let content = std::fs::read_to_string(&path) .unwrap_or_else(|e| panic!("Failed to read {path}: {e}")); let root: serde_yaml::Value = serde_yaml::from_str(&content) .unwrap_or_else(|e| panic!("Failed to parse {path}: {e}")); let mut keys = BTreeSet::new(); collect_yaml_keys(&root, "", &mut keys); keys } #[test] fn catalog_parity_all_locales() { let en = locale_keys("en"); let mut errors = Vec::new(); for locale in SUPPORTED_UI_LOCALES { if *locale == "en" { continue; } let other = locale_keys(locale); let missing: Vec<_> = en.difference(&other).collect(); let extra: Vec<_> = other.difference(&en).collect(); if !missing.is_empty() { errors.push(format!( "Keys in en.yml missing from {locale}.yml:\n {}", missing .iter() .map(|k| k.as_str()) .collect::>() .join("\n ") )); } if !extra.is_empty() { errors.push(format!( "Keys in {locale}.yml not present in en.yml:\n {}", extra .iter() .map(|k| k.as_str()) .collect::>() .join("\n ") )); } } assert!(errors.is_empty(), "Catalog parity errors:\n{}", errors.join("\n")); } #[test] fn placeholder_parity_all_locales() { let en_content = std::fs::read_to_string("locales/en.yml").unwrap(); let en_root: serde_yaml::Value = serde_yaml::from_str(&en_content).unwrap(); let mut en_map = std::collections::BTreeMap::new(); collect_leaf_values(&en_root, "", &mut en_map); let placeholder_re = regex::Regex::new(r"%\{(\w+)\}").unwrap(); let mut all_mismatches = Vec::new(); for locale in SUPPORTED_UI_LOCALES { if *locale == "en" { continue; } let other_content = std::fs::read_to_string(format!("locales/{locale}.yml")).unwrap(); let other_root: serde_yaml::Value = serde_yaml::from_str(&other_content).unwrap(); let mut other_map = std::collections::BTreeMap::new(); collect_leaf_values(&other_root, "", &mut other_map); for (key, en_val) in &en_map { if let Some(other_val) = other_map.get(key) { let en_placeholders: BTreeSet<_> = placeholder_re .captures_iter(en_val) .map(|c| c[1].to_string()) .collect(); let other_placeholders: BTreeSet<_> = placeholder_re .captures_iter(other_val) .map(|c| c[1].to_string()) .collect(); if en_placeholders != other_placeholders { all_mismatches.push(format!( " {locale}/{key}: en={en_placeholders:?} {locale}={other_placeholders:?}" )); } } } } assert!( all_mismatches.is_empty(), "Placeholder mismatches:\n{}", all_mismatches.join("\n") ); } fn collect_leaf_values( value: &serde_yaml::Value, prefix: &str, map: &mut std::collections::BTreeMap, ) { match value { serde_yaml::Value::Mapping(m) => { for (k, v) in m { let key_str = k.as_str().unwrap_or(""); let full = if prefix.is_empty() { key_str.to_string() } else { format!("{prefix}.{key_str}") }; collect_leaf_values(v, &full, map); } } serde_yaml::Value::String(s) => { map.insert(prefix.to_string(), s.clone()); } _ => {} } } #[test] fn set_locale_english_produces_english() { set_ui_locale("en"); let text = t!("menu.subtitle").to_string(); assert_eq!(text, "Terminal Typing Tutor"); } #[test] fn set_locale_german_produces_german() { // Use the explicit locale parameter to avoid race conditions with // parallel tests that share the global locale state. let text = t!("menu.subtitle", locale = "de").to_string(); assert_eq!(text, "Terminal-Tipptrainer"); } #[test] fn unsupported_locale_falls_back_to_english() { set_ui_locale("zz"); // After setting unsupported locale, the effective locale is "en" let text = t!("menu.subtitle", locale = "en").to_string(); assert_eq!(text, "Terminal Typing Tutor"); } #[test] fn branch_name_translated_de() { let text = t!("skill_tree.branch_primary_letters", locale = "de").to_string(); assert_eq!(text, "Grundbuchstaben"); } #[test] fn level_name_translated_de() { let text = t!("skill_tree.level_frequency_order", locale = "de").to_string(); assert_eq!(text, "Haeufigkeitsfolge"); } #[test] fn passage_all_translated_de() { let text = t!("select.passage_all", locale = "de").to_string(); assert_eq!(text, "Alle (Eingebaut + alle Buecher)"); } #[test] fn progress_overall_translated_de() { let text = t!("progress.overall_key_progress", locale = "de").to_string(); assert_eq!(text, "Gesamter Tastenfortschritt"); } }