From c2df1c1a731c73f16a8f358d9d008f4497f46e50 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:42:31 -0400 Subject: [PATCH] Translations: missing strings show in English and are listed by a report, not failed by tests (#2244) * Translations: delete coverage and wording tests A missing translation shows in English and never fails a test. Delete the coverage and per-language parity tests, the tests that pin one language's wording and the source scans for strings not routed through the catalog. Keep the stale-row, duplicate-row and placeholder checks, and make the engine tests that switch language compare against the catalog instead of a literal. Drop the Traditional Chinese panel rows that only repeated the English source to satisfy the parity test. * Translations: missing-strings report in xtask ci missing_translations_report prints, per language, the fixed menu entries, panel strings, effect names and effect categories with no row. It always passes. cargo xtask ci runs it with --nocapture after the test runs, so the list shows in the gate log. The docs say a missing string shows in English and where the list is. --- crates/ui-egui/src/i18n.rs | 500 ++++------------------------- crates/ui-egui/src/i18n/effects.rs | 141 +++----- crates/ui-egui/src/i18n/ui.rs | 302 +++-------------- crates/ui-egui/src/panels/home.rs | 10 +- docs/localization-parity.md | 14 +- docs/preferences.md | 2 +- docs/testing.md | 4 +- xtask/src/main.rs | 2 + 8 files changed, 172 insertions(+), 803 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 8b8b01b5..e31a1a0a 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -6649,140 +6649,6 @@ mod tests { use serde_json::json; use std::collections::BTreeSet; - #[test] - fn czech_translates_menus_without_changing_bindings_or_user_names() { - let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); - app.session.execute("prefs.set", json!({"key": "general.language", "value": "en"})).unwrap(); - app.session.prefs.push_recent("File.ecproj"); - let en = crate::native_menu::build(&app); - let state = crate::native_menu::state_key(&app); - app.session.execute("prefs.set", json!({"key": "general.language", "value": "cs"})).unwrap(); - let cs = crate::native_menu::build(&app); - assert_eq!(language(&app), "cs"); - assert!(czech(&app)); - assert_eq!(code("cs"), "cs"); - assert_ne!(state, crate::native_menu::state_key(&app)); - let bindings = |menu: &crate::native_menu::NativeMenu| { - menu.items().into_iter().map(|i| (i.id.clone(), i.command.clone(), i.params.clone(), i.shortcut.clone(), i.enabled, i.checked)).collect::>() - }; - assert_eq!(bindings(&en), bindings(&cs)); - assert!(cs.items().iter().any(|i| i.command == "comp.new" && i.label == "Nová kompozice...")); - assert!(cs.items().iter().any(|i| i.command == "layer.newText" && i.label == "Text")); - assert!(cs.items().iter().any(|i| i.command == "keys.easyEase" && i.label == "Snadné zpomalení")); - assert!(cs.items().iter().any(|i| i.command == "file.openRecent" && i.label == "File.ecproj")); - for (source, translated) in [ - ("File", "Soubor"), - ("Edit", "Úpravy"), - ("Composition", "Kompozice"), - ("Layer", "Vrstva"), - ("Effect", "Efekt"), - ("Animation", "Animace"), - ("View", "Zobrazení"), - ("Window", "Okno"), - ("Help", "Nápověda"), - ] { - assert_eq!(label(&app, "", source), translated); - } - assert_eq!(label(&app, "unknown.command", "Text"), "Text"); - let panel = - MenuEntry { label: "Layer".into(), command: "window.panel".into(), params: json!({"panel": "layer"}), shortcut: None, alt_shortcuts: vec![] }; - assert_eq!(entry(&app, &panel, "Layer: File Čeština".into()), "Vrstva: File Čeština"); - assert_eq!( - submenu(&app, "Assign Shortcut to Workspace", "Assign Shortcut to “File Čeština” Workspace".into()), - "Přiřadit zkratku pracovnímu prostoru „File Čeština“" - ); - assert_eq!(submenu(&app, "Assign Shortcut to 3D View", "Assign Shortcut to “Top”".into()), "Přiřadit zkratku 3D zobrazení „Top“"); - assert_eq!(submenu(&app, "Assign Shortcut to Workspace", "Assign Shortcut to Workspace".into()), "Přiřadit zkratku pracovnímu prostoru"); - app.session.execute("prefs.set", json!({"key": "general.language", "value": "en"})).unwrap(); - assert_eq!(crate::native_menu::build(&app), en); - } - - #[test] - fn czech_undo_redo_keeps_history_labels_verbatim() { - let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); - app.session.execute("prefs.set", json!({"key": "general.language", "value": "cs"})).unwrap(); - let undo = MenuEntry { label: "Undo".into(), command: "edit.undo".into(), params: json!({}), shortcut: None, alt_shortcuts: vec![] }; - let redo = MenuEntry { label: "Redo".into(), command: "edit.redo".into(), params: json!({}), shortcut: None, alt_shortcuts: vec![] }; - assert_eq!(entry(&app, &undo, "Can't Undo".into()), "Není co vrátit"); - assert_eq!(entry(&app, &redo, "Can't Redo".into()), "Není co zopakovat"); - app.session.history.undo.push(("File Čeština".into(), app.session.project.clone())); - app.session.history.redo.push(("Text Čeština".into(), app.session.project.clone())); - assert_eq!(entry(&app, &undo, "Undo File Čeština".into()), "Zpět File Čeština"); - assert_eq!(entry(&app, &redo, "Redo Text Čeština".into()), "Znovu Text Čeština"); - } - - #[test] - fn german_reveal_labels_follow_the_platform_file_manager() { - // As `czech_…` and `ukrainian_…` do. Asserted through `label`, not `entry`: `entry` - // returns the English for German, Russian and French, which have catalogues but are - // not wired into the menus (see the undo/redo match in `entry` and `supported`). - let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); - app.session.execute("prefs.set", json!({"key": "general.language", "value": "de"})).unwrap(); - for command in ["file.revealInFinder", "layer.revealInFinder"] { - for (source, translated) in [ - ("Reveal in Finder", "Im Finder anzeigen"), - ("Reveal in Explorer", "Im Explorer anzeigen"), - ("Reveal in File Manager", "Im Dateimanager anzeigen"), - ] { - assert_eq!(label(&app, command, source), translated, "{command} / {source}"); - // Another command keeps a label that merely looks like one of these. - assert_eq!(label(&app, "file.openRecent", source), source); - } - assert_eq!(label(&app, command, "My file.mp4"), "My file.mp4", "a user's filename is never looked up"); - } - } - - #[test] - fn german_menu_labels_name_what_their_command_does() { - let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); - app.session.execute("prefs.set", json!({"key": "general.language", "value": "de"})).unwrap(); - // "Ebenenreihenfolge umkehren" reads as reversing the stacking order, not time, and - // "Formate" are formats, not layer styles (#1443). - assert_eq!(label(&app, "layer.timeReverse", "Time-Reverse Layer"), "Ebene zeitlich umkehren"); - assert_eq!(label(&app, "keys.timeReverse", "Time-Reverse Keyframes"), "Keyframes zeitlich umkehren"); - assert_eq!(label(&app, "layer.style.convertToEditable", "Convert to Editable Styles"), "In bearbeitbare Stile umwandeln"); - } - - #[test] - fn german_uses_one_word_per_term() { - let german: Vec<&str> = GERMAN.iter().map(|(_, _, de)| *de).collect(); - let with = |needle: &str| german.iter().filter(|de| de.contains(needle)).copied().collect::>(); - // "Expression" is "Ausdruck", not the English word. - assert!(with("Expression").is_empty(), "{:?}", with("Expression")); - // "Track" as a verb is "verfolgen", not "tracken". - assert!(with("tracken").is_empty(), "{:?}", with("tracken")); - // "Reveal " is "anzeigen". ("einblenden" stays right for "Show All", - // "Fade from Blur" and the like, so only the Reveal entries are checked.) - let reveal: Vec<&str> = - GERMAN.iter().filter(|(_, en, _)| en.starts_with("Reveal")).map(|(_, _, de)| *de).filter(|de| !de.to_lowercase().contains("anzeigen")).collect(); - assert!(reveal.is_empty(), "{reveal:?}"); - // One spelling of "to the front". - let vorn: Vec<&str> = german.iter().filter(|de| de.ends_with("nach vorn")).copied().collect(); - assert!(vorn.is_empty(), "{vorn:?}"); - // The terms those entries joined are still the majority ones. - assert!(with("verfolgen").len() >= 4, "{:?}", with("verfolgen")); - assert!(with("anzeigen").len() >= 12, "{}", with("anzeigen").len()); - } - - #[test] - fn czech_reveal_labels_follow_the_platform_file_manager() { - let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); - app.session.execute("prefs.set", json!({"key": "general.language", "value": "cs"})).unwrap(); - for command in ["file.revealInFinder", "layer.revealInFinder"] { - for (source, translated) in [ - ("Reveal in Finder", "Zobrazit ve Finderu"), - ("Reveal in Explorer", "Zobrazit v Průzkumníku"), - ("Reveal in File Manager", "Zobrazit ve správci souborů"), - ] { - let e = MenuEntry { label: source.into(), command: command.into(), params: json!({}), shortcut: None, alt_shortcuts: vec![] }; - assert_eq!(label(&app, command, source), translated); - assert_eq!(entry(&app, &e, source.into()), translated); - assert_eq!(label(&app, "file.openRecent", source), source); - } - assert_eq!(label(&app, command, "My file.mp4"), "My file.mp4"); - } - } - fn keys(nodes: &[MenuNode], out: &mut BTreeSet<(String, String)>) { for node in nodes { match node { @@ -6798,88 +6664,71 @@ mod tests { } } + /// The fixed menu entries `code` has no row for. `@effects` is the generated plug-in catalog, + /// whose effect names come from the effect catalog instead. + fn missing_menu_entries(code: &str) -> Vec { + let catalog = menu_catalog(code).unwrap_or_default(); + let mut missing = Vec::new(); + for line in TREE.lines() { + let line = line.trim().trim_start_matches("[mac] ").trim_start_matches("[!mac] "); + if line.is_empty() || line == "---" || line.starts_with('@') { + continue; + } + let mut parts = line.split(" | "); + let Some(en) = parts.next() else { continue }; + let command = parts.next().and_then(|p| p.split_whitespace().next()).unwrap_or(""); + if !catalog.iter().any(|(id, source, _)| *id == command && *source == en) { + missing.push(en.to_string()); + } + } + missing + } + + /// Lists, per language, the interface strings with no translation, which show in English. It + /// always passes: a missing translation never blocks a change. `cargo xtask ci` runs it. #[test] - fn every_translation_key_exists_in_the_actual_menu_tree() { + fn missing_translations_report() { + const SHOWN: usize = 12; + println!("Missing translations (shown in English):"); + let mut total = 0; + for code in CATALOG_CODES { + let catalogs = [ + ("menus", missing_menu_entries(code)), + ("panels", ui::missing(code).into_iter().map(str::to_string).collect()), + ("effects", effects::missing(code)), + ]; + let count: usize = catalogs.iter().map(|(_, missing)| missing.len()).sum(); + total += count; + println!("{code}: {count}"); + for (catalog, missing) in &catalogs { + if missing.is_empty() { + continue; + } + let shown: Vec<&String> = missing.iter().take(SHOWN).collect(); + let more = missing.len().saturating_sub(SHOWN); + let more = if more > 0 { format!(" … and {more} more") } else { String::new() }; + println!(" {catalog} ({}): {shown:?}{more}", missing.len()); + } + } + println!("total: {total}"); + } + + #[test] + fn menu_catalogs_have_no_stale_or_duplicate_rows() { let mut actual = BTreeSet::new(); for mac in [false, true] { keys(&parse(TREE, mac).unwrap(), &mut actual); } - let mut japanese_keys = BTreeSet::new(); - let mut chinese_keys = BTreeSet::new(); - let mut traditional_keys = BTreeSet::new(); - let mut ukrainian_keys = BTreeSet::new(); - let mut spanish_keys = BTreeSet::new(); - let mut portuguese_keys = BTreeSet::new(); - let mut russian_keys = BTreeSet::new(); - let mut french_keys = BTreeSet::new(); - let mut german_keys = BTreeSet::new(); - let mut czech_keys = BTreeSet::new(); - for (catalog, unique) in [ - (JAPANESE, &mut japanese_keys), - (SIMPLIFIED_CHINESE, &mut chinese_keys), - (TRADITIONAL_CHINESE, &mut traditional_keys), - (UKRAINIAN, &mut ukrainian_keys), - (SPANISH, &mut spanish_keys), - (PORTUGUESE, &mut portuguese_keys), - (RUSSIAN, &mut russian_keys), - (FRENCH, &mut french_keys), - (GERMAN, &mut german_keys), - (CZECH, &mut czech_keys), - ] { + for code in CATALOG_CODES { + let catalog = menu_catalog(code).unwrap(); + let mut unique = BTreeSet::new(); for (command, en, translated) in catalog { let key = (command.to_string(), en.to_string()); - assert!(actual.contains(&key), "stale translation: {key:?}"); - assert!(unique.insert(key), "duplicate translation: {command} / {en}"); - assert!(!translated.is_empty()); - } - // All fixed TREE entries are covered; @effects is a generated plug-in catalog, - // whose product/effect names are intentionally left as registered. - for line in TREE.lines() { - let line = line.trim().trim_start_matches("[mac] ").trim_start_matches("[!mac] "); - if line.is_empty() || line == "---" || line.starts_with('@') { - continue; - } - let mut parts = line.split(" | "); - let en = parts.next().unwrap(); - let command = parts.next().and_then(|p| p.split_whitespace().next()).unwrap_or(""); - assert!(unique.contains(&(command.into(), en.into())), "untranslated fixed entry: {line}"); + assert!(actual.contains(&key), "{code}: stale translation: {key:?}"); + assert!(unique.insert(key), "{code}: duplicate translation: {command} / {en}"); + assert!(!translated.is_empty(), "{code}: empty translation: {command} / {en}"); } } - // Every translated language covers exactly the same entries. - assert_eq!(japanese_keys, chinese_keys, "the languages must translate the same entries"); - assert_eq!(chinese_keys, traditional_keys, "the languages must translate the same entries"); - assert_eq!(traditional_keys, spanish_keys, "the languages must translate the same entries"); - assert_eq!(spanish_keys, portuguese_keys, "the languages must translate the same entries"); - assert_eq!(japanese_keys, ukrainian_keys, "the languages must translate the same entries"); - assert_eq!(ukrainian_keys, russian_keys, "the languages must translate the same entries"); - assert_eq!(russian_keys, french_keys, "the languages must translate the same entries"); - assert_eq!(french_keys, german_keys, "the languages must translate the same entries"); - assert_eq!(japanese_keys, czech_keys, "the languages must translate the same entries"); - } - - /// Exercise each engine spelling on every host: a macOS-only run must still catch a - /// Windows/Linux catalog miss, while filenames and unrelated commands stay untouched. - #[test] - fn ukrainian_reveal_labels_follow_the_platform_file_manager() { - let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); - app.session.execute("prefs.set", json!({"key": "general.language", "value": "uk"})).unwrap(); - for command in ["file.revealInFinder", "layer.revealInFinder"] { - for (source, translated) in [ - ("Reveal in Finder", "Показати у Finder"), - ("Reveal in Explorer", "Показати у Провіднику"), - ("Reveal in File Manager", "Показати у файловому менеджері"), - ] { - let e = - MenuEntry { label: source.into(), command: command.into(), params: json!({"item": "My file.mp4"}), shortcut: None, alt_shortcuts: vec![] }; - assert_eq!(label(&app, command, source), translated); - assert_eq!(entry(&app, &e, source.into()), translated); - assert_eq!(label(&app, "file.openRecent", source), source); - } - assert_eq!(label(&app, command, "My file.mp4"), "My file.mp4"); - } - app.session.execute("prefs.set", json!({"key": "general.language", "value": "en"})).unwrap(); - assert_eq!(label(&app, "file.revealInFinder", "Reveal in Explorer"), "Reveal in Explorer"); - assert_eq!(label(&app, "layer.revealInFinder", "Reveal in File Manager"), "Reveal in File Manager"); } #[test] @@ -6895,18 +6744,8 @@ mod tests { menu.items().into_iter().map(|i| (i.id.clone(), i.command.clone(), i.params.clone(), i.shortcut.clone(), i.enabled, i.checked)).collect::>() }; assert_eq!(bindings(&en), bindings(&ja)); + assert!(ja.items().iter().any(|i| i.command == "comp.new" && i.label == label(&app, "comp.new", "New Composition..."))); assert_eq!(label(&app, "unknown.command", "Text"), "Text"); - assert_eq!( - entry( - &app, - &MenuEntry { label: "Layer".into(), command: "window.panel".into(), params: json!({"panel":"layer"}), shortcut: None, alt_shortcuts: vec![] }, - "Layer: File".into() - ), - "レイヤー: File" - ); - assert!(ja.items().iter().any(|i| i.command == "comp.new" && i.label == "新規コンポジション...")); - assert!(ja.items().iter().any(|i| i.command == "layer.newText" && i.label == "テキスト")); - assert!(ja.items().iter().any(|i| i.command == "keys.easyEase" && i.label == "イージーイーズ")); app.session.prefs.push_recent("/tmp/File.ecproj"); assert!(crate::native_menu::build(&app).items().iter().any(|i| i.command == "file.openRecent" && i.label == "File.ecproj")); app.session.execute("prefs.set", json!({"key":"general.language", "value":"en"})).unwrap(); @@ -6986,145 +6825,6 @@ mod tests { } } - #[test] - fn zh_hans_translates_labels_computed_titles_and_keeps_names_verbatim() { - let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); - app.session.execute("prefs.set", json!({"key":"general.language", "value":"zh-hans"})).unwrap(); - assert_eq!(language(&app), "zh-hans"); - assert!(simplified(&app)); - assert!(!traditional(&app)); - assert!(!japanese(&app)); - // Fixed menu rows come from the catalog. - assert_eq!(label(&app, "", "Composition"), "合成"); - assert_eq!(label(&app, "", "Effect"), "效果"); - assert_eq!(label(&app, "comp.new", "New Composition..."), "新建合成..."); - assert_eq!(label(&app, "layer.newText", "Text"), "文本"); - // Untranslated keys and dynamic labels stay verbatim. - assert_eq!(label(&app, "unknown.command", "Text"), "Text"); - assert_eq!( - entry( - &app, - &MenuEntry { label: "Undo".into(), command: "edit.undo".into(), params: json!({}), shortcut: None, alt_shortcuts: vec![] }, - "Undo Comp1".into() - ), - "无可撤销的操作" - ); - assert_eq!( - entry( - &app, - &MenuEntry { label: "Layer".into(), command: "window.panel".into(), params: json!({"panel":"layer"}), shortcut: None, alt_shortcuts: vec![] }, - "Layer: File".into() - ), - "图层: File" - ); - // The submenu titles that name the current workspace / view. - assert_eq!(submenu(&app, "Assign Shortcut to Workspace", "Assign Shortcut to “Default” Workspace".into()), "为工作区“Default”指定快捷键"); - assert_eq!(submenu(&app, "Assign Shortcut to 3D View", "Assign Shortcut to “Top”".into()), "为 3D 视图“Top”指定快捷键"); - // The menu tree carries the same commands and shortcuts with the Chinese labels. - let zh = crate::native_menu::build(&app); - assert!(zh.items().iter().any(|i| i.command == "comp.new" && i.label == "新建合成...")); - assert!(zh.items().iter().any(|i| i.command == "layer.newText" && i.label == "文本")); - assert!(zh.items().iter().any(|i| i.command == "keys.easyEase" && i.label == "缓动")); - // A dynamic filename is never translated. - app.session.prefs.push_recent("/tmp/File.ecproj"); - assert!(crate::native_menu::build(&app).items().iter().any(|i| i.command == "file.openRecent" && i.label == "File.ecproj")); - } - - #[test] - fn zh_hant_translates_labels_computed_titles_and_keeps_names_verbatim() { - let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); - app.session.execute("prefs.set", json!({"key":"general.language", "value":"zh-hant"})).unwrap(); - assert_eq!(language(&app), "zh-hant"); - assert!(traditional(&app)); - assert!(!simplified(&app)); - assert!(!japanese(&app)); - // Fixed menu rows come from the catalog. - assert_eq!(label(&app, "", "Composition"), "合成"); - assert_eq!(label(&app, "", "Effect"), "效果"); - assert_eq!(label(&app, "comp.new", "New Composition..."), "新增合成..."); - assert_eq!(label(&app, "layer.newText", "Text"), "文字"); - // Untranslated keys and dynamic labels stay verbatim. - assert_eq!(label(&app, "unknown.command", "Text"), "Text"); - assert_eq!( - entry( - &app, - &MenuEntry { label: "Undo".into(), command: "edit.undo".into(), params: json!({}), shortcut: None, alt_shortcuts: vec![] }, - "Undo Comp1".into() - ), - "沒有可復原的操作" - ); - assert_eq!( - entry( - &app, - &MenuEntry { label: "Layer".into(), command: "window.panel".into(), params: json!({"panel":"layer"}), shortcut: None, alt_shortcuts: vec![] }, - "Layer: File".into() - ), - "圖層: File" - ); - // The submenu titles that name the current workspace / view. - assert_eq!(submenu(&app, "Assign Shortcut to Workspace", "Assign Shortcut to “Default” Workspace".into()), "為工作區“Default”指定快速鍵"); - assert_eq!(submenu(&app, "Assign Shortcut to 3D View", "Assign Shortcut to “Top”".into()), "為 3D 檢視“Top”指定快速鍵"); - // The menu tree carries the same commands and shortcuts with the Traditional labels. - let zh = crate::native_menu::build(&app); - assert!(zh.items().iter().any(|i| i.command == "comp.new" && i.label == "新增合成...")); - assert!(zh.items().iter().any(|i| i.command == "layer.newText" && i.label == "文字")); - assert!(zh.items().iter().any(|i| i.command == "keys.easyEase" && i.label == "緩動")); - // A dynamic filename is never translated. - app.session.prefs.push_recent("/tmp/File.ecproj"); - assert!(crate::native_menu::build(&app).items().iter().any(|i| i.command == "file.openRecent" && i.label == "File.ecproj")); - } - - #[test] - fn ukrainian_translates_menus_without_changing_bindings_or_user_names() { - let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); - app.session.execute("prefs.set", json!({"key":"general.language", "value":"en"})).unwrap(); - app.session.prefs.push_recent("/tmp/File.ecproj"); - let en = crate::native_menu::build(&app); - let state = crate::native_menu::state_key(&app); - app.session.execute("prefs.set", json!({"key":"general.language", "value":"uk"})).unwrap(); - let uk = crate::native_menu::build(&app); - assert_eq!(language(&app), "uk"); - assert!(ukrainian(&app)); - assert_ne!(state, crate::native_menu::state_key(&app)); - let bindings = |menu: &crate::native_menu::NativeMenu| { - menu.items().into_iter().map(|i| (i.id.clone(), i.command.clone(), i.params.clone(), i.shortcut.clone(), i.enabled, i.checked)).collect::>() - }; - assert_eq!(bindings(&en), bindings(&uk)); - assert!(uk.items().iter().any(|i| i.command == "comp.new" && i.label == "Нова композиція...")); - assert!(uk.items().iter().any(|i| i.command == "layer.newText" && i.label == "Текст")); - assert!(uk.items().iter().any(|i| i.command == "file.openRecent" && i.label == "File.ecproj")); - assert_eq!(label(&app, "", "File"), "Файл"); - assert_eq!(label(&app, "", "Light"), "Світло"); - assert_eq!(label(&app, "view.panelBackground", "Black"), "Чорний"); - assert_eq!(label(&app, "layer.addTextAnimator", "Tracking"), "Міжлітерний інтервал"); - assert_eq!(label(&app, "edit.purgeUndo", "Undo"), "Історія скасувань"); - assert_eq!(label(&app, "unknown.command", "Text"), "Text"); - let panel = - MenuEntry { label: "Layer".into(), command: "window.panel".into(), params: json!({"panel":"layer"}), shortcut: None, alt_shortcuts: vec![] }; - assert_eq!(entry(&app, &panel, "Layer: File".into()), "Шар: File"); - assert_eq!( - submenu(&app, "Assign Shortcut to Workspace", "Assign Shortcut to “File Україна” Workspace".into()), - "Призначити скорочення для робочого простору «File Україна»" - ); - assert_eq!(submenu(&app, "Assign Shortcut to 3D View", "Assign Shortcut to “Top”".into()), "Призначити скорочення для вигляду 3D «Top»"); - app.session.execute("prefs.set", json!({"key":"general.language", "value":"en"})).unwrap(); - assert_eq!(crate::native_menu::build(&app), en); - } - - #[test] - fn ukrainian_undo_redo_keeps_history_labels_verbatim() { - let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); - app.session.execute("prefs.set", json!({"key":"general.language", "value":"uk"})).unwrap(); - let undo = MenuEntry { label: "Undo".into(), command: "edit.undo".into(), params: json!({}), shortcut: None, alt_shortcuts: vec![] }; - let redo = MenuEntry { label: "Redo".into(), command: "edit.redo".into(), params: json!({}), shortcut: None, alt_shortcuts: vec![] }; - assert_eq!(entry(&app, &undo, "Can't Undo".into()), "Немає дій для скасування"); - assert_eq!(entry(&app, &redo, "Can't Redo".into()), "Немає дій для повторення"); - app.session.history.undo.push(("File Україна".into(), app.session.project.clone())); - app.session.history.redo.push(("Text Україна".into(), app.session.project.clone())); - assert_eq!(entry(&app, &undo, "Undo File Україна".into()), "Скасувати File Україна"); - assert_eq!(entry(&app, &redo, "Redo Text Україна".into()), "Повторити Text Україна"); - } - #[test] fn ukrainian_catalog_matches_the_settings_registry() { let codes: Vec<_> = effectcraft_engine::prefs::LANGUAGES.iter().map(|(_, code)| *code).filter(|code| !matches!(*code, "system" | "en")).collect(); @@ -7134,92 +6834,6 @@ mod tests { assert_eq!(code("unknown"), "en"); } - #[test] - fn es_translates_labels_computed_titles_and_keeps_names_verbatim() { - let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); - app.session.execute("prefs.set", json!({"key":"general.language", "value":"es"})).unwrap(); - assert_eq!(language(&app), "es"); - assert!(spanish(&app)); - assert!(!traditional(&app)); - assert!(!simplified(&app)); - assert!(!japanese(&app)); - // Fixed menu rows come from the catalog. - assert_eq!(label(&app, "", "Composition"), "Composición"); - assert_eq!(label(&app, "", "Effect"), "Efecto"); - assert_eq!(label(&app, "comp.new", "New Composition..."), "Nueva composición..."); - assert_eq!(label(&app, "layer.newText", "Text"), "Texto"); - assert_eq!(label(&app, "layer.setBlendMode", "Color Burn"), "Subexposición de color"); - // Untranslated keys and dynamic labels stay verbatim. - assert_eq!(label(&app, "unknown.command", "Text"), "Text"); - assert_eq!( - entry( - &app, - &MenuEntry { label: "Undo".into(), command: "edit.undo".into(), params: json!({}), shortcut: None, alt_shortcuts: vec![] }, - "Undo Comp1".into() - ), - "No se puede deshacer" - ); - assert_eq!( - entry( - &app, - &MenuEntry { label: "Layer".into(), command: "window.panel".into(), params: json!({"panel":"layer"}), shortcut: None, alt_shortcuts: vec![] }, - "Layer: File".into() - ), - "Capa: File" - ); - // The submenu titles that name the current workspace / view. - assert_eq!( - submenu(&app, "Assign Shortcut to Workspace", "Assign Shortcut to “Default” Workspace".into()), - "Asignar método abreviado al espacio de trabajo “Default”" - ); - assert_eq!(submenu(&app, "Assign Shortcut to 3D View", "Assign Shortcut to “Top”".into()), "Asignar método abreviado a la vista 3D “Top”"); - // The menu tree carries the same commands and shortcuts with the Spanish labels. - let es = crate::native_menu::build(&app); - assert!(es.items().iter().any(|i| i.command == "comp.new" && i.label == "Nueva composición...")); - assert!(es.items().iter().any(|i| i.command == "layer.newText" && i.label == "Texto")); - assert!(es.items().iter().any(|i| i.command == "keys.easyEase" && i.label == "Suavizado")); - // A dynamic filename is never translated. - app.session.prefs.push_recent("/tmp/File.ecproj"); - assert!(crate::native_menu::build(&app).items().iter().any(|i| i.command == "file.openRecent" && i.label == "File.ecproj")); - } - - #[test] - fn pt_br_translates_labels_computed_titles_and_keeps_names_verbatim() { - let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); - app.session.execute("prefs.set", json!({"key":"general.language", "value":"pt-br"})).unwrap(); - assert_eq!(language(&app), "pt-br"); - assert!(!japanese(&app)); - // Fixed menu rows come from the catalog. - assert_eq!(label(&app, "", "Composition"), "Composição"); - assert_eq!(label(&app, "", "Effect"), "Efeito"); - assert_eq!(label(&app, "comp.new", "New Composition..."), "Nova composição..."); - assert_eq!(label(&app, "layer.newText", "Text"), "Texto"); - // Untranslated keys and dynamic labels stay verbatim. - assert_eq!(label(&app, "unknown.command", "Text"), "Text"); - assert_eq!( - entry( - &app, - &MenuEntry { label: "Undo".into(), command: "edit.undo".into(), params: json!({}), shortcut: None, alt_shortcuts: vec![] }, - "Undo Comp1".into() - ), - "Não há nada para desfazer" - ); - assert_eq!( - entry( - &app, - &MenuEntry { label: "Layer".into(), command: "window.panel".into(), params: json!({"panel":"layer"}), shortcut: None, alt_shortcuts: vec![] }, - "Layer: File".into() - ), - "Camada: File" - ); - // The submenu titles that name the current workspace / view. - assert_eq!( - submenu(&app, "Assign Shortcut to Workspace", "Assign Shortcut to “Default” Workspace".into()), - "Atribuir atalho à área de trabalho “Default”" - ); - assert_eq!(submenu(&app, "Assign Shortcut to 3D View", "Assign Shortcut to “Top”".into()), "Atribuir atalho à visualização 3D “Top”"); - } - #[test] fn general_settings_exposes_the_language_automation_id() { let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); diff --git a/crates/ui-egui/src/i18n/effects.rs b/crates/ui-egui/src/i18n/effects.rs index 5c6d09ba..4aa32f88 100644 --- a/crates/ui-egui/src/i18n/effects.rs +++ b/crates/ui-egui/src/i18n/effects.rs @@ -14,15 +14,15 @@ //! the id is stable, so a translation never becomes an orphan. //! //! Each row repeats the registry's English name so that a rename upstream fails -//! [`tests::every_effect_has_a_row`] instead of silently pointing a translation at another -//! effect. +//! [`tests::every_row_names_a_registry_effect`] instead of silently pointing a translation at +//! another effect. An effect with no row draws the registry's English name; the +//! `missing_translations_report` test (run by `cargo xtask ci`) lists those per language. //! //! A language decides for itself which names stay English: After Effects' Japanese interface //! leaves the `CC` series, the `VR` set and the `OCIO` set in English, while its Simplified //! Chinese one translates them (the `CC` names come from Adobe's Chinese *documentation*, which //! names 68 of the 69). Repeating the English name is therefore a language-level choice, not a -//! missing row: [`tests::only_the_brand_names_stay_in_english`] pins *Simplified Chinese's* five -//! brand names and is deliberately not shared by the other languages. +//! missing row. //! //! The Simplified Chinese names come from the installed After Effects 2026 (26.5.0.89) //! Simplified Chinese interface, its 21 category folder names included; `Cryptomatte`, @@ -118,6 +118,19 @@ pub(crate) fn matches(id: &str, english: &str, query: &str) -> bool { || catalog(current()).and_then(|c| c.row(id)).is_some_and(|(_, translated)| translated.to_lowercase().contains(query)) } +/// The registry's effect names and categories `language` has no row for, for the missing-strings +/// report. +#[cfg(test)] +pub(super) fn missing(language: &str) -> Vec { + let table = catalog(language); + let effects = effectcraft_engine::effects::registry().iter().filter(|spec| table.and_then(|c| c.row(spec.id)).is_none()).map(|spec| spec.name.to_string()); + let categories = effectcraft_engine::effects::categories() + .into_iter() + .filter(|source| table.and_then(|c| c.category(source)).is_none()) + .map(|source| format!("{source} (category)")); + effects.chain(categories).collect() +} + /// Simplified Chinese (`zh-hans`): one row per effect in the registry. const SIMPLIFIED_CHINESE: &[Row] = &[ ("ec.3d.channelextract", "3D Channel Extract", "3D 通道提取"), @@ -524,19 +537,17 @@ mod tests { use crate::i18n::set_current; use effectcraft_engine::effects; - /// Every effect in the registry has a row, and that row still carries the registry's English - /// name. A new effect, a rename or a removal fails here rather than drawing an English name - /// in a Chinese interface, or attaching a translation to a different effect. + /// Every row names an effect in the registry and still carries its English name, so a rename + /// or a removal fails here rather than attaching a translation to a different effect. #[test] - fn every_effect_has_a_row() { + fn every_row_names_a_registry_effect() { let registry = effects::registry(); - assert_eq!(SIMPLIFIED_CHINESE.len(), registry.len(), "the catalog has {} rows for {} effects", SIMPLIFIED_CHINESE.len(), registry.len()); - for spec in registry { - let Some((_, english, translated)) = SIMPLIFIED_CHINESE.iter().find(|(id, _, _)| *id == spec.id) else { - panic!("no catalog row for {} ({})", spec.id, spec.name); + for (id, english, translated) in SIMPLIFIED_CHINESE { + let Some(spec) = registry.iter().find(|spec| spec.id == *id) else { + panic!("{id} ({english}) is not an effect in the registry"); }; - assert_eq!(*english, spec.name, "{}: the catalog says {english:?}, the registry {:?}", spec.id, spec.name); - assert!(!translated.is_empty(), "{}: empty translation", spec.id); + assert_eq!(*english, spec.name, "{id}: the catalog says {english:?}, the registry {:?}", spec.name); + assert!(!translated.is_empty(), "{id}: empty translation"); } } @@ -550,18 +561,6 @@ mod tests { assert_eq!(listed, ids.len(), "an effect id is listed twice"); } - /// The five names Adobe's Simplified Chinese interface leaves in English as brand names, and - /// the only zh-hans rows that may repeat their English name. Deliberately not shared: another - /// language keeps different names in English (Japanese leaves the whole `CC`, `VR` and `OCIO` - /// sets in English), so this is zh-hans's own choice, not an invariant of the tables. - #[test] - fn only_the_brand_names_stay_in_english() { - const BRANDS: [&str; 5] = ["ec.3d.cryptomatte", "ec.3d.extractor", "ec.3d.identifier", "ec.keying.keylight", "ec.key.unmult"]; - for (id, english, translated) in SIMPLIFIED_CHINESE { - assert_eq!(english == translated, BRANDS.contains(id), "{id}: {english:?} is drawn as {translated:?}"); - } - } - /// The languages that have no rows yet, with the table a translator fills in for each. A new /// language is added here and to [`catalog`]; the test then guards its rows as they arrive. const PENDING: &[(&str, &[Row])] = &[("zh-hant", TRADITIONAL_CHINESE)]; @@ -591,15 +590,11 @@ mod tests { } } - /// Every category the registry and its plug-ins report has a Simplified Chinese row, and - /// every row of every language names a category that exists — else the generated Effect menu - /// and Effects & Presets would draw a label no folder has. + /// Every category row of every language names a category that exists, else the generated + /// Effect menu and Effects & Presets would carry a label no folder has. #[test] - fn every_category_has_a_row() { + fn every_category_row_names_a_category() { let categories = effects::categories(); - for category in &categories { - assert!(SIMPLIFIED_CHINESE_CATEGORIES.iter().any(|(english, _)| english == category), "no catalog row for category {category}"); - } for (language, rows) in [("zh-hans", SIMPLIFIED_CHINESE_CATEGORIES), ("zh-hant", TRADITIONAL_CHINESE_CATEGORIES), ("ja", JAPANESE_CATEGORIES)] { for (english, translated) in rows { assert!(categories.contains(english), "{language} lists {english}, which is not a category"); @@ -613,48 +608,38 @@ mod tests { #[test] fn group_names_keep_the_instance_index_and_user_renames() { set_current("zh-hans"); - assert_eq!(group("ec.blur.gaussian", "Gaussian Blur"), "高斯模糊"); - assert_eq!(group("ec.blur.gaussian", "Gaussian Blur 2"), "高斯模糊 2"); - assert_eq!(group("ec.blur.gaussian", "Gaussian Blur 12"), "高斯模糊 12"); + let translated = name("ec.blur.gaussian", "Gaussian Blur"); + assert_eq!(group("ec.blur.gaussian", "Gaussian Blur"), translated); + assert_eq!(group("ec.blur.gaussian", "Gaussian Blur 2"), format!("{translated} 2")); + assert_eq!(group("ec.blur.gaussian", "Gaussian Blur 12"), format!("{translated} 12")); assert_eq!(group("ec.blur.gaussian", "My Blur"), "My Blur", "a rename is not a row"); assert_eq!(group("ec.blur.gaussian", "Gaussian Blur X"), "Gaussian Blur X"); assert_eq!(group("ec.blur.gaussian", "Gaussian Blur 2x"), "Gaussian Blur 2x"); - assert_eq!(group("ec.blur.gaussian", "高斯模糊"), "高斯模糊", "an already translated name stays"); + assert_eq!(group("ec.blur.gaussian", &translated), translated, "an already translated name stays"); set_current("en"); } - /// English, and a language with no catalog or no row for this effect, draws the registry's - /// name rather than an empty label. - /// - /// The pending languages are also checked to still be row-less and to have an arm in - /// [`catalog`]: an arm with no table would leave a translator's rows dead, and the day a - /// pending language gets rows this fails so its slot moves out of [`PENDING`], where the - /// coverage for it lives. + /// English, and a language with no catalog or no row for an effect, draws the registry's + /// name rather than an empty label; a language with a row draws the row. Every table in + /// [`PENDING`] and [`PARTIAL`] has an arm in [`catalog`]: an arm-less table's rows would be dead. #[test] fn languages_without_rows_draw_the_registry_name() { + for (language, _) in PENDING.iter().chain(PARTIAL) { + assert!(catalog(language).is_some(), "{language} has a table but no arm in `catalog`: its rows would be dead"); + } + let tables = PENDING.iter().chain(PARTIAL).copied().chain([("zh-hans", SIMPLIFIED_CHINESE)]); + for (language, rows) in [("en", &[][..]), ("uk", &[][..]), ("cs", &[][..])].into_iter().chain(tables) { + set_current(language); + let expected = rows.iter().find(|(id, _, _)| *id == "ec.blur.gaussian").map_or("Gaussian Blur", |(_, _, translated)| *translated); + assert_eq!(name("ec.blur.gaussian", "Gaussian Blur"), expected, "{language}"); + assert_eq!(group("ec.blur.gaussian", "Gaussian Blur 2"), format!("{expected} 2"), "{language}"); + assert_eq!(name("ec.unknown.effect", "Mystery"), "Mystery", "{language}"); + assert_eq!(category("Not A Category"), "Not A Category", "{language}"); + } for language in ["en", "uk", "cs"] { set_current(language); - assert_eq!(name("ec.blur.gaussian", "Gaussian Blur"), "Gaussian Blur", "{language}"); - assert_eq!(group("ec.blur.gaussian", "Gaussian Blur 2"), "Gaussian Blur 2", "{language}"); assert_eq!(category("Blur & Sharpen"), "Blur & Sharpen", "{language}"); } - for (language, rows) in PENDING { - assert!(catalog(language).is_some(), "{language} has a table but no arm in `catalog`: its rows would be dead"); - assert!(rows.is_empty(), "{language} has rows now: move it out of PENDING and cover it here"); - set_current(language); - assert_eq!(name("ec.blur.gaussian", "Gaussian Blur"), "Gaussian Blur", "{language}"); - assert_eq!(group("ec.blur.gaussian", "Gaussian Blur 2"), "Gaussian Blur 2", "{language}"); - assert_eq!(category("Blur & Sharpen"), "Blur & Sharpen", "{language}"); - } - set_current("zh-hans"); - assert_eq!(name("ec.blur.gaussian", "Gaussian Blur"), "高斯模糊"); - assert_eq!(group("ec.blur.gaussian", "Gaussian Blur 2"), "高斯模糊 2"); - assert_eq!(category("Blur & Sharpen"), "模糊和锐化"); - assert_eq!(name("ec.unknown.effect", "Mystery"), "Mystery"); - assert_eq!(category("Not A Category"), "Not A Category"); - set_current("ja"); - assert_eq!(name("ec.blur.gaussian", "Gaussian Blur"), "ブラー(ガウス)"); - assert_eq!(name("ec.unknown.effect", "Mystery"), "Mystery", "a row a partial table lacks draws the registry name"); set_current("en"); } @@ -665,38 +650,10 @@ mod tests { set_current("zh-hans"); assert!(matches("ec.blur.gaussian", "Gaussian Blur", "blur")); assert!(matches("ec.blur.gaussian", "Gaussian Blur", "gaussian")); - assert!(matches("ec.blur.gaussian", "Gaussian Blur", "高斯")); + assert!(matches("ec.blur.gaussian", "Gaussian Blur", &name("ec.blur.gaussian", "Gaussian Blur").to_lowercase())); assert!(!matches("ec.blur.gaussian", "Gaussian Blur", "sharpen")); set_current("en"); assert!(matches("ec.blur.gaussian", "Gaussian Blur", "blur")); assert!(!matches("ec.blur.gaussian", "Gaussian Blur", "高斯")); } - - /// The drawing sites ask this catalog instead of the registry's English name (#821). Like the - /// panel catalog's literal scan this is a heuristic: it pins the expressions that used to draw - /// the English name, so a revert fails here. - #[test] - fn the_drawing_sites_ask_the_catalog() { - /// A file that must ask the catalog. - const ASKS: &[(&str, &str, &str)] = &[ - ("i18n.rs", include_str!("../i18n.rs"), "effects::name("), - ("i18n.rs", include_str!("../i18n.rs"), "effects::category("), - ("panels/effects_presets.rs", include_str!("../panels/effects_presets.rs"), "effects::name("), - ("panels/effects_presets.rs", include_str!("../panels/effects_presets.rs"), "effects::category("), - ("panels/effect_controls.rs", include_str!("../panels/effect_controls.rs"), "effects::group("), - ("panels/timeline.rs", include_str!("../panels/timeline.rs"), "effects::group("), - ]; - /// An expression that drew the registry's English name and must be gone. - const RAW: &[(&str, &str, &str)] = &[ - ("panels/effects_presets.rs", include_str!("../panels/effects_presets.rs"), "pos2(r.min.x + 56.0 + indent, r.center().y), e.name, query, &t)"), - ("panels/effect_controls.rs", include_str!("../panels/effect_controls.rs"), "Align2::LEFT_CENTER, &g.name,"), - ("panels/timeline.rs", include_str!("../panels/timeline.rs"), "Align2::LEFT_CENTER, name,"), - ]; - for (path, source, call) in ASKS { - assert!(source.contains(call), "{path} does not ask the effect-name catalog (`{call}`)"); - } - for (path, source, raw) in RAW { - assert!(!source.contains(raw), "{path} draws the registry's English name again: `{raw}`"); - } - } } diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 434f2b79..0a4c7b4c 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -12,6 +12,9 @@ //! A row that carries values keeps one `{}` per value and is read with [`tr_args`]; the tests below //! refuse a row whose placeholder count differs from its English source, so a translation can never //! drop a value, and `tr_args` itself cannot panic. +//! +//! A language may lack rows: the English source shows instead, and the `missing_translations_report` +//! test (run by `cargo xtask ci`) lists what each language lacks. use std::cell::Cell; use std::collections::HashMap; @@ -37,6 +40,18 @@ pub(crate) fn current() -> &'static str { type Index = HashMap<&'static str, &'static str>; +/// Every language with panel rows, for the tests and the missing-strings report. +#[cfg(test)] +const ROWS: &[(&str, &[(&str, &str)])] = &[ + ("ja", JAPANESE), + ("zh-hans", SIMPLIFIED_CHINESE), + ("zh-hant", TRADITIONAL_CHINESE), + ("uk", UKRAINIAN), + ("pt-br", PORTUGUESE), + ("cs", CZECH), + ("es", SPANISH), +]; + /// The catalog for `language`, indexed by English source (built on first use: panels translate /// hundreds of strings a frame); `None` for English and any language that has no rows yet. fn catalog(language: &str) -> Option<&'static Index> { @@ -60,6 +75,15 @@ fn catalog(language: &str) -> Option<&'static Index> { Some(index.get_or_init(|| rows.iter().copied().collect())) } +/// The panel strings `language` has no row for (every English source any catalog lists), in +/// catalog order, for the missing-strings report. +#[cfg(test)] +pub(super) fn missing(language: &str) -> Vec<&'static str> { + let table = catalog(language); + let mut seen = std::collections::HashSet::new(); + ROWS.iter().flat_map(|(_, rows)| rows.iter().map(|(en, _)| *en)).filter(|en| seen.insert(*en) && !table.is_some_and(|c| c.contains_key(en))).collect() +} + /// Translate one interface string: the current language's row for `text`, or `text` itself. pub(crate) fn tr(text: &str) -> &str { catalog(current()).and_then(|c| c.get(text)).copied().unwrap_or(text) @@ -85,7 +109,7 @@ pub(crate) fn tr_args(text: &str, args: &[&dyn Display]) -> String { out } -/// Japanese: the same rows, in the same order, as the Chinese catalogs below (the tests keep the two in step). +/// Japanese: the same rows, in the same order, as the Chinese catalogs below. const JAPANESE: &[(&str, &str)] = &[ ("Appearance Settings…", "外観設定…"), ("Add to favorites", "お気に入りに追加"), @@ -2027,59 +2051,10 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("⌘/Ctrl-drag a pin to record its motion", "⌘/Ctrl 拖曳釘選點可記錄其運動"), ("▣ {}", "▣ {}"), ("⚠ {} is used by:", "⚠ {} 已被以下專案使用:"), - // The same rows as the Simplified catalog (the tests require both lists to match). The - // Traditional wording is the translator's call and is left as the English source for now. - ("3D Reference Axes", "3D Reference Axes"), - ("Affect all layers that use this solid", "Affect all layers that use this solid"), - ("Choose grid and guide options", "Choose grid and guide options"), - ("Colorize", "Colorize"), - ("Custom", "Custom"), - ("Custom...", "Custom..."), - ("Fit", "Fit"), - ("Fit up to 100%", "Fit up to 100%"), - ("Full", "Full"), - ("Half", "Half"), - ("Only this layer uses this solid.", "Only this layer uses this solid."), - ("Proportional Grid", "Proportional Grid"), - ("Quarter", "Quarter"), - ("Region of Interest", "Region of Interest"), - ("Reset Exposure", "Reset Exposure"), - ("Show Channel and Color Management Settings", "Show Channel and Color Management Settings"), - ("Show Snapshot (hold)", "Show Snapshot (hold)"), - ("Take Snapshot", "Take Snapshot"), - ("Third", "Third"), - ("Toggle Mask and Shape Path Visibility", "Toggle Mask and Shape Path Visibility"), - ("Toggle Transparency Grid", "Toggle Transparency Grid"), - ("16 bits per channel", "16 bits per channel"), - ("32 bits per channel (float)", "32 bits per channel (float)"), - ("8 bits per channel", "8 bits per channel"), - ("Adaptive Resolution", "Adaptive Resolution"), - ("Adobe-style built-in", "Adobe-style built-in"), ("Blue", "藍色"), - ("Current Settings", "Current Settings"), - ("Disable Per-character 3D", "Disable Per-character 3D"), - ("Draft", "Draft"), - ("Enable Per-character 3D", "Enable Per-character 3D"), - ("Fast Draft", "Fast Draft"), - ("Fast Previews (Draft 3D, Faster Effects)", "Fast Previews (Draft 3D, Faster Effects)"), ("Green", "綠色"), - ("Import Image Sequence", "Import Image Sequence"), - ("Import PDF / Illustrator File", "Import PDF / Illustrator File"), - ("Import Photoshop File", "Import Photoshop File"), - ("Interpret Unlabeled Alpha As", "Interpret Unlabeled Alpha As"), ("Join the ArtCraft Discord", "加入 ArtCraft Discord"), - ("Mercury GPU Acceleration ({})", "Mercury GPU Acceleration ({})"), - ("Mercury Software Only", "Mercury Software Only"), - ("OCIO (built-in ACES config)", "OCIO (built-in ACES config)"), - ("Off (Final Quality)", "Off (Final Quality)"), - ("OpenGL (compatibility)", "OpenGL (compatibility)"), - ("Opus Bitrate: {} kbps", "Opus Bitrate: {} kbps"), - ("RGB Straight", "RGB Straight"), ("Red", "紅色"), - ("Tone map", "Tone map"), - ("Use In-Window Menu Bar on macOS", "Use In-Window Menu Bar on macOS"), - ("Wireframe", "Wireframe"), - ("sRGB IEC61966-2.1 (default)", "sRGB IEC61966-2.1 (default)"), // The Home screen (#574). ("New Project", "新增專案"), ("Open Project…", "開啟專案…"), @@ -4443,7 +4418,7 @@ mod tests { use super::*; /// The interface text that has moved into this catalog. Add a panel here when you convert it; - /// the tests below then keep it translated. + /// `every_row_is_used_by_a_converted_panel` then looks for its rows' English sources in it. const CONVERTED: &[(&str, &str)] = &[ ("color_picker.rs", include_str!("../color_picker.rs")), ("credits.rs", include_str!("../credits.rs")), @@ -4500,86 +4475,31 @@ mod tests { ("panels/viewers.rs", include_str!("../panels/viewers.rs")), // Text the interface draws but does not own: the settings pages and the viewer's channel // and Fast Previews lists come from the engine as data, so their source strings live here. - // Both files pass the literal scan below unchanged (they hold no drawn literals). ("engine settings pages", include_str!("../../../engine/src/prefs.rs")), ("engine viewer channels", include_str!("../../../engine/src/viewer.rs")), ("engine viewer commands", include_str!("../../../engine/src/commands/viewer_cmds.rs")), ]; - /// Call shapes that hand a string literal straight to the user. - const CALLS: &[&str] = &[ - ".label(\"", - ".button(\"", - ".small(\"", - ".strong(\"", - ".heading(\"", - ".monospace(\"", - ".on_hover_text(\"", - ".on_disabled_hover_text(\"", - ".hint_text(\"", - ".text(\"", - ".prefix(\"", - ".suffix(\"", - "RichText::new(\"", - "Label::new(\"", - "Button::new(\"", - "Window::new(\"", - "CollapsingHeader::new(\"", - ]; - - /// Literals that stay as they are: a unit or a separator reads the same in every language. - const UNITS: &[&str] = &[" px", "%", "·", "s", "ms", "°", "fps", " bpc", " mm", " s", " %", " pixels"]; - fn catalogs() -> Vec<(&'static str, &'static str)> { - JAPANESE - .iter() - .copied() - .chain(SIMPLIFIED_CHINESE.iter().copied()) - .chain(TRADITIONAL_CHINESE.iter().copied()) - .chain(UKRAINIAN.iter().copied()) - .chain(PORTUGUESE.iter().copied()) - .chain(CZECH.iter().copied()) - .chain(SPANISH.iter().copied()) - .collect() + ROWS.iter().flat_map(|(_, rows)| rows.iter().copied()).collect() } - #[test] - fn czech_panels_translate_and_keep_project_content_and_argument_order() { - set_current("cs"); - assert_eq!(tr("Composition Name:"), "Název kompozice:"); - assert_eq!(tr("Cancel"), "Zrušit"); - assert_eq!(tr("Guide Layer"), "Pomocná vrstva", "the layer context menus' item, as Layer ▸ Guide Layer"); - assert_eq!(tr("my layer.mp4"), "my layer.mp4"); - assert_eq!(tr_args("Proxy Use: {}", &[&"Best"]), "Použití zástupných souborů: Best"); - assert_eq!(tr_args("Step {} of {}: {}", &[&1, &3, &"Colour"]), "Krok 1 z 3: Colour"); - assert_eq!(tr_args("Template: {}\n{}", &[&"Title", &"My description"]), "Šablona: Title\nMy description"); - assert_eq!(tr_args("Leave all attributes in \"{}\"", &[&"File Čeština"]), "Ponechat všechny atributy v „File Čeština“"); - assert_eq!( - tr_args("{} — double-click to open, {}-click to reveal", &[&"File.ecproj", &"Alt"]), - "File.ecproj — dvojklikem otevřete, kliknutím s Alt zobrazíte umístění" - ); - assert_eq!(tr_args("Proxy Use: {}", &[]), "Použití zástupných souborů: "); - set_current("en"); - assert_eq!(tr("Composition Name:"), "Composition Name:"); + /// `en`'s row in `rows`, else `en`: what `tr` should return without pinning one wording. + fn row(rows: &[(&'static str, &'static str)], en: &'static str) -> &'static str { + rows.iter().find(|(source, _)| *source == en).map_or(en, |(_, translated)| *translated) } /// A row that translates nothing, repeats a source string, or changes the number of values the /// English source carries is a bug in the catalog itself. #[test] fn catalogs_are_unique_and_keep_the_english_shape() { - let hans: Vec<&str> = SIMPLIFIED_CHINESE.iter().map(|(en, _)| *en).collect(); - let hant: Vec<&str> = TRADITIONAL_CHINESE.iter().map(|(en, _)| *en).collect(); - assert_eq!(hans, hant, "the two Chinese catalogs list different source strings"); - // Japanese, Ukrainian and Czech may lag the Chinese catalogs: a missing row falls back to English. - for (name, cat) in [("Japanese", JAPANESE), ("Ukrainian", UKRAINIAN), ("Brazilian Portuguese", PORTUGUESE), ("Czech", CZECH), ("Spanish", SPANISH)] { - let extra: Vec<&str> = cat.iter().map(|(en, _)| *en).filter(|en| !hans.contains(en)).collect(); - assert!(extra.is_empty(), "the {name} catalog lists source strings the Chinese ones don't: {extra:?}"); + for (code, rows) in ROWS { + let mut sources: Vec<&str> = rows.iter().map(|(en, _)| *en).collect(); + sources.sort_unstable(); + let listed = sources.len(); + sources.dedup(); + assert_eq!(listed, sources.len(), "{code}: a source string is listed twice"); } - let mut sorted = hans.clone(); - sorted.sort_unstable(); - let listed = sorted.len(); - sorted.dedup(); - assert_eq!(listed, sorted.len(), "a source string is listed twice"); for (en, translated) in catalogs() { assert!(!en.is_empty() && !translated.is_empty(), "empty row for {en:?}"); assert!(en.contains("{}") || !en.contains('{'), "{en:?} has a placeholder but no `{{}}`"); @@ -4596,78 +4516,28 @@ mod tests { set_current("xx"); // a language without rows assert_eq!(tr("Composition Name:"), "Composition Name:"); set_current("zh-hans"); - assert_eq!(tr("Composition Name:"), "合成名称:"); + assert_eq!(tr("Composition Name:"), row(SIMPLIFIED_CHINESE, "Composition Name:")); assert_eq!(tr("my layer.mp4"), "my layer.mp4"); set_current("en"); } - /// The same language preference used for the menus reaches panels immediately, and formatted - /// text keeps the caller's values intact even when they contain non-Ukrainian words. - #[test] - fn ukrainian_panels_translate_and_keep_project_content() { - set_current("uk"); - assert_eq!(tr("Composition Name:"), "Назва композиції:"); - assert_eq!(tr("Cancel"), "Скасувати"); - assert_eq!(tr("my layer.mp4"), "my layer.mp4"); - assert_eq!(tr_args("Proxy Use: {}", &[&"Best"]), "Використання проксі: Best"); - assert_eq!(tr_args("Step {} of {}: {}", &[&1, &3, &"Colour"]), "Крок 1 з 3: Colour"); - assert_eq!(tr_args("Template: {}\n{}", &[&"Title", &"My description"]), "Шаблон: Title\nMy description"); - set_current("en"); - assert_eq!(tr("Composition Name:"), "Composition Name:"); - } - - /// Brazilian Portuguese panels translate, fill values in order and keep project content. - #[test] - fn brazilian_portuguese_panels_translate_and_keep_project_content() { - set_current("pt-br"); - assert_eq!(tr("Composition Name:"), "Nome da composição:"); - assert_eq!(tr("Cancel"), "Cancelar"); - assert_eq!(tr("my layer.mp4"), "my layer.mp4"); - assert_eq!(tr_args("Step {} of {}: {}", &[&1, &3, &"Colour"]), "Etapa 1 de 3: Colour"); - assert_eq!(tr(" OK "), " OK "); - set_current("en"); - assert_eq!(tr("Cancel"), "Cancel"); - } - - /// Spanish panels translate, fill values in order and keep project content. - #[test] - fn spanish_panels_translate_and_keep_project_content() { - set_current("es"); - assert_eq!(tr("Composition Name:"), "Nombre de la composición:"); - assert_eq!(tr("Cancel"), "Cancelar"); - assert_eq!(tr("my layer.mp4"), "my layer.mp4"); - assert_eq!(tr_args("Step {} of {}: {}", &[&1, &3, &"Colour"]), "Paso 1 de 3: Colour"); - set_current("en"); - assert_eq!(tr("Cancel"), "Cancel"); - } - /// Values fill the placeholders in order, and a missing value is dropped instead of panicking. #[test] fn tr_args_fills_values_in_order() { set_current("zh-hans"); - assert_eq!(tr_args("Proxy Use: {}", &[&"Best"]), "代理使用:Best"); - assert_eq!(tr_args("Step {} of {}: {}", &[&1, &3, &"Colour"]), "第 1 步,共 3 步:Colour"); - assert_eq!(tr_args("Proxy Use: {}", &[]), "代理使用:"); + let template = row(SIMPLIFIED_CHINESE, "Proxy Use: {}"); + assert_eq!(tr_args("Proxy Use: {}", &[&"Best"]), template.replacen("{}", "Best", 1)); + assert_eq!(tr_args("Proxy Use: {}", &[]), template.replacen("{}", "", 1)); set_current("en"); + assert_eq!(tr_args("Step {} of {}: {}", &[&1, &3, &"Colour"]), "Step 1 of 3: Colour"); assert_eq!(tr_args("Proxy Use: {}", &[&"Best"]), "Proxy Use: Best"); + assert_eq!(tr_args("Proxy Use: {}", &[]), "Proxy Use: "); } - /// Japanese reads its own rows: values fill the placeholders in order, and a name that is not a - /// row (a layer or file name) stays verbatim. - #[test] - fn japanese_rows_translate_the_panels() { - set_current("ja"); - assert_eq!(tr("Composition Name:"), "コンポジション名:"); - assert_eq!(tr("my layer.mp4"), "my layer.mp4"); - assert_eq!(tr_args("Proxy Use: {}", &[&"Best"]), "プロキシの使用:Best"); - assert_eq!(tr_args("Step {} of {}: {}", &[&1, &3, &"Colour"]), "ステップ1/3:Colour"); - set_current("en"); - } - - /// Every row is asked for by a converted panel: a leftover row means the panel went back to its - /// English literal. A string a helper draws for the panel (a toolbar tooltip, a popup item, a - /// checkbox label) is looked up inside that helper, so its English source only appears as a - /// literal there - `helper_labels_are_translated` keeps those helpers honest. + /// Every row is asked for by a converted panel: a leftover row is stale, because the panel's + /// English source changed or went away. A string a helper draws for the panel (a toolbar + /// tooltip, a popup item, a checkbox label) is looked up inside that helper, so its English + /// source only appears as a literal there. #[test] fn every_row_is_used_by_a_converted_panel() { for (en, _) in catalogs() { @@ -4680,84 +4550,4 @@ mod tests { ); } } - - /// A label that a helper takes as an argument is drawn by the helper, so the helper has to ask - /// the catalog: `Checkbox::new` and `RadioButton::new` are not among the scanned call shapes - /// (the label is their second argument), which let the solid dialog's checkbox ship in English. - #[test] - fn helper_labels_are_translated() { - for (path, source) in CONVERTED { - for marker in ["Checkbox::new(", "RadioButton::new("] { - let mut from = 0; - while let Some(at) = source[from..].find(marker) { - let at = from + at; - let rest = &source[at + marker.len()..]; - let line = &rest[..rest.find('\n').unwrap_or(rest.len())]; - if let Some(q) = line.find('"') { - let before = line[..q].trim_end(); - assert!(before.ends_with("tr("), "{path}: the `{marker}` label `{}` is not in the catalog", &line[q..]); - } - from = at + marker.len(); - } - } - } - } - - /// The Settings pages come from the engine as data, so the literal scan above cannot see them: - /// every title, section, label, choice, button, note and unit they draw needs a Chinese row - /// (#1178). Language names are written in their own language, and a few labels read the same - /// everywhere. - #[test] - fn settings_pages_are_translated() { - use effectcraft_engine::prefs::{Item, Kind, LANGUAGES, pages}; - const VERBATIM: &[&str] = &["", "3D", "1/2", "1/4", "1/8", "sRGB", "Display P3", "px", "s", "%", "MB", "GB", "Hz", "pt"]; - let mut drawn = Vec::new(); - for page in pages() { - drawn.push(page.title); - for item in page.items { - match item { - Item::Section(text) | Item::Note(text) | Item::Button { label: text, .. } => drawn.push(text), - Item::Setting { label, kind, .. } => { - drawn.push(label); - match kind { - Kind::Int(_, _, unit) | Kind::Float(_, _, unit) => drawn.push(unit), - Kind::Choice(options) => drawn.extend(options.iter().map(|(l, _)| *l)), - _ => {} - } - } - _ => {} - } - } - } - drawn.push("Stored, but not used by EffectCraft yet"); - let languages: Vec<&str> = LANGUAGES.iter().map(|(l, _)| *l).filter(|l| *l != "Match System").collect(); - let hans: HashMap<&str, &str> = SIMPLIFIED_CHINESE.iter().copied().collect(); - let mut missing: Vec<&str> = drawn.into_iter().filter(|en| !VERBATIM.contains(en) && !languages.contains(en) && !hans.contains_key(en)).collect(); - missing.sort_unstable(); - missing.dedup(); - assert!(missing.is_empty(), "Settings text with no Simplified Chinese row: {missing:?}"); - } - - /// A newly hard-coded interface string fails here instead of shipping in English to every - /// language. It is a heuristic - it looks at the call shapes the panels use - and it ignores - /// one-character literals, which are punctuation or a key name rather than a sentence. - #[test] - fn converted_panels_have_no_unwrapped_literals() { - for (path, source) in CONVERTED { - for (n, line) in source.lines().enumerate() { - let code = line.split("//").next().unwrap_or(line); - for call in CALLS { - let Some(at) = code.find(call) else { continue }; - let rest = &code[at + call.len()..]; - let Some(end) = rest.find('"') else { continue }; - let literal = &rest[..end]; - assert!( - literal.chars().count() < 2 || UNITS.contains(&literal), - "{path}:{}: interface text `{literal}` in `{call}` is not in the catalog", - n + 1 - ); - } - } - } - } } diff --git a/crates/ui-egui/src/panels/home.rs b/crates/ui-egui/src/panels/home.rs index 338f9ef8..269f2f44 100644 --- a/crates/ui-egui/src/panels/home.rs +++ b/crates/ui-egui/src/panels/home.rs @@ -556,17 +556,15 @@ mod tests { } /// The Home screen's own text goes through the interface catalog (#574): the Opened and Kind - /// columns read in Chinese, and English stays English. + /// columns read in the interface language, and English stays English. #[test] fn home_text_follows_the_interface_language() { let now = 1_790_000_000; crate::i18n::set_current("zh-hans"); - let zh = (format_ago(now, now - 5), format_ago(now, now - 3 * 3_600), format_ago(now, now - 90_000), kind_of("/a/B.ecproj")); - crate::i18n::set_current("zh-hant"); - let tw = (format_ago(now, now - 60), kind_of("x")); + let shown = (format_ago(now, now - 5), format_ago(now, now - 3 * 3_600), format_ago(now, now - 90_000), kind_of("/a/B.ecproj")); + let catalog = (tr("Just now").to_string(), tr_args("{} hours ago", &[&3]), tr("Yesterday").to_string(), tr("EffectCraft project")); crate::i18n::set_current("en"); - assert_eq!(zh, ("刚刚".into(), "3 小时前".into(), "昨天".into(), "EffectCraft 项目")); - assert_eq!(tw, ("1 分鐘前".into(), "專案")); + assert_eq!(shown, catalog); assert_eq!(tr("Filter recent files"), "Filter recent files"); } diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 5606a857..a9f6b04a 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (catalogs recounted at `upstream/main` 470ea7a3: menus in 10 languages, panels in 6, Hangul probe (#599)) · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-23 · **Change:** minor (missing strings show in English; `missing_translations_report` lists them) · **Target:** Adobe After Effects 2026 How much of EffectCraft's interface is translated, language by language, compared with After Effects. [gaps.md](gaps.md) gap 16 and workstream G9 hold the work; @@ -17,9 +17,9 @@ interface; Arabic and Hebrew text *in layers* is supported through its Middle Ea ## How EffectCraft translates - **Menus:** `crates/ui-egui/src/i18n.rs`, one catalog per language keyed by - `(command, English label)`. A test (`every_translation_key_exists_in_the_actual_menu_tree`) - fails if any fixed menu entry is untranslated or a row is stale, so every catalog covers all - fixed entries (624 catalog rows each at 470ea7a3, counted). The generated Effect menu is not part of that fixed tree: its entries + `(command, English label)` (624 catalog rows each at 470ea7a3, counted). A test + (`menu_catalogs_have_no_stale_or_duplicate_rows`) fails on a row whose entry left the menu tree + or on a repeated row. The generated Effect menu is not part of that fixed tree: its entries and category submenus take their labels from the effect catalog below. - **Panels, dialogs, buttons, tooltips:** `crates/ui-egui/src/i18n/ui.rs`, keyed by the English source string, with English fallback. Catalogs exist for Japanese (410 rows), Simplified and @@ -38,6 +38,12 @@ interface; Arabic and Hebrew text *in layers* is supported through its Middle Ea slot is for). Each language decides for itself which names stay English — Adobe's Japanese interface leaves the `CC`, `VR` and `OCIO` sets in English while its Chinese one translates them — so a row repeating the English name is a choice, not a missing row. +- **Missing strings:** a string with no row in the interface language shows in English, and no + test, build or release fails on a missing translation. The `missing_translations_report` test + (`crates/ui-egui/src/i18n.rs`) prints, per language, the fixed menu entries, panel strings, effect + names and effect categories that have no row. It always passes; `cargo xtask ci` runs it with + `--nocapture`, so the list is in the gate log. Locally: + `cargo test -p effectcraft-ui-egui --lib missing_translations_report -- --nocapture`. - **Not translated anywhere yet:** effect *parameter* names (≈ 1,650, `crates/effects`), engine-generated text (property names, errors, undo labels), the Home screen's unwrapped literals (#574), Learn tutorials and documentation. diff --git a/docs/preferences.md b/docs/preferences.md index 8b86528a..0d032f85 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -30,7 +30,7 @@ Commands (CLI, MCP, control channel): ### Settings that change behaviour -- `general.language`: interface language (`system` / `en` / `ja` / `zh-hans` / `zh-hant` / `uk` / `es` / `pt-br`), Settings ▸ General ▸ Language; menu labels change immediately. `system` (Match System, the default) follows the operating system's interface language where EffectCraft has a translation and is English otherwise: a Simplified Chinese locale (`zh`, `zh-CN`, `zh_CN.UTF-8`, `zh-Hans-CN`, `zh-SG`) selects `zh-hans`, a Traditional one (`zh-TW`, `zh-HK`, `zh-Hant`, `zh_MO`) selects `zh-hant`, a Ukrainian one (`uk`, `uk-UA`, `uk_UA.UTF-8`) selects `uk`, a Spanish one (`es`, `es-ES`, `es_MX.UTF-8`) selects `es`, a Portuguese one (`pt`, `pt-BR`, `pt_PT.UTF-8`) selects `pt-br`; the browser build stays in English with it. Choose **Español** or **Português (Brasil)** for Spanish or Brazilian Portuguese menus (panels stay in English for now); choose **Українська** for Ukrainian menus and cataloged panel, dialog, button and tooltip text; Ukrainian uses the bundled UI fonts. Native Japanese, Simplified and Traditional Chinese UI uses installed system fonts; the web host must supply a Japanese font. The same setting picks the CJK fallback for the interface *and* the Han preference of the text engine, so the composition's own text is drawn with the same language's glyph forms (the text engine's preference is process-global); swapping the language reinstalls the fonts, so it applies without a restart. Two catalogs back the text: the menus are keyed by `(command, English label)` (`crates/ui-egui/src/i18n.rs`), and the rest of the interface - panels, dialogs, buttons, tooltips - is keyed by its English source string (`crates/ui-egui/src/i18n/ui.rs`, with rows for Japanese, Simplified and Traditional Chinese and Ukrainian), where a panel writes `tr("Rename")`, or `tr_args("Proxy Use: {}", &[&quality])` when the row carries a value. A row that is missing falls back to English, so a new string shows up untranslated rather than empty; the tests in that module fail on an unused row and on a hard-coded string in a converted panel. +- `general.language`: interface language (`system` / `en` / `ja` / `zh-hans` / `zh-hant` / `uk` / `es` / `pt-br`), Settings ▸ General ▸ Language; menu labels change immediately. `system` (Match System, the default) follows the operating system's interface language where EffectCraft has a translation and is English otherwise: a Simplified Chinese locale (`zh`, `zh-CN`, `zh_CN.UTF-8`, `zh-Hans-CN`, `zh-SG`) selects `zh-hans`, a Traditional one (`zh-TW`, `zh-HK`, `zh-Hant`, `zh_MO`) selects `zh-hant`, a Ukrainian one (`uk`, `uk-UA`, `uk_UA.UTF-8`) selects `uk`, a Spanish one (`es`, `es-ES`, `es_MX.UTF-8`) selects `es`, a Portuguese one (`pt`, `pt-BR`, `pt_PT.UTF-8`) selects `pt-br`; the browser build stays in English with it. Choose **Español** or **Português (Brasil)** for Spanish or Brazilian Portuguese menus (panels stay in English for now); choose **Українська** for Ukrainian menus and cataloged panel, dialog, button and tooltip text; Ukrainian uses the bundled UI fonts. Native Japanese, Simplified and Traditional Chinese UI uses installed system fonts; the web host must supply a Japanese font. The same setting picks the CJK fallback for the interface *and* the Han preference of the text engine, so the composition's own text is drawn with the same language's glyph forms (the text engine's preference is process-global); swapping the language reinstalls the fonts, so it applies without a restart. Two catalogs back the text: the menus are keyed by `(command, English label)` (`crates/ui-egui/src/i18n.rs`), and the rest of the interface - panels, dialogs, buttons, tooltips - is keyed by its English source string (`crates/ui-egui/src/i18n/ui.rs`, with rows for Japanese, Simplified and Traditional Chinese and Ukrainian), where a panel writes `tr("Rename")`, or `tr_args("Proxy Use: {}", &[&quality])` when the row carries a value. A row that is missing falls back to English, so a new string shows up untranslated rather than empty; the `missing_translations_report` test lists what each language lacks, and the tests in `ui.rs` fail on an unused row. - `general.undoLevels`: Levels of Undo - `general.pathPointSize`: Path Point and Handle Size - `general.recentItems`: Recent Projects Shown diff --git a/docs/testing.md b/docs/testing.md index 69a70f4d..67d622fd 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -6,7 +6,9 @@ cargo xtask ci # fmt, clippy -D warnings, tests, layers, assets, was ``` `cargo xtask ci` builds in the dev profile and runs the tests with `cargo nextest run` (plus -`cargo test --doc`) when `cargo-nextest` is installed, and with `cargo test` otherwise. +`cargo test --doc`) when `cargo-nextest` is installed, and with `cargo test` otherwise. It then +runs `missing_translations_report` with `--nocapture`, which prints the interface strings each +language has no translation for. Those strings show in English; the report always passes. Each crate with several integration-test files has one test binary, `tests/it/main.rs`, whose modules are the files in `tests/it/`. Run one file with `cargo test -p --test it ::` diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 468f832c..e4ddaef7 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -427,6 +427,8 @@ fn tests() -> Result<(), String> { println!("cargo-nextest not found, running cargo test (install: cargo install cargo-nextest --locked)"); runs.push(run(Command::new(cargo).arg("test").args(targets))); } + // The test runs above hide a passing test's output, so the always-passing report runs again with --nocapture. + runs.push(run(Command::new(cargo).args(["test", "-p", "effectcraft-ui-egui", "--lib", "missing_translations_report", "--", "--nocapture"]))); runs.push(run(Command::new(cargo).args(["test", "--workspace", "--doc", "--no-fail-fast"]))); let failed: Vec = runs.into_iter().filter_map(Result::err).collect(); if failed.is_empty() { Ok(()) } else { Err(failed.join("; ")) }