Files
keydr/src/i18n.rs
Tyler Hallada 84f4aabdff Add full set of locale translations
Generated using Claude Code / Codex so there may be errors
2026-03-17 05:05:32 +00:00

233 lines
7.9 KiB
Rust

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<String>) {
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<String> {
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::<Vec<_>>()
.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::<Vec<_>>()
.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<String, String>,
) {
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");
}
}