Tests: missing translations don't fail; xtask ci prints them

Delete the coverage tests, the per-language parity checks and the tests
of one language's exact wording. Catalog integrity, placeholder and
plural-form checks stay, as do the engine tests (locale resolution,
plural rules, fallback, language scoping, drawing in a language); those
compare against tr() or the plural rule rather than catalog wording.
Docs describe the report instead of a failing test.
This commit is contained in:
aiongg
2026-10-23 19:07:23 +00:00
parent cb297eeac1
commit c3aa3f9e51
30 changed files with 178 additions and 1140 deletions
+3 -3
View File
@@ -1075,7 +1075,7 @@ mod tests {
#[test] #[test]
fn curves_channel_popup_keeps_custom_names_verbatim_in_italian() { fn curves_channel_popup_keeps_custom_names_verbatim_in_italian() {
let it = crate::i18n::Lang::from_code("it").expect("it registered"); let it = crate::i18n::Lang::from_code("it").expect("it registered");
assert_eq!(crate::i18n::tr(it, "Green"), "Verde", "the test relies on the built-in label"); let green = crate::i18n::tr(it, "Green");
let _lang = crate::i18n::language_scope(it); let _lang = crate::i18n::language_scope(it);
let mut app = app_with_image(); let mut app = app_with_image();
app.run("channel.new", json!({"name": "Green", "fill": "white"})).unwrap(); app.run("channel.new", json!({"name": "Green", "fill": "white"})).unwrap();
@@ -1083,13 +1083,13 @@ mod tests {
let mut h = curves_harness(app); let mut h = curves_harness(app);
let names = popup_channels(&mut h); let names = popup_channels(&mut h);
assert!(names.iter().any(|n| n == "Green"), "custom names stay as entered: {names:?}"); assert!(names.iter().any(|n| n == "Green"), "custom names stay as entered: {names:?}");
assert!(!names.iter().any(|n| n == "Verde"), "not translated: {names:?}"); assert!(green == "Green" || !names.iter().any(|n| n == green), "not translated: {names:?}");
// A targeted colour component keeps its translated built-in label. // A targeted colour component keeps its translated built-in label.
let mut colour = app_with_image(); let mut colour = app_with_image();
colour.run("channel.target", json!({"channel": {"color": 1}})).unwrap(); colour.run("channel.target", json!({"channel": {"color": 1}})).unwrap();
let mut h = curves_harness(colour); let mut h = curves_harness(colour);
let names = popup_channels(&mut h); let names = popup_channels(&mut h);
assert!(names.iter().any(|n| n == "Verde"), "built-in labels follow the language: {names:?}"); assert!(names.iter().any(|n| n == green), "built-in labels follow the language: {names:?}");
} }
/// Curves open in a harness over `app`. /// Curves open in a harness over `app`.
-1
View File
@@ -1378,7 +1378,6 @@ fn brush_sections_paint_simplified_chinese_labels() {
for (name, _) in SECTIONS { for (name, _) in SECTIONS {
let translated = crate::i18n::tr(zh, name); let translated = crate::i18n::tr(zh, name);
assert!(painted.iter().any(|s| s == translated), "{name} must paint as {translated}"); assert!(painted.iter().any(|s| s == translated), "{name} must paint as {translated}");
assert!(!painted.iter().any(|s| s == name), "{name} must not paint in English");
} }
} }
-20
View File
@@ -154,24 +154,4 @@ mod tests {
assert_eq!(clear.shortcut, Some("Delete")); assert_eq!(clear.shortcut, Some("Delete"));
assert_eq!(delete.shortcut, None); assert_eq!(delete.shortcut, None);
} }
#[test]
fn confirmation_strings_are_translated_in_every_supported_catalog() {
for language in crate::i18n::LANGUAGES.iter().filter(|l| l.code != "en") {
let lang = crate::i18n::Lang::from_code(language.code).unwrap();
for key in [
"Delete layer ‘{name}’?",
"Delete {count} layers? {names}",
"Delete layer?",
"Don't show again",
"The document is closed",
"The document or selected layers changed. Please request deletion again.",
"Missing delete command",
"Invalid delete command",
"Layer deleted, but confirmation preference could not be saved: {error}",
] {
assert_ne!(crate::i18n::tr(lang, key), key, "{}: {key}", language.code);
}
}
}
} }
+5 -2
View File
@@ -787,8 +787,11 @@ mod tests {
assert_eq!(button_labels(false, &buttons), ["(Y)es", "(N)o", "(C)ancel"]); assert_eq!(button_labels(false, &buttons), ["(Y)es", "(N)o", "(C)ancel"]);
let german = crate::i18n::Lang::from_code("de").unwrap(); let german = crate::i18n::Lang::from_code("de").unwrap();
crate::i18n::with_language(german, || { crate::i18n::with_language(german, || {
assert_eq!(button_labels(false, &buttons), ["Ja (Y)", "Nein (N)", "Abbrechen (C)"]); let shown: Vec<&str> = buttons.iter().map(|(label, _)| crate::i18n::tr(german, label)).collect();
assert_eq!(button_labels(true, &buttons), ["Ja", "Nein", "Abbrechen"]); assert_eq!(button_labels(true, &buttons), shown);
let labels = button_labels(false, &buttons);
let appended: Vec<String> = shown.iter().zip(&buttons).map(|(text, (_, key))| format!("{text} ({})", key.map_or("", |k| k.name()))).collect();
assert!(labels.iter().all(|label| label.starts_with('(')) || labels == appended, "{labels:?}");
}); });
} }
+1 -6
View File
@@ -385,15 +385,10 @@ mod tests {
} }
#[test] #[test]
fn image_mode_depth_labels_match_sample_types_and_are_translated() { fn image_mode_depth_labels_match_sample_types() {
assert_eq!( assert_eq!(
DEPTHS.iter().map(|(sample, label, _)| (*sample, *label)).collect::<Vec<_>>(), DEPTHS.iter().map(|(sample, label, _)| (*sample, *label)).collect::<Vec<_>>(),
[(SampleType::U8, "8 Bits/Channel"), (SampleType::U16, "16 Bits/Channel"), (SampleType::F32, "32 Bits/Channel"),] [(SampleType::U8, "8 Bits/Channel"), (SampleType::U16, "16 Bits/Channel"), (SampleType::F32, "32 Bits/Channel"),]
); );
for lang in crate::i18n::Lang::all().filter(|lang| lang.code() != "en") {
for (_, label, id) in DEPTHS {
assert_ne!(crate::i18n::tr_id(lang, id, label), *label, "{}: {label}", lang.code());
}
}
} }
} }
+3 -98
View File
@@ -1396,14 +1396,10 @@ mod tests {
assert_eq!(source_label("centerX"), "Center X"); assert_eq!(source_label("centerX"), "Center X");
} }
/// Convert to Profile's and Proof Setup's `bpc` read "Bpc": spelled out, and translated. /// Convert to Profile's and Proof Setup's `bpc` read "Bpc": spelled out.
#[test] #[test]
fn black_point_compensation_is_spelled_out() { fn black_point_compensation_is_spelled_out() {
assert_eq!(source_label("bpc"), "Black Point Compensation"); assert_eq!(source_label("bpc"), "Black Point Compensation");
// English is the source: it has no catalog.
for lang in crate::i18n::Lang::all().filter(|l| l.code() != "en") {
assert!(crate::i18n::has(lang, "Black Point Compensation"), "{lang:?}");
}
} }
fn key() -> FilterPreviewKey { fn key() -> FilterPreviewKey {
@@ -1855,105 +1851,14 @@ mod tests {
assert!(!has_dialog("filter.stylize.findEdges")); assert!(!has_dialog("filter.stylize.findEdges"));
} }
/// Generated filter and adjustment labels (param names, symbolic choices) and gallery /// Select and Mask's Output To lists all six output targets.
/// names that `lang` has no catalog row for.
fn missing_generated_filter_labels(lang: crate::i18n::Lang) -> std::collections::BTreeSet<String> {
let mut missing = std::collections::BTreeSet::new();
let mut check = |s: String| {
if !matches!(s.as_str(), "X" | "Y" | "A" | "B") && !crate::i18n::has(lang, &s) {
missing.insert(s);
}
};
for c in photocraft_engine::command_specs().iter().filter(|c| c.id.starts_with("filter.") || c.id.starts_with("image.adjustments.")) {
for p in parse_spec(c.params) {
if !p.key.chars().all(|c| c.is_ascii_alphanumeric()) || matches!(p.kind, Kind::Json) {
continue;
}
check(source_label(&p.key));
if let Kind::Choice { options, .. } = &p.kind {
for choice in options {
// Only symbolic options are UI choices; registry docs also contain
// colour syntax and array notation, which are not translatable names.
if choice.chars().all(|c| c.is_ascii_alphanumeric()) {
check(source_label(choice));
}
}
}
}
}
for f in photocraft_algo::GalleryFilter::ALL {
check(f.name().to_string());
}
for cat in photocraft_algo::GALLERY_CATEGORIES {
check(cat.to_string());
}
missing
}
#[test] #[test]
fn korean_covers_generated_filter_options_and_gallery_names() { fn select_and_mask_output_lists_all_six_targets() {
let missing = missing_generated_filter_labels(crate::i18n::lang_from_tag("ko-KR").unwrap());
assert!(missing.is_empty(), "missing Korean dynamic labels: {missing:#?}");
}
#[test]
fn italian_covers_generated_filter_options_and_gallery_names() {
let missing = missing_generated_filter_labels(crate::i18n::lang_from_tag("it-IT").unwrap());
assert!(missing.is_empty(), "missing Italian dynamic labels: {missing:#?}");
}
#[test]
fn spanish_covers_generated_filter_options_and_gallery_names() {
let missing = missing_generated_filter_labels(crate::i18n::lang_from_tag("es-ES").unwrap());
assert!(missing.is_empty(), "missing Spanish dynamic labels: {missing:#?}");
}
#[test]
fn russian_covers_generated_filter_options_and_gallery_names() {
let missing = missing_generated_filter_labels(crate::i18n::lang_from_tag("ru-RU").unwrap());
assert!(missing.is_empty(), "missing Russian dynamic labels: {missing:#?}");
}
/// The Select menu's generated dialogs (Select and Mask, Modify ▸ …) show their
/// params, choices and title in every UI language, not just Korean.
#[test]
fn select_dialogs_are_translated_in_every_language() {
let mut missing = std::collections::BTreeMap::<&str, std::collections::BTreeSet<String>>::new();
for lang in crate::i18n::Lang::all().filter(|l| l.code() != "en") {
let mut check = |s: String| {
if !crate::i18n::has(lang, &s) {
missing.entry(lang.code()).or_default().insert(s);
}
};
for c in photocraft_engine::command_specs().iter().filter(|c| c.id.starts_with("select.") && has_dialog(c.id)) {
let title = c.label.trim_end_matches('…');
if !crate::i18n::has(lang, title) {
check(format!("{title}…"));
}
for p in parse_spec(c.params) {
check(source_label(&p.key));
if let Kind::Choice { options: choices, .. } = p.kind {
choices.iter().for_each(|choice| check(source_label(choice)));
}
}
}
}
assert!(missing.is_empty(), "untranslated Select dialog labels: {missing:#?}");
}
/// Select and Mask's Output To lists all six of Photoshop's targets, each translated.
#[test]
fn select_and_mask_output_choices_are_translated() {
let spec = photocraft_engine::commands::find("select.refineEdge").unwrap().params; let spec = photocraft_engine::commands::find("select.refineEdge").unwrap().params;
let p = parse_spec(spec).into_iter().find(|p| p.key == "output").unwrap(); let p = parse_spec(spec).into_iter().find(|p| p.key == "output").unwrap();
let Kind::Choice { options: choices, .. } = p.kind else { panic!("output is not a choice") }; let Kind::Choice { options: choices, .. } = p.kind else { panic!("output is not a choice") };
let labels: Vec<String> = choices.iter().map(|c| source_label(c)).collect(); let labels: Vec<String> = choices.iter().map(|c| source_label(c)).collect();
assert_eq!(labels, ["Selection", "Layer Mask", "New Layer", "New Layer With Mask", "New Document", "New Document With Mask"]); assert_eq!(labels, ["Selection", "Layer Mask", "New Layer", "New Layer With Mask", "New Document", "New Document With Mask"]);
for lang in crate::i18n::Lang::all().filter(|l| l.code() != "en") {
for label in &labels {
assert!(crate::i18n::has(lang, label), "{}: untranslated {label}", lang.code());
}
}
} }
#[test] #[test]
+10 -4
View File
@@ -297,8 +297,11 @@ mod tests {
assert_eq!(result["info"]["lines"], json!(english)); assert_eq!(result["info"]["lines"], json!(english));
assert_eq!(crate::i18n::current().code(), "cs", "diagnostics restore the drawing language"); assert_eq!(crate::i18n::current().code(), "cs", "diagnostics restore the drawing language");
let screen = system_info_ui(&app); let screen = system_info_ui(&app);
assert!(screen.iter().any(|line| line == "Grafický adaptér: Test GPU"), "{screen:?}"); let cs = crate::i18n::current();
assert!(screen.iter().any(|line| line.starts_with("Profil monitoru:")), "{screen:?}"); let adapter = crate::i18n::fmt(crate::i18n::tr(cs, "Graphics adapter: {adapter}"), &[("adapter", "Test GPU")]);
assert!(screen.contains(&adapter), "{screen:?}");
let profile = crate::i18n::tr(cs, "Monitor profile: {profile}").split('{').next().unwrap_or_default();
assert!(screen.iter().any(|line| line.starts_with(profile)), "{screen:?}");
assert!(english.iter().any(|line| line == "GPU backend preference: auto")); assert!(english.iter().any(|line| line == "GPU backend preference: auto"));
}); });
} }
@@ -313,8 +316,11 @@ mod tests {
app.perf.gpu_info.preference = "auto".into(); app.perf.gpu_info.preference = "auto".into();
app.perf.gpu_info.selected = "auto".into(); app.perf.gpu_info.selected = "auto".into();
assert_eq!(system_info_ui(&app), empty); assert_eq!(system_info_ui(&app), empty);
assert!(empty.iter().any(|line| line == "Grafický adaptér: auto")); let cs = crate::i18n::current();
assert!(empty.iter().any(|line| line == "Preferovaný backend GPU: automaticky"), "{empty:?}"); let adapter = crate::i18n::fmt(crate::i18n::tr(cs, "Graphics adapter: {adapter}"), &[("adapter", "auto")]);
assert!(empty.contains(&adapter), "{empty:?}");
let preference = crate::i18n::fmt(crate::i18n::tr(cs, "GPU backend preference: {backend}"), &[("backend", crate::i18n::tr(cs, "auto"))]);
assert!(empty.contains(&preference), "{empty:?}");
assert_eq!(app.perf.gpu_info.preference, "auto"); assert_eq!(app.perf.gpu_info.preference, "auto");
assert_eq!(app.perf.gpu_info.selected, "auto"); assert_eq!(app.perf.gpu_info.selected, "auto");
}); });
-11
View File
@@ -187,17 +187,6 @@ mod tests {
assert_eq!(states(&h).0, steps.0 - 1, "the current state is deleted"); assert_eq!(states(&h).0, steps.0 - 1, "the current state is deleted");
} }
/// The labels aren't `tl!` literals, so the catalog scan can't see them: every language that
/// claims complete menus must still translate them.
#[test]
fn menu_labels_are_translated() {
for lang in crate::i18n::Lang::all().filter(|l| l.complete_menus()) {
for &(label, _) in ITEMS.iter().filter(|(l, _)| *l != "---") {
assert!(crate::i18n::has(lang, label), "{}: {label}", lang.code());
}
}
}
#[test] #[test]
fn without_a_document_only_close_is_enabled() { fn without_a_document_only_close_is_enabled() {
let h = harness(PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default())); let h = harness(PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()));
+46 -70
View File
@@ -41,9 +41,10 @@ fn ukrainian_native_app_menu_keeps_product_and_language_names() {
with_language(Lang::EN, || { with_language(Lang::EN, || {
let h = harness(); let h = harness();
let layout = crate::native_menu::photocraft_layout(&crate::menus::menu_items(h.state()), lang("uk"), "uk"); let layout = crate::native_menu::photocraft_layout(&crate::menus::menu_items(h.state()), lang("uk"), "uk");
for (id, label) in [("help.about", "Про PhotoCraft"), (crate::native_menu::HIDE, "Приховати PhotoCraft"), ("file.exit", "Вийти з PhotoCraft")] for (id, template) in [("help.about", "About {app}"), (crate::native_menu::HIDE, "Hide {app}"), ("file.exit", "Quit {app}")] {
{ let label = fmt(tr(lang("uk"), template), &[("app", "PhotoCraft")]);
assert_eq!(layout.bar.find(id).map(|item| item.label.as_str()), Some(label), "{id}"); assert!(label.contains("PhotoCraft"), "{id}: {label}");
assert_eq!(layout.bar.find(id).map(|item| item.label.as_str()), Some(label.as_str()), "{id}");
} }
assert_eq!(layout.bar.find("app.language.uk").map(|item| (item.label.as_str(), item.checked)), Some(("Українська", Some(true)))); assert_eq!(layout.bar.find("app.language.uk").map(|item| (item.label.as_str(), item.checked)), Some(("Українська", Some(true))));
}); });
@@ -52,29 +53,21 @@ fn ukrainian_native_app_menu_keeps_product_and_language_names() {
#[test] #[test]
fn ukrainian_color_picker_titles_draw_in_the_selected_language() { fn ukrainian_color_picker_titles_draw_in_the_selected_language() {
with_language(Lang::EN, || { with_language(Lang::EN, || {
for (target, expected) in [("foreground", "Вибір кольору (основний колір)"), ("background", "Вибір кольору (колір тла)")] for (target, title) in [("foreground", "Color Picker (Foreground Color)"), ("background", "Color Picker (Background Color)")] {
{
let mut h = harness(); let mut h = harness();
h.state_mut().run("prefs.set", json!({"path": "interface.language", "value": "uk"})).unwrap(); h.state_mut().run("prefs.set", json!({"path": "interface.language", "value": "uk"})).unwrap();
crate::color_picker_ui::open(h.state_mut(), target); crate::color_picker_ui::open(h.state_mut(), target);
h.run_steps(4); h.run_steps(4);
let text = drawn_text(&h); let expected = tr(lang("uk"), title);
assert!(text.iter().any(|text| text == expected), "missing title {expected}"); assert!(drawn_text(&h).iter().any(|text| text == expected), "missing title {expected}");
assert!(!text.iter().any(|text| text.starts_with("Color Picker")), "no English picker title");
} }
assert_eq!(tr(lang("uk"), "Color Picker (Custom Canvas Color)"), "Вибір кольору (власний колір полотна)");
}); });
} }
#[test] #[test]
fn ukrainian_rasterize_prompts_explain_the_loss_of_editable_content() { fn ukrainian_rasterize_prompt_draws_in_the_selected_language() {
use crate::rasterize_prompt::Kind; use crate::rasterize_prompt::Kind;
for kind in Kind::ALL {
let translated = tr(lang("uk"), kind.message());
assert_ne!(translated, kind.message(), "{kind:?}: the warning must be translated");
assert!(translated.contains("більше не можна буде редагувати"), "{kind:?}: explain the loss of editability");
}
with_language(Lang::EN, || { with_language(Lang::EN, || {
let mut h = harness(); let mut h = harness();
h.state_mut().run("prefs.set", json!({"path": "interface.language", "value": "uk"})).unwrap(); h.state_mut().run("prefs.set", json!({"path": "interface.language", "value": "uk"})).unwrap();
@@ -85,29 +78,17 @@ fn ukrainian_rasterize_prompts_explain_the_loss_of_editable_content() {
let expected = tr(lang("uk"), Kind::Type.message()); let expected = tr(lang("uk"), Kind::Type.message());
let text = drawn_text(&h); let text = drawn_text(&h);
assert!(text.iter().any(|text| text == expected), "the actual dialog draws the Ukrainian warning"); assert!(text.iter().any(|text| text == expected), "the actual dialog draws the Ukrainian warning");
assert!(!text.iter().any(|text| text == Kind::Type.message()));
}); });
} }
#[test]
fn french_and_korean_plural_messages_keep_placeholders() {
for (n, expected) in [(0, "0 élément"), (1, "1 élément"), (2, "2 éléments"), (21, "21 éléments")] {
assert_eq!(trn(lang("fr"), n, "{n} item", "{n} items"), expected);
}
for n in [0, 1, 2, 21, u64::MAX] {
assert_eq!(trn(lang("ko"), n, "{n} item", "{n} items"), format!("항목 {n}개"));
}
assert_eq!(fmt(tr(lang("ko"), "Version {version}"), &[("version", "0.2.0")]), "버전 0.2.0");
assert_eq!(tr(lang("fr"), "unknown translation"), "unknown translation");
}
#[test] #[test]
fn scoped_preview_restores_language_even_after_a_panic() { fn scoped_preview_restores_language_even_after_a_panic() {
with_language(Lang::EN, || { with_language(Lang::EN, || {
with_language(lang("fr"), || { with_language(lang("fr"), || {
assert_eq!(t("Layer"), "Calque"); assert_eq!(current(), lang("fr"));
with_language(lang("ko"), || assert_eq!(t("Layer"), "레이어")); assert_eq!(t("Layer"), tr(lang("fr"), "Layer"));
assert_eq!(t("Layer"), "Calque"); with_language(lang("ko"), || assert_eq!(current(), lang("ko")));
assert_eq!(current(), lang("fr"));
}); });
let result = std::panic::catch_unwind(|| with_language(lang("ja"), || panic!("test unwind"))); let result = std::panic::catch_unwind(|| with_language(lang("ja"), || panic!("test unwind")));
assert!(result.is_err()); assert!(result.is_err());
@@ -120,20 +101,15 @@ fn drawing_languages_are_isolated_between_threads() {
with_language(lang("fr"), || { with_language(lang("fr"), || {
let thread = std::thread::spawn(|| { let thread = std::thread::spawn(|| {
assert_eq!(current(), Lang::EN); assert_eq!(current(), Lang::EN);
with_language(lang("ko"), || assert_eq!(t("Layer"), "레이어")); with_language(lang("ko"), || assert_eq!(t("Layer"), tr(lang("ko"), "Layer")));
}); });
thread.join().expect("language thread"); thread.join().expect("language thread");
assert_eq!(t("Layer"), "Calque"); assert_eq!(current(), lang("fr"));
}); });
} }
#[test] #[test]
fn dynamic_brush_sections_have_translations_and_draw_in_the_selected_language() { fn dynamic_brush_sections_draw_in_the_selected_language() {
for language in Lang::all().filter(|language| language.complete_menus()) {
for (name, _) in crate::brush_panel::SECTIONS {
assert!(has(language, name), "{}: untranslated brush section {name}", language.code());
}
}
with_language(Lang::EN, || { with_language(Lang::EN, || {
let mut h = harness(); let mut h = harness();
h.state_mut().run("prefs.set", json!({"path": "interface.language", "value": "zh-hans"})).expect("language"); h.state_mut().run("prefs.set", json!({"path": "interface.language", "value": "zh-hans"})).expect("language");
@@ -141,21 +117,13 @@ fn dynamic_brush_sections_have_translations_and_draw_in_the_selected_language()
h.run_steps(4); h.run_steps(4);
let text = drawn_text(&h); let text = drawn_text(&h);
// The section row is the only place the name is drawn: the options pane has no heading (#3477). // The section row is the only place the name is drawn: the options pane has no heading (#3477).
assert_eq!(text.iter().filter(|text| text.as_str() == "画笔笔尖形状").count(), 1, "the section row must be translated"); let expected = tr(lang("zh-hans"), "Brush Tip Shape");
assert!(!text.iter().any(|text| text == "Brush Tip Shape")); assert_eq!(text.iter().filter(|text| *text == expected).count(), 1, "the section row must be translated");
}); });
} }
#[test] #[test]
fn fill_dialog_and_contour_choices_have_translations_and_draw_in_the_selected_language() { fn fill_dialog_draws_in_the_selected_language() {
let labels = ["Contents:", "Color:", "Custom Pattern:", "Mode:", "Opacity:", "{size} (was {old})"];
for language in Lang::all().filter(|language| language.complete_menus()) {
let contents = crate::fill_ui::CONTENTS.iter().map(|(_, label)| *label);
let contours = photocraft_engine::layer_style::CONTOURS.iter().map(|(name, _)| *name);
for text in labels.into_iter().chain(contents).chain(contours) {
assert!(has(language, text), "{}: untranslated {text}", language.code());
}
}
with_language(Lang::EN, || { with_language(Lang::EN, || {
let mut h = harness(); let mut h = harness();
h.state_mut().run("prefs.set", json!({"path": "interface.language", "value": "pt-br"})).expect("language"); h.state_mut().run("prefs.set", json!({"path": "interface.language", "value": "pt-br"})).expect("language");
@@ -163,21 +131,15 @@ fn fill_dialog_and_contour_choices_have_translations_and_draw_in_the_selected_la
crate::menus::invoke(h.state_mut(), &ctx, crate::fill_ui::COMMAND, json!({})).expect("fill dialog"); crate::menus::invoke(h.state_mut(), &ctx, crate::fill_ui::COMMAND, json!({})).expect("fill dialog");
h.run_steps(4); h.run_steps(4);
let text = drawn_text(&h); let text = drawn_text(&h);
for want in ["Conteúdo:", "Cor de primeiro plano", "Modo:", "Opacidade:"] { for source in ["Contents:", "Foreground Color", "Mode:", "Opacity:"] {
let want = tr(lang("pt-br"), source);
assert!(text.iter().any(|text| text == want), "missing {want}"); assert!(text.iter().any(|text| text == want), "missing {want}");
} }
assert!(!text.iter().any(|text| text == "Contents:" || text == "Foreground Color"));
}); });
} }
#[test] #[test]
fn file_info_camera_data_has_translations_and_draws_read_only() { fn file_info_camera_data_draws_in_the_selected_language_read_only() {
for language in Lang::all().filter(|language| language.complete_menus()) {
assert!(has(language, "Camera Data"), "{}: untranslated Camera Data", language.code());
for (_, label) in photocraft_engine::file_cmds::CAMERA_FIELDS {
assert!(language.catalog().contextual("cameraData", label).is_some(), "{}: untranslated cameraData {label}", language.code());
}
}
with_language(Lang::EN, || { with_language(Lang::EN, || {
let mut h = harness(); let mut h = harness();
let exif = photocraft_algo::exif::build(&photocraft_algo::exif::CameraInfo { let exif = photocraft_algo::exif::build(&photocraft_algo::exif::CameraInfo {
@@ -199,7 +161,9 @@ fn file_info_camera_data_has_translations_and_draws_read_only() {
assert_eq!(h.state_mut().ui.dialog_mut(dialog).expect("open").fields["__camera"]["exposure"], "1/250 s"); assert_eq!(h.state_mut().ui.dialog_mut(dialog).expect("open").fields["__camera"]["exposure"], "1/250 s");
h.run_steps(4); h.run_steps(4);
let text = drawn_text(&h); let text = drawn_text(&h);
for want in ["Kameradaten", "Hersteller", "SONY", "Belichtungszeit", "1/250 s", "Blende", "f/8"] { let de = lang("de");
let labels = [tr(de, "Camera Data"), tr_ctx(de, "cameraData", "Make"), tr_ctx(de, "cameraData", "Exposure"), tr_ctx(de, "cameraData", "F-Stop")];
for want in labels.into_iter().chain(["SONY", "1/250 s", "f/8"]) {
assert!(text.iter().any(|text| text == want), "missing {want}: {text:?}"); assert!(text.iter().any(|text| text == want), "missing {want}: {text:?}");
} }
// Read-only: confirming submits only the editable fields. // Read-only: confirming submits only the editable fields.
@@ -360,8 +324,9 @@ fn preferences_preview_does_not_commit_until_applied_and_survives_reload() {
dialog.fields.get_mut("values").expect("values")["interface"]["language"] = json!("fr"); dialog.fields.get_mut("values").expect("values")["interface"]["language"] = json!("fr");
h.run_steps(3); h.run_steps(3);
let text = drawn_text(&h); let text = drawn_text(&h);
assert!(text.iter().any(|text| text == "Apparence"), "the draft language must translate the visible dialog"); let fr = lang("fr");
assert!(text.iter().any(|text| text == "Annuler"), "the dialog buttons must preview the language too"); assert!(text.iter().any(|text| text == tr(fr, "Appearance")), "the draft language must translate the visible dialog");
assert!(text.iter().any(|text| text == tr(fr, "Cancel")), "the dialog buttons must preview the language too");
assert!(text.iter().any(|text| text == "File"), "the main window keeps its committed language"); assert!(text.iter().any(|text| text == "File"), "the main window keeps its committed language");
assert_eq!(h.state().session.prefs().interface.language, "auto"); assert_eq!(h.state().session.prefs().interface.language, "auto");
assert_eq!(current(), Lang::EN, "preview must not leak to the main window"); assert_eq!(current(), Lang::EN, "preview must not leak to the main window");
@@ -388,14 +353,15 @@ fn keyboard_shortcuts_list_commands_in_the_selected_language() {
h.step(); h.step();
let ctx = h.ctx.clone(); let ctx = h.ctx.clone();
let id = crate::menus::invoke(h.state_mut(), &ctx, "edit.keyboardShortcuts", json!({})).expect("dialog")["dialog"].as_u64().expect("id"); let id = crate::menus::invoke(h.state_mut(), &ctx, "edit.keyboardShortcuts", json!({})).expect("dialog")["dialog"].as_u64().expect("id");
let pt = lang("pt-br");
let label = tr_id(pt, "file.saveAs", "Save As…");
// Search matches the translated label and the English one. // Search matches the translated label and the English one.
for filter in ["salvar como", "save as"] { for filter in [label.trim_end_matches('…').to_lowercase(), "save as".to_string()] {
h.state_mut().ui.dialog_mut(id).expect("shortcuts").fields.insert("filter".into(), json!(filter)); h.state_mut().ui.dialog_mut(id).expect("shortcuts").fields.insert("filter".into(), json!(filter));
h.run_steps(3); h.run_steps(3);
let text = drawn_text(&h); let text = drawn_text(&h);
assert!(text.iter().any(|text| text == "Arquivo"), "{filter}: the menu heading must be translated"); assert!(text.iter().any(|text| text == tr(pt, "File")), "{filter}: the menu heading must be translated");
assert!(text.iter().any(|text| text.trim() == "Salvar como…"), "{filter}: the command must be translated: {text:?}"); assert!(text.iter().any(|text| text.trim() == label), "{filter}: the command must be translated: {text:?}");
assert!(!text.iter().any(|text| text.trim() == "Save As…"), "{filter}: no English command labels");
} }
}); });
} }
@@ -432,13 +398,18 @@ fn czech_status_notices_and_navigator_draw_translated_text() {
None, None,
); );
let info = crate::gpu_canvas::GpuInfo { driver: "Mesa 25.0".into(), ..Default::default() }; let info = crate::gpu_canvas::GpuInfo { driver: "Mesa 25.0".into(), ..Default::default() };
assert!(info.lines().iter().any(|line| line == "Ovladač: Mesa 25.0")); assert!(info.lines().iter().any(|line| *line == fmt(tr(lang("cs"), "Driver: {driver}"), &[("driver", "Mesa 25.0")])));
}); });
h.run_steps(4); h.run_steps(4);
let text = drawn_text(&h); let text = drawn_text(&h);
let cs = lang("cs");
// The Navigator's own text is its tab title now: its zoom row has no label (#4840). // The Navigator's own text is its tab title now: its zoom row has no label (#4840).
for want in ["Navigátor", "Soubor English file.psd se nepodařilo otevřít: disk full", "Předvolby nelze uložit"] { for want in [
assert!(text.iter().any(|text| text == want), "missing {want}: {text:?}"); tr(cs, "Navigator").to_string(),
fmt(tr(cs, "Couldn't open {name}: {error}"), &[("name", "English file.psd"), ("error", "disk full")]),
tr(cs, "Preferences can't be saved").to_string(),
] {
assert!(text.contains(&want), "missing {want}: {text:?}");
} }
// Czech text must be drawable by the bundled fonts, including the new notices. // Czech text must be drawable by the bundled fonts, including the new notices.
let chars: std::collections::BTreeSet<_> = text.iter().flat_map(|s| s.chars()).filter(|c| !c.is_whitespace()).collect(); let chars: std::collections::BTreeSet<_> = text.iter().flat_map(|s| s.chars()).filter(|c| !c.is_whitespace()).collect();
@@ -469,7 +440,12 @@ fn czech_shortcut_conflicts_keep_warning_state_and_shortcut_codes() {
h.run_steps(4); h.run_steps(4);
let fields = &h.state().ui.dialogs.iter().find(|d| d.id == id).expect("dialog").fields; let fields = &h.state().ui.dialogs.iter().find(|d| d.id == id).expect("dialog").fields;
assert_eq!(fields["messageWarning"], json!(true)); assert_eq!(fields["messageWarning"], json!(true));
assert!(fields["message"].as_str().expect("message").contains("již používá")); let message = fields["message"].as_str().expect("message");
let template = tr(lang("cs"), "{shortcut} is already in use by {commands} and will be removed from it when you click OK.");
// The message is the conflict template, filled in: its fixed text appears around the shortcut and commands.
for piece in template.split('{').map(|piece| piece.split_once('}').map_or(piece, |(_, rest)| rest)).filter(|piece| !piece.trim().is_empty()) {
assert!(message.contains(piece), "{message:?} lacks {piece:?}");
}
assert_eq!(fields["overrides"]["file.new"], json!("Cmd+O")); assert_eq!(fields["overrides"]["file.new"], json!("Cmd+O"));
}); });
} }
+51 -765
View File
@@ -43,7 +43,7 @@ pub struct LangInfo {
/// Czech: 0 = one, 1 = few (2–4), 2 = other; French: 0 = one (0 and 1), 1 = other). A catalog's `@plural` entries list one form per /// Czech: 0 = one, 1 = few (2–4), 2 = other; French: 0 = one (0 and 1), 1 = other). A catalog's `@plural` entries list one form per
/// index. /// index.
pub plural: fn(u64) -> usize, pub plural: fn(u64) -> usize,
/// Must the catalog cover every menu string? (checked by the tests) /// Does the catalog cover every menu string? (`cargo xtask i18n-coverage` lists what it lacks)
pub complete_menus: bool, pub complete_menus: bool,
catalog: OnceLock<Catalog>, catalog: OnceLock<Catalog>,
} }
@@ -479,10 +479,6 @@ mod tests {
assert_eq!(HI().name(), "हिन्दी"); assert_eq!(HI().name(), "हिन्दी");
assert_eq!(Lang::from_pref("hi"), HI()); assert_eq!(Lang::from_pref("hi"), HI());
assert_eq!(Lang::from_pref("HI"), HI()); assert_eq!(Lang::from_pref("HI"), HI());
assert_eq!(tr(HI(), "Layer"), "लेयर");
assert_eq!(tr(HI(), "File"), "फ़ाइल");
assert_eq!(trn(HI(), 1, "{n} layer", "{n} layers"), "1 लेयर");
assert_eq!(trn(HI(), 3, "{n} layer", "{n} layers"), "3 लेयर्स");
} }
#[test] #[test]
@@ -499,7 +495,6 @@ mod tests {
assert_eq!(lang_from_tag(tag), Some(VI()), "{tag}"); assert_eq!(lang_from_tag(tag), Some(VI()), "{tag}");
} }
assert_eq!(VI().name(), "Tiếng Việt"); assert_eq!(VI().name(), "Tiếng Việt");
assert!(VI().complete_menus(), "Vietnamese translates every source key");
assert_eq!(Lang::from_pref("vi"), VI()); assert_eq!(Lang::from_pref("vi"), VI());
assert_eq!(Lang::from_pref("VI"), VI()); assert_eq!(Lang::from_pref("VI"), VI());
} }
@@ -578,87 +573,21 @@ mod tests {
assert_eq!(Lang::from_pref("xx-unknown"), Lang::EN); assert_eq!(Lang::from_pref("xx-unknown"), Lang::EN);
} }
/// The brush size/hardness and preset-stepping shortcut rows (`tools.*` commands without a menu item) must
/// be translated in every catalog, or they show English in the Keyboard Shortcuts dialog and cannot be found
/// by searching.
#[test] #[test]
fn brush_size_and_hardness_shortcuts_are_translated_in_every_language() { fn simplified_chinese_resolves_from_preferences() {
let keys = [
"Decrease Brush Size",
"Increase Brush Size",
"Decrease Brush Hardness",
"Increase Brush Hardness",
"Previous Brush",
"Next Brush",
"First Brush",
"Last Brush",
];
for lang in Lang::all().filter(|lang| *lang != Lang::EN) {
for key in keys {
assert!(has(lang, key), "{} has no translation for {key:?}", lang.code());
}
}
let zh = Lang::from_code("zh-hans").expect("zh-hans registered"); let zh = Lang::from_code("zh-hans").expect("zh-hans registered");
assert_eq!(tr(zh, "Decrease Brush Size"), "减小画笔大小");
assert_eq!(tr(zh, "Increase Brush Size"), "增大画笔大小");
}
#[test]
fn simplified_chinese_covers_dynamic_shortcuts_and_layer_counts() {
let zh = Lang::from_code("zh-hans").expect("zh-hans registered");
assert!(zh.complete_menus(), "Simplified Chinese must participate in the coverage gates");
assert_eq!(Lang::from_pref("ZH-Hans"), zh); assert_eq!(Lang::from_pref("ZH-Hans"), zh);
assert_eq!(tr(zh, "Pixel Layer"), "像素图层");
assert_eq!(tr(zh, "System Info"), "系统信息");
for key in ["⌥", "Alt"] {
assert_eq!(fmt(tr(zh, "Add a mask (from the selection; {key} inverts)"), &[("key", key)]), format!("添加蒙版 (基于选区;{key} 反相)"));
}
for n in [0, 1, 3] {
assert_eq!(trn(zh, n, "{n} layer", "{n} layers"), format!("{n} 个图层"));
}
assert_eq!(tr(zh, "no such label"), "no such label"); assert_eq!(tr(zh, "no such label"), "no such label");
} }
/// Keep the Korean tool/menu vocabulary aligned with the Photoshop equivalents.
/// Sources and the product-specific vocabulary policy are recorded in `ko.tsv`.
#[test]
fn korean_uses_photoshop_terminology() {
let ko = Lang::from_code("ko").expect("ko registered");
for (source, expected) in [
("Shape", "모양"),
("Stroke", "획"),
("Smudge Tool", "손가락 도구"),
("Eyedropper Tool", "스포이드 도구"),
("Rectangular Marquee Tool", "사각형 선택 윤곽 도구"),
("Elliptical Marquee Tool", "원형 선택 윤곽 도구"),
("Zoom Tool", "돋보기 도구"),
("Horizontal Type Tool", "수평 문자 도구"),
("Puppet Warp", "퍼펫 뒤틀기"),
("Liquify…", "픽셀 유동화…"),
("Gaussian Blur…", "가우시안 흐림 효과…"),
("Gaussian Blur", "가우시안 흐림 효과"),
("Adaptive Wide Angle…", "응용 광각…"),
("Render", "렌더"),
("Sharpen", "선명 효과"),
("Vibrance", "활기"),
("Layer Comps", "레이어 구성 요소"),
("Vivid Light", "선명한 라이트"),
("Hard Mix", "하드 혼합"),
] {
assert_eq!(tr(ko, source), expected, "{source}");
}
assert_eq!(tr_id(ko, "filter.sharpen.sharpen", "Sharpen"), "선명하게");
}
#[test] #[test]
fn spanish_resolves_and_pluralises() { fn spanish_resolves_and_pluralises() {
let es = Lang::from_code("es").expect("es registered"); let es = Lang::from_code("es").expect("es registered");
for tag in ["es", "es_ES.UTF-8", "es-MX", "es-419"] { for tag in ["es", "es_ES.UTF-8", "es-MX", "es-419"] {
assert_eq!(lang_from_tag(tag), Some(es), "{tag}"); assert_eq!(lang_from_tag(tag), Some(es), "{tag}");
} }
assert_eq!(tr(es, "Layer"), "Capa"); let forms: Vec<usize> = [0, 1, 3].into_iter().map(es.0.plural).collect();
assert_eq!(trn(es, 1, "{n} item", "{n} items"), "1 elemento"); assert_eq!(forms, [1, 0, 1]);
assert_eq!(trn(es, 3, "{n} item", "{n} items"), "3 elementos");
} }
#[test] #[test]
@@ -667,14 +596,9 @@ mod tests {
for tag in ["hu", "hu_HU.UTF-8", "hu-HU"] { for tag in ["hu", "hu_HU.UTF-8", "hu-HU"] {
assert_eq!(lang_from_tag(tag), Some(hu), "{tag}"); assert_eq!(lang_from_tag(tag), Some(hu), "{tag}");
} }
assert!(hu.complete_menus());
assert_eq!(hu.name(), "Magyar"); assert_eq!(hu.name(), "Magyar");
assert_eq!(tr(hu, "Layer"), "Réteg"); let forms: Vec<usize> = [0, 1, 3, 21].into_iter().map(hu.0.plural).collect();
for (n, expected) in [(0, "0 réteg"), (1, "1 réteg"), (3, "3 réteg"), (21, "21 réteg")] { assert_eq!(forms, [0, 0, 0, 0]);
assert_eq!(trn(hu, n, "{n} layer", "{n} layers"), expected);
}
assert_eq!(tr_ctx(hu, "layerLabel", "Red"), "Piros");
assert_eq!(tr(hu, "Red"), "Vörös");
} }
#[test] #[test]
@@ -684,241 +608,63 @@ mod tests {
assert_eq!(lang_from_tag(tag), Some(pt), "{tag}"); assert_eq!(lang_from_tag(tag), Some(pt), "{tag}");
} }
assert_eq!(candidates("pt_PT"), ["pt-pt", "pt-br", "pt"]); assert_eq!(candidates("pt_PT"), ["pt-pt", "pt-br", "pt"]);
assert_eq!(tr(pt, "Layer"), "Camada"); let forms: Vec<usize> = [0, 1, 2].into_iter().map(pt.0.plural).collect();
assert_eq!(trn(pt, 0, "{n} item", "{n} items"), "0 item"); assert_eq!(forms, [0, 0, 1]);
assert_eq!(trn(pt, 1, "{n} item", "{n} items"), "1 item");
assert_eq!(trn(pt, 2, "{n} item", "{n} items"), "2 itens");
} }
#[test] #[test]
fn lookups_fall_back_to_english() { fn lookups_fall_back_to_english() {
assert_eq!(tr(JA(), "no such label"), "no such label"); assert_eq!(tr(JA(), "no such label"), "no such label");
assert_eq!(tr(Lang::EN, "Layer"), "Layer"); assert_eq!(tr(Lang::EN, "Layer"), "Layer");
assert_eq!(tr(JA(), "Layer"), "レイヤー");
assert_eq!(tr(ZH(), "Layer"), "圖層");
assert_eq!(tr(ZH(), "no such label"), "no such label"); assert_eq!(tr(ZH(), "no such label"), "no such label");
assert_eq!(tr_id(ZH(), "no.such.id", "Layer"), "圖層"); assert_eq!(tr_id(ZH(), "no.such.id", "Layer"), tr(ZH(), "Layer"));
assert_eq!(tr_id(JA(), "no.such.id", "Layer"), "レイヤー"); assert_eq!(tr_id(JA(), "no.such.id", "Layer"), tr(JA(), "Layer"));
assert_eq!(tr_ctx(JA(), "no such context", "Layer"), "レイヤー"); assert_eq!(tr_ctx(JA(), "no such context", "Layer"), tr(JA(), "Layer"));
assert_eq!(trn(JA(), 3, "{n} no such message", "{n} no such messages"), "3 no such messages");
} }
#[test] #[test]
fn russian_plural_rules() { fn russian_plural_rule() {
let ru = || Lang::from_code("ru").expect("ru registered");
assert_eq!(trn(ru(), 1, "{n} item", "{n} items"), "1 элемент");
assert_eq!(trn(ru(), 2, "{n} item", "{n} items"), "2 элемента");
assert_eq!(trn(ru(), 5, "{n} item", "{n} items"), "5 элементов");
assert_eq!(trn(ru(), 11, "{n} item", "{n} items"), "11 элементов");
assert_eq!(trn(ru(), 21, "{n} item", "{n} items"), "21 элемент");
assert_eq!(trn(ru(), 22, "{n} item", "{n} items"), "22 элемента");
assert_eq!(trn(ru(), 101, "{n} item", "{n} items"), "101 элемент");
assert_eq!(trn(ru(), 111, "{n} item", "{n} items"), "111 элементов");
}
#[test]
fn russian_status_messages_agree_with_counts() {
let ru = Lang::from_code("ru").expect("ru registered"); let ru = Lang::from_code("ru").expect("ru registered");
let cases = [ let forms: Vec<usize> = [0, 1, 2, 5, 11, 12, 14, 21, 22, 25, 101, 111, 112].into_iter().map(ru.0.plural).collect();
( assert_eq!(forms, [2, 0, 1, 2, 2, 2, 2, 0, 1, 2, 0, 2, 2]);
"Exported {n} swatch to {file}",
"Exported {n} swatches to {file}",
["Экспортирован {n} образец в {file}", "Экспортированы {n} образца в {file}", "Экспортировано {n} образцов в {file}"],
),
(
"Imported {n} brush from {group}",
"Imported {n} brushes from {group}",
["Импортирована {n} кисть из {group}", "Импортированы {n} кисти из {group}", "Импортировано {n} кистей из {group}"],
),
(
"Imported {n} swatch from {file}",
"Imported {n} swatches from {file}",
["Импортирован {n} образец из {file}", "Импортированы {n} образца из {file}", "Импортировано {n} образцов из {file}"],
),
("Played {n} step", "Played {n} steps", ["Воспроизведён {n} шаг", "Воспроизведены {n} шага", "Воспроизведено {n} шагов"]),
(
"Updated {n} linked smart object changed on disk",
"Updated {n} linked smart objects changed on disk",
[
"Обновлён {n} связанный смарт-объект, изменённый на диске",
"Обновлены {n} связанных смарт-объекта, изменённых на диске",
"Обновлено {n} связанных смарт-объектов, изменённых на диске",
],
),
(
"{n} layer recorded by this comp was deleted. Update the comp, or clear the warning.",
"{n} layers recorded by this comp were deleted. Update the comp, or clear the warning.",
[
"Удалён {n} слой, записанный в этом варианте. Обновите вариант или сбросьте предупреждение.",
"Удалены {n} слоя, записанных в этом варианте. Обновите вариант или сбросьте предупреждение.",
"Удалено {n} слоёв, записанных в этом варианте. Обновите вариант или сбросьте предупреждение.",
],
),
("{n} sample point removed", "{n} sample points removed", ["Удалена {n} точка замера", "Удалены {n} точки замера", "Удалено {n} точек замера"]),
(
"Loaded {n} action from {file}",
"Loaded {n} actions from {file}",
["Загружена {n} операция из {file}", "Загружены {n} операции из {file}", "Загружено {n} операций из {file}"],
),
(
"Saved {n} action to {file}",
"Saved {n} actions to {file}",
["Сохранена {n} операция в {file}", "Сохранены {n} операции в {file}", "Сохранено {n} операций в {file}"],
),
(
"{n} step with no Photoshop equivalent left out",
"{n} steps with no Photoshop equivalent left out",
[
"Пропущен {n} шаг без эквивалента в Photoshop",
"Пропущены {n} шага без эквивалента в Photoshop",
"Пропущено {n} шагов без эквивалента в Photoshop",
],
),
];
// Include zero, teens, and compound counts as well as each basic form.
for (n, form) in [(0, 2), (1, 0), (2, 1), (3, 1), (4, 1), (5, 2), (11, 2), (12, 2), (14, 2), (21, 0), (22, 1), (25, 2), (101, 0), (112, 2)] {
for (one, other, forms) in cases {
let expected = forms[form].replace("{n}", &n.to_string());
assert_eq!(trn(ru, n, one, other), expected, "{one}, n={n}");
}
}
assert_eq!(
fmt(&trn(ru, 2, "Imported {n} brush from {group}", "Imported {n} brushes from {group}"), &[("group", "Кисти")]),
"Импортированы 2 кисти из Кисти"
);
assert_eq!(
fmt(&trn(ru, 22, "Loaded {n} action from {file}", "Loaded {n} actions from {file}"), &[("file", "actions.atn")]),
"Загружены 22 операции из actions.atn"
);
}
#[test]
fn russian_font_weights_and_reviewed_labels() {
let ru = Lang::from_code("ru").expect("ru registered");
assert_eq!(tr_ctx(ru, "fontWeight", "ExtraLight"), "Сверхтонкий");
assert_eq!(tr_ctx(ru, "fontWeight", "Light"), "Лёгкий");
assert_eq!(tr_ctx(ru, "fontWeight", "ExtraBold"), "Сверхжирный");
assert_eq!(tr_ctx(ru, "fontWeight", "Black"), "Сверхжирный (Black)");
assert_eq!(tr(ru, "Delete and Fill Selection"), "Удалить и залить выделение");
assert_eq!(tr(ru, "Discard the embedded profile (don't color manage)"), "Не использовать встроенный профиль (без управления цветом)");
assert_eq!(tr(ru, "No object found there — drag a rectangle around it"), "Объект не найден — обведите его прямоугольником");
} }
#[test] #[test]
fn ukrainian_resolves_locales_and_preferences() { fn ukrainian_resolves_locales_and_preferences() {
let uk = Lang::from_code("uk").expect("uk registered"); let uk = Lang::from_code("uk").expect("uk registered");
assert_eq!(uk.name(), "Українська"); assert_eq!(uk.name(), "Українська");
assert!(uk.complete_menus());
for tag in ["uk", "UK", "uk-UA", "uk_UA", "uk_UA.UTF-8", "uk-UA@euro", "uk-Cyrl-UA"] { for tag in ["uk", "UK", "uk-UA", "uk_UA", "uk_UA.UTF-8", "uk-UA@euro", "uk-Cyrl-UA"] {
assert_eq!(lang_from_tag(tag), Some(uk), "{tag}"); assert_eq!(lang_from_tag(tag), Some(uk), "{tag}");
assert_eq!(Lang::from_pref(tag), uk, "{tag}"); assert_eq!(Lang::from_pref(tag), uk, "{tag}");
} }
assert_eq!(tr(uk, "File"), "Файл");
assert_eq!(tr(uk, "Layer"), "Шар");
assert_eq!(tr(uk, "New document…"), "Новий документ…");
assert_eq!(tr(uk, "No properties"), "Немає властивостей");
assert_eq!(tr_id(uk, "select.all", "All"), "Виділити все");
assert_eq!(tr_ctx(uk, "cameraRaw", "Light"), "Світло");
assert_eq!(tr_ctx(uk, "fontWeight", "Light"), "Легкий");
assert_eq!(fmt(tr(uk, "Camera Raw Filter ({layer})"), &[("layer", "Background")]), "Фільтр Camera Raw (Background)");
for key in ["Alt", "⌥"] {
assert_eq!(
fmt(tr(uk, "Add a mask (from the selection; {key} inverts)"), &[("key", key)]),
format!("Додати маску (із виділення; {key} інвертує)")
);
}
assert_eq!(tr(uk, "unknown translation"), "unknown translation"); assert_eq!(tr(uk, "unknown translation"), "unknown translation");
} }
#[test] #[test]
fn ukrainian_plural_messages_handle_teens_and_compound_counts() { fn ukrainian_plural_rule_handles_teens_and_compound_counts() {
let uk = Lang::from_code("uk").expect("uk registered"); let uk = Lang::from_code("uk").expect("uk registered");
for (n, item, layer) in [ let counts = [0, 1, 2, 4, 5, 11, 12, 14, 19, 21, 22, 24, 25, 100, 101, 111, 112, 114, 121, 122, u64::MAX];
(0, "елементів", "шарів"), let forms: Vec<usize> = counts.into_iter().map(uk.0.plural).collect();
(1, "елемент", "шар"), assert_eq!(forms, [2, 0, 1, 1, 2, 2, 2, 2, 2, 0, 1, 1, 2, 2, 0, 2, 2, 2, 0, 1, 2]);
(2, "елементи", "шари"),
(4, "елементи", "шари"),
(5, "елементів", "шарів"),
(11, "елементів", "шарів"),
(12, "елементів", "шарів"),
(14, "елементів", "шарів"),
(19, "елементів", "шарів"),
(21, "елемент", "шар"),
(22, "елементи", "шари"),
(24, "елементи", "шари"),
(25, "елементів", "шарів"),
(100, "елементів", "шарів"),
(101, "елемент", "шар"),
(111, "елементів", "шарів"),
(112, "елементів", "шарів"),
(114, "елементів", "шарів"),
(121, "елемент", "шар"),
(122, "елементи", "шари"),
(u64::MAX, "елементів", "шарів"),
] {
assert_eq!(trn(uk, n, "{n} item", "{n} items"), format!("{n} {item}"));
assert_eq!(trn(uk, n, "{n} layer", "{n} layers"), format!("{n} {layer}"));
assert_eq!(trn(uk, n, "Group · {n} layer", "Group · {n} layers"), format!("Група · {n} {layer}"));
}
} }
#[test] #[test]
fn serbian_resolves_locales_and_preferences() { fn serbian_resolves_locales_and_preferences() {
let sr = Lang::from_code("sr").expect("sr registered"); let sr = Lang::from_code("sr").expect("sr registered");
assert_eq!(sr.name(), "Srpski"); assert_eq!(sr.name(), "Srpski");
assert!(sr.complete_menus());
for tag in ["sr", "SR", "sr-RS", "sr_RS", "sr_RS.UTF-8", "sr_RS@latin", "sr-Latn", "sr-Latn-RS", "sr-Cyrl-RS", "sr-ME"] { for tag in ["sr", "SR", "sr-RS", "sr_RS", "sr_RS.UTF-8", "sr_RS@latin", "sr-Latn", "sr-Latn-RS", "sr-Cyrl-RS", "sr-ME"] {
assert_eq!(lang_from_tag(tag), Some(sr), "{tag}"); assert_eq!(lang_from_tag(tag), Some(sr), "{tag}");
assert_eq!(Lang::from_pref(tag), sr, "{tag}"); assert_eq!(Lang::from_pref(tag), sr, "{tag}");
} }
assert_eq!(tr(sr, "File"), "Datoteka");
assert_eq!(tr(sr, "Layer"), "Sloj");
assert_eq!(tr(sr, "New document…"), "Novi dokument…");
assert_eq!(tr_id(sr, "select.all", "All"), "Izaberi sve", "an id override wins over the plain label");
assert_eq!(tr(sr, "All"), "Sve");
assert_eq!(tr_ctx(sr, "cameraRaw", "Light"), "Svetlo");
assert_eq!(tr_ctx(sr, "fontWeight", "Light"), "Lagano");
assert_eq!(fmt(tr(sr, "Camera Raw Filter ({layer})"), &[("layer", "Background")]), "Camera Raw filter (Background)");
for key in ["Alt", "⌥"] {
assert_eq!(
fmt(tr(sr, "Add a mask (from the selection; {key} inverts)"), &[("key", key)]),
format!("Dodaj masku (iz selekcije; {key} invertuje)")
);
}
} }
#[test] #[test]
fn serbian_plural_messages_handle_teens_and_compound_counts() { fn serbian_plural_rule_handles_teens_and_compound_counts() {
let sr = Lang::from_code("sr").expect("sr registered"); let sr = Lang::from_code("sr").expect("sr registered");
for (n, item, layer) in [ let counts = [0, 1, 2, 4, 5, 11, 12, 14, 21, 22, 25, 101, 111, 112, 122];
(0, "stavki", "slojeva"), let forms: Vec<usize> = counts.into_iter().map(sr.0.plural).collect();
(1, "stavka", "sloj"), assert_eq!(forms, [2, 0, 1, 1, 2, 2, 2, 2, 0, 1, 2, 0, 2, 2, 1]);
(2, "stavke", "sloja"),
(4, "stavke", "sloja"),
(5, "stavki", "slojeva"),
(11, "stavki", "slojeva"),
(12, "stavki", "slojeva"),
(14, "stavki", "slojeva"),
(21, "stavka", "sloj"),
(22, "stavke", "sloja"),
(25, "stavki", "slojeva"),
(101, "stavka", "sloj"),
(111, "stavki", "slojeva"),
(112, "stavki", "slojeva"),
(122, "stavke", "sloja"),
] {
assert_eq!(trn(sr, n, "{n} item", "{n} items"), format!("{n} {item}"));
assert_eq!(trn(sr, n, "{n} layer", "{n} layers"), format!("{n} {layer}"));
assert_eq!(trn(sr, n, "Group · {n} layer", "Group · {n} layers"), format!("Grupa · {n} {layer}"));
}
for (n, expected) in [
(1, "Uklonjena je 1 tačka uzorkovanja"),
(3, "Uklonjene su 3 tačke uzorkovanja"),
(5, "Uklonjeno je 5 tačaka uzorkovanja"),
(13, "Uklonjeno je 13 tačaka uzorkovanja"),
(21, "Uklonjena je 21 tačka uzorkovanja"),
] {
assert_eq!(trn(sr, n, "{n} sample point removed", "{n} sample points removed"), expected);
}
} }
#[test] #[test]
@@ -945,14 +691,14 @@ mod tests {
assert_eq!(trn(Lang::EN, 1, "{n} item", "{n} items"), "1 item"); assert_eq!(trn(Lang::EN, 1, "{n} item", "{n} items"), "1 item");
assert_eq!(trn(Lang::EN, 0, "{n} item", "{n} items"), "0 items"); assert_eq!(trn(Lang::EN, 0, "{n} item", "{n} items"), "0 items");
assert_eq!(trn(Lang::EN, 7, "{n} item", "{n} items"), "7 items"); assert_eq!(trn(Lang::EN, 7, "{n} item", "{n} items"), "7 items");
assert_eq!(trn(JA(), 1, "{n} item", "{n} items"), "1 件"); let cs = fixture_lang(plural_cs, "@plural\t{n} item|{n} items\t{n} one|{n} few|{n} other\n");
assert_eq!(trn(JA(), 7, "{n} item", "{n} items"), "7 件"); for (n, expected) in [(0, "0 other"), (1, "1 one"), (3, "3 few"), (5, "5 other")] {
assert_eq!(trn(ZH(), 1, "{n} item", "{n} items"), "1 個項目"); assert_eq!(trn(cs, n, "{n} item", "{n} items"), expected);
assert_eq!(trn(ZH(), 7, "{n} item", "{n} items"), "7 個項目"); }
assert_eq!(trn(CS(), 1, "{n} item", "{n} items"), "1 položka"); let ja = fixture_lang(plural_none, "@plural\t{n} item|{n} items\t{n} only\n");
assert_eq!(trn(CS(), 3, "{n} item", "{n} items"), "3 položky"); assert_eq!(trn(ja, 1, "{n} item", "{n} items"), "1 only");
assert_eq!(trn(CS(), 5, "{n} item", "{n} items"), "5 položek"); assert_eq!(trn(ja, 7, "{n} item", "{n} items"), "7 only");
assert_eq!(trn(CS(), 0, "{n} item", "{n} items"), "0 položek"); assert_eq!(trn(ja, 7, "{n} file", "{n} files"), "7 files", "a missing row falls back to English");
assert_eq!(fmt("{b} before {a}", &[("a", "x"), ("b", "y"), ("c", "z")]), "y before x"); assert_eq!(fmt("{b} before {a}", &[("a", "x"), ("b", "y"), ("c", "z")]), "y before x");
assert_eq!(fmt("{missing}", &[]), "{missing}"); assert_eq!(fmt("{missing}", &[]), "{missing}");
assert_eq!(placeholders("a {x} b {y} {"), ["x", "y"]); assert_eq!(placeholders("a {x} b {y} {"), ["x", "y"]);
@@ -964,14 +710,27 @@ mod tests {
for tag in ["fr", "fr_FR.UTF-8", "fr-CA", "fr_BE", "fr-CH"] { for tag in ["fr", "fr_FR.UTF-8", "fr-CA", "fr_BE", "fr-CH"] {
assert_eq!(lang_from_tag(tag), Some(fr), "{tag}"); assert_eq!(lang_from_tag(tag), Some(fr), "{tag}");
} }
assert_eq!(tr(fr, "Layer"), "Calque"); let forms: Vec<usize> = [0, 1, 2, 5, 100, u64::MAX].into_iter().map(fr.0.plural).collect();
assert_eq!(tr_id(fr, "select.all", "All"), "Tout sélectionner", "an id override wins over the plain label");
assert_eq!(tr(fr, "All"), "Tout");
let forms: Vec<usize> = [0, 1, 2, 5, 100, u64::MAX].into_iter().map(plural_fr).collect();
assert_eq!(forms, [0, 0, 1, 1, 1, 1]); assert_eq!(forms, [0, 0, 1, 1, 1, 1]);
assert_eq!(trn(fr, 0, "{n} item", "{n} items"), "0 élément"); }
assert_eq!(trn(fr, 1, "{n} item", "{n} items"), "1 élément");
assert_eq!(trn(fr, 3, "{n} item", "{n} items"), "3 éléments"); /// A language whose catalog is `text`, for testing lookups without depending on a bundled
/// catalog's wording.
fn fixture_lang(plural: fn(u64) -> usize, text: &str) -> Lang {
let catalog = OnceLock::new();
let _ = catalog.set(Catalog::parse(text));
Lang(Box::leak(Box::new(LangInfo { code: "xx", name: "x", source: b"", plural, complete_menus: false, catalog })))
}
#[test]
fn id_and_context_rows_win_over_the_plain_label() {
let lang = fixture_lang(plural_one_other, "\tAll\tplain all\n@id\tselect.all\tid all\nnavigator\tZoom\tcontext zoom\n\tZoom\tplain zoom\n");
assert_eq!(tr_id(lang, "select.all", "All"), "id all");
assert_eq!(tr_id(lang, "edit.other", "All"), "plain all");
assert_eq!(tr(lang, "All"), "plain all");
assert_eq!(tr_ctx(lang, "navigator", "Zoom"), "context zoom");
assert_eq!(tr_ctx(lang, "other", "Zoom"), "plain zoom");
assert_eq!(tr_ctx(lang, "navigator", "Unlisted"), "Unlisted");
} }
#[test] #[test]
@@ -980,61 +739,10 @@ mod tests {
assert_eq!(forms, [2, 0, 1, 1, 2, 2, 2, 2, 1, 2, 2, 1]); assert_eq!(forms, [2, 0, 1, 1, 2, 2, 2, 2, 1, 2, 2, 1]);
} }
/// Counted status messages read in Simplified Chinese instead of falling back to English (they were
/// missing from the catalog).
#[test]
fn simplified_chinese_counted_messages_are_translated() {
let lang = Lang::from_code("zh-hans").expect("zh-hans registered");
assert_eq!(trn(lang, 3, "Played {n} step", "Played {n} steps"), "已播放 3 个步骤");
let brushes = trn(lang, 2, "Imported {n} brush from {group}", "Imported {n} brushes from {group}");
assert_eq!(fmt(&brushes, &[("group", "水彩")]), "已从 水彩 导入 2 个画笔");
assert_eq!(trn(lang, 5, "{n} slice", "{n} slices"), "5 个切片");
}
/// Counted status messages read in Korean instead of falling back to English (they were
/// missing from the catalog).
#[test]
fn korean_counted_messages_are_translated() {
let lang = Lang::from_code("ko").expect("ko registered");
assert_eq!(trn(lang, 3, "Played {n} step", "Played {n} steps"), "단계 3개를 재생했습니다");
let brushes = trn(lang, 2, "Imported {n} brush from {group}", "Imported {n} brushes from {group}");
assert_eq!(fmt(&brushes, &[("group", "수채화")]), "수채화에서 브러시 2개를 가져왔습니다");
assert_eq!(trn(lang, 5, "{n} slice", "{n} slices"), "분할 영역 5개");
}
/// Counted status messages read in Indonesian instead of falling back to English (they were
/// missing from the catalog).
#[test]
fn indonesian_counted_messages_are_translated() {
let lang = Lang::from_code("id").expect("id registered");
assert_eq!(trn(lang, 3, "Played {n} step", "Played {n} steps"), "3 langkah diputar");
let brushes = trn(lang, 2, "Imported {n} brush from {group}", "Imported {n} brushes from {group}");
assert_eq!(fmt(&brushes, &[("group", "Cat Air")]), "2 kuas diimpor dari Cat Air");
assert_eq!(trn(lang, 5, "{n} slice", "{n} slices"), "5 slice");
}
/// Counted status messages read in Polish with all three forms instead of falling back to
/// English (they were missing from the catalog).
#[test]
fn polish_counted_messages_use_all_three_forms() {
let pl = Lang::from_code("pl").expect("pl registered");
let steps = |n| trn(pl, n, "Played {n} step", "Played {n} steps");
assert_eq!(
[steps(1), steps(3), steps(5), steps(12), steps(22)],
["Odtworzono 1 krok", "Odtworzono 3 kroki", "Odtworzono 5 kroków", "Odtworzono 12 kroków", "Odtworzono 22 kroki"]
);
let brushes = trn(pl, 2, "Imported {n} brush from {group}", "Imported {n} brushes from {group}");
assert_eq!(fmt(&brushes, &[("group", "Akwarele")]), "Zaimportowano 2 pędzle z Akwarele");
assert_eq!(trn(pl, 5, "{n} slice", "{n} slices"), "5 plasterków");
}
#[test] #[test]
fn czech_plural_rule() { fn czech_plural_rule() {
let forms: Vec<usize> = [0, 1, 2, 3, 4, 5, 11, 12, 21, 22, 100, u64::MAX].into_iter().map(plural_cs).collect(); let forms: Vec<usize> = [0, 1, 2, 3, 4, 5, 11, 12, 21, 22, 100, u64::MAX].into_iter().map(plural_cs).collect();
assert_eq!(forms, [2, 0, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2]); assert_eq!(forms, [2, 0, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2]);
assert_eq!(tr(CS(), "Layer"), "Vrstva");
assert_eq!(tr_id(CS(), "select.all", "All"), "Vybrat vše", "an id override wins over the plain label");
assert_eq!(tr(CS(), "All"), "Vše");
} }
/// The packed catalogs in the binary unpack to exactly the `.tsv` files. /// The packed catalogs in the binary unpack to exactly the `.tsv` files.
@@ -1117,428 +825,6 @@ mod tests {
} }
} }
} }
/// Languages that claim complete menus have an entry for every label and path segment.
#[test]
fn complete_languages_translate_every_menu_string() {
let mut strings = std::collections::BTreeSet::new();
for &(path, label, _, _) in crate::menu_catalog::CATALOG {
strings.extend(path.iter().copied());
strings.insert(label);
}
for &(_, label, path, _) in crate::menus::UI_COMMANDS {
strings.extend(path.iter().copied());
strings.insert(label);
}
for c in photocraft_engine::command_specs().iter().filter(|c| !c.menu.is_empty()) {
strings.extend(c.menu.iter().copied());
strings.insert(c.label);
}
strings.remove("---");
for l in LANGUAGES.iter().filter(|l| l.complete_menus) {
let cat = l.catalog();
let missing: Vec<_> = strings.iter().filter(|s| cat.plain(s).is_none()).collect();
assert!(missing.is_empty(), "{}: untranslated menu strings: {missing:#?}", l.code);
}
}
/// `text` with each `#[cfg(test)] mod … { … }` block removed, by brace counting (the test
/// modules in this crate are well formed).
fn without_test_modules(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(at) = rest.find("#[cfg(test)]\nmod ") {
out.push_str(&rest[..at]);
let after = &rest[at..];
let Some(open) = after.find('{') else { return out };
let mut depth = 0usize;
let mut end = after.len();
for (i, c) in after[open..].char_indices() {
match c {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
end = open + i + 1;
break;
}
}
_ => {}
}
}
rest = &after[end..];
}
out.push_str(rest);
out
}
#[test]
fn test_modules_are_cut_out_one_by_one() {
let text = "a\n#[cfg(test)]\nmod t { fn x() { tl!(\"hidden\"); } }\nb tl!(\"seen\")\n#[cfg(test)]\nmod u {}\nc";
let code = without_test_modules(text);
assert!(code.contains("tl!(\"seen\")"), "code after the first test module is scanned: {code}");
assert!(!code.contains("hidden"), "{code}");
assert!(code.starts_with("a\n") && code.ends_with("\nc"), "{code}");
}
#[test]
fn czech_status_templates_preserve_details_and_plural_counts() {
let cs = CS();
let template = "Step {step} ({id}) failed: {error}";
let args = [("step", "2"), ("id", "file.export"), ("error", "disk full: /tmp/image.psd")];
assert_eq!(fmt(tr(cs, template), &args), "Krok 2 (file.export) selhal: disk full: /tmp/image.psd");
assert_eq!(fmt(tr(Lang::EN, template), &args), "Step 2 (file.export) failed: disk full: /tmp/image.psd");
// Languages without these dynamic templates keep the English source.
assert_eq!(tr(JA(), template), template);
for (n, expected) in [(0, "Odstraněno 0 bodů měření"), (1, "Odstraněn 1 bod měření"), (2, "Odstraněny 2 body měření"), (5, "Odstraněno 5 bodů měření")]
{
assert_eq!(trn(cs, n, "{n} sample point removed", "{n} sample points removed"), expected);
}
let one = "Imported {n} swatch from {file}";
let other = "Imported {n} swatches from {file}";
assert_eq!(fmt(&trn(cs, 3, one, other), &[("file", "English name.aco")]), "Importovány 3 vzorky ze souboru English name.aco");
assert_eq!(tr(cs, "Preferences can't be saved"), "Předvolby nelze uložit");
}
#[test]
fn czech_dialog_and_hardware_labels_preserve_names() {
let cs = CS();
for label in [
"New Crop Preset…",
"Delete Crop Preset…",
"Color Picker (Background Color)",
"Color Picker (Foreground Color)",
"Pro (Medium Gray)",
"Grid view",
"List view",
"Scroll menu up",
"Scroll menu down",
"Commit transform",
"Cancel transform",
] {
assert_ne!(tr(cs, label), label, "{label}");
}
for kind in [
crate::rasterize_prompt::Kind::Type,
crate::rasterize_prompt::Kind::Shape,
crate::rasterize_prompt::Kind::SmartObject,
crate::rasterize_prompt::Kind::Fill,
] {
assert_ne!(tr(cs, kind.message()), kind.message());
}
assert_eq!(fmt(tr(cs, "Driver: {driver}"), &[("driver", "Mesa 25.0")]), "Ovladač: Mesa 25.0");
assert_eq!(
fmt(tr(cs, "Monitor profile on {display}: {profile}"), &[("display", "DELL U2720Q"), ("profile", "sRGB IEC61966-2.1")]),
"Profil monitoru DELL U2720Q: sRGB IEC61966-2.1"
);
}
#[test]
fn czech_composed_labels_use_context_and_preserve_document_names() {
let cs = CS();
assert_eq!(tr(cs, "Zoom"), "Lupa");
assert_eq!(tr_ctx(cs, "navigator", "Zoom"), "Zvětšení");
assert_eq!(tr_ctx(Lang::EN, "navigator", "Zoom"), "Zoom");
let template = "Data Set: {name} ({index}/{total})";
assert_eq!(fmt(tr(cs, template), &[("name", "Data Set 1"), ("index", "1"), ("total", "2")]), "Datová sada: Data Set 1 (1/2)");
for (n, expected) in
[(1, "Importován 1 přechod ze souboru Sunset"), (3, "Importovány 3 přechody ze souboru Sunset"), (5, "Importováno 5 přechodů ze souboru Sunset")]
{
assert_eq!(fmt(&trn(cs, n, "Imported {n} gradient from {file}", "Imported {n} gradients from {file}"), &[("file", "Sunset")]), expected);
}
}
/// Russian dynamic labels added in the fourth pass (filter parameters, Camera Raw panels,
/// hardware status, rasterize prompts, History/paths menus) render in Russian, not English.
#[test]
fn russian_dynamic_status_hardware_and_dialog_labels_are_translated() {
let ru = Lang::from_code("ru").expect("ru registered");
for kind in [
crate::rasterize_prompt::Kind::Type,
crate::rasterize_prompt::Kind::Shape,
crate::rasterize_prompt::Kind::SmartObject,
crate::rasterize_prompt::Kind::Fill,
] {
assert_ne!(tr(ru, kind.message()), kind.message(), "{:?}", kind);
}
assert_eq!(fmt(tr(ru, "Driver: {driver}"), &[("driver", "Mesa 25.0")]), "Драйвер: Mesa 25.0");
assert_eq!(
fmt(tr(ru, "Monitor profile on {display}: {profile}"), &[("display", "DELL U2720Q"), ("profile", "sRGB IEC61966-2.1")]),
"Профиль монитора для DELL U2720Q: sRGB IEC61966-2.1"
);
assert_eq!(fmt(tr(ru, "Platform: {os} {arch}"), &[("os", "Linux"), ("arch", "x86_64")]), "Платформа: Linux x86_64");
assert_eq!(tr(ru, "Graphics adapter"), "Графический адаптер");
assert_eq!(tr(ru, "Image compositor: {processor}"), "Компоновщик изображения: {processor}");
// Filter / adjustment parameter names.
for label in [
"Amount",
"Radius",
"Threshold",
"Angle",
"High Pass",
"Smart Blur",
"Reduce Noise",
"Lens Correction",
"Colored Pencil",
"Angled Strokes",
"Stained Glass",
"Sumi-e",
"Film Grain",
"Unsharp Mask",
"Lens Flare",
] {
assert!(has(ru, label), "Russian filter label missing: {label}");
}
// Dynamic composed status templates.
assert_eq!(
fmt(tr(ru, "Step {step} ({id}) failed: {error}"), &[("step", "2"), ("id", "file.export"), ("error", "ok")]),
"Шаг 2 (file.export) не выполнен: ok"
);
assert_eq!(fmt(tr(ru, "Couldn't open {name}: {error}"), &[("name", "scan.tif"), ("error", "denied")]), "Не удалось открыть scan.tif: denied");
assert_eq!(fmt(tr(ru, "Exported {path} ({size})"), &[("path", "/tmp/a.png"), ("size", "12 KB")]), "Экспортировано: /tmp/a.png (12 KB)");
assert_eq!(fmt(tr(ru, "Data Set: {name} ({index}/{total})"), &[("name", "Set 1"), ("index", "1"), ("total", "2")]), "Набор данных: Set 1 (1/2)");
// Document and layer names stay verbatim.
let text = fmt(tr(ru, "Saved {path}; image assets: {n} in {dir}"), &[("path", "/tmp/a.pcraft"), ("n", "3"), ("dir", "/tmp/assets")]);
assert!(text.contains("/tmp/a.pcraft") && text.contains("/tmp/assets"), "{text}");
}
/// Every `tl!("literal")` in the shell has an entry in each language that claims complete menus
/// (so a new label can't ship untranslated by accident). Literals that are deliberately shown as
/// they are (names, units) are listed in `KEEP_AS_IS`.
#[test]
fn every_tl_literal_is_translated() {
const KEEP_AS_IS: &[&str] = &[];
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut literals = std::collections::BTreeSet::new();
let mut stack = vec![dir];
while let Some(d) = stack.pop() {
for entry in std::fs::read_dir(&d).into_iter().flatten().flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if path.extension().is_some_and(|e| e == "rs") && !path.ends_with("lib.rs") {
let text = std::fs::read_to_string(&path).unwrap_or_default().replace("\r\n", "\n");
// Test modules aside, scan every `tl!("…")`. Each test *module* is cut out
// on its own: cutting the file at the first one hid everything after a small
// test module early in a file (panels.rs has one at ~line 540). A
// `#[cfg(test)]` on a single item doesn't count.
let code = without_test_modules(&text);
let mut rest = code.as_str();
while let Some(at) = rest.find("tl!(\"") {
rest = &rest[at + 5..];
let mut end = 0;
let bytes = rest.as_bytes();
while end < bytes.len() && !(bytes[end] == b'"' && (end == 0 || bytes[end - 1] != b'\\')) {
end += 1;
}
let lit = rest.get(..end).unwrap_or("").replace("\\\"", "\"");
if rest.get(end + 1..end + 2) == Some(")") {
literals.insert(lit);
}
}
}
}
}
assert!(literals.len() > 300, "scan found only {} literals", literals.len());
for l in LANGUAGES.iter().filter(|l| l.complete_menus) {
let cat = l.catalog();
let missing: Vec<_> = literals.iter().filter(|s| !KEEP_AS_IS.contains(&s.as_str()) && cat.plain(s).is_none()).collect();
assert!(missing.is_empty(), "{}: untranslated tl! strings: {missing:#?}", l.code);
}
}
/// Section names are dynamic labels, so the literal scanner cannot cover them.
#[test]
fn brush_section_names_are_translated() {
for lang in Lang::all().filter(|l| l.complete_menus()) {
for (name, _) in crate::brush_panel::SECTIONS {
assert!(lang.0.catalog().plain(name).is_some(), "{} missing brush section: {name}", lang.code());
}
}
}
/// `menus::reveal_label` picks one name per platform, so every name needs a translation
/// whichever OS runs the tests (UI-217-19).
#[test]
fn reveal_labels_are_translated() {
for lang in Lang::all().filter(|l| l.complete_menus()) {
let cat = lang.0.catalog();
for label in ["Reveal in Finder", "Show in Explorer", "Show in Folder"] {
let tr = cat.plain(label);
assert!(tr.is_some(), "{} missing {label}", lang.code());
if label != "Reveal in Finder" {
assert!(!tr.unwrap_or_default().contains("Finder"), "{}: {label} names the Finder", lang.code());
}
}
}
}
#[test]
fn layer_color_names_are_translated() {
for lang in Lang::all().filter(|l| l.complete_menus()) {
for color in photocraft_doc::LabelColor::ALL {
let cat = lang.0.catalog();
assert!(
cat.contextual("layerLabel", color.label()).or_else(|| cat.plain(color.label())).is_some(),
"{} missing {}",
lang.code(),
color.label()
);
}
}
let de = lang_from_tag("de").unwrap();
assert_eq!(tr_ctx(de, "layerLabel", "No Color"), "Keine Farbe");
assert_eq!(tr_ctx(de, "layerLabel", "Seafoam"), "Meeresschaum");
assert_eq!(tr(Lang::EN, "Seafoam"), "Seafoam");
}
/// These labels are passed to `tl!` dynamically, so the literal coverage scan misses them.
#[test]
fn russian_dynamic_selection_theme_and_measurement_labels_are_translated() {
let ru = Lang::from_code("ru").expect("ru registered");
let (inverse_label, _) =
crate::canvas_tool_menu::SELECTION_MENU.iter().flatten().find(|(_, id)| *id == "select.inverse").expect("selection menu has Select Inverse");
assert_eq!(tr(ru, inverse_label), "Инвертировать выделение");
for kind in [crate::theme::ThemeKind::ProLight, crate::theme::ThemeKind::ProLightGray] {
assert!(has(ru, kind.label()), "Russian theme label missing {}", kind.label());
}
for (_, label) in photocraft_engine::analysis_cmds::COLUMNS {
assert!(has(ru, label), "Russian measurement header missing {label}");
}
assert_eq!(tr(ru, "Pro (Light)"), "Pro (светлая)");
assert_eq!(tr(ru, "Pro (Light Gray)"), "Pro (светло-серая)");
assert_eq!(tr(ru, "Date and Time"), "Дата и время");
assert_eq!(tr(ru, "Integrated Density"), "Интегральная плотность");
}
/// Font style labels are built from dynamic words (weights, "Italic"), so the literal
/// scanner cannot cover them; "Light" there is a weight, distinct from the Camera Raw
/// "Light" section (`type_tool::style_label`).
#[test]
fn font_weight_names_are_translated() {
const TERMS: &[&str] = &["Thin", "ExtraLight", "Light", "Regular", "Medium", "SemiBold", "Bold", "ExtraBold", "Black", "Italic"];
for lang in Lang::all().filter(|l| l.complete_menus()) {
for t in TERMS {
assert!(lang.0.catalog().contextual("fontWeight", t).is_some(), "{} missing font weight: {t}", lang.code());
}
assert_ne!(tr_ctx(lang, "cameraRaw", "Light"), tr_ctx(lang, "fontWeight", "Light"), "{}: Camera Raw Light vs the font weight", lang.code());
}
}
/// Blend mode names come from the colour crate; each must be translated.
#[test]
fn blend_mode_names_are_translated() {
for l in LANGUAGES.iter().filter(|l| l.complete_menus) {
for m in std::iter::once(photocraft_color::BlendMode::PassThrough).chain(photocraft_color::BlendMode::layer_modes()) {
assert!(l.catalog().plain(m.label()).is_some(), "{}: blend mode {:?}", l.code, m.label());
}
}
}
/// The Italian catalog names the Dodge/Burn/Sponge *tools* and the blend modes the way Adobe
/// does. `Scherma` is the Dodge tool (with `Brucia` and `Spugna`) **and** the `Screen` blend
/// mode — Adobe's own collision, not ours — while `Schiarisci` is the `Lighten` blend mode and
/// must not be used for the tool. `Screen` used to read `Schermo`, "the display".
#[test]
fn italian_names_the_dodge_tools_and_the_screen_blend_mode_as_photoshop_does() {
let it = LANGUAGES.iter().find(|l| l.code == "it").expect("the Italian catalog is registered");
for (source, translation) in [("Dodge", "Scherma"), ("Dodge Tool", "Strumento Scherma"), ("Dodge/Burn", "Scherma/Brucia"), ("Screen", "Scherma")] {
assert_eq!(it.catalog().plain(source), Some(translation), "it: {source:?}");
}
// The tool family stays together, and the blend modes keep Adobe's wording.
for (source, translation) in
[("Burn", "Brucia"), ("Sponge", "Spugna"), ("Lighten", "Schiarisci"), ("Color Dodge", "Colore scherma"), ("Linear Dodge", "Scherma lineare")]
{
assert_eq!(it.catalog().plain(source), Some(translation), "it: {source:?}");
}
}
// Dynamic dialog labels generated by label_of() are not seen by the tl! scanner.
#[test]
fn color_lookup_export_scope_labels_are_translated() {
for lang in Lang::all().filter(|l| *l != Lang::EN) {
for source in ["Scope", "Selected"] {
assert!(lang.catalog().plain(source).is_some(), "{}: missing LUT export label {source:?}", lang.code());
assert_ne!(tr(lang, source), source, "{}: untranslated LUT export label {source:?}", lang.code());
}
}
}
#[test]
fn mixer_brush_ui_strings_have_translations_in_every_registered_language() {
const STRINGS: &[&str] = &[
"Mixer Brush",
"Mixer Brush Tool",
"Wet",
"Load",
"Mix",
"Flow",
"Sample All Layers",
"Current Brush Load",
"Load Brush",
"Clean Brush",
"Load the brush after each stroke",
"Clean the brush after each stroke",
];
for lang in Lang::all() {
for source in STRINGS {
let translated = tr(lang, source);
if lang == Lang::EN {
assert_eq!(translated, *source, "English source string {source}");
} else {
assert_ne!(translated, *source, "{} is missing {source:?}", lang.code());
}
}
}
}
#[test]
fn pattern_stamp_ui_strings_have_translations_in_every_registered_language() {
const STRINGS: &[&str] = &["Pattern Stamp Tool", "Pattern Stamp", "Impressionist", "Aligned"];
for lang in Lang::all() {
for source in STRINGS {
let translated = tr(lang, source);
if lang == Lang::EN {
assert_eq!(translated, *source, "English source string {source}");
} else {
assert_ne!(translated, *source, "{} is missing {source:?}", lang.code());
}
}
}
}
/// Camera Raw includes dynamic colour-band labels and contextual labels that the generic
/// tl! scanner cannot see. Cover the partial catalog too, without claiming whole-app coverage.
#[test]
fn camera_raw_labels_are_translated_in_every_available_language() {
let sources = [include_str!("../camera_raw_ui.rs"), include_str!("../camera_raw_scope_ui.rs")];
let mut labels = std::collections::BTreeSet::new();
for source in sources {
let code = source.split("#[cfg(test)]").next().unwrap();
for marker in ["tl!(\"", "row(ui, &mut dirty, \"", "row(ui, dirty, \"", "section(ui, \"", "wheel(ui, &mut dirty, \"", "=> \""] {
for tail in code.split(marker).skip(1) {
labels.insert(tail.split('"').next().unwrap());
}
}
}
let bands = sources[0].split("const BANDS:").nth(1).unwrap().split(" = ").nth(1).unwrap().split(';').next().unwrap();
for band in bands.split('"').skip(1).step_by(2) {
labels.insert(band);
}
assert!(labels.len() >= 66, "missing Camera Raw source labels: {labels:?}");
for lang in Lang::all().filter(|l| *l != Lang::EN) {
let catalog = lang.catalog();
let missing: Vec<_> = labels.iter().filter(|s| catalog.contextual("cameraRaw", s).or_else(|| catalog.plain(s)).is_none()).collect();
assert!(missing.is_empty(), "{}: Camera Raw labels: {missing:?}", lang.code());
assert_ne!(tr_ctx(lang, "cameraRaw", "Highlights"), tr_ctx(lang, "cameraRaw", "Lights"), "{}: distinct curve regions", lang.code());
assert_ne!(tr_ctx(lang, "cameraRaw", "Shadows"), tr_ctx(lang, "cameraRaw", "Darks"), "{}: distinct curve regions", lang.code());
}
let ru = Lang::from_code("ru").unwrap();
assert_eq!(tr_ctx(ru, "cameraRaw", "Vibrance"), "Красочность");
assert_eq!(tr_ctx(ru, "cameraRaw", "Aqua"), "Голубые");
let text = fmt(tr(ru, "Camera Raw Filter ({layer})"), &[("layer", "{Background} 影像")]);
assert_eq!(text, "Фильтр Camera Raw ({Background} 影像)", "user layer names are not translated");
}
} }
#[cfg(test)] #[cfg(test)]
+4 -9
View File
@@ -1923,7 +1923,7 @@ mod tests {
let items = menu_items(&app); let items = menu_items(&app);
let Some(fr) = crate::i18n::Lang::from_code("fr") else { return }; let Some(fr) = crate::i18n::Lang::from_code("fr") else { return };
let ids = |q: &str| search_items(&items, q, fr).iter().map(|i| i.id.clone()).collect::<Vec<_>>(); let ids = |q: &str| search_items(&items, q, fr).iter().map(|i| i.id.clone()).collect::<Vec<_>>();
assert!(!ids("calque").is_empty(), "French menu name"); assert!(!ids(&crate::i18n::tr(fr, "Layer").to_lowercase()).is_empty(), "French menu name");
assert!(ids("distort").contains(&"edit.transform.distort".to_string()), "English still matches"); assert!(ids("distort").contains(&"edit.transform.distort".to_string()), "English still matches");
assert!(ids("zzzzqqq").is_empty()); assert!(ids("zzzzqqq").is_empty());
} }
@@ -2252,7 +2252,7 @@ mod quick_export_label_tests {
assert_eq!(quick[0].label, format!("Quick Export as {shown_format}")); assert_eq!(quick[0].label, format!("Quick Export as {shown_format}"));
let id = crate::i18n::Lang::from_code("id").unwrap(); let id = crate::i18n::Lang::from_code("id").unwrap();
let localized = translated_menu_label(id, quick[0]); let localized = translated_menu_label(id, quick[0]);
assert_eq!(localized, format!("Ekspor Cepat ke {shown_format}")); assert_eq!(localized, crate::i18n::tr_id(id, "file.export.quickExportAsPng", "Quick Export as PNG").replace("PNG", shown_format));
let layer = items.iter().find(|i| i.id == "layer.quickExportAsPng").unwrap(); let layer = items.iter().find(|i| i.id == "layer.quickExportAsPng").unwrap();
assert_eq!(layer.label, "Quick Export as PNG", "layer export always creates PNG"); assert_eq!(layer.label, "Quick Export as PNG", "layer export always creates PNG");
} }
@@ -2290,12 +2290,7 @@ mod reveal_label_tests {
let shortcuts = crate::prefs_ui::shortcut_items(&app); let shortcuts = crate::prefs_ui::shortcut_items(&app);
assert!(shortcuts.iter().any(|(i, label, _, _)| i == id && label == reveal_label()), "the shortcut list shows the menu label"); assert!(shortcuts.iter().any(|(i, label, _, _)| i == id && label == reveal_label()), "the shortcut list shows the menu label");
let de = crate::i18n::Lang::from_code("de").unwrap(); let de = crate::i18n::Lang::from_code("de").unwrap();
let expected = match reveal_label() { assert_eq!(translated_menu_label(de, reveal[0]), crate::i18n::tr(de, reveal_label()));
"Reveal in Finder" => "Im Finder anzeigen",
"Show in Explorer" => "Im Explorer anzeigen",
_ => "Im Ordner anzeigen",
};
assert_eq!(translated_menu_label(de, reveal[0]), expected);
// The menu catalogue (and docs/parity-checklist.md) keeps Photoshop's macOS name. // The menu catalogue (and docs/parity-checklist.md) keeps Photoshop's macOS name.
assert!(crate::menu_catalog::CATALOG.iter().any(|(_, label, _, i)| *i == id && *label == "Reveal in Finder")); assert!(crate::menu_catalog::CATALOG.iter().any(|(_, label, _, i)| *i == id && *label == "Reveal in Finder"));
} }
@@ -2371,7 +2366,7 @@ mod window_documents_tests {
let bar = crate::native_menu::photocraft_layout(&items, de, "de").bar; let bar = crate::native_menu::photocraft_layout(&items, de, "de").bar;
assert_eq!(bar.find("window.document.0").map(|i| i.label.as_str()), Some("Layers")); assert_eq!(bar.find("window.document.0").map(|i| i.label.as_str()), Some("Layers"));
let panel = items.iter().find(|i| i.path == ["Window"] && i.label == "Layers").unwrap(); let panel = items.iter().find(|i| i.path == ["Window"] && i.label == "Layers").unwrap();
assert_eq!(bar.find(&panel.id).map(|i| i.label.as_str()), Some("Ebenen"), "Window › Layers is still translated"); assert_eq!(bar.find(&panel.id).map(|i| i.label.clone()), Some(translated_menu_label(de, panel)), "Window › Layers is still translated");
} }
/// The rows go through the control channel's `ui.menu.invoke`, like a click. /// The rows go through the control channel's `ui.menu.invoke`, like a click.
-17
View File
@@ -1029,23 +1029,6 @@ mod tests {
out.textures_delta.clear(); out.textures_delta.clear();
} }
#[test]
fn new_document_depth_labels_and_tooltips_are_translated() {
// Bit-depth labels are unit/number strings that some languages legitimately write exactly
// like the English source (Turkish and Italian "8 bit"); they only need a catalog entry there.
const SAME_AS_SOURCE: &[(&str, &str)] = &[("tr", "8 bit"), ("tr", "16 bit"), ("it", "8 bit"), ("it", "16 bit")];
for lang in crate::i18n::Lang::all().filter(|lang| lang.code() != "en") {
for (_, label, tooltip) in DEPTH_OPTIONS {
if SAME_AS_SOURCE.contains(&(lang.code(), *label)) {
assert!(crate::i18n::has(lang, label), "{}: {label}", lang.code());
} else {
assert_ne!(crate::i18n::tr(lang, label), *label, "{}: {label}", lang.code());
}
assert_ne!(crate::i18n::tr(lang, tooltip), *tooltip, "{}: {tooltip}", lang.code());
}
}
}
#[test] #[test]
fn recent_category_lists_clipboard_then_created_documents_then_uncovered_defaults() { fn recent_category_lists_clipboard_then_created_documents_then_uncovered_defaults() {
let settings = |w, h, ppi| DocumentSettings { let settings = |w, h, ppi| DocumentSettings {
+8 -14
View File
@@ -293,14 +293,9 @@ mod tests {
let command = Some("WAYLAND_DISPLAY= photocraft".to_string()); let command = Some("WAYLAND_DISPLAY= photocraft".to_string());
let app = PhotocraftApp::new(Session::new(), Services { is_wayland: true, xwayland_command: command, ..Default::default() }); let app = PhotocraftApp::new(Session::new(), Services { is_wayland: true, xwayland_command: command, ..Default::default() });
let notice = &app.ui.notices[0]; let notice = &app.ui.notices[0];
assert!(notice.lines.iter().all(|line| crate::i18n::tr(crate::i18n::Lang::from_code("es").unwrap(), line) != line), "every line is a catalog template");
// the notice keeps its English source text and is translated when drawn (`i18n::t` with // the notice keeps its English source text and is translated when drawn (`i18n::t` with
// the current language); `tr` with an explicit language leaves the process-wide language, // the current language)
// which other tests running in parallel draw with, untouched assert_eq!(notice.title, "Native file drag-and-drop is unavailable");
let es = crate::i18n::Lang::from_code("es").unwrap();
assert_eq!(crate::i18n::tr(es, &notice.title), "Arrastrar y soltar archivos de forma nativa no está disponible en Wayland");
assert_ne!(crate::i18n::tr(es, &notice.lines[0]), notice.lines[0]);
assert_eq!(crate::i18n::tr(crate::i18n::Lang::EN, &notice.title), "Native file drag-and-drop is unavailable");
} }
/// #5031: a paste that can't reach the clipboard (a Flatpak on Wayland) says why and how to /// #5031: a paste that can't reach the clipboard (a Flatpak on Wayland) says why and how to
@@ -325,8 +320,6 @@ mod tests {
assert_eq!(shown.len(), 1, "a second paste doesn't stack another notice"); assert_eq!(shown.len(), 1, "a second paste doesn't stack another notice");
let guidance = english(shown[0]); let guidance = english(shown[0]);
assert!(guidance.contains(&format!("under XWayland: `{command}`")), "{guidance}"); assert!(guidance.contains(&format!("under XWayland: `{command}`")), "{guidance}");
let es = crate::i18n::Lang::from_code("es").unwrap();
assert!(shown[0].lines.iter().chain([&shown[0].title]).all(|line| crate::i18n::tr(es, line) != *line), "every line is a catalog template");
let mut empty = PhotocraftApp::new(Session::new(), services(false)); let mut empty = PhotocraftApp::new(Session::new(), services(false));
crate::menus::invoke(&mut empty, &ctx, "edit.paste", serde_json::json!({})).unwrap(); crate::menus::invoke(&mut empty, &ctx, "edit.paste", serde_json::json!({})).unwrap();
@@ -440,15 +433,16 @@ mod tests {
#[test] #[test]
fn import_warning_punctuation_is_localized_without_changing_english() { fn import_warning_punctuation_is_localized_without_changing_english() {
let mut app = PhotocraftApp::new(Session::new(), Services::default()); let mut app = PhotocraftApp::new(Session::new(), Services::default());
for (lang, expected) in [ for lang in [crate::i18n::Lang::EN, crate::i18n::Lang::from_code("cs").unwrap()] {
(crate::i18n::Lang::EN, "Opened x.psd with 3 warnings: first warning …"),
(crate::i18n::Lang::from_code("cs").unwrap(), "Otevřeno: x.psd (3 upozornění): first warning …"),
] {
crate::i18n::with_language(lang, || { crate::i18n::with_language(lang, || {
let what = crate::i18n::fmt(tl!("Opened {name}"), &[("name", "x.psd")]); let what = crate::i18n::fmt(tl!("Opened {name}"), &[("name", "x.psd")]);
let warnings = vec!["first warning".into(), "second warning".into(), "third warning".into()]; let warnings = vec!["first warning".into(), "second warning".into(), "third warning".into()];
io_warnings(&mut app, &what, &warnings); io_warnings(&mut app, &what, &warnings);
assert_eq!(app.ui.status, expected); if lang == crate::i18n::Lang::EN {
assert_eq!(app.ui.status, "Opened x.psd with 3 warnings: first warning …");
}
let title = crate::i18n::fmt(&crate::i18n::trn(lang, 3, "{what} with {n} warning", "{what} with {n} warnings"), &[("what", &what)]);
assert_eq!(app.ui.status, format!("{title}: first warning …"));
assert_eq!(app.ui.notices.last().unwrap().lines, warnings); assert_eq!(app.ui.notices.last().unwrap().lines, warnings);
io_warnings(&mut app, &what, &warnings[..1]); io_warnings(&mut app, &what, &warnings[..1]);
assert_eq!(app.ui.status, format!("{what}: first warning")); assert_eq!(app.ui.status, format!("{what}: first warning"));
-3
View File
@@ -103,9 +103,6 @@ mod tests {
#[test] #[test]
fn plugin_menu_path_stays_untranslated_in_other_languages() { fn plugin_menu_path_stays_untranslated_in_other_languages() {
let ja = crate::i18n::Lang::from_code("ja").expect("registered language"); let ja = crate::i18n::Lang::from_code("ja").expect("registered language");
// The names on the path must really differ from the English, or the test proves nothing.
assert_ne!(crate::i18n::tr(ja, "Filter"), "Filter");
assert_ne!(crate::i18n::tr(ja, "Plug-ins"), "Plug-ins");
let _lang = crate::i18n::language_scope(ja); let _lang = crate::i18n::language_scope(ja);
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../plugins/tests/fixtures/invert.wasm"); let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../plugins/tests/fixtures/invert.wasm");
-28
View File
@@ -3217,34 +3217,6 @@ mod tests {
} }
} }
/// Every generated preference label, section title and choice label has an entry in each
/// language that claims complete menus (Japanese, Traditional Chinese, ...).
#[test]
fn preference_labels_are_translated() {
let session = photocraft_engine::Session::new();
let v: Value = serde_json::from_str(&session.prefs_to_json()).expect("prefs json");
for lang in crate::i18n::Lang::all().filter(|l| l.complete_menus()) {
let mut missing = Vec::new();
for (sec, title) in SECTIONS {
if !crate::i18n::has(lang, title) {
missing.push(title.to_string());
}
let Some(obj) = v.get(sec).and_then(Value::as_object) else { continue };
for k in obj.keys() {
if sec == "interface" && k == "favoriteThemes" {
continue; // Rendered as star controls beside the theme choices.
}
let mut labels = vec![humanize(k)];
labels.extend(prefs::choices(&format!("{sec}.{k}")).into_iter().flatten().map(|c| choice_label(c)));
missing.extend(labels.into_iter().filter(|l| !crate::i18n::has(lang, l)));
}
}
missing.sort();
missing.dedup();
assert!(missing.is_empty(), "{}: untranslated preference labels: {missing:#?}", lang.code());
}
}
fn app_with_store() -> (PhotocraftApp, Arc<Mutex<Option<String>>>) { fn app_with_store() -> (PhotocraftApp, Arc<Mutex<Option<String>>>) {
app_with_saved(None) app_with_saved(None)
} }
+9 -35
View File
@@ -144,32 +144,8 @@ fn files_opened_together_ask_one_at_a_time_before_each_opens() {
#[test] #[test]
fn the_prompt_is_translated_and_still_names_its_file() { fn the_prompt_is_translated_and_still_names_its_file() {
let png = adobe_png(); let png = adobe_png();
for (code, sentence, use_embedded, convert) in [ let template = "The document “{name}” has an embedded color profile that does not match the current {mode} working space.";
( for code in ["en", "de", "fr", "ja"] {
"en",
"The document “shot {mode}.png” has an embedded color profile that does not match the current RGB working space.",
"Use the embedded profile (Adobe RGB",
"Convert to the working space (sRGB",
),
(
"de",
"Das Dokument „shot {mode}.png“ hat ein eingebettetes Farbprofil, das nicht zum aktuellen RGB-Arbeitsfarbraum passt.",
"Eingebettetes Profil verwenden (Adobe RGB",
"In den Arbeitsfarbraum konvertieren (sRGB",
),
(
"fr",
"Le document «NBSPshot {mode}.pngNBSP» a un profil colorimétrique incorporé qui ne correspond pas à l'espace de travail RVB actuel.",
"Utiliser le profil incorporé (Adobe RGB",
"Convertir vers l'espace de travail (sRGB",
),
(
"ja",
"ドキュメント「shot {mode}.png」の埋め込みカラープロファイルは、現在の RGB 作業用スペースと一致しません。",
"埋め込みプロファイルを使用 (Adobe RGB",
"作業用スペースに変換 (sRGB",
),
] {
let services = Services { let services = Services {
import: Some(Box::new(|name, bytes, opts| { import: Some(Box::new(|name, bytes, opts| {
photocraft_io::import_with_options(name, bytes, opts).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) photocraft_io::import_with_options(name, bytes, opts).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string())
@@ -183,16 +159,14 @@ fn the_prompt_is_translated_and_still_names_its_file() {
assert!(app.session.documents().is_empty(), "{code}: held until answered"); assert!(app.session.documents().is_empty(), "{code}: held until answered");
let fields = app.ui.dialogs[0].fields.clone(); let fields = app.ui.dialogs[0].fields.clone();
let message = fields.get("message").and_then(Value::as_str).unwrap().to_string(); let message = fields.get("message").and_then(Value::as_str).unwrap().to_string();
assert_eq!(message, sentence.replace("NBSP", "\u{a0}"), "{code}: one line, the profiles are on the choices"); let expected = crate::i18n::fmt(crate::i18n::tr(lang, template), &[("mode", crate::i18n::tr(lang, "RGB")), ("name", "shot {mode}.png")]);
assert_eq!(message, expected, "{code}: one line, the profiles are on the choices");
if code == "en" {
assert_eq!(message, "The document “shot {mode}.png” has an embedded color profile that does not match the current RGB working space.");
}
let name = |k: &str| fields.get(k).and_then(Value::as_str).unwrap().to_string(); let name = |k: &str| fields.get(k).and_then(Value::as_str).unwrap().to_string();
let (use_label, convert_label) = crate::i18n::with_language(lang, || { assert!(name("embedded").starts_with("Adobe RGB"), "{code}: {}", name("embedded"));
( assert!(name("working").starts_with("sRGB"), "{code}: {}", name("working"));
crate::i18n::fmt(tl!("Use the embedded profile ({profile})"), &[("profile", &name("embedded"))]),
crate::i18n::fmt(tl!("Convert to the working space ({profile})"), &[("profile", &name("working"))]),
)
});
assert!(use_label.starts_with(use_embedded), "{code}: {use_label}");
assert!(convert_label.starts_with(convert), "{code}: {convert_label}");
} }
} }
@@ -310,9 +310,11 @@ fn a_disabled_shortcut_says_why() {
#[test] #[test]
fn a_disabled_shortcut_uses_the_command_id_translation() { fn a_disabled_shortcut_uses_the_command_id_translation() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
crate::i18n::with_language(crate::i18n::Lang::from_code("cs").unwrap(), || { let cs = crate::i18n::Lang::from_code("cs").unwrap();
crate::i18n::with_language(cs, || {
super::dispatch(&mut app, &egui::Context::default(), "select.all"); super::dispatch(&mut app, &egui::Context::default(), "select.all");
assert!(app.ui.status.starts_with("Vybrat vše není k dispozici:"), "{}", app.ui.status); let shown = crate::i18n::tr_id(cs, "select.all", &super::label("select.all")).to_string();
assert!(app.ui.status.contains(&shown), "{}", app.ui.status);
assert!(app.ui.status_error); assert!(app.ui.status_error);
}); });
} }
-11
View File
@@ -139,17 +139,6 @@ fn command_params_do_not_leak_private_dialog_fields() {
assert_eq!(out["color"], "#ff0000"); assert_eq!(out["color"], "#ff0000");
} }
#[test]
fn stroke_dialog_labels_are_translated_in_every_catalog() {
const LABELS: &[&str] =
&["Stroke", "Width:", "Color:", "Location", "Location:", "Inside", "Center", "Outside", "Blending", "Mode:", "Opacity:", "Preserve Transparency"];
for lang in crate::i18n::Lang::all().filter(|lang| lang.complete_menus()) {
for label in LABELS {
assert!(crate::i18n::has(lang, label), "{} is missing Stroke dialog label: {label}", lang.code());
}
}
}
/// The Stroke dialog's colour swatch opens PhotoCraft's Color Picker on the stroke colour (as in /// The Stroke dialog's colour swatch opens PhotoCraft's Color Picker on the stroke colour (as in
/// Photoshop), not egui's compact popup; OK sets the stroke colour, not the foreground, and the /// Photoshop), not egui's compact popup; OK sets the stroke colour, not the foreground, and the
/// stroke is drawn in it. /// stroke is drawn in it.
-3
View File
@@ -184,9 +184,6 @@ fn overlays_follow_document_view_transforms_in_every_theme() {
} }
out.textures_delta.clear(); out.textures_delta.clear();
} }
let de = crate::i18n::Lang::from_code("de").unwrap();
assert_eq!(crate::i18n::tr(de, "Dual Axis"), "Duale Achse");
assert_eq!(crate::i18n::tr(de, "Transform Symmetry"), "Symmetrie transformieren");
} }
/// #4495: while the symmetry is transformed the options bar has ✓ and ⊘ (Enter and Esc were the /// #4495: while the symmetry is transformed the options bar has ✓ and ⊘ (Enter and Esc were the
/// only ways out), and the canvas shows the move, rotate or arrow cursor instead of the brush. /// only ways out), and the canvas shows the move, rotate or arrow cursor instead of the brush.
+1 -1
View File
@@ -65,7 +65,7 @@ fn numeric_type_fields_have_localized_accessible_names() {
); );
PhotocraftApp::setup_context(&h.ctx, Default::default()); PhotocraftApp::setup_context(&h.ctx, Default::default());
h.run_steps(4); h.run_steps(4);
h.get_by_role_and_label(egui::accesskit::Role::SpinButton, "Tamanho da fonte").click(); h.get_by_role_and_label(egui::accesskit::Role::SpinButton, crate::i18n::tr(pt, "Font size")).click();
h.run(); h.run();
h.key_press_modifiers(Modifiers::COMMAND, egui::Key::A); h.key_press_modifiers(Modifiers::COMMAND, egui::Key::A);
h.event(egui::Event::Text("36".into())); h.event(egui::Event::Text("36".into()));
@@ -80,7 +80,6 @@ fn histogram_matches_visible_preview_at_all_supported_depths_and_cancel_commit_a
#[test] #[test]
fn settings_sections_group_existing_controls_without_rendering_or_document_edits() { fn settings_sections_group_existing_controls_without_rendering_or_document_edits() {
assert_eq!(photocraft_ui_egui::i18n::tr_ctx(photocraft_ui_egui::i18n::Lang::from_code("ru").unwrap(), "cameraRaw", "Light"), "Свет");
for theme in ThemeKind::ALL { for theme in ThemeKind::ALL {
let mut h = Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 900.0)).build_eframe(move |cc| { let mut h = Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 900.0)).build_eframe(move |cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, theme); PhotocraftApp::setup_context(&cc.egui_ctx, theme);
+5 -1
View File
@@ -331,7 +331,11 @@ the live command registry (`menus::is_live`) and rewrites [`docs/parity-checklis
`crates/ui-egui/src/i18n/mod.rs` registry, in stable language-code order. Its English-key set is `crates/ui-egui/src/i18n/mod.rs` registry, in stable language-code order. Its English-key set is
derived from UI `tl!` literals, the menu catalog and UI command table, and menu command labels in derived from UI `tl!` literals, the menu catalog and UI command table, and menu command labels in
the engine source. Catalog rows are validated for malformed and duplicate keys; unused legacy the engine source. Catalog rows are validated for malformed and duplicate keys; unused legacy
translations do not affect the denominator. Unregistered locale TSV files are ignored. translations do not affect the denominator. Unregistered locale TSV files are ignored. After the
counts it lists, per language, the source keys the catalog lacks (one line for a language with
none). A missing translation shows in English and fails nothing: `cargo xtask ci` runs the report
as its last step so the list appears in the log, and only prints a warning if the report itself
can't run.
## Testing strategy ## Testing strategy
+2 -2
View File
@@ -26,8 +26,8 @@ Filter Gallery names, Camera Raw, Lighting Effects, History and Layers controls,
dialogs, status wrappers and System Info labels. Before this pass, those controls fell back to dialogs, status wrappers and System Info labels. Before this pass, those controls fell back to
English because many are assembled dynamically and cannot be found by the static coverage scan. English because many are assembled dynamically and cannot be found by the static coverage scan.
`spanish_covers_generated_filter_options_and_gallery_names` in `filter_dialog.rs` enumerates the A future generated label without a Spanish row shows in English. `cargo xtask i18n-coverage`
generated filter and adjustment surface and fails if a future label has no Spanish row. A few lists missing rows for the keys it scans, which don't include these generated labels. A few
unused legacy labels are retained so the complete catalogs stay aligned. unused legacy labels are retained so the complete catalogs stay aligned.
## Terminology ## Terminology
+4 -7
View File
@@ -52,9 +52,8 @@ command names joined by a slash, which keep both capitals (*Luminosità/Contrast
2026-10-13: `Screen` was *Schermo* — the display, not the blend mode — and is now *Scherma*, the 2026-10-13: `Screen` was *Schermo* — the display, not the blend mode — and is now *Scherma*, the
word Adobe uses. The Dodge/Burn/Sponge tools were left as they were (*Scherma*, *Brucia*, *Spugna*): word Adobe uses. The Dodge/Burn/Sponge tools were left as they were (*Scherma*, *Brucia*, *Spugna*):
a first pass also wanted to rename the Dodge tool to *Schiarisci*, but that is the `Lighten` blend a first pass also wanted to rename the Dodge tool to *Schiarisci*, but that is the `Lighten` blend
mode and would have given two different things the same name. The wording is pinned by a test mode and would have given two different things the same name. Keep the tool family's names
(`i18n::tests::italian_names_the_dodge_tools_and_the_screen_blend_mode_as_photoshop_does`), which together when editing these rows.
also keeps the tool family together.
2026-10-12: a native Italian speaker read the catalog and fixed mistranslations (Trap, HDR 2026-10-12: a native Italian speaker read the catalog and fixed mistranslations (Trap, HDR
Toning, Display Text, Vine Color, Override Action "Save" Commands) and unnatural phrasing (Bevel & Toning, Display Text, Vine Color, Override Action "Save" Commands) and unnatural phrasing (Bevel &
@@ -69,10 +68,8 @@ current code; they are translated to stay aligned with the other catalogs.
## Validation ## Validation
- `italian_covers_generated_filter_options_and_gallery_names` (`crates/ui-egui/src/filter_dialog.rs`) - Every filter and adjustment parameter, symbolic choice and gallery name has an Italian row. The
enumerates every filter and adjustment parameter, symbolic choice and gallery name, like the catalog on `main` before this change lacked 388 of them.
Korean test, and requires an Italian row for each. With the catalog on `main` before this change
it lists 388 missing labels; now none.
- `cargo test -p photocraft-ui-egui --lib`: the catalog tests pass. Two layout tests, - `cargo test -p photocraft-ui-egui --lib`: the catalog tests pass. Two layout tests,
`type_controls_fill_the_panel_at_every_width_and_scale` and `type_controls_fill_the_panel_at_every_width_and_scale` and
`properties_edit_shown_metrics_and_paragraphs_with_a_non_type_primary`, fail on `main` as well `properties_edit_shown_metrics_and_paragraphs_with_a_non_type_primary`, fail on `main` as well
-3
View File
@@ -23,14 +23,11 @@ resources, fonts, screenshots or user documents are included.
Command-ID translations disambiguate menu names from actions. Photoshop terms Command-ID translations disambiguate menu names from actions. Photoshop terms
are used when the operation matches; PhotoCraft-specific features retain their are used when the operation matches; PhotoCraft-specific features retain their
own meaning. Technology names, units and user-provided names are not translated. own meaning. Technology names, units and user-provided names are not translated.
The terminology test protects representative tool names and this distinction.
## Dynamic coverage ## Dynamic coverage
Generated filter parameter names, symbolic choices, gallery categories/effects, Generated filter parameter names, symbolic choices, gallery categories/effects,
job titles, layer-style menus and shortcut labels now go through the catalog. job titles, layer-style menus and shortcut labels now go through the catalog.
The filter test enumerates engine filter/adjustment parameter specs and gallery
names to catch new Korean omissions that literal-only scans cannot find.
Two newly introduced literals, `Enable {name}` and `No patterns`, are present in Two newly introduced literals, `Enable {name}` and `No patterns`, are present in
all nine non-English catalogs. Existing fallback behavior is preserved for all nine non-English catalogs. Existing fallback behavior is preserved for
untranslated dynamic labels in other languages. untranslated dynamic labels in other languages.
+3 -1
View File
@@ -13,7 +13,9 @@ labels and path segments, `menus.rs` UI commands, and engine `CommandSpec` label
checked against that universe by a source script replicating the xtask (no build). Catalogs hold checked against that universe by a source script replicating the xtask (no build). Catalogs hold
more rows than the universe because they also translate dynamic `tl!(expr)` strings (~159 call more rows than the universe because they also translate dynamic `tl!(expr)` strings (~159 call
sites the tool can't enumerate). **Not covered anywhere:** engine error and status messages and sites the tool can't enumerate). **Not covered anywhere:** engine error and status messages and
filter parameter help, which are English in every language; so no language is `full`. filter parameter help, which are English in every language; so no language is `full`. The report
also lists, per language, the keys its catalog lacks; those show in English. `cargo xtask ci` prints
it as its last step and never fails on it.
**Photoshop 2026** ships UI language packs for cs, da, de, en, es, fi, fr (and fr-CA), hu, it, **Photoshop 2026** ships UI language packs for cs, da, de, en, es, fi, fr (and fr-CA), hu, it,
ja, ko, nb, nl, pl, pt-BR, ru, sk, sv, tr, uk, zh-Hans, zh-Hant (24 `.lproj` markers; one pack is ja, ko, nb, nl, pl, pt-BR, ru, sk, sv, tr, uk, zh-Hans, zh-Hant (24 `.lproj` markers; one pack is
+2 -6
View File
@@ -63,12 +63,8 @@ translated to stay aligned with the other catalogs.
## Validation ## Validation
- `russian_covers_generated_filter_options_and_gallery_names` (`crates/ui-egui/src/filter_dialog.rs`) - Every filter and adjustment parameter, symbolic choice and gallery name has a Russian row, as do
enumerates every filter and adjustment parameter, symbolic choice and gallery name and requires the rasterize prompts, hardware status lines and composed status templates.
a Russian row for each, like the Korean and Italian tests.
- `russian_dynamic_status_hardware_and_dialog_labels_are_translated` (`crates/ui-egui/src/i18n/mod.rs`)
covers the rasterize prompts, hardware status lines, composed status templates and a sample of
filter labels.
- `cargo test -p photocraft-ui-egui --lib`: 2,998 passed, 0 failed. - `cargo test -p photocraft-ui-egui --lib`: 2,998 passed, 0 failed.
- `cargo xtask i18n-coverage`: `ru` remains at 100% of the measured keys. - `cargo xtask i18n-coverage`: `ru` remains at 100% of the measured keys.
- The offscreen `snapshot` example with `PHOTOCRAFT_LOCALE=ru` renders the dialogs with Russian - The offscreen `snapshot` example with `PHOTOCRAFT_LOCALE=ru` renders the dialogs with Russian
+7 -9
View File
@@ -28,8 +28,8 @@ language manually there.
- `crates/ui-egui/src/i18n/zh-hans.tsv` contains the translations, independent of - `crates/ui-egui/src/i18n/zh-hans.tsv` contains the translations, independent of
the lookup implementation. Its UTF-8 columns are `context<TAB>source<TAB>translation`. the lookup implementation. Its UTF-8 columns are `context<TAB>source<TAB>translation`.
- `crates/ui-egui/src/i18n/mod.rs` registers `zh-hans` with one plural form and - `crates/ui-egui/src/i18n/mod.rs` registers `zh-hans` with one plural form and
`complete_menus: true`. Shared tests enforce coverage of every menu string, `complete_menus: true`. `cargo xtask i18n-coverage` lists any menu string,
`tl!` literal, blend mode, brush section and generated preference label. `tl!` literal or other source key the catalog lacks; those show in English.
- Shortcut templates retain `{key}` so the shell supplies the correct key for - Shortcut templates retain `{key}` so the shell supplies the correct key for
the current platform. Layer-count messages use one Chinese plural form. the current platform. Layer-count messages use one Chinese plural form.
@@ -62,9 +62,8 @@ complete catalog coverage does not claim those strings are translated.
Run `cargo test -p photocraft-ui-egui`, the touched-crate all-target clippy check, Run `cargo test -p photocraft-ui-egui`, the touched-crate all-target clippy check,
`cargo xtask layers` and `cargo xtask wasm`. The shared catalog tests validate `cargo xtask layers` and `cargo xtask wasm`. The shared catalog tests validate
duplicate keys, placeholders, ellipses, menu coverage, `tl!` literals, brush duplicate keys, placeholders, ellipses and plural forms. Chinese-specific tests
sections and blend modes. Chinese-specific tests cover locale selection, fallback, cover locale selection, fallback and the painted brush section rows. Render and inspect the menus, Preferences and representative
plural messages, formatted labels and the painted brush section rows and heading. Render and inspect the menus, Preferences and representative
dialogs with the existing offscreen `snapshot` example. dialogs with the existing offscreen `snapshot` example.
Font delivery is separate from the translation data. The catalog adds no font Font delivery is separate from the translation data. The catalog adds no font
@@ -73,7 +72,6 @@ label layout and platform-specific shortcuts visually on each target platform,
and verify web glyph coverage separately. and verify web glyph coverage separately.
When English labels change, update their source keys and translations together, When English labels change, update their source keys and translations together,
preserving contexts and placeholders. The complete-catalog tests catch missing preserving contexts and placeholders. `cargo xtask i18n-coverage` (also the last
keys, and the Simplified Chinese regression test checks dynamic shortcut labels, step of `cargo xtask ci`) lists the keys still missing. Review changed source
layer counts and English fallback. Review changed source meanings against the meanings against the current English UI.
current English UI.
+4 -2
View File
@@ -31,7 +31,9 @@ contextual, command-ID and plural translations; missing entries fall back to Eng
| हिन्दी | `hi` | one / other | | हिन्दी | `hi` | one / other |
Languages registered with `complete_menus` cover the current menu labels, `tl!` literals, blend Languages registered with `complete_menus` cover the current menu labels, `tl!` literals, blend
mode names, and generated preference labels; tests enforce that coverage. Vietnamese (`vi`) is mode names, and generated preference labels. A string a catalog lacks shows in English:
`cargo xtask i18n-coverage` lists the missing strings per language, and `cargo xtask ci` prints
that list as its last step without failing. Vietnamese (`vi`) is
selectable, resolves regional tags such as `vi-VN`, and covers all current stable localization selectable, resolves regional tags such as `vi-VN`, and covers all current stable localization
keys. `complete_menus` remains false while generated/dynamic long-tail labels are still under keys. `complete_menus` remains false while generated/dynamic long-tail labels are still under
review. This does not include every engine error or status message: those remain English, as do review. This does not include every engine error or status message: those remain English, as do
@@ -134,7 +136,7 @@ detection remain separate outstanding work. Switching catalogs does not supply m
3. Translate the meaning of the English source keys. Preserve `{name}` placeholders, escaped 3. Translate the meaning of the English source keys. Preserve `{name}` placeholders, escaped
characters and the trailing `…` on dialog commands. Use `@id` to disambiguate a command characters and the trailing `…` on dialog commands. Use `@id` to disambiguate a command
label and `@plural` for count-sensitive text. Keep product names such as PhotoCraft. label and `@plural` for count-sensitive text. Keep product names such as PhotoCraft.
4. Set `complete_menus` when the catalog covers all enforced keys. Add source and license 4. Set `complete_menus` once `cargo xtask i18n-coverage` lists nothing missing for the language. Add source and license
attribution for translation assets, then run the catalog and shell tests. attribution for translation assets, then run the catalog and shell tests.
```sh ```sh
+6 -5
View File
@@ -198,7 +198,7 @@ documented in the header of `ja.tsv`). Command ids, menu paths used for logic, t
the CLI and MCP always use the English ids and labels. the CLI and MCP always use the English ids and labels.
Languages shipped (`complete_menus` marks a catalog that covers every menu string and `tl!` literal; Languages shipped (`complete_menus` marks a catalog that covers every menu string and `tl!` literal;
the tests enforce it): `cargo xtask i18n-coverage` lists what each catalog lacks):
- English (`en`), the source language. - English (`en`), the source language.
- Japanese (`ja`), complete. - Japanese (`ja`), complete.
@@ -225,15 +225,16 @@ the tests enforce it):
- To add a language: add `<code>.tsv` and one row in `i18n::LANGUAGES` (code, native name, catalog, - To add a language: add `<code>.tsv` and one row in `i18n::LANGUAGES` (code, native name, catalog,
plural rule). The dropdown, locale matching and the catalog tests (well-formed, no duplicates, plural rule). The dropdown, locale matching and the catalog tests (well-formed, no duplicates,
placeholders and ellipses agree, command ids exist) pick it up. Set `complete_menus` once every placeholders and ellipses agree, command ids exist) pick it up. Set `complete_menus` once every
menu string is translated; a test then enforces it. A language with more plural forms lists them menu string is translated. A language with more plural forms lists them
all in `@plural` entries (Russian: `one|few|many`); the row's plural rule picks the form. all in `@plural` entries (Russian: `one|few|many`); the row's plural rule picks the form.
- Translations are clean-room: written from the meaning of the English text in ordinary vocabulary, - Translations are clean-room: written from the meaning of the English text in ordinary vocabulary,
never from another product's localisation resources. never from another product's localisation resources.
Localised so far: menus, the command palette, dialogs and panels (literals wrapped in `tl!("…")`; Localised so far: menus, the command palette, dialogs and panels (literals wrapped in `tl!("…")`;
widgets such as `checkbox`, `slider_row`, `dropdown` and the buttons translate their labels widgets such as `checkbox`, `slider_row`, `dropdown` and the buttons translate their labels
themselves). A test fails when a `tl!` literal, a menu string, a blend mode name, brush section name or a generated themselves). A string with no entry in a catalog shows in English; `cargo xtask i18n-coverage`
preference label has no entry in a language marked `complete_menus`. Not translated: status-bar lists the missing strings per language, and `cargo xtask ci` prints that list without failing.
Not translated: status-bar
messages and errors (they stay English, also for agents), names that are user data (layers, styles, messages and errors (they stay English, also for agents), names that are user data (layers, styles,
documents), strings assembled with `format!` that were not converted to `fmt`/`trn`. Not done yet: documents), strings assembled with `format!` that were not converted to `fmt`/`trn`. Not done yet:
right-to-left layout inside panels and dialogs (the shell mirrors, `direction.rs`), right-to-left layout inside panels and dialogs (the shell mirrors, `direction.rs`),
@@ -245,7 +246,7 @@ Simplified Chinese. Its contextual `cameraRaw` entries distinguish tonal regions
colour-band names and font-weight Light. The title uses a `{layer}` placeholder; user layer colour-band names and font-weight Light. The title uses a `{layer}` placeholder; user layer
names and the universal RGB/Lab channel symbols stay unchanged. Collapse IDs use English names and the universal RGB/Lab channel symbols stay unchanged. Collapse IDs use English
source keys, so switching languages preserves open sections. Mixer tabs wrap within the source keys, so switching languages preserves open sections. Mixer tabs wrap within the
panel width. Catalog coverage and actual shell language-switch tests enforce these rules. panel width. Shell language-switch tests check that the panel draws in the selected language.
Native CJK font fallback follows the selected UI script and resets its cache when switching Native CJK font fallback follows the selected UI script and resets its cache when switching
languages; font delivery on the web remains separate work. languages; font delivery on the web remains separate work.