mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-26 19:18:14 -06:00
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:
+57
-443
@@ -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());
|
||||
|
||||
@@ -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
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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>::`
|
||||
|
||||
@@ -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("; ")) }
|
||||
|
||||
Reference in New Issue
Block a user