From 93a845ce4144f3986c5da824a8b2d745a4a19e92 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:39:16 -0400 Subject: [PATCH] i18n: missing translations never fail; xtask ci prints the missing list (#3012) --- crates/ui-egui/README.md | 12 +- crates/ui-egui/src/i18n/mod.rs | 523 ++++-------------- crates/ui-egui/src/panels/shortcuts_dialog.rs | 14 +- crates/ui-egui/tests/it/graphics_notice_ui.rs | 8 +- crates/ui-egui/tests/it/scripted.rs | 5 +- docs/localization-parity.md | 12 +- xtask/src/main.rs | 22 +- 7 files changed, 163 insertions(+), 433 deletions(-) diff --git a/crates/ui-egui/README.md b/crates/ui-egui/README.md index c02b32ebf..75fbe94e1 100644 --- a/crates/ui-egui/README.md +++ b/crates/ui-egui/README.md @@ -38,9 +38,15 @@ translated label and its English source, including Unicode capitals. Project con and preference values retain their original values. Engine errors, CLI and MCP messages remain English. Ukrainian and Russian currently cover the core menus; they fall back to English elsewhere. -Verification: `cargo test -p filmcraft-ui-egui` checks catalog syntax, duplicate keys, placeholders, -literal/menu/registry coverage and UI behaviour; `cargo xtask ci` runs the workspace gates. Visual -checks use the control channel to switch language and capture the resulting panels. +A string a catalog lacks shows in English, so a missing translation never fails a test or a +build. `cargo xtask i18n-report` prints each language's missing strings (menus, `tl!` literals and +registry names) and its entries whose source the interface no longer has; `cargo xtask ci` prints +the same report in its log. + +Verification: `cargo test -p filmcraft-ui-egui` checks catalog syntax, duplicate keys, placeholders +and UI behaviour in other languages (switching, search, font coverage); `cargo xtask ci` runs the +workspace gates. Visual checks use the control channel to switch language and capture the resulting +panels. ## Source playback diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 1e2d09ba6..80f9dfe01 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -6,8 +6,8 @@ //! //! # Looking strings up //! - `tl!` with a string literal: that UI string in the language the UI is drawn in (see -//! [`set_current`]). Use it for every label, button, heading, tooltip and hint; the tests check -//! that each `tl!` literal has a Spanish translation. +//! [`set_current`]). Use it for every label, button, heading, tooltip and hint; `cargo xtask +//! i18n-report` lists the `tl!` literals each catalog lacks. //! - [`t`]: the same for a string that is not a literal (a name from a registry or a list). //! - [`tr_ctx`]: when one English word needs different translations (the Edit menu, the Edit button). //! - [`fmt`]: fill `{name}` placeholders after translating a template; translators may reorder them. @@ -340,21 +340,29 @@ pub fn korean_font_available() -> bool { mod tests { use super::*; + /// The catalog file of a language (none for English, the source language). + fn catalog_text(l: Language) -> Option<&'static str> { + match l { + Language::En => None, + Language::Ja => Some(include_str!("ja.tsv")), + Language::Es => Some(include_str!("es.tsv")), + Language::PtBr => Some(include_str!("pt-br.tsv")), + Language::Uk => Some(include_str!("uk.tsv")), + Language::ZhCn => Some(include_str!("zh-cn.tsv")), + Language::De => Some(include_str!("de.tsv")), + Language::Ru => Some(include_str!("ru.tsv")), + Language::Fr => Some(include_str!("fr.tsv")), + Language::Id => Some(include_str!("id.tsv")), + Language::It => Some(include_str!("it.tsv")), + Language::Ko => Some(include_str!("ko.tsv")), + } + } + #[test] fn catalogs_are_well_formed() { - for (code, text) in [ - ("es", include_str!("es.tsv")), - ("ja", include_str!("ja.tsv")), - ("pt-br", include_str!("pt-br.tsv")), - ("uk", include_str!("uk.tsv")), - ("zh-cn", include_str!("zh-cn.tsv")), - ("de", include_str!("de.tsv")), - ("ru", include_str!("ru.tsv")), - ("fr", include_str!("fr.tsv")), - ("id", include_str!("id.tsv")), - ("it", include_str!("it.tsv")), - ("ko", include_str!("ko.tsv")), - ] { + for l in Language::ALL { + let Some(text) = catalog_text(l) else { continue }; + let code = l.code(); let (entries, errors) = catalog::parse_entries(text); assert!(errors.is_empty(), "{code}: {errors:?}"); for (i, (ctx, en, tr)) in entries.iter().enumerate() { @@ -366,73 +374,54 @@ mod tests { assert_eq!(en.ends_with('…'), tr.ends_with('…'), "{code}: ellipsis differs in {en:?} → {tr:?}"); } } - assert_eq!(Language::Es.tr("File"), "Archivo"); - assert_eq!(Language::Ja.tr("File"), "ファイル"); - assert_eq!(Language::En.tr("File"), "File"); - assert_eq!(Language::Es.tr("mi video.mp4"), "mi video.mp4"); - assert_eq!(Language::Ja.tr("日本語の文書.pdf"), "日本語の文書.pdf"); - assert_eq!(Language::parse("es"), Some(Language::Es)); - assert_eq!(Language::parse("xx"), None); - assert_eq!(Language::PtBr.tr("File"), "Arquivo"); - assert_eq!(Language::PtBr.tr("meu video.mp4"), "meu video.mp4"); - assert_eq!(Language::PtBr.name(), "Português (Brasil)"); - assert_eq!(Language::Uk.name(), "Українська"); - assert_eq!(Language::Uk.tr("File"), "Файл"); - assert_eq!(Language::Uk.tr("мій кліп.mp4"), "мій кліп.mp4"); - assert_eq!(Language::Uk.tr("An untranslated label"), "An untranslated label"); - assert_eq!(Language::ZhCn.name(), "简体中文"); - assert_eq!(Language::ZhCn.tr("File"), "文件"); - assert_eq!(Language::ZhCn.tr("我的视频.mp4"), "我的视频.mp4"); - assert_eq!(Language::De.tr("File"), "Datei"); - assert_eq!(Language::De.tr("Save As…"), "Speichern unter…"); - assert_eq!(Language::De.tr("Größe ändern.mp4"), "Größe ändern.mp4"); - assert_eq!(Language::De.tr("An untranslated label"), "An untranslated label"); - assert_eq!(Language::De.name(), "Deutsch"); - assert_eq!(Language::parse("de"), Some(Language::De)); - assert_eq!(Language::parse("de-DE"), None, "the preference stores the bare code"); - assert_eq!(Language::Ru.name(), "Русский"); - assert_eq!(Language::Ru.tr("File"), "Файл"); - assert_eq!(Language::Ru.tr("мой клип.mp4"), "мой клип.mp4"); - assert_eq!(Language::Ru.tr("An untranslated label"), "An untranslated label"); - assert_eq!(Language::Fr.name(), "Français"); - assert_eq!(Language::Fr.tr("File"), "Fichier"); - assert_eq!(Language::Fr.tr("Save As…"), "Enregistrer sous…"); - assert_eq!(Language::Fr.tr("Montage d'été.mp4"), "Montage d'été.mp4"); - assert_eq!(Language::Fr.tr("An untranslated label"), "An untranslated label"); - assert_eq!(Language::parse("fr"), Some(Language::Fr)); - assert_eq!(Language::parse("fr-FR"), None, "the preference stores the bare code"); - assert_eq!(Language::Id.name(), "Bahasa Indonesia"); - assert_eq!(Language::Id.tr("File"), "Berkas"); - assert_eq!(Language::Id.tr("Save As…"), "Simpan Sebagai…"); - assert_eq!(Language::Id.tr("Liburan keluarga.mp4"), "Liburan keluarga.mp4"); - assert_eq!(Language::Id.tr("An untranslated label"), "An untranslated label"); - assert_eq!(Language::parse("id"), Some(Language::Id)); - assert_eq!(Language::parse("id-ID"), None, "the preference stores the bare code"); - assert_eq!(Language::It.name(), "Italiano"); - assert_eq!(Language::It.tr("File"), "File"); - assert_eq!(Language::It.tr("Save As…"), "Salva con nome…"); - assert_eq!(Language::It.tr("Vacanze d'estate.mp4"), "Vacanze d'estate.mp4"); - assert_eq!(Language::It.tr("An untranslated label"), "An untranslated label"); - assert_eq!(Language::parse("it"), Some(Language::It)); - assert_eq!(Language::parse("it-IT"), None, "the preference stores the bare code"); + } + + #[test] + fn unknown_strings_pass_through_and_languages_round_trip() { for l in Language::ALL { + for text in ["An untranslated label", "mi video.mp4", "日本語の文書.pdf", "Größe ändern.mp4", "Montage d'été.mp4"] { + assert_eq!(l.tr(text), text, "{}", l.code()); + } assert_eq!(Language::parse(l.code()), Some(l)); let json = serde_json::to_string(&l).unwrap(); assert_eq!(json, format!("\"{}\"", l.code())); assert_eq!(serde_json::from_str::(&json).unwrap(), l); } + assert_eq!(Language::En.tr("File"), "File"); + assert_eq!(Language::parse("xx"), None); + for code in ["de-DE", "fr-FR", "id-ID", "it-IT"] { + assert_eq!(Language::parse(code), None, "the preference stores the bare code"); + } + // language names are always shown in their own language + for (l, name) in [ + (Language::PtBr, "Português (Brasil)"), + (Language::Uk, "Українська"), + (Language::ZhCn, "简体中文"), + (Language::De, "Deutsch"), + (Language::Ru, "Русский"), + (Language::Fr, "Français"), + (Language::Id, "Bahasa Indonesia"), + (Language::It, "Italiano"), + (Language::Ko, "한국어"), + ] { + assert_eq!(l.name(), name); + } assert_eq!(fmt("{b} y {a}", &[("a", "1"), ("b", "2")]), "2 y 1"); assert_eq!(fmt("{a} {zz}", &[("a", "1")]), "1 {zz}"); } #[test] fn the_current_language_is_per_thread() { + // any entry the Spanish catalog has + let (entries, _) = catalog::parse_entries(include_str!("es.tsv")); + let Some((_, en, es)) = entries.into_iter().find(|(ctx, en, es)| ctx.is_empty() && en != es) else { return }; set_current(Language::Es); - assert_eq!((current(), t("File")), (Language::Es, "Archivo")); + assert_eq!((current(), t(&en)), (Language::Es, es.as_str())); // another thread (another test) still draws in English - assert_eq!(std::thread::spawn(|| t("File").to_string()).join().ok().as_deref(), Some("File")); + let source = en.clone(); + assert_eq!(std::thread::spawn(move || t(&source).to_string()).join().ok(), Some(en.clone())); set_current(Language::En); - assert_eq!(t("File"), "File"); + assert_eq!(t(&en), en); } #[test] @@ -442,170 +431,75 @@ mod tests { assert_eq!(fmt("{missing} y {unfinished", &[]), "{missing} y {unfinished"); } + /// A search for a language's label finds the item, and so does its English name. #[test] - fn searches_match_spanish_labels_and_english_names() { - set_current(Language::Es); - assert!(matches_query("Gaussian Blur", "desenfoque")); - assert!(matches_query("Gaussian Blur", "gaussian")); - assert!(matches_query("Selection Tool", &"SELECCIÓN".to_lowercase())); - assert!(!matches_query("Gaussian Blur", "selección")); + fn searches_match_translated_labels_and_english_names() { + for l in Language::ALL { + set_current(l); + let blur = l.tr("Gaussian Blur").to_lowercase(); + let selection = l.tr("Selection Tool").to_lowercase(); + assert!(matches_query("Gaussian Blur", &blur), "{}: {blur}", l.code()); + assert!(matches_query("Gaussian Blur", "gaussian"), "{}", l.code()); + assert!(matches_query("Selection Tool", &selection), "{}: {selection}", l.code()); + assert!(!matches_query("Gaussian Blur", &selection), "{}: {selection}", l.code()); + } set_current(Language::En); } + /// `tl!` and `tlf!` take plain string literals: the catalog scan (`tl_literals`) reads no other kind. #[test] - fn spanish_translates_every_menu_label() { - let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); - let mut missing: Vec = Vec::new(); - let mut need = |text: &str| { - if translatable(text) && !Language::Es.has(text) && !missing.iter().any(|m| m == text) { - missing.push(text.to_string()); - } - }; - for top in crate::menus::MENUS { - need(top); - } - for it in crate::menus::menu_items(&app) { - need(&it.label); - for p in &it.path { - need(p); - } - } - missing.sort(); - assert!(missing.is_empty(), "untranslated menu labels: {missing:#?}"); - } - - /// The core menus every other catalog must cover: the entries of the menus-only Ukrainian - /// catalog, which `ukrainian_entries_cover_the_original_menu_catalog` keeps to menu labels (and - /// the native macOS Settings menu). Brazilian Portuguese played this role while it was - /// menus-only. - fn core_menu_labels() -> Vec { - let (entries, _) = catalog::parse_entries(include_str!("uk.tsv")); - entries.into_iter().map(|(_, en, _)| en).collect() - } - - /// Brazilian Portuguese covers the whole interface (the Spanish catalog's entries and the native - /// macOS Settings menu), but, like Japanese and French, the tests require only the menus, so - /// strings added later show in English until they are translated. Every entry must still have a - /// Spanish counterpart: a renamed or removed source would otherwise leave a dead entry. - #[test] - fn portuguese_covers_the_menus_and_has_no_dead_entries() { - let (entries, errors) = catalog::parse_entries(include_str!("pt-br.tsv")); - assert!(errors.is_empty(), "{errors:?}"); - for (ctx, en, _) in &entries { - assert!(ctx.is_empty() && (Language::Es.has(en) || en == "Settings"), "Portuguese entry without a Spanish one: {ctx:?} {en:?}"); - } - let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); - let mut missing: Vec = Vec::new(); - let mut need = |text: &str| { - if translatable(text) && Language::Es.has(text) && !Language::PtBr.has(text) && !missing.iter().any(|m| m == text) { - missing.push(text.to_string()); - } - }; - for top in crate::menus::MENUS.into_iter().chain(["Audio", "Settings"]) { - need(top); - } - for it in crate::menus::menu_items(&app) { - need(&it.label); - for p in &it.path { - need(p); - } - } - for en in core_menu_labels() { - need(&en); - } - missing.sort(); - assert!(missing.is_empty(), "untranslated Portuguese menu labels: {missing:#?}"); - // beyond the menus: panels, effects and their parameters, settings, status messages - for (en, pt) in [ - ("File", "Arquivo"), - ("Settings", "Preferências"), - ("Gaussian Blur", "Desfoque gaussiano"), - ("Audio Track Mixer", "Misturador de faixas de áudio"), - ("Keyboard Shortcuts", "Atalhos de teclado"), - ("Playhead", "Cursor de reprodução"), - ] { - assert_eq!(Language::PtBr.tr(en), pt); - } - set_current(Language::PtBr); - assert!(matches_query("Gaussian Blur", "desfoque")); - assert!(matches_query("Gaussian Blur", "gaussian")); - set_current(Language::En); - } - - /// Every `tl!`/`tlf!` literal outside test modules has a Spanish translation. - #[test] - fn spanish_translates_every_tl_literal() { - let mut missing = Vec::new(); + fn tl_literals_are_plain_strings() { for (path, text) in sources() { - for lit in tl_literals(&text) { - if !Language::Es.has(&lit) && !missing.contains(&lit) { - missing.push(lit); - } - } assert!(!text.contains("tl!(r") && !text.contains("tlf!(r"), "{path}: use a plain string literal in tl! and tlf!"); } - missing.sort(); - assert!(missing.is_empty(), "untranslated tl! strings: {missing:#?}"); } - /// The Simplified Chinese catalog is complete: every menu label and every `tl!` literal. - #[test] - fn chinese_translates_every_menu_label_and_tl_literal() { + /// Every string the interface translates: menu labels and paths (the native macOS app menu + /// included), `tl!`/`tlf!` literals, and names translated through `i18n::t`. + fn interface_strings() -> Vec { let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); - let mut missing: Vec = Vec::new(); - let mut need = |text: &str| { - if translatable(text) && !Language::ZhCn.has(text) && !missing.iter().any(|m| m == text) { - missing.push(text.to_string()); - } - }; - for top in crate::menus::MENUS { - need(top); + let mut out = Vec::new(); + for top in crate::menus::MENUS.into_iter().chain(["Audio"]).chain(crate::menus::NATIVE_APP_MENU) { + push(&mut out, top); } for it in crate::menus::menu_items(&app) { - need(&it.label); - for p in &it.path { - need(p); - } + push(&mut out, &it.label); + it.path.iter().for_each(|p| push(&mut out, p)); } for (_, text) in sources() { - for lit in tl_literals(&text) { - need(&lit); - } + tl_literals(&text).iter().for_each(|lit| push(&mut out, lit)); } - missing.sort(); - assert!(missing.is_empty(), "untranslated Chinese strings: {missing:#?}"); + dynamic_names().iter().for_each(|n| push(&mut out, n)); + out } - /// The Japanese catalog is complete: every menu label, every `tl!` literal and every name the - /// interface translates through `i18n::t`. + /// The missing-translations report. It always passes: a string a catalog lacks shows in + /// English. `cargo xtask ci` runs it with `--nocapture` so the list shows in the gate log + /// (`cargo xtask i18n-report` runs it alone). Per language it lists the interface strings the + /// catalog lacks, then the catalog entries whose source the interface no longer has (renamed + /// or removed strings, or names this scan doesn't enumerate). #[test] - fn japanese_translates_every_menu_label_tl_literal_and_name() { - let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); - let mut missing: Vec = Vec::new(); - let mut need = |text: &str| { - if translatable(text) && !Language::Ja.has(text) && !missing.iter().any(|m| m == text) { - missing.push(text.to_string()); + fn missing_translations_report() { + let strings = interface_strings(); + println!("Missing translations ({} interface strings; each shows in English):", strings.len()); + for l in Language::ALL { + let Some(text) = catalog_text(l) else { continue }; + let code = l.code(); + let mut missing: Vec<&str> = strings.iter().map(String::as_str).filter(|s| !l.has(s)).collect(); + missing.sort_unstable(); + println!("{code}: {} missing", missing.len()); + for s in missing { + println!(" {code}: {s:?}"); } - }; - for top in crate::menus::MENUS { - need(top); - } - for it in crate::menus::menu_items(&app) { - need(&it.label); - for p in &it.path { - need(p); + let (entries, _) = catalog::parse_entries(text); + let unused: Vec<_> = entries.iter().filter(|(_, en, _)| translatable(en) && !strings.contains(en)).collect(); + if !unused.is_empty() { + println!("{code}: {} entries without a source", unused.len()); + for (ctx, en, _) in unused { + println!(" {code}: {ctx:?} {en:?}"); + } } } - for (_, text) in sources() { - for lit in tl_literals(&text) { - need(&lit); - } - } - for name in dynamic_names() { - need(&name); - } - missing.sort(); - assert!(missing.is_empty(), "untranslated Japanese strings: {missing:#?}"); } /// Chinese switches and persists like the other languages when a Chinese face is available, @@ -873,13 +767,6 @@ mod tests { out } - #[test] - fn spanish_translates_dynamic_names() { - let mut missing: Vec = dynamic_names().into_iter().filter(|n| !Language::Es.has(n)).collect(); - missing.sort(); - assert!(missing.is_empty(), "untranslated names: {missing:#?}"); - } - #[test] fn tl_literals_are_found_and_unescaped() { let src = "a(tl!(\"Save\")); b(tl!(\"Say \\\"hi\\\"\\n\")); tl!(x); tlf!(\"{n} file(s)\", n);"; @@ -934,7 +821,7 @@ mod tests { // the menus are drawn in German, the language names in their own language let items = crate::menus::menu_items(&app); let label = |id: &str| items.iter().find(|it| it.id == id).map(|it| it.label.as_str()); - assert_eq!(label("file.saveAs"), Some("Speichern unter…")); + assert_eq!(label("file.saveAs"), Some(Language::De.tr("Save As…"))); assert_eq!(label("app.language.spanish"), Some("Español")); let saved = serde_json::to_string(&app.ui).unwrap(); assert!(saved.contains("\"language\":\"de\""), "{saved}"); @@ -953,7 +840,7 @@ mod tests { } let items = crate::menus::menu_items(&app); let label = |id: &str| items.iter().find(|it| it.id == id).map(|it| it.label.as_str()); - assert_eq!(label("file.saveAs"), Some("Enregistrer sous…")); + assert_eq!(label("file.saveAs"), Some(Language::Fr.tr("Save As…"))); assert_eq!(label("app.language.german"), Some("Deutsch")); let saved = serde_json::to_string(&app.ui).unwrap(); assert!(saved.contains("\"language\":\"fr\""), "{saved}"); @@ -970,7 +857,7 @@ mod tests { } let items = crate::menus::menu_items(&app); let label = |id: &str| items.iter().find(|it| it.id == id).map(|it| it.label.as_str()); - assert_eq!(label("file.saveAs"), Some("Simpan Sebagai…")); + assert_eq!(label("file.saveAs"), Some(Language::Id.tr("Save As…"))); assert_eq!(label("app.language.french"), Some("Français")); assert_eq!(label("app.language.indonesian"), Some("Bahasa Indonesia")); let saved = serde_json::to_string(&app.ui).unwrap(); @@ -988,7 +875,7 @@ mod tests { } let items = crate::menus::menu_items(&app); let label = |id: &str| items.iter().find(|it| it.id == id).map(|it| it.label.as_str()); - assert_eq!(label("file.saveAs"), Some("Salva con nome…")); + assert_eq!(label("file.saveAs"), Some(Language::It.tr("Save As…"))); assert_eq!(label("app.language.indonesian"), Some("Bahasa Indonesia")); assert_eq!(label("app.language.italian"), Some("Italiano")); let saved = serde_json::to_string(&app.ui).unwrap(); @@ -1039,6 +926,9 @@ mod tests { assert_eq!(l(&[tag]), Language::It, "{tag}"); } assert_eq!(l(&["it-CH", "de-CH", "fr-CH", "en"]), Language::It); + for tag in ["ko", "ko-KR", "ko_KR.UTF-8"] { + assert_eq!(l(&[tag]), Language::Ko, "{tag}"); + } for none in [&["ita"][..], &["i"], &["-it"]] { assert_eq!(l(none), Language::En, "{none:?}"); } @@ -1089,199 +979,6 @@ mod tests { set_current(Language::En); } - #[test] - fn ukrainian_entries_cover_the_original_menu_catalog() { - let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); - let items = crate::menus::menu_items(&app); - let known = |text: &str| crate::menus::MENUS.contains(&text) || items.iter().any(|it| it.label == text || it.path.iter().any(|p| p == text)); - let (entries, _) = catalog::parse_entries(include_str!("uk.tsv")); - for (_, en, _) in entries { - assert!(known(&en) || en == "Settings", "not a menu label: {en}"); - } - for en in crate::menus::MENUS.into_iter().chain(["Audio", "Settings"]) { - assert_ne!(Language::Uk.tr(en), en, "untranslated Ukrainian menu: {en}"); - } - } - - /// German covers the whole interface (every entry of the Spanish catalog, the native macOS app - /// menu included), but, like French, the tests require only the core menus and the native app - /// menu, so strings added later show in English until they are translated. Every entry must - /// still have a Spanish counterpart: a renamed or removed source would otherwise leave a dead - /// German entry. - #[test] - fn german_covers_the_menus_and_has_no_dead_entries() { - let (entries, errors) = catalog::parse_entries(include_str!("de.tsv")); - assert!(errors.is_empty(), "{errors:?}"); - for (ctx, en, _) in &entries { - assert!(ctx.is_empty() && Language::Es.has(en), "German entry without a Spanish one: {ctx:?} {en:?}"); - } - for en in core_menu_labels() { - assert!(Language::De.has(&en), "missing German menu label: {en}"); - } - // "Clip" and "Audio" are the same word in German, so check for an entry, not a change - for en in crate::menus::MENUS.into_iter().chain(["Audio"]).chain(crate::menus::NATIVE_APP_MENU) { - assert!(Language::De.has(en), "untranslated German menu: {en}"); - } - assert_eq!(Language::De.tr("Settings"), "Voreinstellungen"); - assert_eq!(Language::De.tr("Quit FilmCraft"), "FilmCraft beenden"); - // beyond the menus: panels, effects and their parameters, settings, status messages - for (en, de) in [ - ("Gaussian Blur", "Gaußscher Weichzeichner"), - ("Audio Track Mixer", "Audiospur-Mischpult"), - ("Sequence", "Sequenz"), - ("Playhead", "Abspielposition"), - ("Keyboard Shortcuts", "Tastenkürzel"), - ("{n} min ago", "vor {n} Min."), - ] { - assert_eq!(Language::De.tr(en), de); - } - set_current(Language::De); - assert!(matches_query("Gaussian Blur", "weichzeichner")); - assert!(matches_query("Gaussian Blur", "gaussian")); - set_current(Language::En); - } - - /// French covers the whole interface (the Spanish catalog's entries and the native macOS - /// Settings menu), but, like Japanese, the tests require only the core menus, so strings added - /// later show in English until they are translated. Every entry must still have a Spanish - /// counterpart: a renamed or removed source would otherwise leave a dead French entry. - #[test] - fn french_covers_the_menus_and_has_no_dead_entries() { - let (entries, errors) = catalog::parse_entries(include_str!("fr.tsv")); - assert!(errors.is_empty(), "{errors:?}"); - for (ctx, en, _) in &entries { - assert!(ctx.is_empty() && (Language::Es.has(en) || en == "Settings"), "French entry without a Spanish one: {ctx:?} {en:?}"); - } - for en in core_menu_labels() { - assert!(Language::Fr.has(&en), "missing French menu label: {en}"); - } - for en in crate::menus::MENUS.into_iter().chain(["Audio", "Settings"]) { - assert!(Language::Fr.has(en), "untranslated French menu: {en}"); - } - // beyond the menus: panels, effects and their parameters, settings, status messages - for (en, fr) in - [("Settings", "Préférences"), ("Gaussian Blur", "Flou gaussien"), ("Audio Track Mixer", "Mixeur de pistes audio"), ("Sequence", "Séquence")] - { - assert_eq!(Language::Fr.tr(en), fr); - } - set_current(Language::Fr); - assert!(matches_query("Gaussian Blur", "flou")); - assert!(matches_query("Gaussian Blur", "gaussian")); - set_current(Language::En); - } - - /// Korean covers the whole interface (the Spanish catalog's entries and the native macOS - /// Settings menu); like French, the tests require the core menus, and every entry must still - /// have a Spanish counterpart so a renamed or removed source can't leave a dead Korean entry. - #[test] - fn korean_covers_the_menus_and_has_no_dead_entries() { - let (entries, errors) = catalog::parse_entries(include_str!("ko.tsv")); - assert!(errors.is_empty(), "{errors:?}"); - for (ctx, en, _) in &entries { - assert!(ctx.is_empty() && (Language::Es.has(en) || en == "Settings"), "Korean entry without a Spanish one: {ctx:?} {en:?}"); - } - for en in core_menu_labels() { - assert!(Language::Ko.has(&en), "missing Korean menu label: {en}"); - } - for en in crate::menus::MENUS.into_iter().chain(["Audio", "Settings"]) { - assert!(Language::Ko.has(en), "untranslated Korean menu: {en}"); - } - for (en, ko) in [("File", "파일"), ("Edit", "편집"), ("Sequence", "시퀀스"), ("Settings", "설정")] { - assert_eq!(Language::Ko.tr(en), ko); - } - assert_eq!(Language::Ko.name(), "한국어"); - assert_eq!(Language::parse("ko"), Some(Language::Ko)); - for tag in ["ko", "ko-KR", "ko_KR.UTF-8"] { - assert_eq!(Language::from_locales(&[tag.to_string()]), Language::Ko, "{tag}"); - } - } - - #[test] - fn russian_entries_cover_the_original_menu_catalog() { - let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); - let items = crate::menus::menu_items(&app); - let known = |text: &str| crate::menus::MENUS.contains(&text) || items.iter().any(|it| it.label == text || it.path.iter().any(|p| p == text)); - let (entries, _) = catalog::parse_entries(include_str!("ru.tsv")); - for (_, en, _) in entries { - assert!(known(&en) || en == "Settings", "not a menu label: {en}"); - } - for en in core_menu_labels() { - assert!(Language::Ru.has(&en), "missing Russian menu label: {en}"); - } - for en in crate::menus::MENUS.into_iter().chain(["Audio", "Settings"]) { - assert_ne!(Language::Ru.tr(en), en, "untranslated Russian menu: {en}"); - } - } - - /// Indonesian covers the whole interface (the Spanish catalog's entries and the native macOS - /// Settings menu), but, like Japanese and French, the tests require only the core menus (the - /// menus-only Ukrainian catalog, the top-level menus and the native macOS Settings menu), so - /// strings added later show in English until they are translated. Every entry must still have a - /// Spanish counterpart: a renamed or removed source would otherwise leave a dead entry. - #[test] - fn indonesian_covers_the_menus_and_has_no_dead_entries() { - let (entries, errors) = catalog::parse_entries(include_str!("id.tsv")); - assert!(errors.is_empty(), "{errors:?}"); - for (ctx, en, _) in &entries { - assert!(ctx.is_empty() && (Language::Es.has(en) || en == "Settings"), "Indonesian entry without a Spanish one: {ctx:?} {en:?}"); - } - let (core, _) = catalog::parse_entries(include_str!("uk.tsv")); - for (_, en, _) in core { - assert!(Language::Id.has(&en), "missing Indonesian menu label: {en}"); - } - for en in crate::menus::MENUS.into_iter().chain(["Audio", "Settings"]) { - assert!(Language::Id.has(en), "untranslated Indonesian menu: {en}"); - } - // beyond the menus: panels, effects and their parameters, settings, status messages - for (en, id) in [ - ("Settings", "Preferensi"), - ("Gaussian Blur", "Buram Gaussian"), - ("Audio Track Mixer", "Mixer Trek Audio"), - ("Sequence", "Sekuens"), - ("Playhead", "Kepala Putar"), - ] { - assert_eq!(Language::Id.tr(en), id); - } - set_current(Language::Id); - assert!(matches_query("Gaussian Blur", "buram")); - assert!(matches_query("Gaussian Blur", "gaussian")); - set_current(Language::En); - } - - /// Italian covers the whole interface (the Spanish catalog's entries and the native macOS - /// Settings menu); like Indonesian, the tests require only the core menus, so strings added - /// later show in English until they are translated, and every entry must have a Spanish - /// counterpart. - #[test] - fn italian_covers_the_menus_and_has_no_dead_entries() { - let (entries, errors) = catalog::parse_entries(include_str!("it.tsv")); - assert!(errors.is_empty(), "{errors:?}"); - for (ctx, en, _) in &entries { - assert!(ctx.is_empty() && (Language::Es.has(en) || en == "Settings"), "Italian entry without a Spanish one: {ctx:?} {en:?}"); - } - let (core, _) = catalog::parse_entries(include_str!("uk.tsv")); - for (_, en, _) in core { - assert!(Language::It.has(&en), "missing Italian menu label: {en}"); - } - for en in crate::menus::MENUS.into_iter().chain(["Audio", "Settings"]) { - assert!(Language::It.has(en), "untranslated Italian menu: {en}"); - } - // beyond the menus: panels, effects and their parameters, settings, status messages - for (en, it) in [ - ("Settings", "Impostazioni"), - ("Gaussian Blur", "Sfocatura gaussiana"), - ("Audio Track Mixer", "Mixer tracce audio"), - ("Sequence", "Sequenza"), - ("Playhead", "Indicatore di riproduzione"), - ] { - assert_eq!(Language::It.tr(en), it); - } - set_current(Language::It); - assert!(matches_query("Gaussian Blur", "sfocatura")); - assert!(matches_query("Gaussian Blur", "gaussian")); - set_current(Language::En); - } - /// Every non-ASCII character of `text` renders with the bundled interface fonts, in every family. fn assert_bundled_fonts_cover(text: impl Iterator) { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/panels/shortcuts_dialog.rs b/crates/ui-egui/src/panels/shortcuts_dialog.rs index db5fab592..62c50ddf3 100644 --- a/crates/ui-egui/src/panels/shortcuts_dialog.rs +++ b/crates/ui-egui/src/panels/shortcuts_dialog.rs @@ -822,13 +822,17 @@ mod localization_tests { use super::*; #[test] - fn command_search_accepts_spanish_labels_ids_and_keys() { - crate::i18n::set_current(crate::i18n::Language::Es); + fn command_search_accepts_translated_labels_ids_and_keys() { let row = json!({"id": "edit.undo", "label": "Undo", "category": "Edit", "shortcuts": [{"keys": "Cmd+Z", "display": "Ctrl+Z"}]}); - for query in ["deshacer", "undo", "edit.undo", "ctrl+z", "cmd+z"] { - assert!(matches_search(&row, query), "{query}"); + for l in crate::i18n::Language::ALL { + crate::i18n::set_current(l); + let undo = l.tr("Undo").to_lowercase(); + for query in [undo.as_str(), "undo", "edit.undo", "ctrl+z", "cmd+z"] { + assert!(matches_search(&row, query), "{}: {query}", l.code()); + } + let redo = l.tr("Redo").to_lowercase(); + assert!(!matches_search(&row, &redo), "{}: {redo}", l.code()); } - assert!(!matches_search(&row, "rehacer")); crate::i18n::set_current(crate::i18n::Language::En); } } diff --git a/crates/ui-egui/tests/it/graphics_notice_ui.rs b/crates/ui-egui/tests/it/graphics_notice_ui.rs index bd743ef17..e8805dc58 100644 --- a/crates/ui-egui/tests/it/graphics_notice_ui.rs +++ b/crates/ui-egui/tests/it/graphics_notice_ui.rs @@ -115,9 +115,11 @@ fn a_reset_that_cannot_be_saved_says_why_and_keeps_the_button() { fn the_notice_follows_the_interface_language() { let notice = GraphicsNotice::new("software rendering", Box::new(|| Ok(()))); let mut d = Driver::new(Some(notice)); - d.app().ui.language = filmcraft_ui_egui::i18n::Language::Es; + let es = filmcraft_ui_egui::i18n::Language::Es; + d.app().ui.language = es; d.frames(3); - assert_eq!(d.label("graphics.notice"), "Modo gráfico seguro: procesamiento por software"); - assert_eq!(d.label("graphics.notice.reset"), "Usar gráficos predeterminados"); + let title = filmcraft_ui_egui::i18n::fmt(es.tr("Safe graphics mode: {mode}"), &[("mode", es.tr("software rendering"))]); + assert_eq!(d.label("graphics.notice"), title); + assert_eq!(d.label("graphics.notice.reset"), es.tr("Use Default Graphics")); filmcraft_ui_egui::i18n::set_current(filmcraft_ui_egui::i18n::Language::En); } diff --git a/crates/ui-egui/tests/it/scripted.rs b/crates/ui-egui/tests/it/scripted.rs index 0fc6fbcc0..f9e2cc9ec 100644 --- a/crates/ui-egui/tests/it/scripted.rs +++ b/crates/ui-egui/tests/it/scripted.rs @@ -191,10 +191,11 @@ fn spanish_search_finds_effects_and_presets_with_stable_automation_ids() { let mut d = Driver::demo(); d.ok("ui.menu.invoke", json!({"id": "app.language.spanish"})); d.ok("ui.panel.show", json!({"panel": "Effects"})); - d.ok("ui.set", json!({"effectsSearch": "DESENFOQUE"})); + let es = filmcraft_ui_egui::i18n::Language::Es; + d.ok("ui.set", json!({"effectsSearch": es.tr("Gaussian Blur").to_uppercase()})); d.frames(3); assert!(d.element_ids("effects.item.").contains(&"effects.item.gaussian_blur".to_string())); - d.ok("ui.set", json!({"effectsSearch": "ENTRADA CON DESENFOQUE"})); + d.ok("ui.set", json!({"effectsSearch": es.tr("Blur In").to_uppercase()})); d.frames(3); assert!(d.element_ids("effects.preset.").contains(&"effects.preset.Blur In".to_string())); let clip = track_clips(&d.sequence(), 0)[0]["clip"].as_u64().unwrap(); diff --git a/docs/localization-parity.md b/docs/localization-parity.md index d8773582d..53ab82423 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -48,18 +48,19 @@ whole interface, one PR) at ~4–8 h per language for the catalog plus tests. | Arabic | ar | 0 (0%) | — | **no RTL interface layout**; fallback fonts and shaping in the interface, captions in reading order, other text in egui's left-to-right run order; titles broken (#395) | — | none | 40–70 h | | French | fr | 3,526 (same entries as Spanish, plus the macOS Settings item) | as Spanish | Latin (bundled fonts) | not recorded; welcome | partial (#498) | review | | Portuguese (Brazil) | pt-br | 3,541 (same entries as Spanish, plus the macOS Settings item) | as Spanish | Latin (bundled fonts) | not recorded; welcome | partial (#859) | review | -| Indonesian | id | 3,541 (same entries as Spanish, plus the macOS Settings item); must cover the core menus, no entry without a Spanish one | as Spanish | Latin (bundled fonts); System Language also maps the legacy `in` code | not recorded; welcome | partial (#867) | review | +| Indonesian | id | 3,541 (same entries as Spanish, plus the macOS Settings item) | as Spanish | Latin (bundled fonts); System Language also maps the legacy `in` code | not recorded; welcome | partial (#867) | review | | Japanese | ja | 3,487 (99.7%) | as zh-cn; system-font kanji may pick the Chinese fallback (#547) | CJK fonts, vertical title text | not recorded | partial | 4–8 h + review | -| German | de | 3,590 (same entries as Spanish); must cover the core menus and the macOS app menu, no entry without a Spanish one | as Spanish | Latin (bundled fonts) | menus yes (#630); the rest not recorded, welcome | partial | review | +| German | de | 3,590 (same entries as Spanish) | as Spanish | Latin (bundled fonts) | menus yes (#630); the rest not recorded, welcome | partial | review | | Korean | ko | 3,573 (same entries as Spanish, plus the macOS Settings item) | as Spanish | installed system Hangul font (Malgun Gothic, Apple SD Gothic Neo, Noto Sans CJK KR…); refused without one | not recorded; welcome | partial (#508) | review; Hangul in craft-fonts for the web | | Vietnamese | vi | 0 (0%) | — | Latin with stacked diacritics (font coverage to check) | — | none | 6–12 h + review | -| *Other shipped:* Italian | it | 3,573 (same entries as Spanish, plus the macOS Settings item); must cover the core menus, no entry without a Spanish one | as Spanish | Latin (bundled fonts) | not recorded; welcome | partial | review | +| *Other shipped:* Italian | it | 3,573 (same entries as Spanish, plus the macOS Settings item) | as Spanish | Latin (bundled fonts) | not recorded; welcome | partial | review | | *Other shipped:* Ukrainian | uk | 351 (10.0%) | menus only | Cyrillic (bundled) | not recorded | menus only | 5–9 h | UI strings are catalog entries (the non-comment lines of `src/i18n/.tsv`; English counts the source strings in the code), so the rows were measured at different times: at this change the -Spanish and German catalogs both have 3,590 entries, German every Spanish one. Strings added -later may stay English in German until translated; only the core menus are required. +Spanish and German catalogs both have 3,590 entries, German every Spanish one. A string a catalog +lacks shows in English; no test or build requires a translation. `cargo xtask i18n-report` (also +printed by `cargo xtask ci`) lists each language's missing strings. Premiere also ships Italian, which we now cover like Spanish, and Russian (6–10 h). @@ -73,6 +74,7 @@ Every language needs native-speaker review (human). | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | Missing translations never fail a test or build (they show in English); `cargo xtask i18n-report`, also run by `cargo xtask ci`, lists them per language | | 2026-10-13 | minor | German (de) covers the whole interface: every entry of the Spanish catalog (3,590); the tests require the core menus and the macOS app menu and reject entries without a Spanish one | | 2026-10-11 | minor | German (de) ships the menus (#630): 371 of 3,498 strings (10.6%), native-reviewed; dimension now 2 menus-only, 5 none | | 2026-10-10 | major | Created from ROADMAP.md's "Interface localization" section; catalog coverage measured (es 3,496, ja 3,487, zh-cn 3,485, pt-br 348, uk 351 of 3,498); the 12-language table and script-support gaps added | diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 1e7c2dc7f..e3f883e0f 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -16,8 +16,10 @@ //! lists, against a reference list (default: the local `plan/premiere/.json`), presence and //! placement reported separately (`parity.rs`, docs/gaps.md G7). //! - `ico …`: pack PNGs into a Windows `.ico` (used by `packaging/icons.sh`). +//! - `i18n-report`: print the interface strings each language's catalog lacks (they show in +//! English). It never fails. //! - `ci`: fmt check, clippy -D warnings, tests (`cargo nextest run` when installed) and doctests, -//! layers, assets, wasm. +//! the i18n report, layers, assets, wasm. mod ico; mod parity; @@ -458,7 +460,9 @@ fn ci() -> Result<(), String> { let cargo = env!("CARGO"); run(Command::new(cargo).args(["fmt", "--check"]))?; run(Command::new(cargo).args(["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]))?; - tests()?; + let tested = tests(); + i18n_report(); + tested?; layers()?; assets()?; wasm() @@ -479,6 +483,16 @@ fn tests() -> Result<(), String> { run(Command::new(cargo).args(["test", "--workspace", "--doc"])) } +/// The missing-translations report (`i18n::tests::missing_translations_report` in +/// `filmcraft-ui-egui`), printed to the log. A missing translation never fails a build, so +/// neither does this. +fn i18n_report() { + let args = ["test", "-p", "filmcraft-ui-egui", "--lib", "--", "--exact", "i18n::tests::missing_translations_report", "--nocapture"]; + if let Err(e) = run(Command::new(env!("CARGO")).args(args)) { + eprintln!("i18n report not printed: {e}"); + } +} + /// The headless playback benchmark (`crates/ui-egui/examples/bench_playback.rs`); extra /// arguments go to the benchmark (`cargo xtask bench-playback --scenario stack3 --res full`). fn bench_playback() -> Result<(), String> { @@ -507,6 +521,10 @@ fn main() -> ExitCode { "assets" => assets(), "fixtures" => fixtures(&std::env::args().skip(2).collect::>()), "ci" => ci(), + "i18n-report" => { + i18n_report(); + Ok(()) + } "bench" => bench(), "bench-playback" => bench_playback(), "web" => web(&std::env::args().skip(2).collect::>()),