diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs index 4ac40bcac..7f8bd6006 100644 --- a/apps/lightcraft/src/native_menu.rs +++ b/apps/lightcraft/src/native_menu.rs @@ -456,10 +456,6 @@ mod tests { set_language(*language); assert!(seen.insert(structure_of(&bar)), "{language:?} shares another language's menu structure key"); } - set_language(Locale::ZhHant); - assert_eq!(label_text("File"), "檔案"); - assert_eq!(label_text("Settings…"), "設定…"); - assert_eq!(label_text("Quit LightCraft"), "結束 LightCraft"); set_language(Locale::En); } diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index 0d0f9a3a4..9d8300e8e 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -7,9 +7,9 @@ //! `{:+}`… keep their meaning). The English source is the fallback arm. Adding a language means //! adding its file (and its `language_table!` entry); this script picks the file up by name. //! -//! Every catalog must carry the same messages: a `tr_format!` call whose message is in no catalog -//! does not compile, so a message missing from one language would otherwise go unnoticed until it -//! shows English. +//! A message a language lacks (or translates as an empty string) shows the English source: that +//! language has no arm for it, and a message no catalog has formats the English source through the +//! macro's last arm. `i18n::tests::translation_gaps_report` lists the gaps per language. //! //! It also bakes the contributor credits into the binary as static Rust tables (`$OUT_DIR/credits.rs`, //! included by `src/credits.rs`; craftrules `standards/contributors.md`): commit stats from @@ -61,25 +61,14 @@ fn generate() -> Result> { let Some(code) = name.strip_suffix("-formats.json").filter(|code| *code != "en") else { continue }; println!("cargo:rerun-if-changed=locales/{name}"); let variant = variant_for(code)?; - let messages: BTreeMap = + let mut messages: BTreeMap = serde_json::from_str(&fs::read_to_string(&path)?).map_err(|error| format!("locales/{name}: {error}"))?; + messages.retain(|_, translated| !translated.is_empty()); catalogs.push((code.to_string(), variant, messages)); } catalogs.sort_by(|a, b| a.0.cmp(&b.0)); - // Every catalog translates the same messages. let all: BTreeSet<&String> = catalogs.iter().flat_map(|(_, _, messages)| messages.keys()).collect(); - let mut problems = Vec::new(); - for (code, _, messages) in &catalogs { - for key in &all { - if !messages.contains_key(*key) { - problems.push(format!("locales/{code}-formats.json is missing {key:?}")); - } - } - } - if !problems.is_empty() { - return Err(problems.join("\n").into()); - } let mut source = String::from(concat!( "/// Translate a format string, checked by `format!` in every language.\n", @@ -98,6 +87,9 @@ fn generate() -> Result> { source.push_str(&format!(" _ => format!({en} $(, $($args)*)?),\n }}\n }};\n")); } source.push_str(concat!( + " ($english:literal $(, $($args:tt)*)?) => {\n", + " format!($english $(, $($args)*)?)\n", + " };\n", "}\n", "\n", "// `macro_rules!` is textual: the module re-export is what lets call sites say\n", diff --git a/crates/ui-egui/locales/it.json b/crates/ui-egui/locales/it.json index a638c1482..759fe53d0 100644 --- a/crates/ui-egui/locales/it.json +++ b/crates/ui-egui/locales/it.json @@ -2344,7 +2344,6 @@ "Loupe Info 1": "Info lente 1", "Loupe Info 2": "Info lente 2", "Use Defaults": "Usa predefiniti", - "Dimensions": "Dimensioni", "Camera & Lens": "Fotocamera e obiettivo", "View → View Options chooses the lines Info 1 and Info 2 show.": "Visualizza → Opzioni di visualizzazione sceglie le righe di Info 1 e Info 2." } diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index acd513e98..c33ba0998 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -2495,7 +2495,7 @@ mod tests { assert!(!row.cancellable, "{row:?}"); assert!(h.app.session.activity.cancel(row.id).is_err(), "Photos can't be stopped once asked"); let current = h.app.export.as_ref().map(|e| e.progress.lock().unwrap().1.clone()).unwrap_or_default(); - assert_eq!(current, "Wird zu Apple Fotos hinzugefügt…"); + assert_eq!(current, crate::i18n::Locale::De.tr("Adding to Apple Photos…")); // the row says so on the next frame (issue #345) h.step(); let row = h.app.session.activity.list().into_iter().find(|r| r.kind == "export").expect("the export's row"); @@ -4570,9 +4570,6 @@ mod tests { assert_eq!(crate::i18n::language(), locale); for (mode, key) in [("basic", "Info Overlay: File"), ("exposure", "Info Overlay: Exposure"), ("off", "Info Overlay Off")] { let expected = locale.tr(key); - if locale != Locale::En { - assert_ne!(expected, key, "this locale must translate the tested message"); - } assert_eq!(h.request("ui.key", json!({"key": "i", "cmd": true}), t)["ok"], true); assert_eq!(serde_json::to_value(h.app.ui.info_overlay).unwrap(), json!(mode)); assert_eq!(h.app.ui.toast.as_ref().expect("overlay toast").0, expected); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 158a00664..3614637a0 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -51,10 +51,13 @@ macro_rules! language_table { static CATALOGS: OnceLock>> = OnceLock::new(); static EMPTY: OnceLock> = OnceLock::new(); // A malformed catalog logs and comes back empty: a broken translation degrades to - // English text, it never takes the app down. + // English text, it never takes the app down. An empty translation is a missing one. let parse = |catalog: Option<&'static str>| match catalog { - Some(json) => match serde_json::from_str(json) { - Ok(messages) => messages, + Some(json) => match serde_json::from_str::>(json) { + Ok(mut messages) => { + messages.retain(|_, text| !text.is_empty()); + messages + } Err(error) => { log::error!("Invalid message catalog for {}: {error}", self.code()); BTreeMap::new() @@ -522,112 +525,55 @@ pub fn rules_label(rules: &lightcraft_catalog::RuleSet, catalog: &lightcraft_cat mod tests { use super::*; + /// Each language through its menu command and the control channel's locale tags, saved by its + /// code: built-in labels follow it, user values and ids don't, the window paints its text and + /// the bundled fonts have its letters. #[test] - fn czech_coverage_of_catalogs_commands_controls_and_rules_is_reported() { - let catalog = Locale::Cs.catalog(); - let keys: std::collections::BTreeSet<&String> = Locale::ALL.iter().flat_map(|language| language.catalog().keys()).collect(); - let lacking: Vec<_> = keys.into_iter().filter(|key| !catalog.contains_key(key.as_str())).collect(); - if !lacking.is_empty() { - eprintln!("cs lacks {} catalog message(s) (shown in English): {lacking:?}", lacking.len()); - } - let mut labels: Vec<&str> = lightcraft_engine::command_specs().iter().map(|spec| spec.label).collect(); - labels.extend(crate::menus::ui_commands().map(|command| command.1).filter(|label| !Locale::ALL.iter().any(|locale| locale.name() == *label))); - labels.extend(lightcraft_develop::CONTROLS.iter().map(|control| control.label)); - labels.extend(crate::panels::settings::TABS.iter().map(|(_, label)| *label)); - labels.extend(lightcraft_catalog::rules::FIELD_GROUPS.iter().map(|group| group.0)); - labels.extend(lightcraft_engine::rename::TOKENS.iter().map(|token| token.meaning)); - labels.extend(lightcraft_engine::rename::TEMPLATE_NOTES); - let missing: Vec<_> = labels.into_iter().filter(|label| !catalog.contains_key(*label)).collect(); - if !missing.is_empty() { - eprintln!("cs lacks {} display label(s) (shown in English): {missing:?}", missing.len()); - } - } - - #[test] - fn czech_switches_persists_and_formats_without_changing_user_values() { + fn languages_switch_persist_and_keep_user_values() { use crate::control::{ControlRequest, Outcome}; - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.run("app.language.czech", serde_json::json!({})).unwrap(); - assert_eq!(app.ui.language, Locale::Cs); - assert_eq!(Locale::Cs.name(), "Čeština"); - assert_eq!(Locale::Cs.script(), "Latn"); - assert_eq!(crate::menubar::checked(&app, "app.language.czech"), Some(true)); - assert_eq!(tr("File"), "Soubor"); - assert_eq!(tr("Develop"), "Vyvolat"); - assert_eq!(date_group_label("2026-09-20", false), "Neděle, 20. 9. 2026"); - assert_eq!(date_group_label("2026-09-20", true), "Neděle, 20."); - assert_eq!(date_group_label("2026-09", false), "9. 2026"); - assert_eq!(date_group_label("2026", false), "2026"); - assert_eq!(display_time("2026-09-20T16:04:05"), "Neděle, 20. 9. 2026 16:04:05"); - for n in [0, 1, 2, 4, 5, 11, 21, 22, 25] { - assert_eq!(tr_format!("Imported {} photo{}", n, if n == 1 { "" } else { "s" }), format!("Importováno fotografií: {n}")); - assert_eq!( - tr_format!("{n} {noun}", n = n, noun = tr(if n == 1 { "photo waiting" } else { "photos waiting" })), - format!("Čekající fotografie: {n}") - ); - } - let name = String::from("Color 1234.56 {title} 日本語"); - assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22), format!("Do „{name}“ přidáno fotografií: 22")); - assert_eq!(builtin_label("Warm Glow", false), "Warm Glow"); - assert_eq!(tr("photo-{title}.jpg"), "photo-{title}.jpg"); - let saved = serde_json::to_string(&app.ui).unwrap(); - assert_eq!(serde_json::from_str::(&saved).unwrap().language, Locale::Cs); - let ctx = egui::Context::default(); - for tag in ["cs", "cs-CZ", "cs_CZ.UTF-8", "cs-Latn-CZ"] { - assert_eq!(Locale::parse_tag(tag), Some(Locale::Cs)); - let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": tag})); - let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") }; - assert_eq!(reply["ok"], true); - assert_eq!(language(), Locale::Cs); - } - let text = painted_text(&ctx, &mut app, Locale::Cs); - assert!(text.contains("Soubor") && text.contains("Expozice"), "{text}"); - ctx.fonts_mut(|fonts| { - for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { - let font = egui::FontId::new(13.0, family); - for ch in "áčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽ„“".chars() { - assert!(fonts.has_glyph(&font, ch), "missing Czech glyph {ch}"); - } + let languages: [(Locale, &str, &[&str], &str); 8] = [ + (Locale::Cs, "czech", &["cs", "cs-CZ", "cs_CZ.UTF-8", "cs-Latn-CZ"], "áčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽ„“"), + (Locale::Id, "indonesian", &["id", "id-ID", "id_ID.UTF-8", "id-Latn-ID"], ""), + (Locale::Sv, "swedish", &["sv", "sv-SE", "sv_SE.UTF-8", "sv-FI"], "åäöÅÄÖé”’"), + (Locale::Uk, "ukrainian", &["uk", "uk-UA", "uk_UA.UTF-8", "uk-Cyrl-UA"], ""), + (Locale::Ka, "georgian", &["ka", "ka-GE", "ka_GE.UTF-8"], ""), + (Locale::It, "italian", &["it", "it-IT", "it_IT.UTF-8", "it-Latn-IT", "it-CH"], ""), + (Locale::Fi, "finnish", &["fi", "fi-FI", "fi_FI.UTF-8", "fi-Latn-FI"], ""), + (Locale::Vi, "vietnamese", &["vi", "vi-VN", "vi_VN", "vi_VN.UTF-8"], ""), + ]; + for (locale, command, tags, letters) in languages { + let command = format!("app.language.{command}"); + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.run(&command, serde_json::json!({})).unwrap(); + assert_eq!(app.ui.language, locale); + assert_eq!(language(), locale); + assert_eq!(crate::menubar::checked(&app, &command), Some(true)); + assert_eq!(builtin_label("Warm Glow", false), "Warm Glow"); + assert_eq!(builtin_label("Warm Glow", true), locale.tr("Warm Glow")); + assert_eq!(tr("photo-{title}.jpg"), "photo-{title}.jpg"); + assert_eq!(tr("develop.set"), "develop.set"); + let saved = serde_json::to_string(&app.ui).unwrap(); + assert!(saved.contains(&format!(r#""language":"{}""#, locale.code())), "{saved}"); + assert_eq!(serde_json::from_str::(&saved).unwrap().language, locale); + let ctx = egui::Context::default(); + for tag in tags { + assert_eq!(Locale::parse_tag(tag), Some(locale), "{tag}"); + let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": tag})); + let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") }; + assert_eq!(reply["ok"], true); + assert_eq!(language(), locale); } - }); - set_language(Locale::En); - } - - #[test] - fn indonesian_switches_persists_formats_dates_and_preserves_values() { - use crate::control::{ControlRequest, Outcome}; - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.run("app.language.indonesian", serde_json::json!({})).unwrap(); - assert_eq!(app.ui.language, Locale::Id); - assert_eq!(Locale::Id.name(), "Bahasa Indonesia"); - assert_eq!(Locale::Id.script(), "Latn"); - assert_eq!(crate::menubar::checked(&app, "app.language.indonesian"), Some(true)); - assert_eq!(tr("Exposure"), "Eksposur"); - assert_eq!(tr("My Photos"), "Foto saya"); - assert_eq!(date_group_label("2026-09-20", false), "Minggu, 20/09/2026"); - assert_eq!(date_group_label("2026-09-20", true), "Minggu, 20"); - assert_eq!(date_group_label("2026-09", false), "09/2026"); - assert_eq!(date_group_label("2026", false), "2026"); - assert_eq!(display_time("2026-09-20T16:04:05"), "Minggu, 20/09/2026 16:04:05"); - // Indonesian keeps a noun unchanged after a count ("1 foto", "22 foto"), so the English - // plural suffix is consumed and never shows. - for n in [0, 1, 2, 5, 11, 22] { - assert_eq!(tr_format!("Imported {} photo{}", n, if n == 1 { "" } else { "s" }), format!("{n} foto diimpor")); - } - let name = String::from("Color 1234.56 {title} 日本語"); - assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22), format!("22 foto ditambahkan ke “{name}”")); - assert_eq!(builtin_label("Warm Glow", false), "Warm Glow"); - assert_eq!(tr("photo-{title}.jpg"), "photo-{title}.jpg"); - let saved = serde_json::to_string(&app.ui).unwrap(); - assert!(saved.contains(r#""language":"id""#)); - assert_eq!(serde_json::from_str::(&saved).unwrap().language, Locale::Id); - let ctx = egui::Context::default(); - for tag in ["id", "id-ID", "id_ID.UTF-8", "id-Latn-ID"] { - assert_eq!(Locale::parse_tag(tag), Some(Locale::Id)); - let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": tag})); - let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") }; - assert_eq!(reply["ok"], true); - assert_eq!(language(), Locale::Id); + let text = painted_text(&ctx, &mut app, locale); + // (not the menu titles: a language whose titles don't fit shows one Menu button) + assert!(text.contains(locale.tr("Exposure")), "{locale:?}: {text}"); + ctx.fonts_mut(|fonts| { + for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { + let font = egui::FontId::new(13.0, family); + for ch in letters.chars() { + assert!(fonts.has_glyph(&font, ch), "{locale:?}: missing glyph {ch}"); + } + } + }); } set_language(Locale::En); } @@ -642,68 +588,64 @@ mod tests { } #[test] - fn swedish_switches_persists_and_formats_without_changing_user_values() { - use crate::control::{ControlRequest, Outcome}; - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.run("app.language.swedish", serde_json::json!({})).unwrap(); - assert_eq!(app.ui.language, Locale::Sv); - assert_eq!(Locale::Sv.name(), "Svenska"); - assert_eq!(Locale::Sv.script(), "Latn"); - assert_eq!(crate::menubar::checked(&app, "app.language.swedish"), Some(true)); - assert_eq!(tr("File"), "Fil"); - assert_eq!(tr("Develop"), "Framkalla"); - assert_eq!(tr("Exposure"), "Exponering"); - assert_eq!(date_group_label("2026", false), "2026"); - assert!(date_group_label("2026-09-20", false).starts_with("Söndag 20 "), "{}", date_group_label("2026-09-20", false)); - assert_eq!(builtin_label("Warm Glow", false), "Warm Glow"); - assert_eq!(tr("photo-{title}.jpg"), "photo-{title}.jpg"); - let saved = serde_json::to_string(&app.ui).unwrap(); - assert!(saved.contains("\"sv\""), "{saved}"); - assert_eq!(serde_json::from_str::(&saved).unwrap().language, Locale::Sv); - let ctx = egui::Context::default(); - for tag in ["sv", "sv-SE", "sv_SE.UTF-8", "sv-FI"] { - assert_eq!(Locale::parse_tag(tag), Some(Locale::Sv), "{tag}"); - let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": tag})); - let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") }; - assert_eq!(reply["ok"], true); - assert_eq!(language(), Locale::Sv); - } - let text = painted_text(&ctx, &mut app, Locale::Sv); - assert!(text.contains("Fil") && text.contains("Exponering"), "{text}"); - ctx.fonts_mut(|fonts| { - for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { - let font = egui::FontId::new(13.0, family); - for ch in "åäöÅÄÖé”’".chars() { - assert!(fonts.has_glyph(&font, ch), "missing Swedish glyph {ch}"); - } - } - }); - set_language(Locale::En); - } - - #[test] - fn every_language_has_a_catalog_covering_the_source() { + fn every_language_has_a_name_a_script_and_its_own_code() { assert!(crate::i18n::Locale::En.catalog().is_empty(), "the source language needs no catalog"); for language in Locale::ALL { assert!(!language.name().is_empty(), "{language:?} has a name"); assert!(!language.script().is_empty(), "{language:?} has a script"); assert_eq!(Locale::parse(language.code()), Some(*language), "{language:?} parses its own code"); - if *language == Locale::En { - continue; - } - let messages = language.catalog(); - assert!(!messages.is_empty(), "{language:?}: {} has no messages", language.code()); - for (key, value) in messages { - assert!(!value.is_empty(), "{language:?}: empty translation for {key:?}"); - } } } - /// Where two catalogs translate the same text they agree on its placeholders. Catalogs may cover - /// different sets of messages (a message a language lacks shows in English): the gaps are - /// reported, not failed, so a translation can grow at its own pace. + /// Every catalog file is a JSON object of strings, each key once: a malformed file would load as + /// no catalog, and a repeated key silently drops one of its translations. #[test] - fn catalogs_agree_on_placeholders_and_report_gaps() { + fn catalogs_load() { + let files = catalog_files(); + assert_eq!(files.len(), 2 * (Locale::ALL.len() - 1), "a plain and a formats catalog per translation"); + for path in files { + let repeated = repeated_keys(&path); + assert_eq!(repeated, Ok(Vec::new()), "{}", path.display()); + } + } + + /// `locales/*.json`, sorted. + fn catalog_files() -> Vec { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("locales"); + let mut files: Vec = std::fs::read_dir(dir) + .unwrap() + .map(|entry| entry.unwrap().path()) + .filter(|path| path.extension().is_some_and(|ext| ext == "json")) + .collect(); + files.sort(); + files + } + + /// The keys a catalog file names more than once, or why it isn't an object of strings. + fn repeated_keys(path: &std::path::Path) -> Result, String> { + struct Repeated; + impl<'de> serde::de::Visitor<'de> for Repeated { + type Value = Vec; + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("an object of strings") + } + fn visit_map>(self, mut map: A) -> Result, A::Error> { + let (mut seen, mut repeated) = (std::collections::BTreeSet::new(), Vec::new()); + while let Some((key, _)) = map.next_entry::()? { + if !seen.insert(key.clone()) { + repeated.push(key); + } + } + Ok(repeated) + } + } + let text = std::fs::read_to_string(path).map_err(|error| error.to_string())?; + serde::Deserializer::deserialize_map(&mut serde_json::Deserializer::from_str(&text), Repeated).map_err(|error| error.to_string()) + } + + /// A translation carries the placeholders of the message it translates. + #[test] + fn catalogs_agree_on_placeholders() { let fields = |text: &str| { let mut out: Vec = Vec::new(); let mut rest = text; @@ -715,17 +657,10 @@ mod tests { out.sort(); out }; - let translated: Vec = Locale::ALL.iter().copied().filter(|language| *language != Locale::En).collect(); - let all: std::collections::BTreeSet<&String> = translated.iter().flat_map(|language| language.catalog().keys()).collect(); - for language in &translated { - let catalog = language.catalog(); - for (key, value) in catalog { + for language in Locale::ALL { + for (key, value) in language.catalog() { assert_eq!(fields(key), fields(value), "{language:?}: {key:?} -> {value:?}"); } - let missing: Vec<&&String> = all.iter().filter(|key| !catalog.contains_key(key.as_str())).collect(); - if !missing.is_empty() { - eprintln!("{} lacks {} message(s) another language translates (shown in English): {missing:?}", language.code(), missing.len()); - } } } @@ -793,18 +728,16 @@ mod tests { #[test] fn switching_language_translates_and_restores() { - set_language(Locale::ZhHans); - assert_eq!(tr("Exposure"), "曝光"); - assert_eq!(tr("Settings"), "设置"); - // Untranslated and data-like text passes through untouched. - assert_eq!(tr("my-photo.jpg"), "my-photo.jpg"); - assert_eq!(tr("develop.set"), "develop.set"); - assert_eq!(tr("A string no catalog has"), "A string no catalog has"); - set_language(Locale::Ja); - assert_eq!(tr("Exposure"), "露出"); - set_language(Locale::PtBr); - assert_eq!(tr("Exposure"), "Exposição"); - assert_eq!(tr("Settings"), "Configurações"); + for language in Locale::ALL { + set_language(*language); + for source in ["Exposure", "Settings"] { + assert_eq!(tr(source), language.catalog().get(source).map_or(source, String::as_str), "{language:?}"); + } + // Untranslated and data-like text passes through untouched. + assert_eq!(tr("my-photo.jpg"), "my-photo.jpg"); + assert_eq!(tr("develop.set"), "develop.set"); + assert_eq!(tr("A string no catalog has"), "A string no catalog has"); + } set_language(Locale::En); assert_eq!(tr("Exposure"), "Exposure"); } @@ -817,16 +750,10 @@ mod tests { set_language(Locale::En); assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "Imported 12 photos"); assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "Exported 4 of 12 photos"); - set_language(Locale::ZhHans); - assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "已导入 12 张照片"); - assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "已导出 12 张中的 4 张照片"); - set_language(Locale::Ja); - assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "12枚を読み込みました"); - assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "12枚中4枚を書き出しました"); - set_language(Locale::Fr); - assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "12 photos importée(s)"); - assert_eq!(tr_format!("Imported {} photo{}", 1, ""), "1 photo importée(s)"); - // Format specs survive translation: precision, sign, and values a language reorders. + // A message no catalog has formats its English source. + assert_eq!(tr_format!("{} of {} (no catalog has this)", 1, 2), "1 of 2 (no catalog has this)"); + // Format specs survive translation: precision, sign, and values a language reorders; user + // text (an album name with braces and CJK) and counts are never dropped. for language in Locale::ALL { set_language(*language); let text = tr_format!("{count} smart preview{} · {:.1} MB", "s", 12.345, count = 3); @@ -838,6 +765,17 @@ mod tests { let reading = tr_format!("Reading photos… {} of {}", 3, 10); let (three, ten) = (reading.find('3'), reading.find("10")); assert!(three.is_some() && ten.is_some(), "{language:?}: {reading}"); + let name = "Color 1234.56 {title} 日本語"; + let added = tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22); + assert!(added.contains(name) && added.contains("22"), "{language:?}: {added}"); + for n in [0, 1, 2, 5, 22] { + let imported = tr_format!("Imported {} photo{}", n, if n == 1 { "" } else { "s" }); + assert!(imported.contains(&n.to_string()), "{language:?}: {imported}"); + } + // The trailing argument is queue progress, not an English plural suffix. + let queued = tr_format!(" · {queued} waiting", queued = 5); + let denoising = tr_format!("Denoising {name}: {done} of {total} tiles{}", queued, name = "IMG.CR3", done = 1, total = 4); + assert!(denoising.contains("IMG.CR3") && denoising.contains('5'), "{language:?}: {denoising}"); } set_language(Locale::En); assert_eq!(tr_format!("Reading photos… {} of {}", 3, 10), "Reading photos… 3 of 10"); @@ -973,48 +911,31 @@ mod tests { assert_eq!(restored.language, Locale::ZhHans); } - #[test] - fn catalogs_contain_core_workflows() { - for language in Locale::ALL.iter().filter(|language| **language != Locale::En) { - for key in ["Import Photos…", "Export…", "Exposure", "White Balance", "Settings", "Language"] { - let value = language.tr(key); - assert!(!value.is_empty() && value != key, "{language:?}: {key}"); - } - } - } - - /// Smart-album choice values read as words in every language (English "Public Domain", German - /// "Gemeinfrei"), never as rule ids like `publicDomain`; an id the field doesn't know shows as - /// written. Unlike other labels these must be translated: an untranslated one would be English - /// in the middle of a translated rule. + /// Smart-album choice values read as words in every language (English "Public Domain"), never as + /// rule ids like `publicDomain`; an id the field doesn't know shows as written. A yes/no value + /// reads Yes / No, or the language's contextual entry for it where it has one. #[test] fn choice_values_read_as_words_in_every_language() { - use lightcraft_catalog::rules::{FIELDS, Kind}; + use lightcraft_catalog::rules::{FIELDS, Kind, bool_label}; assert_eq!(choice_text_in(Locale::En, "copyrightStatus", "publicDomain"), "Public Domain"); - assert_eq!(choice_text_in(Locale::De, "copyrightStatus", "publicDomain"), "Gemeinfrei"); - assert_eq!(choice_text_in(Locale::De, "copyrightStatus", "someday"), "someday"); - assert_eq!(choice_text_in(Locale::ZhHans, "treatment", "color"), "彩色", "in colour, not the colour noun"); - assert_eq!(choice_text_in(Locale::Ru, "treatment", "color"), "Цветное"); - assert_eq!(choice_text_in(Locale::Es, "label", "none"), "Sin etiqueta"); - // yes/no fields read Yes / No, never true / false assert_eq!((bool_text_in(Locale::En, true), bool_text_in(Locale::En, false)), ("Yes", "No")); - assert_eq!(bool_text_in(Locale::De, false), "Nein"); - // Japanese answers はい / いいえ don't read as a rule's value: its own wording, あり / なし, - // through a context, while the shared Yes / No keep their meaning everywhere else - assert_eq!((bool_text_in(Locale::Ja, true), bool_text_in(Locale::Ja, false)), ("あり", "なし")); - assert_eq!((Locale::Ja.tr("Yes"), Locale::Ja.tr("No")), ("はい", "いいえ")); - assert_eq!(tr_ctx_in(Locale::Ja, "nope", "Yes"), "はい", "no contextual entry: the plain message"); assert_eq!(tr_ctx_in(Locale::En, BOOL_CONTEXT, "Yes"), "Yes"); - // and the yes/no fields are named by plain nouns, so "編集 … なし" doesn't read "edits: yes … none" - for (field, label, kind) in FIELDS { - if *kind != Kind::Bool { - continue; + for language in Locale::ALL { + assert_eq!(choice_text_in(*language, "copyrightStatus", "someday"), "someday"); + assert_eq!(tr_ctx_in(*language, "nope", "Yes"), language.tr("Yes"), "no contextual entry: the plain message"); + for b in [true, false] { + let contextual = language.catalog().get(&format!("{BOOL_CONTEXT}|{}", bool_label(b))); + assert_eq!(bool_text_in(*language, b), contextual.map_or(language.tr(bool_label(b)), String::as_str)); + } + for (field, _, kind) in FIELDS { + let Kind::Choice(choices) = kind else { continue }; + for (id, _) in *choices { + assert_ne!(choice_text_in(*language, field, id), *id, "{}: {field} {id}", language.code()); + } } - let ja = Locale::Ja.catalog().get(*label).unwrap_or_else(|| panic!("ja lacks the {field} label {label:?}")); - assert!(!ja.contains("あり") && !ja.contains("なし"), "{field}: {ja}"); } - // a contextual entry names a real value (a typo would silently fall back to はい / いいえ) - let values = [lightcraft_catalog::rules::bool_label(true), lightcraft_catalog::rules::bool_label(false)]; + // a contextual entry names a real value (a typo would silently fall back to the plain Yes / No) + let values = [bool_label(true), bool_label(false)]; for language in Locale::ALL { for key in language.catalog().keys() { if let Some(value) = key.strip_prefix(BOOL_CONTEXT).and_then(|k| k.strip_prefix('|')) { @@ -1022,28 +943,10 @@ mod tests { } } } - for language in Locale::ALL.iter().filter(|language| **language != Locale::En) { - for label in [lightcraft_catalog::rules::bool_label(true), lightcraft_catalog::rules::bool_label(false)] { - assert!(language.catalog().contains_key(label), "{} lacks {label:?}", language.code()); - } - } - for language in Locale::ALL { - for (field, _, kind) in FIELDS { - let Kind::Choice(choices) = kind else { continue }; - for (id, label) in *choices { - let text = choice_text_in(*language, field, id); - assert_ne!(text, *id, "{}: {field} {id}", language.code()); - if *language != Locale::En { - assert!(language.catalog().contains_key(*label), "{} lacks {label:?}", language.code()); - } - } - } - } } - /// A rule's problem reads as the field's name and the issue, in the editor's language ("Titel: - /// braucht einen Suchbegriff …"); the sidebar's list puts the rule's position first. Every issue - /// is translated in every language: an English one would stand out in a translated editor. + /// A rule's problem reads as the field's name and the issue, in the editor's language, joined by + /// a full-width colon in Japanese and Chinese; the sidebar's list puts the rule's position first. #[test] fn problems_read_in_every_language() { use lightcraft_catalog::rules::Issue; @@ -1053,151 +956,19 @@ mod tests { }; let p = problems(serde_json::json!([{"field": "rating", "op": "gte", "value": 3}, {"field": "title", "op": "contains", "value": ""}])); assert_eq!(problem_text_in(Locale::En, &p[0]), "Title: needs something to look for (or use “is empty”)"); - assert_eq!(problem_text_in(Locale::De, &p[0]), "Titel: braucht einen Suchbegriff (oder „ist leer“)"); assert_eq!(problem_line_in(Locale::En, &p[0]), "#2 Title: needs something to look for (or use “is empty”)"); - // Japanese and Chinese join with a full-width colon, as their catalogs do - assert_eq!(problem_text_in(Locale::Ja, &p[0]), "タイトル:検索する語句が必要です(または「が空」を使用)"); + for language in Locale::ALL { + let colon = if matches!(language.script(), "Jpan" | "Hans" | "Hant") { ":" } else { ": " }; + let text = format!("{}{colon}{}", language.tr("Title"), language.tr(p[0].issue.text())); + assert_eq!(problem_text_in(*language, &p[0]), text); + assert_eq!(problem_line_in(*language, &p[0]), format!("#2 {text}")); + } // a problem with the album filter itself has no rule position to show let filter_loop = lightcraft_catalog::rules::Problem { path: Vec::new(), field: Some("album".into()), issue: Issue::AlbumLoop, message: String::new() }; assert!(problem_line_in(Locale::En, &filter_loop).starts_with("Album: "), "{}", problem_line_in(Locale::En, &filter_loop)); let g = problems(serde_json::json!([{"group": {"rules": []}}])); assert_eq!(problem_text_in(Locale::En, &g[0]), "This group is empty: add a rule or remove it."); - // the issues, the operators their hints name and the album picker's words, in every language - for language in Locale::ALL.iter().filter(|l| **l != Locale::En) { - // the field menu: every field and every group, so it never mixes languages - for label in lightcraft_catalog::rules::FIELDS.iter().map(|f| f.1).chain(lightcraft_catalog::rules::FIELD_GROUPS.iter().map(|g| g.0)) { - assert!(language.catalog().contains_key(label), "{} lacks the rule label {label:?}", language.code()); - } - for word in [ - "Choose an album…", - "No albums yet", - "Search albums", - "No albums match", - "This is the album you're editing", - "It tests this album, so testing it back would loop", - "Fix the marked rules to save this album.", - ] { - assert!(language.catalog().contains_key(word), "{} lacks {word:?}", language.code()); - } - for issue in Issue::ALL { - assert!(language.catalog().contains_key(issue.text()), "{} lacks {:?}", language.code(), issue.text()); - } - for (_, _, kind) in lightcraft_catalog::rules::FIELDS { - for (_, op) in lightcraft_catalog::rules::ops_for(*kind) { - assert!(language.catalog().contains_key(*op), "{} lacks the operator {op:?}", language.code()); - } - } - } - } - - /// The date picker's own words (its tooltip, the weekday headers) are in every language, as the - /// month and day labels it borrows from the date headings are. - #[test] - fn date_picker_words_are_translated() { - for language in Locale::ALL.iter().filter(|l| **l != Locale::En) { - for word in crate::date_picker::WEEKDAY_SHORT.iter().chain(&["Pick a date", "Year", "Month", "Day"]) { - assert!(language.catalog().contains_key(*word), "{} lacks {word:?}", language.code()); - } - } - } - - /// Ukrainian's coverage of every catalog message and display label. Gaps are reported, not failed - /// (like `catalogs_agree_on_placeholders_and_report_gaps`): a feature PR that adds a label doesn't - /// have to ship its Ukrainian text, which shows in English until the translation catches up. - #[test] - fn ukrainian_coverage_of_catalogs_commands_controls_and_rules_is_reported() { - let catalog = Locale::Uk.catalog(); - let keys: std::collections::BTreeSet<&String> = Locale::ALL.iter().flat_map(|language| language.catalog().keys()).collect(); - let lacking: Vec<_> = keys.into_iter().filter(|key| !catalog.contains_key(key.as_str())).collect(); - if !lacking.is_empty() { - eprintln!("uk lacks {} catalog message(s) (shown in English): {lacking:?}", lacking.len()); - } - let mut labels: Vec<&str> = lightcraft_engine::command_specs().iter().map(|spec| spec.label).collect(); - labels.extend(crate::menus::ui_commands().map(|command| command.1).filter(|label| !Locale::ALL.iter().any(|locale| locale.name() == *label))); - labels.extend(lightcraft_develop::CONTROLS.iter().map(|control| control.label)); - labels.extend(crate::panels::settings::TABS.iter().map(|(_, label)| *label)); - for (_, label, kind) in lightcraft_catalog::rules::FIELDS { - labels.push(label); - labels.extend(lightcraft_catalog::rules::ops_for(*kind).iter().map(|(_, label)| *label)); - } - labels.extend(lightcraft_engine::rename::TOKENS.iter().map(|token| token.meaning)); - labels.extend(lightcraft_engine::rename::TEMPLATE_NOTES); - let missing: Vec<_> = labels.into_iter().filter(|label| !catalog.contains_key(*label)).collect(); - if !missing.is_empty() { - eprintln!("uk lacks {} display label(s) (shown in English): {missing:?}", missing.len()); - } - } - - #[test] - fn ukrainian_switches_persists_formats_dates_and_preserves_values() { - use crate::control::{ControlRequest, Outcome}; - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.run("app.language.ukrainian", serde_json::json!({})).unwrap(); - assert_eq!(app.ui.language, Locale::Uk); - assert_eq!(crate::menubar::checked(&app, "app.language.ukrainian"), Some(true)); - assert_eq!(tr("Light"), "Світло"); - assert_eq!(tr("Landscape"), "Пейзаж"); - assert_eq!(tr("Home"), "Домашня тека"); - assert_eq!(date_group_label("2026-09-20", false), "Неділя, 20.09.2026"); - assert_eq!(date_group_label("2026-09", false), "09.2026"); - assert_eq!(display_time("2026-09-20T16:04:05"), "Неділя, 20.09.2026 16:04:05"); - // Count-neutral labels work for every Ukrainian integer category, with no English suffix. - for n in [0, 1, 2, 5, 11, 21, 22, 25, 111] { - assert_eq!(tr_format!("Imported {} photo{}", n, if n == 1 { "" } else { "s" }), format!("Імпортовано фото: {n}")); - assert_eq!(tr_format!("{} files and {f} folder{}", n, "s", f = n), format!("Файлів: {n}, тек: {n}")); - let noun = tr(if n == 1 { "cached picture" } else { "cached pictures" }); - assert_eq!(tr_format!("{n} {noun}", n = n, noun = noun), format!("Кешовані зображення: {n}")); - } - // This trailing argument is queue progress, not an English plural suffix. - let queued = tr_format!(" · {queued} waiting", queued = 5); - assert_eq!( - tr_format!("Denoising {name}: {done} of {total} tiles{}", queued, name = "IMG.CR3", done = 1, total = 4), - "Усунення шуму IMG.CR3: ділянки — 1 із 4 · очікують: 5" - ); - let name = "Color {title} 日本語"; - assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22), format!("До «{name}» додано фото: 22")); - assert_eq!(builtin_label("Warm Glow", false), "Warm Glow"); - assert_eq!(builtin_label("Warm Glow", true), "Тепле сяйво"); - assert_eq!(tr("photo-{title}.jpg"), "photo-{title}.jpg"); - assert_eq!(tr("develop.set"), "develop.set"); - let saved = serde_json::to_string(&app.ui).unwrap(); - assert!(saved.contains(r#""language":"uk""#)); - assert_eq!(serde_json::from_str::(&saved).unwrap().language, Locale::Uk); - let ctx = egui::Context::default(); - for code in ["uk", "uk-UA", "uk_UA.UTF-8", "uk-Cyrl-UA"] { - assert_eq!(Locale::parse_tag(code), Some(Locale::Uk)); - let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": code})); - let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") }; - assert_eq!(reply["ok"], true); - assert_eq!(language(), Locale::Uk); - } - set_language(Locale::En); - } - - #[test] - fn georgian_switches_formats_counts_and_dates() { - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.run("app.language.georgian", serde_json::json!({})).unwrap(); - assert_eq!(app.ui.language, Locale::Ka); - assert_eq!(crate::menubar::checked(&app, "app.language.georgian"), Some(true)); - assert_eq!(tr("Light"), "სინათლე"); - assert_eq!(date_group_label("2026-09-20", false), "კვირა, 20.09.2026"); - assert_eq!(date_group_label("2026-09", false), "09.2026"); - for n in [0, 1, 2, 5, 21] { - assert_eq!(tr_format!("Imported {} photo{}", n, if n == 1 { "" } else { "s" }), format!("იმპორტირებულია ფოტო: {n}")); - } - assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", "Trip", n = 3), "„Trip“-ს დაემატა ფოტო: 3"); - let saved = serde_json::to_string(&app.ui).unwrap(); - assert!(saved.contains(r#""language":"ka""#)); - for code in ["ka", "ka-GE", "ka_GE.UTF-8"] { - assert_eq!(Locale::parse_tag(code), Some(Locale::Ka)); - } - // no Mtavruli: Georgian UI text is written in lowercase Mkhedruli - for text in Locale::Ka.catalog().values() { - assert!(!text.chars().any(|c| ('\u{1C90}'..='\u{1CBF}').contains(&c)), "{text}"); - } - set_language(Locale::En); } #[test] @@ -1206,13 +977,12 @@ mod tests { let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); app.ui.left_panel = true; let text = painted_text(&ctx, &mut app, Locale::Uk); - assert!(text.contains("Мої фото") && text.contains("Усі фото"), "{text}"); - for (command, title) in [("app.about", "Про LightCraft"), ("app.shortcuts", "Клавіатурні скорочення"), ("app.settings", "Налаштування")] - { + assert!(text.contains(Locale::Uk.tr("My Photos")) && text.contains(Locale::Uk.tr("All Photos")), "{text}"); + for (command, title) in [("app.about", "About LightCraft"), ("app.shortcuts", "Keyboard Shortcuts"), ("app.settings", "Settings")] { app.ui.dialog = None; app.run(command, serde_json::json!({})).unwrap(); let text = painted_text(&ctx, &mut app, Locale::Uk); - assert!(text.contains(title), "{command}: {text}"); + assert!(text.contains(Locale::Uk.tr(title)), "{command}: {text}"); } ctx.fonts_mut(|fonts| { for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { @@ -1228,94 +998,18 @@ mod tests { set_language(Locale::En); } - /// Italian's coverage of every catalog message and display label, reported like Ukrainian's. - #[test] - fn italian_coverage_of_catalogs_commands_controls_and_rules_is_reported() { - let catalog = Locale::It.catalog(); - let keys: std::collections::BTreeSet<&String> = Locale::ALL.iter().flat_map(|language| language.catalog().keys()).collect(); - let lacking: Vec<_> = keys.into_iter().filter(|key| !catalog.contains_key(key.as_str())).collect(); - if !lacking.is_empty() { - eprintln!("it lacks {} catalog message(s) (shown in English): {lacking:?}", lacking.len()); - } - let mut labels: Vec<&str> = lightcraft_engine::command_specs().iter().map(|spec| spec.label).collect(); - labels.extend(crate::menus::ui_commands().map(|command| command.1).filter(|label| !Locale::ALL.iter().any(|locale| locale.name() == *label))); - labels.extend(lightcraft_develop::CONTROLS.iter().map(|control| control.label)); - labels.extend(crate::panels::settings::TABS.iter().map(|(_, label)| *label)); - for (_, label, kind) in lightcraft_catalog::rules::FIELDS { - labels.push(label); - labels.extend(lightcraft_catalog::rules::ops_for(*kind).iter().map(|(_, label)| *label)); - } - labels.extend(lightcraft_engine::rename::TOKENS.iter().map(|token| token.meaning)); - labels.extend(lightcraft_engine::rename::TEMPLATE_NOTES); - let missing: Vec<_> = labels.into_iter().filter(|label| !catalog.contains_key(*label)).collect(); - if !missing.is_empty() { - eprintln!("it lacks {} display label(s) (shown in English): {missing:?}", missing.len()); - } - } - - #[test] - fn italian_switches_persists_formats_dates_and_preserves_values() { - use crate::control::{ControlRequest, Outcome}; - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.run("app.language.italian", serde_json::json!({})).unwrap(); - assert_eq!(app.ui.language, Locale::It); - assert_eq!(crate::menubar::checked(&app, "app.language.italian"), Some(true)); - assert_eq!(tr("Light"), "Luce"); - assert_eq!(tr("Landscape"), "Paesaggio"); - assert_eq!(tr("Home"), "Cartella personale"); - assert_eq!(date_group_label("2026-09-20", false), "Domenica 20/09/2026"); - assert_eq!(date_group_label("2026-09", false), "09/2026"); - assert_eq!(display_time("2026-09-20T16:04:05"), "Domenica 20/09/2026 16:04:05"); - // Italian agrees adjectives with the count ("1 foto importata", "2 foto importate"), so the - // count follows a label instead, which reads right for every number; the English plural - // suffix is consumed and never shows. - for n in [0, 1, 2, 5, 11, 21, 22, 25, 111] { - assert_eq!(tr_format!("Imported {} photo{}", n, if n == 1 { "" } else { "s" }), format!("Foto importate: {n}")); - assert_eq!(tr_format!("{} files and {f} folder{}", n, "s", f = n), format!("{n} file, cartelle: {n}")); - let noun = tr(if n == 1 { "cached picture" } else { "cached pictures" }); - let expected = if n == 1 { "1 immagine in cache".to_string() } else { format!("{n} immagini in cache") }; - assert_eq!(tr_format!("{n} {noun}", n = n, noun = noun), expected); - } - // This trailing argument is queue progress, not an English plural suffix. - let queued = tr_format!(" · {queued} waiting", queued = 5); - assert_eq!( - tr_format!("Denoising {name}: {done} of {total} tiles{}", queued, name = "IMG.CR3", done = 1, total = 4), - "Riduzione disturbo di IMG.CR3: 1 di 4 riquadri · in attesa: 5" - ); - let name = "Color {title} 日本語"; - assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22), format!("Foto aggiunte a “{name}”: 22")); - assert_eq!(builtin_label("Warm Glow", false), "Warm Glow"); - assert_eq!(builtin_label("Warm Glow", true), "Bagliore caldo"); - assert_eq!(tr("photo-{title}.jpg"), "photo-{title}.jpg"); - assert_eq!(tr("develop.set"), "develop.set"); - let saved = serde_json::to_string(&app.ui).unwrap(); - assert!(saved.contains(r#""language":"it""#)); - assert_eq!(serde_json::from_str::(&saved).unwrap().language, Locale::It); - let ctx = egui::Context::default(); - for code in ["it", "it-IT", "it_IT.UTF-8", "it-Latn-IT", "it-CH"] { - assert_eq!(Locale::parse_tag(code), Some(Locale::It)); - let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": code})); - let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") }; - assert_eq!(reply["ok"], true); - assert_eq!(language(), Locale::It); - } - set_language(Locale::En); - } - #[test] fn italian_panels_dialogs_and_letters_paint_with_bundled_fonts() { let ctx = fonts_ctx(&[]); let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); app.ui.left_panel = true; let text = painted_text(&ctx, &mut app, Locale::It); - assert!(text.contains("Le mie foto") && text.contains("Tutte le foto"), "{text}"); - for (command, title) in - [("app.about", "Informazioni su LightCraft"), ("app.shortcuts", "Scorciatoie da tastiera"), ("app.settings", "Impostazioni")] - { + assert!(text.contains(Locale::It.tr("My Photos")) && text.contains(Locale::It.tr("All Photos")), "{text}"); + for (command, title) in [("app.about", "About LightCraft"), ("app.shortcuts", "Keyboard Shortcuts"), ("app.settings", "Settings")] { app.ui.dialog = None; app.run(command, serde_json::json!({})).unwrap(); let text = painted_text(&ctx, &mut app, Locale::It); - assert!(text.contains(title), "{command}: {text}"); + assert!(text.contains(Locale::It.tr(title)), "{command}: {text}"); } ctx.fonts_mut(|fonts| { for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { @@ -1331,95 +1025,18 @@ mod tests { set_language(Locale::En); } - /// Finnish's coverage of every catalog message and display label, reported like Italian's. - #[test] - fn finnish_coverage_of_catalogs_commands_controls_and_rules_is_reported() { - let catalog = Locale::Fi.catalog(); - let keys: std::collections::BTreeSet<&String> = Locale::ALL.iter().flat_map(|language| language.catalog().keys()).collect(); - let lacking: Vec<_> = keys.into_iter().filter(|key| !catalog.contains_key(key.as_str())).collect(); - if !lacking.is_empty() { - eprintln!("fi lacks {} catalog message(s) (shown in English): {lacking:?}", lacking.len()); - } - let mut labels: Vec<&str> = lightcraft_engine::command_specs().iter().map(|spec| spec.label).collect(); - labels.extend(crate::menus::ui_commands().map(|command| command.1).filter(|label| !Locale::ALL.iter().any(|locale| locale.name() == *label))); - labels.extend(lightcraft_develop::CONTROLS.iter().map(|control| control.label)); - labels.extend(crate::panels::settings::TABS.iter().map(|(_, label)| *label)); - for (_, label, kind) in lightcraft_catalog::rules::FIELDS { - labels.push(label); - labels.extend(lightcraft_catalog::rules::ops_for(*kind).iter().map(|(_, label)| *label)); - } - labels.extend(lightcraft_engine::rename::TOKENS.iter().map(|token| token.meaning)); - labels.extend(lightcraft_engine::rename::TEMPLATE_NOTES); - let missing: Vec<_> = labels.into_iter().filter(|label| !catalog.contains_key(*label)).collect(); - if !missing.is_empty() { - eprintln!("fi lacks {} display label(s) (shown in English): {missing:?}", missing.len()); - } - } - - #[test] - fn finnish_switches_persists_formats_dates_and_preserves_values() { - use crate::control::{ControlRequest, Outcome}; - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.run("app.language.finnish", serde_json::json!({})).unwrap(); - assert_eq!(app.ui.language, Locale::Fi); - assert_eq!(crate::menubar::checked(&app, "app.language.finnish"), Some(true)); - assert_eq!(tr("Light"), "Valo"); - assert_eq!(tr("Landscape"), "Maisema"); - assert_eq!(tr("Home"), "Kotikansio"); - // Finnish dates have no leading zeros and a lower-case weekday; times are 24-hour with colons. - assert_eq!(date_group_label("2026-09-20", false), "sunnuntai 20.9.2026"); - assert_eq!(date_group_label("2026-09-20", true), "sunnuntai 20."); - assert_eq!(date_group_label("2026-09", false), "9/2026"); - assert_eq!(date_group_label("2026-09", true), "9"); - assert_eq!(date_group_label("2026", false), "2026"); - assert_eq!(display_time("2026-09-20T16:04:05"), "sunnuntai 20.9.2026 16:04:05"); - // A noun after a numeral is in the partitive singular (1 kuva, 2 kuvaa), which the English - // plural suffix cannot choose, so counts follow a label; the suffix is consumed and never shows. - for n in [0, 1, 2, 5, 11, 21, 22, 25, 111] { - assert_eq!(tr_format!("Imported {} photo{}", n, if n == 1 { "" } else { "s" }), format!("Tuotuja kuvia: {n}")); - assert_eq!(tr_format!("{} files and {f} folder{}", n, "s", f = n), format!("Tiedostoja: {n}, kansioita: {n}")); - let noun = tr(if n == 1 { "cached picture" } else { "cached pictures" }); - let expected = if n == 1 { "1 välimuistissa oleva kuva".to_string() } else { format!("{n} välimuistissa olevaa kuvaa") }; - assert_eq!(tr_format!("{n} {noun}", n = n, noun = noun), expected); - } - // This trailing argument is queue progress, not an English plural suffix. - let queued = tr_format!(" · {queued} waiting", queued = 5); - assert_eq!( - tr_format!("Denoising {name}: {done} of {total} tiles{}", queued, name = "IMG.CR3", done = 1, total = 4), - "Kohinanpoisto IMG.CR3: 1 / 4 ruutua · odottaa: 5" - ); - let name = "Color {title} 日本語"; - assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22), format!("Lisättyjä kuvia kohteeseen ”{name}”: 22")); - assert_eq!(builtin_label("Warm Glow", false), "Warm Glow"); - assert_eq!(builtin_label("Warm Glow", true), "Lämmin hehku"); - assert_eq!(tr("photo-{title}.jpg"), "photo-{title}.jpg"); - assert_eq!(tr("develop.set"), "develop.set"); - let saved = serde_json::to_string(&app.ui).unwrap(); - assert!(saved.contains(r#""language":"fi""#)); - assert_eq!(serde_json::from_str::(&saved).unwrap().language, Locale::Fi); - let ctx = egui::Context::default(); - for code in ["fi", "fi-FI", "fi_FI.UTF-8", "fi-Latn-FI"] { - assert_eq!(Locale::parse_tag(code), Some(Locale::Fi)); - let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": code})); - let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") }; - assert_eq!(reply["ok"], true); - assert_eq!(language(), Locale::Fi); - } - set_language(Locale::En); - } - #[test] fn finnish_panels_dialogs_and_letters_paint_with_bundled_fonts() { let ctx = fonts_ctx(&[]); let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); app.ui.left_panel = true; let text = painted_text(&ctx, &mut app, Locale::Fi); - assert!(text.contains("Omat kuvat") && text.contains("Kaikki kuvat"), "{text}"); - for (command, title) in [("app.about", "Tietoja LightCraftista"), ("app.shortcuts", "Pikanäppäimet"), ("app.settings", "Asetukset")] { + assert!(text.contains(Locale::Fi.tr("My Photos")) && text.contains(Locale::Fi.tr("All Photos")), "{text}"); + for (command, title) in [("app.about", "About LightCraft"), ("app.shortcuts", "Keyboard Shortcuts"), ("app.settings", "Settings")] { app.ui.dialog = None; app.run(command, serde_json::json!({})).unwrap(); let text = painted_text(&ctx, &mut app, Locale::Fi); - assert!(text.contains(title), "{command}: {text}"); + assert!(text.contains(Locale::Fi.tr(title)), "{command}: {text}"); } ctx.fonts_mut(|fonts| { for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { @@ -1435,66 +1052,14 @@ mod tests { set_language(Locale::En); } - /// Every command, control, rule and rename label, and every line of What's New, is a message a - /// catalog can translate. What a language lacks is reported, not failed (like - /// `catalogs_agree_on_placeholders_and_report_gaps`): a feature PR doesn't have to ship every - /// language, and the translations catch up at their own pace. #[test] - fn display_label_gaps_are_reported() { - let mut labels: Vec<&str> = lightcraft_engine::command_specs().iter().map(|spec| spec.label).collect(); - labels.extend(crate::menus::ui_commands().map(|command| command.1).filter(|label| !Locale::ALL.iter().any(|locale| locale.name() == *label))); - labels.extend(lightcraft_develop::CONTROLS.iter().map(|control| control.label)); - for (_, label, kind) in lightcraft_catalog::rules::FIELDS { - labels.push(label); - labels.extend(lightcraft_catalog::rules::ops_for(*kind).iter().map(|(_, label)| *label)); - } - labels.extend(lightcraft_catalog::rules::FIELD_GROUPS.iter().map(|group| group.0)); - labels.extend(lightcraft_engine::rename::TOKENS.iter().map(|token| token.meaning)); - labels.extend(lightcraft_engine::rename::TEMPLATE_NOTES); - let ui_labels = labels.len(); - for line in crate::panels::dialogs::WHATS_NEW.lines().map(str::trim) { - if line.is_empty() || line.starts_with("# ") { - continue; - } - let label = line.strip_prefix("### ").or_else(|| line.strip_prefix("## ")).or_else(|| line.strip_prefix("- ")).unwrap_or(line); - labels.push(label); - } - for language in Locale::ALL.iter().filter(|language| **language != Locale::En) { - let catalog = language.catalog(); - let missing: Vec<&&str> = labels[..ui_labels].iter().filter(|label| !catalog.contains_key(**label)).collect(); - let notes = labels[ui_labels..].iter().filter(|label| !catalog.contains_key(**label)).count(); - if !missing.is_empty() || notes > 0 { - eprintln!( - "{} lacks {} display label(s) (shown in English): {missing:?}; and {notes} What's New line(s)", - language.code(), - missing.len() - ); - } - } - // the German catalog, which set out to cover them all, still covers every other catalog - let german = Locale::De.catalog(); - let gaps: Vec<&String> = - Locale::ALL.iter().flat_map(|language| language.catalog().keys()).filter(|key| !german.contains_key(key.as_str())).collect(); - if !gaps.is_empty() { - eprintln!("de lacks {} message(s) another catalog has: {gaps:?}", gaps.len()); - } - } - - #[test] - fn german_switches_via_menu_and_control_persists_and_formats_dates() { + fn german_switches_via_menu_and_control_and_persists() { use crate::control::{ControlRequest, Outcome}; let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); app.run("app.language.german", serde_json::json!({})).unwrap(); assert_eq!(app.ui.language, Locale::De); - assert_eq!(tr("File"), "Datei"); + assert_eq!(language(), Locale::De); assert_eq!(crate::menubar::checked(&app, "app.language.german"), Some(true)); - assert_eq!(date_group_label("2026-09-20", false), "Sonntag, 20.09.2026"); - assert_eq!(date_group_label("2026-09-20", true), "Sonntag, 20."); - assert_eq!(date_group_label("2026-09", false), "09/2026"); - assert_eq!(display_time("2026-09-20T16:04:05"), "Sonntag, 20.09.2026 16:04:05"); - assert_eq!(tr_format!("Imported {} photo{}", 1, ""), "1 Foto importiert"); - assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "12 Fotos importiert"); - assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", "Color", n = 12), "12 Fotos zu „Color“ hinzugefügt"); let saved = serde_json::to_string(&app.ui).unwrap(); assert!(saved.contains(r#""language":"de""#)); assert_eq!(serde_json::from_str::(&saved).unwrap().language, Locale::De); @@ -1506,7 +1071,7 @@ mod tests { assert_eq!(language(), Locale::De); } assert_eq!(builtin_label("Warm Glow", false), "Warm Glow"); - assert_eq!(builtin_label("Warm Glow", true), "Warmer Glanz"); + assert_eq!(builtin_label("Warm Glow", true), Locale::De.tr("Warm Glow")); assert_eq!(tr("my-photo.jpg"), "my-photo.jpg"); let rules = lightcraft_catalog::RuleSet { mode: lightcraft_catalog::Match::All, @@ -1516,7 +1081,9 @@ mod tests { catalog .apply(lightcraft_catalog::Op::AddAlbum { album: lightcraft_catalog::Album::new(lightcraft_catalog::AlbumId(4), "Ausgeschlossen") }) .unwrap(); - assert_eq!(rules_label(&rules, &catalog), "Stichwörter enthält Color"); + // "Color" is a message too, but a rule's free-text value stays as written + let keywords = lightcraft_catalog::rules::field_label("keywords").unwrap_or("keywords"); + assert_eq!(rules_label(&rules, &catalog), format!("{} {} Color", Locale::De.tr(keywords), Locale::De.tr("contains"))); let album = lightcraft_catalog::RuleSet { mode: lightcraft_catalog::Match::All, rules: vec![lightcraft_catalog::Rule::Field { field: "album".into(), op: "isNot".into(), value: serde_json::json!(4) }], @@ -1530,21 +1097,28 @@ mod tests { value: serde_json::json!("publicDomain"), }], }; - assert!(rules_label(&rules, &catalog).ends_with(" Gemeinfrei"), "{}", rules_label(&rules, &catalog)); + let public_domain = format!(" {}", choice_text_in(Locale::De, "copyrightStatus", "publicDomain")); + assert!(rules_label(&rules, &catalog).ends_with(&public_domain), "{}", rules_label(&rules, &catalog)); for value in [serde_json::json!(false), serde_json::json!("false")] { let rules = lightcraft_catalog::RuleSet { mode: lightcraft_catalog::Match::All, rules: vec![lightcraft_catalog::Rule::Field { field: "edited".into(), op: "is".into(), value }], }; - assert!(rules_label(&rules, &catalog).ends_with(" Nein"), "{}", rules_label(&rules, &catalog)); + let no = format!(" {}", bool_text_in(Locale::De, false)); + assert!(rules_label(&rules, &catalog).ends_with(&no), "{}", rules_label(&rules, &catalog)); } let filter = lightcraft_catalog::Filter { labels: vec![lightcraft_catalog::ColorLabel::Red, lightcraft_catalog::ColorLabel::Blue], ..Default::default() }; - assert_eq!(filter_label(&filter, &app.session.catalog), "Farbmarkierung: Rot oder Blau"); + let blue = color_label(&app.session.catalog, lightcraft_catalog::ColorLabel::Blue); + let red = color_label(&app.session.catalog, lightcraft_catalog::ColorLabel::Red); + let label = filter_label(&filter, &app.session.catalog); + assert!(label.contains(&red) && label.contains(&blue), "{label}"); + // a label set's own name is the user's app.run("label.setNames", serde_json::json!({"names": {"red": "Color"}})).unwrap(); - assert_eq!(filter_label(&filter, &app.session.catalog), "Farbmarkierung: Color oder Blau"); + let label = filter_label(&filter, &app.session.catalog); + assert!(label.contains("Color") && !label.contains(&red) && label.contains(&blue), "{label}"); set_language(Locale::En); } @@ -1554,15 +1128,15 @@ mod tests { let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); app.ui.left_panel = true; let text = painted_text(&ctx, &mut app, Locale::De); - assert!(text.contains("Meine Fotos") && text.contains("Alle Fotos"), "{text}"); + assert!(text.contains(Locale::De.tr("My Photos")) && text.contains(Locale::De.tr("All Photos")), "{text}"); app.ui.view = crate::state::ViewMode::People; let text = painted_text(&ctx, &mut app, Locale::De); - assert!(text.contains("Benannte Personen"), "{text}"); - for (command, title) in [("app.about", "Über LightCraft"), ("app.shortcuts", "Tastenkürzel"), ("app.settings", "Einstellungen")] { + assert!(text.contains(Locale::De.tr("Named People")), "{text}"); + for (command, title) in [("app.about", "About LightCraft"), ("app.shortcuts", "Keyboard Shortcuts"), ("app.settings", "Settings")] { app.ui.dialog = None; app.run(command, serde_json::json!({})).unwrap(); let text = painted_text(&ctx, &mut app, Locale::De); - assert!(text.contains(title), "{command}: {text}"); + assert!(text.contains(Locale::De.tr(title)), "{command}: {text}"); } ctx.fonts_mut(|fonts| { for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { @@ -1634,10 +1208,6 @@ mod tests { assert_eq!(crate::menubar::display_item_label("app.export", &serde_json::json!({"preset": "Color"}), "Color"), "Color"); assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), language.tr("Color")); } - set_language(Locale::ZhHans); - assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), "颜色"); - set_language(Locale::Ja); - assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), "カラー"); set_language(Locale::En); } @@ -1689,12 +1259,6 @@ mod tests { assert_eq!(app.font_language, *language, "the fonts follow the language"); assert_eq!(ids(&app), english_ids, "{language:?}: command ids are presentation-independent"); } - let text = painted_text(&ctx, &mut app, Locale::Ja); - assert!(text.contains("マイフォト") && text.contains("すべての写真"), "{text}"); - let text = painted_text(&ctx, &mut app, Locale::ZhHans); - assert!(text.contains("我的照片") && text.contains("所有照片"), "{text}"); - let text = painted_text(&ctx, &mut app, Locale::PtBr); - assert!(text.contains("Minhas fotos") && text.contains("Todas as fotos"), "{text}"); set_language(Locale::En); } @@ -1765,7 +1329,7 @@ mod tests { } /// Traditional Chinese through the menu command and the control channel, persisted, with - /// localised headings and dates; ids and user text are untouched. + /// localised headings; ids and user text are untouched. #[test] fn traditional_chinese_switches_through_menu_and_control_and_persists() { use crate::control::{ControlRequest, Outcome}; @@ -1773,15 +1337,9 @@ mod tests { app.run("app.language.traditionalChinese", serde_json::json!({})).unwrap(); assert_eq!(app.ui.language, Locale::ZhHant); // Applied at once, not on the next frame. - assert_eq!(tr("File"), "檔案"); - assert_eq!(source_label(lightcraft_engine::LibrarySource::All, &app.session.catalog), "所有照片"); - assert_eq!(date_group_label("2026-09-20", false), "2026年9月20日 星期日"); - assert_eq!(date_group_label("2026-09-20", true), "20日 星期日"); - assert_eq!(date_group_label("2026-09", false), "2026年9月"); - assert_eq!(date_group_label("2026-09", true), "9月"); - assert_eq!(date_group_label("2026", false), "2026年"); - assert_eq!(date_group_label("", false), "未知日期"); - assert_eq!(display_time("2026-09-20T16:04:05"), "2026年9月20日 星期日 16:04:05"); + assert_eq!(language(), Locale::ZhHant); + assert_eq!(source_label(lightcraft_engine::LibrarySource::All, &app.session.catalog), Locale::ZhHant.tr("All Photos")); + assert_eq!(date_group_label("", false), Locale::ZhHant.tr("Unknown Date")); assert_eq!(display_time("a-user-value"), "a-user-value"); assert_eq!(crate::menubar::checked(&app, "app.language.traditionalChinese"), Some(true)); assert_eq!(crate::menubar::checked(&app, "app.language.english"), Some(false)); @@ -1789,9 +1347,7 @@ mod tests { for id in ["album.addPhotos", "metadata.applyPreset", "label.applySet"] { assert_eq!(crate::menubar::display_item_label(id, &serde_json::json!({"id": 1}), "Color"), "Color"); } - assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), "色彩"); - assert_eq!(tr_format!("{n} photo{}", "s", n = 12), "12 張照片"); - assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "已匯出 4/12 張照片"); + assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), Locale::ZhHant.tr("Color")); let saved = serde_json::to_string(&app.ui).unwrap(); assert!(saved.contains(r#""language":"zh-hant""#), "{saved}"); assert_eq!(serde_json::from_str::(&saved).unwrap().language, Locale::ZhHant); @@ -1815,7 +1371,8 @@ mod tests { assert_eq!(chinese_ids, ids(&app)); } - /// Dates follow every language's own patterns; English keeps the catalog's wording. + /// Dates follow every language's own patterns (English keeps the catalog's wording); a key that + /// is not a date never panics and never shows a raw pattern. #[test] fn date_headings_in_every_language() { set_language(Locale::En); @@ -1823,14 +1380,16 @@ mod tests { assert_eq!(date_group_label("2026-09-20", true), "Sunday, 20"); assert_eq!(date_group_label("2026-09", true), "September"); assert_eq!(display_time("2026-09-20T16:04:05"), lightcraft_catalog::dates::display_time("2026-09-20T16:04:05")); - set_language(Locale::Ja); - assert_eq!(date_group_label("2026-09-20", false), "2026年9月20日(日曜日)"); - assert_eq!(date_group_label("2026-09", true), "9月"); - set_language(Locale::ZhHans); - assert_eq!(date_group_label("2026-09-20", false), "2026年9月20日 星期日"); for language in Locale::ALL { set_language(*language); - // A key that is not a date never panics and never shows a raw pattern. + if *language != Locale::En { + let day = date_group_label("2026-09-20", false); + assert!(day.contains("20") && day.contains("2026") && day.contains(tr("Sunday")), "{language:?}: {day}"); + assert!(date_group_label("2026-09-20", true).contains("20"), "{language:?}"); + assert!(date_group_label("2026-09", false).contains("2026"), "{language:?}"); + assert!(date_group_label("2026", false).contains("2026"), "{language:?}"); + assert_eq!(display_time("2026-09-20T16:04:05"), format!("{day} 16:04:05"), "{language:?}"); + } for key in ["", "abcd", "2026-xx", "2026-13", "2026-09-xx", "2026-09"] { assert!(!date_group_label(key, false).contains('{'), "{language:?}: {key:?}"); assert!(!date_group_label(key, true).contains('{'), "{language:?}: {key:?}"); @@ -1849,172 +1408,18 @@ mod tests { app.ui.presets = true; let builtin = app.session.presets.iter().find(|p| p.name == "Warm Glow").unwrap().clone(); app.session.presets.push(lightcraft_develop::Preset { id: "user.test".into(), builtin: false, ..builtin }); - for (panel, title) in [(crate::state::RightPanel::Activity, "歷史紀錄"), (crate::state::RightPanel::Versions, "版本")] { + let zh = Locale::ZhHant; + for (panel, title) in [(crate::state::RightPanel::Activity, "History"), (crate::state::RightPanel::Versions, "Versions")] { app.ui.right = panel; - let text = painted_text(&ctx, &mut app, Locale::ZhHant); - assert!(text.contains(title), "{text}"); - assert!(!text.contains("History") && !text.contains("Versions"), "{text}"); - assert!(text.contains("暖光"), "stock preset: {text}"); + let text = painted_text(&ctx, &mut app, zh); + assert!(text.contains(zh.tr(title)), "{text}"); + assert!(text.contains(zh.tr("Warm Glow")), "stock preset: {text}"); assert!(text.contains("Warm Glow"), "user preset: {text}"); - assert!(text.contains("色彩"), "stock group: {text}"); - } - assert_eq!(history_label("Exposure", &app.session.presets), "曝光"); - assert_eq!(history_label("Preset: Warm Film", &app.session.presets), "預設集:暖調底片"); - assert_eq!(history_label("Preset: Warm Glow", &app.session.presets), "預設集:Warm Glow"); - set_language(Locale::En); - } - - /// English catalog values Vietnamese keeps on purpose. Grouped by reason: - /// standards/acronyms (`ISO`, `HDR`, …), product names (`LightCraft`, `PhotoCraft`), - /// file-type tokens (`Raw`/`raw`, `video`), look names left as established labels - /// (`Bleach Bypass`, `Vintage`), and machine ids that must stay byte-identical - /// (`publicDomain`, `denoiseModel.accept`). `Album` stays as the common loanword. - /// Every other required label must differ from its English source. - const VI_INTENTIONAL_ENGLISH: &[&str] = &[ - "OK", - "ISO", - "RGB", - "HDR", - "JPEG", - "PNG", - "TIFF", - "WebP", - "AVIF", - "HEIF", - "HEIC", - "DNG", - "ICC", - "EXIF", - "XMP", - "GPU", - "MCP", - "SAM", - "RAW", - "Raw", - "raw", - "B&W", - "LUT", - "LightCraft", - "ArtCraft", - "GitHub", - "Discord", - "English", - "日本語", - "繁體中文(台灣)", - "Display P3", - "Bleach Bypass", - "Cross Process", - "HDR…", - "video", - "Video", - "true", - "false", - "Album", - "Vintage", - "publicDomain", - "denoiseModel.accept", - "Panorama", - "Panorama…", - "PhotoCraft", - "SDR", - "Matrix/TRC", - "LUT-based", - ]; - - /// Vietnamese's coverage of every catalog message and display label, reported like Italian's; the - /// messages it has are never left in English. - #[test] - fn vietnamese_covers_catalogs_commands_controls_and_rules() { - let catalog = Locale::Vi.catalog(); - assert!(!catalog.is_empty()); - let keys: std::collections::BTreeSet<&String> = Locale::ALL.iter().flat_map(|language| language.catalog().keys()).collect(); - let lacking: Vec<_> = keys.into_iter().filter(|key| !catalog.contains_key(key.as_str())).collect(); - if !lacking.is_empty() { - eprintln!("vi lacks {} catalog message(s) (shown in English): {lacking:?}", lacking.len()); - } - - let mut labels: Vec<&str> = lightcraft_engine::command_specs().iter().map(|spec| spec.label).collect(); - labels.extend(crate::menus::ui_commands().map(|command| command.1).filter(|label| !Locale::ALL.iter().any(|locale| locale.name() == *label))); - labels.extend(lightcraft_develop::CONTROLS.iter().map(|control| control.label)); - labels.extend(crate::panels::settings::TABS.iter().map(|(_, label)| *label)); - for (_, label, kind) in lightcraft_catalog::rules::FIELDS { - labels.push(label); - labels.extend(lightcraft_catalog::rules::ops_for(*kind).iter().map(|(_, label)| *label)); - } - labels.extend(lightcraft_catalog::rules::FIELD_GROUPS.iter().map(|group| group.0)); - labels.extend(lightcraft_engine::rename::TOKENS.iter().map(|token| token.meaning)); - labels.extend(lightcraft_engine::rename::TEMPLATE_NOTES); - for word in crate::date_picker::WEEKDAY_SHORT.iter().chain(&["Pick a date", "Year", "Month", "Day"]) { - labels.push(*word); - } - for day in ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] { - labels.push(day); - } - let missing: Vec<_> = labels.into_iter().filter(|label| !catalog.contains_key(*label)).collect(); - if !missing.is_empty() { - eprintln!("vi lacks {} display label(s) (shown in English): {missing:?}", missing.len()); - } - - for (key, value) in catalog { - if key.contains('|') { - continue; - } - if VI_INTENTIONAL_ENGLISH.contains(&key.as_str()) { - continue; - } - assert!(!value.is_empty(), "vi empty for {key:?}"); - // Identical values outside the allowlist are accidental English fallbacks. - if value == key && key.chars().any(|ch| ch.is_ascii_alphabetic()) && key.len() > 1 { - // Single-letter ops / symbols may match; longer shared English must be listed. - assert!( - key.len() <= 2 || key.chars().all(|ch| !ch.is_ascii_alphabetic()), - "vi keeps English for {key:?}; add it to VI_INTENTIONAL_ENGLISH if intentional" - ); - } - } - } - - #[test] - fn vietnamese_switches_persists_formats_dates_and_preserves_values() { - use crate::control::{ControlRequest, Outcome}; - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.run("app.language.vietnamese", serde_json::json!({})).unwrap(); - assert_eq!(app.ui.language, Locale::Vi); - assert_eq!(crate::menubar::checked(&app, "app.language.vietnamese"), Some(true)); - assert_eq!(tr("Light"), "Ánh sáng"); - assert_eq!(tr("Exposure"), "Phơi sáng"); - assert_eq!(Locale::Vi.name(), "Tiếng Việt"); - assert_eq!(tr("Settings"), "Cài đặt"); - assert_eq!(tr("Appearance"), "Giao diện"); - assert_eq!(tr("My Photos"), "Ảnh của tôi"); - assert_eq!(tr("Candidate"), "Ảnh ứng viên"); - assert_eq!(tr("Subject"), "Chủ thể"); - assert_eq!(date_group_label("2026-09-20", false), "Chủ Nhật, 20/9/2026"); - assert_eq!(date_group_label("2026-09", false), "Tháng 9 năm 2026"); - assert_eq!(date_group_label("2026-09", true), "Tháng 9"); - assert_eq!(display_time("2026-09-20T16:04:05"), "Chủ Nhật, 20/9/2026 16:04:05"); - for n in [0, 1, 2, 5, 22] { - let text = tr_format!("Imported {} photo{}", n, if n == 1 { "" } else { "s" }); - assert!(text.contains(&n.to_string()) && text.contains("ảnh"), "{n}: {text}"); - assert!(!text.contains("photos") && !text.contains("photo"), "{n}: {text}"); - } - let name = "Color {title} 日本語"; - assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22), format!("Đã thêm 22 ảnh vào «{name}»")); - assert_eq!(builtin_label("Warm Glow", false), "Warm Glow"); - assert_ne!(builtin_label("Warm Glow", true), "Warm Glow"); - assert_eq!(tr("photo-{title}.jpg"), "photo-{title}.jpg"); - assert_eq!(tr("develop.set"), "develop.set"); - let saved = serde_json::to_string(&app.ui).unwrap(); - assert!(saved.contains(r#""language":"vi""#), "{saved}"); - assert_eq!(serde_json::from_str::(&saved).unwrap().language, Locale::Vi); - let ctx = egui::Context::default(); - for code in ["vi", "vi-VN", "vi_VN", "vi_VN.UTF-8"] { - assert_eq!(Locale::parse_tag(code), Some(Locale::Vi)); - let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": code})); - let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") }; - assert_eq!(reply["ok"], true); - assert_eq!(language(), Locale::Vi); + assert!(text.contains(zh.tr("Color")), "stock group: {text}"); } + assert_eq!(history_label("Exposure", &app.session.presets), zh.tr("Exposure")); + assert!(history_label("Preset: Warm Film", &app.session.presets).ends_with(zh.tr("Warm Film"))); + assert!(history_label("Preset: Warm Glow", &app.session.presets).ends_with("Warm Glow"), "the user's preset shares the name"); set_language(Locale::En); } @@ -2024,12 +1429,12 @@ mod tests { let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); app.ui.left_panel = true; let text = painted_text(&ctx, &mut app, Locale::Vi); - assert!(text.contains("Ảnh của tôi") && text.contains("Tất cả ảnh"), "{text}"); - for (command, title) in [("app.about", "Giới thiệu LightCraft"), ("app.shortcuts", "Phím tắt"), ("app.settings", "Cài đặt")] { + assert!(text.contains(Locale::Vi.tr("My Photos")) && text.contains(Locale::Vi.tr("All Photos")), "{text}"); + for (command, title) in [("app.about", "About LightCraft"), ("app.shortcuts", "Keyboard Shortcuts"), ("app.settings", "Settings")] { app.ui.dialog = None; app.run(command, serde_json::json!({})).unwrap(); let text = painted_text(&ctx, &mut app, Locale::Vi); - assert!(text.contains(title), "{command}: {text}"); + assert!(text.contains(Locale::Vi.tr(title)), "{command}: {text}"); } ctx.fonts_mut(|fonts| { for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { @@ -2047,6 +1452,178 @@ mod tests { }); set_language(Locale::En); } + /// The messages each language lacks, which it shows in English. Never fails: `cargo xtask ci` + /// runs it with `--nocapture`, so the list lands in the gate log. Plain messages are every key + /// some catalog has plus the labels the UI passes to `tr` from tables (commands, controls, rule + /// fields…); formats are every key some `*-formats.json` has plus every `tr_format!` message in + /// the app's source. An empty translation counts as missing. + #[test] + fn translation_gaps_report() { + let notes = whats_new_lines(); + let messages: std::collections::BTreeSet = plain_messages().into_iter().filter(|m| !notes.contains(&m.as_str())).collect(); + let formats = format_messages(); + let quoted = |list: &[&str]| list.iter().map(|m| format!("{m:?}")).collect::>().join(", "); + let mut out = format!( + "Translation gaps, shown in English ({} messages, {} formats, {} What's New lines):\n", + messages.len(), + formats.len(), + notes.len() + ); + for language in Locale::ALL.iter().filter(|language| **language != Locale::En) { + let catalog = language.catalog(); + let own_formats = formats_catalog(language.code()); + let missing: Vec<&str> = messages.iter().map(String::as_str).filter(|m| !catalog.contains_key(*m)).collect(); + let missing_formats: Vec<&str> = formats.iter().map(String::as_str).filter(|m| !own_formats.contains_key(*m)).collect(); + let missing_notes = notes.iter().filter(|line| !catalog.contains_key(**line)).count(); + out.push_str(&format!( + "{}: {} messages, {} formats, {} What's New lines\n", + language.code(), + missing.len(), + missing_formats.len(), + missing_notes + )); + if !missing.is_empty() { + out.push_str(&format!(" messages: {}\n", quoted(&missing))); + } + if !missing_formats.is_empty() { + out.push_str(&format!(" formats: {}\n", quoted(&missing_formats))); + } + } + println!("{out}"); + } + + /// Every plain message: the keys of every catalog (but contextual entries, which a language adds + /// only where it needs one) and the labels the UI translates from tables. + fn plain_messages() -> std::collections::BTreeSet { + use lightcraft_catalog::rules; + let mut labels: Vec<&str> = lightcraft_engine::command_specs().iter().map(|spec| spec.label).collect(); + labels.extend(crate::menus::ui_commands().map(|command| command.1).filter(|label| !Locale::ALL.iter().any(|locale| locale.name() == *label))); + labels.extend(lightcraft_develop::CONTROLS.iter().map(|control| control.label)); + labels.extend(crate::panels::settings::TABS.iter().map(|(_, label)| *label)); + for (_, label, kind) in rules::FIELDS { + labels.push(label); + labels.extend(rules::ops_for(*kind).iter().map(|(_, label)| *label)); + if let rules::Kind::Choice(choices) = kind { + labels.extend(choices.iter().map(|(_, label)| *label)); + } + } + labels.extend(rules::FIELD_GROUPS.iter().map(|group| group.0)); + labels.extend(rules::Issue::ALL.iter().map(|issue| issue.text())); + labels.extend([rules::bool_label(true), rules::bool_label(false)]); + labels.extend(lightcraft_engine::rename::TOKENS.iter().map(|token| token.meaning)); + labels.extend(lightcraft_engine::rename::TEMPLATE_NOTES); + labels.extend(crate::date_picker::WEEKDAY_SHORT); + labels.extend(["Pick a date", "Year", "Month", "Day", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]); + // the album pickers + labels.extend([ + "Choose an album…", + "No albums yet", + "Search albums", + "No albums match", + "This is the album you're editing", + "It tests this album, so testing it back would loop", + "Fix the marked rules to save this album.", + "None", + "New album", + "New album…", + "Imported Photos", + ]); + labels.extend(crate::panels::keyword_list::VARIABLE_LABELS); + let contextual = format!("{BOOL_CONTEXT}|"); + Locale::ALL + .iter() + .flat_map(|language| language.catalog().keys()) + .filter(|key| !key.starts_with(&contextual)) + .cloned() + .chain(labels.into_iter().map(str::to_string)) + .collect() + } + + /// Help ▸ What's New, line by line as the dialog looks each one up. + fn whats_new_lines() -> Vec<&'static str> { + crate::panels::dialogs::WHATS_NEW + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with("# ")) + .map(|line| line.strip_prefix("### ").or_else(|| line.strip_prefix("## ")).or_else(|| line.strip_prefix("- ")).unwrap_or(line)) + .collect() + } + + /// `locales/-formats.json` (the catalog `build.rs` compiles into `tr_format!`), without + /// empty translations. + fn formats_catalog(code: &str) -> BTreeMap { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(format!("locales/{code}-formats.json")); + let mut messages: BTreeMap = + std::fs::read_to_string(path).ok().and_then(|text| serde_json::from_str(&text).ok()).unwrap_or_default(); + messages.retain(|_, text| !text.is_empty()); + messages + } + + /// Every format message: the keys of every formats catalog and the message of every + /// `tr_format!` call in `src/` outside its test modules. + fn format_messages() -> std::collections::BTreeSet { + let mut all: std::collections::BTreeSet = Locale::ALL + .iter() + .filter(|language| **language != Locale::En) + .flat_map(|language| formats_catalog(language.code()).into_keys()) + .collect(); + let mut dirs = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; + while let Some(dir) = dirs.pop() { + for path in std::fs::read_dir(&dir).unwrap().map(|entry| entry.unwrap().path()) { + if path.is_dir() { + dirs.push(path); + } else if path.extension().is_some_and(|ext| ext == "rs") { + let source = std::fs::read_to_string(&path).unwrap(); + let app = source + .split( + "#[cfg(test)] +mod ", + ) + .next() + .unwrap_or_default(); + all.extend(tr_format_messages(app)); + } + } + } + all + } + + /// The first argument of each `tr_format!` call in `source`, when it is a string literal. + fn tr_format_messages(source: &str) -> Vec { + // built from parts, so this file's own source holds no call to find + let call = concat!("tr_format", "!("); + let mut messages = Vec::new(); + for (at, _) in source.match_indices(call) { + let Some(body) = source[at + call.len()..].trim_start().strip_prefix('"') else { continue }; + let mut message = String::new(); + let mut chars = body.chars(); + while let Some(ch) = chars.next() { + match ch { + '"' => break, + '\\' => match chars.next() { + Some('n') => message.push('\n'), + Some('t') => message.push('\t'), + Some('u') => { + let hex: String = chars.by_ref().skip(1).take_while(|ch| *ch != '}').collect(); + message.extend(u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32)); + } + Some(other) => message.push(other), + None => break, + }, + _ => message.push(ch), + } + } + messages.push(message); + } + messages + } + + #[test] + fn tr_format_messages_are_read_from_source() { + let call = concat!("tr_format", "!("); + let source = format!("{call}\"Imported {{}} photo{{}}\", n, s); crate::i18n::{call}\n \"Say \\\"hi\\\" \\u{{2192}}\", x); {call}name)"); + assert_eq!(tr_format_messages(&source), ["Imported {} photo{}", "Say \"hi\" →"]); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/lightroom_import.rs b/crates/ui-egui/src/lightroom_import.rs index 2bda28cf4..059a700e1 100644 --- a/crates/ui-egui/src/lightroom_import.rs +++ b/crates/ui-egui/src/lightroom_import.rs @@ -684,25 +684,17 @@ mod tests { } #[test] - fn skipped_images_are_one_line_in_the_ui_language() { + fn skipped_images_are_one_line() { let value = json!({"report": {"photos": 3, "collections": 0, "warnings": ["w1", "w2"], "skipped": [ {"image": 7, "reason": "missingFile"}, {"image": 8, "reason": "missingRoot"}, {"image": 9, "reason": "masterSkipped"}, ]}}); - let details = |locale| { - crate::i18n::set_language(locale); - let dialog = result_dialog(&value, "done"); - crate::i18n::set_language(crate::i18n::Locale::En); - let crate::state::Dialog::LightroomResult { details, .. } = dialog else { panic!("not a Lightroom result") }; - details - }; - let en = details(crate::i18n::Locale::En); + crate::i18n::set_language(crate::i18n::Locale::En); + let crate::state::Dialog::LightroomResult { details: en, .. } = result_dialog(&value, "done") else { panic!("not a Lightroom result") }; assert_eq!(en.len(), 4, "{en:?}"); assert_eq!(en[1], "3 catalog images were not imported because their records in the catalog are incomplete."); assert_eq!(&en[2..], ["w1", "w2"]); - let es = details(crate::i18n::Locale::Es); - assert_eq!(es[1], "No se importaron 3 imágenes del catálogo porque sus registros en el catálogo están incompletos."); } #[test] diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index a1184892c..fbe52cf12 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -1533,18 +1533,6 @@ mod tests { } } - #[test] - fn library_context_menu_labels_have_french_translations() { - let saved = crate::i18n::language(); - crate::i18n::set_language(crate::i18n::Locale::Fr); - for (label, expected) in - [("Pick", "Sélectionnée"), ("Reject", "Rejeter"), ("Group into Stack", "Regrouper en pile"), ("HDR Panorama…", "Panorama HDR…")] - { - assert_eq!(crate::i18n::tr(label), expected); - } - crate::i18n::set_language(saved); - } - #[test] fn square_grid_groups_and_auto_levels() { let l = layout(&[1.0; 5], &[(0, 3), (3, 2)], 400.0, 100.0, true); diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index 1ec97d1c9..4f2cba91c 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -520,6 +520,35 @@ fn in_tree(tree: &[KeywordNode], path: &str) -> bool { tree.iter().any(|n| same(&n.path, path) || (lightcraft_catalog::keywords::is_under(path, &n.path) && in_tree(&n.children, path))) } +/// The labels the Keyword List and its dialogs pass to `tr` through variables, out of reach of a +/// literal search: the translation gaps report (`i18n`) checks them. +#[cfg(test)] +pub(crate) const VARIABLE_LABELS: &[&str] = &[ + "Include on Export", + "Export Containing Keywords", + "Export Synonyms", + "Person", + "Merge Keywords", + "No keywords yet", + "No keywords match", + "Add to Selected Photos", + "Remove from Selected Photos", + "Keywords", + "& Containing", + "Will Export", + "No keywords are exported", + "Add to All Selected Photos", + "Set name", + "Edit Set…", + "Edit Keyword Set", + "A keyword set needs a name", + "Recent Keywords", + "Keywords (⌥1–⌥9)", + "Save as a new set", + "Import Keywords…", + "Export Keywords…", +]; + #[cfg(test)] mod tests { use lightcraft_catalog::{Op, Photo, Source}; @@ -589,45 +618,4 @@ mod tests { let (c, ids) = library(&[&["Ärzte", "ÄRZTE"], &[]]); assert_eq!(Ticks::of(&c, &ids).tick("ärzte"), Tick::Some); } - - /// The labels the Keyword List and its dialogs pass to `tr` through variables (out of reach of - /// a literal search) are translated in every language. - #[test] - fn labels_passed_through_variables_are_translated() { - use crate::i18n::Locale; - let labels = [ - "Include on Export", - "Export Containing Keywords", - "Export Synonyms", - "Person", - "Merge Keywords", - "No keywords yet", - "No keywords match", - "Add to Selected Photos", - "Remove from Selected Photos", - "Keywords", - "& Containing", - "Will Export", - "No keywords are exported", - "Add to All Selected Photos", - "Set name", - "Edit Set…", - "Edit Keyword Set", - "A keyword set needs a name", - "Recent Keywords", - "Keywords (⌥1–⌥9)", - "Save as a new set", - "Import Keywords…", - "Export Keywords…", - ]; - let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("locales"); - for code in ["de", "es", "fr", "pt-br", "ru", "uk", "it", "ka", "ja", "zh-hans", "zh-hant", "pl", "cs", "sv", "fi", "id", "vi"] { - let text = std::fs::read_to_string(dir.join(format!("{code}.json"))).unwrap(); - let catalog: std::collections::HashMap = serde_json::from_str(&text).unwrap(); - for label in labels { - assert!(catalog.contains_key(label), "{code}: “{label}”"); - } - } - assert_eq!(Locale::ALL.len(), 18, "a language added: list its file above"); - } } diff --git a/crates/ui-egui/src/panels/masking.rs b/crates/ui-egui/src/panels/masking.rs index b0371c422..4fb31be80 100644 --- a/crates/ui-egui/src/panels/masking.rs +++ b/crates/ui-egui/src/panels/masking.rs @@ -897,25 +897,6 @@ fn brush_settings(app: &mut LightcraftApp, ui: &mut egui::Ui) { mod tests { use super::*; - #[test] - fn masking_context_actions_use_catalog_translations() { - let saved = crate::i18n::language(); - crate::i18n::set_language(crate::i18n::Locale::Fr); - for (english, translated) in [ - ("Duplicate Mask", "Dupliquer le masque"), - ("Invert Mask", "Inverser le masque"), - ("Hide Mask", "Masquer le masque"), - ("Move Up", "Monter"), - ("Brush", "Pinceau"), - ("Luminance Range", "Plage de luminance"), - ] { - assert_eq!(menu_label(english), translated, "{english}"); - } - // An arbitrary user name has no catalog entry and is left unchanged. - assert_eq!(menu_label("My custom mask"), "My custom mask"); - crate::i18n::set_language(saved); - } - /// The grid keeps four 52 pt tiles when the labels fit, and gives a long (translated) label /// room by dropping a column rather than drawing it past its tile. #[test] diff --git a/crates/ui-egui/src/tests_album_picker.rs b/crates/ui-egui/src/tests_album_picker.rs index 32cc906fe..97305cba6 100644 --- a/crates/ui-egui/src/tests_album_picker.rs +++ b/crates/ui-egui/src/tests_album_picker.rs @@ -343,20 +343,12 @@ mod import_review { } } - /// Every shipped locale paints translated destination choices and initializes a translated new name. + /// Every shipped locale paints its own destination choices and names a new album in its language. #[test] fn import_album_picker_localizes_choices_in_every_shipped_language() { use crate::i18n::Locale; let Review { mut h, .. } = review(); for locale in Locale::ALL { - for source in - ["None", "New album", "New album…", "Imported Photos", "Choose an album…", "Search albums", "No albums yet", "No albums match"] - { - assert!(!locale.tr(source).is_empty()); - if *locale != Locale::En { - assert_ne!(locale.tr(source), source, "{locale:?}: {source}"); - } - } let r = h.request("ui.set", json!({"language": locale.code()}), T); assert_eq!(r["ok"], true, "{r}"); assert!(h.settle(SETTLE)); diff --git a/docs/localization-cs.md b/docs/localization-cs.md index 52fad4542..c3ed1ddcf 100644 --- a/docs/localization-cs.md +++ b/docs/localization-cs.md @@ -46,8 +46,9 @@ ani anglické zdrojové řetězce. Rozložení panelů a seznamu jazyků je beze ## Ověření a revize Testy `i18n::tests` ověřují registraci příkazu, přepínání přes nabídku a řídicí kanál, -uložení jazyka, pokrytí katalogů, placeholdery, neutrální formulace počtů, datum, čas -a české glyfy. Překladové šablony kontroluje také Rust při sestavení. +uložení jazyka, placeholdery, datum, čas a české glyfy. Překladové šablony kontroluje také +Rust při sestavení. Chybějící překlady se zobrazují anglicky a vypisuje je +`i18n::tests::translation_gaps_report`; žádný test kvůli nim neselže. Při jazykové korektuře doporučujeme zkontrolovat „asistované třídění“, „sada“ pro Stack, „bodová barva“, „měkký nátisk“ a názvy kreativních přednastavení. diff --git a/docs/localization-de.md b/docs/localization-de.md index 0ceb3b45b..aa058af9d 100644 --- a/docs/localization-de.md +++ b/docs/localization-de.md @@ -29,8 +29,8 @@ cargo test -p lightcraft-ui-egui i18n::tests LIGHTCRAFT_LANGUAGE=de lightcraft-cli snapshot --demo -o deutsch.png --size 1600x1000 ``` -Die Tests prüfen Katalogabdeckung, Platzhalter, Sprachwechsel per Menü und Steuerkanal, -Speicherung, Datumsformate, unveränderte Benutzernamen und gerenderte Texte sowie Umlaute -und ß. Ein weiterer Render-Test prüft die Schaltflächenbreiten; der Importtest prüft +Die Tests prüfen Platzhalter, Sprachwechsel per Menü und Steuerkanal, Speicherung, +Datumsformate, unveränderte Benutzernamen und gerenderte Texte sowie Umlaute und ß. Fehlende +Übersetzungen erscheinen auf Englisch; `i18n::tests::translation_gaps_report` listet sie auf. Ein weiterer Render-Test prüft die Schaltflächenbreiten; der Importtest prüft auch den Sprachwechsel nach der Hintergrundprüfung der Quellen. Akzeptierte Sprachangaben umfassen `de`, `de-DE`, `de_AT.UTF-8` und `de-CH`; gespeichert wird stets `de`. Weitere Hinweise stehen in [localization.md](localization.md). diff --git a/docs/localization-fi.md b/docs/localization-fi.md index d80a2e1cc..fcb89d784 100644 --- a/docs/localization-fi.md +++ b/docs/localization-fi.md @@ -52,8 +52,9 @@ englanniksi. Käännös ei muuta kirjastotietoja eikä englanninkielisiä lähde ## Testaus `i18n::tests` tarkistaa komennon rekisteröinnin, vaihdon valikosta ja ohjauskanavasta, -kielen tallennuksen, luetteloiden kattavuuden, paikkamerkit, määrämuodot, päivämäärän, ajan ja -suomen kirjaimet. Rust tarkistaa muotoilumallit käännösvaiheessa. +kielen tallennuksen, paikkamerkit, päivämäärän, ajan ja suomen kirjaimet. Rust tarkistaa +muotoilumallit käännösvaiheessa. Puuttuvat käännökset näkyvät englanniksi, ja +`i18n::tests::translation_gaps_report` luettelee ne; mikään testi ei epäonnistu niiden takia. Kielentarkistuksessa kannattaa katsoa erityisesti: ”Väriluokittelu” (Color Grading), ”Valoalueet / Valkoiset” (Highlights / Whites), ”Näyttövedos” (Soft Proofing), diff --git a/docs/localization-ka.md b/docs/localization-ka.md index 4d517c6ca..e26f6e4a4 100644 --- a/docs/localization-ka.md +++ b/docs/localization-ka.md @@ -19,7 +19,7 @@ not translated. Descriptions for SAM 3 go to the model unchanged. ## Georgian script Mkhedruli has no upper and lower case: `i18n::upper_first` leaves Georgian letters alone, and the -interface never uses Mtavruli (a test checks the whole catalog). Commands are verbal nouns. Counts +interface never uses Mtavruli. Commands are verbal nouns. Counts are count-neutral, noun first (`იმპორტირებულია ფოტო: 3`); English plural endings are dropped with `{:.0}`. Dates read `კვირა, 20.09.2026` (`{weekday}, {day:02}.{month:02}.{year}`), months `09.2026`. @@ -73,7 +73,7 @@ cargo test -p lightcraft-ui-egui i18n::tests -- --nocapture LIGHTCRAFT_LANGUAGE=ka lightcraft-cli snapshot --demo -o georgian.png --size 1600x1000 ``` -The tests check catalog coverage, placeholders, switching language by menu and control channel, -saving, the `ka` locale tags, dates, count wording, the absence of Mtavruli and that sentence case -keeps Georgian lowercase. More notes are in +The tests check placeholders, switching language by menu and control channel, saving, the `ka` +locale tags, dates and that sentence case keeps Georgian lowercase. Messages Georgian lacks show in +English; `i18n::tests::translation_gaps_report` lists them. More notes are in [localization.md](localization.md). diff --git a/docs/localization-vi.md b/docs/localization-vi.md index 2bd6c74e0..02862bc4c 100644 --- a/docs/localization-vi.md +++ b/docs/localization-vi.md @@ -66,8 +66,9 @@ LIGHTCRAFT_LANGUAGE=vi lightcraft-cli snapshot --demo -o vi.png --size 1600x1000 LIGHTCRAFT_LANGUAGE=vi lightcraft-cli snapshot --demo -o vi-narrow.png --size 1280x800 ``` -Các test tiếng Việt bắt buộc phủ catalog union, nhãn lệnh/control/Settings/Smart Album, -đổi ngôn ngữ, lưu `vi`, formats, ngày, dữ liệu người dùng, và glyph dấu. +Các test tiếng Việt kiểm tra đổi ngôn ngữ, lưu `vi`, formats, ngày, dữ liệu người dùng và glyph +dấu. Chuỗi còn thiếu hiện bằng tiếng Anh và được `i18n::tests::translation_gaps_report` liệt kê; +không test nào thất bại vì thiếu bản dịch. ## Nguồn dịch (clean-room) diff --git a/docs/localization-zh-hans.md b/docs/localization-zh-hans.md index e87431582..bf41dfed5 100644 --- a/docs/localization-zh-hans.md +++ b/docs/localization-zh-hans.md @@ -34,5 +34,6 @@ LightCraft 的界面支持英语、简体中文、繁体中文(台湾)和日 `LIGHTCRAFT_LANGUAGE=zh-hans lightcraft-cli snapshot ...` 可以渲染中文界面用于检查。 -翻译是否完整、两种目录的键与占位符是否一致、字形是否齐全,都由 +两种目录的占位符是否一致、字形是否齐全,由 `cargo test -p lightcraft-ui-egui i18n::tests` 验证(字形检查只在指定 `CRAFT_FONTS_DIR` 时实际执行)。 +缺少的译文显示为英文,由 `i18n::tests::translation_gaps_report` 列出,不会让测试失败。 diff --git a/docs/localization.md b/docs/localization.md index 94abfe236..5b4cbc15d 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -12,11 +12,10 @@ are in [`localization-zh-hans.md`](localization-zh-hans.md), [`localization-zh-h 1. **Translate the two catalogs.** Copy `locales/ja.json` and `locales/ja-formats.json` to `locales/.json` and `locales/-formats.json`, then translate the values. Keys are the - English source strings and must stay byte-for-byte identical. The plain catalog may grow at its - own pace: a message it lacks shows in English, and `cargo test -p lightcraft-ui-egui i18n::tests - -- --nocapture` lists the gaps (it fails on an empty entry or mismatched placeholders). The - formats catalog must carry every message (the build fails otherwise; a message copied in English, - value equal to its key, is an untranslated placeholder until someone translates it), including the date + English source strings and must stay byte-for-byte identical. Both catalogs may grow at their + own pace: a message a catalog lacks, or translates as an empty string, shows in English, and + nothing fails. The translation gaps report lists what each language lacks (see + [Testing and looking at it](#testing-and-looking-at-it)). The formats catalog also holds the date patterns (`{year}`, `{month} {year}`, `{weekday}, {day} {month} {year}`…) that date headings and capture times use in every language but English; weekday names go in the plain catalog. 2. **Add the table entry** in `crates/ui-egui/src/i18n.rs`: @@ -81,18 +80,23 @@ tr_format!("Added {n} photo{} to “{}”", if n == 1 { "" } else { "s" }, album `"已将 {n} 张照片添加到“{1}”{0:.0}"`. Keep the source's spec (`{:.1}`, `{d:+}`, `{:.0}%`). - `format!` rejects an unused argument, so a value a language does not need — the English plural suffix (`"s"` / `""`) — is consumed as `{:.0}` (a string at precision 0 prints nothing). -- Every formats catalog carries the same messages (`build.rs` fails the build otherwise), and a - `tr_format!` call whose message is in no catalog does not compile: add it to every - `*-formats.json` file. +- A language whose formats catalog lacks a message shows the English source there, and a + `tr_format!` call whose message is in no catalog formats the English source (the macro's last + arm). A new message builds with no catalog entry. ## Testing and looking at it ```sh -cargo test -p lightcraft-ui-egui i18n::tests # catalogs, keys, placeholders, commands +cargo test -p lightcraft-ui-egui i18n::tests # catalogs load, placeholders, switching CRAFT_FONTS_DIR=../craft-fonts cargo test -p lightcraft-ui-egui i18n::tests # + glyph coverage +cargo test -p lightcraft-ui-egui --lib -- --exact i18n::tests::translation_gaps_report --nocapture ``` -The glyph tests are skipped without `CRAFT_FONTS_DIR` (there are no CJK faces to check then). +The tests fail on a broken catalog (malformed JSON, a repeated key, placeholders that differ from +the source), never on a missing translation. The last command is the translation gaps report: per +language, the count and list of plain and format messages it lacks and the number of untranslated +What's New lines. It always passes, and `cargo xtask ci` prints it after its test step. The glyph +tests are skipped without `CRAFT_FONTS_DIR` (there are no CJK faces to check then). To look at a language, render it headless: ```sh @@ -114,5 +118,5 @@ when a region has no dedicated entry. Help ▸ What's New shows `docs/whats-new.md`, and each of its lines (a heading, or one wrapped line of a bullet, without the `- ` or `### `) is looked up like any message, so a catalog can translate the notes line by line (German does). A line that is re-wrapped or edited no longer matches its translation and -shows in English until the catalog is updated; untranslated lines are reported by -`display_label_gaps_are_reported`, never failed. +shows in English until the catalog is updated; the translation gaps report counts the untranslated +lines. diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 7ce3cb423..e9a9716ce 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -37,7 +37,8 @@ commands: ci [--hardware] [--summary-md] fmt --check, clippy -D warnings, heif, test, GPU probe, parity refs, layers, assets, wasm (stops at first failure). Tests run under cargo test (cargo nextest with - LIGHTCRAFT_NEXTEST=1), without examples, then doctests with cargo test --doc. Writes + LIGHTCRAFT_NEXTEST=1), without examples, then doctests with cargo test --doc; after + them it prints the translation gaps report (never a failure). Writes target/ci/report.json (machine, GPU adapters, data, steps, skipped tests) and target/ci/summary.md; --hardware also runs the GPU tests on every backend with a hardware adapter; --summary-md prints the Markdown summary @@ -238,6 +239,39 @@ fn doctests() -> (Command, String) { (c, "cargo test --workspace --doc".to_owned()) } +/// The test that prints the messages each language lacks (shown in English); it always passes. +const TRANSLATION_GAPS_TEST: &str = "i18n::tests::translation_gaps_report"; + +/// Print the translation gaps report into the log: [`TRANSLATION_GAPS_TEST`] of lightcraft-ui-egui, +/// run from the test binary the test step built (found with `--no-run` and the step's own +/// arguments, so nothing rebuilds). A report that can't run is a note, never a failed step. +fn translation_gaps() { + eprintln!("\n=== ci: translation gaps ==="); + let mut c = cargo(); + c.args(["test", "--no-run", "--message-format=json"]).args(with_test_targets(&["--workspace"])); + let binary = c.output().map_err(|e| format!("cargo test --no-run: {e}")).and_then(|out| { + String::from_utf8_lossy(&out.stdout) + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .find(|m| { + m["reason"] == "compiler-artifact" + && m["target"]["name"] == "lightcraft_ui_egui" + && m["profile"]["test"] == true + && m["target"]["kind"].as_array().is_some_and(|kinds| kinds.iter().any(|kind| kind == "lib")) + }) + .and_then(|m| m["executable"].as_str().map(PathBuf::from)) + .ok_or_else(|| format!("no lightcraft-ui-egui test binary in the build (cargo exited with {})", out.status)) + }); + let ran = binary.and_then(|binary| { + let mut c = Command::new(&binary); + c.args(["--exact", TRANSLATION_GAPS_TEST, "--nocapture", "--quiet"]); + run(c, &format!("{} --exact {TRANSLATION_GAPS_TEST} --nocapture --quiet", binary.display())) + }); + if let Err(e) = ran { + eprintln!("note: the translation gaps report did not run: {e}"); + } +} + pub fn run(mut cmd: Command, what: &str) -> Result<(), String> { eprintln!("$ {what}"); let status = cmd.status().map_err(|e| format!("{what}: failed to spawn: {e}"))?; @@ -431,6 +465,7 @@ fn cmd_ci(args: &[&str]) -> Result<(), String> { if *name == "test" { // right after the workspace tests, before parity and wasm build other feature sets probe(); + translation_gaps(); } if let Err(e) = r { failure = Some((*name, e));