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.
This commit is contained in:
aiongg
2026-10-23 15:42:31 -04:00
committed by GitHub
parent 357b4c5bd5
commit c2df1c1a73
8 changed files with 172 additions and 803 deletions
+57 -443
View File
@@ -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::<Vec<_>>()
};
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::<Vec<_>>();
// "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 <somewhere>" 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<String> {
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::<Vec<_>>()
};
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::<Vec<_>>()
};
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());
+49 -92
View File
@@ -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<String> {
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}`");
}
}
}
+46 -256
View File
@@ -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
);
}
}
}
}
}
+4 -6
View File
@@ -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");
}
+10 -4
View File
@@ -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.
+1 -1
View File
@@ -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
+3 -1
View File
@@ -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 <crate> --test it <module>::`
+2
View File
@@ -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<String> = runs.into_iter().filter_map(Result::err).collect();
if failed.is_empty() { Ok(()) } else { Err(failed.join("; ")) }